- Drie open punten uit plan-revisie afgevinkt (commit 2a4ee6a).
- Sectie 'Bewust niet geïmplementeerd': server-persist van manuele
PBI/story-klikken — op vraag van user nu out-of-scope voor deze PR.
- Tabel localStorage-gebruik in de codebase voor toekomstige referentie.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
46 KiB
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)
- 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): inSortablePbiRowselectionMode-branch wordt onClick weeronSelect; het tri-state icoon zit in een eigen<button>metstopPropagation. - 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 alsstoriesById[hint].pbi_id === pbiId. - Tooltip-API mismatch.
TooltipTriggervan base-ui accepteert geenasChild; 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.activeStoriesper 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.pendingSprintDraftkan blijven bestaan voor type-compatibiliteit maar wordt niet meer geschreven door de UI.- Actions
setPendingSprintDraftAction+clearPendingSprintDraftActionworden gedeprecieerd (of behouden voor migratie van eventueel oude entries) maar niet meer aangeroepen door de UI. - Store
useUserSettingsStore.setPendingSprintDraft/upsertPbiIntent/upsertStoryOverrideblijven bestaan maar de server-roundtrip eruit; lokale state-only. useDirtyCloseGuardop 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 viauseUserSettingsStorein 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.activeSprintsblijft 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
pbiIdsblijven. - Multi-OPEN sprints toegestaan blijft.
Wat nog te doen (na deze plan-update)
Alle drie punten afgerond in commit
2a4ee6a.
Implementeer scope-aanpassing—setPendingSprintDraft/clearPendingSprintDraftzijn nu local-only;hydrate()strip eventuele legacy DB-entries.Sprint-switcher concept-entry—⚙ Concept — [goal]verschijnt bovenaan de dropdown zodra er een draft loopt.Verifieer—npm run verifygroen (826 tests).SprintDraftLeaveGuardregistreertbeforeunload-listener voor browser-refresh/close. In-app route-changes blijven via banner-Annuleren lopen.
Bewust niet geïmplementeerd
- Server-side persist van manuele PBI/story-klikken. Vraag: "wordt de geselecteerde pbi ook opgeslagen". Antwoord: nee, momenteel alleen via sprint-switch auto-select. Manuele klikken gaan naar localStorage. Cross-device parity voor manuele klikken vereist extra server-roundtrips per klik; de helpers
setActivePbiInSettings/setActiveStoryInSettingszijn voorbereid maar niet gewired. Op verzoek opnieuw oppakken in een vervolg-PBI.
localStorage-gebruik (overzicht)
| Locatie | Doel |
|---|---|
| stores/product-workspace/restore.ts | Per-browser hints lastActivePbiId / lastActiveStoryId / lastActiveTaskId per product. |
| stores/sprint-workspace/restore.ts | Idem voor de sprint-pagina. |
| lib/user-settings-migration.ts | One-shot migratie van legacy prefs (PBI-76) naar user-settings. |
| components/ideas/idea-md-editor.tsx | Auto-save van idee-markdown-draft (niet PBI-79-gerelateerd). |
ActiveSelectionHydrator (PBI-79) wint van de localStorage-hints voor PBI/story-selectie zodra user-settings expliciet iets bevat.
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:
- Geen uniforme sprint-samenstelling-UI. Sprint-aanmaak loopt nu via twee flows:
createSprintAction(één pbi_id) encreateSprintWithPbisAction(array, viaNewSprintDialog). Geen UI-feedback over welke PBI's al in welke mate "in de huidige sprint zitten". - Stories aan/uit sprint per stuk kan alleen via de Sprint-pagina, niet vanuit de backlog.
- 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-meegeupdate 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-meegeupdated 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 → schrijftnullin 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:
selectionModeboolean enselectedIds: Set<string>toggleCheck(id)voor PBI-togglesexitSelection()voor cleanupNewSprintDialogaanroep metpbiIds-array- Server-action
createSprintWithPbisActiondie 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]. Bijintent='all'en geenremove→ ✓. Bijintent='none'en geenadd→ ☐. Anders ◐. - Story-vinkje:
(pbiIntent[pbiId] == 'all' || storyOverrides[pbiId]?.add?.includes(storyId)) && !storyOverrides[pbiId]?.remove?.includes(storyId).
Toggle-semantiek:
- Klik PBI-vinkje ☐→✓:
pbiIntent[pbi] = 'all', wisstoryOverrides[pbi]. - Klik PBI-vinkje ✓→☐:
pbiIntent[pbi] = 'none', wisstoryOverrides[pbi]. - Klik story-vinkje (in geopende PBI): voeg toe aan
storyOverrides[pbi].addof.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):
- Server resolveert intent → concrete
storyIdsToAddToSprint: string[]:- Voor elke PBI met
intent = 'all': alle child-stories minusstoryOverrides[pbi].remove - Plus alle stories in
storyOverrides[pbi].add(over alle PBI's)
- Voor elke PBI met
- Eligibility-filter (server, autoritatief): behoud alleen stories waarvoor
sprint_id IS NULLenstatus != 'DONE'. Stories die niet voldoen (in andere sprint, of al DONE) komen inconflicts.notEligible[]met reden. - Cross-sprint-check (gedekt door eligibility, maar separately rapporteren): geblokkeerde stories →
conflicts.crossSprint[]met{ storyId, sprintId, sprintName }. - 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)
clearPendingSprintDraftAction+setActiveSprintInSettings(productId, newSprintId)- Realtime-event broadcasting
- Return:
{ sprintId, affectedStoryIds, affectedPbiIds, conflicts: { notEligible, crossSprint } } - Client patcht workspace-store gericht: voeg sprintId toe aan stories/tasks, zet
story.status = 'IN_SPRINT', invalidatepbiSummary-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 toevoegen — arrays, niet Sets:
sprintMembershipPending: {
adds: string[] // story-ids die in actieve sprint moeten
removes: string[] // story-ids die uit actieve sprint moeten
}
isDirtyselector: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[]):
- Eligibility-filter voor
adds(server, autoritatief): behoud alleen stories metsprint_id IS NULLenstatus != 'DONE'. Niet-eligible stories (cross-sprint-conflict, of DONE) komen inconflicts.notEligible[]. removes-filter: behoud alleen stories die feitelijksprint_id = activeSprintIdhebben (race-safety; story kan ondertussen al ergens anders heen verplaatst zijn).- 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)
- Add:
- Realtime-events broadcasten
- Return:
{ affectedStoryIds, affectedPbiIds, affectedTaskIds, conflicts: { notEligible, alreadyRemoved } } - Client patcht store gericht:
- Update
story.sprint_id+story.statusvoor affected stories instoriesById/storiesByPbi - Update
task.sprint_idvoor affected tasks - Debounced refetch van
sprint-membership-summaryvoor affected PBI's (gescoped oppbiIds=affectedPbiIds) - Wis pending buffer
- Toast voor conflicts
- Geen
router.refresh().
- Update
Andere mutaties in state B
- Story aanmaken (StoryDialog):
sprint_id = activeSprintIddirect 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 metcompleteSprintAction) - 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'/cacheTagin 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 bestaandeensurePbiLoaded/ensureStoryLoadedlazy-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 +
revalidatePathna 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 explicitnullte 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 }
}
pbiIdsis verplicht — endpoint weigert product-brede aanroepen. Client geeft alleen visible/loaded PBI-IDs door.- Eén
groupByopStorywaarpbi_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 }
}
pbiIdsverplicht — 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
crossSprintBlocksin 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)checktkey 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 tedelete, schrijft het nunull. Dat is het verschil tussen "geen voorkeur" en "expliciet geen actieve sprint".
- Nieuw:
clearActiveSprintAction(productId)— gebruikt de aangepasteclearActiveSprintInSettings(schrijft null). - Bestaande
setActiveSprintActionongewijzigd.
Sync na commit — gerichte client-store patches
Server actions retourneren expliciet affected IDs:
return { affectedStoryIds, affectedPbiIds, affectedTaskIds, conflicts }
Client (na await):
- Patch
storiesById+tasksByIdmet nieuwesprint_id-waarden. - Voor elke
affectedPbiId: fire-and-forget refetch vansprint-membership-summary(debounced 100ms) om counts te actualiseren. - Wis pending buffer.
- 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 blijftgetProductBacklogPbis(id, query, 'matching')— geen counts hier. - components/backlog/pbi-list.tsx — bestaande
selectionModeombouwen tot A′-modus: vinkjes worden tri-state, lezen uitpendingSprintDraft.pbiIntentof (in state B) uitselectPbiTriState-selector. Verwijder de directeNewSprintDialog-trigger. - components/backlog/story-panel.tsx — vinkje per story; lees uit selectors (
selectStoryEffectiveInSprint,selectStoryIsBlocked); klik muteertpendingSprintDraft.storyOverridesofsprintMembershipPending. - 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.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 — Zod-schema strictness:
activeSprintsvalue nullable;pendingSprintDraftals 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 naarcreateSprintWithSelectionAction(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—completeSprintActionblijft de afrond-flow.
- actions/active-sprint.ts — nieuwe
clearActiveSprintAction(productId)(schrijft null).setActiveSprintActionongewijzigd voor non-null. - lib/active-sprint.ts —
resolveActiveSprintcheckt key-aanwezigheid (niet truthy): key+null → return null zonder fallback; key+string → sprint; key ontbreekt → fallback-cascade.clearActiveSprintInSettingsschrijft nunulli.p.v. key te verwijderen (essentieel voor het null-contract).
Nieuw
app/api/products/[id]/sprint-membership-summary/route.ts— lazy counts endpointapp/api/products/[id]/cross-sprint-blocks/route.ts— lazy cross-sprint hint endpointcomponents/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 Blib/sprint-conflicts.ts— cross-sprint check helpersactions/sprint-draft.ts—setPendingSprintDraftAction,clearPendingSprintDraftAction
Niet aangeraakt
- prisma/schema.prisma — geen schemawijziging
- Bestaande
completeSprintActionen 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=Xroute handler - MD3-tokens + shadcn
<Checkbox>(tri-state via custom mapping)
Verificatie
End-to-end checks (handmatig + dev-server)
-
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.
-
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).
-
A′ persistente draft: start A′, vink dingen aan, navigeer weg → confirm-dialog → bevestig. Kom terug op pagina → banner + vinkjes hersteld.
-
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.
-
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.
-
Sprint metadata-edit: edit-icoon → SprintEditDialog → wijzig goal → opslaan → direct gecommit, geen page-state-wijziging.
-
Sprint afronden: SprintEditDialog toont link "Sprint afronden…" → navigeert naar
/products/[id]/sprint/[sprintId]→ bestaande completion-flow ongewijzigd. -
Switcher-wissel bij dirty: state B met pending toggles → wissel sprint → confirm-dialog. Cancel → blijft, buffer intact. Bevestig → buffer leeg, switch.
-
"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:createSprintWithSelectionActionresolve van per-PBI intent + per-PBI storyOverrides- Eligibility-filter: stories met
status='DONE'ofsprint_id != NULLworden geweigerd en komen inconflicts.notEligible - Status-mutatie: na add zijn betroffen stories
IN_SPRINT; na remove zijn zeOPEN - 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 pendingSprintDraftmet per-PBI overrides round-trippt
- Zod-schema accepteert nullable values in
actions/active-sprint.test.ts:clearActiveSprintActionschrijftnull, delete niet de key — assert dat key blijft bestaan met null-value
- Endpoint-tests voor de twee nieuwe route handlers:
sprint-membership-summaryzonderpbiIds-param → 400cross-sprint-blockszonderpbiIds-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):
- Story 1 — Active-sprint null-contract +
clearActiveSprintAction+resolveActiveSprint-aanpassing + sprint-switcher uitbreiding ("— Geen actieve sprint —"-optie) - Story 2 — User-settings draft-slot +
setPendingSprintDraftAction/clearPendingSprintDraftAction(compacte intent-shape) - Story 3 — Sprint-membership-summary endpoint +
crossSprintBlocksendpoint + store-uitbreidingen (pbiSummary,loadedStoryIdsByPbi,crossSprintBlocks) - Story 4 — State B pending-buffer-slice (arrays) + selectors voor tri-state +
selectStoryEffectiveInSprint/selectStoryIsBlocked - Story 5 — A′ UI (metadata-modal + sticky banner) + ombouw
selectionModeinPbiList+ persistente draft-restore - Story 6 — State B vinkjes-UI (PBI tri-state, story binair, disabled-bij-conflict) + "Sprint opslaan"-knop met teller
- Story 7 —
createSprintWithSelectionAction(uitbreiding van bestaandecreateSprintWithPbisAction) + server-side intent-resolve + cross-sprint guard + return-affected-IDs - Story 8 —
commitSprintMembershipAction+ cross-sprint guard + gerichte client-store patches + SSE-broadcast - Story 9 — SprintEditDialog (metadata) +
updateSprintAction+ link naar afrondings-flow - Story 10 — Multi-OPEN sprints (drop uniqueness-check in
createSprintAction) - Story 11 — Verificatie + tests (Vitest + handmatige checklist)