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
272
app/(app)/insights/components/cost-analysis.tsx
Normal file
272
app/(app)/insights/components/cost-analysis.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
'use client'
|
||||
|
||||
import { useTransition } from 'react'
|
||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type {
|
||||
Period,
|
||||
CostKpi,
|
||||
CostByDayRow,
|
||||
CostByModelRow,
|
||||
CostByKindRow,
|
||||
CacheEfficiency,
|
||||
} from '@/lib/insights/cost-analysis'
|
||||
|
||||
interface Props {
|
||||
period: Period
|
||||
kpi: CostKpi
|
||||
byDay: CostByDayRow[]
|
||||
byModel: CostByModelRow[]
|
||||
byKind: CostByKindRow[]
|
||||
cache: CacheEfficiency
|
||||
}
|
||||
|
||||
const PERIOD_LABELS: Record<Period, string> = {
|
||||
'7d': 'Laatste 7 dagen',
|
||||
'30d': 'Laatste 30 dagen',
|
||||
'90d': 'Laatste 90 dagen',
|
||||
mtd: 'Deze maand',
|
||||
}
|
||||
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
TASK_IMPLEMENTATION: 'Task impl.',
|
||||
IDEA_GRILL: 'Idea grill',
|
||||
IDEA_MAKE_PLAN: 'Idea plan',
|
||||
PLAN_CHAT: 'Plan chat',
|
||||
SPRINT_IMPLEMENTATION: 'Sprint impl.',
|
||||
}
|
||||
|
||||
function fmtUsd(n: number, decimals = 2): string {
|
||||
return '$' + n.toFixed(decimals)
|
||||
}
|
||||
|
||||
function shortenModel(modelId: string): string {
|
||||
return modelId.replace(/^claude-/, '')
|
||||
}
|
||||
|
||||
export function CostAnalysisCard({ period, kpi, byDay, byModel, byKind, cache }: Props) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
function handlePeriodChange(value: string | null) {
|
||||
if (value === null) return
|
||||
startTransition(() => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set('period', value)
|
||||
router.replace(`${pathname}?${params.toString()}`)
|
||||
})
|
||||
}
|
||||
|
||||
const periodSelector = (
|
||||
<Select value={period} onValueChange={handlePeriodChange}>
|
||||
<SelectTrigger className="w-44" disabled={isPending}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.keys(PERIOD_LABELS) as Period[]).map(p => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{PERIOD_LABELS[p]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
|
||||
if (kpi.jobCount === 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<p className="text-muted-foreground text-sm">Geen jobs in deze periode.</p>
|
||||
{periodSelector}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const cacheData = [
|
||||
{ name: 'Cache', value: cache.cacheReadTokens },
|
||||
{ name: 'Uncached input', value: cache.uncachedInputTokens },
|
||||
]
|
||||
const cacheColors = ['var(--status-done)', 'var(--muted-foreground)']
|
||||
|
||||
const modelData = byModel.map(m => ({ ...m, label: shortenModel(m.modelId) }))
|
||||
const kindData = byKind.map(k => ({ ...k, label: KIND_LABELS[k.kind] ?? k.kind }))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* KPI strip + period selector */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex gap-6 flex-wrap">
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-foreground">{fmtUsd(kpi.totalCostUsd)}</div>
|
||||
<div className="text-xs text-muted-foreground">Totaal kosten</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-foreground">{fmtUsd(kpi.avgPerDayUsd)}</div>
|
||||
<div className="text-xs text-muted-foreground">Gem. per dag</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-status-done">
|
||||
{fmtUsd(kpi.cacheSavingsUsd)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Cache-besparing</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-foreground">
|
||||
{kpi.topModelId ? fmtUsd(kpi.topModelCostUsd) : '—'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Top model{kpi.topModelId ? `: ${shortenModel(kpi.topModelId)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{periodSelector}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Daily cost */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">Kosten per dag</div>
|
||||
{byDay.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">Geen data</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={byDay}>
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fontSize: 11 }}
|
||||
stroke="var(--muted-foreground)"
|
||||
tickFormatter={v => (v as string).slice(5)}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
stroke="var(--muted-foreground)"
|
||||
tickFormatter={v => fmtUsd(v as number, 2)}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={value => [fmtUsd(Number(value), 4), 'Kosten']}
|
||||
/>
|
||||
<Bar dataKey="costUsd" fill="var(--chart-1)" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Per model */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">Kosten per model</div>
|
||||
{modelData.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">Geen data</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={modelData} layout="vertical">
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 11 }}
|
||||
stroke="var(--muted-foreground)"
|
||||
tickFormatter={v => fmtUsd(v as number, 2)}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11 }}
|
||||
stroke="var(--muted-foreground)"
|
||||
width={120}
|
||||
/>
|
||||
<Tooltip formatter={value => [fmtUsd(Number(value), 4), 'Kosten']} />
|
||||
<Bar dataKey="costUsd" fill="var(--chart-2)" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Per kind */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">Kosten per job-kind</div>
|
||||
{kindData.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">Geen data</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={kindData} layout="vertical">
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 11 }}
|
||||
stroke="var(--muted-foreground)"
|
||||
tickFormatter={v => fmtUsd(v as number, 2)}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11 }}
|
||||
stroke="var(--muted-foreground)"
|
||||
width={120}
|
||||
/>
|
||||
<Tooltip formatter={value => [fmtUsd(Number(value), 4), 'Kosten']} />
|
||||
<Bar dataKey="costUsd" fill="var(--chart-3)" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cache efficiency */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-muted-foreground">Cache efficiency</div>
|
||||
{cache.cacheReadTokens + cache.uncachedInputTokens === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">Geen data</p>
|
||||
) : (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={cacheData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={40}
|
||||
outerRadius={70}
|
||||
>
|
||||
{cacheData.map((_, i) => (
|
||||
<Cell key={i} fill={cacheColors[i]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value, name) => [
|
||||
Number(value).toLocaleString() + ' tokens',
|
||||
String(name),
|
||||
]}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<p className="text-sm text-foreground text-center">
|
||||
<span className="font-semibold">{(cache.cacheHitRatio * 100).toFixed(1)}%</span>{' '}
|
||||
cache hit ·{' '}
|
||||
<span className="text-status-done font-semibold">
|
||||
{fmtUsd(cache.savingsUsd)}
|
||||
</span>{' '}
|
||||
bespaard
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -8,6 +8,14 @@ import { getSprintStatusBreakdown } from '@/lib/insights/sprint-status'
|
|||
import { getVerifyResultStats, getAlignmentTrend } from '@/lib/insights/verify-stats'
|
||||
import { getJobsPerDay } from '@/lib/insights/agent-throughput'
|
||||
import { getTokenStats } from '@/lib/insights/token-stats'
|
||||
import {
|
||||
getCostKpi,
|
||||
getCostByDay,
|
||||
getCostByModel,
|
||||
getCostByKind,
|
||||
getCacheEfficiency,
|
||||
type Period,
|
||||
} from '@/lib/insights/cost-analysis'
|
||||
import { getVelocity } from '@/lib/insights/velocity'
|
||||
import { getBacklogHealth } from '@/lib/insights/backlog-health'
|
||||
import { SprintInfoStrip } from './components/sprint-info-strip'
|
||||
|
|
@ -16,6 +24,7 @@ import { SprintStatusDonut } from './components/sprint-status-donut'
|
|||
import { PlanQualityCard } from './components/plan-quality'
|
||||
import { AlignmentTrend } from './components/alignment-trend'
|
||||
import { AgentThroughputCard } from './components/agent-throughput'
|
||||
import { CostAnalysisCard } from './components/cost-analysis'
|
||||
import { TokenUsageCard } from './components/token-usage'
|
||||
import { VelocityChart } from './components/velocity-chart'
|
||||
import { BacklogHealthCard } from './components/backlog-health'
|
||||
|
|
@ -24,7 +33,13 @@ const DAY_MS = 86_400_000
|
|||
const ASSUMED_SPRINT_DAYS = 14
|
||||
|
||||
interface InsightsPageProps {
|
||||
searchParams: Promise<{ product?: string }>
|
||||
searchParams: Promise<{ product?: string; period?: string }>
|
||||
}
|
||||
|
||||
const VALID_PERIODS = ['7d', '30d', '90d', 'mtd'] as const
|
||||
|
||||
function parsePeriod(raw: string | undefined): Period {
|
||||
return (VALID_PERIODS as readonly string[]).includes(raw ?? '') ? (raw as Period) : '30d'
|
||||
}
|
||||
|
||||
function MissingDatesNotice({ productId, productName }: { productId: string; productName: string }) {
|
||||
|
|
@ -41,7 +56,8 @@ function MissingDatesNotice({ productId, productName }: { productId: string; pro
|
|||
export default async function InsightsPage({ searchParams }: InsightsPageProps) {
|
||||
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
const userId = session.userId!
|
||||
const { product: filterProductId } = await searchParams
|
||||
const { product: filterProductId, period: rawPeriod } = await searchParams
|
||||
const period = parsePeriod(rawPeriod)
|
||||
|
||||
const [
|
||||
burndownSprints,
|
||||
|
|
@ -53,6 +69,11 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps)
|
|||
jobsPerDay,
|
||||
velocity,
|
||||
backlogHealth,
|
||||
costKpi,
|
||||
costByDay,
|
||||
costByModel,
|
||||
costByKind,
|
||||
cacheEff,
|
||||
] = await Promise.all([
|
||||
getBurndownData(userId),
|
||||
getSprintStatusBreakdown(userId),
|
||||
|
|
@ -77,6 +98,11 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps)
|
|||
getJobsPerDay(userId, 14, filterProductId),
|
||||
getVelocity(userId, 5),
|
||||
getBacklogHealth(userId),
|
||||
getCostKpi(userId, period),
|
||||
getCostByDay(userId, period),
|
||||
getCostByModel(userId, period),
|
||||
getCostByKind(userId, period),
|
||||
getCacheEfficiency(userId, period),
|
||||
])
|
||||
|
||||
const activeSprintId = activeSprints.find(s => s.product.id === filterProductId)?.id ?? ''
|
||||
|
|
@ -134,6 +160,19 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps)
|
|||
)}
|
||||
</section>
|
||||
|
||||
{/* Cost analyse */}
|
||||
<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>
|
||||
|
||||
{/* Plan-quality */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-medium text-foreground">Plan-quality</h2>
|
||||
|
|
|
|||
|
|
@ -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%)
|
||||
291
lib/insights/cost-analysis.ts
Normal file
291
lib/insights/cost-analysis.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue