feat: lib/insights — burndown en sprint-status helpers

getBurndownData en getSprintStatusBreakdown server-side helpers voor de insights page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-01 15:56:09 +02:00
parent 1646a08754
commit fe89368848
2 changed files with 124 additions and 0 deletions

86
lib/insights/burndown.ts Normal file
View file

@ -0,0 +1,86 @@
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
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
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,
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,
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)
}

View file

@ -0,0 +1,38 @@
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
export type SprintStatusGroup = 'TO_DO' | 'IN_PROGRESS' | 'DONE'
export interface StatusCount {
status: SprintStatusGroup
count: number
}
function toGroup(status: string): SprintStatusGroup {
if (status === 'DONE') return 'DONE'
if (status === 'TO_DO') return 'TO_DO'
return 'IN_PROGRESS'
}
export async function getSprintStatusBreakdown(userId: string): Promise<StatusCount[]> {
const tasks = await prisma.task.findMany({
where: {
story: {
sprint: {
status: 'ACTIVE',
product: productAccessFilter(userId),
},
},
},
select: { status: true },
})
const counts: Record<SprintStatusGroup, number> = { TO_DO: 0, IN_PROGRESS: 0, DONE: 0 }
for (const t of tasks) {
counts[toGroup(t.status)]++
}
return (Object.entries(counts) as [SprintStatusGroup, number][])
.filter(([, count]) => count > 0)
.map(([status, count]) => ({ status, count }))
}