---
title: "Project Structure, Stores, Realtime & Job Queue"
status: active
audience: [maintainer, contributor]
language: nl
last_updated: 2026-05-03
related: [data-model.md](./data-model.md)
---

## Projectstructuur

```
scrum4me/
├── app/
│   ├── (auth)/
│   │   ├── login/page.tsx
│   │   └── register/page.tsx
│   ├── (app)/                        # Beschermde routes (desktop + tablets)
│   │   ├── layout.tsx                # Auth-check (requireSession) + navigatie
│   │   ├── dashboard/page.tsx        # Productenlijst
│   │   ├── products/
│   │   │   ├── new/page.tsx
│   │   │   └── [id]/
│   │   │       ├── layout.tsx        # Zet actief product in Zustand store
│   │   │       ├── page.tsx          # Product Backlog (gesplitst scherm)
│   │   │       ├── solo/page.tsx     # Solo board (Kanban per ingelogde gebruiker)
│   │   │       ├── sprint/
│   │   │       │   ├── page.tsx      # Sprint Backlog (drie-paneel scherm)
│   │   │       │   └── planning/page.tsx  # Redirect → /sprint
│   │   ├── todos/page.tsx
│   │   └── settings/
│   │       ├── page.tsx              # Profiel, account, PB-overzicht, rollen, tokens
│   │       └── tokens/page.tsx
│   ├── (mobile)/                     # Mobile-shell route group (telefoon-UA)
│   │   ├── layout.tsx                # Auth via gedeelde requireSession; geen NavBar/StatusBar
│   │   └── m/
│   │       ├── settings/page.tsx     # Account + product-selector + QR-instructie + logout
│   │       ├── pair/                 # QR-pairing (verhuisd uit (app)/ — URL ongewijzigd)
│   │       │   ├── page.tsx
│   │       │   └── pair-confirmation.tsx
│   │       └── products/[id]/
│   │           ├── page.tsx          # Mobile Product Backlog (tab-mode op <1024px)
│   │           └── solo/page.tsx     # Mobile Solo (3-koloms-kanban)
│   ├── api/                          # REST API voor Claude Code
│   │   ├── products/
│   │   │   └── [id]/
│   │   │       └── next-story/route.ts
│   │   ├── profile/
│   │   │   └── avatar/route.ts       # POST upload + GET serve profielfoto
│   │   ├── sprints/
│   │   │   └── [id]/
│   │   │       └── tasks/route.ts
│   │   ├── stories/
│   │   │   └── [id]/
│   │   │       ├── log/route.ts
│   │   │       └── tasks/reorder/route.ts
│   │   ├── tasks/
│   │   │   └── [id]/route.ts
│   │   └── todos/route.ts
├── components/
│   ├── ui/                           # shadcn/ui primitieven
│   ├── split-pane/                   # Gesplitst scherm component
│   ├── backlog/                      # PBI- en story-componenten
│   ├── sprint/                       # Sprint-componenten
│   ├── products/                     # ProductForm, TeamManager, ArchiveProductButton
│   ├── settings/                     # RoleManager, ProfileEditor, LeaveProductButton
│   ├── mobile/                       # LandscapeGuard, MobileTabBar, LogoutButton
│   └── dnd/                         # dnd-kit wrappers
├── lib/
│   ├── prisma.ts                     # Prisma Client singleton
│   ├── session.ts                    # iron-session configuratie
│   ├── auth.ts                       # login/register/token helpers
│   ├── api-auth.ts                   # Bearer token middleware voor API
│   ├── product-access.ts             # productAccessFilter helper (eigenaar of teamlid)
│   └── env.ts                        # Zod-gevalideerde env vars
├── stores/                           # Zustand stores
│   ├── backlog-store.ts              # PBI/story/task hydration + applyChange (SSE)
│   ├── planner-store.ts              # Optimistische drag-and-drop volgorde
│   ├── selection-store.ts            # Geselecteerd PBI / story (cascade-reset)
│   ├── sprint-store.ts               # Sprint Backlog taakvolgordes
│   ├── solo-store.ts                 # Solo board optimistische taakstatus
│   └── product-store.ts              # Actief product (naam + id) voor navbar
├── prisma/
│   ├── schema.prisma
│   ├── migrations/
│   └── seed.ts                       # Testdata uit Product Backlog document
├── proxy.ts                          # Next.js 16 proxy voor route protection
├── prisma.config.ts                  # Prisma v7 config (DATABASE_URL)
├── instrumentation.ts                # Next.js hook → koppelt Sentry-config aan runtime
├── instrumentation-client.ts         # Sentry client-init + router-transitions
├── sentry.server.config.ts           # Sentry node-runtime init (no-op zonder DSN)
├── sentry.edge.config.ts             # Sentry edge-runtime init (proxy.ts)
└── .env.example
```

