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.
This commit is contained in:
Brad Groux 2026-01-28 08:00:43 -06:00
parent a424d0a7c5
commit dd4e90475b
14 changed files with 624 additions and 43 deletions

View file

@ -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"}

View file

@ -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",

View file

@ -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<unknown, MetricsQuery>, 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;

View file

@ -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

View file

@ -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 (
<div className="space-y-4">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
);
}
if (!failedRuns || failedRuns.length === 0) {
return (
<div className="text-center py-8">
<AlertTriangle className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">No failed runs in the selected period</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Summary */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertTriangle className="h-4 w-4 text-red-500" />
<span>{failedRuns.length} failed run(s) in the {period === '24h' ? 'last 24 hours' : 'last 7 days'}</span>
</div>
{/* Failed Runs List */}
<div className="space-y-2">
{failedRuns.map((run, index) => (
<FailedRunRow
key={`${run.timestamp}-${index}`}
run={run}
onTaskClick={onTaskClick}
/>
))}
</div>
</div>
);
}
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 = (
<div className={cn(
'rounded-lg border border-red-500/20 bg-red-500/5 p-3',
canNavigate && 'hover:bg-red-500/10 transition-colors cursor-pointer'
)}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-red-500 flex-shrink-0" />
<span className="font-medium truncate">
{run.taskTitle || run.taskId || 'Unknown task'}
</span>
{canNavigate && (
<ExternalLink className="h-3 w-3 text-muted-foreground" />
)}
</div>
{run.errorMessage && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{run.errorMessage}
</p>
)}
<div className="flex items-center gap-3 mt-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Bot className="h-3 w-3" />
{run.agent}
</span>
{run.project && (
<Badge variant="outline" className="text-xs">
{run.project}
</Badge>
)}
{run.durationMs && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDuration(run.durationMs)}
</span>
)}
</div>
</div>
<div className="text-xs text-muted-foreground text-right flex-shrink-0">
<div>{date.toLocaleDateString()}</div>
<div>{date.toLocaleTimeString()}</div>
</div>
</div>
</div>
);
if (canNavigate) {
return (
<button
onClick={() => onTaskClick(run.taskId!)}
className="w-full text-left focus:outline-none focus:ring-2 focus:ring-ring rounded-lg"
>
{content}
</button>
);
}
return content;
}

View file

@ -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 (
<div className="space-y-4">
<Skeleton className="h-24 w-full" />
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
);
}
if (!metrics) {
return (
<div className="text-center py-8">
<Coins className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground">No token data available</p>
</div>
);
}
const periodLabel = period === '24h' ? 'last 24 hours' : 'last 7 days';
return (
<div className="space-y-6">
{/* Summary Card */}
<div className="rounded-lg border bg-card p-4">
<h4 className="text-sm font-medium text-muted-foreground mb-3">
Token Usage Summary ({periodLabel})
</h4>
<div className="grid grid-cols-3 gap-4">
<div>
<div className="text-2xl font-bold text-primary">
{formatTokens(metrics.totalTokens)}
</div>
<div className="text-xs text-muted-foreground">Total Tokens</div>
</div>
<div>
<div className="text-2xl font-bold text-blue-500">
{formatTokens(metrics.inputTokens)}
</div>
<div className="text-xs text-muted-foreground">Input</div>
</div>
<div>
<div className="text-2xl font-bold text-green-500">
{formatTokens(metrics.outputTokens)}
</div>
<div className="text-xs text-muted-foreground">Output</div>
</div>
</div>
<div className="mt-4 pt-3 border-t">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Per Run Statistics:</span>
</div>
<div className="flex gap-4 mt-1">
<div>
<span className="text-muted-foreground text-xs">Avg: </span>
<span className="font-medium">{formatTokens(metrics.perSuccessfulRun.avg)}</span>
</div>
<div>
<span className="text-muted-foreground text-xs">p50: </span>
<span className="font-medium">{formatTokens(metrics.perSuccessfulRun.p50)}</span>
</div>
<div>
<span className="text-muted-foreground text-xs">p95: </span>
<span className="font-medium">{formatTokens(metrics.perSuccessfulRun.p95)}</span>
</div>
</div>
</div>
</div>
{/* Per-Agent Breakdown */}
<div>
<h4 className="text-sm font-medium text-muted-foreground mb-3 flex items-center gap-2">
<Bot className="h-4 w-4" />
Breakdown by Agent
</h4>
{metrics.byAgent.length === 0 ? (
<div className="text-center py-4 text-muted-foreground">
No agent data available
</div>
) : (
<div className="space-y-2">
{metrics.byAgent.map((agent, index) => {
const percentage = metrics.totalTokens > 0
? (agent.totalTokens / metrics.totalTokens) * 100
: 0;
return (
<AgentTokenRow
key={agent.agent}
agent={agent}
percentage={percentage}
isTop={index === 0}
/>
);
})}
</div>
)}
</div>
</div>
);
}
interface AgentTokenRowProps {
agent: {
agent: string;
totalTokens: number;
inputTokens: number;
outputTokens: number;
runs: number;
};
percentage: number;
isTop: boolean;
}
function AgentTokenRow({ agent, percentage, isTop }: AgentTokenRowProps) {
return (
<div className={cn(
'rounded-lg border p-3',
isTop && 'border-primary/30 bg-primary/5'
)}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Bot className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{agent.agent}</span>
{isTop && (
<Badge variant="secondary" className="text-xs flex items-center gap-1">
<TrendingUp className="h-3 w-3" />
Top Consumer
</Badge>
)}
</div>
<Badge variant="outline" className="text-xs">
{agent.runs} runs
</Badge>
</div>
{/* Progress Bar */}
<div className="h-2 bg-muted rounded-full overflow-hidden mb-2">
<div
className="h-full bg-primary transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
<div className="flex justify-between text-sm">
<div className="flex gap-4">
<span>
<span className="text-muted-foreground">Total: </span>
<span className="font-medium">{formatTokens(agent.totalTokens)}</span>
</span>
<span>
<span className="text-muted-foreground">In: </span>
<span className="text-blue-500">{formatTokens(agent.inputTokens)}</span>
</span>
<span>
<span className="text-muted-foreground">Out: </span>
<span className="text-green-500">{formatTokens(agent.outputTokens)}</span>
</span>
</div>
<span className="text-muted-foreground">
{percentage.toFixed(1)}%
</span>
</div>
</div>
);
}

