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>
This commit is contained in:
parent
16f01283ef
commit
a268df3680
20 changed files with 97 additions and 29 deletions
|
|
@ -15,7 +15,7 @@ interface Props {
|
|||
}
|
||||
|
||||
interface TooltipPayload {
|
||||
payload?: { total: number; alignedRatio: number; sprintGoal: string }
|
||||
payload?: { total: number; alignedRatio: number; sprintCode: string; sprintGoal: string }
|
||||
}
|
||||
|
||||
function CustomTooltip({ active, payload }: { active?: boolean; payload?: TooltipPayload[] }) {
|
||||
|
|
@ -25,7 +25,10 @@ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Toolti
|
|||
const aligned = Math.round((d.alignedRatio / 100) * d.total)
|
||||
return (
|
||||
<div className="rounded border border-border bg-surface-container px-3 py-2 text-sm shadow">
|
||||
<p className="font-medium text-foreground">{d.sprintGoal}</p>
|
||||
<p className="font-medium text-foreground">
|
||||
<span className="font-mono text-xs text-muted-foreground mr-2">{d.sprintCode}</span>
|
||||
{d.sprintGoal}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{aligned} / {d.total} aligned ({d.alignedRatio}%)
|
||||
</p>
|
||||
|
|
@ -33,10 +36,6 @@ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Toolti
|
|||
)
|
||||
}
|
||||
|
||||
function sprintLabel(goal: string): string {
|
||||
return goal.length > 20 ? goal.slice(0, 18) + '…' : goal
|
||||
}
|
||||
|
||||
export function AlignmentTrend({ trend }: Props) {
|
||||
if (trend.length === 0) {
|
||||
return (
|
||||
|
|
@ -48,7 +47,7 @@ export function AlignmentTrend({ trend }: Props) {
|
|||
|
||||
const data = trend.map(p => ({
|
||||
...p,
|
||||
label: sprintLabel(p.sprintGoal),
|
||||
label: p.sprintCode,
|
||||
}))
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
interface SprintInfo {
|
||||
sprintId: string
|
||||
sprintCode: string
|
||||
productName: string
|
||||
sprintGoal: string
|
||||
taskCount: number
|
||||
|
|
@ -33,6 +34,7 @@ export function SprintInfoStrip({ sprints }: Props) {
|
|||
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="font-mono text-xs text-muted-foreground">{s.sprintCode}</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`}
|
||||
|
|
|
|||
|
|
@ -35,11 +35,9 @@ export function VelocityChart({ data }: Props) {
|
|||
type Row = { sprintLabel: string } & Record<string, number | string>
|
||||
const grouped = new Map<string, Row>()
|
||||
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.set(key, { sprintLabel: s.sprintCode })
|
||||
}
|
||||
grouped.get(key)![s.productName] = s.doneCount
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps)
|
|||
where: { status: 'ACTIVE', product: productAccessFilter(userId) },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
sprint_goal: true,
|
||||
created_at: true,
|
||||
product: { select: { id: true, name: true } },
|
||||
|
|
@ -88,6 +89,7 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps)
|
|||
const nowMs = Date.now()
|
||||
const sprintInfos = activeSprints.map(s => ({
|
||||
sprintId: s.id,
|
||||
sprintCode: s.code,
|
||||
productId: s.product.id,
|
||||
productName: s.product.name,
|
||||
sprintGoal: s.sprint_goal,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
|||
where: { product_id: id, status: { in: ['ACTIVE', 'FAILED'] } },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
sprint_goal: true,
|
||||
status: true,
|
||||
start_date: true,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue