feat(PBI-59): Sprint.code (SP-N sequentieel per product)

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:
Janpeter Visser 2026-05-07 20:03:28 +02:00
parent 16f01283ef
commit 9b299198cd
20 changed files with 97 additions and 29 deletions

View file

@ -37,7 +37,7 @@ const JOB_INCLUDE = {
task: { select: { code: true, title: true, description: true, implementation_plan: true } },
idea: { select: { code: true, title: true, description: true, grill_md: true, plan_md: true } },
product: { select: { name: true } },
sprint_run: { include: { sprint: { select: { sprint_goal: true } } } },
sprint_run: { include: { sprint: { select: { sprint_goal: true, code: true } } } },
} as const
type RawJob = {
@ -72,7 +72,7 @@ type RawJob = {
plan_md: string | null
} | null
product: { name: string }
sprint_run: { sprint: { sprint_goal: string } } | null
sprint_run: { sprint: { sprint_goal: string; code: string } } | null
}
type PriceRow = {
@ -122,7 +122,7 @@ function mapJob(j: RawJob, priceMap: Map<string, PriceRow>): JobWithRelations {
ideaCode: j.idea?.code ?? null,
ideaTitle: j.idea?.title ?? null,
sprintGoal: j.sprint_run?.sprint.sprint_goal ?? null,
sprintCode: null,
sprintCode: j.sprint_run?.sprint.code ?? null,
productName: j.product.name,
modelId: j.model_id,
inputTokens: j.input_tokens,

View file

@ -13,6 +13,7 @@ import {
} from '@/lib/schemas/sprint'
import { enforceUserRateLimit } from '@/lib/rate-limit'
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
@ -54,15 +55,20 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
})
if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id, code: 422 }
const sprint = await prisma.sprint.create({
data: {
product_id: parsed.data.productId,
sprint_goal: parsed.data.sprint_goal,
status: 'ACTIVE',
start_date: parsed.data.start_date,
end_date: parsed.data.end_date,
},
})
const sprint = await createWithCodeRetry(
() => generateNextSprintCode(parsed.data.productId),
(code) =>
prisma.sprint.create({
data: {
product_id: parsed.data.productId,
code,
sprint_goal: parsed.data.sprint_goal,
status: 'ACTIVE',
start_date: parsed.data.start_date,
end_date: parsed.data.end_date,
},
}),
)
revalidatePath(`/products/${parsed.data.productId}`)
return { success: true, sprintId: sprint.id }