feat(tasks): add recurring task support with rrule in create dialog, detail panel, and complete route

This commit is contained in:
2026-07-29 19:25:15 +00:00
parent 1a0704e0f2
commit b314bda934
5 changed files with 157 additions and 0 deletions
@@ -8,6 +8,7 @@ import { withAuth, requireWorkspaceAccess, createErrorResponse } from '@/lib/aut
import { recordActivity } from '@/lib/activity'; import { recordActivity } from '@/lib/activity';
import { db, tasks } from '@project-e/db'; import { db, tasks } from '@project-e/db';
import { and, eq, isNull } from 'drizzle-orm'; import { and, eq, isNull } from 'drizzle-orm';
import { RRule } from 'rrule';
type RouteContext = { params: Promise<{ domainId: string; id: string }> }; type RouteContext = { params: Promise<{ domainId: string; id: string }> };
@@ -43,5 +44,47 @@ export const POST = withAuth<RouteContext>(async (request: NextRequest, user, co
workspaceId: domainId, 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); return NextResponse.json(updated);
}); });
@@ -25,6 +25,7 @@ const updateTaskSchema = z.object({
estimatedMinutes: z.number().int().positive().optional().nullable(), estimatedMinutes: z.number().int().positive().optional().nullable(),
order: z.number().int().optional(), order: z.number().int().optional(),
customFields: z.record(z.string(), z.unknown()).optional(), customFields: z.record(z.string(), z.unknown()).optional(),
recurrenceRule: z.string().optional().nullable(),
}); });
type RouteContext = { params: Promise<{ domainId: string; id: string }> }; type RouteContext = { params: Promise<{ domainId: string; id: string }> };
@@ -141,6 +142,7 @@ export const PATCH = withAuth<RouteContext>(async (request: NextRequest, user, c
if (data.estimatedMinutes !== undefined) updateValues.estimatedMinutes = data.estimatedMinutes; if (data.estimatedMinutes !== undefined) updateValues.estimatedMinutes = data.estimatedMinutes;
if (data.order !== undefined) updateValues.order = data.order; if (data.order !== undefined) updateValues.order = data.order;
if (data.customFields !== undefined) updateValues.customFields = data.customFields; if (data.customFields !== undefined) updateValues.customFields = data.customFields;
if (data.recurrenceRule !== undefined) updateValues.recurrenceRule = data.recurrenceRule;
updateValues.updatedAt = new Date(); updateValues.updatedAt = new Date();
const [updated] = await db.update(tasks) const [updated] = await db.update(tasks)
@@ -25,6 +25,7 @@ const createTaskSchema = z.object({
estimatedMinutes: z.number().int().positive().optional().nullable(), estimatedMinutes: z.number().int().positive().optional().nullable(),
order: z.number().int().optional(), order: z.number().int().optional(),
customFields: z.record(z.string(), z.unknown()).optional(), customFields: z.record(z.string(), z.unknown()).optional(),
recurrenceRule: z.string().optional().nullable(),
tagIds: z.array(z.string().uuid()).optional(), tagIds: z.array(z.string().uuid()).optional(),
}); });
@@ -185,6 +186,7 @@ export const POST = withAuth<RouteContext>(async (request: NextRequest, user, co
estimatedMinutes: data.estimatedMinutes ?? null, estimatedMinutes: data.estimatedMinutes ?? null,
order: data.order ?? 0, order: data.order ?? 0,
customFields: data.customFields ?? {}, customFields: data.customFields ?? {},
recurrenceRule: data.recurrenceRule ?? null,
}).returning(); }).returning();
// Insert tags if provided // Insert tags if provided
@@ -30,6 +30,14 @@ interface TaskCreateDialogProps {
onCreated: () => void; 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({ export function TaskCreateDialog({
open, open,
onOpenChange, onOpenChange,
@@ -43,6 +51,8 @@ export function TaskCreateDialog({
const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'urgent'>('medium'); const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'urgent'>('medium');
const [dueDate, setDueDate] = useState(''); const [dueDate, setDueDate] = useState('');
const [estimatedMinutes, setEstimatedMinutes] = useState(''); const [estimatedMinutes, setEstimatedMinutes] = useState('');
const [recurrenceType, setRecurrenceType] = useState('');
const [customRrule, setCustomRrule] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -55,10 +65,18 @@ export function TaskCreateDialog({
setPriority('medium'); setPriority('medium');
setDueDate(''); setDueDate('');
setEstimatedMinutes(''); setEstimatedMinutes('');
setRecurrenceType('');
setCustomRrule('');
setError(''); setError('');
} }
}, [open, defaultStatus]); }, [open, defaultStatus]);
function getRecurrenceRule(): string | null {
if (!recurrenceType) return null;
if (recurrenceType === 'custom') return customRrule || null;
return recurrenceType;
}
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) { async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!domainId) { if (!domainId) {
@@ -76,6 +94,8 @@ export function TaskCreateDialog({
if (description) body.description = description; if (description) body.description = description;
if (dueDate) body.dueDate = new Date(dueDate).toISOString(); if (dueDate) body.dueDate = new Date(dueDate).toISOString();
if (estimatedMinutes) body.estimatedMinutes = parseInt(estimatedMinutes, 10); if (estimatedMinutes) body.estimatedMinutes = parseInt(estimatedMinutes, 10);
const recurrenceRule = getRecurrenceRule();
if (recurrenceRule) body.recurrenceRule = recurrenceRule;
try { try {
const response = await fetch(`/api/domains/${domainId}/tasks`, { const response = await fetch(`/api/domains/${domainId}/tasks`, {
@@ -186,6 +206,32 @@ export function TaskCreateDialog({
</div> </div>
</div> </div>
{/* Recurrence */}
<div className="space-y-2">
<Label htmlFor="task-recurrence">Recurrence</Label>
<Select value={recurrenceType} onValueChange={setRecurrenceType}>
<SelectTrigger id="task-recurrence">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
{RECURRENCE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{recurrenceType === 'custom' && (
<Input
id="task-custom-rrule"
value={customRrule}
onChange={(e) => setCustomRrule(e.target.value)}
placeholder="e.g. FREQ=WEEKLY;BYDAY=MO,WE,FR"
className="mt-2"
/>
)}
</div>
{error && <p className="text-sm text-destructive" role="alert">{error}</p>} {error && <p className="text-sm text-destructive" role="alert">{error}</p>}
<DialogFooter> <DialogFooter>
@@ -47,6 +47,7 @@ interface TaskDetail {
estimatedMinutes?: number | null; estimatedMinutes?: number | null;
trackedMinutes?: number | null; trackedMinutes?: number | null;
order: number; order: number;
recurrenceRule?: string | null;
customFields?: Record<string, unknown> | null; customFields?: Record<string, unknown> | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -64,6 +65,14 @@ interface TaskDetailPanelProps {
onUpdate: () => void; 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({ export function TaskDetailPanel({
taskId, taskId,
domainId, domainId,
@@ -79,6 +88,8 @@ export function TaskDetailPanel({
const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'urgent'>('medium'); const [priority, setPriority] = useState<'low' | 'medium' | 'high' | 'urgent'>('medium');
const [dueDate, setDueDate] = useState(''); const [dueDate, setDueDate] = useState('');
const [estimatedMinutes, setEstimatedMinutes] = useState(''); const [estimatedMinutes, setEstimatedMinutes] = useState('');
const [recurrenceType, setRecurrenceType] = useState('');
const [customRrule, setCustomRrule] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@@ -100,6 +111,23 @@ export function TaskDetailPanel({
setPriority(data.priority); setPriority(data.priority);
setDueDate(data.dueDate ? data.dueDate.split('T')[0] : ''); setDueDate(data.dueDate ? data.dueDate.split('T')[0] : '');
setEstimatedMinutes(data.estimatedMinutes?.toString() || ''); 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) => { .catch((err) => {
console.error('Failed to load task:', err); console.error('Failed to load task:', err);
@@ -108,6 +136,12 @@ export function TaskDetailPanel({
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [open, taskId, domainId]); }, [open, taskId, domainId]);
function getRecurrenceRule(): string | null {
if (!recurrenceType) return null;
if (recurrenceType === 'custom') return customRrule || null;
return recurrenceType;
}
async function handleSave() { async function handleSave() {
if (!task || !domainId) return; if (!task || !domainId) return;
setSaving(true); setSaving(true);
@@ -124,6 +158,10 @@ export function TaskDetailPanel({
if (estimatedMinutes !== (task.estimatedMinutes?.toString() || '')) { if (estimatedMinutes !== (task.estimatedMinutes?.toString() || '')) {
body.estimatedMinutes = estimatedMinutes ? parseInt(estimatedMinutes, 10) : null; 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}`, { const response = await fetch(`/api/domains/${domainId}/tasks/${task.id}`, {
method: 'PATCH', method: 'PATCH',
@@ -262,6 +300,32 @@ export function TaskDetailPanel({
</div> </div>
</div> </div>
{/* Recurrence */}
<div className="space-y-2">
<Label htmlFor="task-recurrence">Recurrence</Label>
<Select value={recurrenceType} onValueChange={setRecurrenceType}>
<SelectTrigger id="task-recurrence">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
{RECURRENCE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{recurrenceType === 'custom' && (
<Input
id="task-custom-rrule"
value={customRrule}
onChange={(e) => setCustomRrule(e.target.value)}
placeholder="e.g. FREQ=WEEKLY;BYDAY=MO,WE,FR"
className="mt-2"
/>
)}
</div>
{/* Tags */} {/* Tags */}
{task.tags && task.tags.length > 0 && ( {task.tags && task.tags.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">