From fe89368848230bdc27b95afb13c0fe36ec50fcdd Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Fri, 1 May 2026 15:56:09 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20lib/insights=20=E2=80=94=20burndown=20e?= =?UTF-8?q?n=20sprint-status=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getBurndownData en getSprintStatusBreakdown server-side helpers voor de insights page. Co-Authored-By: Claude Sonnet 4.6 --- lib/insights/burndown.ts | 86 +++++++++++++++++++++++++++++++++++ lib/insights/sprint-status.ts | 38 ++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 lib/insights/burndown.ts create mode 100644 lib/insights/sprint-status.ts diff --git a/lib/insights/burndown.ts b/lib/insights/burndown.ts new file mode 100644 index 0000000..7ba1d97 --- /dev/null +++ b/lib/insights/burndown.ts @@ -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 { + 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) +} diff --git a/lib/insights/sprint-status.ts b/lib/insights/sprint-status.ts new file mode 100644 index 0000000..783ce4b --- /dev/null +++ b/lib/insights/sprint-status.ts @@ -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 { + const tasks = await prisma.task.findMany({ + where: { + story: { + sprint: { + status: 'ACTIVE', + product: productAccessFilter(userId), + }, + }, + }, + select: { status: true }, + }) + + const counts: Record = { 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 })) +}