Compare commits
2 commits
main
...
feat/story
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d047b37ec | |||
| 27828c8834 |
3 changed files with 188 additions and 0 deletions
77
__tests__/lib/insights/velocity.test.ts
Normal file
77
__tests__/lib/insights/velocity.test.ts
Normal 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 }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
54
app/(app)/insights/components/velocity-chart.tsx
Normal file
54
app/(app)/insights/components/velocity-chart.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
import type { VelocityData } from '@/lib/insights/velocity'
|
||||||
|
import { SERIES_COLORS } from '@/lib/chart-colors'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: VelocityData
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VelocityChart({ data }: Props) {
|
||||||
|
const { sprints, productNames } = data
|
||||||
|
|
||||||
|
if (sprints.length < 2) {
|
||||||
|
return (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Velocity wordt zichtbaar na 2+ voltooide sprints
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = sprints.map((s, i) => {
|
||||||
|
const row: Record<string, string | number> = { sprintLabel: `S${i + 1}` }
|
||||||
|
row[s.productName] = s.doneCount
|
||||||
|
return row
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalDone = sprints.reduce((sum, s) => sum + s.doneCount, 0)
|
||||||
|
const avg = Math.round((totalDone / sprints.length) * 10) / 10
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={260}>
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<XAxis dataKey="sprintLabel" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis allowDecimals={false} tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip />
|
||||||
|
<Legend />
|
||||||
|
<ReferenceLine y={avg} stroke="var(--muted-foreground)" strokeDasharray="3 3" />
|
||||||
|
{productNames.map((p, i) => (
|
||||||
|
<Bar key={p.id} dataKey={p.name} fill={SERIES_COLORS[i % SERIES_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
lib/insights/velocity.ts
Normal file
57
lib/insights/velocity.ts
Normal 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 }
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue