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