diff --git a/app/(app)/insights/components/cost-analysis.tsx b/app/(app)/insights/components/cost-analysis.tsx new file mode 100644 index 0000000..f147ae3 --- /dev/null +++ b/app/(app)/insights/components/cost-analysis.tsx @@ -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 = { + '7d': 'Laatste 7 dagen', + '30d': 'Laatste 30 dagen', + '90d': 'Laatste 90 dagen', + mtd: 'Deze maand', +} + +const KIND_LABELS: Record = { + 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 = ( + + ) + + if (kpi.jobCount === 0) { + return ( +
+
+

Geen jobs in deze periode.

+ {periodSelector} +
+
+ ) + } + + 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 ( +
+ {/* KPI strip + period selector */} +
+
+
+
{fmtUsd(kpi.totalCostUsd)}
+
Totaal kosten
+
+
+
{fmtUsd(kpi.avgPerDayUsd)}
+
Gem. per dag
+
+
+
+ {fmtUsd(kpi.cacheSavingsUsd)} +
+
Cache-besparing
+
+
+
+ {kpi.topModelId ? fmtUsd(kpi.topModelCostUsd) : '—'} +
+
+ Top model{kpi.topModelId ? `: ${shortenModel(kpi.topModelId)}` : ''} +
+
+
+ {periodSelector} +
+ +
+ {/* Daily cost */} +
+
Kosten per dag
+ {byDay.length === 0 ? ( +

Geen data

+ ) : ( + + + (v as string).slice(5)} + /> + fmtUsd(v as number, 2)} + /> + [fmtUsd(Number(value), 4), 'Kosten']} + /> + + + + )} +
+ + {/* Per model */} +
+
Kosten per model
+ {modelData.length === 0 ? ( +

Geen data

+ ) : ( + + + fmtUsd(v as number, 2)} + /> + + [fmtUsd(Number(value), 4), 'Kosten']} /> + + + + )} +
+ + {/* Per kind */} +
+
Kosten per job-kind
+ {kindData.length === 0 ? ( +

Geen data

+ ) : ( + + + fmtUsd(v as number, 2)} + /> + + [fmtUsd(Number(value), 4), 'Kosten']} /> + + + + )} +
+ + {/* Cache efficiency */} +
+
Cache efficiency
+ {cache.cacheReadTokens + cache.uncachedInputTokens === 0 ? ( +

Geen data

+ ) : ( + <> + + + + {cacheData.map((_, i) => ( + + ))} + + [ + Number(value).toLocaleString() + ' tokens', + String(name), + ]} + /> + + +

+ {(cache.cacheHitRatio * 100).toFixed(1)}%{' '} + cache hit ·{' '} + + {fmtUsd(cache.savingsUsd)} + {' '} + bespaard +

+ + )} +
+
+
+ ) +} diff --git a/app/(app)/insights/page.tsx b/app/(app)/insights/page.tsx index 9b90641..802258c 100644 --- a/app/(app)/insights/page.tsx +++ b/app/(app)/insights/page.tsx @@ -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(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) )} + {/* Cost analyse */} +
+

Cost analyse

+ +
+ {/* Plan-quality */}

Plan-quality

diff --git a/docs/INDEX.md b/docs/INDEX.md index 8e7a080..e7f4d51 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -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 | diff --git a/docs/plans/PBI-78-cost-analysis-widget.md b/docs/plans/PBI-78-cost-analysis-widget.md new file mode 100644 index 0000000..4847656 --- /dev/null +++ b/docs/plans/PBI-78-cost-analysis-widget.md @@ -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 ' 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 +export async function getCostByDay(userId: string, period: Period): Promise +export async function getCostByModel(userId: string, period: Period): Promise +export async function getCostByKind(userId: string, period: Period): Promise +export async function getCacheEfficiency(userId: string, period: Period): Promise +``` + +**Belangrijke details:** + +- Alle queries: `WHERE cj.user_id = ${userId} AND cj.status = 'DONE' AND cj.finished_at >= ` +- 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: `` met één bar (cost in USD), x-as = dag (`MM-DD`), tooltip toont `$X.XXXX` +- Model: `` met model_id labels — beperkt tot top 5 +- Kind: `` met kind labels — beperkt tot top 5 +- Cache: `` 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 +
+

Cost analyse

+ +
+``` + +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 = '' 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%) diff --git a/lib/insights/cost-analysis.ts b/lib/insights/cost-analysis.ts new file mode 100644 index 0000000..f486b4e --- /dev/null +++ b/lib/insights/cost-analysis.ts @@ -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 { + const start = periodStart(period) + const days = Math.max(periodToDays(period), 1) + + const [kpiRows, topModelRows] = await Promise.all([ + prisma.$queryRaw` + 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` + 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 { + const start = periodStart(period) + + const rows = await prisma.$queryRaw` + 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 { + const start = periodStart(period) + + const rows = await prisma.$queryRaw` + 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 { + const start = periodStart(period) + + const rows = await prisma.$queryRaw` + 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 { + const start = periodStart(period) + + const rows = await prisma.$queryRaw` + 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), + } +}