From af775534071f195be8824f2c232600788bfa7020 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Sat, 2 May 2026 16:17:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(insights):=20add=20getBacklogHealth=20help?= =?UTF-8?q?er=20=E2=80=94=20stuck=20tasks=20+=20missing=20AC/plan=20counts?= =?UTF-8?q?=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- __tests__/lib/insights/backlog-health.test.ts | 82 +++++++++++++++++++ lib/insights/backlog-health.ts | 69 ++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 __tests__/lib/insights/backlog-health.test.ts create mode 100644 lib/insights/backlog-health.ts diff --git a/__tests__/lib/insights/backlog-health.test.ts b/__tests__/lib/insights/backlog-health.test.ts new file mode 100644 index 0000000..f74bfe3 --- /dev/null +++ b/__tests__/lib/insights/backlog-health.test.ts @@ -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) + }) +}) diff --git a/lib/insights/backlog-health.ts b/lib/insights/backlog-health.ts new file mode 100644 index 0000000..5dc0ef2 --- /dev/null +++ b/lib/insights/backlog-health.ts @@ -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 { + 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 } +}