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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue