feat(tasks): add recurring task support with rrule in create dialog, detail panel, and complete route
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user