feat(insights): add getBacklogHealth helper — stuck tasks + missing AC/plan counts (#48)

Three read-only counters: stories without acceptance_criteria, tasks without
implementation_plan, and top-10 IN_PROGRESS tasks stuck >7 days. All scoped
via productAccessFilter.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-02 16:17:03 +02:00 committed by GitHub
parent 219d54b3e5
commit af77553407
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 151 additions and 0 deletions

View file

@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockStoryCount, mockTaskCount, mockTaskFindMany } = vi.hoisted(() => ({
mockStoryCount: vi.fn(),
mockTaskCount: vi.fn(),
mockTaskFindMany: vi.fn(),
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
story: { count: mockStoryCount },
task: { count: mockTaskCount, findMany: mockTaskFindMany },
},
}))
vi.mock('@/lib/product-access', () => ({
productAccessFilter: () => ({ some: 'filter' }),
}))
import { getBacklogHealth } from '@/lib/insights/backlog-health'
function makeTask(id: string, daysAgo: number) {
const updatedAt = new Date(Date.now() - daysAgo * 86_400_000)
return {
id,
title: `Task ${id}`,
updated_at: updatedAt,
story: {
product: { id: 'prod-1', name: 'My Product' },
sprint: { sprint_goal: 'Sprint goal' },
},
}
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('getBacklogHealth', () => {
it('returns all zeros when backlog is healthy', async () => {
mockStoryCount.mockResolvedValue(0)
mockTaskCount.mockResolvedValue(0)
mockTaskFindMany.mockResolvedValue([])
const result = await getBacklogHealth('user-1')
expect(result.storiesWithoutAc).toBe(0)
expect(result.tasksWithoutPlan).toBe(0)
expect(result.stuckTasks).toEqual([])
})
it('returns counts and stuck tasks when everything is flagged', async () => {
mockStoryCount.mockResolvedValue(5)
mockTaskCount.mockResolvedValue(3)
mockTaskFindMany.mockResolvedValue([makeTask('t1', 10), makeTask('t2', 8)])
const result = await getBacklogHealth('user-1')
expect(result.storiesWithoutAc).toBe(5)
expect(result.tasksWithoutPlan).toBe(3)
expect(result.stuckTasks).toHaveLength(2)
expect(result.stuckTasks[0].taskId).toBe('t1')
expect(result.stuckTasks[0].daysStuck).toBeGreaterThanOrEqual(10)
expect(result.stuckTasks[0].productName).toBe('My Product')
expect(result.stuckTasks[0].sprintGoal).toBe('Sprint goal')
})
it('mixed: some counters non-zero, one stuck task, no sprint', async () => {
mockStoryCount.mockResolvedValue(2)
mockTaskCount.mockResolvedValue(0)
const task = makeTask('t3', 14)
task.story.sprint = null as unknown as { sprint_goal: string }
mockTaskFindMany.mockResolvedValue([task])
const result = await getBacklogHealth('user-1')
expect(result.storiesWithoutAc).toBe(2)
expect(result.tasksWithoutPlan).toBe(0)
expect(result.stuckTasks[0].sprintGoal).toBeNull()
expect(result.stuckTasks[0].daysStuck).toBeGreaterThanOrEqual(14)
})
})

View file

@ -0,0 +1,69 @@
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
export interface StuckTask {
taskId: string
title: string
productId: string
productName: string
sprintGoal: string | null
daysStuck: number
updatedAt: string
}
export interface BacklogHealth {
storiesWithoutAc: number
tasksWithoutPlan: number
stuckTasks: StuckTask[]
}
const SEVEN_DAYS_MS = 7 * 86_400_000
export async function getBacklogHealth(userId: string): Promise<BacklogHealth> {
const now = new Date()
const stuckCutoff = new Date(now.getTime() - SEVEN_DAYS_MS)
const [storiesWithoutAc, tasksWithoutPlan, rawStuck] = await Promise.all([
prisma.story.count({
where: {
OR: [{ acceptance_criteria: null }, { acceptance_criteria: '' }],
product: productAccessFilter(userId),
},
}),
prisma.task.count({
where: {
implementation_plan: null,
story: { product: productAccessFilter(userId) },
},
}),
prisma.task.findMany({
where: {
status: 'IN_PROGRESS',
updated_at: { lt: stuckCutoff },
story: { product: productAccessFilter(userId) },
},
orderBy: { updated_at: 'asc' },
take: 10,
include: {
story: {
include: {
product: { select: { id: true, name: true } },
sprint: { select: { sprint_goal: true } },
},
},
},
}),
])
const stuckTasks: StuckTask[] = rawStuck.map(t => ({
taskId: t.id,
title: t.title,
productId: t.story.product.id,
productName: t.story.product.name,
sprintGoal: t.story.sprint?.sprint_goal ?? null,
daysStuck: Math.floor((now.getTime() - t.updated_at.getTime()) / 86_400_000),
updatedAt: t.updated_at.toISOString(),
}))
return { storiesWithoutAc, tasksWithoutPlan, stuckTasks }
}