feat: getBurndownData helper + computeBurndownDays (lib/insights/burndown.ts) (#34)
Server-side aggregatie per active sprint: bouwt time-series met remaining en ideal per dag. Inclusief 4 Vitest-unit-tests voor de pure computeBurndownDays functie. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
79c9661ebd
commit
fb3e55b9c0
2 changed files with 144 additions and 0 deletions
57
__tests__/lib/insights/burndown.test.ts
Normal file
57
__tests__/lib/insights/burndown.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('@/lib/prisma', () => ({ prisma: {} }))
|
||||||
|
|
||||||
|
import { computeBurndownDays } from '@/lib/insights/burndown'
|
||||||
|
|
||||||
|
describe('computeBurndownDays', () => {
|
||||||
|
it('5-day sprint: remaining and ideal match spec', () => {
|
||||||
|
const start = new Date('2024-01-01T00:00:00.000Z')
|
||||||
|
const end = new Date('2024-01-05T00:00:00.000Z')
|
||||||
|
|
||||||
|
const tasks = [
|
||||||
|
{ status: 'DONE', updated_at: new Date('2024-01-02T12:00:00.000Z') },
|
||||||
|
{ status: 'DONE', updated_at: new Date('2024-01-04T12:00:00.000Z') },
|
||||||
|
{ status: 'IN_PROGRESS', updated_at: new Date('2024-01-05T12:00:00.000Z') },
|
||||||
|
]
|
||||||
|
|
||||||
|
const days = computeBurndownDays(tasks, start, end)
|
||||||
|
|
||||||
|
expect(days).toHaveLength(5)
|
||||||
|
expect(days.map(d => d.remaining)).toEqual([3, 2, 2, 1, 1])
|
||||||
|
expect(days.map(d => d.ideal)).toEqual([3, 2.25, 1.5, 0.75, 0])
|
||||||
|
expect(days.map(d => d.day)).toEqual([
|
||||||
|
'2024-01-01',
|
||||||
|
'2024-01-02',
|
||||||
|
'2024-01-03',
|
||||||
|
'2024-01-04',
|
||||||
|
'2024-01-05',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty array when end is before start', () => {
|
||||||
|
const start = new Date('2024-01-05T00:00:00.000Z')
|
||||||
|
const end = new Date('2024-01-01T00:00:00.000Z')
|
||||||
|
expect(computeBurndownDays([], start, end)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('single-day sprint has ideal = 0', () => {
|
||||||
|
const day = new Date('2024-01-01T00:00:00.000Z')
|
||||||
|
const tasks = [{ status: 'TO_DO', updated_at: new Date('2024-01-01T08:00:00.000Z') }]
|
||||||
|
const days = computeBurndownDays(tasks, day, day)
|
||||||
|
expect(days).toHaveLength(1)
|
||||||
|
expect(days[0].ideal).toBe(0)
|
||||||
|
expect(days[0].remaining).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('all tasks done on first day: remaining drops to 0', () => {
|
||||||
|
const start = new Date('2024-01-01T00:00:00.000Z')
|
||||||
|
const end = new Date('2024-01-03T00:00:00.000Z')
|
||||||
|
const tasks = [
|
||||||
|
{ status: 'DONE', updated_at: new Date('2024-01-01T10:00:00.000Z') },
|
||||||
|
{ status: 'DONE', updated_at: new Date('2024-01-01T11:00:00.000Z') },
|
||||||
|
]
|
||||||
|
const days = computeBurndownDays(tasks, start, end)
|
||||||
|
expect(days.map(d => d.remaining)).toEqual([0, 0, 0])
|
||||||
|
})
|
||||||
|
})
|
||||||
87
lib/insights/burndown.ts
Normal file
87
lib/insights/burndown.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
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
|
||||||
|
// 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,
|
||||||
|
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)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue