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>
This commit is contained in:
parent
16f01283ef
commit
a268df3680
20 changed files with 97 additions and 29 deletions
|
|
@ -16,6 +16,7 @@ vi.mock('@/lib/prisma', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
sprint: {
|
sprint: {
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
|
findMany: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
},
|
},
|
||||||
|
|
@ -25,7 +26,7 @@ vi.mock('@/lib/prisma', () => ({
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { createSprintAction, updateSprintDatesAction } from '@/actions/sprints'
|
import { createSprintAction, updateSprintDatesAction } from '@/actions/sprints'
|
||||||
|
|
||||||
const mockSprint = prisma as unknown as { sprint: { findFirst: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } }
|
const mockSprint = prisma as unknown as { sprint: { findFirst: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } }
|
||||||
|
|
||||||
function makeFormData(data: Record<string, string | null>) {
|
function makeFormData(data: Record<string, string | null>) {
|
||||||
const fd = new FormData()
|
const fd = new FormData()
|
||||||
|
|
@ -39,6 +40,7 @@ describe('createSprintAction — date validation', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mockSprint.sprint.findFirst.mockResolvedValue(null)
|
mockSprint.sprint.findFirst.mockResolvedValue(null)
|
||||||
|
mockSprint.sprint.findMany.mockResolvedValue([])
|
||||||
mockSprint.sprint.create.mockResolvedValue({ id: 'sprint-1' })
|
mockSprint.sprint.create.mockResolvedValue({ id: 'sprint-1' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ const JOB_INCLUDE = {
|
||||||
task: { select: { code: true, title: true, description: true, implementation_plan: true } },
|
task: { select: { code: true, title: true, description: true, implementation_plan: true } },
|
||||||
idea: { select: { code: true, title: true, description: true, grill_md: true, plan_md: true } },
|
idea: { select: { code: true, title: true, description: true, grill_md: true, plan_md: true } },
|
||||||
product: { select: { name: true } },
|
product: { select: { name: true } },
|
||||||
sprint_run: { include: { sprint: { select: { sprint_goal: true } } } },
|
sprint_run: { include: { sprint: { select: { sprint_goal: true, code: true } } } },
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
type RawJob = {
|
type RawJob = {
|
||||||
|
|
@ -72,7 +72,7 @@ type RawJob = {
|
||||||
plan_md: string | null
|
plan_md: string | null
|
||||||
} | null
|
} | null
|
||||||
product: { name: string }
|
product: { name: string }
|
||||||
sprint_run: { sprint: { sprint_goal: string } } | null
|
sprint_run: { sprint: { sprint_goal: string; code: string } } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type PriceRow = {
|
type PriceRow = {
|
||||||
|
|
@ -122,7 +122,7 @@ function mapJob(j: RawJob, priceMap: Map<string, PriceRow>): JobWithRelations {
|
||||||
ideaCode: j.idea?.code ?? null,
|
ideaCode: j.idea?.code ?? null,
|
||||||
ideaTitle: j.idea?.title ?? null,
|
ideaTitle: j.idea?.title ?? null,
|
||||||
sprintGoal: j.sprint_run?.sprint.sprint_goal ?? null,
|
sprintGoal: j.sprint_run?.sprint.sprint_goal ?? null,
|
||||||
sprintCode: null,
|
sprintCode: j.sprint_run?.sprint.code ?? null,
|
||||||
productName: j.product.name,
|
productName: j.product.name,
|
||||||
modelId: j.model_id,
|
modelId: j.model_id,
|
||||||
inputTokens: j.input_tokens,
|
inputTokens: j.input_tokens,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
} from '@/lib/schemas/sprint'
|
} from '@/lib/schemas/sprint'
|
||||||
import { enforceUserRateLimit } from '@/lib/rate-limit'
|
import { enforceUserRateLimit } from '@/lib/rate-limit'
|
||||||
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
|
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
|
||||||
|
import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server'
|
||||||
|
|
||||||
async function getSession() {
|
async function getSession() {
|
||||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||||
|
|
@ -54,15 +55,20 @@ export async function createSprintAction(_prevState: unknown, formData: FormData
|
||||||
})
|
})
|
||||||
if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id, code: 422 }
|
if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id, code: 422 }
|
||||||
|
|
||||||
const sprint = await prisma.sprint.create({
|
const sprint = await createWithCodeRetry(
|
||||||
|
() => generateNextSprintCode(parsed.data.productId),
|
||||||
|
(code) =>
|
||||||
|
prisma.sprint.create({
|
||||||
data: {
|
data: {
|
||||||
product_id: parsed.data.productId,
|
product_id: parsed.data.productId,
|
||||||
|
code,
|
||||||
sprint_goal: parsed.data.sprint_goal,
|
sprint_goal: parsed.data.sprint_goal,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
start_date: parsed.data.start_date,
|
start_date: parsed.data.start_date,
|
||||||
end_date: parsed.data.end_date,
|
end_date: parsed.data.end_date,
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
revalidatePath(`/products/${parsed.data.productId}`)
|
revalidatePath(`/products/${parsed.data.productId}`)
|
||||||
return { success: true, sprintId: sprint.id }
|
return { success: true, sprintId: sprint.id }
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ interface Props {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TooltipPayload {
|
interface TooltipPayload {
|
||||||
payload?: { total: number; alignedRatio: number; sprintGoal: string }
|
payload?: { total: number; alignedRatio: number; sprintCode: string; sprintGoal: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
function CustomTooltip({ active, payload }: { active?: boolean; payload?: TooltipPayload[] }) {
|
function CustomTooltip({ active, payload }: { active?: boolean; payload?: TooltipPayload[] }) {
|
||||||
|
|
@ -25,7 +25,10 @@ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Toolti
|
||||||
const aligned = Math.round((d.alignedRatio / 100) * d.total)
|
const aligned = Math.round((d.alignedRatio / 100) * d.total)
|
||||||
return (
|
return (
|
||||||
<div className="rounded border border-border bg-surface-container px-3 py-2 text-sm shadow">
|
<div className="rounded border border-border bg-surface-container px-3 py-2 text-sm shadow">
|
||||||
<p className="font-medium text-foreground">{d.sprintGoal}</p>
|
<p className="font-medium text-foreground">
|
||||||
|
<span className="font-mono text-xs text-muted-foreground mr-2">{d.sprintCode}</span>
|
||||||
|
{d.sprintGoal}
|
||||||
|
</p>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{aligned} / {d.total} aligned ({d.alignedRatio}%)
|
{aligned} / {d.total} aligned ({d.alignedRatio}%)
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -33,10 +36,6 @@ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Toolti
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function sprintLabel(goal: string): string {
|
|
||||||
return goal.length > 20 ? goal.slice(0, 18) + '…' : goal
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AlignmentTrend({ trend }: Props) {
|
export function AlignmentTrend({ trend }: Props) {
|
||||||
if (trend.length === 0) {
|
if (trend.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -48,7 +47,7 @@ export function AlignmentTrend({ trend }: Props) {
|
||||||
|
|
||||||
const data = trend.map(p => ({
|
const data = trend.map(p => ({
|
||||||
...p,
|
...p,
|
||||||
label: sprintLabel(p.sprintGoal),
|
label: p.sprintCode,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
interface SprintInfo {
|
interface SprintInfo {
|
||||||
sprintId: string
|
sprintId: string
|
||||||
|
sprintCode: string
|
||||||
productName: string
|
productName: string
|
||||||
sprintGoal: string
|
sprintGoal: string
|
||||||
taskCount: number
|
taskCount: number
|
||||||
|
|
@ -33,6 +34,7 @@ export function SprintInfoStrip({ sprints }: Props) {
|
||||||
className="flex items-center gap-3 rounded-lg border border-border bg-surface-container px-3 py-2 text-sm"
|
className="flex items-center gap-3 rounded-lg border border-border bg-surface-container px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<span className="font-medium text-foreground">{s.productName}</span>
|
<span className="font-medium text-foreground">{s.productName}</span>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{s.sprintCode}</span>
|
||||||
<span className="text-muted-foreground">{truncate(s.sprintGoal, 60)}</span>
|
<span className="text-muted-foreground">{truncate(s.sprintGoal, 60)}</span>
|
||||||
<span className={`font-mono tabular-nums ${daysLeftColor(s.daysLeft)}`}>
|
<span className={`font-mono tabular-nums ${daysLeftColor(s.daysLeft)}`}>
|
||||||
{s.daysLeft > 0 ? `${s.daysLeft}d over` : `${Math.abs(s.daysLeft)}d over tijd`}
|
{s.daysLeft > 0 ? `${s.daysLeft}d over` : `${Math.abs(s.daysLeft)}d over tijd`}
|
||||||
|
|
|
||||||
|
|
@ -35,11 +35,9 @@ export function VelocityChart({ data }: Props) {
|
||||||
type Row = { sprintLabel: string } & Record<string, number | string>
|
type Row = { sprintLabel: string } & Record<string, number | string>
|
||||||
const grouped = new Map<string, Row>()
|
const grouped = new Map<string, Row>()
|
||||||
for (const s of sprints) {
|
for (const s of sprints) {
|
||||||
const label =
|
|
||||||
s.sprintGoal.length > 14 ? s.sprintGoal.slice(0, 14) + '…' : s.sprintGoal
|
|
||||||
const key = `${s.sprintId}`
|
const key = `${s.sprintId}`
|
||||||
if (!grouped.has(key)) {
|
if (!grouped.has(key)) {
|
||||||
grouped.set(key, { sprintLabel: label })
|
grouped.set(key, { sprintLabel: s.sprintCode })
|
||||||
}
|
}
|
||||||
grouped.get(key)![s.productName] = s.doneCount
|
grouped.get(key)![s.productName] = s.doneCount
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps)
|
||||||
where: { status: 'ACTIVE', product: productAccessFilter(userId) },
|
where: { status: 'ACTIVE', product: productAccessFilter(userId) },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
code: true,
|
||||||
sprint_goal: true,
|
sprint_goal: true,
|
||||||
created_at: true,
|
created_at: true,
|
||||||
product: { select: { id: true, name: true } },
|
product: { select: { id: true, name: true } },
|
||||||
|
|
@ -88,6 +89,7 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps)
|
||||||
const nowMs = Date.now()
|
const nowMs = Date.now()
|
||||||
const sprintInfos = activeSprints.map(s => ({
|
const sprintInfos = activeSprints.map(s => ({
|
||||||
sprintId: s.id,
|
sprintId: s.id,
|
||||||
|
sprintCode: s.code,
|
||||||
productId: s.product.id,
|
productId: s.product.id,
|
||||||
productName: s.product.name,
|
productName: s.product.name,
|
||||||
sprintGoal: s.sprint_goal,
|
sprintGoal: s.sprint_goal,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
||||||
where: { product_id: id, status: { in: ['ACTIVE', 'FAILED'] } },
|
where: { product_id: id, status: { in: ['ACTIVE', 'FAILED'] } },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
code: true,
|
||||||
sprint_goal: true,
|
sprint_goal: true,
|
||||||
status: true,
|
status: true,
|
||||||
start_date: true,
|
start_date: true,
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,8 @@ export default function JobCard({
|
||||||
if (kind === 'TASK_IMPLEMENTATION') {
|
if (kind === 'TASK_IMPLEMENTATION') {
|
||||||
titleText = taskCode && taskTitle ? `${taskCode} ${taskTitle}` : taskTitle || 'Taak'
|
titleText = taskCode && taskTitle ? `${taskCode} ${taskTitle}` : taskTitle || 'Taak'
|
||||||
} else if (kind === 'SPRINT_IMPLEMENTATION') {
|
} else if (kind === 'SPRINT_IMPLEMENTATION') {
|
||||||
titleText = sprintGoal || (sprintCode ? `Sprint ${sprintCode}` : 'Sprint')
|
if (sprintCode && sprintGoal) titleText = `${sprintCode} ${sprintGoal}`
|
||||||
|
else titleText = sprintGoal || sprintCode || 'Sprint'
|
||||||
} else if (kind === 'IDEA_GRILL' || kind === 'IDEA_MAKE_PLAN') {
|
} else if (kind === 'IDEA_GRILL' || kind === 'IDEA_MAKE_PLAN') {
|
||||||
titleText = ideaCode && ideaTitle ? `${ideaCode} ${ideaTitle}` : ideaTitle || 'Idee'
|
titleText = ideaCode && ideaTitle ? `${ideaCode} ${ideaTitle}` : ideaTitle || 'Idee'
|
||||||
} else if (kind === 'PLAN_CHAT') {
|
} else if (kind === 'PLAN_CHAT') {
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,11 @@ function subjectLabel(job: JobWithRelations): { label: string; value: string } |
|
||||||
if (!job.taskTitle) return null
|
if (!job.taskTitle) return null
|
||||||
return { label: 'Taak', value: job.taskCode ? `${job.taskCode} ${job.taskTitle}` : job.taskTitle }
|
return { label: 'Taak', value: job.taskCode ? `${job.taskCode} ${job.taskTitle}` : job.taskTitle }
|
||||||
case 'SPRINT_IMPLEMENTATION':
|
case 'SPRINT_IMPLEMENTATION':
|
||||||
if (!job.sprintGoal) return null
|
if (!job.sprintGoal && !job.sprintCode) return null
|
||||||
return { label: 'Sprint', value: job.sprintGoal }
|
return {
|
||||||
|
label: 'Sprint',
|
||||||
|
value: job.sprintCode && job.sprintGoal ? `${job.sprintCode} ${job.sprintGoal}` : (job.sprintGoal ?? job.sprintCode ?? ''),
|
||||||
|
}
|
||||||
case 'IDEA_GRILL':
|
case 'IDEA_GRILL':
|
||||||
case 'IDEA_MAKE_PLAN':
|
case 'IDEA_MAKE_PLAN':
|
||||||
case 'PLAN_CHAT':
|
case 'PLAN_CHAT':
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import type { SprintStory } from './sprint-backlog'
|
||||||
|
|
||||||
interface Sprint {
|
interface Sprint {
|
||||||
id: string
|
id: string
|
||||||
|
code: string
|
||||||
sprint_goal: string
|
sprint_goal: string
|
||||||
status: string
|
status: string
|
||||||
start_date: Date | null
|
start_date: Date | null
|
||||||
|
|
@ -136,6 +137,8 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem
|
||||||
<span className="text-xs text-muted-foreground">{productName}</span>
|
<span className="text-xs text-muted-foreground">{productName}</span>
|
||||||
<span className="text-muted-foreground">›</span>
|
<span className="text-muted-foreground">›</span>
|
||||||
<span className="text-xs font-medium text-primary">Sprint actief</span>
|
<span className="text-xs font-medium text-primary">Sprint actief</span>
|
||||||
|
<span className="text-muted-foreground">·</span>
|
||||||
|
<span className="text-xs font-mono text-muted-foreground">{sprint.code}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editingGoal ? (
|
{editingGoal ? (
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 743 KiB After Width: | Height: | Size: 908 KiB |
|
|
@ -41,6 +41,7 @@ export async function createWithCodeRetry<T>(
|
||||||
const STORY_AUTO_RE = /^ST-(\d+)$/
|
const STORY_AUTO_RE = /^ST-(\d+)$/
|
||||||
const PBI_AUTO_RE = /^PBI-(\d+)$/
|
const PBI_AUTO_RE = /^PBI-(\d+)$/
|
||||||
const TASK_AUTO_RE = /^T-(\d+)$/
|
const TASK_AUTO_RE = /^T-(\d+)$/
|
||||||
|
const SPRINT_AUTO_RE = /^SP-(\d+)$/
|
||||||
|
|
||||||
function nextSequential(existing: (string | null)[], pattern: RegExp): number {
|
function nextSequential(existing: (string | null)[], pattern: RegExp): number {
|
||||||
let max = 0
|
let max = 0
|
||||||
|
|
@ -82,3 +83,12 @@ export async function generateNextTaskCode(productId: string): Promise<string> {
|
||||||
return `T-${next}`
|
return `T-${next}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateNextSprintCode(productId: string): Promise<string> {
|
||||||
|
const sprints = await prisma.sprint.findMany({
|
||||||
|
where: { product_id: productId },
|
||||||
|
select: { code: true },
|
||||||
|
})
|
||||||
|
const next = nextSequential(sprints.map((s) => s.code), SPRINT_AUTO_RE)
|
||||||
|
return `SP-${next}`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export interface BurndownDay {
|
||||||
|
|
||||||
export interface BurndownSprint {
|
export interface BurndownSprint {
|
||||||
sprintId: string
|
sprintId: string
|
||||||
|
sprintCode: string
|
||||||
productId: string
|
productId: string
|
||||||
productName: string
|
productName: string
|
||||||
sprintGoal: string
|
sprintGoal: string
|
||||||
|
|
@ -62,6 +63,7 @@ export async function getBurndownData(userId: string): Promise<BurndownSprint[]>
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
code: true,
|
||||||
sprint_goal: true,
|
sprint_goal: true,
|
||||||
created_at: true,
|
created_at: true,
|
||||||
completed_at: true,
|
completed_at: true,
|
||||||
|
|
@ -77,6 +79,7 @@ export async function getBurndownData(userId: string): Promise<BurndownSprint[]>
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sprintId: sprint.id,
|
sprintId: sprint.id,
|
||||||
|
sprintCode: sprint.code,
|
||||||
productId: sprint.product.id,
|
productId: sprint.product.id,
|
||||||
productName: sprint.product.name,
|
productName: sprint.product.name,
|
||||||
sprintGoal: sprint.sprint_goal,
|
sprintGoal: sprint.sprint_goal,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
export interface SprintTokenRow {
|
export interface SprintTokenRow {
|
||||||
sprintId: string
|
sprintId: string
|
||||||
|
sprintCode: string
|
||||||
sprintGoal: string
|
sprintGoal: string
|
||||||
totalTokens: number
|
totalTokens: number
|
||||||
totalCostUsd: number
|
totalCostUsd: number
|
||||||
|
|
@ -24,6 +25,7 @@ export interface PbiTokenRow {
|
||||||
|
|
||||||
type RawSprintRow = {
|
type RawSprintRow = {
|
||||||
sprint_id: string
|
sprint_id: string
|
||||||
|
sprint_code: string
|
||||||
sprint_goal: string
|
sprint_goal: string
|
||||||
total_tokens: bigint
|
total_tokens: bigint
|
||||||
total_cost: number | null
|
total_cost: number | null
|
||||||
|
|
@ -53,6 +55,7 @@ export async function getSprintTokenHistory(
|
||||||
? await prisma.$queryRaw<RawSprintRow[]>`
|
? await prisma.$queryRaw<RawSprintRow[]>`
|
||||||
SELECT
|
SELECT
|
||||||
sp.id AS sprint_id,
|
sp.id AS sprint_id,
|
||||||
|
sp.code AS sprint_code,
|
||||||
sp.sprint_goal,
|
sp.sprint_goal,
|
||||||
COALESCE(SUM(cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens), 0) AS total_tokens,
|
COALESCE(SUM(cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens), 0) AS total_tokens,
|
||||||
SUM(
|
SUM(
|
||||||
|
|
@ -70,13 +73,14 @@ export async function getSprintTokenHistory(
|
||||||
WHERE cj.user_id = ${userId}
|
WHERE cj.user_id = ${userId}
|
||||||
AND cj.status = 'DONE'
|
AND cj.status = 'DONE'
|
||||||
AND cj.product_id = ${productId}
|
AND cj.product_id = ${productId}
|
||||||
GROUP BY sp.id, sp.sprint_goal
|
GROUP BY sp.id, sp.code, sp.sprint_goal
|
||||||
ORDER BY sp.created_at DESC
|
ORDER BY sp.created_at DESC
|
||||||
LIMIT ${limit}
|
LIMIT ${limit}
|
||||||
`
|
`
|
||||||
: await prisma.$queryRaw<RawSprintRow[]>`
|
: await prisma.$queryRaw<RawSprintRow[]>`
|
||||||
SELECT
|
SELECT
|
||||||
sp.id AS sprint_id,
|
sp.id AS sprint_id,
|
||||||
|
sp.code AS sprint_code,
|
||||||
sp.sprint_goal,
|
sp.sprint_goal,
|
||||||
COALESCE(SUM(cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens), 0) AS total_tokens,
|
COALESCE(SUM(cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens), 0) AS total_tokens,
|
||||||
SUM(
|
SUM(
|
||||||
|
|
@ -93,13 +97,14 @@ export async function getSprintTokenHistory(
|
||||||
LEFT JOIN model_prices mp ON mp.model_id = cj.model_id
|
LEFT JOIN model_prices mp ON mp.model_id = cj.model_id
|
||||||
WHERE cj.user_id = ${userId}
|
WHERE cj.user_id = ${userId}
|
||||||
AND cj.status = 'DONE'
|
AND cj.status = 'DONE'
|
||||||
GROUP BY sp.id, sp.sprint_goal
|
GROUP BY sp.id, sp.code, sp.sprint_goal
|
||||||
ORDER BY sp.created_at DESC
|
ORDER BY sp.created_at DESC
|
||||||
LIMIT ${limit}
|
LIMIT ${limit}
|
||||||
`
|
`
|
||||||
|
|
||||||
return rows.map(r => ({
|
return rows.map(r => ({
|
||||||
sprintId: r.sprint_id,
|
sprintId: r.sprint_id,
|
||||||
|
sprintCode: r.sprint_code,
|
||||||
sprintGoal: r.sprint_goal,
|
sprintGoal: r.sprint_goal,
|
||||||
totalTokens: Number(r.total_tokens),
|
totalTokens: Number(r.total_tokens),
|
||||||
totalCostUsd: Number(r.total_cost ?? 0),
|
totalCostUsd: Number(r.total_cost ?? 0),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { productAccessFilter } from '@/lib/product-access'
|
||||||
|
|
||||||
export interface VelocitySprint {
|
export interface VelocitySprint {
|
||||||
sprintId: string
|
sprintId: string
|
||||||
|
sprintCode: string
|
||||||
sprintGoal: string
|
sprintGoal: string
|
||||||
productId: string
|
productId: string
|
||||||
productName: string
|
productName: string
|
||||||
|
|
@ -25,6 +26,7 @@ export async function getVelocity(userId: string, sprintsBack = 5): Promise<Velo
|
||||||
take: sprintsBack,
|
take: sprintsBack,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
code: true,
|
||||||
sprint_goal: true,
|
sprint_goal: true,
|
||||||
completed_at: true,
|
completed_at: true,
|
||||||
product: { select: { id: true, name: true } },
|
product: { select: { id: true, name: true } },
|
||||||
|
|
@ -42,6 +44,7 @@ export async function getVelocity(userId: string, sprintsBack = 5): Promise<Velo
|
||||||
|
|
||||||
const result: VelocitySprint[] = chronological.map(sprint => ({
|
const result: VelocitySprint[] = chronological.map(sprint => ({
|
||||||
sprintId: sprint.id,
|
sprintId: sprint.id,
|
||||||
|
sprintCode: sprint.code,
|
||||||
sprintGoal: sprint.sprint_goal,
|
sprintGoal: sprint.sprint_goal,
|
||||||
productId: sprint.product.id,
|
productId: sprint.product.id,
|
||||||
productName: sprint.product.name,
|
productName: sprint.product.name,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export interface VerifyResultStats {
|
||||||
|
|
||||||
export interface TrendPoint {
|
export interface TrendPoint {
|
||||||
sprintId: string
|
sprintId: string
|
||||||
|
sprintCode: string
|
||||||
sprintGoal: string
|
sprintGoal: string
|
||||||
productName: string
|
productName: string
|
||||||
alignedRatio: number
|
alignedRatio: number
|
||||||
|
|
@ -117,6 +118,7 @@ export async function getAlignmentTrend(
|
||||||
take: sprintsBack,
|
take: sprintsBack,
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
code: true,
|
||||||
sprint_goal: true,
|
sprint_goal: true,
|
||||||
completed_at: true,
|
completed_at: true,
|
||||||
product: { select: { name: true } },
|
product: { select: { name: true } },
|
||||||
|
|
@ -137,6 +139,7 @@ export async function getAlignmentTrend(
|
||||||
const aligned = jobs.filter(j => j.verify_result === 'ALIGNED').length
|
const aligned = jobs.filter(j => j.verify_result === 'ALIGNED').length
|
||||||
return {
|
return {
|
||||||
sprintId: sprint.id,
|
sprintId: sprint.id,
|
||||||
|
sprintCode: sprint.code,
|
||||||
sprintGoal: sprint.sprint_goal,
|
sprintGoal: sprint.sprint_goal,
|
||||||
productName: sprint.product.name,
|
productName: sprint.product.name,
|
||||||
alignedRatio: jobs.length > 0 ? Math.round((aligned / jobs.length) * 100) : 0,
|
alignedRatio: jobs.length > 0 ? Math.round((aligned / jobs.length) * 100) : 0,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
-- PBI-59: Sprint.code (SP-1, SP-2, ...) sequentieel per product
|
||||||
|
--
|
||||||
|
-- 1. Voeg nullable kolom toe
|
||||||
|
-- 2. Backfill bestaande rijen via ROW_NUMBER() per product op created_at
|
||||||
|
-- 3. Maak NOT NULL en voeg unieke index toe op (product_id, code)
|
||||||
|
|
||||||
|
ALTER TABLE "sprints" ADD COLUMN "code" VARCHAR(30);
|
||||||
|
|
||||||
|
WITH numbered AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
product_id,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY created_at, id) AS n
|
||||||
|
FROM "sprints"
|
||||||
|
)
|
||||||
|
UPDATE "sprints" s
|
||||||
|
SET code = 'SP-' || numbered.n
|
||||||
|
FROM numbered
|
||||||
|
WHERE s.id = numbered.id;
|
||||||
|
|
||||||
|
ALTER TABLE "sprints" ALTER COLUMN "code" SET NOT NULL;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "sprints_product_id_code_key" ON "sprints"("product_id", "code");
|
||||||
|
|
@ -299,6 +299,7 @@ model Sprint {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||||
product_id String
|
product_id String
|
||||||
|
code String @db.VarChar(30)
|
||||||
sprint_goal String
|
sprint_goal String
|
||||||
status SprintStatus @default(ACTIVE)
|
status SprintStatus @default(ACTIVE)
|
||||||
start_date DateTime? @db.Date
|
start_date DateTime? @db.Date
|
||||||
|
|
@ -309,6 +310,7 @@ model Sprint {
|
||||||
tasks Task[]
|
tasks Task[]
|
||||||
sprint_runs SprintRun[]
|
sprint_runs SprintRun[]
|
||||||
|
|
||||||
|
@@unique([product_id, code])
|
||||||
@@index([product_id, status])
|
@@index([product_id, status])
|
||||||
@@map("sprints")
|
@@map("sprints")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,7 @@ async function main() {
|
||||||
console.log(`Loaded backlog: ${milestones.length} milestones, ${milestones.reduce((acc, m) => acc + m.stories.length, 0)} stories`)
|
console.log(`Loaded backlog: ${milestones.length} milestones, ${milestones.reduce((acc, m) => acc + m.stories.length, 0)} stories`)
|
||||||
|
|
||||||
let productTaskCounter = 0
|
let productTaskCounter = 0
|
||||||
|
let sprintCounter = 0
|
||||||
for (const ms of milestones) {
|
for (const ms of milestones) {
|
||||||
const pbi = await prisma.pbi.create({
|
const pbi = await prisma.pbi.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -139,6 +140,7 @@ async function main() {
|
||||||
const sprint = await prisma.sprint.create({
|
const sprint = await prisma.sprint.create({
|
||||||
data: {
|
data: {
|
||||||
product_id: product.id,
|
product_id: product.id,
|
||||||
|
code: `SP-${++sprintCounter}`,
|
||||||
sprint_goal: `${ms.key} — ${ms.goal}`,
|
sprint_goal: `${ms.key} — ${ms.goal}`,
|
||||||
status: ms.sprint_status,
|
status: ms.sprint_status,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue