* 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>
* feat(PBI-79/ST-1334): user-settings pendingSprintDraft-slot
- lib/user-settings.ts: nieuw workflow.pendingSprintDraft veld met
compacte intent-shape (pbiIntent + per-PBI storyOverrides).
- actions/sprint-draft.ts: setPendingSprintDraftAction +
clearPendingSprintDraftAction met product-membership-check + Zod-validatie.
- stores/user-settings/store.ts: setPendingSprintDraft / clearPendingSprintDraft
optimistic acties + fine-grained mutators upsertPbiIntent / upsertStoryOverride.
Sprint-draft actions worden dynamisch geïmporteerd zodat jsdom-tests
zonder DATABASE_URL niet falen.
- Tests: nieuwe sprint-draft.test.ts (action-laag), uitbreiding
user-settings store-tests (5 nieuwe cases) en schema-tests (4 cases).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1343): sprint-conflicts helper-library
- lib/sprint-conflicts.ts: drie pure/server-side helpers voor eligibility
+ cross-sprint detectie.
- isEligibleForSprint(story): sprint_id IS NULL en status != DONE
- partitionByEligibility(prisma, storyIds, excludeSprintId): split in
eligible / notEligible / crossSprint met reden per story
- getBlockingSprintMap(prisma, productId, storyIds, excludeSprintId):
map storyId → { sprintId, sprintName } voor stories in andere OPEN sprint
- Tests: __tests__/lib/sprint-conflicts.test.ts (16 cases) — alle eligibility
paden + cross-sprint scoping + CLOSED-sprint filtering.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1335): sprint-membership-summary + cross-sprint-blocks endpoints
Twee nieuwe GET-route handlers, beide verplicht gescoped op pbiIds (geen
product-brede aanroepen).
- app/api/products/[id]/sprint-membership-summary/route.ts
Response: { [pbiId]: { total, inSprint } } via twee prisma.groupBy calls
(totaal + binnen actieve sprint). Voor state-B tri-state.
- app/api/products/[id]/cross-sprint-blocks/route.ts
Response: { [storyId]: { sprintId, sprintName } } voor stories in andere
OPEN sprints. UX-hint voor disabled-vinkjes; commit-acties blijven
autoritatief.
Tests: 13 cases dekken happy path, 400 zonder pbiIds, 400 zonder sprintId,
404 zonder product-access, auth-fail, en NOT-clause voor excludeSprintId.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1336): product-workspace sprint-membership slice + selectors
Datalaag voor de vinkje-UI van state A′ en state B.
types.ts:
- PbiSummaryEntry, CrossSprintBlock, SprintMembershipSlice toegevoegd.
store.ts:
- Nieuwe slice `sprintMembership` met pbiSummary, crossSprintBlocks,
pending: { adds[], removes[] }, loadedSummaryForSprintId.
- Acties: setPbiSummary, setCrossSprintBlocks, toggleStorySprintMembership
(cancel-out logic), resetSprintMembershipPending, fetchSprintMembershipSummary,
fetchCrossSprintBlocks.
- hydrateSnapshot reset óók de membership-slice.
selectors.ts:
- selectPbiTriState (aggregate-only zolang stories niet geladen; rekent
pending mee bij loaded PBI's).
- selectStoryEffectiveInSprint (DB ⊕ pending).
- selectStoryIsBlocked (cross-sprint hint).
- selectIsDirty, selectPendingCount.
Tests: 25 cases in nieuwe sprint-membership.test.ts dekken alle selector-
paden, toggle-cancel-out, fetch-helpers, en pbiId-scoping.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1337): state A′ UI — metadata dialog + sticky banner + PbiList ombouw
UI-laag voor de sprint-definitie-flow (state A′).
Nieuw:
- NewSprintMetadataDialog (stap 1): sprint_goal + optionele dates;
'Verder' schrijft via useUserSettingsStore.setPendingSprintDraft.
- SprintDefinitionBanner (sticky): toont doel + X PBI's / Y stories teller;
'Annuleren' → AlertDialog confirm → clearPendingSprintDraft;
'Sprint aanmaken' nog niet aangesloten (wacht op ST-1339).
- NewSprintTrigger: button in page header die de metadata-dialog opent;
verbergt zichzelf zolang er al een draft loopt.
- SprintDraftBanner: client-wrapper, rendert banner alleen als draft bestaat.
Wijzigingen:
- lib/user-settings.ts: pendingSprintDraft startAt/endAt → z.string().date().
- PbiList: oude selectionMode + selectedIds + NewSprintDialog vervangen door
hasDraft-afgeleide A′-mode met tri-state vinkjes; togglen muteert
upsertPbiIntent('all'|'none') en wist storyOverrides per PBI.
- StoryPanel: in A′-mode toont elke story een cherrypick-checkbox die
upsertStoryOverride('add'/'remove'/'clear') aanroept; cross-sprint-blocked
stories krijgen disabled-icoon met sprint-naam tooltip.
- app/(app)/products/[id]/page.tsx: StartSprintButton vervangen door
NewSprintTrigger; SprintDraftBanner gepositioneerd boven split-pane.
Tests: bestaande tests blijven groen (806 cases) — UI-specifieke component
tests volgen later. ST-1339 sluit createSprintWithSelectionAction aan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1339): createSprintWithSelectionAction + banner wire-up
actions/sprints.ts:
- Nieuwe createSprintWithSelectionAction(productId, metadata, pbiIntent,
storyOverrides).
- Server-side intent-resolve:
1. Voor elke PBI met intent='all': fetch child-story-IDs minus
storyOverrides[pbi].remove.
2. Plus storyOverrides[*].add (cross-PBI cherrypick toegestaan).
- Eligibility-filter via partitionByEligibility (sprint_id IS NULL + status
!= DONE; stories in andere OPEN sprint → conflicts.crossSprint).
- Transactie wrapt sprint.create + story.updateMany (status='IN_SPRINT') +
task.updateMany (sprint_id cascade) — alles atomair.
- setActiveSprintInSettings na success.
- Return: { success, sprintId, affectedStoryIds, affectedPbiIds,
affectedTaskIds, conflicts: { notEligible, crossSprint } } of error.
components/backlog/sprint-definition-banner.tsx:
- 'Sprint aanmaken'-knop sluit aan op createSprintWithSelectionAction;
toast bij conflicts, success-toast anders, router.refresh() voor SSR
cycle. Pending draft wordt door de action zelf nog niet expliciet gewist
— dat gebeurt via revalidatePath en kan in ST-1340 finetuned worden.
Tests: __tests__/actions/create-sprint-with-selection.test.ts (6 cases)
dekken intent-resolve, override-respect, cross-sprint conflict, transactie-
binding van story.status + task.sprint_id, return-shape, en error-pad.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1340): commitSprintMembershipAction + gerichte client-store patches
actions/sprints.ts:
- Nieuwe commitSprintMembershipAction(activeSprintId, adds[], removes[]).
- Eligibility-filter voor adds via partitionByEligibility (sprint_id IS NULL
en niet DONE; cross-sprint conflicts → notEligible).
- Race-safety voor removes: alleen stories met huidige sprint_id ==
activeSprintId; rest → conflicts.alreadyRemoved.
- Transactie wrapt twee updateMany-paren (story status mee, task.sprint_id
cascade). Update-paren overgeslagen wanneer leeg.
- Return: { success, affectedStoryIds, affectedPbiIds, affectedTaskIds,
conflicts: { notEligible, alreadyRemoved } }.
stores/product-workspace/store.ts:
- applyMembershipCommitResult({ activeSprintId, addedStoryIds,
removedStoryIds }) patcht entities.storiesById met juiste sprint_id +
status; ledigt sprintMembership.pending. Geen task-veld omdat
BacklogTask geen sprint_id-kolom heeft in de store.
Tests: __tests__/actions/commit-sprint-membership.test.ts (8 cases) — happy
path, DONE-conflict, cross-sprint, race-safety voor removes, transactie-
inhoud (status='IN_SPRINT'/'OPEN'), task-cascade, return-shape, auth-fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1338): state B vinkjes-UI + 'Sprint opslaan'-knop met teller
State B (actieve sprint geselecteerd, geen draft) hangt nu aan dezelfde
vinkje-UI als state A′, maar muteert de transient pending-buffer in plaats
van de draft.
- PbiList: nieuwe prop activeSprintId. selectionMode = hasDraft ||
stateBMode. togglePbiInDraft routeert naar upsertPbiIntent (A′) of bulk-
toggleStorySprintMembership over eligible child-stories (B, skip blocked).
- StoryPanel: idem prop activeSprintId. StoryBlockWithCherrypick muteert
draft via upsertStoryOverride in A′ of pending buffer via
toggleStorySprintMembership in B (cross-sprint blocked = disabled).
- SaveSprintButton (nieuw): client component in page header, alleen
zichtbaar als er een actieve sprint is. Disabled bij clean buffer,
enabled met teller bij dirty. Klikken calls commitSprintMembershipAction
→ applyMembershipCommitResult gericht in store + toast bij conflicts.
- page.tsx: activeSprintItem.id wordt doorgegeven aan PbiList, StoryPanel
en SaveSprintButton.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79/ST-1341+ST-1342): SprintEditDialog metadata-edit + multi-OPEN sprints
ST-1341 (T-946):
- actions/sprints.ts: nieuwe updateSprintAction(sprintId, fields) — JSON
input, accepteert optionele goal/startAt/endAt; auth + product-access
check, prisma.sprint.update, revalidatePath. Type-safe return.
- components/backlog/sprint-edit-dialog.tsx: Entity-Dialog-pattern voor
metadata-edit van een sprint. Velden: sprint_goal, start_date, end_date.
Link 'Sprint afronden… →' naar bestaande /products/[id]/sprint/[sprintId]
zodat de completion-flow (per-story DONE/OPEN beslissing + PBI-promotie)
niet wordt geduplicereerd. useDirtyCloseGuard.
ST-1342 (T-947):
- actions/sprints.ts: OPEN-uniqueness check in createSprintAction
verwijderd. Een product mag nu meerdere OPEN sprints tegelijk hebben;
cross-sprint-conflicts per story worden afgevangen door
partitionByEligibility in de membership-commit-flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(PBI-79/ST-1344): updateSprintAction regression coverage
Audits van de geplande non-regressie-tests laten zien dat alle invarianten
uit het ST-1344 plan reeds gedekt zijn door eerder toegevoegde tests:
- clearActiveSprintAction null-not-delete → __tests__/lib/active-sprint.test.ts
+ __tests__/actions/active-sprint-action.test.ts
- Endpoints rejecten zonder pbiIds (400) → __tests__/api/sprint-membership-summary.test.ts
+ __tests__/api/cross-sprint-blocks.test.ts
- Status-mutaties story.status=IN_SPRINT/OPEN met task.sprint_id cascade
in dezelfde transactie → __tests__/actions/create-sprint-with-selection.test.ts
+ __tests__/actions/commit-sprint-membership.test.ts
- Cross-sprint conflicts + DONE-eligibility → __tests__/lib/sprint-conflicts.test.ts
Nieuw: __tests__/actions/update-sprint.test.ts (6 cases) dekt
updateSprintAction die nog geen tests had — goal alleen, dates alleen,
null-clear, 403 zonder access, lege goal weigering, leeg fields-object
weigering.
Handmatige E2E checklist (T-949) blijft staan voor menselijke browser-
validatie tijdens PR-review.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(PBI-79): PBI-rij selecteert weer in A′/B-modus; vinkje is aparte trigger
Voor PBI-79 maakte het hele PBI-kaartje in selectionMode (state A′ én B)
de toggle. Daardoor:
- klik op rij = bulk-toggle stories (teller liep op);
- geen setActivePbi, dus StoryPanel kreeg geen content.
Fix: in selectionMode wordt onClick = onSelect (PBI activeren → stories
laden) en de tri-state-iconen verhuizen naar een eigen <button> in de
actions-slot met stopPropagation. Toggle gedrag (bulk add/remove in B,
upsertPbiIntent in A′) blijft ongewijzigd via die knop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(PBI-79): cascade-restore alleen als hint-story bij nieuwe PBI hoort
Bug: setActivePbi reset activeStoryId/activeTaskId, maar het cascade-
restore-pad zette daarna een hint-story actief zonder te valideren of die
story bij de nieuw-geselecteerde PBI hoort. Bij PBI-switch bleef daardoor
de task-kolom de taken van de vorige story tonen.
Fix: alleen setActiveStory(hint) als entities.storiesById[hint].pbi_id ===
pbiId. Bij mismatch blijft activeStoryId null en is de task-kolom leeg
totdat de gebruiker een story uit de nieuwe PBI kiest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(PBI-79): sprint-switch auto-select PBI/story + user-settings persist
Bij sprint-switch wordt de sprint-content server-side opgevraagd. Wanneer
de sprint precies één PBI (en die PBI exact één story binnen de sprint)
heeft, worden PBI en story automatisch geselecteerd. Alle drie keuzes
(sprint, pbi, story) worden atomair in user-settings opgeslagen zodat ze
cross-device blijven hangen.
- lib/user-settings.ts: layout krijgt nullable activePbis +
activeStories per product.
- lib/active-sprint.ts: setActiveSelectionInSettings schrijft de drie
keys atomair + notify pg_notify.
- actions/active-sprint.ts: switchActiveSprintAction(productId, sprintId)
doet de server-side auto-select-resolutie (single PBI → single story)
en returnt { sprintId, pbiId, storyId }.
- components/shared/sprint-switcher.tsx: handleSwitchSprint roept de
nieuwe action aan en synchroniseert de workspace-store gelijk zodat
de UI geen flash krijgt voor de SSR-refresh.
- components/backlog/active-selection-hydrator.tsx (nieuw): client-side
effect dat user-settings.activePbis/activeStories naar workspace-store
spiegelt; wint van de localStorage hint-restore.
- app/(app)/products/[id]/page.tsx: ActiveSelectionHydrator gemount
binnen BacklogHydrationWrapper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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>
* feat(PBI-79): pendingSprintDraft session-only + concept-entry + leave-guard
Scope-aanpassing uit plan-revisie: drafts persisten niet meer server-side.
Wijzigingen:
- stores/user-settings/store.ts:
- hydrate() strip nu workflow.pendingSprintDraft uit serverstate
(legacy DB-entries blijven harmless aanwezig maar worden niet
gehydreerd → effectief unreachable voor de UI).
- setPendingSprintDraft / clearPendingSprintDraft worden lokale-only;
geen import van sprint-draft-actions, geen server-roundtrip.
- upsertPbiIntent / upsertStoryOverride blijven via setPendingSprintDraft
routeren → ook session-only.
- components/shared/sprint-switcher.tsx: leest draft-goal uit user-settings
store en toont '⚙ Concept — [goal]' als niet-selecteerbare entry
bovenaan de dropdown zolang er een draft loopt.
- components/backlog/sprint-draft-leave-guard.tsx (nieuw): registreert
een beforeunload-listener zolang er een draft is. Browser-refresh,
tab-close en back-navigatie tonen daarmee de standaard confirm. In-app
route-changes blijven via de banner-Annuleren-knop lopen.
- app/(app)/products/[id]/page.tsx: SprintDraftLeaveGuard gemount naast
de banner.
- Tests: user-settings store-tests aangepast (geen server-call assert
meer, hydrate strip-assert toegevoegd; upsert-tests seed nu via
setPendingSprintDraft i.p.v. legacy hydrate).
setPendingSprintDraftAction + clearPendingSprintDraftAction blijven bestaan
voor eventuele toekomstige opruim-flows, maar worden niet meer aangeroepen
vanuit de UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(PBI-79): mark scope-aanpassing afgerond + localStorage overzicht
- 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>
---------
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)