From 96441b818e8a6a97c434659612d95c5bf1aaee71 Mon Sep 17 00:00:00 2001 From: Hammer Date: Thu, 29 Jan 2026 12:05:13 +0000 Subject: [PATCH] feat: recurring tasks - auto-spawn next instance on completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend/src/db/schema.ts | 9 ++ backend/src/routes/tasks.ts | 99 ++++++++++++++++++++- frontend/src/components/CreateTaskModal.tsx | 40 ++++++++- frontend/src/components/KanbanBoard.tsx | 5 ++ frontend/src/components/TaskCard.tsx | 5 ++ frontend/src/components/TaskDetailPanel.tsx | 67 +++++++++++++- frontend/src/lib/api.ts | 4 +- frontend/src/lib/types.ts | 8 ++ frontend/src/pages/DashboardPage.tsx | 3 + frontend/src/pages/TaskPage.tsx | 13 +++ 10 files changed, 246 insertions(+), 7 deletions(-) diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 4b767b6..1e0ad4f 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -46,6 +46,14 @@ export interface Subtask { 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 โ”€โ”€โ”€ export interface ProjectLink { @@ -84,6 +92,7 @@ export const tasks = pgTable("tasks", { dueDate: timestamp("due_date", { withTimezone: true }), estimatedHours: integer("estimated_hours"), tags: jsonb("tags").$type().default([]), + recurrence: jsonb("recurrence").$type(), subtasks: jsonb("subtasks").$type().default([]), progressNotes: jsonb("progress_notes").$type().default([]), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), diff --git a/backend/src/routes/tasks.ts b/backend/src/routes/tasks.ts index f422916..eb3876d 100644 --- a/backend/src/routes/tasks.ts +++ b/backend/src/routes/tasks.ts @@ -1,6 +1,6 @@ import { Elysia, t } from "elysia"; 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 { 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`COALESCE(MAX(${tasks.taskNumber}), 0)` }) + .from(tasks); + const nextNumber = (maxNum[0]?.max ?? 0) + 1; + + const maxPos = await db + .select({ max: sql`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 const statusOrder = sql`CASE 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, estimatedHours: body.estimatedHours ?? null, tags: body.tags || [], + recurrence: body.recurrence || null, subtasks: [], progressNotes: [], }) @@ -197,6 +272,13 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) dueDate: t.Optional(t.Union([t.String(), t.Null()])), estimatedHours: t.Optional(t.Union([t.Number(), t.Null()])), 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.estimatedHours !== undefined) updates.estimatedHours = body.estimatedHours; 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.progressNotes !== undefined) updates.progressNotes = body.progressNotes; @@ -319,6 +402,13 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) 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]; }, { @@ -336,6 +426,13 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" }) dueDate: t.Optional(t.Union([t.String(), t.Null()])), estimatedHours: t.Optional(t.Union([t.Number(), t.Null()])), 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({ id: t.String(), title: t.String(), diff --git a/frontend/src/components/CreateTaskModal.tsx b/frontend/src/components/CreateTaskModal.tsx index 8cb09d3..71daf6b 100644 --- a/frontend/src/components/CreateTaskModal.tsx +++ b/frontend/src/components/CreateTaskModal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { fetchProjects } from "../lib/api"; -import type { Project } from "../lib/types"; +import type { Project, RecurrenceFrequency, Recurrence } from "../lib/types"; interface CreateTaskModalProps { open: boolean; @@ -13,6 +13,7 @@ interface CreateTaskModalProps { projectId?: string; dueDate?: string; estimatedHours?: number; + recurrence?: Recurrence | null; }) => void; } @@ -25,6 +26,8 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp const [dueDate, setDueDate] = useState(""); const [estimatedHours, setEstimatedHours] = useState(""); const [projects, setProjects] = useState([]); + const [recurrenceFreq, setRecurrenceFreq] = useState(""); + const [recurrenceAutoActivate, setRecurrenceAutoActivate] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false); useEffect(() => { @@ -56,6 +59,7 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp projectId: projectId || undefined, dueDate: dueDate ? new Date(dueDate).toISOString() : undefined, estimatedHours: estimatedHours ? Number(estimatedHours) : undefined, + recurrence: recurrenceFreq ? { frequency: recurrenceFreq, autoActivate: recurrenceAutoActivate } : undefined, }); // Reset form setTitle(""); @@ -65,6 +69,8 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp setProjectId(""); setDueDate(""); setEstimatedHours(""); + setRecurrenceFreq(""); + setRecurrenceAutoActivate(false); setShowAdvanced(false); onClose(); }; @@ -200,6 +206,38 @@ export function CreateTaskModal({ open, onClose, onCreate }: CreateTaskModalProp + {/* Recurrence */} +
+ +
+ {(["", "daily", "weekly", "biweekly", "monthly"] as const).map((freq) => ( + + ))} +
+ {recurrenceFreq && ( + + )} +
+ {/* Source */}
diff --git a/frontend/src/components/KanbanBoard.tsx b/frontend/src/components/KanbanBoard.tsx index 6f026da..1ba4e20 100644 --- a/frontend/src/components/KanbanBoard.tsx +++ b/frontend/src/components/KanbanBoard.tsx @@ -118,6 +118,11 @@ function KanbanCard({ task, projectName, onDragStart, onDragEnd, isDragging, onC ๐Ÿท๏ธ {tag} ))} + {task.recurrence && ( + + ๐Ÿ”„ {task.recurrence.frequency} + + )} {task.estimatedHours != null && task.estimatedHours > 0 && ( โฑ {task.estimatedHours}h diff --git a/frontend/src/components/TaskCard.tsx b/frontend/src/components/TaskCard.tsx index 0ba6864..1702cd8 100644 --- a/frontend/src/components/TaskCard.tsx +++ b/frontend/src/components/TaskCard.tsx @@ -133,6 +133,11 @@ export function TaskCard({ {timeAgo(task.createdAt)} + {task.recurrence && ( + + ๐Ÿ”„ {task.recurrence.frequency} + + )} {task.estimatedHours != null && task.estimatedHours > 0 && ( โฑ {task.estimatedHours}h diff --git a/frontend/src/components/TaskDetailPanel.tsx b/frontend/src/components/TaskDetailPanel.tsx index 7b0e5c8..0e3f4ce 100644 --- a/frontend/src/components/TaskDetailPanel.tsx +++ b/frontend/src/components/TaskDetailPanel.tsx @@ -1,5 +1,5 @@ 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 { useToast } from "./Toast"; @@ -261,6 +261,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, const [draftEstimatedHours, setDraftEstimatedHours] = useState(task.estimatedHours != null ? String(task.estimatedHours) : ""); const [draftTags, setDraftTags] = useState(task.tags || []); const [tagInput, setTagInput] = useState(""); + const [draftRecurrence, setDraftRecurrence] = useState(task.recurrence || null); const [projects, setProjects] = useState([]); const [newSubtaskTitle, setNewSubtaskTitle] = useState(""); const [addingSubtask, setAddingSubtask] = useState(false); @@ -287,7 +288,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, setDraftAssigneeName(task.assigneeName || ""); setDraftEstimatedHours(task.estimatedHours != null ? String(task.estimatedHours) : ""); 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 isDirty = @@ -299,7 +301,8 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, draftDueDate !== currentDueDate || draftAssigneeName !== (task.assigneeName || "") || 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 = () => { setDraftTitle(task.title); @@ -311,6 +314,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, setDraftAssigneeName(task.assigneeName || ""); setDraftEstimatedHours(task.estimatedHours != null ? String(task.estimatedHours) : ""); setDraftTags(task.tags || []); + setDraftRecurrence(task.recurrence || null); }; const handleSave = async () => { @@ -329,6 +333,7 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated, (updates as any).estimatedHours = draftEstimatedHours ? Number(draftEstimatedHours) : null; } 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); onTaskUpdated(); toast("Changes saved", "success"); @@ -672,6 +677,62 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
+ {/* Recurrence */} +
+

Recurrence

+ {hasToken ? ( +
+
+ {([null, "daily", "weekly", "biweekly", "monthly"] as const).map((freq) => ( + + ))} +
+ {draftRecurrence && ( + + )} +
+ ) : ( + + {task.recurrence ? ( + + + ๐Ÿ”„ {task.recurrence.frequency === "biweekly" ? "Bi-weekly" : task.recurrence.frequency.charAt(0).toUpperCase() + task.recurrence.frequency.slice(1)} + + {task.recurrence.autoActivate && ( + ยท auto-activate + )} + + ) : ( + One-time task + )} + + )} +
+ {/* Description */}

Description

diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 237fa83..f6408e7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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"; @@ -38,7 +38,7 @@ export async function reorderTasks(ids: string[], token?: string): Promise } 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 ): Promise { const headers: Record = { "Content-Type": "application/json" }; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index b2e810a..464f389 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -44,6 +44,13 @@ export interface ProjectWithTasks extends Project { tasks: Task[]; } +export type RecurrenceFrequency = "daily" | "weekly" | "biweekly" | "monthly"; + +export interface Recurrence { + frequency: RecurrenceFrequency; + autoActivate?: boolean; +} + export interface Task { id: string; taskNumber: number; @@ -59,6 +66,7 @@ export interface Task { dueDate: string | null; estimatedHours: number | null; tags: string[]; + recurrence: Recurrence | null; subtasks: Subtask[]; progressNotes: ProgressNote[]; createdAt: string; diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index d204827..836681e 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -255,6 +255,9 @@ export function DashboardPage() { {task.assigneeName && ( ๐Ÿ‘ค {task.assigneeName} )} + {task.recurrence && ( + ๐Ÿ”„ {task.recurrence.frequency} + )} {task.dueDate && (() => { const due = new Date(task.dueDate); const diffMs = due.getTime() - Date.now(); diff --git a/frontend/src/pages/TaskPage.tsx b/frontend/src/pages/TaskPage.tsx index 7bc7baa..6116c3e 100644 --- a/frontend/src/pages/TaskPage.tsx +++ b/frontend/src/pages/TaskPage.tsx @@ -248,6 +248,11 @@ export function TaskPage() { ๐Ÿ“ {project.name} )} + {task.recurrence && ( + + ๐Ÿ”„ {task.recurrence.frequency === "biweekly" ? "Bi-weekly" : task.recurrence.frequency.charAt(0).toUpperCase() + task.recurrence.frequency.slice(1)} + + )} {task.tags?.map(tag => ( ๐Ÿท๏ธ {tag} @@ -623,6 +628,14 @@ export function TaskPage() { โฑ {task.estimatedHours}h
)} + {task.recurrence && ( +
+ Recurrence + + ๐Ÿ”„ {task.recurrence.frequency === "biweekly" ? "Bi-weekly" : task.recurrence.frequency.charAt(0).toUpperCase() + task.recurrence.frequency.slice(1)} + +
+ )} {task.tags?.length > 0 && (
Tags