---

## Sleutelarchitectuurbeslissingen

### Beslissing: iron-session in plaats van Auth.js / Supabase Auth
**Keuze:** iron-session voor versleutelde server-side sessiecookies
**Rationale:** Scrum4Me gebruikt username/wachtwoord zonder e-mail — een flow die Auth.js/NextAuth met Credentials Provider ondersteunt, maar met onnodige complexiteit (JWT-callbacks, adapter-configuratie). iron-session is minimaal: sla een gesigneerde, versleutelde cookie op met `{ userId, isDemo }` en klaar. Geen externe afhankelijkheid, geen database-adapter voor sessies.
**Trade-off:** Geen ingebouwde OAuth of magic links. Dat is bewust — v1 heeft die niet nodig.

### Beslissing: Route Handlers naast Server Actions
**Keuze:** Server Actions voor UI-mutaties; Route Handlers voor de Claude Code REST API
**Rationale:** Server Actions zijn ideaal voor form-submits en UI-interacties (CSRF-bescherming, progressive enhancement). Maar Claude Code heeft echte HTTP-endpoints nodig — Bearer token, JSON body, programmatisch aanroepbaar. Die twee aanpakken leven naast elkaar zonder conflict.
**Trade-off:** Duplicatie in validatie-logica. Opgelost door gedeelde service-functies in `lib/` die beide aanroepen.

### Beslissing: Float voor sort_order
**Keuze:** `Float` in plaats van `Int` voor volgorde van PBI's, stories en taken
**Rationale:** Bij drag-and-drop tussenvoeging kan de nieuwe positie worden berekend als het gemiddelde van de buurwaarden (bijv. `(1.0 + 2.0) / 2 = 1.5`). Hierdoor is nooit een herindexering van alle items nodig. Herindexering is alleen nodig als de float-precisie opraakt (in de praktijk na duizenden bewegingen).
**Trade-off:** Kleine kans op precisieverlies bij extreme fragmentatie. Opgelost door periodieke herindexering als de minimale afstand onder een drempelwaarde valt.

### Beslissing: Denormalisatie van `product_id` op `stories` en `sprint_id` op `tasks`
**Keuze:** `product_id` opslaan op zowel `pbis` als `stories`; `sprint_id` op zowel `stories` als `tasks`
**Rationale:** Veel queries in de gesplitste schermen filteren op product of Sprint zonder de volledige hiërarchie te doorlopen. Directe foreign keys voorkomen onnodige joins en N+1-risico's.
**Trade-off:** Redundante data vereist consistente updates. Gehandhaafd via Prisma-transacties in de service-laag.

### Beslissing: Zustand voor client-side state management
**Keuze:** Vijf Zustand-stores naast Server Components
**Rationale:** De gesplitste schermen met dnd-kit vereisen client-side staat die twee panelen tegelijk aanstuurt. `useState` per component leidt tot prop drilling; Context API veroorzaakt onnodige re-renders bij 60fps drag-events. Zustand's selector-gebaseerde subscriptions updaten alleen de componenten die de gewijzigde slice observeren. De gouden regel: Zustand beheert uitsluitend ephemere UI-staat — nooit server-data. Server-data blijft in Server Components en wordt opgehaald via Prisma.
**Trade-off:** Extra abstractielaag die geïnitialiseerd moet worden vanuit server-data. Opgelost via een patroon waarbij het Server Component de initiële ids doorgeeft aan een Client Component dat de store hydrateert.

### Beslissing: Eigen route group `(mobile)` voor mobile-shell (PBI-11)
**Keuze:** Telefoon-routes leven onder `app/(mobile)/m/*` met eigen `layout.tsx`, niet als nested directory in `(app)/m/*`.
**Rationale:** Next.js layouts erven naar binnen — een nested layout in `(app)/m/` zou de NavBar/StatusBar/MinWidthBanner/SoloRealtimeBridge/NotificationsBridge erven van `(app)/layout.tsx` zonder die te kunnen onderdrukken. De mobile-shell heeft die chrome niet nodig (alleen bottom-tab-bar). Een eigen route group geeft een schone parent-layout. De auth-check is geëxtraheerd naar `lib/auth-guard.ts` `requireSession()` zodat `(app)/layout.tsx` en `(mobile)/layout.tsx` dezelfde guard delen.
**Trade-off:** Twee layouts om te onderhouden, maar elk met een duidelijk afgebakende verantwoordelijkheid. Content-componenten (PbiList, StoryPanel, TaskPanel, SoloBoard, alle entity-dialogen) blijven volledig gedeeld — geen dubbele implementatie.

### Beslissing: UA-redirect via `Mobi`-substring (PBI-11)
**Keuze:** `lib/user-agent.ts` `isPhoneUA()` test op `Mobi` in de UA-string. `loginAction` (`actions/auth.ts`) leest de header na `session.save()`; phone-UA → `/m/products/[active]/solo` (zonder actief product → `/m/settings`); tablet-UA en desktop → `/dashboard`.
**Rationale:** `Mobi` is de standaard-heuristiek — aanwezig in iPhone Safari Mobile en Android Chrome op telefoons, afwezig op iPad en Android-tablet. Exact wat we willen: alleen telefoons krijgen de mobile-shell, tablets behouden de desktop-flow.
**Trade-off:** Heuristieken zijn nooit 100%; wie via een mobile-emulatie (DevTools) wil testen kan UA spoofen.

### Beslissing: Gedeelde `entityDialogContentClasses` voor mobile-fullscreen (PBI-11)
**Keuze:** Eén Tailwind-class-string in `components/shared/entity-dialog-layout.ts` met `max-sm:w-screen max-sm:h-screen max-sm:max-w-none max-sm:rounded-none` dekt alle entity-dialogen (PbiDialog, StoryDialog, TaskDialog, TaskDetailDialog).
**Rationale:** Dialog-fullscreen op mobile op vier plekken bewaken zou drift introduceren. De gedeelde constant geeft één bron van waarheid. Het regressie-vangnet (`__tests__/components/shared/entity-dialog-layout.test.ts`) verifieert dat elke dialog deze constant blijft gebruiken.
**Trade-off:** Eén dialog kan niet afwijken zonder de constant te verlaten — bewuste keuze voor consistentie.

### Beslissing: Sentry voor error-monitoring (v1-readiness item 2)
**Keuze:** `@sentry/nextjs` met vier config-files in repo-root: `instrumentation.ts`, `instrumentation-client.ts`, `sentry.server.config.ts`, `sentry.edge.config.ts`. DSN via `NEXT_PUBLIC_SENTRY_DSN`. Zonder DSN draait de SDK als no-op — geen overhead in dev of bij ontbrekende creds in CI.
**Rationale:** Een echte v1-launch zonder runtime-monitoring is een blinde vlek; build-fouten vangen we in CI maar productie-fouten zien we anders pas via een gebruiker. Vercel + Sentry koppelen via env-vars (geen native marketplace-integratie nodig). Source-maps uploaden alleen als `SENTRY_AUTH_TOKEN` aanwezig is — anders skip-build voor lokale dev. Tunnel-route `/monitoring` omzeilt ad-blockers die `*.sentry.io` blokkeren.
**Trade-off:** Extra dependency (~150 KB client-bundle additioneel). Sample-rates conservatief (10% performance in productie, 100% errors). Geen Replay-integratie — vereist eigen privacy-review en is overkill voor MVP.

### Beslissing: Gescheiden SplitPane cookie-key voor mobile (PBI-11)
**Keuze:** `BacklogSplitPane` op `app/(mobile)/m/products/[id]/page.tsx` gebruikt `cookieKey={\`backlog-${id}-mobile\`}` (versus desktop `backlog-${id}`).
**Rationale:** Op mobile rendert de `SplitPane` in tab-mode (`<1024px`), waar split-percentages niet aangepast worden. Zonder gescheiden key zou dezelfde cookie hergebruikt worden — telefoon-rotaties of orientatie-wisselingen hadden anders ongewenste interactie met de desktop-split-state.
**Trade-off:** Gebruikers die zowel mobile als desktop gebruiken hebben twee onafhankelijke split-instellingen, wat juist gewenst is.

---

## Zustand stores

### `usePlannerStore` — optimistische drag-and-drop volgorde

Beheert de lokale volgorde van PBI's, stories en taken tijdens en na drag-and-drop, voordat de server bevestigt. Houdt de UI vloeiend op 60fps ongeacht netwerklatency.

```ts
// stores/planner-store.ts
import { create } from 'zustand'

interface PlannerStore {
  // Optimistische volgorde per container (id-arrays)
  pbiOrder: Record<string, string[]>    // productId → pbi-ids
  storyOrder: Record<string, string[]>  // pbiId → story-ids
  taskOrder: Record<string, string[]>   // storyId → taak-ids

  // Initialiseren vanuit server-data (bij mount)
  initPbis: (productId: string, ids: string[]) => void
  initStories: (pbiId: string, ids: string[]) => void
  initTasks: (storyId: string, ids: string[]) => void

  // Optimistisch updaten (vóór server-bevestiging)
  reorderPbis: (productId: string, newOrder: string[]) => void
  reorderStories: (pbiId: string, newOrder: string[]) => void
  reorderTasks: (storyId: string, newOrder: string[]) => void

  // Terugdraaien bij server-fout
  rollbackPbis: (productId: string, prevOrder: string[]) => void
  rollbackStories: (pbiId: string, prevOrder: string[]) => void
  rollbackTasks: (storyId: string, prevOrder: string[]) => void
}
```

**Gebruikspatroon:**
```ts
// 1. Server Component geeft ids door
// app/(app)/products/[id]/page.tsx
const pbis = await prisma.pbi.findMany({ where: { product_id: id }, orderBy: [...] })
return <BacklogPanel productId={id} initialPbiIds={pbis.map(p => p.id)} pbis={pbis} />

// 2. Client Component hydrateert store
// components/backlog/backlog-panel.tsx
'use client'
const { initPbis, reorderPbis, rollbackPbis } = usePlannerStore()
useEffect(() => { initPbis(productId, initialPbiIds) }, [])

// 3. dnd-kit onDragEnd → optimistisch updaten + Server Action
const prevOrder = usePlannerStore(s => s.pbiOrder[productId])
reorderPbis(productId, newOrder)
const result = await reorderPbisAction(productId, newOrder)
if (!result.success) rollbackPbis(productId, prevOrder)
```

---

### `useSelectionStore` — navigatieselectie

Beheert welk PBI of story geselecteerd is in het linkerpaneel, zodat beide panelen en de navigatiebar synchroon reageren zonder prop drilling.

```ts
// stores/selection-store.ts
interface SelectionStore {
  selectedPbiId: string | null
  selectedStoryId: string | null
  selectPbi: (id: string | null) => void
  selectStory: (id: string | null) => void
  clearSelection: () => void
}
```

---

### `useSprintStore` — Sprint Backlog interacties

Beheert optimistische toevoegingen en verwijderingen van stories aan de Sprint Backlog tijdens drag-and-drop tussen de twee panelen.

```ts
// stores/sprint-store.ts
interface SprintStore {
  // Stories per Sprint (optimistisch, op volgorde)
  sprintStoryIds: Record<string, string[]>  // sprintId → story-ids

  initSprint: (sprintId: string, ids: string[]) => void
  addStoryToSprint: (sprintId: string, storyId: string, atIndex: number) => void
  removeStoryFromSprint: (sprintId: string, storyId: string) => void
  reorderSprintStories: (sprintId: string, newOrder: string[]) => void
  rollbackSprint: (sprintId: string, prevIds: string[]) => void
}
```

---

### `useSoloStore` — Solo board optimistische taakstatus

Beheert de taakstatus van de ingelogde gebruiker op het solo Kanban-board. Ondersteunt optimistische verplaatsingen tussen kolommen met rollback bij serverfout.

```ts
// stores/solo-store.ts
interface SoloStore {
  tasks: Record<string, SoloTask>
  initTasks: (tasks: SoloTask[]) => void
  optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
  rollback: (taskId: string, prevStatus: TaskStatus) => void
  updatePlan: (taskId: string, plan: string | null) => void
}
```

---

### `useProductStore` — Actief product voor navbar

Houdt het actief geselecteerde product (id + naam) bij zodat de navbar de productnaam kan tonen zonder prop drilling door de layout-hiërarchie.

```ts
// stores/product-store.ts
interface ProductStore {
  currentProduct: { id: string; name: string } | null
  setCurrentProduct: (id: string, name: string) => void
  clearCurrentProduct: () => void
}
```

---

## Data flow architectuur

```
┌─────────────────────────────────────────┐
│         Server Component (page.tsx)      │
│  Prisma query → initiële data + ids      │
│  → props naar Client Component          │
└──────────────────┬──────────────────────┘
                   │ initialIds, initialData
                   ▼
┌─────────────────────────────────────────┐
│       Client Component (panel.tsx)       │
│  useEffect → store.init(ids)            │
│  dnd-kit drag → store.reorder()         │
│             → Server Action (async)     │
│             → bij fout: store.rollback()│
└──────────────────┬──────────────────────┘
                   │ selecteert state via selector
                   ▼
┌─────────────────────────────────────────┐
│           Zustand Stores                 │
│  usePlannerStore  useSelectionStore      │
│  useSprintStore                          │
│                                          │
│  Alleen ephemere UI-staat               │
│  Nooit server-data of business logic    │
└─────────────────────────────────────────┘
```
**Keuze:** API-tokens opgeslagen als SHA-256 hashes in de `api_tokens` tabel
**Rationale:** Het token zelf wordt eenmalig getoond aan de gebruiker en nooit opgeslagen. De hash is voldoende voor lookup en verificatie. Redis of een aparte token-store zou overkill zijn voor v1-schaal.
**Trade-off:** Tokens kunnen niet worden verlengd of geroteerd zonder een nieuw token aan te maken.

---

## Realtime updates (M8)

Het Solo Paneel update live als andere gebruikers, scripts of admin-tools een task of story muteren. De pijplijn:

```
┌─────────────────────────┐
│  Mutatie (Prisma write) │  PATCH /api/tasks/:id
└────────────┬────────────┘  Server Action, MCP, etc.
             ▼
┌─────────────────────────┐
│ Postgres row trigger    │  AFTER INSERT/UPDATE/DELETE
│ scrum4me_notify_change()│  bouwt JSON payload
└────────────┬────────────┘
             ▼  pg_notify('scrum4me_changes', json)
┌─────────────────────────┐
│ /api/realtime/solo      │  Node runtime, dedicated pg.Client
│ LISTEN scrum4me_changes │  filtert op product + sprint + assignee
└────────────┬────────────┘
             ▼  text/event-stream
┌─────────────────────────┐
│ EventSource (browser)   │  beheerd door useSoloRealtime
│ → solo-store.handleEvent│  via flushSync + startViewTransition
└────────────┬────────────┘
             ▼
┌─────────────────────────┐
│ SoloBoard re-render     │  kanban-kaartje animeert naar
│ (View Transitions API)  │  zijn nieuwe kolom
└─────────────────────────┘
```

**Keuze:** Postgres LISTEN/NOTIFY in plaats van polling, websockets of een externe broker (Pusher, Ably, Supabase Realtime).
**Rationale:** Eén Neon-database is al een verplichte dependency; LISTEN/NOTIFY voegt geen nieuwe infrastructuur toe. Polling zou voor één gebruiker prima werken maar schaalt slecht; een externe broker introduceert kosten, een tweede auth-laag, en synchronisatie-races tussen DB-writes en push-events.
**Trade-off:** Vereist een direct (unpooled) connection per open tab — Neon pooler ondersteunt LISTEN niet. Bij veel gelijktijdige gebruikers een keer her-evalueren.

### Mutaties die NOTIFY triggeren

De row trigger zit op `task` en `story`. Elke INSERT/UPDATE/DELETE op die tabellen — onafhankelijk van de bron (Prisma, MCP-server, raw SQL) — vuurt een NOTIFY met de geüpdate kolommen. Andere tabellen (Sprint, Product, etc.) doen dat niet; die hebben geen live-view in M8.

### Server-side filter

`/api/realtime/solo?product_id=...` filtert NOTIFY-payloads op:
- `product_id` matcht de query-param
- `sprint_id` matcht de actieve sprint van het product (resolve éénmaal per connect)
- `assignee_id` is gelijk aan de ingelogde `userId` (of `null` voor unassigned-story-claims)

Niet-matchende events worden server-side gedropt zodat de browser geen irrelevante data ontvangt en de solo-store geen onnodige diff-checks doet.

### Connection lifecycle

- **Open**: `EventSource('/api/realtime/solo?product_id=...')` zodra de gebruiker een actief product heeft. `SoloRealtimeBridge` mount in `(app)/layout` en krijgt het `productId` via prop, zodat de stream over de hele app open staat — niet alleen op `/solo`. Zo kunnen de Live-status-dot en worker-presence-indicator in de NavBar overal werken. Buiten `/solo` is de solo-store leeg en zijn binnenkomende task-events no-ops (`stores/solo-store.ts handleRealtimeEvent` skipt onbekende ids), dus de stream gedraagt zich automatisch als lichte presence-stream tot `SoloBoard` mount.
- **Reconnect**: exponential backoff bij `onerror` (1s → 30s, reset bij `ready` event).
- **Pause op tab-hidden**: `document.visibilityState === 'hidden'` sluit de stream actief. Bij `visible` wordt opnieuw verbonden. Dit voorkomt dat inactieve tabs DB-connecties open houden.
- **Hard close**: server sluit zelf na 240s (Vercel `maxDuration` is 300s); client herconnect transparant.
- **Heartbeat**: server stuurt elke 25s een `: heartbeat`-comment om proxies te keep-alive'n.

