feat: getVerifyResultStats helper + 5 Vitest-tests (lib/insights/verify-stats.ts) (#38)
Aggregeert verify_result counts (ALIGNED/PARTIAL/EMPTY/DIVERGENT) en top-5 EMPTY/DIVERGENT jobs over de laatste N dagen voor de ingelogde gebruiker. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2539361784
commit
ddd9b8b39b
2 changed files with 204 additions and 0 deletions
112
__tests__/lib/insights/verify-stats.test.ts
Normal file
112
__tests__/lib/insights/verify-stats.test.ts
Normal file
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
92
lib/insights/verify-stats.ts
Normal file
92
lib/insights/verify-stats.ts
Normal file
|
|
@ -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<VerifyResultStats> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue