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 */}
+
+
+ {/* Velocity */}
+
+
+ {/* Backlog health */}
+
)
}