From fb3e55b9c032b9c6128cb2ae4de4aee60927da1a Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Fri, 1 May 2026 16:41:12 +0200 Subject: [PATCH] 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 --- __tests__/lib/insights/burndown.test.ts | 57 ++++++++++++++++ lib/insights/burndown.ts | 87 +++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 __tests__/lib/insights/burndown.test.ts create mode 100644 lib/insights/burndown.ts diff --git a/__tests__/lib/insights/burndown.test.ts b/__tests__/lib/insights/burndown.test.ts new file mode 100644 index 0000000..a85b9e9 --- /dev/null +++ b/__tests__/lib/insights/burndown.test.ts @@ -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]) + }) +}) diff --git a/lib/insights/burndown.ts b/lib/insights/burndown.ts new file mode 100644 index 0000000..551d216 --- /dev/null +++ b/lib/insights/burndown.ts @@ -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 { + 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) +}