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:
parent
5f7d6da53d
commit
814f2191e8
1 changed files with 272 additions and 0 deletions
272
app/(app)/insights/components/cost-analysis.tsx
Normal file
272
app/(app)/insights/components/cost-analysis.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue