feat(PBI-79/ST-1333): active-sprint null-contract + clearActiveSprintAction

- lib/user-settings.ts: activeSprints values nullable in Zod-schema.
  Key-aanwezigheid heeft nu betekenis (key+null = bewust geen sprint;
  key ontbreekt = fallback-cascade).
- lib/active-sprint.ts: nieuwe readStoredActiveSprintState helper +
  resolveActiveSprint respecteert expliciet 'cleared' state zonder fallback.
  clearActiveSprintInSettings schrijft null i.p.v. de key te verwijderen.
- actions/active-sprint.ts: nieuwe clearActiveSprintAction met auth +
  membership-check.
- components/shared/sprint-switcher.tsx: '— Geen actieve sprint —'-optie
  in dropdown, disabled wanneer er geen actieve sprint is.
- Tests: nieuwe active-sprint.test.ts (resolver-paden + clear),
  active-sprint-action.test.ts (action-laag), uitbreiding user-settings.test.ts.

Plan: docs/plans/PBI-79-backlog-sprint-workflow.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-11 13:35:32 +02:00
parent bf7162a5fc
commit 2af6f24598
9 changed files with 939 additions and 16 deletions

View file

@ -40,12 +40,20 @@ async function notifyUserSettings(
`
}
export async function getActiveSprintIdFromSettings(
userId: string,
type StoredActiveSprintState =
| { kind: 'unset' }
| { kind: 'cleared' }
| { kind: 'set'; sprintId: string }
export function readStoredActiveSprintState(
settings: UserSettings,
productId: string,
): Promise<string | null> {
const settings = await readSettings(userId)
return settings.layout?.activeSprints?.[productId] ?? null
): StoredActiveSprintState {
const map = settings.layout?.activeSprints
if (!map || !(productId in map)) return { kind: 'unset' }
const value = map[productId]
if (value === null) return { kind: 'cleared' }
return { kind: 'set', sprintId: value }
}
export async function setActiveSprintInSettings(
@ -71,10 +79,10 @@ export async function clearActiveSprintInSettings(
productId: string,
): Promise<void> {
const current = await readSettings(userId)
const existing = current.layout?.activeSprints
if (!existing || !(productId in existing)) return
const nextActiveSprints = { ...existing }
delete nextActiveSprints[productId]
const nextActiveSprints: Record<string, string | null> = {
...(current.layout?.activeSprints ?? {}),
[productId]: null,
}
const next: UserSettings = {
...current,
layout: { ...current.layout, activeSprints: nextActiveSprints },
@ -89,10 +97,14 @@ export async function resolveActiveSprint(
productId: string,
userId: string,
): Promise<ActiveSprint | null> {
const stored = await getActiveSprintIdFromSettings(userId, productId)
if (stored) {
const settings = await readSettings(userId)
const state = readStoredActiveSprintState(settings, productId)
if (state.kind === 'cleared') return null
if (state.kind === 'set') {
const sprint = await prisma.sprint.findFirst({
where: { id: stored, product_id: productId },
where: { id: state.sprintId, product_id: productId },
select: { id: true, code: true, status: true },
})
if (sprint) return sprint

View file

@ -45,7 +45,7 @@ const DevToolsPrefs = z.object({
const LayoutPrefs = z.object({
splitPanePositions: z.record(z.string(), z.array(z.number())).optional(),
activeSprints: z.record(z.string(), z.string()).optional(),
activeSprints: z.record(z.string(), z.string().nullable()).optional(),
}).strict()
export const UserSettingsSchema = z.object({