From 3d52fe4958bf8622a5ad525e79daca37707e3dc1 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 14 May 2026 22:31:36 +0200 Subject: [PATCH] docs: Product Backlog page workflow & states (PBI-88) (#208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- docs/INDEX.md | 1 + docs/architecture.md | 1 + docs/architecture/product-backlog-workflow.md | 284 ++++++++++++++++++ docs/specs/functional.md | 2 + 4 files changed, 288 insertions(+) create mode 100644 docs/architecture/product-backlog-workflow.md diff --git a/docs/INDEX.md b/docs/INDEX.md index 1c5a0a7..258f807 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -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 | diff --git a/docs/architecture.md b/docs/architecture.md index b64e501..5387082 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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) | diff --git a/docs/architecture/product-backlog-workflow.md b/docs/architecture/product-backlog-workflow.md new file mode 100644 index 0000000..8c28784 --- /dev/null +++ b/docs/architecture/product-backlog-workflow.md @@ -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 diff --git a/docs/specs/functional.md b/docs/specs/functional.md index 436fafe..7def620 100644 --- a/docs/specs/functional.md +++ b/docs/specs/functional.md @@ -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}`)