Compare commits

...
Sign in to create a new pull request.

5 commits

Author SHA1 Message Date
080bdf39a0 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 <noreply@anthropic.com>
2026-05-02 15:54:30 +02:00
66af3d4d30 feat(ST-1204): move Insights NavBar link between Solo and Todo's
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 15:44:14 +02:00
ae0374b63a feat(ST-1203): add Insights link to NavBar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 15:43:05 +02:00
7404d586d1 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 <noreply@anthropic.com>
2026-05-02 15:32:54 +02:00
85f73e43f6 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 <noreply@anthropic.com>
2026-05-02 15:25:16 +02:00
10 changed files with 333 additions and 7 deletions

View file

@ -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<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } }
function makeFormData(data: Record<string, string | null>) {
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<string, string[]>
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<string, string[]>
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')
})
})

View file

@ -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')
}
})
})

View file

@ -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' }

View file

@ -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}`)

View file

@ -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'))}
</nav>
</div>

View file

@ -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 <Button type="submit" size="sm" disabled={pending}>{pending ? 'Opslaan…' : 'Opslaan'}</Button>
}
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<Record<string, 'DONE' | 'OPEN'>>({})
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
)}
</div>
<DemoTooltip show={isDemo}>
<Button size="sm" variant="outline" disabled={isDemo} className="shrink-0 border-warning/40 text-warning hover:bg-warning/10" onClick={() => setCompleteOpen(true)}>
Sprint afronden
</Button>
</DemoTooltip>
<div className="flex items-center gap-2 shrink-0">
<DemoTooltip show={isDemo}>
<Button size="sm" variant="ghost" disabled={isDemo} className="text-muted-foreground" onClick={() => !isDemo && setEditingDates(true)}>
{sprint.start_date && sprint.end_date
? `${toDateInputValue(sprint.start_date)}${toDateInputValue(sprint.end_date)}`
: 'Datums instellen'}
</Button>
</DemoTooltip>
<DemoTooltip show={isDemo}>
<Button size="sm" variant="outline" disabled={isDemo} className="border-warning/40 text-warning hover:bg-warning/10" onClick={() => setCompleteOpen(true)}>
Sprint afronden
</Button>
</DemoTooltip>
</div>
</div>
{/* Dates edit dialog */}
<Dialog open={editingDates} onOpenChange={setEditingDates}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Sprint datums instellen</DialogTitle>
</DialogHeader>
<form action={datesFormAction} className="space-y-4 p-1">
<input type="hidden" name="id" value={sprint.id} />
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Startdatum</label>
<input type="date" name="start_date" defaultValue={toDateInputValue(sprint.start_date)} className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
{typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).start_date && (
<p className="text-xs text-error">{(datesState.error as Record<string, string[]>).start_date[0]}</p>
)}
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Einddatum</label>
<input type="date" name="end_date" defaultValue={toDateInputValue(sprint.end_date)} className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
{typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).end_date && (
<p className="text-xs text-error">{(datesState.error as Record<string, string[]>).end_date[0]}</p>
)}
</div>
</div>
{typeof datesState?.error === 'string' && (
<p className="text-xs text-error">{datesState.error}</p>
)}
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" onClick={() => setEditingDates(false)}>Annuleren</Button>
<Button type="submit">Opslaan</Button>
</div>
</form>
</DialogContent>
</Dialog>
{/* Complete sprint dialog */}
<Dialog open={completeOpen} onOpenChange={setCompleteOpen}>
<DialogContent className="sm:max-w-2xl">

View file

@ -75,6 +75,23 @@ export function StartSprintButton({ productId }: StartSprintButtonProps) {
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Startdatum</label>
<input type="date" name="start_date" className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).start_date && (
<p className="text-xs text-error">{(state.error as Record<string, string[]>).start_date[0]}</p>
)}
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">Einddatum</label>
<input type="date" name="end_date" className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" />
{typeof state?.error === 'object' && (state.error as Record<string, string[]>).end_date && (
<p className="text-xs text-error">{(state.error as Record<string, string[]>).end_date[0]}</p>
)}
</div>
</div>
{globalError && (
<div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error">
{globalError}

39
lib/chart-colors.ts Normal file
View file

@ -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

View file

@ -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");

View file

@ -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[]