Scrum4Me/docs/architecture/product-backlog-workflow.md
Madhura68 1f20faf650 docs(T-1017): PB-workflow doc — gap-analyse, aanbevelingen en docs-wiring
Slotlaag van het Product Backlog page workflow-doc (PBI-88 / ST-1363):
gap-analyse (G1-G6, incl. de oorspronkelijke switcher-FOUT), niet-bindende
aanbevelingen, en verwante-docs sectie. Haakt het doc in via de
architecture.md breadcrumb en een cross-link vanuit functional.md F-04.
npm run docs groen: INDEX geregenereerd, alle doc-links valide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 22:23:09 +02:00

21 KiB


Product Backlog page — workflow & states (PBI-88)

Eén autoritatieve beschrijving van het Product Backlog-scherm: de architectuur-lagen, de impliciete workflow-states, en — als doelbeeld — een expliciete state machine met gap-analyse.

Context & scope

De Product Backlog page (app/(app)/products/[id]/page.tsx) is het scherm waar een gebruiker de PBI's, stories en taken van één product beheert en — sinds PBI-79 — een nieuwe sprint samenstelt zónder het scherm te verlaten. De layout zelf (3-pane split: PBI's · Stories · Taken) staat in functional.md (F-04..F-06); dit doc beschrijft het gedrag eromheen.

Waarom dit doc bestaat: het scherm kent vandaag een handvol impliciete workflow-states die ad-hoc worden afgeleid uit losse SSR-flags en store-flags. Een samenhangende beschrijving ontbrak — kennis zat verspreid over functional.md (alleen layout), de patterns-docs en project-structure.md. Dit doc bundelt die kennis en zet er een doelbeeld naast.

Grens met de sprint-execution-page. Dit scherm gaat over backlog-beheer + sprint-samenstelling. Zodra een sprint draait, verschuift het werk naar de sprint-execution-flow — zie sprint-execution-modes.md. De CLOSED/ARCHIVED-levenscyclus van een sprint valt buiten dit scherm.

Architectuur-lagen (as-is)

Het scherm volgt het lagenmodel PostgreSQL-triggers → SSE → Zustand → React. Belangrijk: dit is geen toekomstplan — het draait al. De lagen, van onder naar boven:

1. PostgreSQL NOTIFY-triggers

Row-level AFTER INSERT/UPDATE/DELETE-triggers emitteren een JSON-payload op het hardcoded kanaal scrum4me_changes:

Tabel Trigger / functie Migratie
tasks tasks_notify_changenotify_task_change() 20260426230316_add_solo_realtime_triggers
stories stories_notify_changenotify_story_change() 20260426230316_add_solo_realtime_triggers
pbis NOTIFY-trigger op pbis 20260502190200_add_pbi_notify_trigger

De payload bevat minimaal { op: 'I'|'U'|'D', entity, id, product_id, … } en bij UPDATE een changed_fields-array. Latere migraties breiden de payload uit (20260427000216_extend_realtime_payload) en voegen story_logs-notificaties toe (20260506001700_story_logs_notify). Volledig payload-contract: realtime-notify-payload.md.

2. SSE-route

app/api/realtime/backlog/route.ts opent een pg.Client op DIRECT_URL (pooler-bypass), doet LISTEN scrum4me_changes en streamt via een ReadableStream:

  • Filter (shouldEmit): events met een type-veld (job/worker) worden genegeerd; alleen entity ∈ {pbi, story, task} met matchend product_id gaat door.
  • Heartbeat elke 25s (: heartbeat), hard-close na 240s (Next-maxDuration is 300s) — de client reconnect.
  • Auth via iron-session; demo-users mogen meelezen.

De sprint-board heeft een eigen route met dezelfde opzet: app/api/realtime/sprint/route.ts.

3. Zustand-store

