* feat(PBI-78): cost-analysis data layer (T-902)
- New lib/insights/cost-analysis.ts with 5 query functions:
getCostKpi, getCostByDay, getCostByModel, getCostByKind, getCacheEfficiency
- Period type: 7d | 30d | 90d | mtd
- Cost formula reused from token-stats.ts (input + output + cache + thinking)
- Cache savings: cache_read_tokens × (input_price - cache_read_price) / 1M
- Plan: docs/plans/PBI-78-cost-analysis-widget.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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>
* feat(PBI-78): wire CostAnalysisCard onto insights page (T-904)
- Parse ?period= from searchParams (default 30d, validates against 7d/30d/90d/mtd)
- Parallel-fetch 5 cost queries via Promise.all alongside existing widgets
- New "Cost analyse" section between Sprint Health and Plan-quality
- Existing TokenUsageCard ("Token gebruik" section) stays as sprint detail
verify (lint+typecheck+test) and build pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
272 lines
8.8 KiB
TypeScript
272 lines
8.8 KiB
TypeScript
'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>
|
|
)
|
|
}
|