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>
90 lines
2.7 KiB
TypeScript
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>
|
|
)
|
|
}
|