De client-hook useBacklogRealtime (lib/realtime/use-backlog-realtime.ts), gemount in BacklogHydrationWrapper, opent een EventSource naar de SSE-route en:

  • dispatcht elk event naar useProductWorkspaceStore.applyRealtimeEvent();
  • beheert exponential backoff (1s → 30s) bij onerror, en reconnect alleen als de tab visible is;
  • triggert bij een latere ready (post-reconnect) resyncActiveScopes('reconnect'), zodat events die tijdens een disconnect gemist zijn alsnog binnenkomen.

applyRealtimeEvent (stores/product-workspace/store.ts) filtert op product_id, stuurt onbekende entities naar resyncActiveScopes('unknown-event') en dispatcht bekende events naar applyPbiEvent / applyStoryEvent / applyTaskEvent: idempotente upsert + sort, parent-move bij gewijzigd pbi_id/story_id, cleanup bij DELETE.

4. React

Componenten lezen via selectors uit de store en re-renderen op mutaties. De SSR-render (page.tsx) levert de initiële snapshot + sprint-switcher-data; daarna is de store leidend en haalt router.refresh() na een server-action de verse SSR-render op.

Kernconclusie

Het lagenmodel uit het architectuurvoorstel draait al: triggers, SSE, stores met applyRealtimeEvent, en backoff/reconnect/resync zijn aanwezig. SSE fungeert hier puur als sync-mechanisme, niet als UI-bestuurder — de stream zegt alleen "er is iets veranderd aan X", de store bepaalt de betekenis. Wat ontbreekt zit niet in deze lagen, maar in de state-modellering erbovenop; dat is het onderwerp van de volgende secties.

Stores (as-is)

Drie Zustand-stores voeden dit scherm. De opdeling volgt het bounded-context-patroon uit PBI-74 (één store per coherente workflow — niet per pagina, geen megastore), vastgelegd in workspace-store.md. Dit doc bouwt op die opdeling voort; het herstructureert die niet.

product-workspacestores/product-workspace/store.ts

De hoofdstore van dit scherm: PBI's, stories en taken van de backlog. Slices:

Slice Inhoud
context activeProduct, activePbiId, activeStoryId, activeTaskId — de cascade-selectie
entities pbisById, storiesById, tasksById — genormaliseerde entity-maps
relations pbiIds, storyIdsByPbi, taskIdsByStory — gesorteerde id-lijsten
loading loaded*Ids, activeRequestId — race-safe markers voor ensure*Loaded
sync realtimeStatus, lastEventAt, lastResyncAt, resyncReason — SSE-connectiestatus
pendingMutations rollback-snapshots voor optimistische DnD/patch-mutaties
sprintMembership pbiSummary, crossSprintBlocks, pending: { adds, removes }, loadedSummaryForSprintId — de sprint-samenstel-laag (PBI-79)

De sprintMembership-slice is uniek voor dit scherm: hij houdt bij welke stories de gebruiker in/uit de actieve sprint cherry-pickt (toggleStorySprintMembershippending), met applyMembershipCommitResult als gericht patch-pad ná de server-action-commit.

sprint-workspacestores/sprint-workspace/store.ts

Spiegelbeeld voor de sprint-board: stories/taken binnen één sprint. Zelfde slice-vorm, maar context heeft activeSprintId i.p.v. activePbiId, en relations is per-sprint georganiseerd (storyIdsBySprint). Op de Product Backlog page niet direct gebruikt, maar relevant voor de grens met de sprint-flow.

user-settingsstores/user-settings/store.ts

Gebruikersvoorkeuren + cross-tab sync. Voor dit scherm cruciaal: workflow.pendingSprintDraft[productId] — de concept-sprint die ontstaat bij "Nieuwe sprint". Sinds PBI-79 is die draft session-only: setPendingSprintDraft / clearPendingSprintDraft schrijven alleen lokaal (geen server-roundtrip), en hydrate() stript legacy DB-entries weg zodat de draft niet "spookt". upsertPbiIntent / upsertStoryOverride muteren de draft-selectie.