**Bekende beperking M8**: events die binnenkomen terwijl de tab `hidden` is, worden niet vervangen bij heropening. De gebruiker ziet de meest recente Postgres-state pas bij een page-refresh of een nieuwe mutatie. Voor v1 acceptabel; in M9+ overwegen we een replay-fetch op visibility-resume.

### Animatie

Voor `task UPDATE`-events wordt de store-update gewikkeld in `document.startViewTransition(() => flushSync(() => handleEvent(payload)))`. `flushSync` dwingt React om binnen de transition-callback synchroon te renderen, zodat View Transitions de oude en nieuwe DOM correct snapshot. Vereist `view-transition-name` op de task-cards (gezet op task-id). INSERT/DELETE-events animeren niet — die mutaties komen typisch met een page-load.

### Auth

Iron-session cookie of Bearer-token (demo). De auth-check loopt éénmalig bij de connect-request; tijdens de stream zelf is er geen herauth, dus een ingetrokken sessie blijft live tot de stream sluit (heartbeat-fail of hard-close). Voor M8 acceptabel — sessies expireren na 30 dagen.

---

## Realtime — Backlog SSE (ST-1115)

De Product Backlog-pagina (`/products/[id]`) update live als PBI's, stories of taken worden gemuteerd. De pijplijn is gelijk aan de Solo-SSE (M8), maar met een eenvoudiger server-side filter: alleen `product_id`-scope, geen sprint- of user-scope.

```
┌─────────────────────────┐
│  Mutatie (Prisma write) │  Server Action, MCP, etc.
└────────────┬────────────┘
             ▼
┌─────────────────────────┐
│ Postgres row trigger    │  AFTER INSERT/UPDATE/DELETE
│ scrum4me_notify_change()│  entity: 'pbi' | 'story' | 'task'
└────────────┬────────────┘
             ▼  pg_notify('scrum4me_changes', json)
┌─────────────────────────┐
│ /api/realtime/backlog   │  Node runtime, dedicated pg.Client
│ LISTEN scrum4me_changes │  filtert op entity ∈ {pbi,story,task}
│                         │  én product_id matcht query-param
└────────────┬────────────┘
             ▼  text/event-stream
┌─────────────────────────┐
│ EventSource (browser)   │  beheerd door useBacklogRealtime
│ → backlog-store.apply   │  via applyChange(entity, op, data)
│   Change(entity,op,data)│
└────────────┬────────────┘
             ▼
┌─────────────────────────┐
│ PbiList / StoryPanel /  │  re-render op basis van Zustand state
│ TaskPanel re-render     │
└─────────────────────────┘
```

### Hydration en SSE-mount

De pagina is een Server Component die alle data parallel fetcht. Resultaten worden doorgegeven aan `BacklogHydrationWrapper` (client component), die:
1. `useBacklogStore.setInitialData(...)` aanroept op mount (eenmalig).
2. `useBacklogRealtime(productId)` mount — opent de SSE-stream.

Alle client-componenten (PbiList, StoryPanel, TaskPanel) lezen uitsluitend uit de Zustand store; ze accepteren geen data-props meer.

### backlog-store en applyChange

```ts
// stores/backlog-store.ts
applyChange(entity: 'pbi' | 'story' | 'task', op: 'I' | 'U' | 'D', data: Record<string, unknown>)
```

- **I (Insert):** voegt het nieuwe object toe aan de juiste sub-array
- **U (Update):** patcht de bestaande entry in-place via spread (`{ ...existing, ...data }`)
- **D (Delete):** filtert de entry weg op `id`; doorzoekt alle sub-arrays omdat de parent-ID afwezig kan zijn in het delete-payload

### Server-side filter (backlog)

`/api/realtime/backlog?product_id=...` filtert op:
- `entity ∈ {pbi, story, task}` — job/worker-events en questions worden genegeerd
- `product_id` matcht de query-param

Demo-gebruikers mogen lezen (geen 403). Overige lifecycle-kenmerken (heartbeat, hard-close, backoff, visibility-pause) zijn identiek aan de Solo SSE.

---

