From 080bdf39a0e9b5441f2ae4e7b61d46c200fbdd43 Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Sat, 2 May 2026 15:54:30 +0200 Subject: [PATCH] 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 --- __tests__/actions/sprint-dates.test.ts | 97 +++++++++++++++++++++++ actions/sprints.ts | 45 ++++++++++- app/(app)/products/[id]/sprint/page.tsx | 7 ++ components/sprint/sprint-header.tsx | 74 +++++++++++++++-- components/sprint/start-sprint-button.tsx | 17 ++++ 5 files changed, 233 insertions(+), 7 deletions(-) create mode 100644 __tests__/actions/sprint-dates.test.ts 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/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/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}