diff --git a/__tests__/actions/sprint-dates.test.ts b/__tests__/actions/sprint-dates.test.ts index eaa05db..875ab1d 100644 --- a/__tests__/actions/sprint-dates.test.ts +++ b/__tests__/actions/sprint-dates.test.ts @@ -16,6 +16,7 @@ vi.mock('@/lib/prisma', () => ({ prisma: { sprint: { findFirst: vi.fn(), + findMany: vi.fn(), create: vi.fn(), update: vi.fn(), }, @@ -25,7 +26,7 @@ vi.mock('@/lib/prisma', () => ({ import { prisma } from '@/lib/prisma' import { createSprintAction, updateSprintDatesAction } from '@/actions/sprints' -const mockSprint = prisma as unknown as { sprint: { findFirst: ReturnType; create: ReturnType; update: ReturnType } } +const mockSprint = prisma as unknown as { sprint: { findFirst: ReturnType; findMany: ReturnType; create: ReturnType; update: ReturnType } } function makeFormData(data: Record) { const fd = new FormData() @@ -39,6 +40,7 @@ describe('createSprintAction — date validation', () => { beforeEach(() => { vi.clearAllMocks() mockSprint.sprint.findFirst.mockResolvedValue(null) + mockSprint.sprint.findMany.mockResolvedValue([]) mockSprint.sprint.create.mockResolvedValue({ id: 'sprint-1' }) }) diff --git a/actions/jobs-page.ts b/actions/jobs-page.ts index 6148439..de9b1b8 100644 --- a/actions/jobs-page.ts +++ b/actions/jobs-page.ts @@ -37,7 +37,7 @@ const JOB_INCLUDE = { 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 } }, product: { select: { name: true } }, - sprint_run: { include: { sprint: { select: { sprint_goal: true } } } }, + sprint_run: { include: { sprint: { select: { sprint_goal: true, code: true } } } }, } as const type RawJob = { @@ -72,7 +72,7 @@ type RawJob = { plan_md: string | null } | null product: { name: string } - sprint_run: { sprint: { sprint_goal: string } } | null + sprint_run: { sprint: { sprint_goal: string; code: string } } | null } type PriceRow = { @@ -122,7 +122,7 @@ function mapJob(j: RawJob, priceMap: Map): JobWithRelations { ideaCode: j.idea?.code ?? null, ideaTitle: j.idea?.title ?? null, sprintGoal: j.sprint_run?.sprint.sprint_goal ?? null, - sprintCode: null, + sprintCode: j.sprint_run?.sprint.code ?? null, productName: j.product.name, modelId: j.model_id, inputTokens: j.input_tokens, diff --git a/actions/sprints.ts b/actions/sprints.ts index 2784334..3da4eda 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -13,6 +13,7 @@ import { } from '@/lib/schemas/sprint' import { enforceUserRateLimit } from '@/lib/rate-limit' import { propagateStatusUpwards } from '@/lib/tasks-status-update' +import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server' async function getSession() { return getIronSession(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 } - const sprint = await prisma.sprint.create({ - data: { - product_id: parsed.data.productId, - sprint_goal: parsed.data.sprint_goal, - status: 'ACTIVE', - start_date: parsed.data.start_date, - end_date: parsed.data.end_date, - }, - }) + const sprint = await createWithCodeRetry( + () => generateNextSprintCode(parsed.data.productId), + (code) => + prisma.sprint.create({ + data: { + product_id: parsed.data.productId, + code, + sprint_goal: parsed.data.sprint_goal, + status: 'ACTIVE', + start_date: parsed.data.start_date, + end_date: parsed.data.end_date, + }, + }), + ) revalidatePath(`/products/${parsed.data.productId}`) return { success: true, sprintId: sprint.id } diff --git a/app/(app)/insights/components/alignment-trend.tsx b/app/(app)/insights/components/alignment-trend.tsx index 45375d1..1718188 100644 --- a/app/(app)/insights/components/alignment-trend.tsx +++ b/app/(app)/insights/components/alignment-trend.tsx @@ -15,7 +15,7 @@ interface Props { } 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[] }) { @@ -25,7 +25,10 @@ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Toolti const aligned = Math.round((d.alignedRatio / 100) * d.total) return (
-

{d.sprintGoal}

+

+ {d.sprintCode} + {d.sprintGoal} +

{aligned} / {d.total} aligned ({d.alignedRatio}%)

@@ -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) { if (trend.length === 0) { return ( @@ -48,7 +47,7 @@ export function AlignmentTrend({ trend }: Props) { const data = trend.map(p => ({ ...p, - label: sprintLabel(p.sprintGoal), + label: p.sprintCode, })) return ( diff --git a/app/(app)/insights/components/sprint-info-strip.tsx b/app/(app)/insights/components/sprint-info-strip.tsx index 3d85a33..ed3c15b 100644 --- a/app/(app)/insights/components/sprint-info-strip.tsx +++ b/app/(app)/insights/components/sprint-info-strip.tsx @@ -2,6 +2,7 @@ interface SprintInfo { sprintId: string + sprintCode: string productName: string sprintGoal: string 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" > {s.productName} + {s.sprintCode} {truncate(s.sprintGoal, 60)} {s.daysLeft > 0 ? `${s.daysLeft}d over` : `${Math.abs(s.daysLeft)}d over tijd`} diff --git a/app/(app)/insights/components/velocity-chart.tsx b/app/(app)/insights/components/velocity-chart.tsx index 7cd2d9e..a05df7f 100644 --- a/app/(app)/insights/components/velocity-chart.tsx +++ b/app/(app)/insights/components/velocity-chart.tsx @@ -35,11 +35,9 @@ export function VelocityChart({ data }: Props) { type Row = { sprintLabel: string } & Record const grouped = new Map() for (const s of sprints) { - const label = - s.sprintGoal.length > 14 ? s.sprintGoal.slice(0, 14) + '…' : s.sprintGoal const key = `${s.sprintId}` if (!grouped.has(key)) { - grouped.set(key, { sprintLabel: label }) + grouped.set(key, { sprintLabel: s.sprintCode }) } grouped.get(key)![s.productName] = s.doneCount } diff --git a/app/(app)/insights/page.tsx b/app/(app)/insights/page.tsx index 39244b7..4c646a1 100644 --- a/app/(app)/insights/page.tsx +++ b/app/(app)/insights/page.tsx @@ -60,6 +60,7 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps) where: { status: 'ACTIVE', product: productAccessFilter(userId) }, select: { id: true, + code: true, sprint_goal: true, created_at: true, product: { select: { id: true, name: true } }, @@ -88,6 +89,7 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps) const nowMs = Date.now() const sprintInfos = activeSprints.map(s => ({ sprintId: s.id, + sprintCode: s.code, productId: s.product.id, productName: s.product.name, sprintGoal: s.sprint_goal, diff --git a/app/(app)/products/[id]/sprint/page.tsx b/app/(app)/products/[id]/sprint/page.tsx index e535758..7f09296 100644 --- a/app/(app)/products/[id]/sprint/page.tsx +++ b/app/(app)/products/[id]/sprint/page.tsx @@ -38,6 +38,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { where: { product_id: id, status: { in: ['ACTIVE', 'FAILED'] } }, select: { id: true, + code: true, sprint_goal: true, status: true, start_date: true, diff --git a/components/jobs/job-card.tsx b/components/jobs/job-card.tsx index 590e743..0d888a6 100644 --- a/components/jobs/job-card.tsx +++ b/components/jobs/job-card.tsx @@ -39,7 +39,8 @@ export default function JobCard({ if (kind === 'TASK_IMPLEMENTATION') { titleText = taskCode && taskTitle ? `${taskCode} ${taskTitle}` : taskTitle || 'Taak' } 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') { titleText = ideaCode && ideaTitle ? `${ideaCode} ${ideaTitle}` : ideaTitle || 'Idee' } else if (kind === 'PLAN_CHAT') { diff --git a/components/jobs/job-detail-pane.tsx b/components/jobs/job-detail-pane.tsx index c90f220..7a691c1 100644 --- a/components/jobs/job-detail-pane.tsx +++ b/components/jobs/job-detail-pane.tsx @@ -25,8 +25,11 @@ function subjectLabel(job: JobWithRelations): { label: string; value: string } | if (!job.taskTitle) return null return { label: 'Taak', value: job.taskCode ? `${job.taskCode} ${job.taskTitle}` : job.taskTitle } case 'SPRINT_IMPLEMENTATION': - if (!job.sprintGoal) return null - return { label: 'Sprint', value: job.sprintGoal } + if (!job.sprintGoal && !job.sprintCode) return null + return { + label: 'Sprint', + value: job.sprintCode && job.sprintGoal ? `${job.sprintCode} ${job.sprintGoal}` : (job.sprintGoal ?? job.sprintCode ?? ''), + } case 'IDEA_GRILL': case 'IDEA_MAKE_PLAN': case 'PLAN_CHAT': diff --git a/components/sprint/sprint-header.tsx b/components/sprint/sprint-header.tsx index 893e21a..9c73c53 100644 --- a/components/sprint/sprint-header.tsx +++ b/components/sprint/sprint-header.tsx @@ -35,6 +35,7 @@ import type { SprintStory } from './sprint-backlog' interface Sprint { id: string + code: string sprint_goal: string status: string start_date: Date | null @@ -136,6 +137,8 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem {productName} Sprint actief + · + {sprint.code}
{editingGoal ? ( diff --git a/docs/erd.svg b/docs/erd.svg index 7b40b67..ef98c47 100644 --- a/docs/erd.svg +++ b/docs/erd.svg @@ -1 +1 @@ -

active_product

user

enum:role

user

user

product

enum:status

pbi

product

sprint

assignee

enum:status

story

enum:type

enum:status

product

enum:status

story

product

sprint

enum:status

enum:verify_required

user

product

task

idea

enum:kind

enum:status

claimed_by_token

enum:verify_result

user

token

product

user

user

product

pbi

enum:status

idea

product

idea

enum:type

enum:status

idea

user

story

task

idea

product

asker

answerer

Role

PRODUCT_OWNER

PRODUCT_OWNER

SCRUM_MASTER

SCRUM_MASTER

DEVELOPER

DEVELOPER

ADMIN

ADMIN

StoryStatus

OPEN

OPEN

IN_SPRINT

IN_SPRINT

DONE

DONE

PbiStatus

READY

READY

BLOCKED

BLOCKED

DONE

DONE

ClaudeJobStatus

QUEUED

QUEUED

CLAIMED

CLAIMED

RUNNING

RUNNING

DONE

DONE

FAILED

FAILED

CANCELLED

CANCELLED

SKIPPED

SKIPPED

VerifyResult

ALIGNED

ALIGNED

PARTIAL

PARTIAL

EMPTY

EMPTY

DIVERGENT

DIVERGENT

VerifyRequired

ALIGNED

ALIGNED

ALIGNED_OR_PARTIAL

ALIGNED_OR_PARTIAL

ANY

ANY

TaskStatus

TO_DO

TO_DO

IN_PROGRESS

IN_PROGRESS

REVIEW

REVIEW

DONE

DONE

LogType

IMPLEMENTATION_PLAN

IMPLEMENTATION_PLAN

TEST_RESULT

TEST_RESULT

COMMIT

COMMIT

TestStatus

PASSED

PASSED

FAILED

FAILED

SprintStatus

ACTIVE

ACTIVE

COMPLETED

COMPLETED

IdeaStatus

DRAFT

DRAFT

GRILLING

GRILLING

GRILL_FAILED

GRILL_FAILED

GRILLED

GRILLED

PLANNING

PLANNING

PLAN_FAILED

PLAN_FAILED

PLAN_READY

PLAN_READY

PLANNED

PLANNED

ClaudeJobKind

TASK_IMPLEMENTATION

TASK_IMPLEMENTATION

IDEA_GRILL

IDEA_GRILL

IDEA_MAKE_PLAN

IDEA_MAKE_PLAN

PLAN_CHAT

PLAN_CHAT

IdeaLogType

DECISION

DECISION

NOTE

NOTE

GRILL_RESULT

GRILL_RESULT

PLAN_RESULT

PLAN_RESULT

STATUS_CHANGE

STATUS_CHANGE

JOB_EVENT

JOB_EVENT

UserQuestionStatus

pending

pending

answered

answered

users

String

id

🗝️

String

username

String

email

String

password_hash

Boolean

is_demo

String

bio

String

bio_detail

Boolean

must_reset_password

Bytes

avatar_data

Int

idea_code_counter

Int

min_quota_pct

DateTime

created_at

DateTime

updated_at

user_roles

String

id

🗝️

Role

role

api_tokens

String

id

🗝️

String

token_hash

String

label

DateTime

created_at

DateTime

revoked_at

products

String

id

🗝️

String

name

String

code

String

description

String

repo_url

String

definition_of_done

Boolean

auto_pr

Boolean

archived

DateTime

created_at

DateTime

updated_at

pbis

String

id

🗝️

String

code

String

title

String

description

Int

priority

Float

sort_order

PbiStatus

status

String

pr_url

DateTime

pr_merged_at

DateTime

created_at

DateTime

updated_at

stories

String

id

🗝️

String

code

String

title

String

description

String

acceptance_criteria

Int

priority

Float

sort_order

StoryStatus

status

DateTime

created_at

DateTime

updated_at

story_logs

String

id

🗝️

LogType

type

String

content

TestStatus

status

String

commit_hash

String

commit_message

Json

metadata

DateTime

created_at

sprints

String

id

🗝️

String

sprint_goal

SprintStatus

status

DateTime

start_date

DateTime

end_date

DateTime

created_at

DateTime

completed_at

tasks

String

id

🗝️

String

code

String

title

String

description

String

implementation_plan

Int

priority

Float

sort_order

TaskStatus

status

Boolean

verify_only

VerifyRequired

verify_required

String

repo_url

DateTime

created_at

DateTime

updated_at

claude_jobs

String

id

🗝️

ClaudeJobKind

kind

ClaudeJobStatus

status

DateTime

claimed_at

DateTime

started_at

DateTime

finished_at

DateTime

pushed_at

VerifyResult

verify_result

String

model_id

Int

input_tokens

Int

output_tokens

Int

cache_read_tokens

Int

cache_write_tokens

String

plan_snapshot

String

branch

String

pr_url

String

summary

String

error

Int

retry_count

DateTime

created_at

DateTime

updated_at

model_prices

String

id

🗝️

String

model_id

Decimal

input_price_per_1m

Decimal

output_price_per_1m

Decimal

cache_read_price_per_1m

Decimal

cache_write_price_per_1m

String

currency

DateTime

created_at

DateTime

updated_at

claude_workers

String

id

🗝️

String

product_id

DateTime

started_at

DateTime

last_seen_at

Int

last_quota_pct

DateTime

last_quota_check_at

product_members

String

id

🗝️

DateTime

created_at

ideas

String

id

🗝️

String

code

String

title

String

description

String

grill_md

String

plan_md

IdeaStatus

status

Boolean

archived

DateTime

created_at

DateTime

updated_at

idea_products

String

id

🗝️

DateTime

created_at

idea_logs

String

id

🗝️

IdeaLogType

type

String

content

Json

metadata

DateTime

created_at

user_questions

String

id

🗝️

String

user_id

String

question

String

answer

UserQuestionStatus

status

DateTime

created_at

DateTime

updated_at

login_pairings

String

id

🗝️

String

secret_hash

String

desktop_token_hash

String

status

String

desktop_ua

String

desktop_ip

DateTime

created_at

DateTime

expires_at

DateTime

approved_at

DateTime

consumed_at

claude_questions

String

id

🗝️

String

question

Json

options

String

status

String

answer

DateTime

answered_at

DateTime

created_at

DateTime

expires_at

\ No newline at end of file +

active_product

user

enum:role

user

user

enum:pr_strategy

product

enum:status

pbi

product

sprint

assignee

enum:status

story

enum:type

enum:status

product

enum:status

sprint

started_by

enum:status

enum:pr_strategy

failed_task

previous_run

story

product

sprint

enum:status

enum:verify_required

user

product

task

idea

sprint_run

enum:kind

enum:status

claimed_by_token

enum:verify_result

sprint_job

task

enum:verify_required_snapshot

enum:status

enum:verify_result

user

token

product

user

user

product

pbi

enum:status

idea

product

idea

enum:type

enum:status

idea

user

story

task

idea

product

asker

answerer

Role

PRODUCT_OWNER

PRODUCT_OWNER

SCRUM_MASTER

SCRUM_MASTER

DEVELOPER

DEVELOPER

ADMIN

ADMIN

StoryStatus

OPEN

OPEN

IN_SPRINT

IN_SPRINT

DONE

DONE

FAILED

FAILED

PbiStatus

READY

READY

BLOCKED

BLOCKED

FAILED

FAILED

DONE

DONE

ClaudeJobStatus

QUEUED

QUEUED

CLAIMED

CLAIMED

RUNNING

RUNNING

DONE

DONE

FAILED

FAILED

CANCELLED

CANCELLED

SKIPPED

SKIPPED

VerifyResult

ALIGNED

ALIGNED

PARTIAL

PARTIAL

EMPTY

EMPTY

DIVERGENT

DIVERGENT

VerifyRequired

ALIGNED

ALIGNED

ALIGNED_OR_PARTIAL

ALIGNED_OR_PARTIAL

ANY

ANY

TaskStatus

TO_DO

TO_DO

IN_PROGRESS

IN_PROGRESS

REVIEW

REVIEW

DONE

DONE

FAILED

FAILED

LogType

IMPLEMENTATION_PLAN

IMPLEMENTATION_PLAN

TEST_RESULT

TEST_RESULT

COMMIT

COMMIT

TestStatus

PASSED

PASSED

FAILED

FAILED

SprintStatus

ACTIVE

ACTIVE

COMPLETED

COMPLETED

FAILED

FAILED

SprintRunStatus

QUEUED

QUEUED

RUNNING

RUNNING

PAUSED

PAUSED

DONE

DONE

FAILED

FAILED

CANCELLED

CANCELLED

PrStrategy

SPRINT

SPRINT

STORY

STORY

SPRINT_BATCH

SPRINT_BATCH

IdeaStatus

DRAFT

DRAFT

GRILLING

GRILLING

GRILL_FAILED

GRILL_FAILED

GRILLED

GRILLED

PLANNING

PLANNING

PLAN_FAILED

PLAN_FAILED

PLAN_READY

PLAN_READY

PLANNED

PLANNED

ClaudeJobKind

TASK_IMPLEMENTATION

TASK_IMPLEMENTATION

IDEA_GRILL

IDEA_GRILL

IDEA_MAKE_PLAN

IDEA_MAKE_PLAN

PLAN_CHAT

PLAN_CHAT

SPRINT_IMPLEMENTATION

SPRINT_IMPLEMENTATION

SprintTaskExecutionStatus

PENDING

PENDING

RUNNING

RUNNING

DONE

DONE

FAILED

FAILED

SKIPPED

SKIPPED

IdeaLogType

DECISION

DECISION

NOTE

NOTE

GRILL_RESULT

GRILL_RESULT

PLAN_RESULT

PLAN_RESULT

STATUS_CHANGE

STATUS_CHANGE

JOB_EVENT

JOB_EVENT

UserQuestionStatus

pending

pending

answered

answered

users

String

id

🗝️

String

username

String

email

String

password_hash

Boolean

is_demo

String

bio

String

bio_detail

Boolean

must_reset_password

Bytes

avatar_data

Int

idea_code_counter

Int

min_quota_pct

DateTime

created_at

DateTime

updated_at

user_roles

String

id

🗝️

Role

role

api_tokens

String

id

🗝️

String

token_hash

String

label

DateTime

created_at

DateTime

revoked_at

products

String

id

🗝️

String

name

String

code

String

description

String

repo_url

String

definition_of_done

Boolean

auto_pr

PrStrategy

pr_strategy

Boolean

archived

DateTime

created_at

DateTime

updated_at

pbis

String

id

🗝️

String

code

String

title

String

description

Int

priority

Float

sort_order

PbiStatus

status

String

pr_url

DateTime

pr_merged_at

DateTime

created_at

DateTime

updated_at

stories

String

id

🗝️

String

code

String

title

String

description

String

acceptance_criteria

Int

priority

Float

sort_order

StoryStatus

status

DateTime

created_at

DateTime

updated_at

story_logs

String

id

🗝️

LogType

type

String

content

TestStatus

status

String

commit_hash

String

commit_message

Json

metadata

DateTime

created_at

sprints

String

id

🗝️

String

code

String

sprint_goal

SprintStatus

status

DateTime

start_date

DateTime

end_date

DateTime

created_at

DateTime

completed_at

sprint_runs

String

id

🗝️

SprintRunStatus

status

PrStrategy

pr_strategy

String

branch

String

pr_url

DateTime

started_at

DateTime

finished_at

String

failure_reason

Json

pause_context

DateTime

created_at

DateTime

updated_at

tasks

String

id

🗝️

String

code

String

title

String

description

String

implementation_plan

Int

priority

Float

sort_order

TaskStatus

status

Boolean

verify_only

VerifyRequired

verify_required

String

repo_url

DateTime

created_at

DateTime

updated_at

claude_jobs

String

id

🗝️

ClaudeJobKind

kind

ClaudeJobStatus

status

DateTime

claimed_at

DateTime

started_at

DateTime

finished_at

DateTime

pushed_at

VerifyResult

verify_result

String

model_id

Int

input_tokens

Int

output_tokens

Int

cache_read_tokens

Int

cache_write_tokens

String

plan_snapshot

String

base_sha

String

head_sha

String

branch

String

pr_url

String

summary

String

error

Int

retry_count

DateTime

lease_until

DateTime

created_at

DateTime

updated_at

sprint_task_executions

String

id

🗝️

Int

order

String

plan_snapshot

VerifyRequired

verify_required_snapshot

Boolean

verify_only_snapshot

String

base_sha

String

head_sha

SprintTaskExecutionStatus

status

VerifyResult

verify_result

String

verify_summary

String

skip_reason

DateTime

started_at

DateTime

finished_at

DateTime

created_at

DateTime

updated_at

model_prices

String

id

🗝️

String

model_id

Decimal

input_price_per_1m

Decimal

output_price_per_1m

Decimal

cache_read_price_per_1m

Decimal

cache_write_price_per_1m

String

currency

DateTime

created_at

DateTime

updated_at

claude_workers

String

id

🗝️

String

product_id

DateTime

started_at

DateTime

last_seen_at

Int

last_quota_pct

DateTime

last_quota_check_at

product_members

String

id

🗝️

DateTime

created_at

ideas

String

id

🗝️

String

code

String

title

String

description

String

grill_md

String

plan_md

IdeaStatus

status

Boolean

archived

DateTime

created_at

DateTime

updated_at

idea_products

String

id

🗝️

DateTime

created_at

idea_logs

String

id

🗝️

IdeaLogType

type

String

content

Json

metadata

DateTime

created_at

user_questions

String

id

🗝️

String

user_id

String

question

String

answer

UserQuestionStatus

status

DateTime

created_at

DateTime

updated_at

login_pairings

String

id

🗝️

String

secret_hash

String

desktop_token_hash

String

status

String

desktop_ua

String

desktop_ip

DateTime

created_at

DateTime

expires_at

DateTime

approved_at

DateTime

consumed_at

claude_questions

String

id

🗝️

String

question

Json

options

String

status

String

answer

DateTime

answered_at

DateTime

created_at

DateTime

expires_at

\ No newline at end of file diff --git a/lib/code-server.ts b/lib/code-server.ts index 461c859..a74fc4b 100644 --- a/lib/code-server.ts +++ b/lib/code-server.ts @@ -41,6 +41,7 @@ export async function createWithCodeRetry( const STORY_AUTO_RE = /^ST-(\d+)$/ const PBI_AUTO_RE = /^PBI-(\d+)$/ const TASK_AUTO_RE = /^T-(\d+)$/ +const SPRINT_AUTO_RE = /^SP-(\d+)$/ function nextSequential(existing: (string | null)[], pattern: RegExp): number { let max = 0 @@ -82,3 +83,12 @@ export async function generateNextTaskCode(productId: string): Promise { return `T-${next}` } +export async function generateNextSprintCode(productId: string): Promise { + 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}` +} + diff --git a/lib/insights/burndown.ts b/lib/insights/burndown.ts index 551d216..bc14b11 100644 --- a/lib/insights/burndown.ts +++ b/lib/insights/burndown.ts @@ -9,6 +9,7 @@ export interface BurndownDay { export interface BurndownSprint { sprintId: string + sprintCode: string productId: string productName: string sprintGoal: string @@ -62,6 +63,7 @@ export async function getBurndownData(userId: string): Promise }, select: { id: true, + code: true, sprint_goal: true, created_at: true, completed_at: true, @@ -77,6 +79,7 @@ export async function getBurndownData(userId: string): Promise return { sprintId: sprint.id, + sprintCode: sprint.code, productId: sprint.product.id, productName: sprint.product.name, sprintGoal: sprint.sprint_goal, diff --git a/lib/insights/token-history.ts b/lib/insights/token-history.ts index 33f1abd..75674b0 100644 --- a/lib/insights/token-history.ts +++ b/lib/insights/token-history.ts @@ -2,6 +2,7 @@ import { prisma } from '@/lib/prisma' export interface SprintTokenRow { sprintId: string + sprintCode: string sprintGoal: string totalTokens: number totalCostUsd: number @@ -24,6 +25,7 @@ export interface PbiTokenRow { type RawSprintRow = { sprint_id: string + sprint_code: string sprint_goal: string total_tokens: bigint total_cost: number | null @@ -53,6 +55,7 @@ export async function getSprintTokenHistory( ? await prisma.$queryRaw` SELECT sp.id AS sprint_id, + sp.code AS sprint_code, sp.sprint_goal, COALESCE(SUM(cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens), 0) AS total_tokens, SUM( @@ -70,13 +73,14 @@ export async function getSprintTokenHistory( WHERE cj.user_id = ${userId} AND cj.status = 'DONE' 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 LIMIT ${limit} ` : await prisma.$queryRaw` SELECT sp.id AS sprint_id, + sp.code AS sprint_code, sp.sprint_goal, COALESCE(SUM(cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens), 0) AS total_tokens, SUM( @@ -93,13 +97,14 @@ export async function getSprintTokenHistory( LEFT JOIN model_prices mp ON mp.model_id = cj.model_id WHERE cj.user_id = ${userId} 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 LIMIT ${limit} ` return rows.map(r => ({ sprintId: r.sprint_id, + sprintCode: r.sprint_code, sprintGoal: r.sprint_goal, totalTokens: Number(r.total_tokens), totalCostUsd: Number(r.total_cost ?? 0), diff --git a/lib/insights/velocity.ts b/lib/insights/velocity.ts index c01c45e..05228f0 100644 --- a/lib/insights/velocity.ts +++ b/lib/insights/velocity.ts @@ -3,6 +3,7 @@ import { productAccessFilter } from '@/lib/product-access' export interface VelocitySprint { sprintId: string + sprintCode: string sprintGoal: string productId: string productName: string @@ -25,6 +26,7 @@ export async function getVelocity(userId: string, sprintsBack = 5): Promise ({ sprintId: sprint.id, + sprintCode: sprint.code, sprintGoal: sprint.sprint_goal, productId: sprint.product.id, productName: sprint.product.name, diff --git a/lib/insights/verify-stats.ts b/lib/insights/verify-stats.ts index 0de209f..1c6c0e6 100644 --- a/lib/insights/verify-stats.ts +++ b/lib/insights/verify-stats.ts @@ -20,6 +20,7 @@ export interface VerifyResultStats { export interface TrendPoint { sprintId: string + sprintCode: string sprintGoal: string productName: string alignedRatio: number @@ -117,6 +118,7 @@ export async function getAlignmentTrend( take: sprintsBack, select: { id: true, + code: true, sprint_goal: true, completed_at: true, product: { select: { name: true } }, @@ -137,6 +139,7 @@ export async function getAlignmentTrend( 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, diff --git a/prisma/migrations/20260507195507_add_sprint_code/migration.sql b/prisma/migrations/20260507195507_add_sprint_code/migration.sql new file mode 100644 index 0000000..5d096e1 --- /dev/null +++ b/prisma/migrations/20260507195507_add_sprint_code/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bb37e55..548f8fc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -299,6 +299,7 @@ model Sprint { id String @id @default(cuid()) product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) product_id String + code String @db.VarChar(30) sprint_goal String status SprintStatus @default(ACTIVE) start_date DateTime? @db.Date @@ -309,6 +310,7 @@ model Sprint { tasks Task[] sprint_runs SprintRun[] + @@unique([product_id, code]) @@index([product_id, status]) @@map("sprints") } diff --git a/prisma/seed.ts b/prisma/seed.ts index 50b4158..7ed1c43 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -124,6 +124,7 @@ async function main() { console.log(`Loaded backlog: ${milestones.length} milestones, ${milestones.reduce((acc, m) => acc + m.stories.length, 0)} stories`) let productTaskCounter = 0 + let sprintCounter = 0 for (const ms of milestones) { const pbi = await prisma.pbi.create({ data: { @@ -139,6 +140,7 @@ async function main() { const sprint = await prisma.sprint.create({ data: { product_id: product.id, + code: `SP-${++sprintCounter}`, sprint_goal: `${ms.key} — ${ms.goal}`, status: ms.sprint_status, },