Scrum4Me/lib/insights/velocity.ts
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

65 lines
1.9 KiB
TypeScript

import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
export interface VelocitySprint {
sprintId: string
sprintCode: string
sprintGoal: string
productId: string
productName: string
doneCount: number
completedAt: string
}
export interface VelocityData {
sprints: VelocitySprint[]
productNames: { id: string; name: string }[]
}
export async function getVelocity(userId: string, sprintsBack = 5): Promise<VelocityData> {
const sprints = await prisma.sprint.findMany({
where: {
status: 'COMPLETED',
product: productAccessFilter(userId),
},
orderBy: { completed_at: 'desc' },
take: sprintsBack,
select: {
id: true,
code: true,
sprint_goal: true,
completed_at: true,
product: { select: { id: true, name: true } },
tasks: { select: { status: true } },
},
})
// Reverse to chronological order (oldest first, for x-axis)
// Type-guard so the narrowed array carries `completed_at: Date` (not Date | null).
// A `.filter(s => s.completed_at != null)` alone does NOT narrow the element type.
type SprintWithCompletedAt = (typeof sprints)[number] & { completed_at: Date }
const chronological = [...sprints]
.filter((s): s is SprintWithCompletedAt => s.completed_at != null)
.reverse()
const result: VelocitySprint[] = chronological.map(sprint => ({
sprintId: sprint.id,
sprintCode: sprint.code,
sprintGoal: sprint.sprint_goal,
productId: sprint.product.id,
productName: sprint.product.name,
doneCount: sprint.tasks.filter(t => t.status === 'DONE').length,
completedAt: sprint.completed_at.toISOString(),
}))
const seen = new Set<string>()
const productNames: { id: string; name: string }[] = []
for (const s of result) {
if (!seen.has(s.productId)) {
seen.add(s.productId)
productNames.push({ id: s.productId, name: s.productName })
}
}
return { sprints: result, productNames }
}