Scrum4Me/lib/insights/cost-analysis.ts
Janpeter Visser a1e6ec35e5
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>
2026-05-10 12:59:45 +02:00

291 lines
8.7 KiB
TypeScript

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