Scrum4Me/docs/plans/PBI-79-backlog-sprint-workflow.md
Madhura68 e4252cad3e docs(PBI-79): plan-update met implementatie-stand + scope-aanpassing
Documenteert wat er sinds de eerste implementatie-pass is gebeurd:
- Tabel van 14 commits met hun rol.
- Twee bugs die tijdens testen boven kwamen (PBI-rij-klik, cascade-restore).
- Nieuwe feature sprint-switch auto-select (server resolveert single-PBI/
  single-story; user-settings persist).

En kondigt scope-aanpassing aan voor de volgende implementatie-ronde:
- pendingSprintDraft wordt session-only (geen server-persist meer).
- useDirtyCloseGuard wist draft op leave-with-confirm.
- Sprint-switcher krijgt concept-entry zolang er een draft loopt.

De rest van het plan beneden blijft van kracht behalve waar deze sectie
het overruled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:25:07 +02:00

44 KiB
Raw Blame History

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. De vier P1-bevindingen zijn allemaal geadresseerd, evenals de vijf P2-punten. Zie de sectie "Reactie op review" onderaan voor de mapping.


Implementatie-stand & scope-aanpassingen (post-testing)

Deze sectie documenteert wat er sinds de eerste implementatie-pass is bijgewerkt op basis van gebruikerstests + nieuwe inzichten. De rest van het plan beneden geldt behalve waar dit kopje dat overrulet.

Gerealiseerde commits (in volgorde)

# Commit Story Inhoud
1 2af6f24 ST-1333 Active-sprint null-contract + clearActiveSprintAction
2 56c55e1 ST-1334 pendingSprintDraft slot (compacte intent-shape)
3 b4a515e ST-1343 lib/sprint-conflicts.ts eligibility helpers
4 e89fb71 ST-1335 Gescoped endpoints (sprint-membership-summary, cross-sprint-blocks)
5 89c2356 ST-1336 sprintMembership-slice + selectors in product-workspace-store
6 947d970 ST-1337 State A UI (metadata-dialog + sticky banner + PbiList ombouw)
7 d21011c ST-1339 createSprintWithSelectionAction + banner wire-up
8 4c6e999 ST-1340 commitSprintMembershipAction + gerichte client-store patches
9 117616f ST-1338 State B vinkjes-UI + "Sprint opslaan"-knop
10 b91d92a ST-1341+1342 SprintEditDialog + multi-OPEN sprints
11 0c36f4e ST-1344 updateSprintAction regression tests
12 8d6fbdf bugfix PBI-rij weer klikbaar voor selectie; vinkje als aparte trigger
13 35c6404 bugfix Cascade-restore alleen wanneer hint-story bij nieuwe PBI hoort
14 d7d1112 feat Sprint-switch auto-select PBI/story + user-settings persist (3 keys)

Bugs gevonden tijdens testen (afgehandeld)

  1. Hele PBI-rij was de toggle in selectionMode. Gevolg: rij-klik bulk-toggled stories en update de teller, maar PBI werd niet als focus geselecteerd → story-kolom bleef leeg. Fix (8d6fbdf): in SortablePbiRow selectionMode-branch wordt onClick weer onSelect; het tri-state icoon zit in een eigen <button> met stopPropagation.
  2. Cascade-restore overschrijft PBI-switch. Bij wisselen naar een andere PBI bleef de oude story (en dus zijn taken) zichtbaar omdat setActivePbi's async hint-restore de vorige story-id terugzette zonder PBI-validatie. Fix (35c6404): hint wordt alleen toegepast als storiesById[hint].pbi_id === pbiId.
  3. Tooltip-API mismatch. TooltipTrigger van base-ui accepteert geen asChild; geprobeerd via render-prop maar uiteindelijk de hele knop in selectionMode in de Tooltip gewikkeld.

Nieuwe feature (na implementatie toegevoegd) — sprint-switch auto-select

Bij wisselen van sprint via de switcher wordt server-side de inhoud van de sprint geresolved en als deze precies één PBI heeft (en die PBI exact één story binnen de sprint), worden beide automatisch geselecteerd. Alle drie selectie-velden worden atomair in user-settings weggeschreven zodat cross-device-restore klopt.

  • Schema: layout.activePbis + layout.activeStories per product (beide nullable).
  • Helper: setActiveSelectionInSettings(userId, productId, { sprintId, pbiId?, storyId? }).
  • Server-action: switchActiveSprintAction(productId, sprintId) doet de auto-select-resolutie en returnt het tripel.
  • Sprint-switcher: roept de nieuwe action aan en synchroniseert de client-store gelijk (geen flash).
  • ActiveSelectionHydrator (nieuw): client-side effect dat user-settings-activePbi/activeStory naar de workspace-store spiegelt; wint van de bestaande localStorage hint-restore.