Slice-details van het workspace-patroon (ensure*Loaded, selectors, optimistic mutations, gotchas) staan in workspace-store.md — hier niet gedupliceerd.

Workflow-states (as-is)

Het scherm kent vandaag geen expliciete screenState. De zichtbare toestand wordt per render ad-hoc afgeleid uit SSR-flags in app/(app)/products/[id]/page.tsx (isActiveProduct, hasOpenSprint, isDemo, plus sprintItems / activeSprintItem / buildingSprintIds uit getSprintSwitcherData) en store-flags (pendingSprintDraft uit user-settings, sprintMembership.pending uit product-workspace). Daaruit zijn zeven herkenbare states te destilleren.

1. PRODUCT_NOT_ACTIVE

Preconditie: isActiveProduct === false (user.active_product_id !== id). UI: SprintSwitcher wordt niet gerenderd ({isActiveProduct && …}); ActivateProductButton zichtbaar. De PBI/Story/Task-panes werken normaal. NewSprintTrigger is wél zichtbaar (alleen demo-gated, geen isActiveProduct-gate); SaveSprintButton niet.

2. PRODUCT_ACTIVE_NO_SPRINTS

Preconditie: isActiveProduct === true && sprintItems.length === 0. UI: SprintSwitcher rendert maar valt terug op de "Geen sprints"-tooltip (early return in sprint-switcher.tsx). NewSprintTrigger enabled. Geen SaveSprintButton, geen "Sprint actief →"-link (hasOpenSprint === false).

3. SPRINT_DRAFT_PENDING

Preconditie: user-settings.workflow.pendingSprintDraft[productId] is truthy (session-only). UI: sticky SprintDefinitionBanner onder de header (sprint-draft-banner.tsx); NewSprintTrigger verbergt zichzelf (if (hasDraft) return null); SprintSwitcher toont een disabled "⚙ Concept — [goal]"-item; SprintDraftLeaveGuard registreert een beforeunload-waarschuwing. De cherrypick-checkboxes in de PBI/Story-panes schakelen naar draft-selectie-modus.

4. ACTIVE_SPRINT_CLEAN

Preconditie: isActiveProduct && activeSprintItem !== null && selectIsDirty === false. UI: SprintSwitcher toont de actieve sprint-code + status; SaveSprintButton zichtbaar maar disabled (disabled={!isDirty || …}), label "Sprint opslaan"; "Sprint actief →"-link zichtbaar zolang er een open sprint is.

5. ACTIVE_SPRINT_DIRTY

Preconditie: als 4, maar selectIsDirty === truesprintMembership.pending.adds/removes is niet leeg. UI: als 4, maar SaveSprintButton enabled met teller — label Sprint opslaan (N).

6. ACTIVE_SPRINT_BUILDING

Preconditie: activeSprintItem && buildingSprintIds.includes(activeSprintItem.id) — er loopt een SprintRun met status QUEUED/RUNNING (getSprintSwitcherData). UI: als 4/5, maar SprintSwitcher toont een gele "BUILDING"-badge i.p.v. de sprint-status. Cosmetisch — er worden geen knoppen geblokkeerd.

7. DEMO_MODE

Preconditie: session.isDemo === true. Strikt genomen geen state maar een cross-cutting constraint over alle bovenstaande states: schrijf-knoppen (ActivateProductButton, SaveSprintButton, NewSprintTrigger, EditProductButton) zijn verborgen of disabled; de SprintSwitcher navigeert i.p.v. een server-action aan te roepen; en de server-actions zelf returnen vroeg met { error: 'Niet beschikbaar in demo-modus' }.

Transitions (as-is)

Er is geen centrale overgangs-tabel; elke transitie is een server-action of store-mutatie gevolgd door een re-render:

