Scrum4Me/lib/insights/verify-stats.ts
Janpeter Visser a268df3680
feat(PBI-59): Sprint.code (SP-N sequentieel per product) (#153)
Voegt een verplicht code-veld toe aan Sprint, sequentieel per product
(consistent met PBI-N, ST-NNN, T-N).

- **Schema** — `Sprint.code String @db.VarChar(30)` + `@@unique([product_id, code])`
- **Migratie** — voegt kolom toe als nullable, backfillt bestaande sprints
  via `ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY created_at)`
  als `SP-N`, en zet daarna NOT NULL + UNIQUE.
- **Generator** — `generateNextSprintCode(productId)` in lib/code-server.ts
  volgt het patroon van story/pbi/task; createSprintAction gebruikt
  `createWithCodeRetry` voor race-bescherming.
- **Seed** — sprint-counter per product (`SP-1`, `SP-2`, ...).

Zichtbaar in:
- Sprint-header (`Product › Sprint actief · SP-3`)
- JobCard + JobDetailPane voor SPRINT_IMPLEMENTATION jobs
- Insights: VelocityChart x-axis (compacter dan goal-truncated),
  AlignmentTrend tooltip, SprintInfoStrip
- actions/jobs-page.ts: `sprintCode` is weer een echte code i.p.v. null

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:10:16 +02:00

153 lines
4.1 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
sprintCode: 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 },
// Note: task_id can now be NULL on idea-jobs (M12). The toTopJob mapper
// filters them out via .filter(Boolean). Keeping a where-side filter
// (`task_id: { not: null }`) is rejected by Prisma 7 runtime.
}
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 } | null; product: { id: string; name: string } }): TopJob | null {
if (!j.task) return null
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).filter((j): j is TopJob => j !== null),
topDivergent: rawDivergent.map(toTopJob).filter((j): j is TopJob => j !== null),
}
}
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,
code: 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,
sprintCode: sprint.code,
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()
}