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>
This commit is contained in:
Janpeter Visser 2026-05-14 22:20:55 +02:00
parent bf42609d6d
commit 6d071f70b8

View file

@ -167,3 +167,88 @@ stateDiagram-v2
```
`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.