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.3 KiB
TypeScript
90 lines
2.3 KiB
TypeScript
import { prisma } from '@/lib/prisma'
|
|
import { productAccessFilter } from '@/lib/product-access'
|
|
|
|
export interface BurndownDay {
|
|
day: string
|
|
remaining: number
|
|
ideal: number
|
|
}
|
|
|
|
export interface BurndownSprint {
|
|
sprintId: string
|
|
sprintCode: string
|
|
productId: string
|
|
productName: string
|
|
sprintGoal: string
|
|
days: BurndownDay[]
|
|
}
|
|
|
|
const DAY_MS = 86_400_000
|
|
|
|
function toUTCMidnight(d: Date): Date {
|
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()))
|
|
}
|
|
|
|
export function computeBurndownDays(
|
|
tasks: { status: string; updated_at: Date }[],
|
|
startDate: Date,
|
|
endDate: Date,
|
|
): BurndownDay[] {
|
|
const start = toUTCMidnight(startDate)
|
|
const end = toUTCMidnight(endDate)
|
|
const total = tasks.length
|
|
// n = number of intervals (end - start in days)
|
|
const n = Math.round((end.getTime() - start.getTime()) / DAY_MS)
|
|
|
|
const days: BurndownDay[] = []
|
|
|
|
for (let i = 0; ; i++) {
|
|
const dayStart = new Date(start.getTime() + i * DAY_MS)
|
|
if (dayStart > end) break
|
|
|
|
const nextDay = new Date(dayStart.getTime() + DAY_MS)
|
|
const done = tasks.filter(t => t.status === 'DONE' && t.updated_at < nextDay).length
|
|
const ideal = n === 0 ? 0 : Math.round((total * (n - i) / n) * 100) / 100
|
|
|
|
days.push({
|
|
day: dayStart.toISOString().slice(0, 10),
|
|
remaining: total - done,
|
|
ideal,
|
|
})
|
|
}
|
|
|
|
return days
|
|
}
|
|
|
|
export async function getBurndownData(userId: string): Promise<BurndownSprint[]> {
|
|
const now = new Date()
|
|
|
|
const sprints = await prisma.sprint.findMany({
|
|
where: {
|
|
status: 'ACTIVE',
|
|
product: productAccessFilter(userId),
|
|
},
|
|
select: {
|
|
id: true,
|
|
code: true,
|
|
sprint_goal: true,
|
|
created_at: true,
|
|
completed_at: true,
|
|
product: { select: { id: true, name: true } },
|
|
tasks: { select: { status: true, updated_at: true } },
|
|
},
|
|
})
|
|
|
|
return sprints
|
|
.map(sprint => {
|
|
const endDate = sprint.completed_at ?? now
|
|
if (endDate <= sprint.created_at) return null
|
|
|
|
return {
|
|
sprintId: sprint.id,
|
|
sprintCode: sprint.code,
|
|
productId: sprint.product.id,
|
|
productName: sprint.product.name,
|
|
sprintGoal: sprint.sprint_goal,
|
|
days: computeBurndownDays(sprint.tasks, sprint.created_at, endDate),
|
|
}
|
|
})
|
|
.filter((s): s is BurndownSprint => s !== null)
|
|
}
|