Van → naar Trigger
PRODUCT_NOT_ACTIVEPRODUCT_ACTIVE_* setActiveProductAction (actions/active-product.ts) → revalidatePath('/', 'layout')
PRODUCT_ACTIVE_NO_SPRINTS / ACTIVE_SPRINT_*SPRINT_DRAFT_PENDING NewSprintTriggerNewSprintMetadataDialogsetPendingSprintDraft (user-settings store, session-only)
SPRINT_DRAFT_PENDING → vorige state SprintDefinitionBanner "Annuleren" → clearPendingSprintDraft
SPRINT_DRAFT_PENDINGACTIVE_SPRINT_CLEAN SprintDefinitionBanner "Sprint aanmaken" → createSprintWithSelectionAction (actions/sprints.ts): maakt de sprint, koppelt de geselecteerde stories, en de nieuwe sprint wordt de actieve sprint
ACTIVE_SPRINT_CLEANACTIVE_SPRINT_DIRTY toggleStorySprintMembership (product-workspace store) maakt dirty; commitSprintMembershipAction via SaveSprintButtonapplyMembershipCommitResult maakt weer clean
ACTIVE_SPRINT_* → andere ACTIVE_SPRINT_* SprintSwitcher dropdown → switchActiveSprintAction (actions/active-sprint.ts)
ACTIVE_SPRINT_*PRODUCT_ACTIVE_NO_SPRINTS (geen actieve sprint) SprintSwitcher "— Geen actieve sprint —" → clearActiveSprintAction
ACTIVE_SPRINT_CLEAN/DIRTYACTIVE_SPRINT_BUILDING extern: een SprintRun gaat naar QUEUED/RUNNING resp. rondt af — zichtbaar bij de volgende SSR-render

Server-actions roepen revalidatePath aan; de client doet daarnaast router.refresh() (bv. na sprint-creatie). De realtime-laag (SSE → applyRealtimeEvent) dekt externe wijzigingen — zie de sectie Architectuur-lagen hierboven.

State-diagram

stateDiagram-v2
    [*] --> PRODUCT_NOT_ACTIVE
    PRODUCT_NOT_ACTIVE --> PRODUCT_ACTIVE_NO_SPRINTS: setActiveProductAction

    PRODUCT_ACTIVE_NO_SPRINTS --> SPRINT_DRAFT_PENDING: Nieuwe sprint / setPendingSprintDraft
    SPRINT_DRAFT_PENDING --> PRODUCT_ACTIVE_NO_SPRINTS: Annuleren / clearPendingSprintDraft
    SPRINT_DRAFT_PENDING --> ACTIVE_SPRINT: Sprint aanmaken / createSprintWithSelectionAction

    state ACTIVE_SPRINT {
        [*] --> CLEAN
        CLEAN --> DIRTY: toggleStorySprintMembership
        DIRTY --> CLEAN: commitSprintMembershipAction
        CLEAN --> BUILDING: SprintRun QUEUED/RUNNING
        BUILDING --> CLEAN: SprintRun klaar
    }

    ACTIVE_SPRINT --> SPRINT_DRAFT_PENDING: Nieuwe sprint / setPendingSprintDraft
    ACTIVE_SPRINT --> ACTIVE_SPRINT: switchActiveSprintAction
    ACTIVE_SPRINT --> PRODUCT_ACTIVE_NO_SPRINTS: clearActiveSprintAction

DEMO_MODE staat bewust niet in het diagram: het is geen knoop in de graaf maar een read-only constraint die over álle states heen ligt.

To-be: expliciete state machine

Doelbeeld — nog niet geïmplementeerd. Deze sectie beschrijft hoe de impliciete states een expliciet, testbaar model kunnen worden, conform het architectuurvoorstel. Het is een ontwerp, geen weerslag van bestaande code.

Canonieke state-set

Het voorstel noemde zeven states (NO_SPRINT, DRAFT, EDITING, READY_TO_START, ACTIVE, CLOSED, ERROR). Tegen de werkelijkheid van dit scherm afgezet blijven er vier canonieke states over, plus twee cross-cutting gates:

