From ce94fb48c359f92185b6df54ceb9d83a40c1eaf5 Mon Sep 17 00:00:00 2001 From: Janpeter Visser Date: Sat, 2 May 2026 15:58:15 +0200 Subject: [PATCH] Foundation: route, recharts, sprint-dates migration, chart-colors helper (#46) * feat(ST-1201): add Sprint start_date/end_date + claude_jobs index migration - Sprint model: optionele start_date en end_date (DATE) voor burndown x-as - CREATE INDEX claude_jobs(status, finished_at) voor agent-throughput-queries - Bestaande sprints houden NULL; burndown skipt die Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1202): add lib/chart-colors.ts + vitest coverage MD3-token-to-CSS-var mappings for STATUS, PRIORITY, VERIFY, JOB_STATUS and SERIES_COLORS; all 5 tests pass. Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1203): add Insights link to NavBar Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1204): move Insights NavBar link between Solo and Todo's Co-Authored-By: Claude Sonnet 4.6 * feat(ST-1205): add sprint start_date/end_date UI + server actions - createSprintAction + updateSprintDatesAction: Zod date validation with end_date >= start_date cross-check - start-sprint-button: date inputs in create dialog - sprint-header: date display button + edit dialog with updateSprintDatesAction - sprint page: select start_date/end_date for SprintHeader prop - Demo blokkade via bestaande isDemo checks - 6 tests groen (validation + demo guard) Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- __tests__/actions/sprint-dates.test.ts | 97 +++++++++++++++++++ __tests__/lib/chart-colors.test.ts | 52 ++++++++++ actions/sprints.ts | 45 ++++++++- app/(app)/products/[id]/sprint/page.tsx | 7 ++ components/shared/nav-bar.tsx | 1 + components/sprint/sprint-header.tsx | 74 ++++++++++++-- components/sprint/start-sprint-button.tsx | 17 ++++ lib/chart-colors.ts | 39 ++++++++ .../migration.sql | 6 ++ prisma/schema.prisma | 2 + 10 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 __tests__/actions/sprint-dates.test.ts create mode 100644 __tests__/lib/chart-colors.test.ts create mode 100644 lib/chart-colors.ts create mode 100644 prisma/migrations/20260502132322_add_sprint_dates_and_jobs_index/migration.sql diff --git a/__tests__/actions/sprint-dates.test.ts b/__tests__/actions/sprint-dates.test.ts new file mode 100644 index 0000000..6cb59c2 --- /dev/null +++ b/__tests__/actions/sprint-dates.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) +vi.mock('iron-session', () => ({ + getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), +})) +vi.mock('@/lib/session', () => ({ + sessionOptions: { cookieName: 'test', password: 'test' }, +})) +vi.mock('@/lib/product-access', () => ({ + productAccessFilter: vi.fn().mockReturnValue({}), + getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1', user_id: 'user-1' }), +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + sprint: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + }, +})) + +import { prisma } from '@/lib/prisma' +import { createSprintAction, updateSprintDatesAction } from '@/actions/sprints' + +const mockSprint = prisma as unknown as { sprint: { findFirst: ReturnType; create: ReturnType; update: ReturnType } } + +function makeFormData(data: Record) { + const fd = new FormData() + for (const [k, v] of Object.entries(data)) { + if (v !== null) fd.append(k, v) + } + return fd +} + +describe('createSprintAction — date validation', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSprint.sprint.findFirst.mockResolvedValue(null) + mockSprint.sprint.create.mockResolvedValue({ id: 'sprint-1' }) + }) + + it('accepts valid start_date + end_date', async () => { + const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '2026-05-01', end_date: '2026-05-14' }) + const result = await createSprintAction(undefined, fd) + expect(result.success).toBe(true) + expect(mockSprint.sprint.create).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ start_date: new Date('2026-05-01'), end_date: new Date('2026-05-14') }) }) + ) + }) + + it('rejects end_date before start_date', async () => { + const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '2026-05-14', end_date: '2026-05-01' }) + const result = await createSprintAction(undefined, fd) + expect(result.error).toBeTruthy() + const errors = result.error as Record + expect(errors.end_date?.[0]).toContain('Einddatum') + }) + + it('accepts no dates (both optional)', async () => { + const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '', end_date: '' }) + const result = await createSprintAction(undefined, fd) + expect(result.success).toBe(true) + }) +}) + +describe('updateSprintDatesAction — date validation', () => { + beforeEach(() => { + vi.clearAllMocks() + mockSprint.sprint.findFirst.mockResolvedValue({ id: 'sprint-1', product_id: 'product-1' }) + mockSprint.sprint.update.mockResolvedValue({}) + }) + + it('saves valid dates', async () => { + const fd = makeFormData({ id: 'sprint-1', start_date: '2026-05-01', end_date: '2026-05-14' }) + const result = await updateSprintDatesAction(undefined, fd) + expect(result.success).toBe(true) + }) + + it('rejects end_date before start_date', async () => { + const fd = makeFormData({ id: 'sprint-1', start_date: '2026-05-10', end_date: '2026-05-05' }) + const result = await updateSprintDatesAction(undefined, fd) + expect(result.error).toBeTruthy() + const errors = result.error as Record + expect(errors.end_date?.[0]).toContain('Einddatum') + }) + + it('blocks demo users', async () => { + const { getIronSession } = await import('iron-session') + vi.mocked(getIronSession).mockResolvedValueOnce({ userId: 'user-1', isDemo: true } as never) + const fd = makeFormData({ id: 'sprint-1', start_date: '', end_date: '' }) + const result = await updateSprintDatesAction(undefined, fd) + expect(result.error).toBe('Niet beschikbaar in demo-modus') + }) +}) diff --git a/__tests__/lib/chart-colors.test.ts b/__tests__/lib/chart-colors.test.ts new file mode 100644 index 0000000..b8d0be2 --- /dev/null +++ b/__tests__/lib/chart-colors.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest' +import { + STATUS_COLORS, + PRIORITY_COLORS, + VERIFY_COLORS, + JOB_STATUS_COLORS, + SERIES_COLORS, +} from '@/lib/chart-colors' + +describe('chart-colors', () => { + it('STATUS_COLORS has all TaskStatus keys and non-empty values', () => { + const keys: (keyof typeof STATUS_COLORS)[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE'] + for (const key of keys) { + expect(STATUS_COLORS[key]).toBeTruthy() + expect(typeof STATUS_COLORS[key]).toBe('string') + } + }) + + it('PRIORITY_COLORS has keys 1-4 with non-empty values', () => { + const keys = [1, 2, 3, 4] as const + for (const key of keys) { + expect(PRIORITY_COLORS[key]).toBeTruthy() + expect(typeof PRIORITY_COLORS[key]).toBe('string') + } + }) + + it('VERIFY_COLORS has all VerifyResult keys and non-empty values', () => { + const keys: (keyof typeof VERIFY_COLORS)[] = ['ALIGNED', 'PARTIAL', 'EMPTY', 'DIVERGENT'] + for (const key of keys) { + expect(VERIFY_COLORS[key]).toBeTruthy() + expect(typeof VERIFY_COLORS[key]).toBe('string') + } + }) + + it('JOB_STATUS_COLORS has all ClaudeJobStatus keys and non-empty values', () => { + const keys: (keyof typeof JOB_STATUS_COLORS)[] = [ + 'queued', 'claimed', 'running', 'done', 'failed', 'cancelled', + ] + for (const key of keys) { + expect(JOB_STATUS_COLORS[key]).toBeTruthy() + expect(typeof JOB_STATUS_COLORS[key]).toBe('string') + } + }) + + it('SERIES_COLORS has 5 non-empty entries', () => { + expect(SERIES_COLORS).toHaveLength(5) + for (const color of SERIES_COLORS) { + expect(color).toBeTruthy() + expect(typeof color).toBe('string') + } + }) +}) diff --git a/actions/sprints.ts b/actions/sprints.ts index 7eb7229..8eb2292 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -16,6 +16,14 @@ function hasDuplicateIds(ids: string[]) { return new Set(ids).size !== ids.length } +const dateField = z.string().optional().nullable().transform(v => (v && v.trim() !== '' ? new Date(v) : null)) + +function validateDateOrder(data: { start_date: Date | null; end_date: Date | null }, ctx: z.RefinementCtx) { + if (data.start_date && data.end_date && data.end_date < data.start_date) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['end_date'], message: 'Einddatum moet na startdatum liggen' }) + } +} + export async function createSprintAction(_prevState: unknown, formData: FormData) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } @@ -24,9 +32,13 @@ export async function createSprintAction(_prevState: unknown, formData: FormData const parsed = z.object({ productId: z.string(), sprint_goal: z.string().min(1, 'Sprint Goal is verplicht').max(500), - }).safeParse({ + start_date: dateField, + end_date: dateField, + }).superRefine(validateDateOrder).safeParse({ productId: formData.get('productId'), sprint_goal: formData.get('sprint_goal'), + start_date: formData.get('start_date'), + end_date: formData.get('end_date'), }) if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } @@ -43,6 +55,8 @@ export async function createSprintAction(_prevState: unknown, formData: FormData 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, }, }) @@ -50,6 +64,35 @@ export async function createSprintAction(_prevState: unknown, formData: FormData return { success: true, sprintId: sprint.id } } +export async function updateSprintDatesAction(_prevState: unknown, formData: FormData) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = z.object({ + id: z.string(), + start_date: dateField, + end_date: dateField, + }).superRefine(validateDateOrder).safeParse({ + id: formData.get('id'), + start_date: formData.get('start_date'), + end_date: formData.get('end_date'), + }) + if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } + + const sprint = await prisma.sprint.findFirst({ + where: { id: parsed.data.id, product: productAccessFilter(session.userId) }, + }) + if (!sprint) return { error: 'Sprint niet gevonden' } + + await prisma.sprint.update({ + where: { id: parsed.data.id }, + data: { start_date: parsed.data.start_date, end_date: parsed.data.end_date }, + }) + revalidatePath(`/products/${sprint.product_id}/sprint`) + return { success: true } +} + export async function updateSprintGoalAction(_prevState: unknown, formData: FormData) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } diff --git a/app/(app)/products/[id]/sprint/page.tsx b/app/(app)/products/[id]/sprint/page.tsx index 3b16d5f..e8a6b9e 100644 --- a/app/(app)/products/[id]/sprint/page.tsx +++ b/app/(app)/products/[id]/sprint/page.tsx @@ -33,6 +33,13 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { const sprint = await prisma.sprint.findFirst({ where: { product_id: id, status: 'ACTIVE' }, + select: { + id: true, + sprint_goal: true, + status: true, + start_date: true, + end_date: true, + }, }) if (!sprint) redirect(`/products/${id}`) diff --git a/components/shared/nav-bar.tsx b/components/shared/nav-bar.tsx index f039e67..2a7e97e 100644 --- a/components/shared/nav-bar.tsx +++ b/components/shared/nav-bar.tsx @@ -137,6 +137,7 @@ export function NavBar({ pathname.includes('/solo') ) : disabledSpan('Solo')} + {navLink('/insights', 'Insights', pathname.startsWith('/insights'))} {navLink('/todos', "Todo's", pathname.startsWith('/todos'))} diff --git a/components/sprint/sprint-header.tsx b/components/sprint/sprint-header.tsx index e923af8..b47a567 100644 --- a/components/sprint/sprint-header.tsx +++ b/components/sprint/sprint-header.tsx @@ -12,13 +12,15 @@ import { } from '@/components/ui/dialog' import { toast } from 'sonner' import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { updateSprintGoalAction, completeSprintAction } from '@/actions/sprints' +import { updateSprintGoalAction, updateSprintDatesAction, completeSprintAction } from '@/actions/sprints' import type { SprintStory } from './sprint-backlog' interface Sprint { id: string sprint_goal: string status: string + start_date: Date | null + end_date: Date | null } interface SprintHeaderProps { @@ -34,8 +36,14 @@ function SaveGoalButton() { return } +function toDateInputValue(d: Date | null) { + if (!d) return '' + return d.toISOString().slice(0, 10) +} + export function SprintHeader({ productId: _productId, productName, sprint, isDemo, sprintStories }: SprintHeaderProps) { const [editingGoal, setEditingGoal] = useState(false) + const [editingDates, setEditingDates] = useState(false) const [completeOpen, setCompleteOpen] = useState(false) const [decisions, setDecisions] = useState>({}) const [isCompleting, startCompleting] = useTransition() @@ -50,6 +58,16 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem undefined ) + const [datesState, datesFormAction] = useActionState( + async (_prev: unknown, fd: FormData) => { + const result = await updateSprintDatesAction(_prev, fd) + if (result?.success) { setEditingDates(false); toast.success('Sprint datums opgeslagen') } + else if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt') + return result + }, + undefined + ) + function setDecision(storyId: string, value: 'DONE' | 'OPEN') { setDecisions(prev => ({ ...prev, [storyId]: value })) } @@ -96,13 +114,57 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem )} - - - +
+ + + + + + +
+ {/* Dates edit dialog */} + + + + Sprint datums instellen + +
+ +
+
+ + + {typeof datesState?.error === 'object' && (datesState.error as Record).start_date && ( +

{(datesState.error as Record).start_date[0]}

+ )} +
+
+ + + {typeof datesState?.error === 'object' && (datesState.error as Record).end_date && ( +

{(datesState.error as Record).end_date[0]}

+ )} +
+
+ {typeof datesState?.error === 'string' && ( +

{datesState.error}

+ )} +
+ + +
+
+
+
+ {/* Complete sprint dialog */} diff --git a/components/sprint/start-sprint-button.tsx b/components/sprint/start-sprint-button.tsx index f0b951c..f9c18d6 100644 --- a/components/sprint/start-sprint-button.tsx +++ b/components/sprint/start-sprint-button.tsx @@ -75,6 +75,23 @@ export function StartSprintButton({ productId }: StartSprintButtonProps) { )} +
+
+ + + {typeof state?.error === 'object' && (state.error as Record).start_date && ( +

{(state.error as Record).start_date[0]}

+ )} +
+
+ + + {typeof state?.error === 'object' && (state.error as Record).end_date && ( +

{(state.error as Record).end_date[0]}

+ )} +
+
+ {globalError && (
{globalError} diff --git a/lib/chart-colors.ts b/lib/chart-colors.ts new file mode 100644 index 0000000..561d4dc --- /dev/null +++ b/lib/chart-colors.ts @@ -0,0 +1,39 @@ +// Mapping van MD3-tokens naar CSS-var-strings voor Recharts fill/stroke. +// Recharts accepteert gewone strings — 'var(--status-done)' werkt direct. +export const STATUS_COLORS = { + TO_DO: 'var(--status-todo)', + IN_PROGRESS: 'var(--status-in-progress)', + REVIEW: 'var(--status-in-progress)', + DONE: 'var(--status-done)', +} as const + +export const PRIORITY_COLORS = { + 1: 'var(--priority-critical)', + 2: 'var(--priority-high)', + 3: 'var(--priority-medium)', + 4: 'var(--priority-low)', +} as const + +export const VERIFY_COLORS = { + ALIGNED: 'var(--status-done)', + PARTIAL: 'var(--priority-medium)', + EMPTY: 'var(--priority-critical)', + DIVERGENT: 'var(--priority-high)', +} as const + +export const JOB_STATUS_COLORS = { + queued: 'var(--muted-foreground)', + claimed: 'var(--status-in-progress)', + running: 'var(--status-in-progress)', + done: 'var(--status-done)', + failed: 'var(--priority-critical)', + cancelled: 'var(--muted-foreground)', +} as const + +export const SERIES_COLORS = [ + 'var(--chart-1)', + 'var(--chart-2)', + 'var(--chart-3)', + 'var(--chart-4)', + 'var(--chart-5)', +] as const diff --git a/prisma/migrations/20260502132322_add_sprint_dates_and_jobs_index/migration.sql b/prisma/migrations/20260502132322_add_sprint_dates_and_jobs_index/migration.sql new file mode 100644 index 0000000..c73d025 --- /dev/null +++ b/prisma/migrations/20260502132322_add_sprint_dates_and_jobs_index/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "sprints" ADD COLUMN "end_date" DATE, +ADD COLUMN "start_date" DATE; + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "claude_jobs_status_finished_at_idx" ON "claude_jobs"("status", "finished_at"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 820695b..4beb4f4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -223,6 +223,8 @@ model Sprint { product_id String sprint_goal String status SprintStatus @default(ACTIVE) + start_date DateTime? @db.Date + end_date DateTime? @db.Date created_at DateTime @default(now()) completed_at DateTime? stories Story[]