diff --git a/__tests__/lib/insights/verify-stats.test.ts b/__tests__/lib/insights/verify-stats.test.ts new file mode 100644 index 0000000..bc96de1 --- /dev/null +++ b/__tests__/lib/insights/verify-stats.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockGroupBy, mockFindMany } = vi.hoisted(() => ({ + mockGroupBy: vi.fn(), + mockFindMany: vi.fn(), +})) + +vi.mock('@/lib/prisma', () => ({ + prisma: { + claudeJob: { + groupBy: mockGroupBy, + findMany: mockFindMany, + }, + }, +})) + +import { getVerifyResultStats } from '@/lib/insights/verify-stats' + +const USER_ID = 'user-1' + +const makeJob = (id: string, verifyResult: string, daysAgo: number) => { + const finishedAt = new Date() + finishedAt.setDate(finishedAt.getDate() - daysAgo) + return { + id, + finished_at: finishedAt, + task: { id: `task-${id}`, title: `Task ${id}` }, + product: { id: 'prod-1', name: 'Scrum4Me' }, + verify_result: verifyResult, + } +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('getVerifyResultStats', () => { + it('returns counts in ALIGNED→PARTIAL→EMPTY→DIVERGENT order', async () => { + mockGroupBy.mockResolvedValue([ + { verify_result: 'DIVERGENT', _count: { _all: 2 } }, + { verify_result: 'ALIGNED', _count: { _all: 10 } }, + { verify_result: 'EMPTY', _count: { _all: 3 } }, + { verify_result: 'PARTIAL', _count: { _all: 1 } }, + ]) + mockFindMany.mockResolvedValue([]) + + const stats = await getVerifyResultStats(USER_ID) + + expect(stats.counts.map(c => c.result)).toEqual([ + 'ALIGNED', 'PARTIAL', 'EMPTY', 'DIVERGENT', + ]) + expect(stats.counts.map(c => c.count)).toEqual([10, 1, 3, 2]) + }) + + it('omits results with zero count from groupBy', async () => { + mockGroupBy.mockResolvedValue([ + { verify_result: 'ALIGNED', _count: { _all: 5 } }, + ]) + mockFindMany.mockResolvedValue([]) + + const stats = await getVerifyResultStats(USER_ID) + + expect(stats.counts).toHaveLength(1) + expect(stats.counts[0]).toEqual({ result: 'ALIGNED', count: 5 }) + }) + + it('maps topEmpty jobs correctly', async () => { + mockGroupBy.mockResolvedValue([]) + const job = makeJob('j1', 'EMPTY', 2) + // First findMany call → topEmpty, second → topDivergent + mockFindMany + .mockResolvedValueOnce([job]) + .mockResolvedValueOnce([]) + + const stats = await getVerifyResultStats(USER_ID) + + expect(stats.topEmpty).toHaveLength(1) + expect(stats.topEmpty[0]).toMatchObject({ + jobId: 'j1', + taskId: 'task-j1', + taskTitle: 'Task j1', + productId: 'prod-1', + productName: 'Scrum4Me', + }) + }) + + it('topDivergent is ordered most-recent first (from DB order)', async () => { + mockGroupBy.mockResolvedValue([]) + const jobs = [ + makeJob('jOld', 'DIVERGENT', 10), + makeJob('jNew', 'DIVERGENT', 1), + ] + mockFindMany + .mockResolvedValueOnce([]) // topEmpty + .mockResolvedValueOnce(jobs) // topDivergent (already sorted by Prisma orderBy) + + const stats = await getVerifyResultStats(USER_ID) + + expect(stats.topDivergent.map(j => j.jobId)).toEqual(['jOld', 'jNew']) + }) + + it('returns empty stats when no jobs found', async () => { + mockGroupBy.mockResolvedValue([]) + mockFindMany.mockResolvedValue([]) + + const stats = await getVerifyResultStats(USER_ID) + + expect(stats.counts).toEqual([]) + expect(stats.topEmpty).toEqual([]) + expect(stats.topDivergent).toEqual([]) + }) +}) diff --git a/lib/insights/verify-stats.ts b/lib/insights/verify-stats.ts new file mode 100644 index 0000000..5f3772e --- /dev/null +++ b/lib/insights/verify-stats.ts @@ -0,0 +1,92 @@ +import { prisma } from '@/lib/prisma' + +export type VerifyResultKey = 'ALIGNED' | 'PARTIAL' | 'EMPTY' | 'DIVERGENT' + +export interface TopJob { + jobId: string + taskId: string + taskTitle: string + productId: string + productName: string + finishedAt: Date +} + +export interface VerifyResultStats { + counts: { result: VerifyResultKey; count: number }[] + topEmpty: TopJob[] + topDivergent: TopJob[] +} + +const RESULT_ORDER: VerifyResultKey[] = ['ALIGNED', 'PARTIAL', 'EMPTY', 'DIVERGENT'] + +export async function getVerifyResultStats( + userId: string, + daysBack = 30, +): Promise { + const cutoff = new Date() + cutoff.setDate(cutoff.getDate() - daysBack) + + const baseWhere = { + user_id: userId, + status: 'DONE' as const, + verify_result: { not: null as null }, + finished_at: { gt: cutoff }, + } + + const [grouped, rawEmpty, rawDivergent] = await Promise.all([ + prisma.claudeJob.groupBy({ + by: ['verify_result'], + where: baseWhere, + _count: { _all: true }, + }), + prisma.claudeJob.findMany({ + where: { ...baseWhere, verify_result: 'EMPTY' }, + orderBy: { finished_at: 'desc' }, + take: 5, + select: { + id: true, + finished_at: true, + task: { select: { id: true, title: true } }, + product: { select: { id: true, name: true } }, + }, + }), + prisma.claudeJob.findMany({ + where: { ...baseWhere, verify_result: 'DIVERGENT' }, + orderBy: { finished_at: 'desc' }, + take: 5, + select: { + id: true, + finished_at: true, + task: { select: { id: true, title: true } }, + product: { select: { id: true, name: true } }, + }, + }), + ]) + + const countMap = new Map( + grouped + .filter(g => g.verify_result !== null) + .map(g => [g.verify_result as VerifyResultKey, g._count._all]), + ) + + const counts = RESULT_ORDER + .filter(r => countMap.has(r)) + .map(r => ({ result: r, count: countMap.get(r)! })) + + function toTopJob(j: { id: string; finished_at: Date | null; task: { id: string; title: string }; product: { id: string; name: string } }): TopJob { + return { + jobId: j.id, + taskId: j.task.id, + taskTitle: j.task.title, + productId: j.product.id, + productName: j.product.name, + finishedAt: j.finished_at!, + } + } + + return { + counts, + topEmpty: rawEmpty.map(toTopJob), + topDivergent: rawDivergent.map(toTopJob), + } +}