Tweede laag van het Product Backlog page workflow-doc (PBI-88 / ST-1363): de zeven impliciete workflow-states met preconditie en UI-gedrag, de transition-tabel, en een Mermaid stateDiagram-v2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
14 KiB
title: "Product Backlog page — workflow & states" status: active audience: [maintainer, contributor] language: nl last_updated: 2026-05-14 related: project-structure.md, sprint-execution-modes.md
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_change → notify_task_change() |
20260426230316_add_solo_realtime_triggers |
stories |
stories_notify_change → notify_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 eentype-veld (job/worker) worden genegeerd; alleenentity ∈ {pbi, story, task}met matchendproduct_idgaat door. - Heartbeat elke 25s (
: heartbeat), hard-close na 240s (Next-maxDurationis 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 tabvisibleis; - 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-workspace — stores/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 (toggleStorySprintMembership → pending), met applyMembershipCommitResult als gericht patch-pad ná de server-action-commit.
sprint-workspace — stores/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-settings — stores/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 === true — sprintMembership.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_ACTIVE → PRODUCT_ACTIVE_* |
setActiveProductAction (actions/active-product.ts) → revalidatePath('/', 'layout') |
PRODUCT_ACTIVE_NO_SPRINTS / ACTIVE_SPRINT_* → SPRINT_DRAFT_PENDING |
NewSprintTrigger → NewSprintMetadataDialog → setPendingSprintDraft (user-settings store, session-only) |
SPRINT_DRAFT_PENDING → vorige state |
SprintDefinitionBanner "Annuleren" → clearPendingSprintDraft |
SPRINT_DRAFT_PENDING → ACTIVE_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_CLEAN ↔ ACTIVE_SPRINT_DIRTY |
toggleStorySprintMembership (product-workspace store) maakt dirty; commitSprintMembershipAction via SaveSprintButton → applyMembershipCommitResult 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/DIRTY ↔ ACTIVE_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.