docs: Product Backlog page workflow & states (PBI-88) (#208)

* docs(T-1014): PB-workflow doc — skelet + as-is architectuur-lagen en stores

Eerste laag van het Product Backlog page workflow-doc (PBI-88 / ST-1363):
frontmatter, Context & scope, de architectuur-lagen (PG-triggers -> SSE ->
Zustand -> React) en de drie voedende Zustand-stores.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(T-1015): PB-workflow doc — as-is workflow-states, transitions en diagram

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>

* docs(T-1016): PB-workflow doc — to-be expliciete state machine

Derde laag van het Product Backlog page workflow-doc (PBI-88 / ST-1363):
canonieke state-set met mapping op de as-is werkelijkheid, transitietabel,
en het ontwerp van een dunne deriveScreenState()-afleidingslaag bovenop de
bestaande PBI-74 stores (geen nieuwe store).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* 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>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-14 22:31:36 +02:00 committed by GitHub
parent 8287509c7c
commit 3d52fe4958
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 288 additions and 0 deletions

View file

@ -92,6 +92,7 @@ Auto-generated on 2026-05-14 from front-matter and headings.
| [Claude ↔ User Question Channel](./architecture/claude-question-channel.md) | `architecture/claude-question-channel.md` | active | 2026-05-03 |
| [Data Model & Prisma Schema](./architecture/data-model.md) | `architecture/data-model.md` | active | 2026-05-08 |
| [Scrum4Me — Architecture Overview](./architecture/overview.md) | `architecture/overview.md` | active | 2026-05-08 |
| [Product Backlog page — workflow & states](./architecture/product-backlog-workflow.md) | `architecture/product-backlog-workflow.md` | active | 2026-05-14 |
| [Project Structure, Stores, Realtime & Job Queue](./architecture/project-structure.md) | `architecture/project-structure.md` | active | 2026-05-08 |
| [QR-pairing Login Flow](./architecture/qr-pairing.md) | `architecture/qr-pairing.md` | active | 2026-05-03 |
| [Sprint execution modes — PER_TASK vs SPRINT_BATCH](./architecture/sprint-execution-modes.md) | `architecture/sprint-execution-modes.md` | active | 2026-05-07 |

View file

@ -19,3 +19,4 @@ last_updated: 2026-05-03
| Claude ↔ User vraag-kanaal | [architecture/claude-question-channel.md](./architecture/claude-question-channel.md) |
| Projectstructuur, Stores, Realtime, Job queue | [architecture/project-structure.md](./architecture/project-structure.md) |
| Sprint execution modes (PER_TASK vs SPRINT_BATCH) | [architecture/sprint-execution-modes.md](./architecture/sprint-execution-modes.md) |
| Product Backlog page — workflow & states | [architecture/product-backlog-workflow.md](./architecture/product-backlog-workflow.md) |

View file

@ -0,0 +1,284 @@
---
title: "Product Backlog page — workflow & states"
status: active
audience: [maintainer, contributor]
language: nl
last_updated: 2026-05-14
related: [project-structure.md](./project-structure.md), [sprint-execution-modes.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](../specs/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](./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](./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](../patterns/realtime-notify-payload.md).
### 2. SSE-route
[app/api/realtime/backlog/route.ts](../../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](../../app/api/realtime/sprint/route.ts).
### 3. Zustand-store
De client-hook `useBacklogRealtime` ([lib/realtime/use-backlog-realtime.ts](../../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](../../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](../patterns/workspace-store.md). Dit doc bouwt op die opdeling voort; het herstructureert die niet.
### `product-workspace` — [stores/product-workspace/store.ts](../../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](../../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](../../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](../patterns/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
```mermaid
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_ACTIVE``isActiveProduct === false`: de sprint-workflow is nog niet begonnen; alleen `ActivateProductButton` brengt je verder.
- `DEMO_MODE``session.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`.
```ts
// 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
- [functional.md](../specs/functional.md) — F-04..F-06: layout van de 3-pane Product Backlog page
- [workspace-store.md](../patterns/workspace-store.md) — het bounded-context store-patroon (PBI-74)
- [realtime-notify-payload.md](../patterns/realtime-notify-payload.md) — payload-contract van de NOTIFY-triggers
- [sprint-execution-modes.md](./sprint-execution-modes.md) — de sprint-flow ná dit scherm
- [project-structure.md](./project-structure.md) — stores, realtime en projectopbouw in het groot

View file

@ -210,6 +210,8 @@ Gebruikers kunnen producten aanmaken, bewerken en archiveren. Een product is het
**Omschrijving:**
De Product Backlog wordt weergegeven als een 3-paneels gesplitst scherm: PBI's (links) | Stories (midden) | Taken (rechts). De splitters zijn versleepbaar. Selectie cascadeert: klikken op een PBI toont de bijbehorende stories; klikken op een story toont de bijbehorende taken. Elk paneel heeft een eigen navigatiebar met acties.
> **Workflow & states:** dit spec beschrijft de *layout*. Voor het *gedrag* — architectuur-lagen, de impliciete workflow-states, transitions en de to-be state machine — zie [architecture/product-backlog-workflow.md](../architecture/product-backlog-workflow.md).
**Acceptatiecriteria:**
- [ ] Standaard splitverhouding is 20/45/35 (PBI's / Stories / Taken)
- [ ] Splitters zijn versleepbaar; positie wordt opgeslagen in een cookie (`sp:backlog-{id}`)