# PBI-78 — Cost-analyse widget op Insights-pagina ## Context De insights-pagina heeft al een `TokenUsageCard` die KPI's + per-job tabel toont, maar **alleen voor de actieve sprint van een gefilterd product**. Daardoor mis je het globale plaatje: hoeveel geef je deze maand uit, welk model is de grootste kostenpost, hoe goed werkt prompt-caching, en welke job-kinds (IDEA_MAKE_PLAN met Opus vs TASK_IMPLEMENTATION met Sonnet) trekken het budget. We voegen een nieuwe sectie **"Cost analyse"** toe (tussen Sprint Health en Plan-quality). Eén shared periode-selector (7d/30d/90d/MTD) stuurt vier visualisaties aan op basis van best practices uit de Anthropic Console + LLM-observability tools (Datadog, Portkey): 1. Trend-chart over tijd 2. Breakdown per model 3. Breakdown per job-kind 4. Cache efficiency De bestaande `TokenUsageCard` blijft staan als sprint-detail (top duurste jobs voor de actieve sprint). ## Bestaande infrastructuur (hergebruik) **Reeds aanwezig in DB:** - [prisma/schema.prisma](../../prisma/schema.prisma) — `ClaudeJob` heeft `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `actual_thinking_tokens`, `model_id`, `kind`, `finished_at` - `ModelPrice` tabel met prijzen per 1M tokens (input/output/cache_read/cache_write) - Prijzen worden gesynced via [scripts/sync-model-prices.ts](../../scripts/sync-model-prices.ts) **Hergebruikbare patronen:** - KPI-strip stijl: zie [app/(app)/insights/components/token-usage.tsx](../../app/(app)/insights/components/token-usage.tsx) (regels 43-64) - URL-param-gestuurde filter met `useTransition` + `router.replace`: zie [app/(app)/insights/components/agent-throughput.tsx](../../app/(app)/insights/components/agent-throughput.tsx) (regels 38-62) - Recharts BarChart pattern: zie [app/(app)/insights/components/agent-throughput.tsx](../../app/(app)/insights/components/agent-throughput.tsx) (regels 110-130) - Cost-formule (zelfde overal): zie [lib/insights/token-stats.ts](../../lib/insights/token-stats.ts) (regels 73-79) — input + output + cache_read + cache_write + thinking, allemaal `× price_per_1m / 1_000_000` - Server component → parallel data-fetch via `Promise.all`: zie [app/(app)/insights/page.tsx](../../app/(app)/insights/page.tsx) (regels 46-80) ## Te bouwen ### Taak 1 — Data-laag — `lib/insights/cost-analysis.ts` (nieuw) Eén bestand met vijf functies, allemaal `(userId, period)` als parameters. Period wordt naar `WHERE cj.finished_at >= NOW() - INTERVAL ' days'` vertaald (MTD = `>= date_trunc('month', NOW())`). ```ts export type Period = '7d' | '30d' | '90d' | 'mtd' export interface CostKpi { totalCostUsd: number totalTokens: number jobCount: number avgPerDayUsd: number cacheSavingsUsd: number // (input_price - cache_read_price) × cache_read_tokens topModelId: string | null topModelCostUsd: number } export interface CostByDayRow { day: string; costUsd: number } export interface CostByModelRow { modelId: string; costUsd: number; jobCount: number } export interface CostByKindRow { kind: string; costUsd: number; jobCount: number } export interface CacheEfficiency { cacheReadTokens: number uncachedInputTokens: number cacheHitRatio: number // cache_read / (cache_read + input) savingsUsd: number spentOnCacheWriteUsd: number } export async function getCostKpi(userId: string, period: Period): Promise export async function getCostByDay(userId: string, period: Period): Promise export async function getCostByModel(userId: string, period: Period): Promise export async function getCostByKind(userId: string, period: Period): Promise export async function getCacheEfficiency(userId: string, period: Period): Promise ``` **Belangrijke details:** - Alle queries: `WHERE cj.user_id = ${userId} AND cj.status = 'DONE' AND cj.finished_at >= ` - Geen `productAccessFilter` nodig — `cj.user_id = ${userId}` filtert al op de eigenaar - `getCostByDay` vult ontbrekende dagen op met `0` (anders breekt de chart-x-as) — vul aan client- of server-side, kies één - Periode → days mapping inline: `7d`→7, `30d`→30, `90d`→90, `mtd`→huidige dag-van-maand - Cache savings: `cache_read_tokens × (input_price - cache_read_price) / 1_000_000` — "wat je betaald zou hebben zonder cache, minus wat je betaalde mét cache" ### Taak 2 — UI — `app/(app)/insights/components/cost-analysis.tsx` (nieuw) Eén client-component die de hele sectie rendert. Structuur: ``` [Period selector rechtsboven] [KPI strip: Totaal | Cache savings | Avg/dag | Top model ($X op claude-opus-4-7)] [grid grid-cols-1 md:grid-cols-2 gap-4] [Daily cost line/bar chart] [Model breakdown - horizontal bar of donut] [Job-kind breakdown - bar] [Cache efficiency - donut + label "X% hit, $Y bespaard"] ``` **Period selector:** kopieer pattern uit [agent-throughput.tsx](../../app/(app)/insights/components/agent-throughput.tsx) (regels 50-61) — `useTransition` + `router.replace` met `?period=` in URL. Default tonen als "30d". **Charts:** Recharts (al gebruikt in `BurndownChart`, `AgentThroughputCard`, `VelocityChart`): - Daily: `` met één bar (cost in USD), x-as = dag (`MM-DD`), tooltip toont `$X.XXXX` - Model: `` met model_id labels — beperkt tot top 5 - Kind: `` met kind labels — beperkt tot top 5 - Cache: `` met twee segmenten (cached / uncached input) + tekst "X% cache hit · $Y bespaard" **Empty state:** als `kpi.jobCount === 0`: render één regel "Geen jobs in deze periode." ### Taak 3 — Integratie — `app/(app)/insights/page.tsx` (edit) Wijzigingen: ```diff interface InsightsPageProps { - searchParams: Promise<{ product?: string }> + searchParams: Promise<{ product?: string; period?: string }> } ``` ```diff - const { product: filterProductId } = await searchParams + const { product: filterProductId, period: rawPeriod } = await searchParams + const period = (['7d','30d','90d','mtd'].includes(rawPeriod ?? '') ? rawPeriod : '30d') as Period ``` In de `Promise.all`, voeg toe: ```ts getCostKpi(userId, period), getCostByDay(userId, period), getCostByModel(userId, period), getCostByKind(userId, period), getCacheEfficiency(userId, period), ``` Nieuwe sectie tussen Sprint Health en Plan-quality: ```tsx

