feat: recurring tasks - auto-spawn next instance on completion

- Added recurrence field (daily/weekly/biweekly/monthly) to tasks schema
- Backend: auto-creates next task instance when recurring task completed
  - Copies title, description, assignee, project, tags, subtasks (unchecked)
  - Computes next due date based on frequency
  - Optional autoActivate to immediately activate next instance
- Frontend: recurrence picker in CreateTaskModal and TaskDetailPanel
- Recurrence badges (🔄) on TaskCard, KanbanBoard, TaskPage, DashboardPage
- Schema uses JSONB column (no migration needed, db:push on deploy)
This commit is contained in:
2026-01-29 12:05:13 +00:00
parent dd401290c1
commit 96441b818e
10 changed files with 246 additions and 7 deletions

View File

@@ -46,6 +46,14 @@ export interface Subtask {
createdAt: string; createdAt: string;
} }
export type RecurrenceFrequency = "daily" | "weekly" | "biweekly" | "monthly";
export interface Recurrence {
frequency: RecurrenceFrequency;
/** Auto-activate the next instance (vs. queue it) */
autoActivate?: boolean;
}
// ─── Projects ─── // ─── Projects ───
export interface ProjectLink { export interface ProjectLink {
@@ -84,6 +92,7 @@ export const tasks = pgTable("tasks", {
dueDate: timestamp("due_date", { withTimezone: true }), dueDate: timestamp("due_date", { withTimezone: true }),
estimatedHours: integer("estimated_hours"), estimatedHours: integer("estimated_hours"),
tags: jsonb("tags").$type<string[]>().default([]), tags: jsonb("tags").$type<string[]>().default([]),
recurrence: jsonb("recurrence").$type<Recurrence>(),
subtasks: jsonb("subtasks").$type<Subtask[]>().default([]), subtasks: jsonb("subtasks").$type<Subtask[]>().default([]),
progressNotes: jsonb("progress_notes").$type<ProgressNote[]>().default([]), progressNotes: jsonb("progress_notes").$type<ProgressNote[]>().default([]),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),

View File

@@ -1,6 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import { db } from "../db"; import { db } from "../db";
import { tasks, type ProgressNote, type Subtask } from "../db/schema"; import { tasks, type ProgressNote, type Subtask, type Recurrence, type RecurrenceFrequency } from "../db/schema";
import { eq, asc, desc, sql, inArray, or } from "drizzle-orm"; import { eq, asc, desc, sql, inArray, or } from "drizzle-orm";
import { auth } from "../lib/auth"; import { auth } from "../lib/auth";
@@ -41,6 +41,80 @@ async function notifyTaskActivated(task: { id: string; title: string; descriptio
} }
} }
// Compute the next due date for a recurring task
function computeNextDueDate(frequency: RecurrenceFrequency, fromDate?: Date | null): Date {
const base = fromDate && fromDate > new Date() ? new Date(fromDate) : new Date();
switch (frequency) {
case "daily":
base.setDate(base.getDate() + 1);
break;
case "weekly":
base.setDate(base.getDate() + 7);
break;
case "biweekly":
base.setDate(base.getDate() + 14);
break;
case "monthly":
base.setMonth(base.getMonth() + 1);
break;
}
return base;
}
// Create the next instance of a recurring task
async function spawnNextRecurrence(completedTask: typeof tasks.$inferSelect) {
const recurrence = completedTask.recurrence as Recurrence | null;
if (!recurrence) return;
// Get next task number
const maxNum = await db
.select({ max: sql<number>`COALESCE(MAX(${tasks.taskNumber}), 0)` })
.from(tasks);
const nextNumber = (maxNum[0]?.max ?? 0) + 1;
const maxPos = await db
.select({ max: sql<number>`COALESCE(MAX(${tasks.position}), 0)` })
.from(tasks);
const nextDue = computeNextDueDate(recurrence.frequency, completedTask.dueDate);
const nextStatus = recurrence.autoActivate ? "active" : "queued";
const newTask = await db
.insert(tasks)
.values({
title: completedTask.title,
description: completedTask.description,
source: completedTask.source,
status: nextStatus,
priority: completedTask.priority,
position: (maxPos[0]?.max ?? 0) + 1,
taskNumber: nextNumber,
assigneeId: completedTask.assigneeId,
assigneeName: completedTask.assigneeName,
projectId: completedTask.projectId,
dueDate: nextDue,
estimatedHours: completedTask.estimatedHours,
tags: completedTask.tags,
recurrence: recurrence,
subtasks: (completedTask.subtasks as Subtask[] || []).map(s => ({
...s,
completed: false,
completedAt: undefined,
})),
progressNotes: [],
})
.returning();
console.log(`[recurrence] Spawned next instance: ${newTask[0].id} (HQ-${nextNumber}) due ${nextDue.toISOString()} from completed task ${completedTask.id}`);
// If auto-activate, fire the webhook
if (nextStatus === "active") {
notifyTaskActivated(newTask[0]);
}
return newTask[0];
}
// Status sort order: active first, then queued, blocked, completed, cancelled // Status sort order: active first, then queued, blocked, completed, cancelled
const statusOrder = sql`CASE const statusOrder = sql`CASE
WHEN ${tasks.status} = 'active' THEN 0 WHEN ${tasks.status} = 'active' THEN 0
@@ -156,6 +230,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
dueDate: body.dueDate ? new Date(body.dueDate) : null, dueDate: body.dueDate ? new Date(body.dueDate) : null,
estimatedHours: body.estimatedHours ?? null, estimatedHours: body.estimatedHours ?? null,
tags: body.tags || [], tags: body.tags || [],
recurrence: body.recurrence || null,
subtasks: [], subtasks: [],
progressNotes: [], progressNotes: [],
}) })
@@ -197,6 +272,13 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
dueDate: t.Optional(t.Union([t.String(), t.Null()])), dueDate: t.Optional(t.Union([t.String(), t.Null()])),
estimatedHours: t.Optional(t.Union([t.Number(), t.Null()])), estimatedHours: t.Optional(t.Union([t.Number(), t.Null()])),
tags: t.Optional(t.Array(t.String())), tags: t.Optional(t.Array(t.String())),
recurrence: t.Optional(t.Union([
t.Object({
frequency: t.Union([t.Literal("daily"), t.Literal("weekly"), t.Literal("biweekly"), t.Literal("monthly")]),
autoActivate: t.Optional(t.Boolean()),
}),
t.Null(),
])),
}), }),
} }
) )
@@ -304,6 +386,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
if (body.dueDate !== undefined) updates.dueDate = body.dueDate ? new Date(body.dueDate) : null; if (body.dueDate !== undefined) updates.dueDate = body.dueDate ? new Date(body.dueDate) : null;
if (body.estimatedHours !== undefined) updates.estimatedHours = body.estimatedHours; if (body.estimatedHours !== undefined) updates.estimatedHours = body.estimatedHours;
if (body.tags !== undefined) updates.tags = body.tags; if (body.tags !== undefined) updates.tags = body.tags;
if (body.recurrence !== undefined) updates.recurrence = body.recurrence;
if (body.subtasks !== undefined) updates.subtasks = body.subtasks; if (body.subtasks !== undefined) updates.subtasks = body.subtasks;
if (body.progressNotes !== undefined) updates.progressNotes = body.progressNotes; if (body.progressNotes !== undefined) updates.progressNotes = body.progressNotes;
@@ -319,6 +402,13 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
notifyTaskActivated(updated[0]); notifyTaskActivated(updated[0]);
} }
// Spawn next recurring instance if task was just completed
if (body.status === "completed" && updated[0].recurrence) {
spawnNextRecurrence(updated[0]).catch((err) =>
console.error("Failed to spawn recurring task:", err)
);
}
return updated[0]; return updated[0];
}, },
{ {
@@ -336,6 +426,13 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
dueDate: t.Optional(t.Union([t.String(), t.Null()])), dueDate: t.Optional(t.Union([t.String(), t.Null()])),
estimatedHours: t.Optional(t.Union([t.Number(), t.Null()])), estimatedHours: t.Optional(t.Union([t.Number(), t.Null()])),
tags: t.Optional(t.Array(t.String())), tags: t.Optional(t.Array(t.String())),
recurrence: t.Optional(t.Union([
t.Object({
frequency: t.Union([t.Literal("daily"), t.Literal("weekly"), t.Literal("biweekly"), t.Literal("monthly")]),
autoActivate: t.Optional(t.Boolean()),
}),
t.Null(),
])),
subtasks: t.Optional(t.Array(t.Object({ subtasks: t.Optional(t.Array(t.Object({
id: t.String(), id: t.String(),
title: t.String(), title: t.String(),

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { fetchProjects } from "../lib/api"; import { fetchProjects } from "../lib/api";
import type { Project } from "../lib/types"; import type { Project, RecurrenceFrequency, Recurrence } from "../lib/types";
interface CreateTaskModalProps { interface CreateTaskModalProps {
open: boolean; open: boolean;
@@ -13,6 +13,7 @@ interface CreateTaskModalProps {
projectId?: string; projectId?: string;
dueDate?: string; dueDate?: string;
estimatedHours?: number; estimatedHours?: number;
recurrence?: Recurrence | null;
}) => void; }) => void;
} }
@@ -25,6 +26,8 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp
const [dueDate, setDueDate] = useState(""); const [dueDate, setDueDate] = useState("");
const [estimatedHours, setEstimatedHours] = useState(""); const [estimatedHours, setEstimatedHours] = useState("");
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [recurrenceFreq, setRecurrenceFreq] = useState<RecurrenceFrequency | "">("");
const [recurrenceAutoActivate, setRecurrenceAutoActivate] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
useEffect(() => { useEffect(() => {
@@ -56,6 +59,7 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp
projectId: projectId || undefined, projectId: projectId || undefined,
dueDate: dueDate ? new Date(dueDate).toISOString() : undefined, dueDate: dueDate ? new Date(dueDate).toISOString() : undefined,
estimatedHours: estimatedHours ? Number(estimatedHours) : undefined, estimatedHours: estimatedHours ? Number(estimatedHours) : undefined,
recurrence: recurrenceFreq ? { frequency: recurrenceFreq, autoActivate: recurrenceAutoActivate } : undefined,
}); });
// Reset form // Reset form
setTitle(""); setTitle("");
@@ -65,6 +69,8 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp
setProjectId(""); setProjectId("");
setDueDate(""); setDueDate("");
setEstimatedHours(""); setEstimatedHours("");
setRecurrenceFreq("");
setRecurrenceAutoActivate(false);
setShowAdvanced(false); setShowAdvanced(false);
onClose(); onClose();
}; };
@@ -200,6 +206,38 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp
</div> </div>
</div> </div>
{/* Recurrence */}
<div>
<label className="text-xs font-medium text-gray-500 block mb-1">Recurrence</label>
<div className="flex flex-wrap gap-1.5">
{(["", "daily", "weekly", "biweekly", "monthly"] as const).map((freq) => (
<button
key={freq || "none"}
type="button"
onClick={() => setRecurrenceFreq(freq as RecurrenceFrequency | "")}
className={`text-xs px-2.5 py-1.5 rounded-lg font-medium transition border ${
recurrenceFreq === freq
? "bg-teal-500 text-white border-teal-500"
: "bg-white text-gray-500 border-gray-200 hover:border-gray-300 hover:bg-gray-50"
}`}
>
{freq === "" ? "None" : freq === "biweekly" ? "Bi-weekly" : freq.charAt(0).toUpperCase() + freq.slice(1)}
</button>
))}
</div>
{recurrenceFreq && (
<label className="flex items-center gap-2 mt-2 text-xs text-gray-600 cursor-pointer">
<input
type="checkbox"
checked={recurrenceAutoActivate}
onChange={(e) => setRecurrenceAutoActivate(e.target.checked)}
className="rounded border-gray-300 text-teal-500 focus:ring-teal-400"
/>
Auto-activate next instance
</label>
)}
</div>
{/* Source */} {/* Source */}
<div> <div>
<label className="text-xs font-medium text-gray-500 block mb-1">Source</label> <label className="text-xs font-medium text-gray-500 block mb-1">Source</label>

View File

@@ -118,6 +118,11 @@ function KanbanCard({ task, projectName, onDragStart, onDragEnd, isDragging, onC
🏷 {tag} 🏷 {tag}
</span> </span>
))} ))}
{task.recurrence && (
<span className="text-[10px] text-teal-600 dark:text-teal-400 bg-teal-50 dark:bg-teal-900/30 px-1.5 py-0.5 rounded-full">
🔄 {task.recurrence.frequency}
</span>
)}
{task.estimatedHours != null && task.estimatedHours > 0 && ( {task.estimatedHours != null && task.estimatedHours > 0 && (
<span className="text-[10px] text-gray-500 dark:text-gray-400"> <span className="text-[10px] text-gray-500 dark:text-gray-400">
{task.estimatedHours}h {task.estimatedHours}h

View File

@@ -133,6 +133,11 @@ export function TaskCard({
<span className="text-[10px] sm:text-xs text-gray-400 dark:text-gray-500"> <span className="text-[10px] sm:text-xs text-gray-400 dark:text-gray-500">
{timeAgo(task.createdAt)} {timeAgo(task.createdAt)}
</span> </span>
{task.recurrence && (
<span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full font-medium bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-400">
🔄 {task.recurrence.frequency}
</span>
)}
{task.estimatedHours != null && task.estimatedHours > 0 && ( {task.estimatedHours != null && task.estimatedHours > 0 && (
<span className="text-[10px] sm:text-xs text-gray-400 dark:text-gray-500 flex items-center gap-0.5"> <span className="text-[10px] sm:text-xs text-gray-400 dark:text-gray-500 flex items-center gap-0.5">
{task.estimatedHours}h {task.estimatedHours}h

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import type { Task, TaskStatus, TaskPriority, TaskSource, Project } from "../lib/types"; import type { Task, TaskStatus, TaskPriority, TaskSource, Project, Recurrence } from "../lib/types";
import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api"; import { updateTask, fetchProjects, addProgressNote, addSubtask, toggleSubtask, deleteSubtask, deleteTask } from "../lib/api";
import { useToast } from "./Toast"; import { useToast } from "./Toast";
@@ -261,6 +261,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
const [draftEstimatedHours, setDraftEstimatedHours] = useState<string>(task.estimatedHours != null ? String(task.estimatedHours) : ""); const [draftEstimatedHours, setDraftEstimatedHours] = useState<string>(task.estimatedHours != null ? String(task.estimatedHours) : "");
const [draftTags, setDraftTags] = useState<string[]>(task.tags || []); const [draftTags, setDraftTags] = useState<string[]>(task.tags || []);
const [tagInput, setTagInput] = useState(""); const [tagInput, setTagInput] = useState("");
const [draftRecurrence, setDraftRecurrence] = useState<Recurrence | null>(task.recurrence || null);
const [projects, setProjects] = useState<Project[]>([]); const [projects, setProjects] = useState<Project[]>([]);
const [newSubtaskTitle, setNewSubtaskTitle] = useState(""); const [newSubtaskTitle, setNewSubtaskTitle] = useState("");
const [addingSubtask, setAddingSubtask] = useState(false); const [addingSubtask, setAddingSubtask] = useState(false);
@@ -287,7 +288,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
setDraftAssigneeName(task.assigneeName || ""); setDraftAssigneeName(task.assigneeName || "");
setDraftEstimatedHours(task.estimatedHours != null ? String(task.estimatedHours) : ""); setDraftEstimatedHours(task.estimatedHours != null ? String(task.estimatedHours) : "");
setDraftTags(task.tags || []); setDraftTags(task.tags || []);
}, [task.id, task.title, task.description, task.priority, task.source, task.projectId, task.dueDate, task.assigneeName, task.estimatedHours, task.tags]); setDraftRecurrence(task.recurrence || null);
}, [task.id, task.title, task.description, task.priority, task.source, task.projectId, task.dueDate, task.assigneeName, task.estimatedHours, task.tags, task.recurrence]);
const currentDueDate = task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : ""; const currentDueDate = task.dueDate ? new Date(task.dueDate).toISOString().slice(0, 16) : "";
const isDirty = const isDirty =
@@ -299,7 +301,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
draftDueDate !== currentDueDate || draftDueDate !== currentDueDate ||
draftAssigneeName !== (task.assigneeName || "") || draftAssigneeName !== (task.assigneeName || "") ||
draftEstimatedHours !== (task.estimatedHours != null ? String(task.estimatedHours) : "") || draftEstimatedHours !== (task.estimatedHours != null ? String(task.estimatedHours) : "") ||
JSON.stringify(draftTags) !== JSON.stringify(task.tags || []); JSON.stringify(draftTags) !== JSON.stringify(task.tags || []) ||
JSON.stringify(draftRecurrence) !== JSON.stringify(task.recurrence || null);
const handleCancel = () => { const handleCancel = () => {
setDraftTitle(task.title); setDraftTitle(task.title);
@@ -311,6 +314,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
setDraftAssigneeName(task.assigneeName || ""); setDraftAssigneeName(task.assigneeName || "");
setDraftEstimatedHours(task.estimatedHours != null ? String(task.estimatedHours) : ""); setDraftEstimatedHours(task.estimatedHours != null ? String(task.estimatedHours) : "");
setDraftTags(task.tags || []); setDraftTags(task.tags || []);
setDraftRecurrence(task.recurrence || null);
}; };
const handleSave = async () => { const handleSave = async () => {
@@ -329,6 +333,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
(updates as any).estimatedHours = draftEstimatedHours ? Number(draftEstimatedHours) : null; (updates as any).estimatedHours = draftEstimatedHours ? Number(draftEstimatedHours) : null;
} }
if (JSON.stringify(draftTags) !== JSON.stringify(task.tags || [])) (updates as any).tags = draftTags; if (JSON.stringify(draftTags) !== JSON.stringify(task.tags || [])) (updates as any).tags = draftTags;
if (JSON.stringify(draftRecurrence) !== JSON.stringify(task.recurrence || null)) (updates as any).recurrence = draftRecurrence;
await updateTask(task.id, updates, token); await updateTask(task.id, updates, token);
onTaskUpdated(); onTaskUpdated();
toast("Changes saved", "success"); toast("Changes saved", "success");
@@ -672,6 +677,62 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
</div> </div>
</div> </div>
{/* Recurrence */}
<div className="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Recurrence</h3>
{hasToken ? (
<div>
<div className="flex flex-wrap gap-1.5">
{([null, "daily", "weekly", "biweekly", "monthly"] as const).map((freq) => (
<button
key={freq || "none"}
onClick={() => {
if (freq === null) {
setDraftRecurrence(null);
} else {
setDraftRecurrence({ frequency: freq, autoActivate: draftRecurrence?.autoActivate });
}
}}
className={`text-xs px-2.5 py-1.5 rounded-lg font-medium transition border ${
(freq === null && !draftRecurrence) || (draftRecurrence && freq === draftRecurrence.frequency)
? "bg-teal-500 text-white border-teal-500"
: "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
{freq === null ? "None" : freq === "biweekly" ? "Bi-weekly" : freq.charAt(0).toUpperCase() + freq.slice(1)}
</button>
))}
</div>
{draftRecurrence && (
<label className="flex items-center gap-2 mt-2.5 text-xs text-gray-600 dark:text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={draftRecurrence.autoActivate || false}
onChange={(e) => setDraftRecurrence({ ...draftRecurrence, autoActivate: e.target.checked })}
className="rounded border-gray-300 dark:border-gray-600 text-teal-500 focus:ring-teal-400"
/>
Auto-activate next instance when completed
</label>
)}
</div>
) : (
<span className="text-sm text-gray-700 dark:text-gray-300">
{task.recurrence ? (
<span className="inline-flex items-center gap-1.5">
<span className="text-xs px-2 py-1 bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-400 rounded-full font-medium">
🔄 {task.recurrence.frequency === "biweekly" ? "Bi-weekly" : task.recurrence.frequency.charAt(0).toUpperCase() + task.recurrence.frequency.slice(1)}
</span>
{task.recurrence.autoActivate && (
<span className="text-xs text-gray-400 dark:text-gray-500">· auto-activate</span>
)}
</span>
) : (
<span className="text-gray-400 dark:text-gray-500 italic">One-time task</span>
)}
</span>
)}
</div>
{/* Description */} {/* Description */}
<div className="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-gray-800"> <div className="px-4 sm:px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3> <h3 className="text-xs font-semibold text-gray-400 dark:text-gray-500 uppercase tracking-wider mb-2">Description</h3>

View File

@@ -1,4 +1,4 @@
import type { Task, Project, ProjectWithTasks, VelocityStats } from "./types"; import type { Task, Project, ProjectWithTasks, VelocityStats, Recurrence } from "./types";
const BASE = "/api/tasks"; const BASE = "/api/tasks";
@@ -38,7 +38,7 @@ export async function reorderTasks(ids: string[], token?: string): Promise<void>
} }
export async function createTask( export async function createTask(
task: { title: string; description?: string; source?: string; priority?: string; status?: string; projectId?: string; dueDate?: string; estimatedHours?: number }, task: { title: string; description?: string; source?: string; priority?: string; status?: string; projectId?: string; dueDate?: string; estimatedHours?: number; recurrence?: Recurrence | null },
token?: string token?: string
): Promise<Task> { ): Promise<Task> {
const headers: Record<string, string> = { "Content-Type": "application/json" }; const headers: Record<string, string> = { "Content-Type": "application/json" };

View File

@@ -44,6 +44,13 @@ export interface ProjectWithTasks extends Project {
tasks: Task[]; tasks: Task[];
} }
export type RecurrenceFrequency = "daily" | "weekly" | "biweekly" | "monthly";
export interface Recurrence {
frequency: RecurrenceFrequency;
autoActivate?: boolean;
}
export interface Task { export interface Task {
id: string; id: string;
taskNumber: number; taskNumber: number;
@@ -59,6 +66,7 @@ export interface Task {
dueDate: string | null; dueDate: string | null;
estimatedHours: number | null; estimatedHours: number | null;
tags: string[]; tags: string[];
recurrence: Recurrence | null;
subtasks: Subtask[]; subtasks: Subtask[];
progressNotes: ProgressNote[]; progressNotes: ProgressNote[];
createdAt: string; createdAt: string;

View File

@@ -255,6 +255,9 @@ export function DashboardPage() {
{task.assigneeName && ( {task.assigneeName && (
<span className="text-xs text-emerald-600 dark:text-emerald-400 px-1.5 py-0.5 bg-emerald-100 dark:bg-emerald-900/30 rounded-full">👤 {task.assigneeName}</span> <span className="text-xs text-emerald-600 dark:text-emerald-400 px-1.5 py-0.5 bg-emerald-100 dark:bg-emerald-900/30 rounded-full">👤 {task.assigneeName}</span>
)} )}
{task.recurrence && (
<span className="text-xs text-teal-600 dark:text-teal-400 px-1.5 py-0.5 bg-teal-100 dark:bg-teal-900/30 rounded-full">🔄 {task.recurrence.frequency}</span>
)}
{task.dueDate && (() => { {task.dueDate && (() => {
const due = new Date(task.dueDate); const due = new Date(task.dueDate);
const diffMs = due.getTime() - Date.now(); const diffMs = due.getTime() - Date.now();

View File

@@ -248,6 +248,11 @@ export function TaskPage() {
📁 {project.name} 📁 {project.name}
</span> </span>
)} )}
{task.recurrence && (
<span className="text-xs text-teal-600 dark:text-teal-400 bg-teal-100 dark:bg-teal-900/30 px-2 py-0.5 rounded-full font-medium">
🔄 {task.recurrence.frequency === "biweekly" ? "Bi-weekly" : task.recurrence.frequency.charAt(0).toUpperCase() + task.recurrence.frequency.slice(1)}
</span>
)}
{task.tags?.map(tag => ( {task.tags?.map(tag => (
<span key={tag} className="text-xs text-violet-600 dark:text-violet-400 bg-violet-100 dark:bg-violet-900/30 px-2 py-0.5 rounded-full font-medium"> <span key={tag} className="text-xs text-violet-600 dark:text-violet-400 bg-violet-100 dark:bg-violet-900/30 px-2 py-0.5 rounded-full font-medium">
🏷 {tag} 🏷 {tag}
@@ -623,6 +628,14 @@ export function TaskPage() {
<span className="text-gray-700 dark:text-gray-300 font-medium"> {task.estimatedHours}h</span> <span className="text-gray-700 dark:text-gray-300 font-medium"> {task.estimatedHours}h</span>
</div> </div>
)} )}
{task.recurrence && (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Recurrence</span>
<span className="text-teal-600 dark:text-teal-400 font-medium">
🔄 {task.recurrence.frequency === "biweekly" ? "Bi-weekly" : task.recurrence.frequency.charAt(0).toUpperCase() + task.recurrence.frequency.slice(1)}
</span>
</div>
)}
{task.tags?.length > 0 && ( {task.tags?.length > 0 && (
<div> <div>
<span className="text-gray-500 dark:text-gray-400 block mb-1">Tags</span> <span className="text-gray-500 dark:text-gray-400 block mb-1">Tags</span>