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 } +}