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 <noreply@anthropic.com>

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

* feat(ST-1203): add Insights link to NavBar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1204): move Insights NavBar link between Solo and Todo's

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-02 15:58:15 +02:00 committed by GitHub
parent 55a1ee035c
commit ce94fb48c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 333 additions and 7 deletions

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