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>
This commit is contained in:
Janpeter Visser 2026-05-10 12:48:02 +02:00
parent 1f8cbacb0a
commit 5f7d6da53d
3 changed files with 478 additions and 0 deletions

View file

@ -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 |

View 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%)

View file

@ -0,0 +1,291 @@
import { prisma } from '@/lib/prisma'
export type Period = '7d' | '30d' | '90d' | 'mtd'
export interface CostKpi {
totalCostUsd: number
totalTokens: number
jobCount: number
avgPerDayUsd: number
cacheSavingsUsd: number
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
savingsUsd: number
spentOnCacheWriteUsd: number
}
function periodToDays(period: Period, now: Date = new Date()): number {
switch (period) {
case '7d':
return 7
case '30d':
return 30
case '90d':
return 90
case 'mtd':
return now.getUTCDate()
}
}
function periodStart(period: Period, now: Date = new Date()): Date {
if (period === 'mtd') {
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
}
const days = periodToDays(period, now)
return new Date(now.getTime() - days * 86_400_000)
}
type RawKpiRow = {
total_cost: number | null
total_tokens: bigint
job_count: bigint
cache_savings: number | null
}
type RawTopModelRow = {
model_id: string | null
cost: number | null
}
export async function getCostKpi(userId: string, period: Period): Promise<CostKpi> {
const start = periodStart(period)
const days = Math.max(periodToDays(period), 1)
const [kpiRows, topModelRows] = await Promise.all([
prisma.$queryRaw<RawKpiRow[]>`
SELECT
SUM(
cj.input_tokens * mp.input_price_per_1m / 1000000.0
+ cj.output_tokens * mp.output_price_per_1m / 1000000.0
+ cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0
+ cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0
+ COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0
) FILTER (WHERE cj.input_tokens IS NOT NULL) AS total_cost,
COALESCE(SUM(
cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens
+ COALESCE(cj.actual_thinking_tokens, 0)
), 0) AS total_tokens,
COUNT(*) FILTER (WHERE cj.input_tokens IS NOT NULL) AS job_count,
SUM(
cj.cache_read_tokens * (mp.input_price_per_1m - mp.cache_read_price_per_1m) / 1000000.0
) FILTER (WHERE cj.cache_read_tokens IS NOT NULL) AS cache_savings
FROM claude_jobs cj
LEFT JOIN model_prices mp ON mp.model_id = cj.model_id
WHERE cj.user_id = ${userId}
AND cj.status = 'DONE'
AND cj.finished_at >= ${start}
`,
prisma.$queryRaw<RawTopModelRow[]>`
SELECT
cj.model_id,
SUM(
cj.input_tokens * mp.input_price_per_1m / 1000000.0
+ cj.output_tokens * mp.output_price_per_1m / 1000000.0
+ cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0
+ cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0
+ COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0
) AS cost
FROM claude_jobs cj
LEFT JOIN model_prices mp ON mp.model_id = cj.model_id
WHERE cj.user_id = ${userId}
AND cj.status = 'DONE'
AND cj.finished_at >= ${start}
AND cj.input_tokens IS NOT NULL
AND cj.model_id IS NOT NULL
GROUP BY cj.model_id
ORDER BY cost DESC NULLS LAST
LIMIT 1
`,
])
const kpi = kpiRows[0]
const totalCost = Number(kpi?.total_cost ?? 0)
const top = topModelRows[0]
return {
totalCostUsd: totalCost,
totalTokens: Number(kpi?.total_tokens ?? 0),
jobCount: Number(kpi?.job_count ?? 0),
avgPerDayUsd: totalCost / days,
cacheSavingsUsd: Number(kpi?.cache_savings ?? 0),
topModelId: top?.model_id ?? null,
topModelCostUsd: Number(top?.cost ?? 0),
}
}
type RawDayRow = {
day: Date
cost: number | null
}
export async function getCostByDay(userId: string, period: Period): Promise<CostByDayRow[]> {
const start = periodStart(period)
const rows = await prisma.$queryRaw<RawDayRow[]>`
SELECT
DATE(cj.finished_at) AS day,
SUM(
cj.input_tokens * mp.input_price_per_1m / 1000000.0
+ cj.output_tokens * mp.output_price_per_1m / 1000000.0
+ cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0
+ cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0
+ COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0
) FILTER (WHERE cj.input_tokens IS NOT NULL) AS cost
FROM claude_jobs cj
LEFT JOIN model_prices mp ON mp.model_id = cj.model_id
WHERE cj.user_id = ${userId}
AND cj.status = 'DONE'
AND cj.finished_at >= ${start}
GROUP BY DATE(cj.finished_at)
ORDER BY day ASC
`
return rows.map(r => ({
day: r.day.toISOString().slice(0, 10),
costUsd: Number(r.cost ?? 0),
}))
}
type RawModelRow = {
model_id: string
cost: number | null
job_count: bigint
}
export async function getCostByModel(userId: string, period: Period): Promise<CostByModelRow[]> {
const start = periodStart(period)
const rows = await prisma.$queryRaw<RawModelRow[]>`
SELECT
cj.model_id,
SUM(
cj.input_tokens * mp.input_price_per_1m / 1000000.0
+ cj.output_tokens * mp.output_price_per_1m / 1000000.0
+ cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0
+ cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0
+ COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0
) AS cost,
COUNT(*) AS job_count
FROM claude_jobs cj
LEFT JOIN model_prices mp ON mp.model_id = cj.model_id
WHERE cj.user_id = ${userId}
AND cj.status = 'DONE'
AND cj.finished_at >= ${start}
AND cj.input_tokens IS NOT NULL
AND cj.model_id IS NOT NULL
GROUP BY cj.model_id
ORDER BY cost DESC NULLS LAST
LIMIT 5
`
return rows.map(r => ({
modelId: r.model_id,
costUsd: Number(r.cost ?? 0),
jobCount: Number(r.job_count),
}))
}
type RawKindRow = {
kind: string
cost: number | null
job_count: bigint
}
export async function getCostByKind(userId: string, period: Period): Promise<CostByKindRow[]> {
const start = periodStart(period)
const rows = await prisma.$queryRaw<RawKindRow[]>`
SELECT
cj.kind::text AS kind,
SUM(
cj.input_tokens * mp.input_price_per_1m / 1000000.0
+ cj.output_tokens * mp.output_price_per_1m / 1000000.0
+ cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0
+ cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0
+ COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0
) AS cost,
COUNT(*) AS job_count
FROM claude_jobs cj
LEFT JOIN model_prices mp ON mp.model_id = cj.model_id
WHERE cj.user_id = ${userId}
AND cj.status = 'DONE'
AND cj.finished_at >= ${start}
AND cj.input_tokens IS NOT NULL
GROUP BY cj.kind
ORDER BY cost DESC NULLS LAST
LIMIT 5
`
return rows.map(r => ({
kind: r.kind,
costUsd: Number(r.cost ?? 0),
jobCount: Number(r.job_count),
}))
}
type RawCacheRow = {
cache_read_tokens: bigint
uncached_input_tokens: bigint
savings: number | null
cache_write_cost: number | null
}
export async function getCacheEfficiency(
userId: string,
period: Period,
): Promise<CacheEfficiency> {
const start = periodStart(period)
const rows = await prisma.$queryRaw<RawCacheRow[]>`
SELECT
COALESCE(SUM(cj.cache_read_tokens), 0) AS cache_read_tokens,
COALESCE(SUM(cj.input_tokens), 0) AS uncached_input_tokens,
SUM(
cj.cache_read_tokens * (mp.input_price_per_1m - mp.cache_read_price_per_1m) / 1000000.0
) FILTER (WHERE cj.cache_read_tokens IS NOT NULL) AS savings,
SUM(
cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0
) FILTER (WHERE cj.cache_write_tokens IS NOT NULL) AS cache_write_cost
FROM claude_jobs cj
LEFT JOIN model_prices mp ON mp.model_id = cj.model_id
WHERE cj.user_id = ${userId}
AND cj.status = 'DONE'
AND cj.finished_at >= ${start}
`
const r = rows[0]
const cacheReadTokens = Number(r?.cache_read_tokens ?? 0)
const uncachedInputTokens = Number(r?.uncached_input_tokens ?? 0)
const totalInputLike = cacheReadTokens + uncachedInputTokens
return {
cacheReadTokens,
uncachedInputTokens,
cacheHitRatio: totalInputLike > 0 ? cacheReadTokens / totalInputLike : 0,
savingsUsd: Number(r?.savings ?? 0),
spentOnCacheWriteUsd: Number(r?.cache_write_cost ?? 0),
}
}