From b314bda934f2fc51b64ad3d5e4617d899fdca2c5 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Wed, 29 Jul 2026 19:25:15 +0000 Subject: [PATCH] feat(tasks): add recurring task support with rrule in create dialog, detail panel, and complete route --- .../[domainId]/tasks/[id]/complete/route.ts | 43 +++++++++++++ .../domains/[domainId]/tasks/[id]/route.ts | 2 + .../app/api/domains/[domainId]/tasks/route.ts | 2 + .../components/tasks/task-create-dialog.tsx | 46 +++++++++++++ .../components/tasks/task-detail-panel.tsx | 64 +++++++++++++++++++ 5 files changed, 157 insertions(+) diff --git a/apps/web/app/api/domains/[domainId]/tasks/[id]/complete/route.ts b/apps/web/app/api/domains/[domainId]/tasks/[id]/complete/route.ts index a963459..a69a009 100644 --- a/apps/web/app/api/domains/[domainId]/tasks/[id]/complete/route.ts +++ b/apps/web/app/api/domains/[domainId]/tasks/[id]/complete/route.ts @@ -8,6 +8,7 @@ import { withAuth, requireWorkspaceAccess, createErrorResponse } from '@/lib/aut import { recordActivity } from '@/lib/activity'; import { db, tasks } from '@project-e/db'; import { and, eq, isNull } from 'drizzle-orm'; +import { RRule } from 'rrule'; type RouteContext = { params: Promise<{ domainId: string; id: string }> }; @@ -43,5 +44,47 @@ export const POST = withAuth(async (request: NextRequest, user, co workspaceId: domainId, }); + // Auto-create next recurring instance if recurrenceRule is set + if (existing.recurrenceRule) { + try { + const rule = RRule.fromString(existing.recurrenceRule); + const nextOccurrence = rule.after(new Date(), true); + + if (nextOccurrence) { + const [spawned] = await db.insert(tasks).values({ + title: existing.title, + description: existing.description, + status: 'todo', + priority: existing.priority, + domainId: existing.domainId, + projectId: existing.projectId, + sectionId: existing.sectionId, + parentId: existing.parentId, + dueDate: nextOccurrence, + estimatedMinutes: existing.estimatedMinutes, + order: existing.order, + customFields: existing.customFields ?? {}, + recurrenceRule: existing.recurrenceRule, + }).returning(); + + await recordActivity({ + actor: user.name, + action: 'created', + entityType: 'task', + entityId: spawned.id, + changes: { + title: spawned.title, + note: 'Auto-created from recurring task', + sourceTaskId: id, + }, + workspaceId: domainId, + }); + } + } catch (err) { + console.error('[tasks complete] Failed to spawn recurring instance:', err); + // Don't fail the completion — the original task is already marked done + } + } + return NextResponse.json(updated); }); diff --git a/apps/web/app/api/domains/[domainId]/tasks/[id]/route.ts b/apps/web/app/api/domains/[domainId]/tasks/[id]/route.ts index a42c9f2..82496e9 100644 --- a/apps/web/app/api/domains/[domainId]/tasks/[id]/route.ts +++ b/apps/web/app/api/domains/[domainId]/tasks/[id]/route.ts @@ -25,6 +25,7 @@ const updateTaskSchema = z.object({ estimatedMinutes: z.number().int().positive().optional().nullable(), order: z.number().int().optional(), customFields: z.record(z.string(), z.unknown()).optional(), + recurrenceRule: z.string().optional().nullable(), }); type RouteContext = { params: Promise<{ domainId: string; id: string }> }; @@ -141,6 +142,7 @@ export const PATCH = withAuth(async (request: NextRequest, user, c if (data.estimatedMinutes !== undefined) updateValues.estimatedMinutes = data.estimatedMinutes; if (data.order !== undefined) updateValues.order = data.order; if (data.customFields !== undefined) updateValues.customFields = data.customFields; + if (data.recurrenceRule !== undefined) updateValues.recurrenceRule = data.recurrenceRule; updateValues.updatedAt = new Date(); const [updated] = await db.update(tasks) diff --git a/apps/web/app/api/domains/[domainId]/tasks/route.ts b/apps/web/app/api/domains/[domainId]/tasks/route.ts index 138d204..2628c94 100644 --- a/apps/web/app/api/domains/[domainId]/tasks/route.ts +++ b/apps/web/app/api/domains/[domainId]/tasks/route.ts @@ -25,6 +25,7 @@ const createTaskSchema = z.object({ estimatedMinutes: z.number().int().positive().optional().nullable(), order: z.number().int().optional(), customFields: z.record(z.string(), z.unknown()).optional(), + recurrenceRule: z.string().optional().nullable(), tagIds: z.array(z.string().uuid()).optional(), }); @@ -185,6 +186,7 @@ export const POST = withAuth(async (request: NextRequest, user, co estimatedMinutes: data.estimatedMinutes ?? null, order: data.order ?? 0, customFields: data.customFields ?? {}, + recurrenceRule: data.recurrenceRule ?? null, }).returning(); // Insert tags if provided diff --git a/apps/web/components/tasks/task-create-dialog.tsx b/apps/web/components/tasks/task-create-dialog.tsx index 434e5c9..26866d8 100644 --- a/apps/web/components/tasks/task-create-dialog.tsx +++ b/apps/web/components/tasks/task-create-dialog.tsx @@ -30,6 +30,14 @@ interface TaskCreateDialogProps { onCreated: () => void; } +const RECURRENCE_OPTIONS = [ + { label: 'None', value: '' }, + { label: 'Daily', value: 'FREQ=DAILY' }, + { label: 'Weekly', value: 'FREQ=WEEKLY' }, + { label: 'Monthly', value: 'FREQ=MONTHLY' }, + { label: 'Custom (rrule)', value: 'custom' }, +] as const; + export function TaskCreateDialog({ open, onOpenChange, @@ -43,6 +51,8 @@ export function TaskCreateDialog({ const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'urgent'>('medium'); const [dueDate, setDueDate] = useState(''); const [estimatedMinutes, setEstimatedMinutes] = useState(''); + const [recurrenceType, setRecurrenceType] = useState(''); + const [customRrule, setCustomRrule] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); @@ -55,10 +65,18 @@ export function TaskCreateDialog({ setPriority('medium'); setDueDate(''); setEstimatedMinutes(''); + setRecurrenceType(''); + setCustomRrule(''); setError(''); } }, [open, defaultStatus]); + function getRecurrenceRule(): string | null { + if (!recurrenceType) return null; + if (recurrenceType === 'custom') return customRrule || null; + return recurrenceType; + } + async function handleSubmit(event: React.FormEvent) { event.preventDefault(); if (!domainId) { @@ -76,6 +94,8 @@ export function TaskCreateDialog({ if (description) body.description = description; if (dueDate) body.dueDate = new Date(dueDate).toISOString(); if (estimatedMinutes) body.estimatedMinutes = parseInt(estimatedMinutes, 10); + const recurrenceRule = getRecurrenceRule(); + if (recurrenceRule) body.recurrenceRule = recurrenceRule; try { const response = await fetch(`/api/domains/${domainId}/tasks`, { @@ -186,6 +206,32 @@ export function TaskCreateDialog({ + {/* Recurrence */} +
+ + + {recurrenceType === 'custom' && ( + setCustomRrule(e.target.value)} + placeholder="e.g. FREQ=WEEKLY;BYDAY=MO,WE,FR" + className="mt-2" + /> + )} +
+ {error &&

{error}

} diff --git a/apps/web/components/tasks/task-detail-panel.tsx b/apps/web/components/tasks/task-detail-panel.tsx index 6757ec5..3cbccc9 100644 --- a/apps/web/components/tasks/task-detail-panel.tsx +++ b/apps/web/components/tasks/task-detail-panel.tsx @@ -47,6 +47,7 @@ interface TaskDetail { estimatedMinutes?: number | null; trackedMinutes?: number | null; order: number; + recurrenceRule?: string | null; customFields?: Record | null; createdAt: string; updatedAt: string; @@ -64,6 +65,14 @@ interface TaskDetailPanelProps { onUpdate: () => void; } +const RECURRENCE_OPTIONS = [ + { label: 'None', value: '' }, + { label: 'Daily', value: 'FREQ=DAILY' }, + { label: 'Weekly', value: 'FREQ=WEEKLY' }, + { label: 'Monthly', value: 'FREQ=MONTHLY' }, + { label: 'Custom (rrule)', value: 'custom' }, +] as const; + export function TaskDetailPanel({ taskId, domainId, @@ -79,6 +88,8 @@ export function TaskDetailPanel({ const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'urgent'>('medium'); const [dueDate, setDueDate] = useState(''); const [estimatedMinutes, setEstimatedMinutes] = useState(''); + const [recurrenceType, setRecurrenceType] = useState(''); + const [customRrule, setCustomRrule] = useState(''); const [saving, setSaving] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [deleting, setDeleting] = useState(false); @@ -100,6 +111,23 @@ export function TaskDetailPanel({ setPriority(data.priority); setDueDate(data.dueDate ? data.dueDate.split('T')[0] : ''); setEstimatedMinutes(data.estimatedMinutes?.toString() || ''); + + // Recurrence + if (data.recurrenceRule) { + const isPreset = RECURRENCE_OPTIONS.some( + (o) => o.value !== 'custom' && o.value !== '' && o.value === data.recurrenceRule + ); + if (isPreset) { + setRecurrenceType(data.recurrenceRule); + setCustomRrule(''); + } else { + setRecurrenceType('custom'); + setCustomRrule(data.recurrenceRule); + } + } else { + setRecurrenceType(''); + setCustomRrule(''); + } }) .catch((err) => { console.error('Failed to load task:', err); @@ -108,6 +136,12 @@ export function TaskDetailPanel({ .finally(() => setLoading(false)); }, [open, taskId, domainId]); + function getRecurrenceRule(): string | null { + if (!recurrenceType) return null; + if (recurrenceType === 'custom') return customRrule || null; + return recurrenceType; + } + async function handleSave() { if (!task || !domainId) return; setSaving(true); @@ -124,6 +158,10 @@ export function TaskDetailPanel({ if (estimatedMinutes !== (task.estimatedMinutes?.toString() || '')) { body.estimatedMinutes = estimatedMinutes ? parseInt(estimatedMinutes, 10) : null; } + const recurrenceRule = getRecurrenceRule(); + if (recurrenceRule !== (task.recurrenceRule || null)) { + body.recurrenceRule = recurrenceRule; + } const response = await fetch(`/api/domains/${domainId}/tasks/${task.id}`, { method: 'PATCH', @@ -262,6 +300,32 @@ export function TaskDetailPanel({ + {/* Recurrence */} +
+ + + {recurrenceType === 'custom' && ( + setCustomRrule(e.target.value)} + placeholder="e.g. FREQ=WEEKLY;BYDAY=MO,WE,FR" + className="mt-2" + /> + )} +
+ {/* Tags */} {task.tags && task.tags.length > 0 && (