Scrum4Me/docs/old/plans/PBI-78-cost-analysis-widget.md
Janpeter Visser b39c3ec2e1
docs(cleanup): archief verouderde plannen, backlog en root-duplicaten (#191)
* docs(cleanup): archief verouderde plannen, backlog en root-duplicaten

- 6 plans naar docs/old/plans/ (PBI-11/75/78, user-settings-store, Local github setup, lees-de-readme — laatste was verkeerde repo)
- docs/backlog/ naar docs/old/backlog/ (pre-MCP statische registry; live werk loopt via Scrum4Me-MCP)
- 6 root-level duplicaten naar docs/old/ (functional, {pbi,story,task}-dialog, product-backlog, backlog)
- 2 landing plans (niet uitgevoerd) krijgen archived: true frontmatter — blijven op plek maar uit INDEX
- scripts/generate-docs-index.mjs: skip docs/old/** + skip archived: true
- CLAUDE.md: rijen docs/backlog/, docs/plans/<key>-*.md, docs/manual/ weg; Track B-sectie verwijderd
- README.md / CHANGELOG.md / docs/plans/v1-readiness.md: link-fixes naar nieuwe locaties

Verify groen (lint + typecheck + 718 tests). docs/INDEX.md geregenereerd.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(cleanup): registreer handmatige verplaatsingen en fix referenties

- 4 plans verplaatst naar docs/old/plans/ (M10-qr-pairing-login, auto-pr-deploy-sync, docs-restructure-ai-lookup, v1-readiness)
- 3 archive-plans verplaatst naar docs/old/plans/ (archive-map nu leeg)
- ST-1114-copilot-reviews + 3 research-docs naar nieuwe docs/Ideas/ map
- Duplicaat docs/old/2026-04-27-m8-realtime-solo.md verwijderd (origineel zit in docs/old/plans/)
- Link-fixes naar nieuwe locaties:
  - CHANGELOG.md → docs/old/plans/v1-readiness.md
  - docs/runbooks/deploy-control.md → docs/old/plans/auto-pr-deploy-sync.md (2x)
  - docs/runbooks/worker-idempotency.md → docs/old/plans/auto-pr-deploy-sync.md
  - docs/plans/docs-restructure-pbi-spec.md → docs/old/plans/docs-restructure-ai-lookup.md (4x text + 2x href)
- docs/INDEX.md geregenereerd (96 docs, was 100)

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-11 19:46:00 +02:00

186 lines
8.1 KiB
Markdown
Raw Permalink 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%)