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>
This commit is contained in:
parent
f8693d126b
commit
a1e6ec35e5
5 changed files with 791 additions and 2 deletions
|
|
@ -54,6 +54,7 @@ Auto-generated on 2026-05-10 from front-matter and headings.
|
|||
| [M9 — Actief Product Backlog](./plans/M9-active-product-backlog.md) | active | 2026-05-03 |
|
||||
| [PBI-11 — Mobile-shell met landscape-lock (settings + backlog + solo)](./plans/PBI-11-mobile-shell.md) | — | — |
|
||||
| [PBI-75 — Sprint task-edit client-side via workspace-store](./plans/PBI-75-sprint-task-edit-store.md) | — | — |
|
||||
| [PBI-78 — Cost-analyse widget op Insights-pagina](./plans/PBI-78-cost-analysis-widget.md) | — | — |
|
||||
| [Queue-loop verplaatsen van Claude naar runner](./plans/queue-loop-extraction.md) | — | — |
|
||||
| [Advies - SprintRun, PR en worktree lifecycle als state machines](./plans/sprint-pr-worktree-state-machines.md) | draft | 2026-05-06 |
|
||||
| [ST-1109 — PBI krijgt een status (Ready / Blocked / Done)](./plans/ST-1109-pbi-status.md) | active | 2026-05-03 |
|
||||
|
|
|
|||
186
docs/plans/PBI-78-cost-analysis-widget.md
Normal file
186
docs/plans/PBI-78-cost-analysis-widget.md
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
# 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%)
|
||||
Loading…
Add table
Add a link
Reference in a new issue