From 2af6f2459816c4893e1263993f0c70dfdd6e418d Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 11 May 2026 13:35:32 +0200 Subject: [PATCH] feat(PBI-79/ST-1333): active-sprint null-contract + clearActiveSprintAction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../actions/active-sprint-action.test.ts | 103 ++++ __tests__/lib/active-sprint.test.ts | 190 ++++++ __tests__/lib/user-settings.test.ts | 13 + actions/active-sprint.ts | 28 +- components/shared/sprint-switcher.tsx | 29 +- docs/INDEX.md | 3 +- docs/plans/PBI-79-backlog-sprint-workflow.md | 551 ++++++++++++++++++ lib/active-sprint.ts | 36 +- lib/user-settings.ts | 2 +- 9 files changed, 939 insertions(+), 16 deletions(-) create mode 100644 __tests__/actions/active-sprint-action.test.ts create mode 100644 __tests__/lib/active-sprint.test.ts create mode 100644 docs/plans/PBI-79-backlog-sprint-workflow.md diff --git a/__tests__/actions/active-sprint-action.test.ts b/__tests__/actions/active-sprint-action.test.ts new file mode 100644 index 0000000..b87a767 --- /dev/null +++ b/__tests__/actions/active-sprint-action.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) +vi.mock('next/headers', () => ({ + cookies: vi.fn().mockResolvedValue({ + set: vi.fn(), + get: vi.fn(), + delete: vi.fn(), + }), +})) +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({}), +})) +vi.mock('@/lib/prisma', () => ({ + prisma: { + sprint: { findFirst: vi.fn() }, + product: { findFirst: vi.fn() }, + user: { + findUnique: vi.fn(), + update: vi.fn().mockResolvedValue({}), + }, + $executeRaw: vi.fn().mockResolvedValue(1), + }, +})) + +import { prisma } from '@/lib/prisma' +import { clearActiveSprintAction } from '@/actions/active-sprint' + +const mockPrisma = prisma as unknown as { + product: { findFirst: ReturnType } + user: { + findUnique: ReturnType + update: ReturnType + } +} + +describe('clearActiveSprintAction', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('writes null instead of deleting the key', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + mockPrisma.user.findUnique.mockResolvedValueOnce({ + settings: { layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } } }, + }) + + const result = await clearActiveSprintAction('p1') + + expect(result).toEqual({ success: true }) + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: { layout?: { activeSprints?: Record } } } + } + expect(updateArg.data.settings.layout?.activeSprints).toEqual({ + p1: null, + p2: 'sprint-2', + }) + }) + + it('preserves other product keys when clearing one', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) + mockPrisma.user.findUnique.mockResolvedValueOnce({ + settings: { + layout: { + activeSprints: { p1: 'sprint-1', p2: 'sprint-2', p3: null }, + }, + }, + }) + + await clearActiveSprintAction('p1') + + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: { layout?: { activeSprints?: Record } } } + } + expect(updateArg.data.settings.layout?.activeSprints).toEqual({ + p1: null, + p2: 'sprint-2', + p3: null, + }) + }) + + it('rejects when product is not accessible', async () => { + mockPrisma.product.findFirst.mockResolvedValueOnce(null) + + const result = await clearActiveSprintAction('p1') + + expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' }) + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) + + it('rejects invalid productId', async () => { + const result = await clearActiveSprintAction('') + + expect(result).toEqual({ error: 'Ongeldig product-id' }) + expect(mockPrisma.user.update).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/lib/active-sprint.test.ts b/__tests__/lib/active-sprint.test.ts new file mode 100644 index 0000000..b2de7ef --- /dev/null +++ b/__tests__/lib/active-sprint.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ + prisma: { + sprint: { findFirst: vi.fn() }, + user: { + findUnique: vi.fn(), + update: vi.fn().mockResolvedValue({}), + }, + $executeRaw: vi.fn().mockResolvedValue(1), + }, +})) + +import { prisma } from '@/lib/prisma' +import type { UserSettings } from '@/lib/user-settings' +import { + clearActiveSprintInSettings, + readStoredActiveSprintState, + resolveActiveSprint, +} from '@/lib/active-sprint' + +const mockPrisma = prisma as unknown as { + sprint: { findFirst: ReturnType } + user: { + findUnique: ReturnType + update: ReturnType + } + $executeRaw: ReturnType +} + +function withSettings(settings: UserSettings) { + mockPrisma.user.findUnique.mockResolvedValueOnce({ settings }) +} + +describe('readStoredActiveSprintState', () => { + it('returns unset when activeSprints map is absent', () => { + expect(readStoredActiveSprintState({}, 'p1')).toEqual({ kind: 'unset' }) + }) + + it('returns unset when productId key is absent', () => { + const settings: UserSettings = { + layout: { activeSprints: { p2: 'sprint-2' } }, + } + expect(readStoredActiveSprintState(settings, 'p1')).toEqual({ + kind: 'unset', + }) + }) + + it('returns cleared when key is present with null value', () => { + const settings: UserSettings = { + layout: { activeSprints: { p1: null } }, + } + expect(readStoredActiveSprintState(settings, 'p1')).toEqual({ + kind: 'cleared', + }) + }) + + it('returns set when key is present with string value', () => { + const settings: UserSettings = { + layout: { activeSprints: { p1: 'sprint-1' } }, + } + expect(readStoredActiveSprintState(settings, 'p1')).toEqual({ + kind: 'set', + sprintId: 'sprint-1', + }) + }) +}) + +describe('resolveActiveSprint', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns null without fallback when key is explicitly null (cleared)', async () => { + withSettings({ layout: { activeSprints: { p1: null } } }) + + const result = await resolveActiveSprint('p1', 'user-1') + + expect(result).toBeNull() + expect(mockPrisma.sprint.findFirst).not.toHaveBeenCalled() + }) + + it('returns the stored sprint when key is set and sprint exists', async () => { + withSettings({ layout: { activeSprints: { p1: 'sprint-1' } } }) + mockPrisma.sprint.findFirst.mockResolvedValueOnce({ + id: 'sprint-1', + code: 'SP-1', + status: 'OPEN', + }) + + const result = await resolveActiveSprint('p1', 'user-1') + + expect(result).toEqual({ id: 'sprint-1', code: 'SP-1', status: 'OPEN' }) + expect(mockPrisma.sprint.findFirst).toHaveBeenCalledTimes(1) + }) + + it('falls back when stored sprint is not found in DB', async () => { + withSettings({ layout: { activeSprints: { p1: 'stale-id' } } }) + mockPrisma.sprint.findFirst + .mockResolvedValueOnce(null) // stored lookup misses + .mockResolvedValueOnce({ id: 'sprint-open', code: 'SP-O', status: 'OPEN' }) + + const result = await resolveActiveSprint('p1', 'user-1') + + expect(result).toEqual({ + id: 'sprint-open', + code: 'SP-O', + status: 'OPEN', + }) + }) + + it('falls back to first OPEN sprint when key is absent', async () => { + withSettings({}) + mockPrisma.sprint.findFirst.mockResolvedValueOnce({ + id: 'sprint-open', + code: 'SP-O', + status: 'OPEN', + }) + + const result = await resolveActiveSprint('p1', 'user-1') + + expect(result).toEqual({ + id: 'sprint-open', + code: 'SP-O', + status: 'OPEN', + }) + }) + + it('falls back to recent CLOSED sprint when no OPEN exists', async () => { + withSettings({}) + mockPrisma.sprint.findFirst + .mockResolvedValueOnce(null) // no OPEN + .mockResolvedValueOnce({ + id: 'sprint-closed', + code: 'SP-C', + status: 'CLOSED', + }) + + const result = await resolveActiveSprint('p1', 'user-1') + + expect(result).toEqual({ + id: 'sprint-closed', + code: 'SP-C', + status: 'CLOSED', + }) + }) + + it('returns null when key absent and no sprints exist', async () => { + withSettings({}) + mockPrisma.sprint.findFirst.mockResolvedValue(null) + + const result = await resolveActiveSprint('p1', 'user-1') + + expect(result).toBeNull() + }) +}) + +describe('clearActiveSprintInSettings', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('writes null instead of deleting the key', async () => { + withSettings({ + layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } }, + }) + + await clearActiveSprintInSettings('user-1', 'p1') + + expect(mockPrisma.user.update).toHaveBeenCalledTimes(1) + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: UserSettings } + } + expect(updateArg.data.settings.layout?.activeSprints).toEqual({ + p1: null, + p2: 'sprint-2', + }) + }) + + it('adds the key with null when previously unset', async () => { + withSettings({}) + + await clearActiveSprintInSettings('user-1', 'p1') + + const updateArg = mockPrisma.user.update.mock.calls[0][0] as { + data: { settings: UserSettings } + } + expect(updateArg.data.settings.layout?.activeSprints).toEqual({ p1: null }) + }) +}) diff --git a/__tests__/lib/user-settings.test.ts b/__tests__/lib/user-settings.test.ts index c7cd5d9..985cf11 100644 --- a/__tests__/lib/user-settings.test.ts +++ b/__tests__/lib/user-settings.test.ts @@ -122,4 +122,17 @@ describe('UserSettingsSchema', () => { layout: { splitPanePositions: { x: [50, 50] }, activeSprints: { p: 's' } }, }).success).toBe(true) }) + + it('accepts null values in activeSprints (explicit "no active sprint")', () => { + const result = UserSettingsSchema.safeParse({ + layout: { activeSprints: { 'product-1': null, 'product-2': 'sprint-2' } }, + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.layout?.activeSprints).toEqual({ + 'product-1': null, + 'product-2': 'sprint-2', + }) + } + }) }) diff --git a/actions/active-sprint.ts b/actions/active-sprint.ts index 7705206..b0fbb75 100644 --- a/actions/active-sprint.ts +++ b/actions/active-sprint.ts @@ -7,7 +7,10 @@ import { z } from 'zod' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { productAccessFilter } from '@/lib/product-access' -import { setActiveSprintInSettings } from '@/lib/active-sprint' +import { + clearActiveSprintInSettings, + setActiveSprintInSettings, +} from '@/lib/active-sprint' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -18,6 +21,10 @@ const setSchema = z.object({ sprintId: z.string().min(1), }) +const clearSchema = z.object({ + productId: z.string().min(1), +}) + export async function setActiveSprintAction(productId: string, sprintId: string) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } @@ -41,6 +48,25 @@ export async function setActiveSprintAction(productId: string, sprintId: string) return { success: true, sprintId: parsed.data.sprintId } } +export async function clearActiveSprintAction(productId: string) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = clearSchema.safeParse({ productId }) + if (!parsed.success) return { error: 'Ongeldig product-id' } + + const product = await prisma.product.findFirst({ + where: { id: parsed.data.productId, ...productAccessFilter(session.userId) }, + select: { id: true }, + }) + if (!product) return { error: 'Product niet gevonden of niet toegankelijk' } + + await clearActiveSprintInSettings(session.userId, parsed.data.productId) + revalidatePath('/', 'layout') + return { success: true } +} + export async function syncActiveSprintCookieAction(productId: string, sprintId: string) { const session = await getSession() if (!session.userId) return diff --git a/components/shared/sprint-switcher.tsx b/components/shared/sprint-switcher.tsx index 4377742..d0ada8d 100644 --- a/components/shared/sprint-switcher.tsx +++ b/components/shared/sprint-switcher.tsx @@ -13,7 +13,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { cn } from '@/lib/utils' -import { setActiveSprintAction } from '@/actions/active-sprint' +import { clearActiveSprintAction, setActiveSprintAction } from '@/actions/active-sprint' import type { SprintStatusApi } from '@/lib/task-status' import { debugProps } from '@/lib/debug' @@ -67,6 +67,22 @@ export function SprintSwitcher({ }) } + function handleClearActiveSprint() { + if (!activeSprint) return + startTransition(async () => { + const result = await clearActiveSprintAction(productId) + if (result?.error) { + toast.error(typeof result.error === 'string' ? result.error : 'Wisselen mislukt') + return + } + if (pathname.includes('/sprint')) { + router.push(`/products/${productId}`) + } else { + router.refresh() + } + }) + } + if (sprints.length === 0) { return ( @@ -127,6 +143,17 @@ export function SprintSwitcher({ Toon afgeronde sprints + + — Geen actieve sprint — + + {visibleSprints.length === 0 ? (
Geen open sprints diff --git a/docs/INDEX.md b/docs/INDEX.md index e7f4d51..5dce5b3 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -2,7 +2,7 @@ # Documentation Index -Auto-generated on 2026-05-10 from front-matter and headings. +Auto-generated on 2026-05-11 from front-matter and headings. ## Architecture Decision Records @@ -55,6 +55,7 @@ Auto-generated on 2026-05-10 from front-matter and headings. | [PBI-11 — Mobile-shell met landscape-lock (settings + backlog + solo)](./plans/PBI-11-mobile-shell.md) | — | — | | [PBI-75 — Sprint task-edit client-side via workspace-store](./plans/PBI-75-sprint-task-edit-store.md) | — | — | | [PBI-78 — Cost-analyse widget op Insights-pagina](./plans/PBI-78-cost-analysis-widget.md) | — | — | +| [PBI-79: Product Backlog workflow — sprint-membership via vinkjes](./plans/PBI-79-backlog-sprint-workflow.md) | — | — | | [Queue-loop verplaatsen van Claude naar runner](./plans/queue-loop-extraction.md) | — | — | | [Advies - SprintRun, PR en worktree lifecycle als state machines](./plans/sprint-pr-worktree-state-machines.md) | draft | 2026-05-06 | | [ST-1109 — PBI krijgt een status (Ready / Blocked / Done)](./plans/ST-1109-pbi-status.md) | active | 2026-05-03 | diff --git a/docs/plans/PBI-79-backlog-sprint-workflow.md b/docs/plans/PBI-79-backlog-sprint-workflow.md new file mode 100644 index 0000000..a2bd739 --- /dev/null +++ b/docs/plans/PBI-79-backlog-sprint-workflow.md @@ -0,0 +1,551 @@ +# PBI-79: Product Backlog workflow — sprint-membership via vinkjes + +> **MCP:** PBI-79 (`cmp13vrxd0001m017ta9aflg9`) in Scrum4Me product (`cmohrysyj0000rd17clnjy4tc`). +> +> **Review verwerkt:** Dit plan is een herziene versie na de review in [`product-backlog-workflow-plan-review.md`](product-backlog-workflow-plan-review.md). De vier P1-bevindingen zijn allemaal geadresseerd, evenals de vijf P2-punten. Zie de sectie *"Reactie op review"* onderaan voor de mapping. + +## Context + +De Product Backlog-pagina (`/products/[id]`) is het hart van Scrum4Me. De **lazy-load-basis bestaat al** (filter-first/background-remaining-PBI's + lazy stories/tasks per klik via [lib/product-backlog-pbis.ts](lib/product-backlog-pbis.ts), `ensurePbiLoaded`, `ensureStoryLoaded`). Dit plan bouwt daarop voort, het herontwerpt dat fundament niet. + +Wat nog ontbreekt: + +1. **Geen uniforme sprint-samenstelling-UI**. Sprint-aanmaak loopt nu via twee flows: `createSprintAction` (één pbi_id) en `createSprintWithPbisAction` (array, via `NewSprintDialog`). Geen UI-feedback over welke PBI's al in welke mate "in de huidige sprint zitten". +2. **Stories aan/uit sprint per stuk** kan alleen via de Sprint-pagina, niet vanuit de backlog. +3. **Geen pending/dirty-flow** voor sprint-mutaties — alle huidige acties zijn direct gecommit, wat zware multi-toggle-flows omslachtig maakt. + +We bouwen een vinkje-gebaseerde workflow met drie states. Geen schemamutatie op de DB — `sprint_id` blijft op Story en Task. PBI-vinkjes zijn puur afgeleid. `task.sprint_id` blijft denormalisatie van `story.sprint_id` en wordt cascade-meeg­e­update bij bulk-mutaties. + +--- + +## Beslissingen (samenvatting) + +| Onderdeel | Keuze | +|---|---| +| **Datamodel** | Ongewijzigd. `story.sprint_id` is unit-of-truth; PBI/task vinkjes afgeleid | +| **Cross-sprint conflict** | Disabled vinkje + tooltip; **alleen** tegen andere OPEN sprints | +| **State A** (geen sprint) | Alle PBI's, geen vinkjes, klassieke 3-koloms inspect | +| **State A′ vorm** | Two-step: kleine modal (metadata) → sticky banner + inline vinkjes | +| **State A′ annuleren** | Dirty-close confirm (`useDirtyCloseGuard`-pattern) | +| **State A′ persistentie** | `user-settings.pendingSprintDraft[productId]` — compacte intent (zie hieronder), niet alle story-IDs | +| **Lege sprint** | Toegestaan | +| **State B vinkjes** | Tri-state op PBI (selector-afgeleid), binair op story; klikken muteert pending buffer | +| **State B pending scope** | Alleen sprint-membership toggles | +| **State B dirty-UI** | "Sprint opslaan"-knop altijd zichtbaar, disabled bij clean, met teller bij dirty | +| **State B navigatie bij dirty** | Confirm-dialog | +| **Sprint-switcher** | OPEN sprints + "Geen actieve sprint"-optie. CLOSED via bestaande sprint-pagina | +| **Sprint-scope** | Per-user (huidig `user-settings.activeSprints[productId]`) | +| **Multiple OPEN sprints** | Toegestaan — `createSprintAction`-uniqueness-check vervalt | +| **Nieuwe story in state B** | `sprint_id = activeSprintId` direct bij aanmaak | +| **Tasks-niveau** | Geen vinkjes. Cascade-meeg­e­updated met story | +| **Sprint metadata edit** | `SprintEditDialog` (goal, dates) via edit-icoon | +| **Sprint afsluiten** | Hergebruik bestaande `completeSprintAction` (per-story DONE/OPEN beslissing + PBI-promotie) — **niet** een nieuwe `closeSprintAction` | +| **`story.status` bij membership-mutaties** | Add: `status='IN_SPRINT'` (én `sprint_id` gezet). Remove: `status='OPEN'` (én `sprint_id=NULL`). `task.sprint_id` cascadeert in **dezelfde transactie** | +| **Eligibility voor toevoegen** | Server-resolve mag alleen stories met `sprint_id IS NULL` **en** `status != 'DONE'` toevoegen. Stories uit CLOSED/ARCHIVED/FAILED sprints met DONE-status zijn dus niet eligible — moeten eerst handmatig op OPEN gezet worden (of via re-open flow) | +| **Active-sprint null-contract** | Schema nullable maken — `activeSprints[productId]: string \| null`. **Key-aanwezigheid heeft betekenis**: key ontbreekt → fallback-cascade (eerste OPEN, dan recent CLOSED). Key met `null`-waarde → expliciet *geen* actieve sprint, géén fallback | +| **PBI-selectie-flow migratie** | Bestaande `selectionMode` + `NewSprintDialog` + `createSprintWithPbisAction` worden **omgebouwd** tot A′-draft-mode. Eén flow, geen feature-flag-parallellisme | +| **Initial server-side load** | Bestaande `getProductBacklogPbis(productId, query, 'matching')` blijft basis — geen counts in deze call. Geen stories, geen taken | +| **Background remaining-load** | Behoud huidige patroon: client laadt `?mode=remaining` via route handler | +| **PBI-counts (state B tri-state)** | Aparte lazy summary-endpoint `GET /api/products/[id]/sprint-membership-summary?sprintId=X&pbiIds=` — **expliciet gescoped op pbiIds** (visible/loaded batch), nooit product-breed. Alleen aangeroepen in state B | +| **Story-detail (description + taken)** | Lazy bij PBI-klik via bestaande `ensurePbiLoaded`/`ensureStoryLoaded` route handlers | +| **Story-IDs voor A′ tri-state** | **Niet** brede `getStoryIdsByPbi(productId)`-fetch. Per PBI lazy via dezelfde `ensurePbiLoaded` als state A | +| **Cross-sprint conflict-detectie** | Server-side bij commit (autoritatief). Client-hint via lichte `GET /api/products/[id]/cross-sprint-blocks?excludeSprintId=X&pbiIds=` — **gescoped op pbiIds** voor disabled-vinkjes | +| **Data-access stijl** | Blijven bij **route handlers + `cache: 'no-store'` + `revalidatePath`** (huidige stijl). Géén Cache Components / `'use cache'` / `cacheTag` in dit plan | +| **Sync na commit** | Server action retourneert affected ids → client patcht workspace-store gericht. **Geen `router.refresh()` of full page rehydration** | + +--- + +## State A — geen actieve sprint geselecteerd + +**UI:** bestaande 3-koloms layout uit [components/backlog/backlog-split-pane.tsx](components/backlog/backlog-split-pane.tsx) onveranderd. PBI-lijst | Story-panel | Task-panel. Geen vinkjes. + +**Header-acties:** sprint-switcher toont "Geen actieve sprint" + dropdown van OPEN sprints + "— Geen actieve sprint —"-optie. Naast switcher: knop **"Nieuwe sprint"** → start A′ door metadata-modal te openen. + +**Wijzigingen t.o.v. huidig gedrag:** +- Sprint-switcher in [components/shared/sprint-switcher.tsx](components/shared/sprint-switcher.tsx) krijgt expliciete optie "— Geen actieve sprint —"; selectie roept (nieuwe) `clearActiveSprintAction(productId)` aan → schrijft `null` in user-settings. +- De huidige "Start Sprint"-knop in [app/(app)/products/[id]/page.tsx](app/(app)/products/[id]/page.tsx) wordt "Nieuwe sprint" en triggert A′-flow i.p.v. direct `NewSprintDialog`. + +--- + +## State A′ — sprint definiëren (ombouw van huidige selectionMode) + +### Migratie-uitgangspunt + +De bestaande PBI-selectie-flow in [components/backlog/pbi-list.tsx:219-523](components/backlog/pbi-list.tsx) heeft al: +- `selectionMode` boolean en `selectedIds: Set` +- `toggleCheck(id)` voor PBI-toggles +- `exitSelection()` voor cleanup +- `NewSprintDialog` aanroep met `pbiIds`-array +- Server-action `createSprintWithPbisAction` die alle stories van geselecteerde PBI's bulk-update + +We **bouwen dit om** tot A′. Het oude `NewSprintDialog` wordt vervangen door de two-step flow (metadata-modal → banner). De selectie-state wordt uitgebreid van "PBI's only" naar "PBI's én individuele stories (overrides)". `createSprintWithPbisAction` wordt aangepast om óók override-lijsten te accepteren. + +### Stap 1: metadata-modal + +Klik "Nieuwe sprint" → kleine `Dialog` (Entity-Dialog-pattern uit [docs/patterns/dialog.md](docs/patterns/dialog.md)): +- **Sprint-doel** (`sprint_goal`, verplicht) +- **Startdatum** (optioneel, default = vandaag) +- **Einddatum** (optioneel, default = +2 weken) +- Knoppen: "Annuleren" | "Verder" + +"Verder" valideert (Zod) en schrijft via `setPendingSprintDraftAction` naar user-settings. **Geen sprint in DB.** + +### Stap 2: vinkjes + sticky banner (compacte intent-state) + +Op de pagina verschijnt een **sticky banner**: +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Sprint definiëren — [doel] · X PBI's, Y stories │ +│ [Annuleren] [Sprint aanmaken] │ +└──────────────────────────────────────────────────────────────────┘ +``` + +Op alle PBI-rijen en story-rijen verschijnen vinkjes — story-vinkjes pas zichtbaar als de PBI is geopend (via bestaande `ensurePbiLoaded`). + +**Pending draft-state (compact, overrides per PBI):** + +```ts +pendingSprintDraft: { + goal: string + startAt?: string + endAt?: string + // Per-PBI bulk-intent: + pbiIntent: { + [pbiId]: 'all' | 'none' // default 'none' tot user PBI aanvinkt + } + // Per-PBI overrides (story-ids die afwijken van de PBI-intent): + storyOverrides: { + [pbiId]: { + add: string[] // expliciet aan, ook al staat PBI op 'none' + remove: string[] // expliciet uit, ook al staat PBI op 'all' + } + } +} +``` + +**Waarom per-PBI overrides (i.p.v. één globale add/remove):** bij PBI-toggle (`'all' → 'none'`) of bij sessie-restore moet je zonder brede story-fetch betrouwbaar weten welke overrides bij welke PBI horen. Globale lijsten dwingen je tot een product-breed `getStoryIdsByPbi` om op te schonen — dat is precies wat we niet willen. Met per-PBI overrides is opruimen lokaal: bij PBI-toggle wis je `storyOverrides[pbiId]`, klaar. + +**Tri-state-resolutie (selector, niet opgeslagen):** +- PBI-vinkje weergave: bereken uit `pbiIntent[pbiId]` + de subset van zijn child-stories die geladen is + `storyOverrides[pbiId]`. Bij `intent='all'` en geen `remove` → ✓. Bij `intent='none'` en geen `add` → ☐. Anders ◐. +- Story-vinkje: `(pbiIntent[pbiId] == 'all' || storyOverrides[pbiId]?.add?.includes(storyId)) && !storyOverrides[pbiId]?.remove?.includes(storyId)`. + +**Toggle-semantiek:** +- Klik PBI-vinkje ☐→✓: `pbiIntent[pbi] = 'all'`, wis `storyOverrides[pbi]`. +- Klik PBI-vinkje ✓→☐: `pbiIntent[pbi] = 'none'`, wis `storyOverrides[pbi]`. +- Klik story-vinkje (in geopende PBI): voeg toe aan `storyOverrides[pbi].add` of `.remove`, met cancel-out tegen de tegenoverliggende lijst van diezelfde PBI. + +**Voordelen:** geen N×K JSON-blob per draft. Per-PBI scoping maakt cleanup lokaal en restore deterministisch. + +**Annuleren** → dirty-close confirm → `clearPendingSprintDraftAction` → banner verdwijnt. + +**Sprint aanmaken** → server action `createSprintWithSelectionAction(productId, metadata, pbiIntent, storyOverrides)`: +1. Server resolveert intent → concrete `storyIdsToAddToSprint: string[]`: + - Voor elke PBI met `intent = 'all'`: alle child-stories minus `storyOverrides[pbi].remove` + - Plus alle stories in `storyOverrides[pbi].add` (over alle PBI's) +2. **Eligibility-filter (server, autoritatief):** behoud alleen stories waarvoor `sprint_id IS NULL` **en** `status != 'DONE'`. Stories die niet voldoen (in andere sprint, of al DONE) komen in `conflicts.notEligible[]` met reden. +3. **Cross-sprint-check** (gedekt door eligibility, maar separately rapporteren): geblokkeerde stories → `conflicts.crossSprint[]` met `{ storyId, sprintId, sprintName }`. +4. Transactie: + - Insert Sprint (status=OPEN) + - `story.sprint_id = newSprintId, story.status = 'IN_SPRINT' WHERE id IN (eligibleStoryIds)` + - `task.sprint_id = newSprintId WHERE story_id IN (eligibleStoryIds)` (cascade — task.status onveranderd) +5. `clearPendingSprintDraftAction` + `setActiveSprintInSettings(productId, newSprintId)` +6. Realtime-event broadcasting +7. **Return:** `{ sprintId, affectedStoryIds, affectedPbiIds, conflicts: { notEligible, crossSprint } }` +8. Client patcht workspace-store gericht: voeg sprintId toe aan stories/tasks, zet `story.status = 'IN_SPRINT'`, invalidate `pbiSummary`-counts voor affected PBI's via lazy summary-refetch (gescoped). Toast voor conflicts. **Geen page-refresh.** + +### Persistent draft + +Verlaten van de pagina/sessie tijdens A′ → `pendingSprintDraft` blijft in user-settings. Volgende bezoek: pagina detecteert draft → banner + vinkjes verschijnen automatisch. + +--- + +## State B — actieve sprint geselecteerd + +### UI + +- **Header**: sprint-switcher toont actieve sprint. Edit-icoon ernaast → opent `SprintEditDialog` (alleen metadata: goal + dates). +- **"Sprint opslaan"-knop**: altijd zichtbaar, disabled bij clean, geactiveerd met teller bij dirty: *"Sprint opslaan (3)"*. +- **Sprint afsluiten**: bestaande `completeSprintAction`-flow blijft op de sprint-pagina (`/products/[id]/sprint/[sprintId]`); SprintEditDialog krijgt een link "Sprint afronden…" die naar die pagina navigeert. Geen duplicate flow. +- **3-koloms layout**: ongewijzigd. PBI-vinkjes (tri-state via selector), story-vinkjes (binair, disabled-bij-conflict), geen task-vinkjes. + +### Pending buffer (state B) + +In [stores/product-workspace/store.ts](stores/product-workspace/store.ts) toevoegen — **arrays, niet Sets**: + +```ts +sprintMembershipPending: { + adds: string[] // story-ids die in actieve sprint moeten + removes: string[] // story-ids die uit actieve sprint moeten +} +``` +- `isDirty` selector: `adds.length + removes.length > 0` +- Teller selector: `adds.length + removes.length` +- Cancel-out: bij toggle terug wordt het ID uit de tegenoverliggende lijst gehaald + +Arrays zijn JSON-serialiseerbaar (handig voor debugging/devtools) en spelen netjes met Zustand/Immer (geen mutable Set-valkuil). + +### Tri-state vinkjes via selectors (geen opgeslagen state) + +In [stores/product-workspace/store.ts](stores/product-workspace/store.ts): + +```ts +// Primitieven (opgeslagen): +pbiSummary: { + [pbiId]: { + totalStoryCount: number // uit summary-endpoint + inActiveSprintStoryCount: number // uit summary-endpoint, of 0 in state A + } +} +loadedStoryIdsByPbi: { [pbiId]: string[] } // alleen voor stories die al geladen zijn +storiesByPbi: { [pbiId]: Story[] | undefined } +tasksByStory: { [storyId]: Task[] | undefined } +sprintMembershipPending: { adds: string[], removes: string[] } +crossSprintBlocks: { [storyId]: { sprintId: string, sprintName: string } } // lazy + +// Selectors (afgeleid, gememoized): +selectPbiTriState(pbiId): 'empty' | 'partial' | 'full' +selectStoryEffectiveInSprint(storyId): boolean +selectStoryIsBlocked(storyId): { sprintId, sprintName } | null +``` + +`selectPbiTriState` rekent met `inActiveSprintStoryCount` + pending adds/removes voor stories van deze PBI (waarvan we de mapping kennen via `loadedStoryIdsByPbi` of via een lichte query bij PBI-load). Als de PBI niet geladen is, kan tri-state worden afgeleid uit de counts alleen (full = count==total, empty = count==0, partial = anders). + +### Sprint opslaan + +Server action `commitSprintMembershipAction(activeSprintId, adds[], removes[])`: +1. **Eligibility-filter voor `adds` (server, autoritatief):** behoud alleen stories met `sprint_id IS NULL` **en** `status != 'DONE'`. Niet-eligible stories (cross-sprint-conflict, of DONE) komen in `conflicts.notEligible[]`. +2. **`removes`-filter:** behoud alleen stories die feitelijk `sprint_id = activeSprintId` hebben (race-safety; story kan ondertussen al ergens anders heen verplaatst zijn). +3. Transactie: + - **Add**: `story.sprint_id = activeSprintId, story.status = 'IN_SPRINT' WHERE id IN (eligibleAdds)` + - **Add**: `task.sprint_id = activeSprintId WHERE story_id IN (eligibleAdds)` (cascade, task.status onveranderd) + - **Remove**: `story.sprint_id = NULL, story.status = 'OPEN' WHERE id IN (validRemoves)` + - **Remove**: `task.sprint_id = NULL WHERE story_id IN (validRemoves)` (cascade) +4. Realtime-events broadcasten +5. **Return:** `{ affectedStoryIds, affectedPbiIds, affectedTaskIds, conflicts: { notEligible, alreadyRemoved } }` +6. Client patcht store gericht: + - Update `story.sprint_id` + `story.status` voor affected stories in `storiesById` / `storiesByPbi` + - Update `task.sprint_id` voor affected tasks + - Debounced refetch van `sprint-membership-summary` voor affected PBI's (**gescoped op `pbiIds=affectedPbiIds`**) + - Wis pending buffer + - Toast voor conflicts + - **Geen `router.refresh()`.** + +### Andere mutaties in state B + +- **Story aanmaken** (StoryDialog): `sprint_id = activeSprintId` direct bij create. Verschijnt direct in sprint. +- **PBI/Story/Task field-edit** (bestaande Entity Dialogs): onveranderd. +- **Sprint-switcher wisselt bij dirty**: confirm-dialog. +- **Wegnavigeren met dirty**: `useDirtyCloseGuard` → confirm-dialog. + +--- + +## Cross-sprint conflict — afhandeling + +**Client (hint-laag):** lazy fetch `GET /api/products/[id]/cross-sprint-blocks?excludeSprintId=X` bij state-B-load. Vult `crossSprintBlocks` in de store. Story-rij met `crossSprintBlocks[storyId] != null` → vinkje disabled, tooltip "Zit in Sprint [naam]". + +**Server (autoritatieve check):** in `commitSprintMembershipAction` en `createSprintWithSelectionAction` opnieuw checken — race-conditie wordt afgevangen, conflicts worden geretourneerd als warning. Client toont toast voor geskippte stories. + +Helper `lib/sprint-conflicts.ts` (nieuw) doet de check op een set story-IDs en geeft `{ allowed: string[], blocked: { storyId, sprintId, sprintName }[] }`. + +--- + +## SprintEditDialog (nieuw) + +`components/backlog/sprint-edit-dialog.tsx` — Entity-Dialog-pattern: +- Velden: `sprint_goal`, `start_at`, `end_at` +- Knop "Opslaan" → `updateSprintAction(sprintId, fields)` +- Link "Sprint afronden…" → navigeert naar `/products/[id]/sprint/[sprintId]` (bestaande sprint-page met `completeSprintAction`) +- **Geen** "Sprint afsluiten"-knop hier — hergebruik bestaande completion-flow met per-story DONE/OPEN beslissing en PBI-promotie. + +Server action `updateSprintAction(sprintId, { goal?, start_at?, end_at? })`: validate met Zod, update Sprint-record, `revalidatePath('/products/[id]')`, retourneert affected sprint. Client patcht sprint-record in store. + +--- + +## Dataflow + +### Uitgangspunten + +- **Blijf bij route handlers + `cache: 'no-store'`** (huidige patroon). Geen `'use cache'`/`cacheTag` in deze migratie — review's P2 zegt: meng deze stijlen niet half. Migratie naar Cache Components is een eigen project. +- **Filter-first respecteren**: initial render levert alleen *matching* PBI-metadata; *remaining* op de achtergrond — beide via bestaande [getProductBacklogPbis](lib/product-backlog-pbis.ts). +- **Geen aggregaten in initial query**: dat zou bij groei alsnog brede story-aggregaties bij elke render forceren. +- **Counts apart via lazy endpoint**: alleen voor state B, alleen voor zichtbare PBI's (of bulk per sprint — beheerbaar omdat #PBI's per product bescheiden blijft). +- **Geen brede `getStoryIdsByPbi`**: hergebruik bestaande `ensurePbiLoaded`/`ensureStoryLoaded` lazy-loads. Tri-state werkt op counts (uit summary-endpoint) zolang de PBI dichtgeklapt is; pas bij open-klik komen story-IDs in beeld voor accurate selector-state. +- **Sync-model**: SSE-patches (al aanwezig) voor reactieve updates + `revalidatePath` na server-actions (huidige patroon) + gerichte client-store patches met de affected-IDs uit action-returns. + +### Initial server-side load (page render) + +Onveranderd t.o.v. huidige flow — geen nieuwe loader: + +```ts +// app/(app)/products/[id]/page.tsx (huidige code, behouden): +const initialPbiQuery = productBacklogPbiQueryFromSettings(...) +const pbis = await getProductBacklogPbis(id, initialPbiQuery, 'matching') +// Geen stories, geen taken in initial render. +``` + +Plus parallel: +- `activeSprint = resolveActiveSprint(productId, userId)` — gewijzigd om explicit `null` te respecteren (zie hieronder). +- `pendingSprintDraft = getUserSettings(userId).pendingSprintDraft?.[productId] ?? null`. + +### Background remaining-load + +Bestaande route handler `GET /api/products/[id]/backlog?mode=remaining` blijft. Client triggert na initial render om de overige PBI-metadata in de store te krijgen (zonder stories/tasks). + +### Lazy per PBI-klik + +Bestaande `ensurePbiLoaded(pbiId)` in [stores/product-workspace/store.ts](stores/product-workspace/store.ts) blijft. Fetch via route handler met `cache: 'no-store'`. Vult `storiesByPbi[pbiId]` + `loadedStoryIdsByPbi[pbiId]`. + +### Lazy per story-klik + +Bestaande `ensureStoryLoaded(storyId)` blijft (laadt taken). + +### Sprint-membership summary (NIEUW — alleen state B, gescoped) + +Nieuw route handler `GET /api/products/[id]/sprint-membership-summary?sprintId=X&pbiIds=`: +```ts +// Response: +{ + [pbiId: string]: { total: number, inSprint: number } +} +``` + +- **`pbiIds` is verplicht** — endpoint weigert product-brede aanroepen. Client geeft alleen visible/loaded PBI-IDs door. +- Eén `groupBy` op `Story` waar `pbi_id IN (pbiIds)` (matching-filter werkt nog: we vragen alleen counts voor PBI's die al in viewport-batch staan). +- Verwaarloosbare belasting omdat de query begrensd is op de doorgegeven set. + +Aangeroepen door client wanneer state B actief wordt OF na sprint-switch, OF na een commit (gescoped op affected pbi-ids). Vult `pbiSummary` in de store. + +In state A wordt **niet** aangeroepen. + +### Cross-sprint blocks (NIEUW — alleen state B, gescoped) + +Nieuw route handler `GET /api/products/[id]/cross-sprint-blocks?excludeSprintId=X&pbiIds=`: +```ts +{ + [storyId: string]: { sprintId: string, sprintName: string } +} +``` + +- **`pbiIds` verplicht** — endpoint weigert product-brede scans. Begrenzing op visible/loaded batch. +- Aangeroepen bij state B-load + na elke PBI-batch-load (zodat nieuwe PBI's hun blocks krijgen). +- Vult `crossSprintBlocks` in de store voor disabled-vinkjes. +- Server-side check bij commit blijft autoritatief — dit endpoint is alleen UX-hint. + +### Active-sprint resolver (gewijzigd) + +**Schema-contract (cruciaal, zit in [lib/user-settings.ts](lib/user-settings.ts)):** + +```ts +// Zod schema wijziging: +activeSprints: z.record(z.string(), z.string().nullable()).optional() +``` + +**Drie distincte states per `productId`:** + +| Settings-staat | Betekenis | +|---|---| +| Key ontbreekt | Geen voorkeur ingesteld — fallback-cascade actief (eerste OPEN, dan recent CLOSED, dan `null`) | +| Key bestaat met `string` | Die specifieke sprint is gekozen (mits gevonden in DB; anders fallback) | +| Key bestaat met `null` | **Bewust geen actieve sprint** — geen fallback, blijft "Geen actieve sprint" | + +**Wijzigingen in [lib/active-sprint.ts](lib/active-sprint.ts):** +- `resolveActiveSprint(productId, userId)` checkt `key in activeSprints` (niet alleen truthy): + - Key niet aanwezig → fallback-cascade + - Key aanwezig, value=null → return null + - Key aanwezig, value=string → die sprint +- `setActiveSprintInSettings(productId, sprintId)` ongewijzigd (schrijft string). +- **`clearActiveSprintInSettings(productId)` wordt aangepast**: i.p.v. de key te `delete`, schrijft het nu `null`. Dat is het verschil tussen "geen voorkeur" en "expliciet geen actieve sprint". + +**[actions/active-sprint.ts](actions/active-sprint.ts):** +- Nieuw: `clearActiveSprintAction(productId)` — gebruikt de aangepaste `clearActiveSprintInSettings` (schrijft null). +- Bestaande `setActiveSprintAction` ongewijzigd. + +### Sync na commit — gerichte client-store patches + +Server actions retourneren expliciet affected IDs: +```ts +return { affectedStoryIds, affectedPbiIds, affectedTaskIds, conflicts } +``` + +Client (na await): +1. Patch `storiesById` + `tasksById` met nieuwe `sprint_id`-waarden. +2. Voor elke `affectedPbiId`: fire-and-forget refetch van `sprint-membership-summary` (debounced 100ms) om counts te actualiseren. +3. Wis pending buffer. +4. **Geen `router.refresh()`.** + +`revalidatePath` blijft in de server-actie voor andere users / lossely-coupled views, maar de huidige user's UI updateert via de gerichte patches. + +### Data-load-volgorde overzicht + +| Moment | Wat | Wie | +|---|---|---| +| Page render | Matching PBI's (metadata) + activeSprint + draft | Server (SSR) — bestaande flow | +| Na hydratie | Remaining PBI's (metadata) | Client → bestaande `/api/.../backlog?mode=remaining` | +| State B activeert | Sprint-membership-summary + cross-sprint-blocks | Client → nieuwe endpoints | +| PBI-klik | Stories voor die PBI (full) | Client → bestaande `ensurePbiLoaded` | +| Story-klik | Taken voor die story | Client → bestaande `ensureStoryLoaded` | +| A→A′ start | Geen extra fetch — werk met `pendingSprintDraft` (compact) | | +| A′ stories cherrypicken | Klik PBI → bestaande lazy-load voor die PBI | | +| Sprint-switch | Refetch membership-summary + cross-sprint-blocks voor nieuwe sprint | Client | +| SSE event | Patch lokale store | Client | +| Na server-action commit | Affected IDs uit return → gerichte store-patches + debounced summary-refetch | Client | + +--- + +## Critical files + +### Te wijzigen + +- [app/(app)/products/[id]/page.tsx](app/(app)/products/[id]/page.tsx) — state-detectie (A/A′/B); banner-rendering; "Nieuwe sprint"-knop opent metadata-modal (i.p.v. direct `NewSprintDialog`). **Initial query blijft `getProductBacklogPbis(id, query, 'matching')`** — geen counts hier. +- [components/backlog/pbi-list.tsx](components/backlog/pbi-list.tsx) — bestaande `selectionMode` ombouwen tot A′-modus: vinkjes worden tri-state, lezen uit `pendingSprintDraft.pbiIntent` of (in state B) uit `selectPbiTriState`-selector. Verwijder de directe `NewSprintDialog`-trigger. +- [components/backlog/story-panel.tsx](components/backlog/story-panel.tsx) — vinkje per story; lees uit selectors (`selectStoryEffectiveInSprint`, `selectStoryIsBlocked`); klik muteert `pendingSprintDraft.storyOverrides` of `sprintMembershipPending`. +- [components/backlog/task-panel.tsx](components/backlog/task-panel.tsx) — geen wijzigingen aan task-flow. +- [components/shared/sprint-switcher.tsx](components/shared/sprint-switcher.tsx) — "— Geen actieve sprint —"-optie; dirty-check bij wissel. +- [stores/product-workspace/store.ts](stores/product-workspace/store.ts) — uitbreidingen: `pbiSummary`, `loadedStoryIdsByPbi`, `crossSprintBlocks`, `sprintMembershipPending` (arrays), selectors voor tri-state, gerichte patch-helpers voor server-action-returns. +- [stores/user-settings/store.ts](stores/user-settings/store.ts) — `pendingSprintDraft[productId]: { goal, startAt?, endAt?, pbiIntent, storyOverrides: { [pbiId]: { add, remove } } } | null`; `activeSprints[productId]: string | null` (zie ook user-settings.ts hieronder). +- **[lib/user-settings.ts](lib/user-settings.ts)** — Zod-schema strictness: `activeSprints` value nullable; `pendingSprintDraft` als optionele key per productId met de hier-gespecificeerde shape; migratie-tests aanpassen. +- [actions/sprints.ts](actions/sprints.ts): + - `createSprintAction` — drop OPEN-uniqueness-check (multi-OPEN toegestaan) + - **`createSprintWithPbisAction` → uitbreiden naar `createSprintWithSelectionAction(productId, metadata, pbiIntent, storyOverrides)`**. Server resolveert intent → concrete story-IDs. Returnt affected IDs. + - Nieuw: `commitSprintMembershipAction(sprintId, adds[], removes[])` — transactional, retourneert affected + conflicts. + - Nieuw: `updateSprintAction(sprintId, { goal?, startAt?, endAt? })` — alleen metadata. + - **GEEN** nieuwe `closeSprintAction` — `completeSprintAction` blijft de afrond-flow. +- [actions/active-sprint.ts](actions/active-sprint.ts) — nieuwe `clearActiveSprintAction(productId)` (schrijft null). `setActiveSprintAction` ongewijzigd voor non-null. +- [lib/active-sprint.ts](lib/active-sprint.ts) — `resolveActiveSprint` checkt key-aanwezigheid (niet truthy): key+null → return null zonder fallback; key+string → sprint; key ontbreekt → fallback-cascade. **`clearActiveSprintInSettings` schrijft nu `null` i.p.v. key te verwijderen** (essentieel voor het null-contract). + +### Nieuw + +- `app/api/products/[id]/sprint-membership-summary/route.ts` — lazy counts endpoint +- `app/api/products/[id]/cross-sprint-blocks/route.ts` — lazy cross-sprint hint endpoint +- `components/backlog/sprint-definition-banner.tsx` — sticky banner voor A′ +- `components/backlog/new-sprint-metadata-dialog.tsx` — stap 1 van A′ +- `components/backlog/sprint-edit-dialog.tsx` — metadata-edit in B +- `lib/sprint-conflicts.ts` — cross-sprint check helpers +- `actions/sprint-draft.ts` — `setPendingSprintDraftAction`, `clearPendingSprintDraftAction` + +### Niet aangeraakt + +- [prisma/schema.prisma](prisma/schema.prisma) — geen schemawijziging +- Bestaande `completeSprintAction` en de sprint-pagina `/products/[id]/sprint/[sprintId]` — sprint-afronding-flow blijft daar +- [components/backlog/task-panel.tsx](components/backlog/task-panel.tsx), task-dialog, pbi-dialog, story-dialog — Entity Dialogs onveranderd + +--- + +## Hergebruik bestaande patronen + +- **Entity-Dialog-pattern**: metadata-modal + sprint-edit-dialog +- **useDirtyCloseGuard**: A′-annulering, B-navigatie +- **Zustand optimistic pattern**: pending buffer + gerichte server-action-return-patches +- **Realtime NOTIFY-payload**: sprint-membership events +- **Server-action-pattern**: auth + Zod +- **Filter-first/background-remaining**: blijft via [getProductBacklogPbis](lib/product-backlog-pbis.ts) en bestaande `/api/products/[id]/backlog?mode=X` route handler +- **MD3-tokens + shadcn ``** (tri-state via custom mapping) + +--- + +## Verificatie + +### End-to-end checks (handmatig + dev-server) + +1. **State A pad**: zonder actieve sprint → geen vinkjes, switcher toont "Geen actieve sprint", klik PBI → stories tonen, klik story → taken tonen, Entity-Dialog edits direct gecommit. + +2. **A → A′ → B happy path**: "Nieuwe sprint" → metadata-modal → "Verder" → banner verschijnt, vinkjes verschijnen op PBI's. Vink 2 PBI's met 5 child-stories totaal → banner toont "2 PBI's, 5 stories". Open één PBI en deselecteer 1 story (storyOverride.remove). Banner: "2 PBI's, 4 stories". Klik "Sprint aanmaken" → sprint actief, state B met afgeleide vinkjes, **geen page refresh** (controle via DevTools Network: alleen affected updates). + +3. **A′ persistente draft**: start A′, vink dingen aan, navigeer weg → confirm-dialog → bevestig. Kom terug op pagina → banner + vinkjes hersteld. + +4. **State B pending buffer**: vink een story aan → "Sprint opslaan (1)". Vink een story in sprint weg → "Sprint opslaan (2)". Vink eerste weer uit → "Sprint opslaan (1)" (cancel-out). Klik opslaan → store-patches, geen full reload. + +5. **Cross-sprint blokkade**: maak twee OPEN sprints, story X in sprint A. Switch naar sprint B → story X heeft disabled vinkje, tooltip "Zit in Sprint [A]". Verplaats story X via sprint A's sprint-page → cross-sprint-blocks updaten via SSE-patch. + +6. **Sprint metadata-edit**: edit-icoon → SprintEditDialog → wijzig goal → opslaan → direct gecommit, geen page-state-wijziging. + +7. **Sprint afronden**: SprintEditDialog toont link "Sprint afronden…" → navigeert naar `/products/[id]/sprint/[sprintId]` → bestaande completion-flow ongewijzigd. + +8. **Switcher-wissel bij dirty**: state B met pending toggles → wissel sprint → confirm-dialog. Cancel → blijft, buffer intact. Bevestig → buffer leeg, switch. + +9. **"Geen actieve sprint" persistentie**: kies "— Geen actieve sprint —" in switcher → schrijf null. Refresh pagina → blijft state A, valt **niet** terug op nieuwste OPEN sprint. + +### Geautomatiseerde tests (Vitest) + +- `lib/sprint-conflicts.test.ts`: vrij, in-zelfde-sprint, in-andere-OPEN, in-CLOSED (niet blokkerend voor commit-laag). +- `stores/product-workspace.test.ts`: pending buffer (arrays) toggle-cancel-out; tri-state-selector op verschillende load-staten (PBI niet geladen / geladen / met per-PBI overrides). +- `actions/sprints.test.ts`: + - `createSprintWithSelectionAction` resolve van per-PBI intent + per-PBI storyOverrides + - **Eligibility-filter**: stories met `status='DONE'` of `sprint_id != NULL` worden geweigerd en komen in `conflicts.notEligible` + - **Status-mutatie**: na add zijn betroffen stories `IN_SPRINT`; na remove zijn ze `OPEN` + - **Task.sprint_id in dezelfde transactie** — assert via mock prisma dat beide updates één tx delen + - Returns met `affectedStoryIds`, `affectedPbiIds`, `affectedTaskIds`, `conflicts` +- `actions/commit-sprint-membership.test.ts`: + - Race-conditie: story die ondertussen in andere sprint zit, eindigt in conflicts en wordt niet ge-update + - Removes met onverwachte sprint_id (al verwijderd) eindigen in `conflicts.alreadyRemoved` +- `lib/active-sprint.test.ts`: + - Key+null → return null (geen fallback) + - Key+string → die sprint (mits gevonden) + - Key ontbreekt → fallback-cascade actief +- `lib/user-settings.test.ts`: + - Zod-schema accepteert nullable values in `activeSprints` + - `pendingSprintDraft` met per-PBI overrides round-trippt +- `actions/active-sprint.test.ts`: + - `clearActiveSprintAction` schrijft `null`, **delete niet** de key — assert dat key blijft bestaan met null-value +- Endpoint-tests voor de twee nieuwe route handlers: + - `sprint-membership-summary` zonder `pbiIds`-param → 400 + - `cross-sprint-blocks` zonder `pbiIds`-param → 400 +- **Initial render doet géén story/task query** — assert via mock dat alleen `getProductBacklogPbis(_, _, 'matching')` is aangeroepen +- **A′ start doet géén brede story-ID query** — assert dat geen call met product-wide scope uitgaat; per-PBI overrides cleanup werkt zonder fetch + +### Code-validatie + +```bash +npm run verify && npm run build +``` + +--- + +## Reactie op review + +### Eerste review + +| Review-punt | Hoe geadresseerd | +|---|---| +| **P1 — Initial summary kan te zwaar worden** | Geen counts in initial render. Bestaande `getProductBacklogPbis(_, _, 'matching')` blijft. Counts apart via lazy summary-endpoint, alleen in state B, gescoped op `pbiIds`. | +| **P1 — `getStoryIdsByPbi(productId)` breekt lazy-loading** | Verwijderd. Hergebruik `ensurePbiLoaded` lazy per PBI. Pending draft-state is compact (per-PBI `pbiIntent` + per-PBI `storyOverrides`), niet alle story-IDs. | +| **P1 — "Page herhydrateert" introduceert dure refresh** | Server actions retourneren `affectedStoryIds`/`affectedPbiIds`/`affectedTaskIds`. Client patcht workspace-store gericht. Geen `router.refresh()`. | +| **P1 — `Sprint afsluiten` mag completion-semantiek niet overslaan** | `closeSprintAction` geschrapt. SprintEditDialog doet alleen metadata. Sprint-afronden gaat via bestaande `completeSprintAction` op sprint-page; SprintEditDialog krijgt link daarheen. | +| **P2 — "Geen actieve sprint"-contract** | Schema nullable: `activeSprints[productId]: string \| null`. Sleutel-aanwezigheid heeft betekenis (key ontbreekt = fallback; key=null = bewust geen). `clearActiveSprintInSettings` schrijft null. | +| **P2 — Cache Components vs huidige stijl** | Beslist: blijven bij route handlers + `cache: 'no-store'` + `revalidatePath`. Géén `'use cache'`/`cacheTag` in dit plan. | +| **P2 — Bestaande PBI-selectieflow** | Ombouwen naar A′-mode. Eén flow, geen feature-flag-parallellisme. `createSprintWithPbisAction` wordt `createSprintWithSelectionAction`. | +| **P2 — Store moet primitives bewaren** | `pbiSummary` slaat alleen `totalStoryCount`/`inActiveSprintStoryCount` op. Tri-state is een selector. `sprintMembershipPending` gebruikt arrays, geen Sets. | +| **P2 — Filter-first/background-remaining ontbreekt** | Expliciet opgenomen: initial = matching, background = remaining via bestaand route-handler-patroon. | +| **Tests die review zou toevoegen** | Allemaal opgenomen in test-sectie hierboven. | + +### Tweede review (deze ronde) + +| Punt | Hoe geadresseerd | +|---|---| +| **P1 — `story.status` bij membership-mutaties** | Add: `sprint_id=X` **én** `status='IN_SPRINT'`. Remove: `sprint_id=NULL` **én** `status='OPEN'`. Task.sprint_id mee in **dezelfde transactie**. Expliciet in pseudocode van `commitSprintMembershipAction` en `createSprintWithSelectionAction`. | +| **P1 — Eligibility voor toevoegen** | Server-resolve filtert vóór mutatie: alleen stories met `sprint_id IS NULL` **en** `status != 'DONE'`. Niet-eligible → `conflicts.notEligible[]` in return, toast op client. Stories uit CLOSED/ARCHIVED/FAILED sprints met DONE-status zijn dus geblokkeerd. | +| **P1 — A′ draft-shape moet per-PBI** | `storyOverrides` herstructureerd naar `{ [pbiId]: { add, remove } }`. Cleanup bij PBI-toggle is lokaal; restore is deterministisch zonder brede story-fetch. | +| **P1 — Endpoint scoping** | `sprint-membership-summary` en `cross-sprint-blocks` vereisen verplichte `pbiIds`-query-parameter. Server weigert product-brede aanroepen. | +| **P2 — `lib/user-settings.ts` expliciet** | Opgenomen in critical files. Zod-schema wijzigt: `activeSprints` nullable; `pendingSprintDraft` als optionele key. | +| **P2 — `clearActiveSprintInSettings`-semantiek** | Schrijft nu `null` i.p.v. key te `delete`. Onderscheid: key ontbreekt = fallback; key=null = bewust geen actieve sprint. | +| **P2 — Context-tekst stale** | Context-sectie herschreven: lazy-load-basis bestaat al; dit plan bouwt erop voort. | + +--- + +## Volgende stap (na goedkeuring) + +Per project-memory: PBI + stories + taken aanmaken via Scrum4Me-MCP, daarna implementatieplan koppelen, taken pas uitvoeren op verzoek. + +Werk-splitsing (laag-voor-laag, met dataflow eerst maar zonder onnodige eager loads): + +1. **Story 1 — Active-sprint null-contract** + `clearActiveSprintAction` + `resolveActiveSprint`-aanpassing + sprint-switcher uitbreiding ("— Geen actieve sprint —"-optie) +2. **Story 2 — User-settings draft-slot** + `setPendingSprintDraftAction` / `clearPendingSprintDraftAction` (compacte intent-shape) +3. **Story 3 — Sprint-membership-summary endpoint** + `crossSprintBlocks` endpoint + store-uitbreidingen (`pbiSummary`, `loadedStoryIdsByPbi`, `crossSprintBlocks`) +4. **Story 4 — State B pending-buffer-slice** (arrays) + selectors voor tri-state + `selectStoryEffectiveInSprint` / `selectStoryIsBlocked` +5. **Story 5 — A′ UI** (metadata-modal + sticky banner) + ombouw `selectionMode` in `PbiList` + persistente draft-restore +6. **Story 6 — State B vinkjes-UI** (PBI tri-state, story binair, disabled-bij-conflict) + "Sprint opslaan"-knop met teller +7. **Story 7 — `createSprintWithSelectionAction`** (uitbreiding van bestaande `createSprintWithPbisAction`) + server-side intent-resolve + cross-sprint guard + return-affected-IDs +8. **Story 8 — `commitSprintMembershipAction`** + cross-sprint guard + gerichte client-store patches + SSE-broadcast +9. **Story 9 — SprintEditDialog** (metadata) + `updateSprintAction` + link naar afrondings-flow +10. **Story 10 — Multi-OPEN sprints** (drop uniqueness-check in `createSprintAction`) +11. **Story 11 — Verificatie + tests** (Vitest + handmatige checklist) diff --git a/lib/active-sprint.ts b/lib/active-sprint.ts index 17cf527..4ca3234 100644 --- a/lib/active-sprint.ts +++ b/lib/active-sprint.ts @@ -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 { - 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 { 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 = { + ...(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 { - 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 diff --git a/lib/user-settings.ts b/lib/user-settings.ts index 597a2c4..ecc7c71 100644 --- a/lib/user-settings.ts +++ b/lib/user-settings.ts @@ -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({