Scrum4Me/docs/plans/PBI-79-backlog-sprint-workflow.md
Madhura68 2af6f24598 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>
2026-05-11 13:35:32 +02:00

551 lines
38 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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=<ids>`**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=<ids>`**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<string>`
- `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=<comma-separated>`:
```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=<comma-separated>`:
```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 `<Checkbox>`** (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)