Scope-aanpassing — pendingSprintDraft wordt session-only

Was: de draft (sprint-doel + per-PBI intent + per-PBI overrides) staat persistent in user-settings.workflow.pendingSprintDraft zodat de gebruiker na navigatie kan hervatten.

Wordt: de draft leeft alleen in de Zustand-store van de sessie. Bij wegnavigeren krijgt de gebruiker een useDirtyCloseGuard-confirm; bij doorgaan wordt de draft weggegooid (niet hervat-baar). Reden: de user geeft expliciet aan dat ongeslagen sprints geen rest-state mogen achterlaten in de DB.

Concrete wijzigingen:

  • lib/user-settings.ts: workflow.pendingSprintDraft kan blijven bestaan voor type-compatibiliteit maar wordt niet meer geschreven door de UI.
  • Actions setPendingSprintDraftAction + clearPendingSprintDraftAction worden gedeprecieerd (of behouden voor migratie van eventueel oude entries) maar niet meer aangeroepen door de UI.
  • Store useUserSettingsStore.setPendingSprintDraft / upsertPbiIntent / upsertStoryOverride blijven bestaan maar de server-roundtrip eruit; lokale state-only.
  • useDirtyCloseGuard op het banner-niveau triggert een confirm bij browser-back / route-wissel; bevestigen → clearPendingSprintDraftAction (om eventuele oude DB-entries op te ruimen) + lokale state-reset.

Nieuwe feature — draft-sprint zichtbaar in sprint-switcher

Tijdens state A (er is een draft) toont de sprint-switcher de draft-naam (= draft.goal, ingekort) als extra entry bovenaan de dropdown met markering "Concept" of italic-styling. Hij is niet selecteerbaar als "actieve" sprint (want geen sprintId); klikken erop opent de banner-actie of doet niets bijzonders. Doel: visueel feedback geven dat er een onafgemaakte sprint loopt zonder die in de DB op te slaan.

Concreet:

  • Sprint-switcher krijgt prop pendingDraftGoal?: string | null (server-side leesbaar via user-settings store na hydration, of via useUserSettingsStore in de switcher-component).
  • Render bovenaan de dropdown (boven "— Geen actieve sprint —") wanneer aanwezig: "⚙ Concept — [goal-prefix]".

Wat blijft staan uit de oorspronkelijke ontwerpkeuzes

  • Schema layout.activeSprints blijft nullable (key+null = bewust geen sprint).
  • Drie-states-model (A / A / B) blijft.
  • Tri-state PBI-vinkje, story-binair-vinkje, cross-sprint disabled blijven.
  • "Sprint opslaan"-knop met teller (state B) blijft.
  • Eligibility-filter + status-mutaties in dezelfde transactie blijven.
  • Endpoints gescoped op pbiIds blijven.
  • Multi-OPEN sprints toegestaan blijft.

Wat nog te doen (na deze plan-update)

  1. Implementeer scope-aanpassing: maak pendingSprintDraft session-only. UI ondervangt server-roundtrip; bestaande server-actions blijven voor cleanup-doeleinden.
  2. Sprint-switcher concept-entry: render draft-goal in dropdown.
  3. Verifieer: A → A → leave-with-confirm pad → geen DB-entry achtergelaten.

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, 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 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 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 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 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):

  • 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):

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 ApendingSprintDraft 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 toevoegen — arrays, niet Sets:

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:

// 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.
  • 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:

// 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 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>:

// 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>:

{
  [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):

// 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:

  • 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:

  • 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:

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 — 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 — 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 — vinkje per story; lees uit selectors (selectStoryEffectiveInSprint, selectStoryIsBlocked); klik muteert pendingSprintDraft.storyOverrides of sprintMembershipPending.
  • components/backlog/task-panel.tsx — geen wijzigingen aan task-flow.
  • components/shared/sprint-switcher.tsx — "— Geen actieve sprint —"-optie; dirty-check bij wissel.
  • 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.tspendingSprintDraft[productId]: { goal, startAt?, endAt?, pbiIntent, storyOverrides: { [pbiId]: { add, remove } } } | null; activeSprints[productId]: string | null (zie ook user-settings.ts hieronder).
  • 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:
    • 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 closeSprintActioncompleteSprintAction blijft de afrond-flow.
  • actions/active-sprint.ts — nieuwe clearActiveSprintAction(productId) (schrijft null). setActiveSprintAction ongewijzigd voor non-null.
  • lib/active-sprint.tsresolveActiveSprint 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.tssetPendingSprintDraftAction, clearPendingSprintDraftAction

Niet aangeraakt

  • 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, 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 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

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)