Scrum4Me/app/(app)/insights/components/velocity-chart.tsx
Janpeter Visser a268df3680
feat(PBI-59): Sprint.code (SP-N sequentieel per product) (#153)
Voegt een verplicht code-veld toe aan Sprint, sequentieel per product
(consistent met PBI-N, ST-NNN, T-N).

- **Schema** — `Sprint.code String @db.VarChar(30)` + `@@unique([product_id, code])`
- **Migratie** — voegt kolom toe als nullable, backfillt bestaande sprints
  via `ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY created_at)`
  als `SP-N`, en zet daarna NOT NULL + UNIQUE.
- **Generator** — `generateNextSprintCode(productId)` in lib/code-server.ts
  volgt het patroon van story/pbi/task; createSprintAction gebruikt
  `createWithCodeRetry` voor race-bescherming.
- **Seed** — sprint-counter per product (`SP-1`, `SP-2`, ...).

Zichtbaar in:
- Sprint-header (`Product › Sprint actief · SP-3`)
- JobCard + JobDetailPane voor SPRINT_IMPLEMENTATION jobs
- Insights: VelocityChart x-axis (compacter dan goal-truncated),
  AlignmentTrend tooltip, SprintInfoStrip
- actions/jobs-page.ts: `sprintCode` is weer een echte code i.p.v. null

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:10:16 +02:00

90 lines
2.7 KiB
TypeScript

'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 (
<div className="rounded-lg border border-border p-4 space-y-2">
<h2 className="text-sm font-medium">Velocity</h2>
<p className="text-sm text-muted-foreground">
Velocity wordt zichtbaar na 2+ voltooide sprints (nu: {sprints.length}).
</p>
</div>
)
}
// Reshape: [{ sprintLabel, [productName1]: count, [productName2]: count, ... }]
type Row = { sprintLabel: string } & Record<string, number | string>
const grouped = new Map<string, Row>()
for (const s of sprints) {
const key = `${s.sprintId}`
if (!grouped.has(key)) {
grouped.set(key, { sprintLabel: s.sprintCode })
}
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 (
<div className="rounded-lg border border-border p-4 space-y-2">
<h2 className="text-sm font-medium">Velocity (laatste {sprints.length} sprints)</h2>
<ResponsiveContainer width="100%" height={240}>
<BarChart data={rows}>
<XAxis
dataKey="sprintLabel"
tick={{ fontSize: 11 }}
stroke="var(--muted-foreground)"
/>
<YAxis tick={{ fontSize: 11 }} stroke="var(--muted-foreground)" />
<Tooltip
contentStyle={{
background: 'var(--popover)',
border: '1px solid var(--border)',
borderRadius: 6,
fontSize: 12,
}}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
{productNames.map((p, i) => (
<Bar
key={p.id}
dataKey={p.name}
fill={SERIES_COLORS[i % SERIES_COLORS.length]}
radius={[2, 2, 0, 0]}
/>
))}
{avg > 0 && (
<ReferenceLine
y={avg}
stroke="var(--muted-foreground)"
strokeDasharray="3 3"
label={{ value: `avg ${avg.toFixed(1)}`, fontSize: 10, fill: 'var(--muted-foreground)' }}
/>
)}
</BarChart>
</ResponsiveContainer>
</div>
)
}