merge: fix/ux-leaf-c-settings into integration/ux-28-gaps

This commit is contained in:
2026-07-29 20:31:22 +00:00
9 changed files with 878 additions and 2 deletions
@@ -8,6 +8,7 @@ import {
Webhook,
Download,
AlertTriangle,
Tag,
} from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -17,6 +18,8 @@ import { SettingsShortcuts } from '@/components/settings/settings-shortcuts';
import { SettingsAgents } from '@/components/settings/settings-agents';
import { SettingsWebhooks } from '@/components/settings/settings-webhooks';
import { SettingsImportExport } from '@/components/settings/settings-import-export';
import { SettingsTags } from '@/components/settings/settings-tags';
import { SettingsCustomFields } from '@/components/settings/settings-custom-fields';
export default function SettingsPage() {
return (
@@ -36,6 +39,14 @@ export default function SettingsPage() {
<Globe className="h-4 w-4" aria-hidden="true" />
Domains
</TabsTrigger>
<TabsTrigger value="tags" className="shrink-0 justify-start gap-2">
<Tag className="h-4 w-4" aria-hidden="true" />
Tags
</TabsTrigger>
<TabsTrigger value="custom-fields" className="shrink-0 justify-start gap-2">
<Tag className="h-4 w-4" aria-hidden="true" />
Custom Fields
</TabsTrigger>
<TabsTrigger value="shortcuts" className="shrink-0 justify-start gap-2">
<Keyboard className="h-4 w-4" aria-hidden="true" />
Keyboard Shortcuts
@@ -65,6 +76,12 @@ export default function SettingsPage() {
<TabsContent value="domains">
<SettingsDomains />
</TabsContent>
<TabsContent value="tags">
<SettingsTags />
</TabsContent>
<TabsContent value="custom-fields">
<SettingsCustomFields />
</TabsContent>
<TabsContent value="shortcuts">
<SettingsShortcuts />
</TabsContent>
@@ -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<RouteContext>(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);
});
@@ -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<RouteContext>(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)
@@ -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<RouteContext>(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
@@ -0,0 +1,254 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { Plus, Trash2, GripVertical } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
const STORAGE_KEY = 'pe_custom_field_schemas';
type FieldType = 'text' | 'number' | 'select' | 'checkbox';
type FieldScope = 'task' | 'habit' | 'project' | 'note';
interface FieldSchema {
id: string;
name: string;
type: FieldType;
options?: string[];
scope: FieldScope;
}
const FIELD_TYPES: { value: FieldType; label: string }[] = [
{ value: 'text', label: 'Text' },
{ value: 'number', label: 'Number' },
{ value: 'select', label: 'Select' },
{ value: 'checkbox', label: 'Checkbox' },
];
const SCOPE_OPTIONS: { value: FieldScope; label: string }[] = [
{ value: 'task', label: 'Task' },
{ value: 'habit', label: 'Habit' },
{ value: 'project', label: 'Project' },
{ value: 'note', label: 'Note' },
];
function loadSchemas(): FieldSchema[] {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function saveSchemas(schemas: FieldSchema[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(schemas));
}
export function SettingsCustomFields() {
const [schemas, setSchemas] = useState<FieldSchema[]>([]);
const [filterScope, setFilterScope] = useState<FieldScope | 'all'>('all');
// Create form
const [newName, setNewName] = useState('');
const [newType, setNewType] = useState<FieldType>('text');
const [newScope, setNewScope] = useState<FieldScope>('task');
const [newOptions, setNewOptions] = useState('');
// Delete
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
setSchemas(loadSchemas());
}, []);
const filteredSchemas = filterScope === 'all'
? schemas
: schemas.filter((s) => s.scope === filterScope);
function handleAdd() {
if (!newName.trim()) return;
const newSchema: FieldSchema = {
id: crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`,
name: newName.trim(),
type: newType,
scope: newScope,
options: newType === 'select' ? newOptions.split(',').map((o) => o.trim()).filter(Boolean) : undefined,
};
const updated = [...schemas, newSchema];
saveSchemas(updated);
setSchemas(updated);
toast.success('Custom field added');
setNewName('');
setNewType('text');
setNewOptions('');
}
function handleDelete(id: string) {
const updated = schemas.filter((s) => s.id !== id);
saveSchemas(updated);
setSchemas(updated);
setDeletingId(null);
toast.success('Custom field removed');
}
return (
<Card>
<CardHeader>
<CardTitle>Custom Fields</CardTitle>
<CardDescription>
Define custom field schemas for tasks, habits, projects, and notes. Stored locally in your browser.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Scope filter */}
<div className="flex items-center gap-2">
<Label htmlFor="cf-filter-scope" className="text-xs">Filter by scope:</Label>
<Select value={filterScope} onValueChange={(v) => setFilterScope(v as FieldScope | 'all')}>
<SelectTrigger id="cf-filter-scope" className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All scopes</SelectItem>
{SCOPE_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Schema list */}
<div className="space-y-2">
{filteredSchemas.length === 0 ? (
<p className="text-sm text-muted-foreground">
{schemas.length === 0
? 'No custom fields defined yet. Add one below.'
: 'No custom fields match the selected scope.'}
</p>
) : (
filteredSchemas.map((field) => (
<div key={field.id} className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-3">
<GripVertical className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<div>
<span className="font-medium">{field.name}</span>
<div className="flex gap-1 mt-1">
<Badge variant="secondary" className="text-xs">{field.type}</Badge>
<Badge variant="outline" className="text-xs capitalize">{field.scope}</Badge>
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setDeletingId(field.id)}
aria-label={`Delete custom field: ${field.name}`}
>
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
</Button>
</div>
))
)}
</div>
{/* Add form */}
<div className="flex flex-wrap items-end gap-2 rounded-lg border p-3">
<div className="flex-1 space-y-1">
<Label htmlFor="new-cf-name" className="text-xs">Name</Label>
<Input
id="new-cf-name"
placeholder="Field name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
/>
</div>
<div className="w-32 space-y-1">
<Label htmlFor="new-cf-type" className="text-xs">Type</Label>
<Select value={newType} onValueChange={(v) => setNewType(v as FieldType)}>
<SelectTrigger id="new-cf-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FIELD_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-32 space-y-1">
<Label htmlFor="new-cf-scope" className="text-xs">Scope</Label>
<Select value={newScope} onValueChange={(v) => setNewScope(v as FieldScope)}>
<SelectTrigger id="new-cf-scope">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{newType === 'select' && (
<div className="w-48 space-y-1">
<Label htmlFor="new-cf-options" className="text-xs">Options (comma-separated)</Label>
<Input
id="new-cf-options"
placeholder="Option A, Option B"
value={newOptions}
onChange={(e) => setNewOptions(e.target.value)}
/>
</div>
)}
<Button onClick={handleAdd} disabled={!newName.trim()}>
<Plus className="mr-1 h-4 w-4" />
Add
</Button>
</div>
{/* Delete confirmation */}
<AlertDialog open={!!deletingId} onOpenChange={(open) => !open && setDeletingId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove custom field?</AlertDialogTitle>
<AlertDialogDescription>
This removes the field definition from your browser storage. Existing task data is not affected.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deletingId && handleDelete(deletingId)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}
@@ -0,0 +1,353 @@
'use client';
import { useEffect, useState } from 'react';
import { Plus, Trash2, Pencil, Tag } from 'lucide-react';
import { toast } from 'sonner';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
interface Tag {
id: string;
name: string;
color?: string | null;
scope?: string | null;
}
const SCOPE_OPTIONS = ['global', 'tasks', 'habits', 'projects', 'notes'] as const;
export function SettingsTags() {
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Create form
const [newName, setNewName] = useState('');
const [newColor, setNewColor] = useState('#3b82f6');
const [newScope, setNewScope] = useState('global');
const [creating, setCreating] = useState(false);
// Edit dialog
const [editing, setEditing] = useState<Tag | null>(null);
const [editName, setEditName] = useState('');
const [editColor, setEditColor] = useState('#3b82f6');
const [editScope, setEditScope] = useState('global');
const [saving, setSaving] = useState(false);
// Delete confirmation
const [deleting, setDeleting] = useState<Tag | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
fetchTags();
}, []);
useEffect(() => {
if (editing) {
setEditName(editing.name);
setEditColor(editing.color || '#3b82f6');
setEditScope(editing.scope || 'global');
}
}, [editing]);
async function fetchTags() {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/tags?sort=name');
if (!response.ok) throw new Error('Unable to load tags.');
const data = await response.json();
setTags(data.items || []);
} catch (err) {
console.error('Failed to fetch tags:', err);
setError('Unable to load tags. Please try again.');
} finally {
setLoading(false);
}
}
async function handleCreate() {
if (!newName.trim()) return;
setCreating(true);
try {
const response = await fetch('/api/tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newName.trim(),
color: newColor,
scope: newScope,
}),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error?.message || 'Unable to create tag.');
}
toast.success('Tag created');
setNewName('');
setNewColor('#3b82f6');
setNewScope('global');
await fetchTags();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Unable to create tag.');
} finally {
setCreating(false);
}
}
async function handleEditSave() {
if (!editing || !editName.trim()) return;
setSaving(true);
try {
const response = await fetch(`/api/tags/${editing.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: editName.trim(),
color: editColor,
scope: editScope,
}),
});
if (!response.ok) {
const err = await response.json();
throw new Error(err.error?.message || 'Unable to update tag.');
}
toast.success('Tag updated');
setEditing(null);
await fetchTags();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Unable to update tag.');
} finally {
setSaving(false);
}
}
async function handleDelete() {
if (!deleting) return;
setDeletingId(deleting.id);
try {
const response = await fetch(`/api/tags/${deleting.id}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Unable to delete tag.');
toast.success('Tag deleted');
setDeleting(null);
await fetchTags();
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Unable to delete tag.');
} finally {
setDeletingId(null);
}
}
return (
<Card>
<CardHeader>
<CardTitle>Tags</CardTitle>
<CardDescription>Manage tags for organizing tasks, habits, projects, and notes.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<div className="flex items-center justify-between gap-3 text-sm text-destructive" role="alert">
<span>{error}</span>
<Button variant="outline" size="sm" onClick={fetchTags} disabled={loading}>
Retry
</Button>
</div>
)}
{/* Tag list */}
<div className="space-y-2">
{loading ? (
<p className="text-sm text-muted-foreground">Loading tags...</p>
) : tags.length === 0 ? (
<p className="text-sm text-muted-foreground">No tags yet. Create one below.</p>
) : (
tags.map((tag) => (
<div key={tag.id} className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-3">
<div
className="h-4 w-4 rounded-full"
style={{ backgroundColor: tag.color || '#6b7280' }}
/>
<span className="font-medium">{tag.name}</span>
{tag.scope && (
<Badge variant="secondary" className="text-xs capitalize">
{tag.scope}
</Badge>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => setEditing(tag)}
aria-label={`Edit tag: ${tag.name}`}
>
<Pencil className="h-4 w-4" aria-hidden="true" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleting(tag)}
aria-label={`Delete tag: ${tag.name}`}
disabled={deletingId === tag.id}
>
<Trash2 className="h-4 w-4 text-destructive" aria-hidden="true" />
</Button>
</div>
</div>
))
)}
</div>
{/* Create form */}
<div className="flex flex-wrap items-end gap-2 rounded-lg border p-3">
<div className="flex-1 space-y-1">
<Label htmlFor="new-tag-name" className="text-xs">Name</Label>
<Input
id="new-tag-name"
placeholder="Tag name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
disabled={creating}
/>
</div>
<div className="space-y-1">
<Label htmlFor="new-tag-color" className="text-xs">Color</Label>
<input
id="new-tag-color"
type="color"
value={newColor}
onChange={(e) => setNewColor(e.target.value)}
className="h-10 w-10 cursor-pointer rounded border"
disabled={creating}
/>
</div>
<div className="w-32 space-y-1">
<Label htmlFor="new-tag-scope" className="text-xs">Scope</Label>
<Select value={newScope} onValueChange={setNewScope} disabled={creating}>
<SelectTrigger id="new-tag-scope">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((s) => (
<SelectItem key={s} value={s} className="capitalize">
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={handleCreate} disabled={creating || !newName.trim()}>
<Plus className="mr-1 h-4 w-4" />
{creating ? 'Adding...' : 'Add'}
</Button>
</div>
{/* Edit Dialog */}
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit tag</DialogTitle>
<DialogDescription>Update the tag name, color, and scope.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-tag-name">Name</Label>
<Input
id="edit-tag-name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
autoFocus
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-tag-color">Color</Label>
<input
id="edit-tag-color"
type="color"
value={editColor}
onChange={(e) => setEditColor(e.target.value)}
className="h-10 w-10 cursor-pointer rounded border"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-tag-scope">Scope</Label>
<Select value={editScope} onValueChange={setEditScope}>
<SelectTrigger id="edit-tag-scope">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SCOPE_OPTIONS.map((s) => (
<SelectItem key={s} value={s} className="capitalize">
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditing(null)}>
Cancel
</Button>
<Button onClick={handleEditSave} disabled={saving || !editName.trim()}>
{saving ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete confirmation */}
<AlertDialog open={!!deleting} onOpenChange={(open) => !open && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete &ldquo;{deleting?.name}&rdquo;?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove this tag. It will be removed from all associated items.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={!!deletingId}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={!!deletingId}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deletingId ? 'Deleting...' : 'Delete tag'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
);
}
@@ -309,10 +309,18 @@ export function SettingsWebhooks() {
const response = await fetch(`/api/webhooks/${id}/test`, { method: 'POST' });
const data = await response.json();
const statusCode = data.status || 0;
const responseBody = data.response || '';
const preview = responseBody.substring(0, 200);
if (data.success) {
toast.success(`Test delivery succeeded (${data.status})`);
toast.success(`Test delivery succeeded (${statusCode})`, {
description: preview || undefined,
});
} else {
toast.error(`Test delivery failed: ${data.response || 'Unknown error'}`);
toast.error(`Test delivery failed (${statusCode})`, {
description: preview || undefined,
});
}
} catch (error) {
toast.error('Failed to send test event');
@@ -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<HTMLFormElement>) {
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({
</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>}
<DialogFooter>
@@ -47,6 +47,7 @@ interface TaskDetail {
estimatedMinutes?: number | null;
trackedMinutes?: number | null;
order: number;
recurrenceRule?: string | null;
customFields?: Record<string, unknown> | 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,9 @@ 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 [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({});
const [saving, setSaving] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
@@ -100,6 +112,26 @@ 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('');
}
// Custom fields
setCustomFieldValues(data.customFields || {});
})
.catch((err) => {
console.error('Failed to load task:', err);
@@ -108,6 +140,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 +162,16 @@ 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;
}
// Include custom fields if changed
const currentCustomFields = task.customFields || {};
if (JSON.stringify(customFieldValues) !== JSON.stringify(currentCustomFields)) {
body.customFields = customFieldValues;
}
const response = await fetch(`/api/domains/${domainId}/tasks/${task.id}`, {
method: 'PATCH',
@@ -262,6 +310,109 @@ export function TaskDetailPanel({
</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>
{/* Custom fields */}
{(() => {
const schemas = (() => {
try {
if (typeof window === 'undefined') return [];
const raw = localStorage.getItem('pe_custom_field_schemas');
return raw ? JSON.parse(raw) : [];
} catch { return []; }
})();
const taskSchemas = schemas.filter((s: any) => s.scope === 'task');
if (taskSchemas.length === 0) return null;
return (
<div className="space-y-3">
<Label>Custom fields</Label>
{taskSchemas.map((field: any) => (
<div key={field.id} className="space-y-1">
<Label className="text-xs text-muted-foreground">{field.name}</Label>
{field.type === 'checkbox' ? (
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={!!customFieldValues[field.name]}
onChange={(e) =>
setCustomFieldValues((prev) => ({
...prev,
[field.name]: e.target.checked,
}))
}
className="h-4 w-4 rounded border-gray-300"
/>
<span className="text-sm">{field.name}</span>
</div>
) : field.type === 'select' ? (
<Select
value={(customFieldValues[field.name] as string) || ''}
onValueChange={(v) =>
setCustomFieldValues((prev) => ({ ...prev, [field.name]: v }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{(field.options || []).map((opt: string) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
) : field.type === 'number' ? (
<Input
type="number"
value={(customFieldValues[field.name] as string) || ''}
onChange={(e) =>
setCustomFieldValues((prev) => ({
...prev,
[field.name]: e.target.value ? Number(e.target.value) : '',
}))
}
placeholder={`Enter ${field.name}...`}
/>
) : (
<Input
value={(customFieldValues[field.name] as string) || ''}
onChange={(e) =>
setCustomFieldValues((prev) => ({ ...prev, [field.name]: e.target.value }))
}
placeholder={`Enter ${field.name}...`}
/>
)}
</div>
))}
</div>
);
})()}
{/* Tags */}
{task.tags && task.tags.length > 0 && (
<div className="space-y-2">