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) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-10 12:50:16 +02:00
parent 5f7d6da53d
commit 814f2191e8

View file

@ -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<Period, string> = {
'7d': 'Laatste 7 dagen',
'30d': 'Laatste 30 dagen',
'90d': 'Laatste 90 dagen',
mtd: 'Deze maand',
}
const KIND_LABELS: Record<string, string> = {
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 = (
<Select value={period} onValueChange={handlePeriodChange}>
<SelectTrigger className="w-44" disabled={isPending}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.keys(PERIOD_LABELS) as Period[]).map(p => (
<SelectItem key={p} value={p}>
{PERIOD_LABELS[p]}
</SelectItem>
))}
</SelectContent>
</Select>
)
if (kpi.jobCount === 0) {
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<p className="text-muted-foreground text-sm">Geen jobs in deze periode.</p>
{periodSelector}
</div>
</div>
)
}
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 (
<div className="space-y-4">
{/* KPI strip + period selector */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="flex gap-6 flex-wrap">
<div>
<div className="text-2xl font-semibold text-foreground">{fmtUsd(kpi.totalCostUsd)}</div>
<div className="text-xs text-muted-foreground">Totaal kosten</div>
</div>
<div>
<div className="text-2xl font-semibold text-foreground">{fmtUsd(kpi.avgPerDayUsd)}</div>
<div className="text-xs text-muted-foreground">Gem. per dag</div>
</div>
<div>
<div className="text-2xl font-semibold text-status-done">
{fmtUsd(kpi.cacheSavingsUsd)}
</div>
<div className="text-xs text-muted-foreground">Cache-besparing</div>
</div>
<div>
<div className="text-2xl font-semibold text-foreground">
{kpi.topModelId ? fmtUsd(kpi.topModelCostUsd) : '—'}
</div>
<div className="text-xs text-muted-foreground">
Top model{kpi.topModelId ? `: ${shortenModel(kpi.topModelId)}` : ''}
</div>
</div>
</div>
{periodSelector}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Daily cost */}
<div className="space-y-1">
<div className="text-sm text-muted-foreground">Kosten per dag</div>
{byDay.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">Geen data</p>
) : (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={byDay}>
<XAxis
dataKey="day"
tick={{ fontSize: 11 }}
stroke="var(--muted-foreground)"
tickFormatter={v => (v as string).slice(5)}
/>
<YAxis
tick={{ fontSize: 11 }}
stroke="var(--muted-foreground)"
tickFormatter={v => fmtUsd(v as number, 2)}
/>
<Tooltip
formatter={value => [fmtUsd(Number(value), 4), 'Kosten']}
/>
<Bar dataKey="costUsd" fill="var(--chart-1)" />
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Per model */}
<div className="space-y-1">
<div className="text-sm text-muted-foreground">Kosten per model</div>
{modelData.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">Geen data</p>
) : (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={modelData} layout="vertical">
<XAxis
type="number"
tick={{ fontSize: 11 }}
stroke="var(--muted-foreground)"
tickFormatter={v => fmtUsd(v as number, 2)}
/>
<YAxis
type="category"
dataKey="label"
tick={{ fontSize: 11 }}
stroke="var(--muted-foreground)"
width={120}
/>
<Tooltip formatter={value => [fmtUsd(Number(value), 4), 'Kosten']} />
<Bar dataKey="costUsd" fill="var(--chart-2)" />
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Per kind */}
<div className="space-y-1">
<div className="text-sm text-muted-foreground">Kosten per job-kind</div>
{kindData.length === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">Geen data</p>
) : (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={kindData} layout="vertical">
<XAxis
type="number"
tick={{ fontSize: 11 }}
stroke="var(--muted-foreground)"
tickFormatter={v => fmtUsd(v as number, 2)}
/>
<YAxis
type="category"
dataKey="label"
tick={{ fontSize: 11 }}
stroke="var(--muted-foreground)"
width={120}
/>
<Tooltip formatter={value => [fmtUsd(Number(value), 4), 'Kosten']} />
<Bar dataKey="costUsd" fill="var(--chart-3)" />
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Cache efficiency */}
<div className="space-y-1">
<div className="text-sm text-muted-foreground">Cache efficiency</div>
{cache.cacheReadTokens + cache.uncachedInputTokens === 0 ? (
<p className="text-sm text-muted-foreground py-8 text-center">Geen data</p>
) : (
<>
<ResponsiveContainer width="100%" height={160}>
<PieChart>
<Pie
data={cacheData}
dataKey="value"
nameKey="name"
innerRadius={40}
outerRadius={70}
>
{cacheData.map((_, i) => (
<Cell key={i} fill={cacheColors[i]} />
))}
</Pie>
<Tooltip
formatter={(value, name) => [
Number(value).toLocaleString() + ' tokens',
String(name),
]}
/>
</PieChart>
</ResponsiveContainer>
<p className="text-sm text-foreground text-center">
<span className="font-semibold">{(cache.cacheHitRatio * 100).toFixed(1)}%</span>{' '}
cache hit ·{' '}
<span className="text-status-done font-semibold">
{fmtUsd(cache.savingsUsd)}
</span>{' '}
bespaard
</p>
</>
)}
</div>
</div>
</div>
)
}