Re-introduce the 3 unique files from closed PRs #37 and #40 that overlap-merged with already-landed sub-PRs (#34, #35, #36, #38, #39): - app/(app)/insights/page.tsx — Server Component dat alle helpers parallel aanroept en de 5 sectie-Cards rendert (Sprint Health, Plan-quality, Agent throughput, Velocity, Backlog health) - app/(app)/insights/components/sprint-info-strip.tsx — chips per active sprint met productname + goal + dagen-over + taakcount - app/(app)/insights/components/alignment-trend.tsx — Recharts LineChart die % ALIGNED jobs per sprint over laatste 5 sprints toont - lib/insights/verify-stats.ts — TrendPoint type + getAlignmentTrend helper (uitgebreid van PR #38) Plus dependency: recharts (was in package.json van #37/#40 die we sloten). Tests: 290/290 groen, tsc clean, lint clean. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
97 lines
3.1 KiB
TypeScript
97 lines
3.1 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 { SprintInfoStrip } from './components/sprint-info-strip'
|
|
import { BurndownChart } from './components/burndown-chart'
|
|
import { SprintStatusDonut } from './components/sprint-status-donut'
|
|
|
|
const DAY_MS = 86_400_000
|
|
const ASSUMED_SPRINT_DAYS = 14
|
|
|
|
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() {
|
|
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
|
const userId = session.userId!
|
|
|
|
const [burndownSprints, statusBreakdown, activeSprints] = await Promise.all([
|
|
getBurndownData(userId),
|
|
getSprintStatusBreakdown(userId),
|
|
prisma.sprint.findMany({
|
|
where: { status: 'ACTIVE', product: productAccessFilter(userId) },
|
|
select: {
|
|
id: true,
|
|
sprint_goal: true,
|
|
created_at: true,
|
|
product: { select: { id: true, name: true } },
|
|
tasks: { select: { id: true } },
|
|
},
|
|
}),
|
|
])
|
|
|
|
if (activeSprints.length === 0) {
|
|
return (
|
|
<div className="p-6 max-w-4xl mx-auto w-full">
|
|
<h1 className="text-xl font-medium text-foreground mb-6">Sprint Health</h1>
|
|
<p className="text-muted-foreground">
|
|
Geen active sprints — start er een via /products/[id]/sprint
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const nowMs = new Date().getTime()
|
|
const sprintInfos = activeSprints.map(s => ({
|
|
sprintId: s.id,
|
|
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-6 max-w-4xl mx-auto w-full">
|
|
<h1 className="text-xl font-medium text-foreground">Sprint Health</h1>
|
|
|
|
<SprintInfoStrip sprints={sprintInfos} />
|
|
|
|
<div className="grid 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>
|
|
</div>
|
|
)
|
|
}
|