Scrum4Me/docs/architecture/project-structure.md
Madhura68 ac11483c68 feat(ops): Sentry error-monitoring (v1-readiness item 2)
Vier config-files volgens Next.js 15+ conventie:
- instrumentation.ts (root) → koppelt server/edge config aan runtime-hook
- instrumentation-client.ts → client-init + onRouterTransitionStart
- sentry.server.config.ts → node-runtime
- sentry.edge.config.ts → edge-runtime (proxy.ts)

next.config.ts gewrapped met withSentryConfig:
- Source-map-upload ALLEEN als SENTRY_AUTH_TOKEN gezet is
- Tunnel /monitoring omzeilt ad-blockers (*.sentry.io)
- Silent buiten CI

SDK is no-op zonder NEXT_PUBLIC_SENTRY_DSN — geen network/overhead in
dev of bij ontbrekende creds. Sample-rates conservatief: errors 100%,
performance 10% in productie / 100% in dev. Geen Replay (privacy-review
nodig + overkill voor MVP). sendDefaultPii uit.

.env.example gedocumenteerd; architectuur-doc bijgewerkt met nieuwe
sleutelbeslissing en file-tree-aanvulling. v1-readiness #1 verschoven
naar 'done', #2 hiermee in flight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:24:19 +02:00

26 KiB


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.

Keuze: BacklogSplitPane op app/(mobile)/m/products/[id]/page.tsx gebruikt cookieKey={\backlog-${id}-mobile`}(versus desktopbacklog-${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.

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

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

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

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

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

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

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