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:
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user