Voorstel-state Canoniek hier Mapping op de as-is werkelijkheid
NO_SPRINT NO_SPRINT PRODUCT_ACTIVE_NO_SPRINTS + de "geen actieve sprint"-situatie na clearActiveSprintAction
DRAFT DRAFT SPRINT_DRAFT_PENDING — 1-op-1
EDITING EDITING ACTIVE_SPRINT_DIRTY — 1-op-1 (ongecommitte membership-wijzigingen)
ACTIVE ACTIVE (+ building-flag) ACTIVE_SPRINT_CLEAN + ACTIVE_SPRINT_BUILDING
READY_TO_START (vervalt) Bestaat niet: createSprintWithSelectionAction maakt de sprint status: 'OPEN' én roept meteen setActiveSprintInSettings aan. Er is geen tussenstap "sprint klaar, nog niet gestart"
CLOSED (buiten scope) completeSprintAction zet status: 'CLOSED' vanaf de sprint-execution-page. De switcher kán closed/archived sprints tónen ("Toon afgeronde sprints"), maar muteert ze hier niet
ERROR (niet gemodelleerd) Server-actions returnen { error, code }; de client toont een toast.error en blijft in de huidige state. Er is geen expliciete ERROR-schermtoestand

building is een orthogonale flag op ACTIVE/EDITING (een SprintRun is QUEUED/RUNNING), geen aparte state — de UI blokkeert tijdens building niets, het is puur een badge.

Cross-cutting gates (geen knopen in de machine, wel bepalend voor wat zichtbaar is):

  • PRODUCT_NOT_ACTIVEisActiveProduct === false: de sprint-workflow is nog niet begonnen; alleen ActivateProductButton brengt je verder.
  • DEMO_MODEsession.isDemo: read-only over alle states.

Transitietabel

currentState + event + context → nextState:

currentState event context / guard nextState
NO_SPRINT OPEN_DRAFT DRAFT
DRAFT CANCEL_DRAFT vorige state (NO_SPRINT of ACTIVE)
DRAFT CREATE_SPRINT ≥1 eligible story ACTIVE
ACTIVE OPEN_DRAFT DRAFT
ACTIVE TOGGLE_MEMBERSHIP EDITING
EDITING TOGGLE_MEMBERSHIP pending wordt leeg ACTIVE
EDITING TOGGLE_MEMBERSHIP pending blijft gevuld EDITING
EDITING COMMIT_MEMBERSHIP ACTIVE
ACTIVE / EDITING SWITCH_SPRINT ACTIVE (andere sprint)
ACTIVE / EDITING CLEAR_ACTIVE_SPRINT NO_SPRINT
ACTIVE / EDITING SPRINT_RUN_STARTED / _FINISHED idem state, building flag om

CANCEL_DRAFT is context-afhankelijk: open je DRAFT vanuit ACTIVE, dan keert annuleren terug naar ACTIVE; vanuit NO_SPRINT naar NO_SPRINT.

Dunne afleidingslaag — deriveScreenState()

Conform de met de gebruiker afgestemde keuze blijven de PBI-74 bounded-context stores leidend. Er komt dus geen workflowStore en geen herstructurering — alleen een dunne, pure afleidingslaag bovenop wat er al is: één functie die de verspreide flags consolideert tot één ScreenState.

// stores/product-workspace/screen-state.ts — ONTWERP, nog niet geïmplementeerd
export type ScreenState =
  | { kind: 'NO_SPRINT' }
  | { kind: 'DRAFT' }
  | { kind: 'ACTIVE'; building: boolean }
  | { kind: 'EDITING'; building: boolean }

