diff --git a/docs/architecture/product-backlog-workflow.md b/docs/architecture/product-backlog-workflow.md index 3ef0afa..6fc0470 100644 --- a/docs/architecture/product-backlog-workflow.md +++ b/docs/architecture/product-backlog-workflow.md @@ -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.