Scrum4Me/docs/plans/PBI-78-cost-analysis-widget.md
Janpeter Visser a1e6ec35e5
feat(PBI-78): cost-analysis widget on insights page (#187)
* 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>
2026-05-10 12:59:45 +02:00

186 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 '<n> 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<CostKpi>
export async function getCostByDay(userId: string, period: Period): Promise<CostByDayRow[]>
export async function getCostByModel(userId: string, period: Period): Promise<CostByModelRow[]>
export async function getCostByKind(userId: string, period: Period): Promise<CostByKindRow[]>
export async function getCacheEfficiency(userId: string, period: Period): Promise<CacheEfficiency>
```
**Belangrijke details:**
- Alle queries: `WHERE cj.user_id = ${userId} AND cj.status = 'DONE' AND cj.finished_at >= <periodStart>`
- 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: `<BarChart>` met één bar (cost in USD), x-as = dag (`MM-DD`), tooltip toont `$X.XXXX`
- Model: `<BarChart layout="vertical">` met model_id labels — beperkt tot top 5
- Kind: `<BarChart layout="vertical">` met kind labels — beperkt tot top 5
- Cache: `<PieChart>` 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
<section className="space-y-3">
<h2 className="text-lg font-medium text-foreground">Cost analyse</h2>
<CostAnalysisCard
period={period}
kpi={costKpi}
byDay={costByDay}
byModel={costByModel}
byKind={costByKind}
cache={cacheEff}
/>
</section>
```
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 = '<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%)