From 219d54b3e5586a84494f440d55c3571808c847ae Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Sat, 2 May 2026 16:07:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(insights):=20add=20getVelocity=20helper=20?= =?UTF-8?q?=E2=80=94=20DONE-tasks=20per=20completed=20sprint=20(#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggregates task.status=DONE counts across last N completed sprints (default 5), filtered by productAccessFilter and returned in chronological order for x-axis rendering. Co-authored-by: Claude Sonnet 4.6 --- __tests__/lib/insights/velocity.test.ts | 77 +++++++++++++++++++++++++ lib/insights/velocity.ts | 57 ++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 __tests__/lib/insights/velocity.test.ts create mode 100644 lib/insights/velocity.ts diff --git a/__tests__/lib/insights/velocity.test.ts b/__tests__/lib/insights/velocity.test.ts new file mode 100644 index 0000000..535c1fa --- /dev/null +++ b/__tests__/lib/insights/velocity.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi } from 'vitest' + +const { mockFindMany } = vi.hoisted(() => ({ mockFindMany: vi.fn() })) + +vi.mock('@/lib/prisma', () => ({ + prisma: { + sprint: { findMany: mockFindMany }, + }, +})) + +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: () => ({ some: 'filter' }), +})) + +import { getVelocity } from '@/lib/insights/velocity' + +const completedAt = (iso: string) => new Date(iso) + +function makeSprint(id: string, goal: string, productId: string, productName: string, doneCounts: number, completedIso: string) { + const tasks = Array.from({ length: doneCounts }, () => ({ status: 'DONE' })) + return { + id, + sprint_goal: goal, + completed_at: completedAt(completedIso), + product: { id: productId, name: productName }, + tasks, + } +} + +describe('getVelocity', () => { + it('returns 3 sprints in chronological order with correct done counts', async () => { + // DB returns newest-first (orderBy: completed_at desc), getVelocity reverses to oldest-first + mockFindMany.mockResolvedValue([ + makeSprint('s3', 'Sprint C', 'p1', 'Prod A', 3, '2024-03-01T00:00:00.000Z'), + makeSprint('s2', 'Sprint B', 'p1', 'Prod A', 5, '2024-02-01T00:00:00.000Z'), + makeSprint('s1', 'Sprint A', 'p1', 'Prod A', 2, '2024-01-01T00:00:00.000Z'), + ]) + + const result = await getVelocity('user-1') + + expect(result.sprints).toHaveLength(3) + expect(result.sprints.map(s => s.doneCount)).toEqual([2, 5, 3]) + expect(result.sprints.map(s => s.sprintId)).toEqual(['s1', 's2', 's3']) + }) + + it('deduplicates productNames from sprints', async () => { + mockFindMany.mockResolvedValue([ + makeSprint('s2', 'Sprint B', 'p2', 'Prod B', 1, '2024-02-01T00:00:00.000Z'), + makeSprint('s1', 'Sprint A', 'p1', 'Prod A', 2, '2024-01-01T00:00:00.000Z'), + ]) + + const result = await getVelocity('user-1') + + const ids = result.productNames.map(p => p.id) + expect(new Set(ids).size).toBe(ids.length) + expect(result.productNames).toHaveLength(2) + }) + + it('returns empty sprints and productNames when no completed sprints exist', async () => { + mockFindMany.mockResolvedValue([]) + + const result = await getVelocity('user-1') + + expect(result.sprints).toEqual([]) + expect(result.productNames).toEqual([]) + }) + + it('passes sprintsBack as take parameter', async () => { + mockFindMany.mockResolvedValue([]) + + await getVelocity('user-1', 3) + + expect(mockFindMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 3 }), + ) + }) +}) diff --git a/lib/insights/velocity.ts b/lib/insights/velocity.ts new file mode 100644 index 0000000..528b321 --- /dev/null +++ b/lib/insights/velocity.ts @@ -0,0 +1,57 @@ +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' + +export interface VelocitySprint { + sprintId: string + sprintGoal: string + productId: string + productName: string + doneCount: number + completedAt: string +} + +export interface VelocityData { + sprints: VelocitySprint[] + productNames: { id: string; name: string }[] +} + +export async function getVelocity(userId: string, sprintsBack = 5): Promise { + const sprints = await prisma.sprint.findMany({ + where: { + status: 'COMPLETED', + product: productAccessFilter(userId), + }, + orderBy: { completed_at: 'desc' }, + take: sprintsBack, + select: { + id: true, + sprint_goal: true, + completed_at: true, + product: { select: { id: true, name: true } }, + tasks: { select: { status: true } }, + }, + }) + + // Reverse to chronological order (oldest first, for x-axis) + const chronological = [...sprints].reverse() + + const result: VelocitySprint[] = chronological.map(sprint => ({ + sprintId: sprint.id, + sprintGoal: sprint.sprint_goal, + productId: sprint.product.id, + productName: sprint.product.name, + doneCount: sprint.tasks.filter(t => t.status === 'DONE').length, + completedAt: sprint.completed_at!.toISOString(), + })) + + const seen = new Set() + const productNames: { id: string; name: string }[] = [] + for (const s of result) { + if (!seen.has(s.productId)) { + seen.add(s.productId) + productNames.push({ id: s.productId, name: s.productName }) + } + } + + return { sprints: result, productNames } +}