View file

@ -81,6 +81,12 @@ export function BoardTab() {
checked={settings.board.enableDragAndDrop}
onCheckedChange={(v) => update('enableDragAndDrop', v)}
/>
<ToggleRow
label="Done Column Metrics"
description="Show agent run count, success status, and duration on completed tasks"
checked={settings.board.showDoneMetrics}
onCheckedChange={(v) => update('showDoneMetrics', v)}
/>
</div>
</div>
);

View file

@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
@ -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
</Button>
@ -415,7 +410,7 @@ export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps)
</Button>
<Button
type="submit"
disabled={(isBlueprint ? false : !title.trim()) || isCreating}
disabled={!canSubmit(isBlueprint) || isCreating}
>
{isCreating ? 'Creating...' : isBlueprint ? 'Create Tasks' : 'Create Task'}
</Button>

View file

@ -50,7 +50,7 @@ const priorityColors: Record<TaskPriority, string> = {
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) && (
<span className={cn(
"text-xs px-1.5 py-0.5 rounded flex items-center gap-1",
!subtaskTotal && "ml-auto",
!subtaskTotal && !cardMetrics && "ml-auto",
task.timeTracking?.isRunning
? "bg-green-500/20 text-green-500"
: "bg-muted text-muted-foreground"
@ -296,6 +296,66 @@ export const TaskCard = memo(function TaskCard({ task, isDragging, onClick, isSe
{formatDuration(task.timeTracking?.totalSeconds || 0)}
</span>
)}
{/* Agent run metrics (for done tasks only) */}
{cardMetrics && cardMetrics.totalRuns > 0 && (
<>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn(
"text-xs px-1.5 py-0.5 rounded flex items-center gap-1",
!subtaskTotal && !task.timeTracking?.totalSeconds && "ml-auto",
"bg-muted text-muted-foreground"
)}>
<Play className="h-3 w-3" />
{cardMetrics.totalRuns}
</span>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">{cardMetrics.totalRuns} run{cardMetrics.totalRuns !== 1 ? 's' : ''}</p>
<p className="text-sm text-muted-foreground">
{cardMetrics.successfulRuns} successful, {cardMetrics.failedRuns} failed
</p>
</TooltipContent>
</Tooltip>
{cardMetrics.lastRunSuccess !== undefined && (
<Tooltip>
<TooltipTrigger asChild>
<span className={cn(
"text-xs px-1 py-0.5 rounded flex items-center",
cardMetrics.lastRunSuccess
? "bg-green-500/20 text-green-500"
: "bg-red-500/20 text-red-500"
)}>
{cardMetrics.lastRunSuccess ? (
<CheckCircle className="h-3 w-3" />
) : (
<XCircle className="h-3 w-3" />
)}
</span>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">Last run: {cardMetrics.lastRunSuccess ? 'Success' : 'Failed'}</p>
</TooltipContent>
</Tooltip>
)}
{cardMetrics.totalDurationMs > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs px-1.5 py-0.5 rounded flex items-center gap-1 bg-muted text-muted-foreground">
<Clock className="h-3 w-3" />
{formatCompactDuration(cardMetrics.totalDurationMs)}
</span>
</TooltipTrigger>
<TooltipContent>
<p className="font-medium">Total agent time</p>
<p className="text-sm text-muted-foreground">
{formatCompactDuration(cardMetrics.totalDurationMs)} across {cardMetrics.totalRuns} run{cardMetrics.totalRuns !== 1 ? 's' : ''}
</p>
</TooltipContent>
</Tooltip>
)}
</>
)}
</div>
</div>
</TooltipTrigger>

View file

@ -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';

View file

@ -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;

View file

@ -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 ======

View file

@ -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<FailedRunDetails[]> {
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<DetailedRunMetrics> {
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<DetailedTokenMetrics> {
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<DetailedDurationMetrics> {
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,
});
}

View file

@ -91,22 +91,30 @@ export function useUpdateTask() {
const previousTasks = queryClient.getQueryData<Task[]>(['tasks']);
const previousTask = queryClient.getQueryData<Task>(['tasks', id]);
// Optimistically update the task in the list
// Optimistically update the task in the list (merge only defined fields)
queryClient.setQueryData<Task[]>(['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<Task> = { updated: new Date().toISOString() };
Object.entries(input).forEach(([key, value]) => {
if (value !== undefined) {
(updates as Record<string, unknown>)[key] = value;
}
});
return { ...task, ...updates } as Task;
})
);
// Also update the individual task query
if (previousTask) {
queryClient.setQueryData<Task>(['tasks', id], {
...previousTask,
...input,
updated: new Date().toISOString(),
const updates: Partial<Task> = { updated: new Date().toISOString() };
Object.entries(input).forEach(([key, value]) => {
if (value !== undefined) {
(updates as Record<string, unknown>)[key] = value;
}
});
queryClient.setQueryData<Task>(['tasks', id], { ...previousTask, ...updates } as Task);
}
return { previousTasks, previousTask };