Compare commits
2 commits
main
...
feat/story
| Author | SHA1 | Date | |
|---|---|---|---|
| 876ea5ed8e | |||
| b23db68916 |
3 changed files with 241 additions and 0 deletions
82
__tests__/lib/insights/backlog-health.test.ts
Normal file
82
__tests__/lib/insights/backlog-health.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
90
app/(app)/insights/components/backlog-health.tsx
Normal file
90
app/(app)/insights/components/backlog-health.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { CheckCircle, AlertTriangle, XCircle } from 'lucide-react'
|
||||
import type { BacklogHealth, StuckTask } from '@/lib/insights/backlog-health'
|
||||
|
||||
interface Props {
|
||||
data: BacklogHealth
|
||||
}
|
||||
|
||||
function Counter({ label, count }: { label: string; count: number }) {
|
||||
const healthy = count === 0
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 rounded-lg border border-border bg-surface-container p-3">
|
||||
{healthy ? (
|
||||
<CheckCircle className="h-5 w-5 text-status-done" />
|
||||
) : (
|
||||
<AlertTriangle className="h-5 w-5 text-priority-medium" />
|
||||
)}
|
||||
<span className="text-xl font-semibold text-foreground">{count}</span>
|
||||
<span className="text-xs text-muted-foreground text-center leading-tight">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function daysStuckClass(days: number): string {
|
||||
if (days >= 14) return 'bg-priority-critical/15 text-priority-critical font-semibold'
|
||||
if (days >= 7) return 'bg-priority-medium/15 text-priority-medium font-semibold'
|
||||
return ''
|
||||
}
|
||||
|
||||
function StuckTable({ tasks }: { tasks: StuckTask[] }) {
|
||||
const router = useRouter()
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground py-2">Geen stuck tasks 🎉</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wide">
|
||||
<th className="pb-1 pr-3 font-medium">Taak</th>
|
||||
<th className="pb-1 pr-3 font-medium">Product</th>
|
||||
<th className="pb-1 pr-3 font-medium">Days</th>
|
||||
<th className="pb-1 font-medium">Sprint</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map(t => (
|
||||
<tr
|
||||
key={t.taskId}
|
||||
className="border-b border-border last:border-0 cursor-pointer hover:bg-surface-container-high transition-colors"
|
||||
onClick={() => router.push(`/products/${t.productId}/solo?task=${t.taskId}`)}
|
||||
>
|
||||
<td className="py-1.5 pr-3 line-clamp-1 max-w-[200px]">{t.title}</td>
|
||||
<td className="py-1.5 pr-3 text-muted-foreground whitespace-nowrap">{t.productName}</td>
|
||||
<td className={`py-1.5 pr-3 whitespace-nowrap px-1 rounded ${daysStuckClass(t.daysStuck)}`}>
|
||||
{t.daysStuck}d
|
||||
</td>
|
||||
<td className="py-1.5 text-muted-foreground whitespace-nowrap line-clamp-1 max-w-[140px]">
|
||||
{t.sprintGoal ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
export function BacklogHealthCard({ data }: Props) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Counter label="Stories zonder AC" count={data.storiesWithoutAc} />
|
||||
<Counter label="Tasks zonder plan" count={data.tasksWithoutPlan} />
|
||||
<Counter label="Stuck > 7 dagen" count={data.stuckTasks.length} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
|
||||
Stuck tasks
|
||||
</p>
|
||||
<StuckTable tasks={data.stuckTasks} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
lib/insights/backlog-health.ts
Normal file
69
lib/insights/backlog-health.ts
Normal 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 }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue