From 814f2191e868bf78240730a2b520e2c0cf4e9011 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 10 May 2026 12:50:16 +0200 Subject: [PATCH] feat(PBI-78): cost-analysis UI component (T-903) - New app/(app)/insights/components/cost-analysis.tsx - Period selector (7d/30d/90d/MTD) via URL ?period= with useTransition + router.replace - KPI strip: total cost, avg per day, cache savings, top model - 2x2 chart grid: daily cost (BarChart), per-model + per-kind (vertical BarCharts), cache efficiency (PieChart) - Empty state for kpi.jobCount === 0 - Uses MD3 tokens (var(--chart-N), var(--status-done)) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../insights/components/cost-analysis.tsx | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 app/(app)/insights/components/cost-analysis.tsx diff --git a/app/(app)/insights/components/cost-analysis.tsx b/app/(app)/insights/components/cost-analysis.tsx new file mode 100644 index 0000000..f147ae3 --- /dev/null +++ b/app/(app)/insights/components/cost-analysis.tsx @@ -0,0 +1,272 @@ +'use client' + +import { useTransition } from 'react' +import { useRouter, usePathname, useSearchParams } from 'next/navigation' +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + PieChart, + Pie, + Cell, + ResponsiveContainer, +} from 'recharts' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import type { + Period, + CostKpi, + CostByDayRow, + CostByModelRow, + CostByKindRow, + CacheEfficiency, +} from '@/lib/insights/cost-analysis' + +interface Props { + period: Period + kpi: CostKpi + byDay: CostByDayRow[] + byModel: CostByModelRow[] + byKind: CostByKindRow[] + cache: CacheEfficiency +} + +const PERIOD_LABELS: Record = { + '7d': 'Laatste 7 dagen', + '30d': 'Laatste 30 dagen', + '90d': 'Laatste 90 dagen', + mtd: 'Deze maand', +} + +const KIND_LABELS: Record = { + TASK_IMPLEMENTATION: 'Task impl.', + IDEA_GRILL: 'Idea grill', + IDEA_MAKE_PLAN: 'Idea plan', + PLAN_CHAT: 'Plan chat', + SPRINT_IMPLEMENTATION: 'Sprint impl.', +} + +function fmtUsd(n: number, decimals = 2): string { + return '$' + n.toFixed(decimals) +} + +function shortenModel(modelId: string): string { + return modelId.replace(/^claude-/, '') +} + +export function CostAnalysisCard({ period, kpi, byDay, byModel, byKind, cache }: Props) { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + const [isPending, startTransition] = useTransition() + + function handlePeriodChange(value: string | null) { + if (value === null) return + startTransition(() => { + const params = new URLSearchParams(searchParams.toString()) + params.set('period', value) + router.replace(`${pathname}?${params.toString()}`) + }) + } + + const periodSelector = ( + + ) + + if (kpi.jobCount === 0) { + return ( +
+
+

Geen jobs in deze periode.

+ {periodSelector} +
+
+ ) + } + + const cacheData = [ + { name: 'Cache', value: cache.cacheReadTokens }, + { name: 'Uncached input', value: cache.uncachedInputTokens }, + ] + const cacheColors = ['var(--status-done)', 'var(--muted-foreground)'] + + const modelData = byModel.map(m => ({ ...m, label: shortenModel(m.modelId) })) + const kindData = byKind.map(k => ({ ...k, label: KIND_LABELS[k.kind] ?? k.kind })) + + return ( +
+ {/* KPI strip + period selector */} +
+
+
+
{fmtUsd(kpi.totalCostUsd)}
+
Totaal kosten
+
+
+
{fmtUsd(kpi.avgPerDayUsd)}
+
Gem. per dag
+
+
+
+ {fmtUsd(kpi.cacheSavingsUsd)} +
+
Cache-besparing
+
+
+
+ {kpi.topModelId ? fmtUsd(kpi.topModelCostUsd) : '—'} +
+
+ Top model{kpi.topModelId ? `: ${shortenModel(kpi.topModelId)}` : ''} +
+
+
+ {periodSelector} +
+ +
+ {/* Daily cost */} +
+
Kosten per dag
+ {byDay.length === 0 ? ( +

Geen data

+ ) : ( + + + (v as string).slice(5)} + /> + fmtUsd(v as number, 2)} + /> + [fmtUsd(Number(value), 4), 'Kosten']} + /> + + + + )} +
+ + {/* Per model */} +
+
Kosten per model
+ {modelData.length === 0 ? ( +

Geen data

+ ) : ( + + + fmtUsd(v as number, 2)} + /> + + [fmtUsd(Number(value), 4), 'Kosten']} /> + + + + )} +
+ + {/* Per kind */} +
+
Kosten per job-kind
+ {kindData.length === 0 ? ( +

Geen data

+ ) : ( + + + fmtUsd(v as number, 2)} + /> + + [fmtUsd(Number(value), 4), 'Kosten']} /> + + + + )} +
+ + {/* Cache efficiency */} +
+
Cache efficiency
+ {cache.cacheReadTokens + cache.uncachedInputTokens === 0 ? ( +

Geen data

+ ) : ( + <> + + + + {cacheData.map((_, i) => ( + + ))} + + [ + Number(value).toLocaleString() + ' tokens', + String(name), + ]} + /> + + +

+ {(cache.cacheHitRatio * 100).toFixed(1)}%{' '} + cache hit ·{' '} + + {fmtUsd(cache.savingsUsd)} + {' '} + bespaard +

+ + )} +
+
+
+ ) +}