Scrum4Me/app/(app)/insights/page.tsx
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

212 lines
7.4 KiB
TypeScript

import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { SessionData, sessionOptions } from '@/lib/session'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { getBurndownData } from '@/lib/insights/burndown'
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'
import { BurndownChart } from './components/burndown-chart'
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'
const DAY_MS = 86_400_000
const ASSUMED_SPRINT_DAYS = 14
interface InsightsPageProps {
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 }) {
return (
<p className="text-muted-foreground text-sm">
{productName} sprint heeft geen datums.{' '}
<a href={`/products/${productId}/sprint`} className="underline text-primary">
Stel datums in
</a>
</p>
)
}
export default async function InsightsPage({ searchParams }: InsightsPageProps) {
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
const userId = session.userId!
const { product: filterProductId, period: rawPeriod } = await searchParams
const period = parsePeriod(rawPeriod)
const [
burndownSprints,
statusBreakdown,
activeSprints,
productList,
verifyStats,
alignmentTrend,
jobsPerDay,
velocity,
backlogHealth,
costKpi,
costByDay,
costByModel,
costByKind,
cacheEff,
] = await Promise.all([
getBurndownData(userId),
getSprintStatusBreakdown(userId),
prisma.sprint.findMany({
where: { status: 'OPEN', product: productAccessFilter(userId) },
select: {
id: true,
code: true,
sprint_goal: true,
created_at: true,
product: { select: { id: true, name: true } },
tasks: { select: { id: true } },
},
}),
prisma.product.findMany({
where: productAccessFilter(userId),
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
getVerifyResultStats(userId, 30),
getAlignmentTrend(userId, 5),
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 ?? ''
const tokenStats = await (activeSprints.length > 0 && filterProductId
? getTokenStats(userId, activeSprintId)
: Promise.resolve({ kpi: { totalTokens: 0, totalCostUsd: 0, avgCostPerJob: 0, jobCount: 0 }, jobs: [] }))
// Date.now is an impure call but used once per request — safe in a Server Component.
// eslint-disable-next-line react-hooks/purity
const nowMs = Date.now()
const sprintInfos = activeSprints.map(s => ({
sprintId: s.id,
sprintCode: s.code,
productId: s.product.id,
productName: s.product.name,
sprintGoal: s.sprint_goal,
taskCount: s.tasks.length,
daysLeft: ASSUMED_SPRINT_DAYS - Math.floor((nowMs - s.created_at.getTime()) / DAY_MS),
}))
const burndownMap = new Map(burndownSprints.map(b => [b.sprintId, b]))
return (
<div className="p-6 space-y-8 max-w-6xl mx-auto w-full">
<h1 className="text-2xl font-semibold text-foreground">Insights</h1>
{/* Sprint Health */}
<section className="space-y-3">
<h2 className="text-lg font-medium text-foreground">Sprint Health</h2>
{activeSprints.length === 0 ? (
<p className="text-muted-foreground text-sm">
Geen active sprints start er een via /products/[id]/sprint.
</p>
) : (
<>
<SprintInfoStrip sprints={sprintInfos} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-4">
{sprintInfos.map(s => {
const burndown = burndownMap.get(s.sprintId)
if (!burndown || burndown.days.length === 0) {
return (
<MissingDatesNotice
key={s.sprintId}
productId={s.productId}
productName={s.productName}
/>
)
}
return <BurndownChart key={s.sprintId} sprint={burndown} />
})}
</div>
<SprintStatusDonut data={statusBreakdown} />
</div>
</>
)}
</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>
<PlanQualityCard stats={verifyStats} nowMs={nowMs} />
{alignmentTrend.length > 0 && <AlignmentTrend trend={alignmentTrend} />}
</section>
{/* Agent throughput */}
<section className="space-y-3">
<h2 className="text-lg font-medium text-foreground">Agent throughput</h2>
<AgentThroughputCard
data={jobsPerDay}
productList={productList}
currentProductId={filterProductId}
/>
</section>
{/* Token usage */}
<section className="space-y-3">
<h2 className="text-lg font-medium text-foreground">Token gebruik</h2>
<TokenUsageCard kpi={tokenStats.kpi} jobs={tokenStats.jobs} />
</section>
{/* Velocity */}
<section className="space-y-3">
<h2 className="text-lg font-medium text-foreground">Velocity</h2>
<VelocityChart data={velocity} />
</section>
{/* Backlog health */}
<section className="space-y-3">
<h2 className="text-lg font-medium text-foreground">Backlog health</h2>
<BacklogHealthCard data={backlogHealth} />
</section>
</div>
)
}