feat: /insights page — SprintInfoStrip, BurndownChart, SprintStatusDonut integratie
Sprint Health pagina met info-strip per sprint (daysLeft gekleurd), gestackte burndown charts en status-donut. Empty-state bij geen active sprints, MissingDatesNotice deeplink. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fe89368848
commit
c0646ec039
4 changed files with 239 additions and 0 deletions
49
app/(app)/insights/components/burndown-chart.tsx
Normal file
49
app/(app)/insights/components/burndown-chart.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
'use client'
|
||||
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import type { BurndownSprint } from '@/lib/insights/burndown'
|
||||
|
||||
interface Props {
|
||||
sprint: BurndownSprint
|
||||
}
|
||||
|
||||
export function BurndownChart({ sprint }: Props) {
|
||||
if (sprint.days.length === 0) {
|
||||
return <p className="text-muted-foreground text-sm">Geen sprint-data</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-foreground">
|
||||
{sprint.productName} — {sprint.sprintGoal}
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<LineChart data={sprint.days}>
|
||||
<XAxis dataKey="day" tick={{ fontSize: 11 }} />
|
||||
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line
|
||||
dataKey="ideal"
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeDasharray="4 4"
|
||||
dot={false}
|
||||
/>
|
||||
<Line
|
||||
dataKey="remaining"
|
||||
stroke="var(--status-in-progress)"
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
app/(app)/insights/components/sprint-info-strip.tsx
Normal file
45
app/(app)/insights/components/sprint-info-strip.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
'use client'
|
||||
|
||||
interface SprintInfo {
|
||||
sprintId: string
|
||||
productName: string
|
||||
sprintGoal: string
|
||||
taskCount: number
|
||||
daysLeft: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sprints: SprintInfo[]
|
||||
}
|
||||
|
||||
function daysLeftColor(daysLeft: number): string {
|
||||
if (daysLeft >= 3) return 'text-[color:var(--status-done)]'
|
||||
if (daysLeft >= 1) return 'text-[color:var(--priority-medium)]'
|
||||
return 'text-[color:var(--priority-critical)]'
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
return text.length > max ? text.slice(0, max) + '…' : text
|
||||
}
|
||||
|
||||
export function SprintInfoStrip({ sprints }: Props) {
|
||||
if (sprints.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{sprints.map(s => (
|
||||
<div
|
||||
key={s.sprintId}
|
||||
className="flex items-center gap-3 rounded-lg border border-border bg-surface-container px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-medium text-foreground">{s.productName}</span>
|
||||
<span className="text-muted-foreground">{truncate(s.sprintGoal, 60)}</span>
|
||||
<span className={`font-mono tabular-nums ${daysLeftColor(s.daysLeft)}`}>
|
||||
{s.daysLeft > 0 ? `${s.daysLeft}d over` : `${Math.abs(s.daysLeft)}d over tijd`}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{s.taskCount} tasks</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
app/(app)/insights/components/sprint-status-donut.tsx
Normal file
48
app/(app)/insights/components/sprint-status-donut.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
'use client'
|
||||
|
||||
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||
import type { StatusCount } from '@/lib/insights/sprint-status'
|
||||
|
||||
interface Props {
|
||||
data: StatusCount[]
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
TO_DO: 'var(--status-todo)',
|
||||
IN_PROGRESS: 'var(--status-in-progress)',
|
||||
DONE: 'var(--status-done)',
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
TO_DO: 'To do',
|
||||
IN_PROGRESS: 'In progress',
|
||||
DONE: 'Done',
|
||||
}
|
||||
|
||||
export function SprintStatusDonut({ data }: Props) {
|
||||
if (data.length === 0) {
|
||||
return <p className="text-muted-foreground text-sm">Geen actieve sprint-taken</p>
|
||||
}
|
||||
|
||||
const labeled = data.map(d => ({ ...d, name: STATUS_LABELS[d.status] ?? d.status }))
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={labeled}
|
||||
dataKey="count"
|
||||
nameKey="name"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
>
|
||||
{labeled.map(entry => (
|
||||
<Cell key={entry.status} fill={STATUS_COLORS[entry.status]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
97
app/(app)/insights/page.tsx
Normal file
97
app/(app)/insights/page.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue