feat(insights): add getVelocity helper — DONE-tasks per completed sprint (#47)

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 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-02 16:07:57 +02:00 committed by GitHub
parent ce94fb48c3
commit 219d54b3e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 134 additions and 0 deletions

View file

@ -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 }),
)
})
})

57
lib/insights/velocity.ts Normal file
View file

@ -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<VelocityData> {
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<string>()
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 }
}