From c704916059abf1a228dec9eb34cdcac8db580d28 Mon Sep 17 00:00:00 2001 From: bot-hermes Date: Wed, 29 Jul 2026 19:26:44 +0000 Subject: [PATCH] feat(settings): add Custom Fields tab with localStorage schema + task detail panel rendering --- .../settings/settings-custom-fields.tsx | 254 ++++++++++++++++++ .../components/tasks/task-detail-panel.tsx | 87 ++++++ 2 files changed, 341 insertions(+) create mode 100644 apps/web/components/settings/settings-custom-fields.tsx diff --git a/apps/web/components/settings/settings-custom-fields.tsx b/apps/web/components/settings/settings-custom-fields.tsx new file mode 100644 index 0000000..52a8781 --- /dev/null +++ b/apps/web/components/settings/settings-custom-fields.tsx @@ -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([]); + const [filterScope, setFilterScope] = useState('all'); + + // Create form + const [newName, setNewName] = useState(''); + const [newType, setNewType] = useState('text'); + const [newScope, setNewScope] = useState('task'); + const [newOptions, setNewOptions] = useState(''); + + // Delete + const [deletingId, setDeletingId] = useState(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 ( + + + Custom Fields + + Define custom field schemas for tasks, habits, projects, and notes. Stored locally in your browser. + + + + {/* Scope filter */} +
+ + +
+ + {/* Schema list */} +
+ {filteredSchemas.length === 0 ? ( +

+ {schemas.length === 0 + ? 'No custom fields defined yet. Add one below.' + : 'No custom fields match the selected scope.'} +

+ ) : ( + filteredSchemas.map((field) => ( +
+
+
+ +
+ )) + )} +
+ + {/* Add form */} +
+
+ + setNewName(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAdd()} + /> +
+
+ + +
+
+ + +
+ {newType === 'select' && ( +
+ + setNewOptions(e.target.value)} + /> +
+ )} + +
+ + {/* Delete confirmation */} + !open && setDeletingId(null)}> + + + Remove custom field? + + This removes the field definition from your browser storage. Existing task data is not affected. + + + + Cancel + deletingId && handleDelete(deletingId)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Remove + + + + +
+
+ ); +} diff --git a/apps/web/components/tasks/task-detail-panel.tsx b/apps/web/components/tasks/task-detail-panel.tsx index 3cbccc9..dfc0bc7 100644 --- a/apps/web/components/tasks/task-detail-panel.tsx +++ b/apps/web/components/tasks/task-detail-panel.tsx @@ -90,6 +90,7 @@ export function TaskDetailPanel({ const [estimatedMinutes, setEstimatedMinutes] = useState(''); const [recurrenceType, setRecurrenceType] = useState(''); const [customRrule, setCustomRrule] = useState(''); + const [customFieldValues, setCustomFieldValues] = useState>({}); const [saving, setSaving] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [deleting, setDeleting] = useState(false); @@ -128,6 +129,9 @@ export function TaskDetailPanel({ setRecurrenceType(''); setCustomRrule(''); } + + // Custom fields + setCustomFieldValues(data.customFields || {}); }) .catch((err) => { console.error('Failed to load task:', err); @@ -163,6 +167,12 @@ export function TaskDetailPanel({ 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', headers: { 'Content-Type': 'application/json' }, @@ -326,6 +336,83 @@ export function TaskDetailPanel({ )} + {/* 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 ( +
+ + {taskSchemas.map((field: any) => ( +
+ + {field.type === 'checkbox' ? ( +
+ + setCustomFieldValues((prev) => ({ + ...prev, + [field.name]: e.target.checked, + })) + } + className="h-4 w-4 rounded border-gray-300" + /> + {field.name} +
+ ) : field.type === 'select' ? ( + + ) : field.type === 'number' ? ( + + setCustomFieldValues((prev) => ({ + ...prev, + [field.name]: e.target.value ? Number(e.target.value) : '', + })) + } + placeholder={`Enter ${field.name}...`} + /> + ) : ( + + setCustomFieldValues((prev) => ({ ...prev, [field.name]: e.target.value })) + } + placeholder={`Enter ${field.name}...`} + /> + )} +
+ ))} +
+ ); + })()} + {/* Tags */} {task.tags && task.tags.length > 0 && (