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:
Janpeter Visser 2026-05-07 20:10:16 +02:00 committed by GitHub
parent 16f01283ef
commit a268df3680
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 97 additions and 29 deletions

View file

@ -9,6 +9,7 @@ export interface BurndownDay {
export interface BurndownSprint {
sprintId: string
sprintCode: string
productId: string
productName: string
sprintGoal: string
@ -62,6 +63,7 @@ export async function getBurndownData(userId: string): Promise<BurndownSprint[]>
},
select: {
id: true,
code: true,
sprint_goal: true,
created_at: true,
completed_at: true,
@ -77,6 +79,7 @@ export async function getBurndownData(userId: string): Promise<BurndownSprint[]>
return {
sprintId: sprint.id,
sprintCode: sprint.code,
productId: sprint.product.id,
productName: sprint.product.name,
sprintGoal: sprint.sprint_goal,

View file

@ -2,6 +2,7 @@ import { prisma } from '@/lib/prisma'
export interface SprintTokenRow {
sprintId: string
sprintCode: string
sprintGoal: string
totalTokens: number
totalCostUsd: number
@ -24,6 +25,7 @@ export interface PbiTokenRow {
type RawSprintRow = {
sprint_id: string
sprint_code: string
sprint_goal: string
total_tokens: bigint
total_cost: number | null
@ -53,6 +55,7 @@ export async function getSprintTokenHistory(
? await prisma.$queryRaw<RawSprintRow[]>`
SELECT
sp.id AS sprint_id,
sp.code AS sprint_code,
sp.sprint_goal,
COALESCE(SUM(cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens), 0) AS total_tokens,
SUM(
@ -70,13 +73,14 @@ export async function getSprintTokenHistory(
WHERE cj.user_id = ${userId}
AND cj.status = 'DONE'
AND cj.product_id = ${productId}
GROUP BY sp.id, sp.sprint_goal
GROUP BY sp.id, sp.code, sp.sprint_goal
ORDER BY sp.created_at DESC
LIMIT ${limit}
`
: await prisma.$queryRaw<RawSprintRow[]>`
SELECT
sp.id AS sprint_id,
sp.code AS sprint_code,
sp.sprint_goal,
COALESCE(SUM(cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens), 0) AS total_tokens,
SUM(
@ -93,13 +97,14 @@ export async function getSprintTokenHistory(
LEFT JOIN model_prices mp ON mp.model_id = cj.model_id
WHERE cj.user_id = ${userId}
AND cj.status = 'DONE'
GROUP BY sp.id, sp.sprint_goal
GROUP BY sp.id, sp.code, sp.sprint_goal
ORDER BY sp.created_at DESC
LIMIT ${limit}
`
return rows.map(r => ({
sprintId: r.sprint_id,
sprintCode: r.sprint_code,
sprintGoal: r.sprint_goal,
totalTokens: Number(r.total_tokens),
totalCostUsd: Number(r.total_cost ?? 0),

View file

@ -3,6 +3,7 @@ import { productAccessFilter } from '@/lib/product-access'
export interface VelocitySprint {
sprintId: string
sprintCode: string
sprintGoal: string
productId: string
productName: string
@ -25,6 +26,7 @@ export async function getVelocity(userId: string, sprintsBack = 5): Promise<Velo
take: sprintsBack,
select: {
id: true,
code: true,
sprint_goal: true,
completed_at: true,
product: { select: { id: true, name: true } },
@ -42,6 +44,7 @@ export async function getVelocity(userId: string, sprintsBack = 5): Promise<Velo
const result: VelocitySprint[] = chronological.map(sprint => ({
sprintId: sprint.id,
sprintCode: sprint.code,
sprintGoal: sprint.sprint_goal,
productId: sprint.product.id,
productName: sprint.product.name,

View file

@ -20,6 +20,7 @@ export interface VerifyResultStats {
export interface TrendPoint {
sprintId: string
sprintCode: string
sprintGoal: string
productName: string
alignedRatio: number
@ -117,6 +118,7 @@ export async function getAlignmentTrend(
take: sprintsBack,
select: {
id: true,
code: true,
sprint_goal: true,
completed_at: true,
product: { select: { name: true } },
@ -137,6 +139,7 @@ export async function getAlignmentTrend(
const aligned = jobs.filter(j => j.verify_result === 'ALIGNED').length
return {
sprintId: sprint.id,
sprintCode: sprint.code,
sprintGoal: sprint.sprint_goal,
productName: sprint.product.name,
alignedRatio: jobs.length > 0 ? Math.round((aligned / jobs.length) * 100) : 0,