Re-introduce the 3 unique files from closed PRs #37 and #40 that overlap-merged with already-landed sub-PRs (#34, #35, #36, #38, #39): - app/(app)/insights/page.tsx — Server Component dat alle helpers parallel aanroept en de 5 sectie-Cards rendert (Sprint Health, Plan-quality, Agent throughput, Velocity, Backlog health) - app/(app)/insights/components/sprint-info-strip.tsx — chips per active sprint met productname + goal + dagen-over + taakcount - app/(app)/insights/components/alignment-trend.tsx — Recharts LineChart die % ALIGNED jobs per sprint over laatste 5 sprints toont - lib/insights/verify-stats.ts — TrendPoint type + getAlignmentTrend helper (uitgebreid van PR #38) Plus dependency: recharts (was in package.json van #37/#40 die we sloten). Tests: 290/290 groen, tsc clean, lint clean. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
146 lines
3.7 KiB
TypeScript
146 lines
3.7 KiB
TypeScript
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<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),
|
|
}
|
|
}
|
|
|
|
export async function getAlignmentTrend(
|
|
userId: string,
|
|
sprintsBack = 5,
|
|
): Promise<TrendPoint[]> {
|
|
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()
|
|
}
|