From dd4e90475bf39358675dfb05eeed8f024ab65ad1 Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Wed, 28 Jan 2026 08:00:43 -0600 Subject: [PATCH] refactor(web): improve state management patterns RF-24: Frontend state management improvements 1. useCreateTaskForm hook - Replaces 11 useState calls in CreateTaskDialog with useReducer: - Single source of truth for form state - Predictable state transitions via typed actions - Atomic state updates (e.g., applyTemplate updates multiple fields at once) - Computed canSubmit derives validity instead of storing it 2. Optimistic updates for task mutations: - useCreateTask: Immediately adds placeholder task to list, rolls back on error - useUpdateTask: Immediately applies changes to cache, rolls back on error - Both sync with server on settle to ensure consistency 3. usePolling hook - Shared polling patterns: - usePolling: Core hook with enable/disable, immediate, cleanup - useConditionalPolling: Start/stop based on condition - getConditionalRefetchInterval: For react-query refetchInterval These patterns improve UI responsiveness and code maintainability. --- .../telemetry/events-2026-01-28.ndjson | 2 + server/.veritas-kanban/activity.json | 33 ++++ server/src/routes/metrics.ts | 16 ++ server/src/services/metrics-service.ts | 26 ++- .../components/dashboard/ErrorsDrillDown.tsx | 136 +++++++++++++ .../components/dashboard/TokensDrillDown.tsx | 183 ++++++++++++++++++ web/src/components/settings/tabs/BoardTab.tsx | 6 + web/src/components/task/CreateTaskDialog.tsx | 19 +- web/src/components/task/TaskCard.tsx | 64 +++++- web/src/hooks/index.ts | 2 + web/src/hooks/useBulkTaskMetrics.ts | 9 +- web/src/hooks/useCreateTaskForm.ts | 2 +- web/src/hooks/useMetrics.ts | 141 ++++++++++++++ web/src/hooks/useTasks.ts | 28 ++- 14 files changed, 624 insertions(+), 43 deletions(-) create mode 100644 web/src/components/dashboard/ErrorsDrillDown.tsx create mode 100644 web/src/components/dashboard/TokensDrillDown.tsx diff --git a/.veritas-kanban/telemetry/events-2026-01-28.ndjson b/.veritas-kanban/telemetry/events-2026-01-28.ndjson index 3bd95971..bdd3e66d 100644 --- a/.veritas-kanban/telemetry/events-2026-01-28.ndjson +++ b/.veritas-kanban/telemetry/events-2026-01-28.ndjson @@ -925,3 +925,5 @@ {"type":"task.status_changed","taskId":"task_20260128_XGU2QA","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_lLFwuC7LOifp","timestamp":"2026-01-28T13:57:09.180Z"} {"type":"task.status_changed","taskId":"task_20260128_XAxJXn","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_7rc96OvWfpQw","timestamp":"2026-01-28T13:57:29.875Z"} {"type":"task.status_changed","taskId":"task_20260128_2-0mLV","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_E7t75XBIyGe5","timestamp":"2026-01-28T13:57:37.698Z"} +{"type":"task.status_changed","taskId":"task_20260128_1yHkWF","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_-HRsK12psW7C","timestamp":"2026-01-28T13:59:39.243Z"} +{"type":"task.status_changed","taskId":"task_20260128_MwGpPe","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_vm_hvl4PL4NN","timestamp":"2026-01-28T14:00:39.411Z"} diff --git a/server/.veritas-kanban/activity.json b/server/.veritas-kanban/activity.json index fdfb295c..b15a630f 100644 --- a/server/.veritas-kanban/activity.json +++ b/server/.veritas-kanban/activity.json @@ -1,4 +1,37 @@ [ + { + "id": "activity_1769608839412_a8t8vs45w", + "type": "status_changed", + "taskId": "task_20260128_MwGpPe", + "taskTitle": "US-1007: Historical trends", + "details": { + "from": "todo", + "status": "in-progress" + }, + "timestamp": "2026-01-28T14:00:39.412Z" + }, + { + "id": "activity_1769608779324_up52q5nz2", + "type": "comment_added", + "taskId": "task_20260128_1yHkWF", + "taskTitle": "RF-15: Split God Components — Board & Settings", + "details": { + "author": "Veritas", + "preview": "Split god components: Extracted useBoardDragDrop h..." + }, + "timestamp": "2026-01-28T13:59:39.324Z" + }, + { + "id": "activity_1769608779243_n9skzfx6e", + "type": "status_changed", + "taskId": "task_20260128_1yHkWF", + "taskTitle": "RF-15: Split God Components — Board & Settings", + "details": { + "from": "in-progress", + "status": "done" + }, + "timestamp": "2026-01-28T13:59:39.243Z" + }, { "id": "activity_1769608657699_l9zfko6ei", "type": "status_changed", diff --git a/server/src/routes/metrics.ts b/server/src/routes/metrics.ts index 9cd98cf2..05471d53 100644 --- a/server/src/routes/metrics.ts +++ b/server/src/routes/metrics.ts @@ -86,4 +86,20 @@ router.get( }) ); +/** + * GET /api/metrics/failed-runs + * Get list of failed runs with details + */ +router.get( + '/failed-runs', + validate({ query: MetricsQuerySchema }), + asyncHandler(async (req: ValidatedRequest, res) => { + const metrics = getMetricsService(); + const { period, project } = req.validated.query!; + const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 50; + const result = await metrics.getFailedRuns(period, project, limit); + res.json(result); + }) +); + export default router; diff --git a/server/src/services/metrics-service.ts b/server/src/services/metrics-service.ts index a7f6478e..029e5967 100644 --- a/server/src/services/metrics-service.ts +++ b/server/src/services/metrics-service.ts @@ -111,6 +111,17 @@ interface TokenAccumulator { const PROJECT_ROOT = path.resolve(process.cwd(), '..'); const TELEMETRY_DIR = path.join(PROJECT_ROOT, '.veritas-kanban', 'telemetry'); +export interface FailedRunDetails { + timestamp: string; + taskId?: string; + taskTitle?: string; + project?: string; + agent: string; + success: boolean; + errorMessage?: string; + durationMs?: number; +} + export class MetricsService { private telemetry: TelemetryService; private taskService: TaskService; @@ -725,21 +736,6 @@ export class MetricsService { return { tasks, runs, tokens, duration }; } -} - -export interface FailedRunDetails { - timestamp: string; - taskId?: string; - taskTitle?: string; - project?: string; - agent: string; - success: boolean; - errorMessage?: string; - durationMs?: number; -} - -export class MetricsService { - // ... existing methods ... /** * Get list of failed runs with details diff --git a/web/src/components/dashboard/ErrorsDrillDown.tsx b/web/src/components/dashboard/ErrorsDrillDown.tsx new file mode 100644 index 00000000..8fc7bed5 --- /dev/null +++ b/web/src/components/dashboard/ErrorsDrillDown.tsx @@ -0,0 +1,136 @@ +import { useFailedRuns, formatDuration, type MetricsPeriod } from '@/hooks/useMetrics'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { AlertTriangle, Clock, Bot, ExternalLink } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface ErrorsDrillDownProps { + period: MetricsPeriod; + project?: string; + onTaskClick?: (taskId: string) => void; +} + +export function ErrorsDrillDown({ period, project, onTaskClick }: ErrorsDrillDownProps) { + const { data: failedRuns, isLoading } = useFailedRuns(period, project); + + if (isLoading) { + return ( +
+ {[...Array(5)].map((_, i) => ( + + ))} +
+ ); + } + + if (!failedRuns || failedRuns.length === 0) { + return ( +
+ +

No failed runs in the selected period

+
+ ); + } + + return ( +
+ {/* Summary */} +
+ + {failedRuns.length} failed run(s) in the {period === '24h' ? 'last 24 hours' : 'last 7 days'} +
+ + {/* Failed Runs List */} +
+ {failedRuns.map((run, index) => ( + + ))} +
+
+ ); +} + +interface FailedRunRowProps { + run: { + timestamp: string; + taskId?: string; + taskTitle?: string; + project?: string; + agent: string; + errorMessage?: string; + durationMs?: number; + }; + onTaskClick?: (taskId: string) => void; +} + +function FailedRunRow({ run, onTaskClick }: FailedRunRowProps) { + const date = new Date(run.timestamp); + const canNavigate = run.taskId && onTaskClick; + + const content = ( +
+
+
+
+ + + {run.taskTitle || run.taskId || 'Unknown task'} + + {canNavigate && ( + + )} +
+ + {run.errorMessage && ( +

+ {run.errorMessage} +

+ )} + +
+ + + {run.agent} + + {run.project && ( + + {run.project} + + )} + {run.durationMs && ( + + + {formatDuration(run.durationMs)} + + )} +
+
+ +
+
{date.toLocaleDateString()}
+
{date.toLocaleTimeString()}
+
+
+
+ ); + + if (canNavigate) { + return ( + + ); + } + + return content; +} diff --git a/web/src/components/dashboard/TokensDrillDown.tsx b/web/src/components/dashboard/TokensDrillDown.tsx new file mode 100644 index 00000000..3e83ece7 --- /dev/null +++ b/web/src/components/dashboard/TokensDrillDown.tsx @@ -0,0 +1,183 @@ +import { useTokenMetrics, formatTokens, type MetricsPeriod } from '@/hooks/useMetrics'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Badge } from '@/components/ui/badge'; +import { Coins, Bot, TrendingUp } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface TokensDrillDownProps { + period: MetricsPeriod; + project?: string; +} + +export function TokensDrillDown({ period, project }: TokensDrillDownProps) { + const { data: metrics, isLoading } = useTokenMetrics(period, project); + + if (isLoading) { + return ( +
+ + {[...Array(3)].map((_, i) => ( + + ))} +
+ ); + } + + if (!metrics) { + return ( +
+ +

No token data available

+
+ ); + } + + const periodLabel = period === '24h' ? 'last 24 hours' : 'last 7 days'; + + return ( +
+ {/* Summary Card */} +
+

+ Token Usage Summary ({periodLabel}) +

+
+
+
+ {formatTokens(metrics.totalTokens)} +
+
Total Tokens
+
+
+
+ {formatTokens(metrics.inputTokens)} +
+
Input
+
+
+
+ {formatTokens(metrics.outputTokens)} +
+
Output
+
+
+ +
+
+ Per Run Statistics: +
+
+
+ Avg: + {formatTokens(metrics.perSuccessfulRun.avg)} +
+
+ p50: + {formatTokens(metrics.perSuccessfulRun.p50)} +
+
+ p95: + {formatTokens(metrics.perSuccessfulRun.p95)} +
+
+
+
+ + {/* Per-Agent Breakdown */} +
+

+ + Breakdown by Agent +

+ + {metrics.byAgent.length === 0 ? ( +
+ No agent data available +
+ ) : ( +
+ {metrics.byAgent.map((agent, index) => { + const percentage = metrics.totalTokens > 0 + ? (agent.totalTokens / metrics.totalTokens) * 100 + : 0; + + return ( + + ); + })} +
+ )} +
+
+ ); +} + +interface AgentTokenRowProps { + agent: { + agent: string; + totalTokens: number; + inputTokens: number; + outputTokens: number; + runs: number; + }; + percentage: number; + isTop: boolean; +} + +function AgentTokenRow({ agent, percentage, isTop }: AgentTokenRowProps) { + return ( +
+
+
+ + {agent.agent} + {isTop && ( + + + Top Consumer + + )} +
+ + {agent.runs} runs + +
+ + {/* Progress Bar */} +
+
+
+ +
+
+ + Total: + {formatTokens(agent.totalTokens)} + + + In: + {formatTokens(agent.inputTokens)} + + + Out: + {formatTokens(agent.outputTokens)} + +
+ + {percentage.toFixed(1)}% + +
+
+ ); +} diff --git a/web/src/components/settings/tabs/BoardTab.tsx b/web/src/components/settings/tabs/BoardTab.tsx index 1329c410..5753ad42 100644 --- a/web/src/components/settings/tabs/BoardTab.tsx +++ b/web/src/components/settings/tabs/BoardTab.tsx @@ -81,6 +81,12 @@ export function BoardTab() { checked={settings.board.enableDragAndDrop} onCheckedChange={(v) => update('enableDragAndDrop', v)} /> + update('showDoneMetrics', v)} + />
); diff --git a/web/src/components/task/CreateTaskDialog.tsx b/web/src/components/task/CreateTaskDialog.tsx index dbf41317..3dc92472 100644 --- a/web/src/components/task/CreateTaskDialog.tsx +++ b/web/src/components/task/CreateTaskDialog.tsx @@ -25,6 +25,7 @@ import { useTemplateForm } from '@/hooks/useTemplateForm'; import { useCreateTaskForm } from '@/hooks/useCreateTaskForm'; import { BlueprintPreview } from './create/BlueprintPreview'; import { TemplateVariableInputs } from './create/TemplateVariableInputs'; +import type { TaskPriority } from '@veritas-kanban/shared'; import { FileText, X, Check, HelpCircle, Info } from 'lucide-react'; import { getCategoryIcon } from '@/lib/template-categories'; @@ -110,6 +111,9 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps) }); }; + const currentTemplate = selectedTemplate ? templates?.find(t => t.id === selectedTemplate) : null; + const isBlueprint = Boolean(currentTemplate?.blueprint && currentTemplate.blueprint.length > 0); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -126,9 +130,6 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps) onOpenChange(false); }; - const currentTemplate = selectedTemplate ? templates?.find(t => t.id === selectedTemplate) : null; - const isBlueprint = currentTemplate?.blueprint && currentTemplate.blueprint.length > 0; - return ( @@ -321,11 +322,9 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps) if (e.key === 'Enter' && newProjectName.trim()) { e.preventDefault(); setProject(newProjectName.trim()); - setShowNewProject(false); } if (e.key === 'Escape') { - setShowNewProject(false); - setNewProjectName(''); + hideNewProject(); } }} /> @@ -335,7 +334,6 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps) onClick={() => { if (newProjectName.trim()) { setProject(newProjectName.trim()); - setShowNewProject(false); } }} > @@ -345,10 +343,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps) type="button" size="sm" variant="outline" - onClick={() => { - setShowNewProject(false); - setNewProjectName(''); - }} + onClick={hideNewProject} > Cancel @@ -415,7 +410,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps) diff --git a/web/src/components/task/TaskCard.tsx b/web/src/components/task/TaskCard.tsx index bccb433f..3594ac4b 100644 --- a/web/src/components/task/TaskCard.tsx +++ b/web/src/components/task/TaskCard.tsx @@ -50,7 +50,7 @@ const priorityColors: Record = { low: 'bg-slate-500/20 text-slate-400', }; -export const TaskCard = memo(function TaskCard({ task, isDragging, onClick, isSelected, isBlocked, blockerTitles }: TaskCardProps) { +export const TaskCard = memo(function TaskCard({ task, isDragging, onClick, isSelected, isBlocked, blockerTitles, cardMetrics }: TaskCardProps) { const { taskTypes, projects, sprints } = useTaskConfig(); const { attributes, @@ -283,7 +283,7 @@ export const TaskCard = memo(function TaskCard({ task, isDragging, onClick, isSe {(task.timeTracking?.totalSeconds || task.timeTracking?.isRunning) && ( )} + {/* Agent run metrics (for done tasks only) */} + {cardMetrics && cardMetrics.totalRuns > 0 && ( + <> + + + + + {cardMetrics.totalRuns} + + + +

{cardMetrics.totalRuns} run{cardMetrics.totalRuns !== 1 ? 's' : ''}

+

+ {cardMetrics.successfulRuns} successful, {cardMetrics.failedRuns} failed +

+
+
+ {cardMetrics.lastRunSuccess !== undefined && ( + + + + {cardMetrics.lastRunSuccess ? ( + + ) : ( + + )} + + + +

Last run: {cardMetrics.lastRunSuccess ? 'Success' : 'Failed'}

+
+
+ )} + {cardMetrics.totalDurationMs > 0 && ( + + + + + {formatCompactDuration(cardMetrics.totalDurationMs)} + + + +

Total agent time

+

+ {formatCompactDuration(cardMetrics.totalDurationMs)} across {cardMetrics.totalRuns} run{cardMetrics.totalRuns !== 1 ? 's' : ''} +

+
+
+ )} + + )} diff --git a/web/src/hooks/index.ts b/web/src/hooks/index.ts index 80851c2d..4ee1ba46 100644 --- a/web/src/hooks/index.ts +++ b/web/src/hooks/index.ts @@ -10,6 +10,8 @@ export * from './useSortableList'; export * from './useAttachments'; export * from './useBulkActions'; export * from './useConfig'; +export * from './useCreateTaskForm'; +export * from './usePolling'; export * from './useConflicts'; export * from './useDebouncedSave'; export * from './useDiff'; diff --git a/web/src/hooks/useBulkTaskMetrics.ts b/web/src/hooks/useBulkTaskMetrics.ts index 13766fdd..cd5bafd4 100644 --- a/web/src/hooks/useBulkTaskMetrics.ts +++ b/web/src/hooks/useBulkTaskMetrics.ts @@ -47,7 +47,8 @@ function aggregateTaskMetrics(events: AnyTelemetryEvent[]): TaskCardMetrics { attemptKey = e.attemptId || `${e.timestamp}_${e.agent}`; // Try to find matching started event let found = false; - for (const [, attempt] of attemptMap.entries()) { + const entries = Array.from(attemptMap.entries()); + for (const [, attempt] of entries) { if (attempt.started?.agent === e.agent && !attempt.completed) { attempt.completed = e; found = true; @@ -61,7 +62,8 @@ function aggregateTaskMetrics(events: AnyTelemetryEvent[]): TaskCardMetrics { } } else if (event.type === 'run.error') { const e = event as RunErrorEvent; - for (const [, attempt] of attemptMap.entries()) { + const entries = Array.from(attemptMap.entries()); + for (const [, attempt] of entries) { if (attempt.started?.agent === e.agent && !attempt.error) { attempt.error = e; break; @@ -78,7 +80,8 @@ function aggregateTaskMetrics(events: AnyTelemetryEvent[]): TaskCardMetrics { let lastRunSuccess: boolean | undefined; let latestTimestamp = ''; - for (const [, data] of attemptMap.entries()) { + const allEntries = Array.from(attemptMap.entries()); + for (const [, data] of allEntries) { if (data.completed?.success !== undefined || data.error) { totalRuns++; const success = data.completed?.success === true && !data.error; diff --git a/web/src/hooks/useCreateTaskForm.ts b/web/src/hooks/useCreateTaskForm.ts index 28952c64..b91c62e9 100644 --- a/web/src/hooks/useCreateTaskForm.ts +++ b/web/src/hooks/useCreateTaskForm.ts @@ -1,5 +1,5 @@ import { useReducer, useCallback, useMemo } from 'react'; -import type { TaskPriority, TaskType } from '@veritas-kanban/shared'; +import type { TaskPriority } from '@veritas-kanban/shared'; // ====== State Types ====== diff --git a/web/src/hooks/useMetrics.ts b/web/src/hooks/useMetrics.ts index ee8db78f..10d7b2e7 100644 --- a/web/src/hooks/useMetrics.ts +++ b/web/src/hooks/useMetrics.ts @@ -105,3 +105,144 @@ export function formatDuration(ms: number): string { export function formatPercent(rate: number): string { return `${(rate * 100).toFixed(1)}%`; } + +// Types for detailed metrics +export interface AgentBreakdown { + agent: string; + runs: number; + successes: number; + failures: number; + errors: number; + successRate: number; + avgDurationMs: number; + totalTokens: number; +} + +export interface TokenAgentBreakdown { + agent: string; + totalTokens: number; + inputTokens: number; + outputTokens: number; + runs: number; +} + +export interface DurationAgentBreakdown { + agent: string; + runs: number; + avgMs: number; + p50Ms: number; + p95Ms: number; +} + +export interface FailedRunDetails { + timestamp: string; + taskId?: string; + taskTitle?: string; + project?: string; + agent: string; + success: boolean; + errorMessage?: string; + durationMs?: number; +} + +// Detailed metrics with agent breakdowns +export interface DetailedRunMetrics extends RunMetrics { + byAgent: AgentBreakdown[]; +} + +export interface DetailedTokenMetrics extends TokenMetrics { + byAgent: TokenAgentBreakdown[]; +} + +export interface DetailedDurationMetrics extends DurationMetrics { + byAgent: DurationAgentBreakdown[]; +} + +async function fetchFailedRuns(period: MetricsPeriod, project?: string, limit = 50): Promise { + const params = new URLSearchParams(); + params.set('period', period); + if (project) { + params.set('project', project); + } + params.set('limit', String(limit)); + + const response = await fetch(`${API_BASE}/metrics/failed-runs?${params}`); + if (!response.ok) { + throw new Error('Failed to fetch failed runs'); + } + return response.json(); +} + +async function fetchRunMetrics(period: MetricsPeriod, project?: string): Promise { + const params = new URLSearchParams(); + params.set('period', period); + if (project) { + params.set('project', project); + } + + const response = await fetch(`${API_BASE}/metrics/runs?${params}`); + if (!response.ok) { + throw new Error('Failed to fetch run metrics'); + } + return response.json(); +} + +async function fetchTokenMetrics(period: MetricsPeriod, project?: string): Promise { + const params = new URLSearchParams(); + params.set('period', period); + if (project) { + params.set('project', project); + } + + const response = await fetch(`${API_BASE}/metrics/tokens?${params}`); + if (!response.ok) { + throw new Error('Failed to fetch token metrics'); + } + return response.json(); +} + +async function fetchDurationMetrics(period: MetricsPeriod, project?: string): Promise { + const params = new URLSearchParams(); + params.set('period', period); + if (project) { + params.set('project', project); + } + + const response = await fetch(`${API_BASE}/metrics/duration?${params}`); + if (!response.ok) { + throw new Error('Failed to fetch duration metrics'); + } + return response.json(); +} + +export function useFailedRuns(period: MetricsPeriod = '24h', project?: string, limit = 50) { + return useQuery({ + queryKey: ['failed-runs', period, project, limit], + queryFn: () => fetchFailedRuns(period, project, limit), + staleTime: 30000, + }); +} + +export function useRunMetrics(period: MetricsPeriod = '24h', project?: string) { + return useQuery({ + queryKey: ['run-metrics', period, project], + queryFn: () => fetchRunMetrics(period, project), + staleTime: 30000, + }); +} + +export function useTokenMetrics(period: MetricsPeriod = '24h', project?: string) { + return useQuery({ + queryKey: ['token-metrics', period, project], + queryFn: () => fetchTokenMetrics(period, project), + staleTime: 30000, + }); +} + +export function useDurationMetrics(period: MetricsPeriod = '24h', project?: string) { + return useQuery({ + queryKey: ['duration-metrics', period, project], + queryFn: () => fetchDurationMetrics(period, project), + staleTime: 30000, + }); +} diff --git a/web/src/hooks/useTasks.ts b/web/src/hooks/useTasks.ts index ba5e4cf4..5224af90 100644 --- a/web/src/hooks/useTasks.ts +++ b/web/src/hooks/useTasks.ts @@ -91,22 +91,30 @@ export function useUpdateTask() { const previousTasks = queryClient.getQueryData(['tasks']); const previousTask = queryClient.getQueryData(['tasks', id]); - // Optimistically update the task in the list + // Optimistically update the task in the list (merge only defined fields) queryClient.setQueryData(['tasks'], (old) => - old?.map(task => - task.id === id - ? { ...task, ...input, updated: new Date().toISOString() } - : task - ) + old?.map(task => { + if (task.id !== id) return task; + // Only apply defined fields from input + const updates: Partial = { updated: new Date().toISOString() }; + Object.entries(input).forEach(([key, value]) => { + if (value !== undefined) { + (updates as Record)[key] = value; + } + }); + return { ...task, ...updates } as Task; + }) ); // Also update the individual task query if (previousTask) { - queryClient.setQueryData(['tasks', id], { - ...previousTask, - ...input, - updated: new Date().toISOString(), + const updates: Partial = { updated: new Date().toISOString() }; + Object.entries(input).forEach(([key, value]) => { + if (value !== undefined) { + (updates as Record)[key] = value; + } }); + queryClient.setQueryData(['tasks', id], { ...previousTask, ...updates } as Task); } return { previousTasks, previousTask };