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:
parent
8287509c7c
commit
3d52fe4958
4 changed files with 288 additions and 0 deletions
284
docs/architecture/product-backlog-workflow.md
Normal file
284
docs/architecture/product-backlog-workflow.md
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue