diff --git a/app/(app)/insights/components/backlog-health.tsx b/app/(app)/insights/components/backlog-health.tsx new file mode 100644 index 0000000..193efe9 --- /dev/null +++ b/app/(app)/insights/components/backlog-health.tsx @@ -0,0 +1,90 @@ +'use client' + +import Link from 'next/link' +import { CheckCircle2, AlertTriangle } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { BacklogHealth, StuckTask } from '@/lib/insights/backlog-health' + +interface Props { + data: BacklogHealth +} + +function Counter({ count, label }: { count: number; label: string }) { + const ok = count === 0 + return ( +
+ {ok ? ( + + ) : ( + + )} + {count} + {label} +
+ ) +} + +function StuckRow({ task }: { task: StuckTask }) { + const severity = + task.daysStuck >= 14 + ? 'bg-priority-critical/10 text-priority-critical' + : task.daysStuck >= 7 + ? 'bg-priority-high/10 text-priority-high' + : '' + return ( + + + + {task.title} + + + + {task.productName} + + + {task.daysStuck}d + + + {task.sprintGoal ?? '—'} + + + ) +} + +export function BacklogHealthCard({ data }: Props) { + const { storiesWithoutAc, tasksWithoutPlan, stuckTasks } = data + const allClear = + storiesWithoutAc === 0 && tasksWithoutPlan === 0 && stuckTasks.length === 0 + + return ( +
+

Backlog health

+ +
+ + + +
+ + {allClear ? ( +

Geen stuck tasks 🎉

+ ) : stuckTasks.length > 0 ? ( +
+

+ Stuck tasks (top {stuckTasks.length}) +

+ + + {stuckTasks.map(t => ( + + ))} + +
+
+ ) : null} +
+ ) +} diff --git a/app/(app)/insights/components/velocity-chart.tsx b/app/(app)/insights/components/velocity-chart.tsx new file mode 100644 index 0000000..7cd2d9e --- /dev/null +++ b/app/(app)/insights/components/velocity-chart.tsx @@ -0,0 +1,92 @@ +'use client' + +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + Legend, + ReferenceLine, + ResponsiveContainer, +} from 'recharts' +import type { VelocityData } from '@/lib/insights/velocity' +import { SERIES_COLORS } from '@/lib/chart-colors' + +interface Props { + data: VelocityData +} + +export function VelocityChart({ data }: Props) { + const { sprints, productNames } = data + + if (sprints.length < 2) { + return ( +
+

Velocity

+

+ Velocity wordt zichtbaar na 2+ voltooide sprints (nu: {sprints.length}). +

+
+ ) + } + + // Reshape: [{ sprintLabel, [productName1]: count, [productName2]: count, ... }] + type Row = { sprintLabel: string } & Record + const grouped = new Map() + for (const s of sprints) { + const label = + s.sprintGoal.length > 14 ? s.sprintGoal.slice(0, 14) + '…' : s.sprintGoal + const key = `${s.sprintId}` + if (!grouped.has(key)) { + grouped.set(key, { sprintLabel: label }) + } + grouped.get(key)![s.productName] = s.doneCount + } + const rows = Array.from(grouped.values()) + + // Average across all bars (used for ReferenceLine) + const allCounts = sprints.map(s => s.doneCount) + const avg = allCounts.length > 0 ? allCounts.reduce((a, b) => a + b, 0) / allCounts.length : 0 + + return ( +
+

Velocity (laatste {sprints.length} sprints)

+ + + + + + + {productNames.map((p, i) => ( + + ))} + {avg > 0 && ( + + )} + + +
+ ) +} diff --git a/app/(app)/insights/page.tsx b/app/(app)/insights/page.tsx index 64e16af..77164d5 100644 --- a/app/(app)/insights/page.tsx +++ b/app/(app)/insights/page.tsx @@ -5,32 +5,53 @@ 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 { 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 { 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 }> +} + function MissingDatesNotice({ productId, productName }: { productId: string; productName: string }) { return (

{productName} — sprint heeft geen datums.{' '} - + Stel datums in

) } -export default async function InsightsPage() { +export default async function InsightsPage({ searchParams }: InsightsPageProps) { const session = await getIronSession(await cookies(), sessionOptions) const userId = session.userId! + const { product: filterProductId } = await searchParams - const [burndownSprints, statusBreakdown, activeSprints] = await Promise.all([ + const [ + burndownSprints, + statusBreakdown, + activeSprints, + productList, + verifyStats, + alignmentTrend, + jobsPerDay, + velocity, + backlogHealth, + ] = await Promise.all([ getBurndownData(userId), getSprintStatusBreakdown(userId), prisma.sprint.findMany({ @@ -43,20 +64,21 @@ export default async function InsightsPage() { 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), ]) - if (activeSprints.length === 0) { - return ( -
-

Sprint Health

-

- Geen active sprints — start er een via /products/[id]/sprint -

-
- ) - } - - const nowMs = new Date().getTime() + // 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, productId: s.product.id, @@ -65,33 +87,72 @@ export default async function InsightsPage() { 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 ( -
-

Sprint Health

+
+

Insights

- + {/* Sprint Health */} +
+

Sprint Health

+ {activeSprints.length === 0 ? ( +

+ Geen active sprints — start er een via /products/[id]/sprint. +

+ ) : ( + <> + +
+
+ {sprintInfos.map(s => { + const burndown = burndownMap.get(s.sprintId) + if (!burndown || burndown.days.length === 0) { + return ( + + ) + } + return + })} +
+ +
+ + )} +
-
-
- {sprintInfos.map(s => { - const burndown = burndownMap.get(s.sprintId) - if (!burndown || burndown.days.length === 0) { - return ( - - ) - } - return - })} -
- -
+ {/* Plan-quality */} +
+

Plan-quality

+ + {alignmentTrend.length > 0 && } +
+ + {/* Agent throughput */} +
+

Agent throughput

+ +
+ + {/* Velocity */} +
+

Velocity

+ +
+ + {/* Backlog health */} +
+

Backlog health

+ +
) }