export interface ScreenStateInput {
  // SSR-props uit page.tsx
  activeSprintItem: { id: string } | null
  buildingSprintIds: string[]
  // store-slices (geen nieuwe state — alleen lezen)
  hasPendingDraft: boolean        // user-settings: pendingSprintDraft[productId]
  pendingAdds: string[]           // product-workspace: sprintMembership.pending.adds
  pendingRemoves: string[]        // product-workspace: sprintMembership.pending.removes
}

export function deriveScreenState(i: ScreenStateInput): ScreenState {
  if (i.hasPendingDraft) return { kind: 'DRAFT' }            // draft wint van alles
  if (i.activeSprintItem) {
    const building = i.buildingSprintIds.includes(i.activeSprintItem.id)
    const dirty = i.pendingAdds.length > 0 || i.pendingRemoves.length > 0
    return dirty ? { kind: 'EDITING', building } : { kind: 'ACTIVE', building }
  }
  return { kind: 'NO_SPRINT' }
}

Ontwerp-uitgangspunten:

  • Pure functie, geen nieuwe store. Leest uitsluitend uit de bestaande product-workspace- en user-settings-stores plus de SSR-props. Geen gedupliceerde state.
  • Eén plek voor "in welke state zit ik". Vandaag zit die afleiding verspreid over page.tsx, sprint-switcher.tsx, new-sprint-trigger.tsx en save-sprint-button.tsx; componenten schakelen straks over op één switch (screenState.kind).
  • Leeft als selector/util naast stores/product-workspace/selectors.ts — testbaar in isolatie (pure input → output).
  • PRODUCT_NOT_ACTIVE en DEMO_MODE blijven buiten ScreenState: het zijn gates die de UI al apart als booleans heeft, geen knopen in de machine.

Gap-analyse

Wat verschilt tussen de as-is werkelijkheid en het doelbeeld:

# Gap Huidige situatie Doelbeeld / divergentie
G1 Geen expliciete schermstaat De state wordt per render ad-hoc afgeleid; die logica zit verspreid over page.tsx, sprint-switcher.tsx, new-sprint-trigger.tsx en save-sprint-button.tsx Eén deriveScreenState() als single source of truth
G2 Geen READY_TO_START createSprintWithSelectionAction maakt de sprint én activeert 'm in één stap Voorstel-state vervalt bewust — vastleggen, niet "fixen"
G3 ERROR niet gemodelleerd Server-action-fout → toast.error, scherm blijft in de huidige state Geen expliciete ERROR-state; afweging of dat wenselijk is bij falende commit / SSE-verlies
G4 DEMO_MODE / PRODUCT_NOT_ACTIVE zijn geen states Cross-cutting booleans, los van de state-afleiding Bewust buiten ScreenState — gates, geen knopen
G5 Draft onzichtbaar op de switcher-trigger De SprintSwitcher-knop toont alleen activeSprint.code of "Selecteer sprint"; de "⚙ Concept"-regel zit alleen in de (disabled) dropdown In DRAFT zou de trigger de concept-status moeten tonen — dit was de oorspronkelijke "FOUT"-melding die tot dit doc leidde
G6 Draft te starten op niet-actief product NewSprintTrigger is alleen demo-gated, niet isActiveProduct-gated Een draft op een niet-actief product is verwarrend; overweeg de trigger ook achter isActiveProduct te zetten

Aanbevelingen

Geprioriteerd en niet-bindend — input voor latere PBI's, geen scope van dit doc:

  1. deriveScreenState() introduceren (G1) — grootste hefboom: maakt de UI testbaar en de overige gaps adresseerbaar vanuit één plek.
  2. Draft-indicatie op de switcher-trigger (G5) — kleine, zichtbare UX-fix die de oorspronkelijke melding direct oplost.
  3. NewSprintTrigger achter isActiveProduct (G6) — kleine guard-toevoeging.
  4. ERROR-state overwegen (G3) — grotere afweging; alleen oppakken als falende commits of SSE-verlies een echt UX-probleem blijken.

Verwante docs