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>