* docs(cleanup): archief verouderde plannen, backlog en root-duplicaten
- 6 plans naar docs/old/plans/ (PBI-11/75/78, user-settings-store, Local github setup, lees-de-readme — laatste was verkeerde repo)
- docs/backlog/ naar docs/old/backlog/ (pre-MCP statische registry; live werk loopt via Scrum4Me-MCP)
- 6 root-level duplicaten naar docs/old/ (functional, {pbi,story,task}-dialog, product-backlog, backlog)
- 2 landing plans (niet uitgevoerd) krijgen archived: true frontmatter — blijven op plek maar uit INDEX
- scripts/generate-docs-index.mjs: skip docs/old/** + skip archived: true
- CLAUDE.md: rijen docs/backlog/, docs/plans/<key>-*.md, docs/manual/ weg; Track B-sectie verwijderd
- README.md / CHANGELOG.md / docs/plans/v1-readiness.md: link-fixes naar nieuwe locaties
Verify groen (lint + typecheck + 718 tests). docs/INDEX.md geregenereerd.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* docs(cleanup): registreer handmatige verplaatsingen en fix referenties
- 4 plans verplaatst naar docs/old/plans/ (M10-qr-pairing-login, auto-pr-deploy-sync, docs-restructure-ai-lookup, v1-readiness)
- 3 archive-plans verplaatst naar docs/old/plans/ (archive-map nu leeg)
- ST-1114-copilot-reviews + 3 research-docs naar nieuwe docs/Ideas/ map
- Duplicaat docs/old/2026-04-27-m8-realtime-solo.md verwijderd (origineel zit in docs/old/plans/)
- Link-fixes naar nieuwe locaties:
- CHANGELOG.md → docs/old/plans/v1-readiness.md
- docs/runbooks/deploy-control.md → docs/old/plans/auto-pr-deploy-sync.md (2x)
- docs/runbooks/worker-idempotency.md → docs/old/plans/auto-pr-deploy-sync.md
- docs/plans/docs-restructure-pbi-spec.md → docs/old/plans/docs-restructure-ai-lookup.md (4x text + 2x href)
- docs/INDEX.md geregenereerd (96 docs, was 100)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
195 lines
13 KiB
Markdown
195 lines
13 KiB
Markdown
---
|
||
title: "Realtime updates voor Solo Paneel (M8)"
|
||
status: done
|
||
audience: [maintainer, contributor]
|
||
language: nl
|
||
last_updated: 2026-05-03
|
||
applies_to: [M8]
|
||
---
|
||
|
||
# Plan: Realtime updates voor Solo Paneel (M8)
|
||
|
||
## Aanleiding
|
||
|
||
Wanneer Lars in zijn Solo Paneel werkt en parallel Claude Code (via MCP) of Codex aan dezelfde sprint sleutelt, ziet hij de gevolgen pas na een refresh. We willen DB-wijzigingen op `tasks`/`stories` van zijn actieve sprint live in beeld zien. Vraag van de gebruiker: "open een websocket".
|
||
|
||
## Transport-keuze — niet écht een WebSocket
|
||
|
||
Vercel-deploys ondersteunen geen stateful native WebSockets in serverless of Edge functions. Drie reële opties:
|
||
|
||
| Optie | Werkt op Vercel | Externe dienst | Latency | Complexiteit |
|
||
|---|---|---|---|---|
|
||
| **A. SSE + Postgres LISTEN/NOTIFY** | ✅ (Node runtime, streaming response) | nee | <100ms na DB-write | gemiddeld |
|
||
| B. SSE + polling 2–3s | ✅ | nee | 1–3s | laag |
|
||
| C. Pusher/Ably (echte WS) | ✅ | ja (gratis tier) | <50ms | laag, maar elke schrijver moet publishen |
|
||
|
||
**Voorgestelde keuze: A — SSE met Postgres LISTEN/NOTIFY.**
|
||
|
||
Reden:
|
||
- Eén bron van waarheid: de DB. Web-mutations, REST-API én MCP schrijven allemaal naar Postgres; een trigger NOTIFY't onafhankelijk van de schrijver. Geen coördinatie nodig met mcp.
|
||
- Geen externe dienst, geen extra dep, geen kosten erbij.
|
||
- Neon ondersteunt LISTEN/NOTIFY op directe verbindingen. `DIRECT_URL` is al geconfigureerd.
|
||
- Naar de client toe: éénrichtingsverkeer — server pusht events, client doet mutaties via bestaande Server Actions/REST. SSE volstaat dus; we hoeven geen full-duplex.
|
||
- Voor de gebruiker is het verschil onmerkbaar: realtime updates komen binnen, browsers ondersteunen `EventSource` native.
|
||
|
||
We kiezen B (polling) niet omdat het meer DB-load geeft en je Pusher-achtige latency niet haalt. We kiezen C niet vanwege coördinatieoverhead met de MCP-server (extra publish-step in mcp).
|
||
|
||
## Architectuur
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────┐
|
||
│ Postgres (Neon) │
|
||
│ ┌────────────────────────┐ │
|
||
│ │ TRIGGER on tasks │──► pg_notify('scrum4me_solo', payload_json) │
|
||
│ │ TRIGGER on stories │ │
|
||
│ └────────────────────────┘ │
|
||
└──────────────┬──────────────────────────────────────────────────────────┘
|
||
│ LISTEN scrum4me_solo
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────────┐
|
||
│ Next.js Node.js runtime route: /api/realtime/solo │
|
||
│ - auth via iron-session cookie │
|
||
│ - opent dedicated pg client (DIRECT_URL), LISTEN scrum4me_solo │
|
||
│ - filtert events: alleen tasks/stories in actieve sprint van een │
|
||
│ product waar user lid/eigenaar is, EN (assignee_id == user OR │
|
||
│ onbeklemtoonde unassigned-story-list) │
|
||
│ - stuurt SSE: data: {type, entity, id, fields} \n\n │
|
||
│ - heartbeat \n\n elke 25s │
|
||
│ - sluit zelf na 4 min (Vercel maxDuration safety); client reconnect │
|
||
└──────────────┬──────────────────────────────────────────────────────────┘
|
||
│ EventSource('/api/realtime/solo?product_id=...')
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────────┐
|
||
│ Browser — Solo Paneel │
|
||
│ - useSoloRealtime(productId) hook │
|
||
│ - reconnect met exponential backoff (max 30s) │
|
||
│ - Page Visibility API: close on hidden, reopen on visible │
|
||
│ - dispatcht naar solo-store: applyTaskUpdate, applyTaskCreate, │
|
||
│ applyTaskDelete, applyStoryUpdate (assignee/title/status) │
|
||
│ - reconcile-policy: skip update als optimistic in-flight is voor die │
|
||
│ task; anders server wint │
|
||
└─────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
## Filtering — wie krijgt welke events?
|
||
|
||
De trigger NOTIFY't elke task/story-mutatie globaal. De SSE-handler is verantwoordelijk voor toegangs- en relevantie-filtering:
|
||
|
||
1. **Toegang**: alleen events waarvan de gerelateerde `story.product_id` in `productAccessFilter(userId)` zit.
|
||
2. **Sprint-scope**: alleen events binnen de actieve sprint van het product dat in de query-parameter zit.
|
||
3. **Persoonlijke relevantie**: tasks waar `story.assignee_id == userId` (jouw kolommen), plus stories met `assignee_id == null` (de "claim me" lijst).
|
||
|
||
Per event extra DB-roundtrip om dit te checken zou duur zijn. Twee oplossingen, bij voorkeur (b):
|
||
|
||
(a) Triggerpayload bevat `product_id`, `sprint_id`, `assignee_id` zodat de handler in-memory kan filteren — geen extra DB-call.
|
||
|
||
(b) Cache in handler: bij connect resolveert de handler `userId → activeSprintId, productId, assignedStoryIds`. Bij elke notify checkt het de payload tegen die set; bij story-create/assignee-change herwoordt het de set on demand.
|
||
|
||
Strategie: combineer (a) trigger zet `product_id` en `assignee_id` in de payload + (b) handler cacht `(activeSprintId, productId, accessibleProducts)` voor de connectie-duur.
|
||
|
||
## Concrete implementatie — stories
|
||
|
||
### ST-801 Postgres LISTEN/NOTIFY-infrastructuur
|
||
- Migration `prisma/migrations/<ts>_add_solo_realtime_triggers/migration.sql`:
|
||
- `CREATE OR REPLACE FUNCTION notify_solo_change() RETURNS TRIGGER ...` — bouwt JSON met `op` (`INSERT`/`UPDATE`/`DELETE`), `entity` (`task`/`story`), `id`, `product_id`, `sprint_id`, `assignee_id`, `fields` (alleen gewijzigde kolommen bij UPDATE)
|
||
- Triggers `AFTER INSERT OR UPDATE OR DELETE ON tasks`, idem op `stories`
|
||
- Pas `prisma migrate deploy` toe (idempotent, geen schema-wijziging dus geen TS-impact)
|
||
- Done when: `psql $DIRECT_URL -c "LISTEN scrum4me_solo;"` toont een payload bij UI-mutatie
|
||
|
||
### ST-802 SSE-route `/api/realtime/solo`
|
||
- Bestand `app/api/realtime/solo/route.ts`, `runtime: 'nodejs'`, `maxDuration: 300`
|
||
- Gebruikt `pg.Client` (niet de Prisma adapter — directe `LISTEN`-verbinding)
|
||
- Auth via iron-session, 401 zonder cookie
|
||
- Query-parameter `product_id`, 403 zonder access
|
||
- Resolveert active sprint id eenmalig; cachet die in connection-scope
|
||
- `ReadableStream` met heartbeat-interval 25s, hard close na 240s
|
||
- Filter per event op `product_id == requested && (assignee_id == userId || (entity == 'story' && assignee_id == null))`
|
||
- Logged via `console.error` bij pg-disconnect
|
||
- Done when: handmatig met `curl -N` op localhost krijg je events binnen 1s na een UI-mutatie
|
||
|
||
### ST-803 Client hook `useSoloRealtime(productId)`
|
||
- `lib/realtime/use-solo-realtime.ts` (client-only)
|
||
- Opent `EventSource('/api/realtime/solo?product_id=' + productId)`
|
||
- Reconnect: exponential backoff start 1s → 30s, reset op succesvolle connect
|
||
- Page Visibility: `document.visibilityState === 'hidden'` → close; bij visible → reopen
|
||
- Cleanup op unmount
|
||
- Dispatcht events naar solo-store via nieuwe acties (zie ST-804)
|
||
- Done when: tab wisselen sluit/opent connectie zichtbaar in DevTools Network
|
||
|
||
### ST-804 Solo-store realtime-acties
|
||
- Uitbreiden `stores/solo-store.ts`:
|
||
- `applyTaskUpdate(taskId, fields)` — merge in tasks-record; skip als `pendingOps[taskId]` set is
|
||
- `applyTaskCreate(task)` — alleen als de task in de eigen kolommen hoort (assignee_id == userId)
|
||
- `applyTaskDelete(taskId)`
|
||
- `applyStoryAssignment(storyId, assigneeId)` — re-fetch unassigned-list (kleine GET) of ontvang als deel van payload
|
||
- `markPending(taskId)`/`clearPending(taskId)` — optimistic-flow markeert mutaties die we zelf doen, zodat we de echo van onze eigen NOTIFY niet dubbel verwerken
|
||
- Done when: unit-test op solo-store met simulated events laat juiste state zien
|
||
|
||
### ST-805 Wire-up in SoloBoard
|
||
- `components/solo/solo-board.tsx`: roep `useSoloRealtime(productId)` aan na `useEffect`-init van tasks
|
||
- Klein "live" / "verbinden..." status-indicator (status uit hook): groene stip / pulserende grijze stip
|
||
- Toast bij langer dan 5s disconnected
|
||
- Done when: open Solo paneel in twee tabs, mutate task in tab A, zie status flippen in tab B binnen 1–2s zonder refresh
|
||
|
||
### ST-806 Documentatie + acceptatietest
|
||
- Update `docs/architecture.md`: nieuwe sectie "Realtime updates" met diagram en filtering-regels
|
||
- Update `CLAUDE.md`: vermelding dat Solo Paneel realtime is + dat MCP-writes vanzelf doorkomen
|
||
- Update `docs/api/rest-contract.md`: korte note over `/api/realtime/solo` (Bearer auth, SSE format)
|
||
- E2E-acceptatie: lijst van scenario's (zelfde gebruiker twee tabs, MCP-write, REST-write, story-claim, network-flap) handmatig getest
|
||
- Done when: scenario's lopen door zonder onverwachte gedragingen
|
||
|
||
## Backlog-edits
|
||
|
||
In `docs/backlog/index.md`:
|
||
|
||
1. **Milestone-overzicht** — rij toevoegen onder M7:
|
||
```
|
||
| M8: Realtime Solo Paneel | Live updates voor stories/tasks via SSE+LISTEN/NOTIFY | ST-801 – ST-806 |
|
||
```
|
||
|
||
2. **Sectie M8** toevoegen na de M7-sectie, met de zes stories hierboven (ST-801..ST-806) inclusief "Done when"-criteria. Allemaal `[ ]` (nog niet gestart).
|
||
|
||
## Wijzigingen elders
|
||
|
||
- `.env.example` blijft ongewijzigd (DIRECT_URL stond er al)
|
||
- `docs/architecture.md` — sectie "Realtime updates" met diagram en regel "alle UPDATE-triggers zitten op tasks/stories; nieuwe entiteiten erbij vragen om uitbreiding van de trigger-functie"
|
||
- Geen wijziging in `lib/code.ts` of `lib/code-server.ts` — dit is server-only realtime
|
||
- Schema-drift agent in mcp pikt de migratie automatisch op (geen Prisma-modelwijziging maar wel een nieuwe migratie); typecheck blijft groen omdat we geen Prisma Client-wijziging hebben
|
||
|
||
## Risico's en mitigaties
|
||
|
||
| Risico | Mitigatie |
|
||
|---|---|
|
||
| Vercel sluit Node-route na maxDuration | Hard-close server-side bij 240s + automatische client-reconnect; gebruiker merkt dit niet |
|
||
| Echo van eigen optimistic mutation | `markPending`/`clearPending` in solo-store; skip als `pendingOps[taskId]` set is |
|
||
| Connection leaks (open `pg.Client`'s) | `req.signal.addEventListener('abort')` cleanup; bij Edge cold-start sluit Vercel zelf |
|
||
| Trigger overhead op writes | Triggers zijn lichtgewicht (één pg_notify call); meet bij rollout |
|
||
| Oude pg_notify payloads >8kb | Zorg dat we alleen primitives (id, status, sort_order, etc.) sturen — geen description/implementation_plan in de payload, daar is een refetch voor |
|
||
| Test-DB heeft geen triggers | Migratie automatisch toegepast in CI (Prisma migrate deploy); bestaande tests blijven groen |
|
||
| MCP-server schema-sync detecteert migratie als drift | False alarm — wekelijkse cron rapporteert "schema-prisma diff", maar typecheck blijft groen omdat het alleen migratie-SQL is. Beoordeel handmatig bij rapport |
|
||
|
||
## Wat dit NIET oplost
|
||
|
||
- Realtime in Sprint Backlog of Product Backlog — alleen Solo Paneel
|
||
- Conflict-merge bij gelijktijdige updates van twee gebruikers (last-write-wins blijft)
|
||
- Mobile pagina (out of scope desktop-first MVP)
|
||
- Audit-trail van wie wat wanneer veranderde (bestaat al via StoryLog)
|
||
|
||
## Volgorde van uitvoering
|
||
|
||
1. Branch `feat/m8-realtime-solo` van main
|
||
2. ST-801 (migratie + trigger) — commit, lokaal verifiëren met `psql LISTEN`
|
||
3. ST-802 (SSE-route) — commit, `curl -N` lokaal testen tegen lokale UI-mutatie
|
||
4. ST-803 (client hook) — commit
|
||
5. ST-804 (store-uitbreiding) — commit, met unit-test
|
||
6. ST-805 (wire-up + UI-indicator) — commit
|
||
7. ST-806 (docs + acceptatie) — commit
|
||
8. PR openen — Vercel preview-deploy laat realtime werken op preview-DB (mits trigger via `migrate deploy` mee)
|
||
9. Na review: merge
|
||
|
||
## Geschatte size
|
||
|
||
- ~6 stories, ~12–18 commits
|
||
- 1 migratie, 1 nieuwe route, 1 nieuwe hook, kleine store-uitbreiding, UI-indicator
|
||
- ~400 regels code + ~80 regels docs
|
||
- 1 PR
|