* 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>
8.1 KiB
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):
- Trend-chart over tijd
- Breakdown per model
- Breakdown per job-kind
- 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 —
ClaudeJobheeftinput_tokens,output_tokens,cache_read_tokens,cache_write_tokens,actual_thinking_tokens,model_id,kind,finished_at ModelPricetabel met prijzen per 1M tokens (input/output/cache_read/cache_write)- Prijzen worden gesynced via scripts/sync-model-prices.ts
Hergebruikbare patronen:
- KPI-strip stijl: zie 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 (regels 38-62) - Recharts BarChart pattern: zie app/(app)/insights/components/agent-throughput.tsx (regels 110-130)
- Cost-formule (zelfde overal): zie 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 (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())).
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
productAccessFilternodig —cj.user_id = ${userId}filtert al op de eigenaar getCostByDayvult ontbrekende dagen op met0(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 (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:
interface InsightsPageProps {
- searchParams: Promise<{ product?: string }>
+ searchParams: Promise<{ product?: string; period?: string }>
}
- 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:
getCostKpi(userId, period),
getCostByDay(userId, period),
getCostByModel(userId, period),
getCostByKind(userId, period),
getCacheEfficiency(userId, period),
Nieuwe sectie tussen Sprint Health en Plan-quality:
<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 + typesapp/(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
npm run verify && npm run build
Handmatig:
- Open
/insightszonder query — period default30d, sectie toont KPI + 4 charts - Wissel period via selector → URL updatet
?period=7d, charts laden nieuwe data viarouter.replace - Check empty state: kies periode zonder jobs → "Geen jobs in deze periode."
- Sanity-check KPI's tegen ruwe DB-query:
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'; - Cache savings sanity:
cacheSavingsUsd ≈ cache_read_tokens × 0.9 × input_price / 1M(cache_read prijs = 0.1× input prijs, dus savings is 90%)