Cost analyse

``` De bestaande "Token gebruik" sectie blijft staan (sprint-detail tabel). ## Bestanden **Nieuw:** - `lib/insights/cost-analysis.ts` — 5 query-functies + types - `app/(app)/insights/components/cost-analysis.tsx` — client-component met period-selector + 4 charts **Edit:** - `app/(app)/insights/page.tsx` — period uit searchParams, parallel-fetch, nieuwe sectie **Geen wijzigingen aan:** - Prisma schema (alle data is er al) - MCP server (token-data wordt al weggeschreven via `update_job_status`) - `TokenUsageCard` (blijft als sprint-detail tabel) ## Verificatie ```bash npm run verify && npm run build ``` **Handmatig:** 1. Open `/insights` zonder query — period default `30d`, sectie toont KPI + 4 charts 2. Wissel period via selector → URL updatet `?period=7d`, charts laden nieuwe data via `router.replace` 3. Check empty state: kies periode zonder jobs → "Geen jobs in deze periode." 4. Sanity-check KPI's tegen ruwe DB-query: ```sql SELECT SUM(input_tokens * mp.input_price_per_1m / 1e6 + output_tokens * mp.output_price_per_1m / 1e6 + cache_read_tokens * mp.cache_read_price_per_1m / 1e6 + cache_write_tokens * mp.cache_write_price_per_1m / 1e6 + COALESCE(actual_thinking_tokens, 0) * mp.input_price_per_1m / 1e6) FROM claude_jobs cj LEFT JOIN model_prices mp ON mp.model_id = cj.model_id WHERE cj.user_id = '' AND cj.status = 'DONE' AND cj.finished_at >= NOW() - INTERVAL '30 days'; ``` 5. Cache savings sanity: `cacheSavingsUsd ≈ cache_read_tokens × 0.9 × input_price / 1M` (cache_read prijs = 0.1× input prijs, dus savings is 90%)