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:
Janpeter Visser 2026-05-10 12:59:45 +02:00 committed by GitHub
parent f8693d126b
commit a1e6ec35e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 791 additions and 2 deletions

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

View file

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

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),
}
}