diff --git a/lib/insights/verify-stats.ts b/lib/insights/verify-stats.ts new file mode 100644 index 0000000..c19140f --- /dev/null +++ b/lib/insights/verify-stats.ts @@ -0,0 +1,146 @@ +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' + +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[] +} + +export interface TrendPoint { + sprintId: string + sprintGoal: string + productName: string + alignedRatio: number + total: number +} + +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), + } +} + +export async function getAlignmentTrend( + userId: string, + sprintsBack = 5, +): Promise { + const sprints = await prisma.sprint.findMany({ + where: { + status: 'COMPLETED', + product: productAccessFilter(userId), + }, + orderBy: { completed_at: 'desc' }, + take: sprintsBack, + select: { + id: true, + sprint_goal: true, + completed_at: true, + product: { select: { name: true } }, + }, + }) + + const points = await Promise.all( + sprints.map(async sprint => { + const jobs = await prisma.claudeJob.findMany({ + where: { + user_id: userId, + status: 'DONE', + verify_result: { not: null }, + task: { story: { sprint_id: sprint.id } }, + }, + select: { verify_result: true }, + }) + const aligned = jobs.filter(j => j.verify_result === 'ALIGNED').length + return { + sprintId: sprint.id, + sprintGoal: sprint.sprint_goal, + productName: sprint.product.name, + alignedRatio: jobs.length > 0 ? Math.round((aligned / jobs.length) * 100) : 0, + total: jobs.length, + } + }), + ) + + // chronologisch oplopend (we fetched desc, so reverse) + return points.reverse() +}