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;
}
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<string[]>().default([]),
recurrence: jsonb("recurrence").$type<Recurrence>(),
subtasks: jsonb("subtasks").$type<Subtask[]>().default([]),
progressNotes: jsonb("progress_notes").$type<ProgressNote[]>().default([]),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),

View File

@@ -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<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
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(),