docs(cleanup): archief verouderde plannen, backlog en root-duplicaten (#191)

* 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>
This commit is contained in:
Janpeter Visser 2026-05-11 19:46:00 +02:00 committed by GitHub
parent d587be2fb3
commit b39c3ec2e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1068 additions and 49 deletions

View file

@ -1,371 +0,0 @@
# Advies — Zelf een Git-platform hosten naast of in plaats van GitHub
## Situatie
Je wilt onderzoeken of je lokaal of op een eigen server een Git repository-platform kunt draaien vergelijkbaar met GitHub.
In jouw situatie spelen mee:
- Next.js/Vercel apps
- AI-workers / automation
- batch processing
- deploy pipelines
- private code
- mogelijk draaien op NAS of VPS
- integratie met Claude Code / Codex / agents
Het antwoord is: ja, dit kan uitstekend.
---
# Architectuur-opties
## Optie 1 — Alleen een centrale Git remote
De lichtste oplossing.
Je draait alleen een zogenaamde "bare repo" op een Linux server.
### Voordelen
- extreem simpel
- weinig resources
- volledige controle
- SSH push/pull
### Nadelen
- geen webinterface
- geen PRs
- geen issues
- geen gebruikersbeheer
- geen CI/CD UI
### Setup
Server:
```bash
mkdir -p /srv/git/myapp.git
cd /srv/git/myapp.git
git init --bare
```
Client:
```bash
git remote add origin ssh://user@server:/srv/git/myapp.git
git push -u origin main
```
---
# Optie 2 — Self-hosted GitHub alternatief
Dit is meestal de beste keuze.
Software opties:
| Software | Omschrijving |
|---|---|
| Gitea | Lichtgewicht GitHub alternatief |
| Forgejo | Community fork van Gitea |
| GitLab | Zeer compleet maar zwaar |
| OneDev | Moderne alles-in-één oplossing |
---
# Aanbevolen keuze: Gitea
## Waarom
Voor jouw situatie is Gitea waarschijnlijk de beste balans tussen:
- eenvoud
- performance
- features
- beheerlast
Je krijgt:
- Git hosting
- web UI
- pull requests
- issues
- SSH support
- webhooks
- CI integratie
- Docker support
- private repos
- multi-user support
---
# Aanbevolen architectuur voor jouw setup
## Huidige richting
```text
MacBook
GitHub
Vercel deploy
```
## Uitgebreide AI workflow
```text
MacBook
Gitea / GitHub
↓ webhook
AI Worker Server
Repo clone
Code generatie
Commit + push
PR creation
Merge
Vercel deploy
```
---
# Beste strategie voor jouw situatie
## Advies: hybride model
Gebruik:
| Component | Platform |
|---|---|
| publieke repos | GitHub |
| deploys | Vercel |
| AI worker orchestration | eigen server |
| interne experimenten | Gitea |
| automation | self-hosted |
Waarom:
- GitHub ecosystem blijft beschikbaar
- recruiters herkennen GitHub
- Copilot integratie blijft optimaal
- minder beheer
- sneller stabiel
---
# Wanneer volledig self-hosted interessant wordt
Volledig self-hosted wordt interessant als:
- privacy belangrijk is
- AI agents autonoom moeten kunnen werken
- je volledige controle wilt
- je GitHub limieten wilt vermijden
- je meerdere workers wilt draaien
Dan bouw je:
```text
Gitea
+ Postgres
+ Docker Registry
+ CI Runners
+ Reverse Proxy
+ Backups
+ Monitoring
```
---
# Aanbevolen infrastructuur
## Lichtgewicht setup
### Hardware
- Synology NAS of mini-PC
- 816 GB RAM
- SSD opslag
### Software stack
| Component | Advies |
|---|---|
| OS | Ubuntu Server |
| Containers | Docker Compose |
| Git platform | Gitea |
| Reverse proxy | Traefik |
| Database | Postgres |
| SSL | Let's Encrypt |
| Deploys | Vercel |
---
# Docker Compose voorbeeld
```yaml
services:
gitea:
image: gitea/gitea:latest
container_name: gitea
ports:
- "3000:3000"
- "222:22"
volumes:
- ./gitea:/data
restart: always
```
Starten:
```bash
docker compose up -d
```
Daarna bereikbaar via:
```text
http://server-ip:3000
```
---
# Belangrijke aandachtspunten
## Backups
Bij self-hosting moet je zelf regelen:
- database backups
- repo backups
- disaster recovery
---
## Security
Je bent zelf verantwoordelijk voor:
- updates
- SSH security
- firewall
- SSL certificaten
- gebruikersbeheer
---
## CI/CD
GitHub Actions vervang je mogelijk door:
- Gitea Actions
- Drone CI
- Woodpecker CI
- self-hosted runners
---
# Integratie met jouw AI-worker ideeën
Dit sluit zeer goed aan op jouw eerdere ideeën:
- Neon database events
- worker servers
- auto-generated PRs
- selective deploys
- batch execution
Je kunt bijvoorbeeld:
1. story wordt aangemaakt
2. worker krijgt event via SSE/webhook
3. repo wordt gecloned
4. AI implementeert wijziging
5. commit + push
6. PR automatisch aangemaakt
7. review pipeline start
8. merge → deploy
Dit wordt veel eenvoudiger wanneer je volledige controle hebt over de Git infrastructuur.
---
# Concrete roadmap
## Fase 1 — huidige setup stabiliseren
Hou:
- GitHub
- Vercel
- Neon
Voeg toe:
- AI worker server
- webhooks
- automation pipeline
---
## Fase 2 — interne Git infrastructuur
Installeer:
- Gitea
- Docker
- Postgres
Gebruik dit voor:
- experimenten
- AI-generated branches
- interne repos
- automation testing
---
## Fase 3 — geavanceerde automation
Later toevoegen:
- self-hosted runners
- preview environments
- deploy approvals
- selective deployments
- agent orchestration
---
# Eindadvies
Voor jouw situatie:
## Niet meteen GitHub vervangen
Dat levert nu vooral extra beheerlast op.
## Wel nu al beginnen met:
- eigen AI worker server
- webhook automation
- lokale Git orchestration
- Gitea testomgeving
Dat sluit perfect aan op:
- Scrum4Me
- AI-assisted development
- batch story execution
- autonome pipelines

View file

@ -1,894 +0,0 @@
---
title: "M10 — Password-loze inlog via QR-pairing"
status: active
audience: [maintainer, contributor]
language: nl
last_updated: 2026-05-03
applies_to: [M10]
---
# M10 — Password-loze inlog via QR-pairing
Inloggen op een (publieke) desktop zonder wachtwoord: desktop toont QR, telefoon (al-ingelogd) scant en bevestigt expliciet, desktop is binnen 12 s ingelogd. Bouwt voort op M8 LISTEN/NOTIFY-infra met eigen channel `scrum4me_pairing`.
**Beveiligingsuitgangspunt:** geheim materiaal nooit in URL-paden, querystrings, access logs of browsergeschiedenis.
- `mobileSecret` reist alleen via QR-fragment (`#s=…`) → mobile `location.hash` → POST-body
- `desktopToken` reist alleen via HttpOnly cookie `s4m_pair` met `Path=/api/auth/pair`, `Max-Age=120`, `SameSite=Lax`
- Twee gescheiden hashes in DB scheiden mobiel-bewijs (`secret_hash`) van desktop-bewijs (`desktop_token_hash`)
Backlog-entries: zie [backlog.md § M10](../backlog/index.md#m10-password-loze-inlog-via-qr-pairing).
Functional spec: zie [functional.md § F-01b](../specs/functional.md#f-01b-inloggen-via-mobiel-qr-pairing).
**Implementatie-volgorde** (commit-strategy uit CLAUDE.md):
1. **DB-laag** — ST-1001 (schema + trigger)
2. **Auth helpers + sessie-uitbreiding** — ST-1002
3. **API-laag** — ST-1003 (start), ST-1004 (SSE), ST-1006 (claim)
4. **Server actions + mobile UI** — ST-1005
5. **Desktop UI** — ST-1007
6. **Documentatie + acceptatietest** — ST-1008
ST-1006 staat bij de API-laag (niet bij UI) omdat het een Route Handler is; ST-1005 levert tegelijk de server actions en de mobiele bevestigingspagina omdat die strak gekoppeld zijn.
---
## ST-1001 — LoginPairing schema + Postgres-trigger
**Bestanden**
- `prisma/schema.prisma` — nieuw model `LoginPairing` + back-relation op `User`
- `prisma/migrations/<timestamp>_add_login_pairing/migration.sql` — model + trigger
- `vendor/scrum4me`-submodule in repo `mcp` — schema-sync ná merge
**Stappen**
1. **Schema-uitbreiding**:
```prisma
model LoginPairing {
id String @id @default(cuid())
secret_hash String // sha256 hex van mobileSecret
desktop_token_hash String // sha256 hex van desktopToken (HttpOnly cookie)
status String // 'pending' | 'approved' | 'consumed' | 'cancelled'
user_id String?
user User? @relation(fields: [user_id], references: [id], onDelete: SetNull)
desktop_ua String? @db.VarChar(255)
desktop_ip String? @db.VarChar(45) // IPv6 max
created_at DateTime @default(now())
expires_at DateTime
approved_at DateTime?
consumed_at DateTime?
@@index([expires_at])
@@index([status, expires_at])
@@map("login_pairings")
}
```
Op `User`: `login_pairings LoginPairing[]` toevoegen.
2. **Migratie-SQL** voegt naast de tabel ook trigger toe (mirror van `notify_solo_change` in `prisma/migrations/20260426230316_add_solo_realtime_triggers/migration.sql`):
```sql
CREATE OR REPLACE FUNCTION notify_pairing_change() RETURNS trigger AS $$
DECLARE payload jsonb;
BEGIN
payload := jsonb_build_object(
'op', CASE TG_OP WHEN 'INSERT' THEN 'I' WHEN 'UPDATE' THEN 'U' ELSE 'D' END,
'pairing_id', COALESCE(NEW.id, OLD.id),
'status', COALESCE(NEW.status, OLD.status)
);
PERFORM pg_notify('scrum4me_pairing', payload::text);
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER login_pairings_notify
AFTER INSERT OR UPDATE ON login_pairings
FOR EACH ROW EXECUTE FUNCTION notify_pairing_change();
```
3. `npx prisma migrate dev --name add_login_pairing`.
**Aandachtspunten**
- `desktop_ip` houdt op 45 tekens om IPv6 te accommoderen (`xxxx:xxxx:…:255.255.255.255`).
- Geen index op `user_id` nodig voor v1 — er is geen lookup-pad "geef alle pairings van user X" (komt pas bij remote-revoke in M+1).
- Trigger emit ook bij DELETE niet nodig — pairings worden niet gedelete'd, ze gaan naar `consumed`/`cancelled`.
- `vendor/scrum4me`-submodule in mcp moet ná merge op `main` direct gesynced worden, anders breekt de wekelijkse drift-check (`trig_015FFUnxjz9WMuhhWNGBQKFD`). Dit was ook een aandachtspunt bij ST-901.
**Verificatie**
- `npx prisma migrate dev` slaagt
- `npx prisma validate` zonder fouten
- `psql $DIRECT_URL -c "LISTEN scrum4me_pairing;"` toont payload bij `INSERT INTO login_pairings(...) VALUES(...)`
- `prisma studio` toont tabel met beide hash-kolommen `NOT NULL`
---
## ST-1002 — Pairing-helpers + sessie-uitbreiding + pre-auth-cookie
**Bestanden**
- `lib/auth/pairing.ts` — nieuw, secret/token-generatie en hash-helpers
- `lib/auth/pair-cookie.ts` — nieuw, set/read/clear van `s4m_pair`-cookie
- `lib/session.ts``SessionData` uitbreiden met `paired` en `pairedExpiresAt`
- `app/(app)/layout.tsx` — extra guard op vervallen paired-sessie
- `__tests__/lib/auth/pairing.test.ts` — nieuw
**Stappen**
1. **`lib/auth/pairing.ts`**:
```ts
import { createHash, randomBytes, timingSafeEqual } from 'crypto'
export function generateMobileSecret(): string {
return randomBytes(32).toString('base64url')
}
export function generateDesktopToken(): string {
return randomBytes(32).toString('base64url')
}
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
export function verifyToken(token: string, hash: string): boolean {
const a = Buffer.from(hashToken(token), 'hex')
const b = Buffer.from(hash, 'hex')
if (a.length !== b.length) return false
return timingSafeEqual(a, b)
}
```
Twee aparte generators (niet één functie met arg) voorkomen dat dezelfde geheim per ongeluk twee keer wordt gebruikt.
2. **`lib/auth/pair-cookie.ts`**:
```ts
import { cookies } from 'next/headers'
const COOKIE_NAME = 's4m_pair'
const MAX_AGE = 120 // 2 min, gelijk aan pending-TTL van pairing
export async function setPairCookie(desktopToken: string) {
const jar = await cookies()
jar.set(COOKIE_NAME, desktopToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/api/auth/pair',
maxAge: MAX_AGE,
})
}
export async function readPairCookie(): Promise<string | null> {
const jar = await cookies()
return jar.get(COOKIE_NAME)?.value ?? null
}
export async function clearPairCookie() {
const jar = await cookies()
jar.delete({ name: COOKIE_NAME, path: '/api/auth/pair' })
}
```
`Path=/api/auth/pair` zorgt dat de cookie alleen naar pair-endpoints wordt gestuurd — niet naar elke route.
3. **`lib/session.ts`** — `SessionData` interface:
```ts
export interface SessionData {
userId: string
isDemo: boolean
paired?: boolean // true als sessie is aangemaakt via QR-pairing
pairedExpiresAt?: number // unix ms
}
```
Bestaande sessies blijven werken — beide velden zijn optioneel.
4. **`app/(app)/layout.tsx`** — guard ná de bestaande `if (!session.userId) redirect('/login')`:
```ts
if (session.paired && session.pairedExpiresAt && session.pairedExpiresAt < Date.now()) {
session.destroy()
redirect('/login?notice=paired-expired')
}
```
Hergebruikt het bestaande `notice`-querystring-patroon van M9's `<NoticeToast />` voor de melding "Je sessie is verlopen, log opnieuw in".
5. **Tests**`__tests__/lib/auth/pairing.test.ts`:
- `generateMobileSecret()` produceert 43-karakter base64url (32 bytes)
- `hashToken` is deterministisch
- `verifyToken` is true voor geldig paar, false voor ongeldig
- Twee verschillende `generateMobileSecret()`-calls geven verschillende waardes
- Cookie helpers: HttpOnly bit gezet (via Next.js cookie-store mock)
**Aandachtspunten**
- Geen middleware nodig voor de paired-expiry-check; layout-guard is voldoende. Middleware komt pas in beeld als `proxy.ts` herzien wordt (uit scope hier).
- `cookies().delete({ name, path })` moet **dezelfde path** specificeren als bij set, anders blijft de cookie staan.
- `crypto.randomBytes` is sync en blocking — voor 32 bytes ruim < 1ms; geen async-variant nodig.
**Verificatie**
- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen
- Handmatig: in DevTools Application-tab is de cookie zichtbaar als HttpOnly + Path scoped
- `document.cookie` op de pagina laat de cookie *niet* zien
---
## ST-1003 — `POST /api/auth/pair/start` (anon, sets pre-auth cookie)
**Bestanden**
- `app/api/auth/pair/start/route.ts` — nieuw
- `lib/rate-limit.ts` — checken of bestaand (uit ST-608); anders helper toevoegen
- `__tests__/api/pair-start.test.ts` — nieuw
**Stappen**
1. Route Handler (vrij van `authenticateApiRequest` — dit is anon):
```ts
import { NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
import {
generateMobileSecret, generateDesktopToken, hashToken,
} from '@/lib/auth/pairing'
import { setPairCookie } from '@/lib/auth/pair-cookie'
import { rateLimit } from '@/lib/rate-limit'
export const runtime = 'nodejs'
const PENDING_TTL_MS = 2 * 60 * 1000
export async function POST(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? null
const ua = request.headers.get('user-agent')?.slice(0, 255) ?? null
// Rate-limit per IP — 10/min (zelfde patroon als ST-608)
const limited = await rateLimit(`pair-start:${ip ?? 'anon'}`, 10, 60_000)
if (limited) {
return Response.json({ error: 'Te veel verzoeken' }, { status: 429 })
}
const mobileSecret = generateMobileSecret()
const desktopToken = generateDesktopToken()
const pairing = await prisma.loginPairing.create({
data: {
secret_hash: hashToken(mobileSecret),
desktop_token_hash: hashToken(desktopToken),
status: 'pending',
desktop_ua: ua,
desktop_ip: ip,
expires_at: new Date(Date.now() + PENDING_TTL_MS),
},
select: { id: true, expires_at: true },
})
await setPairCookie(desktopToken)
const origin = request.nextUrl.origin
const qrUrl = `${origin}/m/pair#id=${pairing.id}&s=${mobileSecret}`
return Response.json({
pairingId: pairing.id,
mobileSecret,
expiresAt: pairing.expires_at.toISOString(),
qrUrl,
})
}
```
2. **Rate-limit helper** — als `lib/rate-limit.ts` nog niet bestaat:
- In-memory Map keyed op `key`, met sliding-window van timestamps; thread-safe genoeg voor v1 single-instance Vercel Functions.
- Wordt ook door `actions/auth.ts` (login) gebruikt; verifieer met grep of die al bestaat — zo ja, hergebruik exact.
3. **Tests**`__tests__/api/pair-start.test.ts`:
- 200 response bevat `pairingId`, `mobileSecret`, `qrUrl` met fragment-syntax (`#id=…&s=…`)
- `Set-Cookie`-header bevat `s4m_pair=...; HttpOnly; SameSite=Lax; Path=/api/auth/pair; Max-Age=120`
- Database-rij heeft `secret_hash`, `desktop_token_hash`, geen plaintext
- 11e call binnen 60s → 429
- `desktop_ua` en `desktop_ip` worden opgeslagen bij aanwezigheid, anders `null`
**Aandachtspunten**
- `mobileSecret` mag in de JSON-response — die is HTTPS-encrypted en wordt niet door browsers naar logs geschreven. Kritisch is dat het niet in **request**-URLs of **server**-toegangslogs belandt.
- `qrUrl` gebruikt `#` (fragment), niet `?`. Browsers strippen het fragment voordat ze de mobile pair-page ophalen — maar dat is uitvoerig getest in ST-1005 server-side: de page leest de URL niet, alleen de client component leest `window.location.hash`.
- Geen idempotency-key nodig: een tweede start vanuit dezelfde tab maakt simpelweg een nieuwe pairing en cookie aan; oude cookie wordt overschreven.
- Vercel-edge-of-fluid: `runtime: 'nodejs'` expliciet (niet 'edge') want we gebruiken `crypto.randomBytes`.
**Verificatie**
- `curl -i -X POST http://localhost:3000/api/auth/pair/start --cookie-jar /tmp/jar` retourneert JSON + `Set-Cookie`
- Body bevat `qrUrl` met `#id=…&s=…`
- Rij in `login_pairings` heeft beide hashes ingevuld
- Tests groen
---
## ST-1004 — SSE-route `/api/auth/pair/stream/[pairingId]` (cookie-auth)
**Bestanden**
- `app/api/auth/pair/stream/[pairingId]/route.ts` — nieuw
- `__tests__/api/pair-stream.test.ts` — nieuw (auth-test, geen full SSE-test)
**Stappen**
1. **Routebestand** — exacte structuur uit `app/api/realtime/solo/route.ts` (incl. heartbeat, hard-close, abort-handler), maar:
- Geen iron-session check; auth via `s4m_pair`-cookie:
```ts
const desktopToken = await readPairCookie()
if (!desktopToken) return Response.json({ error: 'Geen pairing-cookie' }, { status: 401 })
const pairing = await prisma.loginPairing.findUnique({
where: { id: pairingId },
select: { desktop_token_hash: true, status: true, expires_at: true },
})
if (!pairing) return Response.json({ error: 'Pairing niet gevonden' }, { status: 404 })
if (pairing.expires_at < new Date()) return Response.json({ error: 'Pairing verlopen' }, { status: 410 })
if (!verifyToken(desktopToken, pairing.desktop_token_hash)) {
return Response.json({ error: 'Ongeldige cookie' }, { status: 401 })
}
```
- Channel = `'scrum4me_pairing'`
- Filter `shouldEmit`: `payload.pairing_id === pairingId`
- Auto-close ook bij payload `status``{'consumed', 'cancelled'}` — niet alleen na 240 s
- Geen sprint-resolve, geen userId-filter — eenvoudiger dan solo-route
2. **Initial-state-event** vlak na verbinden: query de pairing-status één keer uit DB en stuur als `event: state\ndata: {"status":"pending"}` zodat de desktop niet hoeft te wachten op het eerste pg_notify (handig als pairing al `approved` is voordat de SSE opent — race-conditie).
3. **Tests**:
- GET zonder cookie → 401
- GET met cookie maar onbekende `pairingId` → 404
- GET met verlopen pairing → 410
- GET met cookie die hasht naar een andere `desktop_token_hash` → 401
Geen full-stream-test — vereist een Postgres-event-mock die het niet waard is voor v1. Manuele test dekt dit (zie verificatie).
**Aandachtspunten**
- `pairingId` in het URL-pad is OK — niet vertrouwelijk. De cookie is het bewijs.
- `EventSource` op de client kan geen custom headers; cookie is daarom de enige praktische auth-methode. `withCredentials: true` op de client is verplicht.
- Bij browser-tab-sluiten wordt `request.signal.abort` getriggered → cleanup zoals in solo-route.
- Vermijd Prisma in de notification-handler; gebruik alleen `pg.Client` zoals solo-route. Status-check hierboven is de enige Prisma-call (vóór de stream start).
**Verificatie**
- `curl -N --cookie /tmp/jar http://localhost:3000/api/auth/pair/stream/<id>` blijft open en print `: heartbeat` elke 25 s
- Andere terminal: `psql $DIRECT_URL -c "UPDATE login_pairings SET status='approved' WHERE id='<id>'"` → curl-uitvoer toont event binnen 1 s
- Manuele 401-test: `curl -N` zonder cookie → JSON 401
---
## ST-1005 — Server actions + mobiele bevestigingspagina
**Bestanden**
- `actions/pairing.ts` — nieuw, drie Server Actions
- `app/(app)/m/pair/page.tsx` — nieuw, Server Component
- `app/(app)/m/pair/pair-confirmation.tsx` — nieuw, Client Component
- `__tests__/actions/pairing.test.ts` — nieuw
**Stappen**
1. **`actions/pairing.ts`** — volgt `docs/patterns/server-action.md`:
```ts
'use server'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { getSession } from '@/lib/auth'
import { verifyToken } from '@/lib/auth/pairing'
const inputSchema = z.object({
pairingId: z.string().cuid(),
mobileSecret: z.string().min(40), // base64url van 32 bytes ≈ 43 chars
})
export async function getPairingForApproval(pairingId: string, mobileSecret: string) {
const session = await getSession()
if (!session.userId) return { ok: false, error: 'Niet ingelogd' } as const
const parsed = inputSchema.safeParse({ pairingId, mobileSecret })
if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' } as const
const pairing = await prisma.loginPairing.findUnique({
where: { id: pairingId },
select: {
status: true, expires_at: true, secret_hash: true,
desktop_ua: true, desktop_ip: true,
},
})
if (!pairing) return { ok: false, error: 'Pairing niet gevonden' } as const
if (pairing.expires_at < new Date()) return { ok: false, error: 'Pairing verlopen' } as const
if (pairing.status !== 'pending') return { ok: false, error: 'Pairing al afgehandeld' } as const
if (!verifyToken(mobileSecret, pairing.secret_hash)) {
return { ok: false, error: 'Ongeldig pairing-geheim' } as const
}
const me = await prisma.user.findUnique({
where: { id: session.userId },
select: { username: true },
})
return {
ok: true,
desktop_ua: pairing.desktop_ua,
desktop_ip: pairing.desktop_ip,
username: me?.username ?? '',
} as const
}
export async function approvePairing(pairingId: string, mobileSecret: string) {
const session = await getSession()
if (!session.userId) return { ok: false, error: 'Niet ingelogd' } as const
if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' } as const
const parsed = inputSchema.safeParse({ pairingId, mobileSecret })
if (!parsed.success) return { ok: false, error: 'Ongeldige invoer' } as const
const pairing = await prisma.loginPairing.findUnique({
where: { id: pairingId },
select: { status: true, expires_at: true, secret_hash: true },
})
if (!pairing) return { ok: false, error: 'Pairing niet gevonden' } as const
if (pairing.expires_at < new Date()) return { ok: false, error: 'Pairing verlopen' } as const
if (pairing.status !== 'pending') return { ok: false, error: 'Pairing al afgehandeld' } as const
if (!verifyToken(mobileSecret, pairing.secret_hash)) {
return { ok: false, error: 'Ongeldig pairing-geheim' } as const
}
const APPROVED_TTL_MS = 5 * 60 * 1000
await prisma.loginPairing.update({
where: { id: pairingId },
data: {
status: 'approved',
user_id: session.userId,
approved_at: new Date(),
expires_at: new Date(Date.now() + APPROVED_TTL_MS),
},
})
// Trigger emit pg_notify automatisch — geen revalidatePath nodig
return { ok: true } as const
}
export async function cancelPairing(pairingId: string, mobileSecret: string) {
// Vergelijkbaar met approvePairing maar status='cancelled', geen user_id; demo mag annuleren.
}
```
2. **`app/(app)/m/pair/page.tsx`** — Server Component, achter bestaande `(app)/layout.tsx` auth-guard:
```tsx
import { PairConfirmation } from './pair-confirmation'
export default function PairPage() {
return (
<main className="container mx-auto max-w-md py-12">
<h1 className="text-h2">Inloggen op desktop</h1>
<p className="text-muted-foreground mt-2">
Bevestig hieronder dat je wilt inloggen op het apparaat dat de QR-code toont.
</p>
<PairConfirmation />
</main>
)
}
```
Geen searchParams! De page leest de URL überhaupt niet — alleen het client-island doet dat client-side via `window.location.hash`.
3. **`app/(app)/m/pair/pair-confirmation.tsx`** — Client Component:
```tsx
'use client'
import { useEffect, useState, useTransition } from 'react'
import { Button } from '@/components/ui/button'
import { toast } from 'sonner'
import { getPairingForApproval, approvePairing, cancelPairing } from '@/actions/pairing'
type State =
| { kind: 'loading' }
| { kind: 'invalid'; error: string }
| { kind: 'ready'; pairingId: string; secret: string; ua: string | null; ip: string | null; username: string }
| { kind: 'success' }
| { kind: 'error'; error: string }
function parseHash(): { id: string; s: string } | null {
if (typeof window === 'undefined') return null
const hash = window.location.hash.replace(/^#/, '')
const params = new URLSearchParams(hash)
const id = params.get('id'); const s = params.get('s')
return id && s ? { id, s } : null
}
export function PairConfirmation() {
const [state, setState] = useState<State>({ kind: 'loading' })
const [pending, startTransition] = useTransition()
useEffect(() => {
const parsed = parseHash()
if (!parsed) {
setState({ kind: 'invalid', error: 'Ongeldige pairing-link' })
return
}
getPairingForApproval(parsed.id, parsed.s).then((res) => {
if (!res.ok) setState({ kind: 'invalid', error: res.error })
else setState({
kind: 'ready',
pairingId: parsed.id, secret: parsed.s,
ua: res.desktop_ua, ip: res.desktop_ip, username: res.username,
})
})
}, [])
function onApprove() {
if (state.kind !== 'ready') return
startTransition(async () => {
const res = await approvePairing(state.pairingId, state.secret)
if (!res.ok) { toast.error(res.error); return }
// Wist secret uit URL zodat back/forward 'm niet onthult
if (typeof window !== 'undefined') {
window.history.replaceState(null, '', window.location.pathname)
}
setState({ kind: 'success' })
})
}
function onCancel() {
if (state.kind !== 'ready') return
startTransition(async () => {
await cancelPairing(state.pairingId, state.secret)
setState({ kind: 'invalid', error: 'Pairing geannuleerd' })
})
}
// Render-logica per state — kort:
// loading → spinner
// invalid → foutmelding + link "Terug naar dashboard"
// ready → kaart met UA/IP/username + Bevestig/Annuleer
// success → "Klaar — je kunt deze tab sluiten"
// … (volledige JSX in implementation)
}
```
4. **Tests**`__tests__/actions/pairing.test.ts`:
- `getPairingForApproval` met `pending`-pairing → `ok: true` + ua/ip/username
- `getPairingForApproval` met al-`approved``ok: false`
- `getPairingForApproval` met verlopen → `ok: false`
- `getPairingForApproval` met verkeerd secret → `ok: false`
- `approvePairing` als demo-user → `ok: false` + DB onveranderd
- `approvePairing` happy path → status `approved`, `user_id` gezet, `expires_at` bumped
- `cancelPairing` happy path → status `cancelled`
**Aandachtspunten**
- `getPairingForApproval` mág door demo-users worden aangeroepen (om iets te zien); alleen `approvePairing` blokkeert demo's.
- `pair-confirmation.tsx` gebruikt **geen** `useSearchParams` — die kan in Next.js 16 hash niet lezen, en we willen het ook niet via routing zien.
- Na approve `window.history.replaceState` zonder de `#hash` zorgt dat browser-back de secret niet opnieuw onthult.
- `revalidatePath` in `approvePairing` is niet nodig — de desktop hoort het via SSE, en de mobiele page heeft geen server-state om te ververversen.
**Verificatie**
- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen
- Handmatig: log in op telefoon-emulator → bezoek `/m/pair#id=…&s=…` → kaart toont UA/IP → Bevestig → succes-state, URL is `/m/pair` zonder hash
---
## ST-1006 — `POST /api/auth/pair/claim` (cookie-auth, schrijft iron-session)
**Bestanden**
- `app/api/auth/pair/claim/route.ts` — nieuw
- `__tests__/api/pair-claim.test.ts` — nieuw
**Stappen**
1. Route Handler:
```ts
import { NextRequest } from 'next/server'
import { getIronSession } from 'iron-session'
import { cookies } from 'next/headers'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
import { hashToken } from '@/lib/auth/pairing'
import { readPairCookie, clearPairCookie } from '@/lib/auth/pair-cookie'
export const runtime = 'nodejs'
const PAIRED_TTL_MS = 8 * 60 * 60 * 1000 // 8 uur
export async function POST(request: NextRequest) {
const desktopToken = await readPairCookie()
if (!desktopToken) return Response.json({ error: 'Geen pairing-cookie' }, { status: 401 })
const body = await request.json().catch(() => null) as { pairingId?: string } | null
const pairingId = body?.pairingId
if (!pairingId) return Response.json({ error: 'pairingId vereist' }, { status: 400 })
const desktopTokenHash = hashToken(desktopToken)
// Atomic: WHERE status='approved' AND token-hash + expiry → consumed, RETURNING user_id
const updated = await prisma.loginPairing.updateMany({
where: {
id: pairingId,
status: 'approved',
desktop_token_hash: desktopTokenHash,
expires_at: { gt: new Date() },
},
data: { status: 'consumed', consumed_at: new Date() },
})
if (updated.count !== 1) {
// Was het wél een geldige cookie maar al consumed? → 410. Anders → 401.
const exists = await prisma.loginPairing.findFirst({
where: { id: pairingId, desktop_token_hash: desktopTokenHash },
select: { status: true, expires_at: true },
})
await clearPairCookie()
if (!exists) return Response.json({ error: 'Ongeldig' }, { status: 401 })
if (exists.status === 'consumed') return Response.json({ error: 'Al gebruikt' }, { status: 410 })
return Response.json({ error: 'Niet beschikbaar' }, { status: 410 })
}
const pairing = await prisma.loginPairing.findUnique({
where: { id: pairingId },
select: { user_id: true, user: { select: { is_demo: true } } },
})
if (!pairing?.user_id) {
await clearPairCookie()
return Response.json({ error: 'Pairing zonder user' }, { status: 500 })
}
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
session.userId = pairing.user_id
session.isDemo = pairing.user?.is_demo ?? false
session.paired = true
session.pairedExpiresAt = Date.now() + PAIRED_TTL_MS
await session.save()
await clearPairCookie()
return Response.json({ ok: true })
}
```
2. **Tests**:
- 200 + iron-session cookie + clear `s4m_pair` na succes
- 410 op tweede claim met dezelfde cookie
- 401 zonder cookie
- 401 met cookie die hasht naar andere pairing
- paired-sessie bevat `paired: true` en `pairedExpiresAt` rond `now + 8h`
**Aandachtspunten**
- `updateMany` gebruiken (niet `update`) want we hebben een composite WHERE met `status` + `desktop_token_hash` + `expires_at`; `update` kan alleen op unique keys.
- Het WHERE-criterium garandeert atomiciteit: PostgreSQL UPDATE met meerdere predicates is row-level locked; concurrent dubbele claim resulteert in `count = 1` voor één caller en `count = 0` voor de ander.
- `clearPairCookie` ook bij faalpaden, anders blijft 'm na expiry hangen (cosmetisch — `Max-Age=120` regelt het ook).
- De `session.isDemo` check overneemt: als de approver een demo-user is — wat ST-1005 al blokkeert — komen we hier niet eens, maar `is_demo` doorzetten is een extra vangnet.
**Verificatie**
- Handmatig: na approve in mobiele tab, POST naar `/api/auth/pair/claim` met de cookie van start → 200 + `Set-Cookie: session=...`
- `curl -X POST` zonder cookie → 401
- Tweede claim → 410
---
## ST-1007 — Desktop UI: QR-render + SSE-listener op `/login`
**Bestanden**
- `app/login/page.tsx` — bestaand, knop "Inloggen via mobiel" toevoegen
- `app/login/qr-login-button.tsx` — nieuw, Client Component
- `package.json``qrcode.react` toevoegen
- `__tests__/components/qr-login-button.test.tsx` — minimale render-test
**Stappen**
1. **Dependency**: `npm install qrcode.react` — direct in `dependencies` per CLAUDE.md-conventie.
2. **`qr-login-button.tsx`**:
```tsx
'use client'
import { useEffect, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { QRCodeSVG } from 'qrcode.react'
import { Button } from '@/components/ui/button'
import { toast } from 'sonner'
type Phase =
| { kind: 'idle' }
| { kind: 'starting' }
| { kind: 'showing'; pairingId: string; qrUrl: string; expiresAt: number }
| { kind: 'expired'; pairingId: string }
| { kind: 'claiming' }
export function QrLoginButton() {
const router = useRouter()
const [phase, setPhase] = useState<Phase>({ kind: 'idle' })
const sseRef = useRef<EventSource | null>(null)
async function start() {
setPhase({ kind: 'starting' })
const res = await fetch('/api/auth/pair/start', {
method: 'POST', credentials: 'same-origin',
})
if (!res.ok) { toast.error('Kon QR-code niet aanmaken'); setPhase({ kind: 'idle' }); return }
const data = await res.json() as { pairingId: string; qrUrl: string; expiresAt: string }
setPhase({
kind: 'showing',
pairingId: data.pairingId,
qrUrl: data.qrUrl,
expiresAt: new Date(data.expiresAt).getTime(),
})
}
// SSE-koppeling
useEffect(() => {
if (phase.kind !== 'showing') return
const es = new EventSource(`/api/auth/pair/stream/${phase.pairingId}`, {
withCredentials: true,
})
sseRef.current = es
es.addEventListener('message', async (ev) => {
const data = JSON.parse(ev.data) as { status?: string }
if (data.status === 'approved') {
es.close()
setPhase({ kind: 'claiming' })
const res = await fetch('/api/auth/pair/claim', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pairingId: phase.pairingId }),
})
if (!res.ok) {
toast.error('Inloggen mislukt')
setPhase({ kind: 'idle' }); return
}
router.push('/dashboard')
}
})
es.addEventListener('error', () => { /* silent — laat reconnecten */ })
return () => { es.close() }
}, [phase, router])
// Aftellende timer + auto-expire
useEffect(() => {
if (phase.kind !== 'showing') return
const t = setInterval(() => {
if (Date.now() > phase.expiresAt) {
sseRef.current?.close()
setPhase({ kind: 'expired', pairingId: phase.pairingId })
clearInterval(t)
}
}, 1000)
return () => clearInterval(t)
}, [phase])
// Render: knop / QR + countdown / "Vernieuwen" — JSX hier weggelaten voor brevity
}
```
3. **`app/login/page.tsx`** — knop toevoegen onder of naast het wachtwoord-formulier:
```tsx
<div className="my-4 flex items-center gap-2">
<div className="border-border h-px flex-1 border-t" />
<span className="text-muted-foreground text-sm">of</span>
<div className="border-border h-px flex-1 border-t" />
</div>
<QrLoginButton />
```
MD3-tokens uit `docs/design/styling.md`; geen willekeurige Tailwind-kleuren.
4. **A11y**: QR-component krijgt `aria-label="QR-code voor mobiel inloggen"` en de URL wordt visueel als kopieer-bare tekst onder de QR getoond zodat screenreaders en gebruikers met cameraproblemen de URL handmatig kunnen openen.
**Aandachtspunten**
- `EventSource({ withCredentials: true })` is verplicht zodat de browser de `s4m_pair`-cookie meestuurt; standaard verstuurt EventSource geen credentials.
- Cleanup-volgorde: `es.close()` eerst, dán fetch claim. Anders kan een tweede `approved`-event tussen close-en-claim binnenkomen en een dubbele claim triggeren (server vangt het op met 410, maar netter is het netjes te sluiten).
- `qrcode.react` exporteert zowel `QRCodeSVG` als `QRCodeCanvas`. Kies SVG: schaalbaarder voor printen/screenshots en kleinere bundle.
- Geen `next/image` — QR is dynamisch en client-side.
**Verificatie**
- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen
- Handmatige twee-browser-test: A toont QR → B (ingelogd op mobile-emulator) opent QR-URL en bevestigt → A redirect naar `/dashboard` met `session.paired === true`
- DevTools Network-tab: geen URL bevat `s=` (alleen `Set-Cookie`/`Cookie` headers)
- Speel met een verlopen QR: na 2 min toont knop "Vernieuwen", `Vernieuwen` start nieuwe pairing
---
## ST-1008 — Documentatie + acceptatietest
**Bestanden**
- `docs/api/rest-contract.md` — drie nieuwe endpoints
- `docs/architecture.md` — sectie "QR-pairing flow" + threat-model
- `docs/patterns/qr-login.md` — nieuw pattern-doc
- `CLAUDE.md` — verwijzing naar het pattern-doc in de patterns-tabel
- `__tests__/integration/qr-pairing-e2e.test.ts` — optioneel, alleen als de test-infra het toelaat
**Stappen**
1. **`docs/api/rest-contract.md`** — drie endpoints documenteren met request/response, foutcodes (400/401/403/404/410/422/429), curl-voorbeelden inclusief `--cookie-jar`. Voeg een sectie *"Cookie-mechaniek"* toe die uitlegt dat `s4m_pair` een tijdelijke pre-auth cookie is, anders dan de iron-session cookie.
2. **`docs/architecture.md`** — sectie *"QR-pairing flow"* met:
- Sequence-diagram (mermaid of ASCII analoog aan M8)
- Threat-model:
- **Replay**: atomic `updateMany` met `status='approved'` voorkomt dubbele claim
- **Phishing-QR**: mobiele bevestigingspagina toont UA + IP zodat gebruiker een vreemd apparaat herkent; expliciete tap vereist
- **Demo-block**: `approvePairing` early return op `session.isDemo`
- **Rate-limit**: 10 starts per IP per minuut
- **Secret-hashing**: alleen sha256-hashes in DB; secrets verlaten desktop alleen via QR-fragment + POST-body
- **TTL-rationale**: 2 min pending vs. 5 min approved vs. 8 u paired-sessie — verschillen verklaren
- **Subsectie "Waarom geen secret in URL"**: fragment-eigenschap (browsers sturen `#…` niet naar server); HttpOnly cookie voor desktop-bewijs; geen secret in access logs / reverse-proxy logs / observability / browsergeschiedenis
3. **`docs/patterns/qr-login.md`** — herbruikbaar patroon: "unauth-SSE-via-pre-auth-cookie". Toekomstige features die een pre-auth flow met realtime-updates willen kunnen dit kopiëren (bv. "ontvang webhook live", "long-running export"). Inclusief:
- Wanneer dit patroon te gebruiken (er moet realtime-feedback zijn vóór de gebruiker is geauthenticeerd)
- Verwijzingen naar `lib/auth/pair-cookie.ts` als sjabloon
- Risico's en mitigaties
4. **`CLAUDE.md`** — in de *Implementatiepatronen*-tabel een rij `| QR-pairing (unauth-SSE + pre-auth cookie) | docs/patterns/qr-login.md |`.
5. **Acceptatie-scenario's** (handmatig, eventueel automatiseerbaar in v2):
1. Happy path — twee browsers, end-to-end binnen 2 minuten ingelogd
2. Demo-block — mobiel ingelogd als demo-user, scant QR → kan niet bevestigen
3. Replay — claim de pairing twee keer → tweede call 410
4. Expiry tijdens pending — wacht 3 min na start, scan dan → mobiel toont "Pairing verlopen"
5. Expiry tussen approve en claim — approve, wacht 6 min, claim → 410
6. Ontbrekende cookie op SSE/claim — verwijder `s4m_pair` in DevTools, herhaal → 401
7. **Secret niet in access logs** — controleer Vercel runtime-logs (via `mcp__a1fa0fcf-…__get_runtime_logs`) en lokale dev-logs; zoek op de secret-string en op `s=`-substrings; verwacht: 0 hits
**Aandachtspunten**
- Zorg dat de runtime-logs MCP-controle in `docs/qa/api-test-plan.md` belandt zodat hij bij elke release herhaalbaar is.
- `docs/patterns/qr-login.md` mag refereren naar bestaande pattern-docs (iron-session, route-handler) zonder ze te dupliceren.
**Verificatie**
- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen
- Alle zeven scenario's handmatig groen, beschreven in een test-rapport-sectie
- `vendor/scrum4me`-submodule in mcp gesynced ná schema-merge
---
## Branch- en commit-strategie
Per [Branch & PR Strategy](../runbooks/branch-and-commit.md): **één branch voor de hele milestone**, PR pas na handmatige acceptatie door de gebruiker. Reden: elke push triggert een Vercel preview-build, en op het Hobby-account zijn die schaars.
**Branch:** `feat/M10-qr-login` — afgesplitst van `main` na merge van de planning-PR (#11). Alle ST-1001..ST-1008-werk landt op deze branch.
**Commits** in chronologische volgorde, één per stap, ST-code in de titel. Voorbeeld-progressie:
```
feat(ST-1001): add LoginPairing model
feat(ST-1001): add pg_notify trigger on scrum4me_pairing channel
feat(ST-1002): add pairing helpers and pre-auth cookie
feat(ST-1002): extend SessionData with paired flag
feat(ST-1002): guard expired paired sessions in app layout
feat(ST-1003): add /api/auth/pair/start with rate-limit and pre-auth cookie
feat(ST-1004): add SSE /api/auth/pair/stream with cookie auth
feat(ST-1005): add pairing server actions
feat(ST-1005): add mobile pair confirmation page with hash-fragment client island
feat(ST-1006): add /api/auth/pair/claim with atomic consume
chore(ST-1007): add qrcode.react dependency
feat(ST-1007): add QR login button on /login with SSE listener
docs(ST-1008): document QR-pairing endpoints in api.md
docs(ST-1008): add QR-pairing flow and threat-model to architecture
docs(ST-1008): add qr-login pattern doc
```
**Push + PR**: pas nadat ST-1008-acceptatie-scenario 1 (happy path, end-to-end op localhost) handmatig groen is bevonden door de gebruiker. Tussentijdse "klaar voor jouw test"-momenten markeren we lokaal — niet met een push.
**Pre-merge gates** (uit CLAUDE.md DoD):
- `npm run lint && npm test && npm run build` groen op CI
- Schema-wijziging in ST-1001 → wekelijkse drift-check `trig_015FFUnxjz9WMuhhWNGBQKFD` mag niet rood staan; `vendor/scrum4me`-submodule in mcp meebewegen na merge
**Wanneer dit aanpassen:** zodra het Vercel-account naar Pro gaat — zie CLAUDE.md.
---
## Reseed-stap (eenmalig vóór ST-1001-implementatie)
De backlog-markdown bevat ST-1001..1008, maar de live database heeft die rijen nog niet. Voordat `mcp__scrum4me__get_claude_context` ze als next-story kan teruggeven:
```
npx prisma db seed
```
Verifieer dat M10 en zijn 8 stories als `status=OPEN` in de DB staan. Daarna geeft `mcp__scrum4me__implement_next_story product_id=cmohfotr70000jwrt0hw4q020` automatisch ST-1001 als startpunt.
> **Let op:** seed kan bestaande dev-data overschrijven. Doe dit op een dev-DB, niet productie. Voor productie volstaat het om de stories handmatig of via een eenmalige migratie-script in te voegen — buiten scope hier.

View file

@ -1,198 +0,0 @@
# PBI-11 — Mobile-shell met landscape-lock (settings + backlog + solo)
> **Status:** READY · priority 3 · sort_order 8
> **Stories:** ST-1133 (TaskDialog full-screen) · ST-1134 (foundation) · ST-1135 (UA-redirect) · ST-1136 (settings) · ST-1137 (backlog) · ST-1138 (solo) · ST-1139 (docs + E2E)
## Doel
Scrum4Me bruikbaar maken op een mobiele telefoon, beperkt tot drie schermen — Settings (account + product-selector + QR-pairing-instructie + logout), Product Backlog (PBI/Story/Task aanmaken), Solo Paneel (voortgang vastleggen). Landscape-orientatie afgedwongen via PWA-manifest + CSS-overlay. App-naam en -icoon onderdrukken op `/m/*`. Desktop-app blijft ongewijzigd.
## Drie architectuur-beslissingen
### Beslissing A — gedeelde dialog-classes (raakt ST-1133 + ST-1138)
Alle entity-dialogen (PbiDialog, StoryDialog, TaskDialog, TaskDetailDialog) delen dezelfde class-string in [components/shared/entity-dialog-layout.ts](../../components/shared/entity-dialog-layout.ts):
```ts
export const entityDialogContentClasses = cn(
'flex flex-col p-0 gap-0',
'max-h-[90vh] w-full max-w-[calc(100%-2rem)]',
'sm:max-w-[90vw] sm:max-h-[85vh]',
'lg:max-w-[50vw] lg:min-w-[480px]',
)
```
→ Mobile-fullscreen wordt via één edit op deze constant geregeld:
```ts
'max-sm:w-screen max-sm:h-screen max-sm:max-w-none max-sm:rounded-none'
```
**Gevolg voor stories:**
- ST-1133 T-317 muteert `entity-dialog-layout.ts`, niet `task-dialog.tsx` rechtstreeks
- ST-1138 T-332 vervalt als file-edit — wordt verify-only (controleer dat TaskDetailDialog mee-erft)
- PBI/Story-dialogen krijgen mobile-fullscreen "voor niets" (handig voor ST-1137)
### Beslissing B — eigen route group `app/(mobile)/`
Parent layout `app/(app)/layout.tsx` rendert NavBar, MinWidthBanner, StatusBar, SoloRealtimeBridge, NotificationsBridge. Een nested layout in `(app)/m/` kan deze parent-output **niet** verwijderen (Next.js layouts erven naar binnen, niet vervangen).
**Keuze:** verplaats `/m/*` naar een eigen route group `app/(mobile)/m/{settings,pair,products}/...` met eigen `app/(mobile)/layout.tsx`.
**Auth-guard duplicatie voorkomen** door `getSession()`-check te extraheren naar `lib/auth-guard.ts`:
```ts
// lib/auth-guard.ts
export async function requireSession() {
const session = await getSession()
if (!session.userId) redirect('/login')
return session
}
```
Beide layouts (`(app)/layout.tsx` en `(mobile)/layout.tsx`) roepen deze helper aan. Bestaande `/m/pair/page.tsx` (M10 QR-pairing) verhuist mee naar `app/(mobile)/m/pair/page.tsx` — geen URL-wijziging, alleen filesystem-move.
**Gevolg voor stories:**
- ST-1134 T-321 schrijft `app/(mobile)/layout.tsx`, niet `app/(app)/m/layout.tsx`
- ST-1136 page wordt `app/(mobile)/m/settings/page.tsx`
- ST-1137 page wordt `app/(mobile)/m/products/[id]/page.tsx`
- ST-1138 page wordt `app/(mobile)/m/products/[id]/solo/page.tsx`
- M10's `/m/pair` verhuist naar `app/(mobile)/m/pair/` — URL ongewijzigd, geen redirect-migratie nodig
### Beslissing C — gescheiden SplitPane cookie-key
ST-1137 hergebruikt `BacklogSplitPane` (drie panelen). Op mobile rendert die in tab-mode (auto-switch + back-button uit ST-1116). De SplitPane bewaart split-percentages in een cookie.
**Keuze:** gescheiden cookie-key voor mobile — `split-pane:backlog-3-mobile:<id>` — zodat mobile-gebruikers (die in tab-mode geen split-percentages bewerken maar wel terug kunnen schakelen) de desktop-split niet beïnvloeden.
**Gevolg voor stories:**
- ST-1137 T-328 geeft expliciete `cookieKey`-prop aan `BacklogSplitPane` op de mobile-route
## Hergebruik (al aanwezig)
| Wat | Bron |
|---|---|
| Mobile tab-mode in `SplitPane` (incl. `tabLabels`, `mobileBreakpoint`, `activeTab`) | ST-1116 — [components/split-pane/split-pane.tsx](../../components/split-pane/split-pane.tsx) |
| Click-cascade auto-switch in `BacklogSplitPane` | ST-1116 commit `3e86a8d` |
| QR-pairing route `/m/pair` | M10 commit `625221f` |
| `/m/pair` confirmation page | bestaand |
| Functional-spec mobile-tabs sectie | `docs/specs/functional.md:234-235` |
## Stories
### ST-1133 — TaskDialog full-screen op mobile (verifieer en fix)
**Doel:** entity-dialogen renderen 100vw × 100vh op viewport `<640px`.
**Acceptance:**
- `entityDialogContentClasses` in `components/shared/entity-dialog-layout.ts` bevat `max-sm:w-screen max-sm:h-screen max-sm:max-w-none max-sm:rounded-none`
- Sticky header en footer blijven bereikbaar; body scrollt
- Werkt voor TaskDialog, TaskDetailDialog, PbiDialog, StoryDialog (alle gebruiken de constant)
- Tests dekken mobile-render via `window.innerWidth`-mock voor minstens TaskDialog en TaskDetailDialog
- Geen regressie op desktop (`sm:max-w-[90vw]` blijft op `>=640px`)
**Tasks:**
- T-316 inventariseer huidige render
- T-317 fix de gedeelde constant
- T-318 tests
### ST-1134 — Mobile shell foundation (route group + landscape-guard + tab-bar + manifest)
**Doel:** route group `(mobile)`, landscape-overlay, bottom tab-bar, PWA-manifest.
**Acceptance:**
- `app/(mobile)/layout.tsx` rendert zonder NavBar / AppIcon / MinWidthBanner / StatusBar
- Auth-guard via gedeelde `lib/auth-guard.ts` helper; `(app)/layout.tsx` gebruikt dezelfde helper
- `<LandscapeGuard>` toont rotate-overlay in portrait (window.matchMedia)
- `<MobileTabBar>` bottom-fixed met 3 lucide-iconen (ListTree, Activity, Settings); tap-targets ≥44×44 px
- `public/manifest.json` bevat `"orientation": "landscape"`
- M10 `/m/pair` verhuist filesystem-only naar `app/(mobile)/m/pair/` — URL onveranderd
- Tests: LandscapeGuard render-states, TabBar route-active, auth-guard helper
**Tasks:**
- T-319 LandscapeGuard
- T-320 MobileTabBar
- T-321 `(mobile)/layout.tsx` + manifest + auth-guard extractie + filesystem-move van `/m/pair`
### ST-1135 — Mobile UA-redirect bij login
**Acceptance:**
- `lib/user-agent.ts` exporteert `isPhoneUA(ua: string | null): boolean` op basis van `Mobi`-substring
- `actions/auth.ts` `loginAction` redirect bij phone-UA naar `/m/products/[active]/solo`; zonder actief product naar `/m/settings`
- Tablet-UA en desktop-UA blijven op `/dashboard`
- Demo-user volgt zelfde routing
- Tests dekken alle paden (phone met/zonder product, tablet, desktop, null UA, demo)
**Tasks:** T-322 helper · T-323 loginAction integratie · T-324 tests
### ST-1136 — Mobile Settings-pagina
**Acceptance:**
- `app/(mobile)/m/settings/page.tsx`
- Toont username, isDemo-badge, actief-product-naam
- Product-selector — klik → `setActiveProductAction` + redirect `/m/products/[id]/solo`
- QR-pairing-instructie — link "Open scrum4me.app/login op je desktop om in te loggen via QR"
- Logout-knop met AlertDialog "Uitloggen?" → `logoutAction`
- Geen avatar-upload, geen bio-edit
- Tests render-states + logout-flow
**Tasks:** T-325 layout · T-326 logout-flow · T-327 tests
### ST-1137 — Mobile Product Backlog-pagina
**Acceptance:**
- `app/(mobile)/m/products/[id]/page.tsx` hergebruikt PbiList/StoryPanel/TaskPanel + backlog-store
- `BacklogSplitPane` rendert in tab-mode op `<1024px`; auto-switch op selectie blijft werken
- TaskDialog-searchParams wiring (`?newTask=`, `?editTask=`, `?storyId=`) werkt
- Cookie-key gescheiden: `split-pane:backlog-3-mobile:<id>`
- + knoppen voor PBI/Story/Task werken; demo blijft read-only
- Tests: page-rendering met initial state, tab-mode, click-cascade-flow
**Tasks:** T-328 page wrapper + cookie-key · T-329 TaskDialog wiring · T-330 tests
### ST-1138 — Mobile Solo Paneel
**Acceptance:**
- `app/(mobile)/m/products/[id]/solo/page.tsx` hergebruikt SoloBoard
- 3 kanban-kolommen blijven; horizontal scroll
- TaskDetailDialog rendert 100vw × 100vh op `<640px`**gedekt door beslissing A** (entityDialogContentClasses)
- "Voer uit"-knop bereikbaar
- SSE-stream blijft werken
- Tests: solo-page rendert, TaskDetailDialog erft mobile-classes (zonder eigen file-edit)
**Tasks:**
- T-331 page wrapper
- T-332 verify-only (geen file-edit; controleer dat shared constant uit ST-1133 doorwerkt)
- T-333 tests
### ST-1139 — Docs sync + end-to-end verificatie
**Acceptance:**
- `docs/specs/functional.md` heeft "Mobile shell"-sectie; desktop-first-clausule herzien
- `docs/architecture.md` beschrijft route group `(mobile)`, manifest landscape, UA-redirect, gedeelde auth-guard
- `npm run lint && npm test && npm run build` slagen
- E2E checklist (11 punten — zie hieronder)
- Bekende limiet: iOS Safari PWA-orientation-lock werkt niet 100% — CSS-overlay als fallback
**Tasks:** T-334 functional-spec · T-335 architecture-doc · T-336 E2E-verificatie
## Verificatie (E2E checklist uit T-336)
1. `npm run lint && npm test && npm run build` slagen
2. DevTools mobile-emulatie iPhone 12 landscape: `/m/products/[id]` rendert tab-mode, geen NavBar, tab-bar onderaan
3. Portrait → rotate-overlay zichtbaar; landscape → overlay verdwijnt
4. Tab-bar 3 iconen werken (Backlog/Solo/Settings)
5. Login phone-UA → redirect `/m/products/[id]/solo`; desktop-UA → `/dashboard`
6. Backlog-flow: + PBI, + Story, + Task in TaskDialog
7. Solo-flow: tap task → TaskDetailDialog full-screen, "Voer uit"-knop bereikbaar
8. TaskDialog full-screen op `<640px` (via shared constant)
9. PWA-installatie test op echte mobile (Android of iOS)
10. `/m/pair` QR-flow intact na route-group-verhuizing
11. Demo op mobile read-only; logout via `/m/settings` werkt; geen Scrum4Me-tekst of AppIcon op `/m/*`
## Out of scope
- Tablets (geen Mobi-UA) blijven desktop-flow gebruiken
- iOS PWA full-orientation-lock (CSS-overlay is fallback)
- Avatar/bio editor op mobile-settings
- 1-koloms-kanban (3-koloms blijft, swipe horizontaal)

View file

@ -1,128 +0,0 @@
# PBI-75 — Sprint task-edit client-side via workspace-store
## Context
In het Sprint-scherm (`/products/<id>/sprint/<sprintId>`) duurt het bewerken van een taak onevenredig lang. Klik op een taakregel of het potlood-icoon roept `router.push(?editTask=<id>)` aan vanuit [`components/sprint/task-list.tsx:226`](../../components/sprint/task-list.tsx). Dat triggert:
- **Volledige server re-render** van [`app/(app)/products/[id]/sprint/[sprintId]/page.tsx`](../../app/(app)/products/[id]/sprint/[sprintId]/page.tsx) met zware queries (sprint, alle sprintStories+tasks, productMembers, alle PBIs+stories voor het backlog-paneel)
- **Tweede Prisma-query** in [`EditTaskLoader`](../../app/_components/tasks/edit-task-loader.tsx) voor task-detail (incl. `implementation_plan`)
- **Na save**: `router.push(closePath)` + `revalidatePath` → opnieuw alle queries
De [`sprint-workspace-store`](../../stores/sprint-workspace/store.ts) (sinds PBI-74 Story 9) bevat al alles voor een client-side edit-flow:
- [`setActiveTask(taskId)`](../../stores/sprint-workspace/store.ts) (regel 337) — zet `context.activeTaskId` + roept `ensureTaskLoaded()` aan
- [`ensureTaskLoaded(taskId)`](../../stores/sprint-workspace/store.ts) (regel 414) — `GET /api/tasks/{id}` → upsert met `_detail: true`
- [`selectActiveTask`](../../stores/sprint-workspace/selectors.ts) (regel 91) — bestaat, nog geen consumer
- [`applyTaskEvent`](../../stores/sprint-workspace/store.ts) (regel 748) — SSE-events propageren idempotent na server-save
- Optimistic-mutation primitives (`applyOptimisticMutation` / `settleMutation` / `rollbackMutation`)
Het patroon "URL-param → store" bestaat al voor product-workspace in [`components/backlog/url-task-sync.tsx`](../../components/backlog/url-task-sync.tsx) — we volgen dat als precedent.
**Doel**: klik op een taak opent de edit-dialoog client-side via store-state binnen ~100ms. Geen URL-navigatie, geen server re-render, alleen `GET /api/tasks/{id}` voor het detail.
## Aanpak
**Architectuur**: store-mounted dialog + URL-sync component voor deeplinks.
1. **Klik-flow**: `TaskList.openEditDialog` roept `setActiveTask(taskId)` aan op de store. Geen `router.push`.
2. **Render-flow**: nieuwe client-component `SprintTaskDialogMount` zit binnen `SprintHydrationWrapper`, subscribet `selectActiveTask`, en rendert `<TaskDialog>` zodra de active task `_detail === true` is.
3. **Save-flow**: `TaskDialog` krijgt optionele `onClose`/`onSaved` callbacks (backwards compatible met bestaande `closePath`). Mount geeft `onClose = () => setActiveTask(null)`. Server action `saveTask` blijft `revalidatePath` doen voor server-cache; SSE-event update store via `applyTaskEvent`.
4. **Deeplink-flow**: nieuwe `SprintUrlTaskSync` leest `?editTask=<id>` en roept `setActiveTask(id)` aan (analoog aan `url-task-sync.tsx`).
## Bestanden + wijzigingen
### Nieuw — `components/sprint/sprint-task-dialog-mount.tsx`
Client component. Subscribet `selectActiveTask` (single-value, geen `useShallow`). Wanneer task aanwezig is en `isDetail(task)` true, mappt naar `TaskDialogTask`-shape:
- `status`: via `taskStatusFromApi` uit [`lib/task-status.ts`](../../lib/task-status.ts) (lowercase API → Prisma UPPER_SNAKE)
- `implementation_plan: task.implementation_plan ?? null`
- `created_at: new Date(task.created_at)`
Rendert `<TaskDialog task={mapped} productId={productId} onClose={() => setActiveTask(null)} isDemo={isDemo} />`. Geen render tussen `setActiveTask` en `_detail: true` (detail-fetch <100ms).
### Nieuw — `components/sprint/sprint-url-task-sync.tsx`
Kopie van [`components/backlog/url-task-sync.tsx`](../../components/backlog/url-task-sync.tsx) maar tegen `useSprintWorkspaceStore` en `writeTaskHint` uit [`stores/sprint-workspace/restore`](../../stores/sprint-workspace/restore.ts).
### Wijziging — `components/sprint/task-list.tsx` (regels 225-227)
Vervang:
```ts
function openEditDialog(taskId: string) {
router.push(`${pathname}?editTask=${taskId}`)
}
```
door:
```ts
function openEditDialog(taskId: string) {
useSprintWorkspaceStore.getState().setActiveTask(taskId)
}
```
`openCreateDialog` (regel 222) blijft URL-gebaseerd — out-of-scope.
### Wijziging — `app/(app)/products/[id]/sprint/[sprintId]/page.tsx`
- Verwijder `editTask` uit searchParams-destructuring (regel 36)
- Verwijder `editTask &&`-block met `<Suspense><EditTaskLoader>` (regels 250-260)
- Verwijder ongebruikte imports (`EditTaskLoader`, `TaskDialogSkeleton`, evt. `Suspense`)
- Mount binnen `SprintHydrationWrapper`:
```tsx
<SprintHydrationWrapper ...>
<SprintBoardClient ... />
<SprintTaskDialogMount productId={id} isDemo={isDemo} />
<SprintUrlTaskSync />
</SprintHydrationWrapper>
```
- `newTask`-block (regels 241-248) blijft ongemoeid — out-of-scope.
### Wijziging — `app/_components/tasks/task-dialog.tsx`
Maak `closePath` optioneel + voeg `onClose`/`onSaved` toe (backwards compatible):
```ts
interface TaskDialogProps {
task?: TaskDialogTask
storyId?: string
productId: string
closePath?: string
onClose?: () => void
onSaved?: (taskId: string) => void
isDemo?: boolean
}
```
Refactor de drie `router.push(closePath)`-calls (regels 104, 120, 155) naar één helper:
```ts
function close() {
if (onClose) { onClose(); return }
if (closePath) router.push(closePath)
}
```
Bestaande callers (`EditTaskLoader`, mobile, product-page, sprint `newTask`-block) blijven werken via `closePath`. Nieuwe `SprintTaskDialogMount` gebruikt `onClose`.
### Geen wijziging
- `stores/sprint-workspace/selectors.ts``selectActiveTask` bestaat al
- `app/_components/tasks/edit-task-loader.tsx` — nog gebruikt door product-page en mobile
## Edge cases
- **Status-enum mapping**: store API-lowercase → Prisma UPPER_SNAKE via `taskStatusFromApi`, fallback `'TO_DO'`
- **`_detail: true` race**: mount rendert pas wanneer `isDetail(task)` true is — geen flash met undefined velden
- **Demo-mode**: prop blijft via server doorlopen, dialog respecteert al `isDemo`
- **Dirty-close-guard**: ingebouwd in dialog (regels 107, 172) — werkt via `onClose`
- **SSE na save**: `applyTaskEvent` updatet store automatisch
- **Deeplink + task niet bestaat**: `GET /api/tasks/{id}` 404 → store doet niets, dialog opent niet (huidige `redirect()` verdwijnt — acceptabel)
## Verificatie
1. **Browser** (`npm run dev`): klik op task in takenlijst → dialog opent <100ms, geen URL-verandering, alleen `GET /api/tasks/<id>` in Network
2. **Save**: wijzig titel → Opslaan → dialog sluit → store toont nieuwe titel via SSE
3. **Deeplink**: `?editTask=<id>` → dialog opent via `SprintUrlTaskSync`
4. **Bestaande flows ongebroken**: product-page edit, mobile edit, sprint `?newTask=1`
5. **`npm run verify && npm run build`**
6. **Vitest**: `__tests__/components/sprint/sprint-task-dialog-mount.test.tsx` — hydreer store, mock fetch, `setActiveTask(id)`, assert UPPER_SNAKE status + `onClose` clear
## Risico's
- Andere mounts (mobile, product-backlog, sprint `newTask`) blijven URL-gebaseerd — `closePath?` optional houdt ze werkend
- Geen `redirect()` bij not-found-deeplink (klein UX-verschil)
- SSE-latency 100-500ms na save — eventueel later mitigeren via `applyOptimisticMutation` in `onSaved`-callback
## Out-of-scope (follow-up PBIs)
- `?newTask=1`-flow naar store
- Mobile + product-backlog mounts
- `EditTaskLoader` verwijderen wanneer alle callers over zijn

View file

@ -1,186 +0,0 @@
# PBI-78 — Cost-analyse widget op Insights-pagina
## Context
De insights-pagina heeft al een `TokenUsageCard` die KPI's + per-job tabel toont, maar **alleen voor de actieve sprint van een gefilterd product**. Daardoor mis je het globale plaatje: hoeveel geef je deze maand uit, welk model is de grootste kostenpost, hoe goed werkt prompt-caching, en welke job-kinds (IDEA_MAKE_PLAN met Opus vs TASK_IMPLEMENTATION met Sonnet) trekken het budget.
We voegen een nieuwe sectie **"Cost analyse"** toe (tussen Sprint Health en Plan-quality). Eén shared periode-selector (7d/30d/90d/MTD) stuurt vier visualisaties aan op basis van best practices uit de Anthropic Console + LLM-observability tools (Datadog, Portkey):
1. Trend-chart over tijd
2. Breakdown per model
3. Breakdown per job-kind
4. Cache efficiency
De bestaande `TokenUsageCard` blijft staan als sprint-detail (top duurste jobs voor de actieve sprint).
## Bestaande infrastructuur (hergebruik)
**Reeds aanwezig in DB:**
- [prisma/schema.prisma](../../prisma/schema.prisma) — `ClaudeJob` heeft `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `actual_thinking_tokens`, `model_id`, `kind`, `finished_at`
- `ModelPrice` tabel met prijzen per 1M tokens (input/output/cache_read/cache_write)
- Prijzen worden gesynced via [scripts/sync-model-prices.ts](../../scripts/sync-model-prices.ts)
**Hergebruikbare patronen:**
- KPI-strip stijl: zie [app/(app)/insights/components/token-usage.tsx](../../app/(app)/insights/components/token-usage.tsx) (regels 43-64)
- URL-param-gestuurde filter met `useTransition` + `router.replace`: zie [app/(app)/insights/components/agent-throughput.tsx](../../app/(app)/insights/components/agent-throughput.tsx) (regels 38-62)
- Recharts BarChart pattern: zie [app/(app)/insights/components/agent-throughput.tsx](../../app/(app)/insights/components/agent-throughput.tsx) (regels 110-130)
- Cost-formule (zelfde overal): zie [lib/insights/token-stats.ts](../../lib/insights/token-stats.ts) (regels 73-79) — input + output + cache_read + cache_write + thinking, allemaal `× price_per_1m / 1_000_000`
- Server component → parallel data-fetch via `Promise.all`: zie [app/(app)/insights/page.tsx](../../app/(app)/insights/page.tsx) (regels 46-80)
## Te bouwen
### Taak 1 — Data-laag — `lib/insights/cost-analysis.ts` (nieuw)
Eén bestand met vijf functies, allemaal `(userId, period)` als parameters. Period wordt naar `WHERE cj.finished_at >= NOW() - INTERVAL '<n> days'` vertaald (MTD = `>= date_trunc('month', NOW())`).
```ts
export type Period = '7d' | '30d' | '90d' | 'mtd'
export interface CostKpi {
totalCostUsd: number
totalTokens: number
jobCount: number
avgPerDayUsd: number
cacheSavingsUsd: number // (input_price - cache_read_price) × cache_read_tokens
topModelId: string | null
topModelCostUsd: number
}
export interface CostByDayRow { day: string; costUsd: number }
export interface CostByModelRow { modelId: string; costUsd: number; jobCount: number }
export interface CostByKindRow { kind: string; costUsd: number; jobCount: number }
export interface CacheEfficiency {
cacheReadTokens: number
uncachedInputTokens: number
cacheHitRatio: number // cache_read / (cache_read + input)
savingsUsd: number
spentOnCacheWriteUsd: number
}
export async function getCostKpi(userId: string, period: Period): Promise<CostKpi>
export async function getCostByDay(userId: string, period: Period): Promise<CostByDayRow[]>
export async function getCostByModel(userId: string, period: Period): Promise<CostByModelRow[]>
export async function getCostByKind(userId: string, period: Period): Promise<CostByKindRow[]>
export async function getCacheEfficiency(userId: string, period: Period): Promise<CacheEfficiency>
```
**Belangrijke details:**
- Alle queries: `WHERE cj.user_id = ${userId} AND cj.status = 'DONE' AND cj.finished_at >= <periodStart>`
- Geen `productAccessFilter` nodig — `cj.user_id = ${userId}` filtert al op de eigenaar
- `getCostByDay` vult ontbrekende dagen op met `0` (anders breekt de chart-x-as) — vul aan client- of server-side, kies één
- Periode → days mapping inline: `7d`→7, `30d`→30, `90d`→90, `mtd`→huidige dag-van-maand
- Cache savings: `cache_read_tokens × (input_price - cache_read_price) / 1_000_000` — "wat je betaald zou hebben zonder cache, minus wat je betaalde mét cache"
### Taak 2 — UI — `app/(app)/insights/components/cost-analysis.tsx` (nieuw)
Eén client-component die de hele sectie rendert. Structuur:
```
[Period selector rechtsboven]
[KPI strip: Totaal | Cache savings | Avg/dag | Top model ($X op claude-opus-4-7)]
[grid grid-cols-1 md:grid-cols-2 gap-4]
[Daily cost line/bar chart] [Model breakdown - horizontal bar of donut]
[Job-kind breakdown - bar] [Cache efficiency - donut + label "X% hit, $Y bespaard"]
```
**Period selector:** kopieer pattern uit [agent-throughput.tsx](../../app/(app)/insights/components/agent-throughput.tsx) (regels 50-61) — `useTransition` + `router.replace` met `?period=` in URL. Default tonen als "30d".
**Charts:** Recharts (al gebruikt in `BurndownChart`, `AgentThroughputCard`, `VelocityChart`):
- Daily: `<BarChart>` met één bar (cost in USD), x-as = dag (`MM-DD`), tooltip toont `$X.XXXX`
- Model: `<BarChart layout="vertical">` met model_id labels — beperkt tot top 5
- Kind: `<BarChart layout="vertical">` met kind labels — beperkt tot top 5
- Cache: `<PieChart>` met twee segmenten (cached / uncached input) + tekst "X% cache hit · $Y bespaard"
**Empty state:** als `kpi.jobCount === 0`: render één regel "Geen jobs in deze periode."
### Taak 3 — Integratie — `app/(app)/insights/page.tsx` (edit)
Wijzigingen:
```diff
interface InsightsPageProps {
- searchParams: Promise<{ product?: string }>
+ searchParams: Promise<{ product?: string; period?: string }>
}
```
```diff
- const { product: filterProductId } = await searchParams
+ const { product: filterProductId, period: rawPeriod } = await searchParams
+ const period = (['7d','30d','90d','mtd'].includes(rawPeriod ?? '') ? rawPeriod : '30d') as Period
```
In de `Promise.all`, voeg toe:
```ts
getCostKpi(userId, period),
getCostByDay(userId, period),
getCostByModel(userId, period),
getCostByKind(userId, period),
getCacheEfficiency(userId, period),
```
Nieuwe sectie tussen Sprint Health en Plan-quality:
```tsx
<section className="space-y-3">
<h2 className="text-lg font-medium text-foreground">Cost analyse</h2>
<CostAnalysisCard
period={period}
kpi={costKpi}
byDay={costByDay}
byModel={costByModel}
byKind={costByKind}
cache={cacheEff}
/>
</section>
```
De bestaande "Token gebruik" sectie blijft staan (sprint-detail tabel).
## Bestanden
**Nieuw:**
- `lib/insights/cost-analysis.ts` — 5 query-functies + types
- `app/(app)/insights/components/cost-analysis.tsx` — client-component met period-selector + 4 charts
**Edit:**
- `app/(app)/insights/page.tsx` — period uit searchParams, parallel-fetch, nieuwe sectie
**Geen wijzigingen aan:**
- Prisma schema (alle data is er al)
- MCP server (token-data wordt al weggeschreven via `update_job_status`)
- `TokenUsageCard` (blijft als sprint-detail tabel)
## Verificatie
```bash
npm run verify && npm run build
```
**Handmatig:**
1. Open `/insights` zonder query — period default `30d`, sectie toont KPI + 4 charts
2. Wissel period via selector → URL updatet `?period=7d`, charts laden nieuwe data via `router.replace`
3. Check empty state: kies periode zonder jobs → "Geen jobs in deze periode."
4. Sanity-check KPI's tegen ruwe DB-query:
```sql
SELECT SUM(input_tokens * mp.input_price_per_1m / 1e6
+ output_tokens * mp.output_price_per_1m / 1e6
+ cache_read_tokens * mp.cache_read_price_per_1m / 1e6
+ cache_write_tokens * mp.cache_write_price_per_1m / 1e6
+ COALESCE(actual_thinking_tokens, 0) * mp.input_price_per_1m / 1e6)
FROM claude_jobs cj
LEFT JOIN model_prices mp ON mp.model_id = cj.model_id
WHERE cj.user_id = '<id>' AND cj.status = 'DONE'
AND cj.finished_at >= NOW() - INTERVAL '30 days';
```
5. Cache savings sanity: `cacheSavingsUsd ≈ cache_read_tokens × 0.9 × input_price / 1M`
(cache_read prijs = 0.1× input prijs, dus savings is 90%)

View file

@ -1,142 +0,0 @@
---
title: "ST-1114 — Copilot reviews op dashboard"
status: active
audience: [maintainer, contributor]
language: nl
last_updated: 2026-05-03
applies_to: [ST-1114]
---
# Plan — ST-1114 · Copilot reviews op dashboard
## Context
Als ontwerper wil je een overzicht zien van GitHub Copilot's PR-reviews om per stuk te beslissen of je 'm implementeert of overslaat. De codebase heeft nu **nul** GitHub-integratie — alleen `product.repo_url` als string voor hyperlinks. We bouwen een minimale, hobby-vriendelijke architectuur.
## Architectuurkeuzes (via AskUserQuestion bevestigd)
- **Auth**: lokaal script met `GITHUB_TOKEN` — webapp heeft GEEN GitHub-credentials. Het script draai je lokaal wanneer je wil verversen.
- **Fetch**: on-demand op dashboard-load (server-side `prisma.copilotReview.findMany`, geen externe call)
- **Decision**: alleen visuele toggle in `localStorage` (geen DB-persistentie)
- **Scope**: MVP — tonen + lokale toggle. Geen cron, geen webhook, geen GitHub-auth in productie.
## Architectuur
```
┌──────────────┐ octokit ┌────────────┐ API token ┌─────────────┐
│ scripts/ │ ──────────▶ │ GitHub │ │ Scrum4Me │
│ sync-copilot │ │ REST API │ │ /api/ │
│ -reviews.ts │ ◀────────── │ │ │ copilot- │
└──────────────┘ reviews └────────────┘ POST batch │ reviews │
│ │ │
└──────────────────────────────────────────────────▶ DB upsert │
└──────┬──────┘
┌──────▼──────┐
│ /dashboard │
│ server-side │
│ findMany │
└─────────────┘
```
Het script is de enige plek waar GitHub-credentials nodig zijn. Productie kent alleen Scrum4Me-data.
## Datamodel
```prisma
model CopilotReview {
id String @id @default(cuid())
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
product_id String
pr_number Int
pr_title String
pr_url String
pr_state String // 'open' | 'closed' | 'merged'
author_login String // bv. 'copilot-pull-request-reviewer[bot]'
review_state String // 'COMMENTED' | 'APPROVED' | 'CHANGES_REQUESTED'
body String @db.Text
submitted_at DateTime
html_url String // directe link naar de review
fetched_at DateTime @default(now())
@@unique([product_id, pr_number, submitted_at])
@@index([product_id, submitted_at])
@@map("copilot_reviews")
}
```
`@@unique` zorgt voor idempotency — script kan herhaald draaien zonder dupes. Geen `decision`-veld: dat staat in `localStorage`.
## Script
`scripts/sync-copilot-reviews.ts` — TypeScript via `tsx`, leest env, gebruikt Octokit, POST naar eigen API.
```bash
GITHUB_TOKEN=ghp_... \
SCRUM4ME_API_URL=http://localhost:3000 \
SCRUM4ME_API_TOKEN=s4m_... \
npx tsx scripts/sync-copilot-reviews.ts
```
Stappen:
1. `GET /api/products` (Bearer-auth) — lijst toegankelijke producten met `repo_url`
2. Per product: parse `owner/repo` uit `repo_url`, `octokit.pulls.list({state: 'all', per_page: 50})`
3. Per PR: `octokit.pulls.listReviews(...)`, filter op `user.type === 'Bot' && user.login.includes('copilot')`
4. `POST /api/copilot-reviews` met `{ product_id, reviews: [...] }` — endpoint doet `deleteMany` + `createMany` per product (atomic replace)
5. Print samenvatting: aantal reviews per product + totale runtime
## API endpoint
`app/api/copilot-reviews/route.ts`:
- **POST**: Bearer-auth, demo-block, payload `{ product_id, reviews: CopilotReview[] }`. Atomic transaction: delete-all-for-product → createMany. Validatie via Zod.
- **GET**: niet nodig — dashboard leest direct via Prisma server-side. Endpoint kan komen voor toekomstige clients.
## Dashboard widget
Boven of onder de bestaande product-grid een nieuwe sectie "Copilot reviews".
`components/dashboard/copilot-reviews.tsx` (client component):
- Props: `reviews: CopilotReview[]` (server-fetched)
- Lijst met cards: PR-titel + nummer (link naar PR), Copilot's body (truncated of accordion), state-badge, "Implementeer" / "Skip"-knoppen
- Decision-state in `localStorage` keyed op `review.id`: `'implement' | 'skip' | undefined` (default: ongezien)
- Cards met decision='skip' visueel gedimmed; cards met 'implement' krijgen een groen randje
- Filter-toggles bovenaan: "Alle / Te beoordelen / Implementeren / Skip"
- Empty state: "Geen Copilot-reviews gevonden — draai het sync-script."
`app/(app)/dashboard/page.tsx` past `prisma.copilotReview.findMany({ where: { product_id: { in: accessibleIds } }, orderBy: { submitted_at: 'desc' } })` en geeft door.
## Voorgestelde sub-tasks
| Code | Onderwerp |
|---|---|
| ST-1114.2 | DB: `CopilotReview` model + migration |
| ST-1114.3 | API: `POST /api/copilot-reviews` (Bearer-auth + demo-block + replace-by-product) |
| ST-1114.4 | Script: `scripts/sync-copilot-reviews.ts` met octokit |
| ST-1114.5 | UI: dashboard-widget met cards, localStorage-decision, filter-toggle |
| ST-1114.6 | Tests: API endpoint (auth, demo-block, validation), dashboard-widget snapshot |
| ST-1114.7 | Docs: README-sectie over script + env-vars; CLAUDE.md-update |
## M11-keuzes voor de implementerende sessie
Drie open beslissingen die niet kritiek zijn voor het plan zelf:
1. **PR-state filter**: alle PR's of alleen `state=open`? (closed-PRs hebben oude reviews die misschien niet meer relevant zijn)
2. **Markdown-rendering**: react-markdown, of plain `<pre>`? (react-markdown is +35KB bundle)
3. **localStorage-key-vorm**: `scrum4me:copilot-decision:{review_id}` per review, of één map-object onder één key?
## Branch + PR
- Branch: `feat/M14-copilot-reviews` (M14 = nieuwe milestone)
- 6 commits (.2 t/m .7), één per laag
- PR pas openen na handmatige test door gebruiker
## Verificatie (end-to-end)
1. `npm run dev`
2. `GITHUB_TOKEN=... SCRUM4ME_API_TOKEN=... npx tsx scripts/sync-copilot-reviews.ts` — toont `n reviews opgeslagen`
3. Browser refresht dashboard → "Copilot reviews"-sectie toont cards met PR-titels
4. Klik "Implementeer" → kaart krijgt groen randje, decision in localStorage
5. Refresh → state blijft (localStorage)
6. Filter toggle "Alleen te beoordelen" → cards met decision verdwijnen
7. Demo-user: kan reviews zien, maar `POST /api/copilot-reviews` weigert (al via middleware-guard van ST-1110)

View file

@ -1,150 +0,0 @@
---
title: "CLAUDE.md workflow-update na M7 + ST-509/511/512/513"
status: done
audience: [maintainer, contributor]
language: nl
last_updated: 2026-05-03
applies_to: [M7, ST-509, ST-511, ST-512, ST-513]
---
# Plan: CLAUDE.md workflow-update na M7 + ST-509/511/512/513
## Aanleiding
`CLAUDE.md` is voor het laatst groot bijgewerkt op 2026-04-25 (`docs/decisions/agent-instructions-history.md`). Sindsdien is er substantieel werk geland dat de workflow raakt:
- **ST-511** entity codes (Product/PBI/Story) — branch- en commit-conventies hangen er nu aan vast
- **ST-513** API hardening — `400` (malformed JSON) vs `422` (zod-validatie), lowercase status-enums op API-grens, `StoryLog.metadata` JSONB
- **PR #2** review-saga (8 testbestanden faalden bij contract-flip) — duidelijk leerpunt: testpariteit hoort bij contract-wijziging
- **M7 MCP-server** — Claude Code praat nu native met Scrum4Me via `mcp__scrum4me__*` tools en de prompt `implement_next_story`. De huidige 7-stap "vraag-de-gebruiker"-loop in CLAUDE.md is daarmee gedateerd
- **lib/code-server.ts** vs **lib/code.ts** — split is nodig om client-bundle vrij te houden van `pg`. Als gotcha noemenswaard
- **Schema-drift cron** (`trig_015FFUnxjz9WMuhhWNGBQKFD`) — wekelijkse remote agent — agents moeten weten wat ze met zijn rapport doen
Doel: CLAUDE.md weerspiegelt de werkelijke 2026-04-27 workflow zonder dat het een changelog wordt.
## Scope — wat we wél en niet aanpassen
**Wel** (in `CLAUDE.md`):
1. Workflow-sectie — MCP-first met expliciete fallback
2. Conventies — uitbreiden met status-enums, foutcodes, test-pariteit, entity codes in commits
3. Implementatiepatronen — rij voor `lib/task-status.ts` en `lib/code-server.ts`-boundary toevoegen
4. Nieuwe sectie "MCP-integratie" — wat staat er, hoe te gebruiken, link naar mcp repo
5. Definition of Done — markeer expliciet als MVP-scope; M7 is post-MVP en heeft eigen acceptatie
**Niet**:
- Geen changelog of historiek in CLAUDE.md zelf — dat hoort in `docs/decisions/agent-instructions-history.md` (separate update)
- Geen volledige herschrijving — bestaande structuur blijft (Wat is Scrum4Me, Spec-tabel, Stack, Conventies, Commit Strategy, etc.)
- Geen wijziging in `AGENTS.md` (Codex) — die heeft geen MCP, mag los blijven
- Geen wijziging in functional-spec/architecture/styling docs — die zijn al actueel
## Concrete edits in `CLAUDE.md`
### 1. Sectie "Specificatiedocumenten" — uitbreiden
Voeg toe onder de bestaande tabel:
| Document | Gebruik voor |
|---|---|
| `https://github.com/madhura68/scrum4me-mcp` | MCP-server repo: tools, prompts, schema-sync workflow |
(`docs/api/rest-contract.md` staat er al — laten staan.)
### 2. Sectie "Waar te beginnen" — herschrijven
Vervang de 7-stap manual loop door een dual-track:
**Track A — via Claude Code MCP (aanbevolen)**:
```
1. Roep `mcp__scrum4me__implement_next_story` aan met product_id
(of `list_products` als je het id niet weet)
2. De prompt orkestreert: get_claude_context → log_implementation
→ per task in_progress/done → log_test_result → log_commit
3. Bouw de tasks in volgorde van `sort_order`
4. Test: `npm run lint && npm test && npm run build`
5. Commit per laag (zie Commit Strategy)
```
**Track B — manueel (Codex of zonder MCP)**:
- Lees task in `docs/backlog/index.md`
- Volg verder de bestaande 7-stappen-loop
### 3. Sectie "Implementatiepatronen" — uitbreiden
Twee rijen toevoegen aan de patronen-tabel:
| Patroon | Bestand |
|---|---|
| Status-enum mapping (DB ↔ API) | `lib/task-status.ts` |
| Client/server module-boundary | regel: `*-server.ts` bevat DB-calls, `*.ts` is pure helpers — nooit `import { ... } from 'lib/foo-server'` in een client component |
### 4. Sectie "Conventies" — vier regels toevoegen
Voeg toe aan de bestaande lijst:
- **Entity codes**: gebruik product/PBI/story-codes in commit-titles wanneer aanwezig (`feat(ST-356.2): ...`); branchnaam blijft `feat/ST-XXX-slug`
- **Status-enums op API**: lowercase (`todo|in_progress|review|done`, `open|in_sprint|done`); DB houdt UPPER_SNAKE; conversie via `lib/task-status.ts`-mappers — nooit ad-hoc lowercase elders
- **Foutcodes API**: `400` alleen voor malformed JSON-body (parse-fout); `422` voor zod-validatie en well-formed-maar-niet-acceptabel; `403` voor demo-tokens. Documenteren in `docs/api/rest-contract.md`
- **Tests volgen contract**: bij API-contract-wijziging (status, foutcode, response-shape) MOET in dezelfde commit ook `__tests__/api/` bijgewerkt worden — een falende test op review betekent niet dat de tests "stuk zijn" maar dat de wijziging onvolledig is
### 5. Nieuwe sectie "MCP-integratie" — toevoegen vóór "Definition of Done"
Korte sectie (~15 regels):
```markdown
## MCP-integratie
Scrum4Me heeft een eigen MCP-server (repo: `madhura68/scrum4me-mcp`)
die deze API exposed als native tools voor Claude Code.
### Tools beschikbaar in Claude Code
- `mcp__scrum4me__health` — service + DB ping
- `mcp__scrum4me__list_products` — producten waar je toegang tot hebt
- `mcp__scrum4me__get_claude_context` — bundled product/sprint/story/todos
- `mcp__scrum4me__update_task_status`, `_update_task_plan`
- `mcp__scrum4me__log_implementation`, `_log_test_result`, `_log_commit`
- `mcp__scrum4me__create_todo`
### Prompt
- `implement_next_story` (arg: `product_id`) — end-to-end workflow
### Schema-drift bewaking
Wekelijks (maandag 08:00 Amsterdam) draait een remote agent
(`trig_015FFUnxjz9WMuhhWNGBQKFD`) die `vendor/scrum4me` syncet en
`prisma:generate + typecheck` uitvoert in mcp. Als die agent
een drift-rapport opent, hoort dat **vóór** een Scrum4Me-PR met
schema-wijziging gemerged kan worden — zodat de MCP-server niet
stilletjes breekt op runtime.
```
### 6. Sectie "Definition of Done" — kop verduidelijken
Wijzig `## Definition of Done``## Definition of Done (MVP)` en voeg eronder een korte zin toe: *"M7 (MCP-server) is post-MVP en heeft eigen acceptatie in `docs/backlog/index.md`."*
## Bijwerken van auditdoc
Voeg een sectie aan `docs/decisions/agent-instructions-history.md` toe (datum: 2026-04-27) met:
- Aanleiding: ST-509/511/512/513 + M7 + PR #2 review-saga
- Gecontroleerde wijzigingen: zelfde tabel-stijl als 2026-04-25
- Nieuwe regels: status-enums op API-grens, error-code split 400/422, test-pariteit bij contract-wijziging, client/server module-boundary
- Verwijzing naar mcp repo en schema-drift cron
## Volgorde van uitvoering
1. **Edits in `CLAUDE.md`** — alle 6 secties hierboven, in volgorde
2. **Edits in `docs/decisions/agent-instructions-history.md`** — nieuwe sectie 2026-04-27
3. **`npm run lint`** — sanity check
4. **Commit als één logische change**`docs(workflow): align CLAUDE.md with M7 and post-PR-#2 contract`
5. **PR openen** — review-bare scope, deploys triggeren maar zijn docs-only
## Wat het NIET oplost
- `AGENTS.md` (Codex) blijft achter; los aan te pakken indien gewenst
- Eventuele drift in `docs/specs/functional.md` rond status-enums — niet onderzocht; te volgen bij volgende audit
- Geen check of de losse pattern-files in `docs/patterns/` nog kloppen — ook volgende audit
## Geschatte size
- ~80 regels toegevoegd/gewijzigd in `CLAUDE.md`
- ~30 regels nieuw in `docs/decisions/agent-instructions-history.md`
- 1 commit, 1 PR

View file

@ -1,111 +0,0 @@
---
title: "Herbruikbaar scripts/insert-milestone.ts"
status: done
audience: [maintainer, contributor]
language: nl
last_updated: 2026-05-03
applies_to: []
---
# Plan: herbruikbaar `scripts/insert-milestone.ts`
## Doel
Eén commando dat een specifieke milestone (PBI + stories + tasks) uit de backlog leest en idempotent toevoegt aan de DB, zónder bestaande data te raken. Voor M8 nu, en voor M9..M∞ later.
## Bron-keuze: backlog ipv plan-bestand
Twee bronnen denkbaar:
- **`.Plans/<datum>-<slug>.md`** — freeform plan-tekst, niet gestructureerd, niet gecommit
- **`docs/backlog/index.md`** — al strict gestructureerd, gecommit, single source of truth voor alle bestaande seed-pipelines
Voorstel: het script leest de **backlog**. Workflow blijft natuurlijk:
1. Plan schrijven naar `.Plans/<naam>.md` (lokaal, draft)
2. Milestone-sectie + stories formaliseren in `docs/backlog/index.md` (PR)
3. Na merge: `npm run db:insert-milestone -- M8 [--product SCRUM4ME]`
Eén canonical bron, geen ambiguïteit, en de bestaande parser doet 90% van het werk al.
## Wijzigingen
### 1. `prisma/seed-data/parse-backlog.ts` — tolerant maken
Huidige parser kent alleen M0..M6 in `MILESTONE_PRIORITY/_GOAL/_SPRINT_STATUS` + asserts ≥8 milestones / ≥60 stories. M7 en M8 worden nu stilletjes overgeslagen.
Concrete edits:
- Voeg `M7` en `M8` toe aan de drie maps (M7: priority 4, sprint COMPLETED, goal "MCP-server voor Claude Code"; M8: priority 4, sprint COMPLETED, goal "Realtime updates voor Solo Paneel")
- Voor onbekende sleutels: fallback naar `priority: 4`, `sprint_status: 'COMPLETED'`, `goal: <header-title>`. Dat maakt M9..M∞ vanzelf bruikbaar zonder code-wijziging
- Verwijder de strikte filter `KNOWN_KEYS.includes(...)` of verleg naar een "alle-M[\d.]+ headers" check
- Voeg optionele `loadBacklog(repoRoot, { strict?: boolean })` toe. `strict: true` (default) behoudt de bestaande "≥8 milestones, ≥60 stories" asserts (zodat de seed niet stilletjes anders gedraagt). Insert-milestone roept met `strict: false`
### 2. `scripts/insert-milestone.ts` (nieuw, ~90 regels)
```
Usage: tsx scripts/insert-milestone.ts <milestone-key> [--product <code>] [--dry-run]
Default product code: SCRUM4ME
```
Logica:
1. Parse args; valideer dat milestone-key matcht `^M[\d.]+$`
2. `loadBacklog(repoRoot, { strict: false })`
3. Zoek milestone op `key`; faal helder met "milestone <key> not found in docs/backlog/index.md" als ie er niet in staat
4. Lookup product via `code` (default `SCRUM4ME`); faal als niet gevonden
5. Upsert PBI:
- `where: { product_id_code: { product_id, code: milestone.key } }`
- sort_order = `(max(sort_order) van bestaande PBIs in product) + 1` als nieuw, anders ongemoeid
6. Voor elke story:
- Upsert Story op `(product_id, code = story.ref)`
- status = `'DONE'` of `'OPEN'` zoals gemarkeerd in markdown
- sort_order, priority en pbi_id correct ingesteld
7. Voor elke task: bulk insert **alleen** als de story op dit moment 0 tasks heeft (idempotent — herhaling dupliceert niets)
8. Print samenvatting: `M8: PBI created, 6 stories upserted (1 created, 5 unchanged), 6 tasks created`
9. `--dry-run`: alle DB-calls overslaan, alleen wat het zou doen printen
Edge cases:
- Story-code conflict tussen producten: schema heeft `@@unique([product_id, code])` op Story dus dit is per-product safe
- Tasks zonder `code` veld in DB (klopt — code wordt afgeleid van story.code + index in get_claude_context)
- Demo-product: script accepteert `--product DEMO` o.i.d. — niet hardcoded SCRUM4ME
### 3. `package.json` script
```json
"db:insert-milestone": "tsx scripts/insert-milestone.ts"
```
### 4. Verificatie na implementatie
- Dry-run eerst: `npm run db:insert-milestone -- M8 --dry-run`
- Daarna echt: `npm run db:insert-milestone -- M8`
- In Prisma Studio of via SQL: zie M8 PBI, 6 stories, 6 tasks onder SCRUM4ME-product
- Tweede run: `npm run db:insert-milestone -- M8` → "0 created, 6 unchanged" — geen duplicaten
- Niet-bestaande key: `npm run db:insert-milestone -- M99` → "milestone M99 not found"
- Bestaande seed-flow blijft werken: `prisma db seed` met `strict: true` faalt nog steeds bij format-drift in de backlog
## Branch- en PR-strategie
`scripts/insert-milestone.ts` is orthogonaal aan ST-801. Twee keuzes:
- **A. Eigen mini-branch + PR**`tooling/insert-milestone-script`, ~95 regels code, makkelijk reviewbaar, gemerged voordat M8 verder gaat. Daarna gebruiken om M8 in DB te zetten en met de implementatie door.
- **B. Aan ST-801 plakken** — voegt scope toe aan een PR die al code ↔ infra-overschrijdend is (migratie + tools).
Voorgestelde keuze: **A**. De tool is breder bruikbaar dan M8 alleen.
## Volgorde
1. Switch naar `main` (ST-801 blijft op zijn eigen branch staan)
2. Branch `tooling/insert-milestone-script`
3. Edit `parse-backlog.ts` (M7/M8 maps + tolerant + strict-mode option)
4. Schrijf `scripts/insert-milestone.ts`
5. Voeg `db:insert-milestone` toe aan `package.json`
6. Lokaal testen met M8 (dry-run + echt + tweede run)
7. Commit, push, PR
8. Na merge: tool gebruiken om M8 in DB te krijgen, daarna ST-802 oppakken op feat/ST-801-branch
## Geschatte size
- ~10 regels parser-edit
- ~95 regels nieuw script
- ~1 regel package.json
- ~25 regels test/usage doc in script-comment
- 1 commit, 1 PR

View file

@ -1,195 +0,0 @@
---
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 23s | ✅ | nee | 13s | 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 12s 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, ~1218 commits
- 1 migratie, 1 nieuwe route, 1 nieuwe hook, kleine store-uitbreiding, UI-indicator
- ~400 regels code + ~80 regels docs
- 1 PR

View file

@ -1,486 +0,0 @@
# Plan — Auto-PR + selectieve deploy-controle + sync-zicht (end-to-end batch flow)
> Bij merge: dit plan verplaatsen naar `docs/plans/auto-pr-deploy-sync.md`
> conform feedback-memory (plans in `docs/plans/`).
## Context
Drie samenhangende problemen rond de "idee → uitvoeren"-keten:
1. **Worker stopt bij `commit`.** De Scrum4Me NAS-worker werkt lokaal:
commits blijven op de machine staan totdat de gebruiker zelf pusht en
een PR aanmaakt. Voor batch-uitvoer van story-jobs is dit een harde
menselijke gate.
2. **Deploy is alles-of-niets.** `.github/workflows/ci.yml` deployt nu
**elke** push naar `main` automatisch naar productie en **elke** PR
naar preview. `vercel.json` heeft geen `git.deploymentEnabled: false`,
dus Vercel's eigen Git-integratie deployt waarschijnlijk parallel mee
→ dubbele deploys en geen selectieve controle.
3. **Geen zicht op voortgang per Idea/PBI.** Concreet getest geval:
PBI-33 wordt nu de eerste sprint-batch — er is **geen git-voetafdruk**
(geen branch/commit/PR met "PBI-33"), **geen activiteitenlog-entry**,
en geen UI-pagina die per Story toont of er een ClaudeJob loopt, een
commit gepusht is, of een PR open/merged is. De data zit in
`Story.status`, `ClaudeJob.pushed_at/branch/pr_url`,
`Pbi.pr_url/pr_merged_at` — er is alleen geen view die het joint.
Doel: de complete keten **plan → job → commit → push → PR → auto-merge →
deploy** in één coherent ontwerp leggen, met (a) selectieve
deploy-controle als veiligheidsklep en (b) een sync-tab die per Idea
laat zien wat er werkelijk in git/PR-land gebeurd is.
## Vastgelegde keuzes
### Deploy-controle
1. **Mechanisme**: PR-labels (B) + path-filter (C) gecombineerd.
2. **Eigenaar**: GitHub Actions-workflow (A). Vercel Git-integratie uit.
3. **Defaults**: PR → preview, push naar `main` → productie.
4. **Override-richtingen**:
- `skip-deploy` label: voorkomt preview-deploy op een PR.
- `force-deploy` label: forceert deploy ook als path-filter doc-only
zegt.
### Auto-PR (uit IDEA-007-grill)
5. **Triggers in worker**: na elke succesvolle `update_job_status('done')`
pusht de worker; na laatste story van een PBI maakt de worker een PR
aan en activeert auto-merge (SQUASH).
6. **Auth**: `GITHUB_TOKEN` als omgevingsvariabele op de worker; geen UI
of GitHub App in v1.
7. **Foutafhandeling**: push/PR-aanmaak-fail → `update_job_status('failed',
error: …)`; geen force-push, geen automatische retry.
### Interactie tussen beide
8. **Worker-PRs gebruiken hetzelfde labelsysteem als alle andere PRs.**
Default = preview deploy, auto-merge wacht op CI groen, na merge
prod-deploy (mits path-filter zegt "code"). De worker zet **geen**
labels automatisch — als je batch-output zonder preview wilt mergen
moet je `skip-deploy` zelf toevoegen, of preview later uitzetten via
een product-instelling (out-of-scope v1).
9. **Implementatievolgorde**: eerst deploy-controle (infra,
onafhankelijk), daarna auto-PR (afhankelijk van stabiele deploy-flow).
## Architectuur in één plaat
```
auto-merge wacht op
[story-job DONE] ─push branch─┐ deploy-preview groen
▼ │
[laatste story?]──ja──[PR + auto-merge]──CI──┴──merge naar main
[job: ci] altijd
[paths-filter]
├ PR → deploy-preview
│ if code && !skip-deploy
│ || force-deploy
└ push → deploy-production
if code
```
---
## Deel A — Deploy-controle
### A.1 `vercel.json` — Vercel Git-deploy uitzetten
```json
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"git": { "deploymentEnabled": false },
"crons": [
{ "path": "/api/cron/expire-questions", "schedule": "0 4 * * *" },
{ "path": "/api/cron/cleanup-agent-artifacts", "schedule": "0 3 * * *" }
]
}
```
Effect: Vercel deployt niet meer automatisch op git-events. Alleen
`vercel deploy` vanuit de workflow (met `VERCEL_TOKEN`) maakt nog
deployments.
### A.2 `.github/workflows/ci.yml` — path-filter + label-checks
Triggers uitbreiden met `workflow_dispatch`:
```yaml
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
inputs:
target:
type: choice
description: Deploy target
options: [preview, production]
default: preview
```
Nieuwe job vóór de deploy-jobs:
```yaml
changes:
name: Detect deploy-relevant changes
runs-on: ubuntu-latest
needs: ci
outputs:
code: ${{ steps.filter.outputs.code }}
steps:
- uses: actions/checkout@v5
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
code:
- 'app/**'
- 'components/**'
- 'lib/**'
- 'actions/**'
- 'stores/**'
- 'prisma/**'
- 'public/**'
- 'package.json'
- 'package-lock.json'
- 'next.config.ts'
- 'tsconfig.json'
- 'vercel.json'
- 'proxy.ts'
- 'middleware.ts'
- '.github/workflows/**'
```
`deploy-preview` if-conditie aanpassen:
```yaml
deploy-preview:
needs: [ci, changes]
if: |
github.event_name == 'pull_request' && (
(needs.changes.outputs.code == 'true'
&& !contains(github.event.pull_request.labels.*.name, 'skip-deploy'))
|| contains(github.event.pull_request.labels.*.name, 'force-deploy')
)
```
`deploy-production` if-conditie aanpassen:
```yaml
deploy-production:
needs: [ci, changes]
if: |
github.ref == 'refs/heads/main'
&& github.event_name == 'push'
&& needs.changes.outputs.code == 'true'
```
Nieuwe `deploy-manual` job voor `workflow_dispatch` met `inputs.target`
`vercel deploy` of `vercel deploy --prod`.
### A.3 GitHub-labels aanmaken
```bash
gh label create skip-deploy --color BFBFBF --description "Preview-deploy overslaan"
gh label create force-deploy --color 0E8A16 --description "Forceer deploy ondanks path-filter"
```
### A.4 Documentatie
`docs/runbooks/deploy-control.md` — triggers, labels, path-filter,
voorbeelden. `CLAUDE.md` § Deployment-regel verwijst naar runbook.
---
## Deel B — Auto-PR (worker → GitHub)
### B.1 Acceptatiecriteria (uit IDEA-007)
- **AC 1 — Push per story**: Na succesvolle `update_job_status('done')`
pusht de worker via HTTPS (`https://$GITHUB_TOKEN@github.com/…`) naar
origin. Push-timestamp via nieuwe MCP-call in `ClaudeJob.pushed_at`.
- **AC 2 — Detectie laatste story**: Nieuwe MCP-call `check_pbi_complete`
retourneert `{ complete: boolean, pbi_id }`.
- **AC 3 — PR aanmaken**: Op `complete: true` POST naar
`/repos/{owner}/{repo}/pulls`; titel/body uit PBI-naam + voltooide
stories; PR-URL via `set_pbi_pr`.
- **AC 4 — Auto-merge activeren**: Direct na PR-aanmaak GraphQL
`enablePullRequestAutoMerge` (SQUASH).
- **AC 5 — Foutafhandeling**: push/PR-fail →
`update_job_status('failed', error)`; PR-URL blijft bewaard voor
handmatige inspectie.
### B.2 Server-side wijzigingen (Scrum4Me-repo)
Velden bestaan al in schema:
- `Product.auto_pr Boolean @default(false)` (regel 176)
- `Pbi.pr_url String?` + `Pbi.pr_merged_at DateTime?` (regel 207208)
- `ClaudeJob.pushed_at DateTime?` + `ClaudeJob.pr_url String?` +
`ClaudeJob.branch String?` (regel 335, 338, 339)
Geen migratie nodig.
Server actions / REST: bestaande `set_pbi_pr` en `mark_pbi_pr_merged`
MCP-tools blijven. Nieuwe action:
- `actions/jobs.ts``recordJobPushedAtAction(jobId)` voor
`pushed_at`-write (als die nog niet via MCP gaat).
### B.3 MCP-laag (`scrum4me-mcp`-repo)
Nieuwe tool:
- `check_pbi_complete(pbi_id) → { complete: boolean, pbi_id }`. Leest
alle ClaudeJobs gelinkt aan PBI; aggregeert status. `complete = true`
als **alle** story-jobs status DONE hebben.
Uitbreiding bestaande tool:
- `update_job_status`: bij `status: 'done'` ook `pushed_at` accepteren
(worker geeft timestamp door).
- `set_pbi_pr`: ongewijzigd, bestaat al.
Schema-drift watchdog (`docs/runbooks/mcp-integration.md`) moet groen
voor merge.
### B.4 Worker-laag (lokaal Claude-CLI worker)
Nieuwe stappen na elke story:
```
1. update_job_status('done', pushed_at: null) ← bestaand
2. git push https://$GITHUB_TOKEN@github.com/$OWNER/$REPO.git $BRANCH
3. record_pushed_at(job_id, now) ← nieuwe MCP-call
4. { complete } = check_pbi_complete(pbi_id)
5. if complete:
prNumber = POST /repos/.../pulls
set_pbi_pr(pbi_id, pr_url)
enablePullRequestAutoMerge(prNumber, MERGE_METHOD: SQUASH)
6. on any HTTP/git failure → update_job_status('failed', error)
```
GITHUB_TOKEN-scope: `repo` voor private, `public_repo` voor public.
Documenteer in worker-readme.
### B.5 Repo-instellingen (handmatig, one-time)
- GitHub repo Settings → General → "Allow auto-merge" → **aanvinken**.
- Branch protection op `main`: required CI checks = `ci`,
`deploy-preview` is **niet** required (kan skipped zijn door label).
---
## Deel C — Interactie & demo-policy
### C.1 Interactie deploy-controle ↔ auto-PR
| Scenario | Preview-deploy | Prod-deploy bij merge |
|--------------------------------------------------|----------------|------------------------|
| Worker maakt PR met code-changes (default) | ✅ runt | ✅ runt |
| Worker maakt PR met `skip-deploy` (manueel toegevoegd) | ❌ skipped | ✅ runt |
| Worker maakt PR met enkel docs-changes (path-filter) | ❌ skipped | ❌ skipped |
| User voegt `force-deploy` toe aan doc-only PR | ✅ runt | ✅ runt (path-filter) of ❌ (doc-only push) |
Auto-merge wacht op required CI checks. `deploy-preview` mag skipped
zijn — branch protection markeert hem niet als required.
### C.2 Demo-policy
Auto-PR-flow draait op de worker, niet vanuit de webapp. Geen
demo-sessie kan deze code triggeren — geen extra proxy.ts of
`session.isDemo`-guards nodig. Wel: `check_pbi_complete` MCP-call moet
`requireWriteAccess` doen (consistent met andere write-MCP-tools), zodat
demo-tokens hem niet kunnen aanroepen.
---
---
## Deel D — Sync-tab op Idea-detail (zicht op voortgang)
### D.1 Wat bestaat al
- `model StoryLog` (`prisma/schema.prisma:251`) met types
`IMPLEMENTATION_PLAN | TEST_RESULT | COMMIT`, plus `commit_hash`,
`commit_message`, `metadata`. **Dit is de activiteitenlog.**
- MCP-tools `log_implementation`, `log_commit`, `log_test_result`
schrijven naar deze tabel.
- UI-component `components/shared/story-log.tsx` rendert
`StoryLogEntry[]` met type-styling.
- `Story.status`, `ClaudeJob.pushed_at/branch/pr_url`,
`Pbi.pr_url/pr_merged_at` zijn al gevuld door bestaande flows.
Geen nieuwe tabellen, geen migraties.
### D.2 Nieuwe tab op `/ideas/[id]`
Voeg vijfde tab **Sync** toe (naast Idee · Grill · Plan · Timeline) op
Idea-detail-page. Alleen zichtbaar als `Idea.status === 'PLANNED'` en
`pbi_id` gevuld.
Layout per tab-content:
- Header: PBI-link + `pr_url` + `pr_merged_at` als badge.
- Per Story (volgorde uit PBI): collapsible card met:
- **Story-header**: code · titel · status-badge.
- **Job-rij**: voor elke `ClaudeJob` (kind=TASK_IMPLEMENTATION) gelinkt
aan een Task van deze Story → status, `branch`, `pushed_at`,
`pr_url`. Toont "geen job" als nog niets gequeued.
- **Activity-log**: `<StoryLog logs={logs} repoUrl={product.repo_url} />`
— bestaande component, ongewijzigd.
### D.3 Server-laag
Nieuwe loader in `app/(app)/ideas/[id]/page.tsx` (of nieuw
`sync-tab-server.ts`):
```ts
async function loadIdeaSyncData(ideaId: string, userId: string) {
// Auth-scope: idea.user_id === userId (M12-keuze 2)
return prisma.idea.findFirst({
where: { id: ideaId, user_id: userId },
include: {
pbi: {
include: {
stories: {
orderBy: { sort_order: 'asc' },
include: {
tasks: { include: { claude_jobs: true } },
logs: { orderBy: { created_at: 'desc' } },
},
},
},
},
},
})
}
```
Server-only. Nooit importeren in client component (zie hardstop
`*-server.ts` regel).
### D.4 Realtime refresh
Sync-tab abonneert op bestaande SSE-streams:
- `app/api/realtime/solo/route.ts``JobPayload` voor job-status-updates
(al uitgebreid met `kind` en `idea_id` per Deel B).
- `app/api/realtime/notifications/route.ts` — voor StoryLog-inserts; als
story_logs nog geen pg_notify-trigger heeft, voeg er een toe (nieuwe
migratie, payload `{op: 'INSERT', entity: 'story_log', id, story_id}`).
Op event → `router.refresh()` of `revalidate` van Sync-tab data.
### D.5 PBI-33 als live testgeval
PBI-33 is **nu** in TODO + gequeued als ClaudeJobs (gebruiker bevestigt:
"taken op TODO gezet en claude-job aangemaakt"). Verwacht gedrag zodra
deze sprint live is:
| Moment | Sync-tab toont |
|----------------------------|-----------------------------------------------|
| Job QUEUED | "Wachtend op worker" |
| Job RUNNING | Status RUNNING + log-entry IMPLEMENTATION_PLAN|
| Worker commit | log-entry COMMIT (hash + message) |
| Worker test | log-entry TEST_RESULT (status) |
| Worker push (Deel B AC 1) | `branch` + `pushed_at` zichtbaar |
| Laatste story → PR | PBI.`pr_url` zichtbaar |
| Auto-merge | PBI.`pr_merged_at` zichtbaar |
Als één van deze niet verschijnt: bug in MCP-tool of worker (niet in
sync-tab zelf).
---
## Bestanden
| Wijziging | Pad |
|-------------------|--------------------------------------------------|
| Edit | `vercel.json` |
| Edit | `.github/workflows/ci.yml` |
| Nieuw | `docs/runbooks/deploy-control.md` |
| Edit | `CLAUDE.md` (verwijzing toevoegen) |
| Nieuw (mcp-repo) | `src/tools/check-pbi-complete.ts` |
| Edit (mcp-repo) | `src/tools/update-job-status.ts` (pushed_at) |
| Edit | `actions/jobs.ts` (optioneel: record-pushed-at) |
| Edit | Worker-script (post-story-hook + PR-aanmaak) |
| Doc | `docs/runbooks/auto-pr-flow.md` (worker-flow) |
| Nieuw | `app/(app)/ideas/[id]/sync-tab-server.ts` |
| Nieuw | `components/ideas/idea-sync-tab.tsx` |
| Edit | `app/(app)/ideas/[id]/page.tsx` (5e tab toevoegen) |
| Migratie | `prisma/migrations/<ts>_story_logs_notify/migration.sql` (pg_notify-trigger op story_logs) |
| Edit | `app/api/realtime/notifications/route.ts` (story_log-payload doorlaten) |
| GitHub (extern) | Labels `skip-deploy`, `force-deploy` aanmaken |
| GitHub (extern) | Repo Settings → "Allow auto-merge" aan |
| Vercel-dashboard | `git.deploymentEnabled: false` actief verifiëren |
## Implementatievolgorde
1. **Deel A — Deploy-controle**
1. `vercel.json` aanpassen
2. `ci.yml` uitbreiden (path-filter, labels, dispatch)
3. Labels op GitHub aanmaken
4. Runbook + CLAUDE.md-verwijzing
5. Test-PR voor elk scenario (zie Verificatie)
2. **Deel D — Sync-tab** (kan parallel met B; alleen DB-reads + UI)
1. `loadIdeaSyncData` server-loader
2. `idea-sync-tab.tsx` component met `<StoryLog>`-hergebruik
3. 5e tab in `app/(app)/ideas/[id]/page.tsx`
4. pg_notify-trigger op `story_logs` + SSE-route uitbreiden
5. **Live test op PBI-33** (sprint loopt al — check of activity
verschijnt zodra worker logs schrijft)
3. **Deel B — Auto-PR**
1. MCP `check_pbi_complete` + `update_job_status(pushed_at)` PR
(parallel-repo, schema-drift-watchdog groen)
2. Worker-hook: push na done, PR + auto-merge bij complete
3. Repo-instelling "Allow auto-merge" aan
4. End-to-end smoke met één test-PBI
## Verificatie
Lokaal:
```bash
npm run lint && npm test && npm run build
```
Workflow-syntax:
```bash
gh workflow view ci.yml
```
End-to-end deploy-controle:
1. **Doc-only PR**`deploy-preview` skipped.
2. **Doc-only PR + `force-deploy`**`deploy-preview` runt.
3. **Code-PR + `skip-deploy`**`deploy-preview` skipped.
4. **Code-PR zonder labels**`deploy-preview` runt.
5. **Push naar `main` met code-change**`deploy-production` runt.
6. **Push naar `main` doc-only**`deploy-production` skipped.
7. **`workflow_dispatch` target=production** → manuele prod.
8. **Vercel dashboard** → geen auto-deploy bij geforceerde test-push.
End-to-end auto-PR:
9. Maak een test-PBI met 1 story + 1 task.
10. Worker draait → na `done`: `pushed_at` gevuld, branch op origin
zichtbaar.
11. `check_pbi_complete``complete: true`.
12. PR verschijnt op GitHub met titel = PBI-naam, body = story-list.
13. Auto-merge actief; CI groen → squash-merge.
14. `mark_pbi_pr_merged` getriggerd door `pull_request: closed`-webhook
(al bestaand) → `Pbi.pr_merged_at` gevuld.
15. Push-event op `main``deploy-production` runt (path-filter ja).
16. **Failure-test**: revoke GITHUB_TOKEN tijdelijk → push faalt →
`update_job_status('failed')` met error; geen PR aangemaakt.
## Out-of-scope (v1)
- UI-toggle voor `auto_pr` per product (veld bestaat, geen UI-wiring).
- GitHub App-installatie (per-repo tokens, scopes-finetuning).
- Multi-repo PBI's (huidig ontwerp: één `repo_url` per PBI).
- Force-push / non-fast-forward retry-flow.
- Notificaties (Slack, e-mail) bij merge of CI-failure.
- Rollback-flow bij gemergende regressie.
- Migratie naar `vercel.ts` (knowledge-update beveelt het aan; later).
- Auto-skip preview-deploy specifiek voor worker-PRs op basis van
product-instelling.

View file

@ -1,499 +0,0 @@
---
title: Docs-restructuur — geoptimaliseerd voor AI-lookup
status: proposal
audience: maintainer, ai-agent
language: nl
last_updated: 2026-05-02
related:
- CLAUDE.md
- AGENTS.md
- README.md
- docs/decisions/agent-instructions-history.md
---
# Plan — Docs-restructuur voor AI-lookup
> Doel van dit plan: de huidige documentatie- en instructielaag van Scrum4Me omzetten naar een structuur die een AI-agent (Claude Code, Codex, een MCP-worker) in zo min mogelijk tokens en tool-calls het juiste document laat vinden, lezen en toepassen — zonder dat de mens-leesbaarheid eronder lijdt.
Dit is een **proposal**, niet een afgerond ontwerp. Lees het, markeer wat je niet wil, en ik werk het uit naar een uitvoerbaar migratieplan met file-per-file diff.
---
## 1. Waarom dit plan
Een AI-agent doet voor élke beslissing typisch dit:
1. Leest `CLAUDE.md` (of `AGENTS.md`) volledig in context.
2. Scant `docs/` met `ls`/`grep`/`glob` om relevante bestanden te kiezen.
3. Leest één of meerdere docs volledig — vaak meer dan nodig, omdat doc-grenzen vaag zijn.
4. Vindt cross-refs (`zie docs/X#Y`) en herhaalt stap 3.
Elke stap kost tokens en latency. Als de bestandsboom, naamgeving of inhoud onduidelijk is, leest de agent te veel of het verkeerde — en maakt vervolgens beslissingen op verouderde of irrelevante informatie.
**Concrete kosten in deze repo (gemeten 2026-05-02):**
| Plek | Bestanden | Regels totaal | Grootste bestand |
|---|---:|---:|---|
| Root (CLAUDE.md, README.md, AGENTS.md, Brainstorm.md) | 4 | 679 | CLAUDE.md (340) |
| `docs/` (root, exclusief subdirs) | 13 | 5.873 | architecture.md (1.247) |
| `docs/patterns/` | 11 | 1.013 | dialog.md (387) |
| `docs/plans/` | 8 | 2.121 | M10-qr-pairing-login.md (885) |
| `.Plans/` (parallelle plan-historie) | 3 | ~600 | — |
| **Totaal** | **39** | **~10.700** | — |
Bij elke turn die met `CLAUDE.md` start, wordt minimaal 340 regels orientation in de context geladen — vóór er één regel code is gelezen. De agent kan vervolgens uit ~9.000 regels documentatie het juiste fragment moeten kiezen op basis van bestandsnamen alleen, want er is geen front-matter en geen index.
---
## 2. Wat ik aantrof — review per laag
### 2.1 Root-niveau orientation
| Bestand | Wat het doet vandaag | Probleem |
|---|---|---|
| `CLAUDE.md` (340 r) | Doel, doc-index, twee start-tracks, tech stack, UI-conventies, patroon-index, env vars, conventies, branch/commit-strategie, scrum-terminologie, MCP, deployment, DoD | Té breed: oriëntatie + harde regels + referentie-tabellen + procedures. Alles wordt elke turn geladen. |
| `AGENTS.md` (38 r) | Codex-variant van CLAUDE.md | Duplicatie: 80% overlapt met CLAUDE.md (access-control, doc-sync, verificatie). Twee waarheden die uit elkaar kunnen lopen. |
| `README.md` (285 r) | Portfolio-pitch, stack, setup, routes, dev-flow | Mensgericht (recruiters, GitHub-bezoekers). Goed dat het bestaat — niet aanraken. |
| `Brainstorm.md` (16 r) | Stukjes Prisma-schema, JSON-snippet en HTML-DOM-dump zonder context | **Dood bestand**, weghalen of verplaatsen naar `docs/scratch/`. |
### 2.2 `docs/` root
| Bestand | Regels | Waar het thuishoort |
|---|---:|---|
| `architecture.md` | 1.247 | `docs/architecture/` — splitsen (zie §4) |
| `functional.md` | 650 | `docs/specs/functional.md` |
| `backlog.md` | 751 | `docs/backlog/index.md` |
| `product-backlog.md` | 454 | `docs/backlog/product-historical.md` (referentie, zie noot in CLAUDE.md) |
| `personas.md` | 138 | `docs/specs/personas.md` |
| `styling.md` | 670 | `docs/design/styling.md` |
| `md3-color-scheme.md` | 941 | `docs/design/styling.md` (overlapt deels met `styling.md` — kandidaat voor merge) |
| `test-plan.md` | 454 | `docs/qa/api-test-plan.md` |
| `pbi-dialog.md` | 120 | `docs/specs/dialogs/pbi.md` |
| `story-dialog.md` | 163 | `docs/specs/dialogs/story.md` |
| `task-dialog.md` | 127 | `docs/specs/dialogs/task.md` |
| `solo-paneel-spec.md` | 771 | `docs/specs/solo-panel.md` |
| `api.md` | 520 | `docs/api/rest-contract.md` |
| `decisions/agent-instructions-history.md` | 173 | `docs/decisions/agent-instructions.md` (ADR-stijl) |
| `erd.svg`, `icons.html` | — | `docs/assets/` |
**Patroon dat opvalt:** alles met prefix `` — dat prefix is overbodig, je staat al in `docs/` van de Scrum4Me-repo. Verwijderen scheelt visuele ruis bij `ls`.
**Inconsistente capitalization:** één bestand `md3-color-scheme.md` (snake + UpperCamel), de rest kebab. Eén bestand `api.md` (UPPER), de rest lowercase.
### 2.3 `docs/patterns/`
11 patronen, elk 25390 regels. Goed als concept, maar:
- `test.md` bevat letterlijk het woord "test" — junkfile, weghalen.
- Geen front-matter; agent moet titel + intro lezen om te weten of het patroon van toepassing is.
- Naamgeving inconsistent: `iron-session.md`, `qr-login.md`, `claude-question-channel.md` — sommige domeinspecifiek, andere generiek. Geen prefix die laat zien of het een **rule** (verplicht), **recipe** (voorbeeldcode) of **explainer** is.
### 2.4 `docs/plans/`
- 8 plans. Eén heeft een filename met spaties en em-dash: `tweede-claude-agent-planning.md` — breekt grep/glob/git-workflows op sommige shells, en is moeilijk te linken. Omnoemen naar `tweede-claude-agent-planning.md`.
- ST-1109-pbi-status.md verwijst naar `~/.claude/plans/welke-rioriteiten-heeft-een-mighty-shell.md` (ook nog typo "rioriteiten") — externe locatie die niet in de repo zit en die de agent niet kan lezen.
- `MEMORY.md` wordt op meerdere plaatsen genoemd maar bestaat niet in de repo.
### 2.5 `.Plans/` (root)
3 historische planfiles uit april 2026, parallel aan `docs/plans/`. Twee waarheden voor "waar staan plannen". Voorstel: archiveren naar `docs/plans/archive/` of weghalen.
### 2.6 Cross-referenties en dode links
- CLAUDE.md verwijst naar `docs/architecture.md#demo-user-policy` — die anchor bestaat (regel 1068 `## Demo-user policy (ST-1110)`), dus dit is OK; maar er bestaat geen lint die garandeert dat dit zo blijft als de header verandert.
- ST-1109-pbi-status.md verwijst naar `~/.claude/plans/welke-rioriteiten-heeft-een-mighty-shell.md` — externe locatie buiten de repo, plus typo "rioriteiten". Een agent kan die niet lezen.
- README.md verwijst niet naar CLAUDE.md/AGENTS.md (mensbezoekers vinden de agent-instructie laag niet).
- Geen enkel doc heeft een "Zie ook"-blok aan de onderkant. Cross-navigatie tussen patroon ↔ spec ↔ plan moet de agent zelf reconstrueren.
### 2.7 Wat er níet is (en zou moeten)
- **Geen index/manifest.** Een agent die `glob "docs/**/*.md"` doet, krijgt 30+ paden zonder context.
- **Geen front-matter.** Geen status (draft/active/deprecated), audience, last-updated, related.
- **Geen ADR-laag.** Beslissingen zoals "waarom geen Radix maar @base-ui/react", "waarom float sort_order", "waarom one-branch-per-milestone" zitten verstrooid in CLAUDE.md, README en losse plans. Een `docs/decisions/`-folder met ADR-format zou ze vindbaar maken.
- **Geen glossary.** Domeintermen (PBI, Story, Sprint, Solo, Todo, demo-token) zijn alleen impliciet gedefinieerd in de functional spec.
- **Geen "lookup-hints" in de doc-index.** CLAUDE.md zegt *waarvoor* je een doc gebruikt, niet *wanneer je het NIET hoeft te lezen*.
---
## 3. Doelen voor de nieuwe structuur
In volgorde van belangrijkheid:
1. **Eén goedkope orientation-laag.** Een agent moet ≤150 regels lezen om te weten waar hij verder moet kijken.
2. **Voorspelbare paden.** `docs/<topic>/<entity-or-feature>.md` zonder uitzonderingen.
3. **Machine-leesbare metadata.** YAML-front-matter op élk doc met minimaal `status`, `audience`, `last_updated`, `related`.
4. **Per-doc lookup-hint.** Eén zin "Lees dit als …" bovenaan; één zin "Niet hiervoor lezen: …" om verkeerd ophalen te voorkomen.
5. **Splitsing van regels (verplicht) en uitleg (referentie).** Regels in een korte rule-doc; voorbeeldcode en rationale in een aparte recipe/explainer.
6. **Eén bron-van-waarheid per onderwerp.** Geen Codex-vs-Claude-duplicatie; AGENTS.md wordt een 10-regelige verwijzing naar CLAUDE.md.
---
## 4. Voorgestelde doelstructuur
```
/ (repo-root)
├── README.md (mens, portfolio — ongewijzigd)
├── CLAUDE.md (agent-orientation, ≤150 regels — zie §5)
├── AGENTS.md (10 regels: "alles in CLAUDE.md geldt ook voor jou")
├── docs/
│ ├── INDEX.md (NIEUW — manifest met front-matter van alle docs)
│ ├── glossary.md (NIEUW — PBI, Story, Sprint, demo-token, …)
│ │
│ ├── architecture/
│ │ ├── overview.md (uit huidige architecture.md §1§3)
│ │ ├── data-model.md (uit §Datamodel + §Prisma Schema)
│ │ ├── auth-and-sessions.md (uit §Authenticatieflow)
│ │ ├── qr-pairing.md (uit §QR-pairing flow)
│ │ ├── claude-question-channel.md (uit §Vraag-antwoord-kanaal)
│ │ └── project-structure.md (uit §Projectstructuur)
│ │
│ ├── specs/
│ │ ├── functional.md (huidige functional.md)
│ │ ├── personas.md
│ │ ├── solo-panel.md
│ │ └── dialogs/
│ │ ├── pbi.md
│ │ ├── story.md
│ │ └── task.md
│ │
│ ├── design/
│ │ ├── styling.md (samengevoegd uit styling + MD3-color)
│ │ └── color-tokens.md (alleen het token-overzicht)
│ │
│ ├── api/
│ │ ├── rest-contract.md (huidige api.md)
│ │ └── error-codes.md (afgesplitst — vandaag verspreid)
│ │
│ ├── patterns/ (RULES — kort en bindend)
│ │ ├── 00-conventions.md (server-action, prisma-client, route-handler — kort)
│ │ ├── dialog.md
│ │ ├── sort-order.md
│ │ ├── zustand-optimistic.md
│ │ ├── iron-session.md
│ │ ├── proxy.md (was middleware.md — nieuwe naam)
│ │ ├── qr-pairing.md
│ │ └── claude-question-channel.md
│ │
│ ├── recipes/ (NIEUW — uitgewerkte voorbeeldcode bij rules)
│ │ └── … (één recipe per pattern dat code-snippets had)
│ │
│ ├── runbooks/ (NIEUW — operationele procedures)
│ │ ├── deploy-vercel.md (uit CLAUDE.md §Deployment)
│ │ ├── env-vars.md (uit CLAUDE.md §Env vars + .env.example)
│ │ └── local-dev.md (huidige README §setup, geëxtraheerd)
│ │
│ ├── decisions/ (NIEUW — ADR-stijl)
│ │ ├── 0001-base-ui-over-radix.md
│ │ ├── 0002-float-sort-order.md
│ │ ├── 0003-one-branch-per-milestone.md
│ │ ├── 0004-status-enum-mapping.md
│ │ └── 0005-agent-instructions.md (was decisions/agent-instructions-history.md)
│ │
│ ├── backlog/
│ │ ├── index.md (huidige backlog.md)
│ │ └── product-historical.md (huidige product-backlog.md)
│ │
│ ├── plans/
│ │ ├── M9-active-product-backlog.md
│ │ ├── M10-qr-pairing-login.md
│ │ ├── M11-claude-questions.md
│ │ ├── ST-1109-pbi-status.md
│ │ ├── ST-1110-demo-readonly.md
│ │ ├── ST-1111-claude-job-trigger.md
│ │ ├── ST-1114-copilot-reviews.md
│ │ ├── tweede-claude-agent-planning.md (rename — geen spaties/em-dash)
│ │ └── archive/ (uit `.Plans/` aan repo-root)
│ │ ├── 2026-04-27-claude-md-workflow-update.md
│ │ ├── 2026-04-27-insert-milestone-tool.md
│ │ └── 2026-04-27-m8-realtime-solo.md
│ │
│ ├── qa/
│ │ └── api-test-plan.md
│ │
│ └── assets/
│ ├── erd.svg
│ └── icons.html
└── .Plans/ (WEG — naar docs/plans/archive/)
└── Brainstorm.md (WEG — junk, of naar docs/scratch/)
└── docs/patterns/test.md (WEG — junk)
```
**Prefix `` overal weg.** Je staat in de Scrum4Me-repo.
**Alle bestandsnamen kebab-case, lowercase.** Geen `api.md`, geen `MD3_…`.
---
## 5. CLAUDE.md herontwerp
CLAUDE.md wordt strikt **router + harde regels** — geen referentie-tabellen, geen voorbeelden, geen rationale.
Voorgestelde nieuwe inhoud (max ~150 regels):
```markdown
# CLAUDE.md — Scrum4Me
## 1. Wat is Scrum4Me
(2 zinnen — link naar README voor de pitch)
## 2. Eerst lezen, altijd
- docs/INDEX.md — manifest van alle docs
- docs/glossary.md — bij twijfel over een term
## 3. Hoe je werk vindt
Twee tracks (A: MCP, B: manueel) — verkort tot 10 regels.
Detail: docs/runbooks/sprint-flow.md
## 4. Hardstop-regels (nooit overtreden)
- demo-user heeft geen schrijfrechten (3-laagsdekking)
- @base-ui/react, niet Radix
- nooit bg-blue-500, altijd MD3-tokens
- één commit = één verantwoordelijkheid
- één branch per milestone, push pas na user-approval
- denormalized FKs uit DB-parent, niet uit client-input
(elk punt → 1 regel + link naar pattern/decision)
## 6. Stack op één regel per laag
(geen versie-uitleg, link naar docs/architecture/overview.md)
## 7. Snelreferentie patronen
| Wanneer | Lees |
|---|---|
| Server Action schrijven | docs/patterns/server-action.md |
| Drag-and-drop reorder | docs/patterns/sort-order.md |
| …(max 10 rijen)…|
## 8. Verificatie vóór hand-off
`npm run lint && npm test && npm run build`
```
Alles wat nu in CLAUDE.md §Conventies, §Branch & PR Strategy, §Commit Strategy, §MCP-integratie, §Deployment staat → verhuist naar:
- `docs/runbooks/branch-and-commit.md` (regels + voorbeelden samen)
- `docs/runbooks/deploy-vercel.md`
- `docs/runbooks/mcp-integration.md`
- `docs/decisions/0003-one-branch-per-milestone.md` (waarom)
CLAUDE.md houdt in §4 alleen de éénregelige regel + link.
**Effect:** elke turn 150 r in plaats van 340 r aan orientation-context. De agent leest aanvullende docs alleen wanneer de huidige taak ze raakt.
---
## 6. Front-matter spec
Élk markdown-bestand in `docs/` (en `CLAUDE.md`, `AGENTS.md`) krijgt bovenaan:
```yaml
---
title: <korte titel>
status: draft | active | deprecated
audience: ai-agent | maintainer | contributor | external
language: nl | en
last_updated: 2026-05-02
applies_to: [feature-of-module-of-milestone-keys] # optioneel
related:
- docs/<andere>.md
- CLAUDE.md
when_to_read: <één zin>
do_not_read_for: <één zin voorkomt mis-fetch>
---
```
**Waarom dit voor AI-lookup helpt:**
- `status: deprecated` → agent slaat het over zonder te lezen.
- `applies_to: [M10, qr-login]` → grep op milestone-key → directe hit.
- `when_to_read` / `do_not_read_for` → agent kan beslissen op de eerste 20 regels of dit doc nuttig is, zonder de hele 800-regelige spec in te lezen.
- `related` → expliciete graaf in plaats van impliciete cross-refs.
`docs/INDEX.md` wordt automatisch gegenereerd uit deze front-matter (klein script in `scripts/build-docs-index.ts` — onderdeel van het migratieplan).
---
## 7. AGENTS.md herontwerp
```markdown
# AGENTS.md
This repo's source of truth for agent instructions is **CLAUDE.md**.
Codex, Cursor, Continue, and any other coding agent: read CLAUDE.md first.
The same product, security, and verification rules apply regardless of which agent runs.
Repo-specific addendum (only if your agent does NOT speak markdown well):
- The "This is NOT the Next.js you know" note also applies to you.
- Run `npm run lint && npm test && npm run build` before handing work back.
```
Geen duplicatie van access-control of doc-sync — die regels staan exclusief in CLAUDE.md / `docs/patterns/` / `docs/decisions/`.
---
## 8. Migratie in fases
Elke fase is een eigen branch + PR. Geen big-bang. Volgorde gekozen zodat agents tijdens de migratie nog steeds werken.
### Fase 1 — Junk weg, front-matter erbij (laag risico)
- `docs/patterns/test.md` weghalen.
- `Brainstorm.md` weghalen of `docs/scratch/brainstorm-2026-05.md`.
- `.Plans/``docs/plans/archive/`.
- Front-matter toevoegen aan élk bestaand bestand (zonder verplaatsen). Status default = `active`.
- `docs/INDEX.md` genereren via script.
**Voor commit:** alle bestaande paden werken nog. Geen risico voor lopende sessies of CI.
### Fase 2 — Naamgeving normaliseren
- `` prefix overal weg via `git mv` (1 commit per groep — backlog/specs/personas/styling/dialogs).
- `api.md``api/rest-contract.md`.
- `md3-color-scheme.md``design/md3-color-scheme.md`.
- `tweede-claude-agent-planning.md``plans/tweede-claude-agent-planning.md`.
- `middleware.md``proxy.md` (volgt Next.js 16 hernoeming).
- Per `git mv`: in dezelfde commit zoek-en-vervang alle interne links + CLAUDE.md doc-index.
### Fase 3 — Folder-taxonomie
- Maak `docs/architecture/`, `docs/specs/`, `docs/design/`, `docs/api/`, `docs/runbooks/`, `docs/decisions/`, `docs/backlog/`, `docs/qa/`, `docs/assets/`.
- Verplaats per groep met `git mv`. Eén commit per groep.
- Update CLAUDE.md doc-index per stap.
### Fase 4 — Splits monolithische docs
- `architecture.md` (1.247 r) opdelen in 6 docs onder `docs/architecture/`.
- Originele file wordt 20 regels: titel + "Dit document is opgesplitst — zie:" + lijst met nieuwe paden.
- Idem voor `solo-paneel-spec.md` als dat onderdelen heeft die naar specs én patterns kunnen.
### Fase 5 — CLAUDE.md verkorten + AGENTS.md verkorten
- Knip CLAUDE.md naar het skelet uit §5.
- Verplaats verwijderde secties naar `docs/runbooks/` en `docs/decisions/`.
- AGENTS.md vervangen door de versie uit §7.
### Fase 6 — ADR-backfill
- Schrijf ADR's voor de impliciete beslissingen (58 stuks):
1. base-ui-over-radix
2. float-sort-order
3. one-branch-per-milestone
4. status-enum-mapping (db UPPER ↔ api lower)
5. iron-session-over-nextauth
6. demo-user-policy (3-laags)
7. claude-question-channel-design
8. agent-instructions-policy (was audit)
### Fase 7 — Glossary + index-script
- `docs/glossary.md` schrijven (PBI, Story, Sprint, Solo, Todo, demo-token, MCP-job, …).
- `scripts/build-docs-index.ts` — genereert `docs/INDEX.md` uit alle front-matters.
- Husky pre-commit hook: index regenereren bij wijziging van front-matter.
### Fase 8 — Cross-link-check
- Klein script dat alle `docs/...md` links volgt en rapporteert dode links én anchor-misses.
- Toevoegen aan `npm run lint` of `npm test`.
---
## 9. Wat dit oplevert (meetbaar)
| Metric | Vandaag | Doel |
|---|---:|---:|
| Regels die elke agent-turn standaard in context komen (CLAUDE.md) | 340 | ≤150 |
| Doc-bestanden in `docs/` root | 13 | 2 (INDEX.md, glossary.md) |
| Doc-bestanden zonder front-matter | 36 | 0 |
| Junk-bestanden | 3 (test.md, Brainstorm.md, .Plans/) | 0 |
| Bestandsnamen met spaties of niet-ASCII | 1 | 0 |
| Filename-prefixen die geen informatie toevoegen (``) | 8 | 0 |
| Documenten >800 regels | 4 | 0 (na splitsing) |
| Dode interne links | onbekend | 0 (na lint) |
---
## 10. Wat dit níet oplevert (eerlijk)
- Codequaliteit verbetert niet automatisch.
- Patronen die nu fout zijn worden niet gefixt — alleen vindbaar gemaakt.
- ADR's invullen kost denkwerk dat ik niet uit jouw hoofd kan halen — fase 6 vereist jouw input.
- AI-agents die geen front-matter parseren (oudere modellen, sommige codex-flavors) profiteren minder. Voor de `docs/INDEX.md` is het wel platte tekst — die helpt iedereen.
---
## 11. Open beslissingen — status
| # | Vraag | Besluit (2026-05-02) |
|---|---|---|
| 1 | Taal van docs, front-matter, INDEX.md | **English** — alle nieuwe en herschreven docs in het Engels. Code comments blijven Engels (al zo). UI-strings blijven Nederlands. |
| 2 | MD3-color + styling samenvoegen | **Eén doc**`docs/design/styling.md`. |
| 3 | `solo-paneel-spec.md` | **Samenvoegen** — opgaan in `docs/specs/functional.md` (eigen sectie). |
| 4 | `.Plans/` archief | **Bewaren** — verplaatsen naar `docs/plans/archive/`. |
| 5 | ADR-template (Nygard vs MADR) | **In discussie** — referentielink gedeeld, ik heb die niet kunnen openen (claude.ai share staat niet op de fetch-allowlist). Default voor fase 6: Nygard, klein en passend bij solo + kleine repo. Vervangbaar zodra besluit valt. |
| 6 | Index-generator | **Node-script** in `scripts/build-docs-index.ts`. |
## 12. Implicaties van besluit 1 — taalwissel
De keuze "alle docs Engels" is groter dan hij lijkt. Drie scope-niveaus:
**Niveau A — going-forward only (kleinste scope):**
- Élk nieuw doc en élke nieuw aangemaakte front-matter in het Engels.
- Dit plan, INDEX.md, glossary.md, runbooks/, decisions/ — allemaal Engels vanaf creatie.
- Bestaande Nederlandse docs blijven staan tot ze om een andere reden geraakt worden.
- **Risico:** mengvormen in docs/ — een agent vindt ene helft Engels, andere helft Nederlands. Grep op een Engels keyword mist Nederlandse hits.
**Niveau B — opportunistic (middel):**
- Niveau A + élke doc die we aanraken voor de restructuur (renames, splitsingen, front-matter toevoegen) wordt meteen vertaald.
- Aan het eind van fase 5 zijn `architecture/`, `specs/`, `design/`, `api/`, `patterns/`, `runbooks/`, `decisions/` allemaal Engels.
- Backlog, plans en QA blijven Nederlands tenzij ze ge-edit worden.
**Niveau C — full sweep:**
- Élke `.md` in de repo vertalen, ongeacht of de restructuur hem aanraakt.
- Aanzienlijk werk: ~10.700 regels prose. Schatting: 1-2 dagen agent-tijd of een batch-translate-pass met review.
**Voorstel: niveau B.** Sluit aan bij de migratiefases zonder een aparte translation-sprint te hoeven plannen. Niveau C kan later als één losse PR.
## 13. ADR-template — voorlopige keuze
Tot besluit valt op vraag 5: ik gebruik **Nygard-Light** als template voor fase 6. Eén ADR is één bestand met:
```markdown
---
title: <decision title>
adr_number: 0001
status: proposed | accepted | superseded by 00xx
date: 2026-05-02
---
# 0001. <decision title>
## Context
Why this decision matters now. What forces are in play.
## Decision
The choice we make. One paragraph, declarative.
## Consequences
What becomes easier, what becomes harder, what we accept.
```
Compact, grep-vriendelijk, en agent-leesbaar binnen ~30 regels. Als MADR de uitkomst wordt, swappen we het template voor fase 6 — alle eerdere fases zijn template-onafhankelijk.
## 14. Volgende stap
Met deze besluiten kan ik fase 1 omzetten naar een concrete uitvoeringslijst:
- exacte `rm` / `git mv` / `mkdir` commando's
- de front-matter-template in het Engels
- één `npm run` of bash-snippet die de hele fase in één commit zet
- bijbehorende update-diff voor CLAUDE.md (alleen de doc-index-tabel)
Zeg het woord en ik produceer dat als `docs/plans/docs-restructure-phase-1.md`.
---
## Verificatie van dit plan
- [x] Bestandsnamen en regelaantallen gecheckt tegen `find docs -type f` + `wc -l` op disk (2026-05-02).
- [x] Cross-refs in CLAUDE.md gegrep't en op bestaan getoetst.
- [x] Geen voorgestelde nieuwe paden conflicteren met bestaande.
- [x] Open beslissingen (§11) afgehandeld door maintainer (2026-05-02) — vraag 5 voorlopig op default.
- [ ] ADR-template definitief vastgesteld (vraag 5).
- [ ] Migratie fase 1 omgezet naar uitvoerbaar PR-plan (`docs/plans/docs-restructure-phase-1.md`).

View file

@ -28,7 +28,7 @@ notes: |
## 1. Context (this becomes the PBI description)
This PBI executes the docs-restructure plan
([`docs/plans/docs-restructure-ai-lookup.md`](./docs-restructure-ai-lookup.md))
([`docs/old/plans/docs-restructure-ai-lookup.md`](../old/plans/docs-restructure-ai-lookup.md))
over eight phases, mapped here as eight stories with three to eight tasks
each. The goal is to cut the documentation surface an AI agent has to read
to find the right reference, without breaking existing workflows.
@ -81,7 +81,7 @@ in parallel with Stories 35 if you want.
### Where to look first
- This file (the PBI context block above).
- [`docs/plans/docs-restructure-ai-lookup.md`](./docs-restructure-ai-lookup.md)
- [`docs/old/plans/docs-restructure-ai-lookup.md`](../old/plans/docs-restructure-ai-lookup.md)
— the full plan, especially §3 (Goals), §4 (Target structure), §6
(Front-matter spec), §8 (Phased migration).
- [`docs/adr/README.md`](../adr/README.md) — when writing an ADR in
@ -143,7 +143,7 @@ pbi:
- One commit per logical layer (`docs(<story-slug>):` prefix).
- No pushes without user approval.
- Update every internal link in the same commit as a rename.
Read docs/plans/docs-restructure-ai-lookup.md §3, §4, §6, §8 first.
Read docs/old/plans/docs-restructure-ai-lookup.md §3, §4, §6, §8 first.
priority: 2
stories:
@ -337,7 +337,7 @@ pbi:
acceptance_criteria: |
- docs/ root contains only INDEX.md and (later) glossary.md.
- All existing docs moved into the right folder per
docs/plans/docs-restructure-ai-lookup.md §4.
docs/old/plans/docs-restructure-ai-lookup.md §4.
- Internal links updated in the same commit as each move.
- `npm run docs:index` shows docs grouped correctly.
priority: 2

View file

@ -7,6 +7,9 @@ last_updated: 2026-05-03
applies_to: [SCRUM4ME]
story_id: cmoq2qoik0001qa175iynfnaa
pbi_id: cmoq2q50s0000qa174rmrjove
archived: true
archived_reason: niet-uitgevoerd, uit standaard sessiecontext gehouden
archived_at: 2026-05-11
---
# Landing v2 — lokaal & veilig + architectuurdiagram

View file

@ -8,6 +8,9 @@ applies_to: [SCRUM4ME]
story_id: cmot8226500017h174z5qpphx
story_code: ST-1224
pbi_id: cmoq2q50s0000qa174rmrjove
archived: true
archived_reason: niet-uitgevoerd, uit standaard sessiecontext gehouden
archived_at: 2026-05-11
---
# Landing v3 — van idee tot pull request

View file

@ -1,205 +0,0 @@
# Scrum4Me-Research — Zustand rearchitecture (reset + execute)
> **Scope:** dit plan is geschreven voor de research-repo
> [`madhura68/Scrum4Me-Research`](https://github.com/madhura68/Scrum4Me-Research),
> niet voor dit hoofdproject. Bestandsverwijzingen die naar
> `stores/data-store.ts`, `hooks/use-event-stream.ts`,
> `components/*-select.tsx` etc. wijzen, bestaan in de research-repo —
> niet hier. Ze staan in `code`-tags zodat de doc-link-checker ze niet
> probeert te resolven.
## Context
Het bestaande [zustand-store-rearchitecture.md](./zustand-store-rearchitecture.md) beschrijft een doel-architectuur (`product-workspace-store` met genormaliseerde entities, race-safe loaders, resync-laag, optimistic mutations). De research-repo is dé plek om dat eerst te testen voordat het in `Scrum4Me/` belandt.
Probleem nu: de research-repo wijkt af van het hoofdproject. Mijn custom `data-store.ts` lijkt qua vorm op de doel-architectuur, maar springt over de baseline heen. We willen aantonen dat de migratie *vanaf* de huidige Scrum4Me-patronen werkt, niet vanaf een verzonnen tussenvorm.
Dus: eerst de research-repo terugbrengen naar dezelfde stores/hooks/routes als Scrum4Me nu heeft, dan de rearchitecture daarop uitvoeren.
## Bron-documenten
- **Doel-architectuur**: [zustand-store-rearchitecture.md](./zustand-store-rearchitecture.md) (in research-repo). Dit plan voert dat document uit; herhaalt het niet.
- **Conventies**: [CLAUDE.md](../../CLAUDE.md) hoofdproject. Taal NL, MD3 tokens, `@base-ui/react` render-prop, `*-server.ts`, enum UPPER_SNAKE↔lowercase via `lib/task-status.ts`.
## Drie-faseplan
### Fase A — Reset naar Scrum4Me-patronen
Doel: onze research-pagina werkt op exact dezelfde store/hook/route-vorm als het hoofdproject, met identiek gedrag.
**Verwijderen** (research-repo):
- `stores/data-store.ts` (research-repo) — mijn megastore
- `hooks/use-event-stream.ts` (research-repo) — vervangen door `use-backlog-realtime.ts`
- `hooks/use-browser-presence.ts` (research-repo) — niet in main, drop voor reset
- `app/api/realtime/events/route.ts` (research-repo) — vervangen door `app/api/realtime/backlog/route.ts`
- Mijn custom `loadX/resyncAll`-paden in selectie-componenten
**Kopiëren uit `/Users/janpetervisser/Development/Scrum4Me/`** (1-op-1 of stripped van auth):
| Bron | Doel |
|---|---|
| `stores/backlog-store.ts` | `stores/backlog-store.ts` (`pbis`, `storiesByPbi`, `tasksByStory`; `setInitialData`, `applyChange`) |
| `stores/planner-store.ts` | `stores/planner-store.ts` (DnD-order; voor research nog niet gebruikt maar we zetten 'm klaar) |
| `stores/selection-store.ts` | overschrijf bestaand (state: `selectedPbiId`, `selectedStoryId`, geen taskId/productId in main; add `selectedTaskId` + `productId` als research-uitbreiding) |
| `stores/product-store.ts` | `stores/product-store.ts` (`currentProduct`) |
| `stores/products-store.ts` | `stores/products-store.ts` (lijst, voor pulldown) |
| `lib/realtime/use-backlog-realtime.ts` | `lib/realtime/use-backlog-realtime.ts` (SSE-client → `applyChange` op backlog-store) |
| `lib/task-status.ts` | `lib/task-status.ts` (enum-converters) |
| `app/api/realtime/backlog/route.ts` | `app/api/realtime/backlog/route.ts` (SSE+LISTEN, **research-only: strip auth/session/getAccessibleProduct** — vraagt enkel `product_id` querystring) |
> ⚠️ **Auth-strip is research-only.** Het hoofdproject MOET sessie + `getAccessibleProduct()`-check op SSE en read-routes behouden. Bij backport vanaf de research-repo nooit de geknipte route 1-op-1 overnemen. Dit geldt voor zowel `app/api/realtime/backlog/route.ts` als alle read-routes onder `app/api/products/...`, `/pbis/...`, `/stories/...`, `/tasks/...`.
**API-routes (read)** — bestaande paden behouden, alleen `force-dynamic` blijft:
- `GET /api/products` (list voor pulldown)
- `GET /api/products/[id]/pbis` (open: READY+BLOCKED)
- `GET /api/pbis/[id]/stories`
- `GET /api/stories/[id]/tasks`
- `GET /api/tasks/[id]`
**Componenten herschrijven**:
- `components/product-select.tsx` (research-repo) → leest `useProductsStore`, schrijft naar `useProductStore.setCurrentProduct`
- `components/pbi-select.tsx` (research-repo) → leest `useBacklogStore` (filter op currentProduct), `useSelectionStore.selectPbi`. Triggert fetch op product-mount via een `useBacklogLoader`-helper die initial data binnenhaalt.
- `components/story-select.tsx` (research-repo) → idem voor stories
- `components/tasks-table.tsx` (research-repo) → leest `tasksByStory[selectedStoryId]`. **Max 10 rijen, scrollbaar** (al ingebouwd, behouden)
- `components/task-detail-card.tsx` (research-repo) → fetcht task detail apart (geen full-fat backlog veld; matcht main's `tasks/[id]` route)
- `components/event-stream-panel.tsx` (research-repo) → blijft bestaan voor research-doel (event-tap), maar luistert nu mee op dezelfde EventSource via `use-backlog-realtime` (of een tweede readonly listener); selecteerbare events met JSON-detail rechts blijven. Twee checkboxes (Postgres / Browser). Truncate met ellipsis in de lijst.
**Werkwijzen (verifiëren tijdens reset)**:
- Comments en UI-tekst NL
- Geen `bg-blue-500` etc; enkel MD3 tokens (`bg-primary`, `bg-card`, `bg-status-done`, ...)
- shadcn-componenten al `base-nova` style
- Server-only files krijgen `*-server.ts` suffix waar van toepassing (in deze fase niet nodig — alle DB-toegang loopt via `lib/prisma.ts` in route handlers)
- TaskStatus-mapping via `lib/task-status.ts` als de UI lowercase wil
**Acceptatie Fase A**:
- `npx tsc --noEmit` schoon
- Pagina rendert, cascading werkt, tabel toont taken, detail-card vult, events stromen door (preview-verificatie)
- Stores matchen het hoofdproject qua vorm (vergelijking via `diff` uitvoerbaar voor backlog-store etc.)
### Fase B — Rearchitecture uitvoeren
Volgt de 15 stappen uit [zustand-store-rearchitecture.md](./zustand-store-rearchitecture.md) §Implementatiepad. Concreet voor de research-repo:
1. **Map** `stores/product-workspace/` aanmaken (factory + provider + selectors).
2. **`activeProduct`** wordt nu nog gespiegeld vanuit `useProductStore`; voor de research-pagina geen layout/server-side bepaling — we lezen het uit de pulldown-state.
3. **Selection migreren**`selection-store``context.{activePbiId, activeStoryId, activeTaskId}` + `productId`. Setters cascaden de reset naar children (zoals doc beschrijft).
4. **Backlog naar entities + relations**`pbisById`, `storiesById`, `tasksById`, `pbiIds`, `storyIdsByPbi`, `taskIdsByStory`. Selectors:
- `selectVisiblePbis(productId)`
- `selectStoriesForActivePbi(state)`
- `selectTasksForActiveStory(state)`
- `selectActivePbi/Story/Task(state)`
5. **Planner-state** in dezelfde workspace-store landen (`relations` slice); voor research: niet actief gebruikt, wel structureel meekoppen.
6. **Race-safe loaders**`ensureProductLoaded`, `ensurePbiLoaded`, `ensureStoryLoaded`, `ensureTaskLoaded` met `requestId`-guard. Implementatie:
```ts
setActivePbi(pbiId) {
const requestId = crypto.randomUUID()
set({ context: { ..., activePbiId: pbiId, ... }, loading: { ..., activeRequestId: requestId } })
void get().ensurePbiLoaded(pbiId, requestId)
}
// in ensure: if (get().loading.activeRequestId !== requestId) return
```
7. **localStorage = restore hints**`lastActivePbiIdByProduct`, `lastActiveStoryIdByProduct`, `lastActiveTaskIdByProduct`. Niet de waarheid, alleen hint die getoetst wordt aan toegankelijkheid.
8. **`use-backlog-realtime` dispatcht naar `applyRealtimeEvent`** — store interpreteert pbi/story/task I|U|D events, doet upsert + parent-id move + sort.
9. **Hidden tab beleid**`EventSource` openhouden bij `hidden`. Op `visible``resyncActiveScopes('visible')`.
10. **Reconnect resync** — bij `ready` na disconnect of na exponential backoff: `resyncActiveScopes('reconnect')`.
11. **Unknown-event fallback** — onbekend event met `payload.product_id === activeProductId``resyncActiveScopes('unknown-event')`. Dit is wat het "veel events maar geen update" issue oplost.
12. **`force-dynamic` + `cache: 'no-store'`** — al gedaan in mijn fixes; behouden bij reset en versterken.
13. **Componenten naar selectors** — backlog-componenten lezen via `selectStoriesForActivePbi` etc., niet via raw store-velden.
14. **Tests** (Vitest, conform main):
- hydrate snapshot
- active selectie cascade
- race-safe ensure (laat trage promise van oude selectie geen nieuwe data overschrijven)
- SSE I|U|D voor pbi/story/task
- parent-move (story verandert van pbi)
- hidden→visible resync
- reconnect resync
- unknown-event resync
- delete-cleanup van actieve selectie
- localStorage restore-hint validatie tegen toegankelijkheid
15. **Sprint-workspace** — buiten scope; flag voor latere herhaling.
**Optimistic mutations** (§Optimistic in doc): voor research geen DnD, dus alleen het patroon dropunten en niet bouwen. Wel: `applyOptimisticMutation`-action wel klaarzetten in de store-API zodat het patroon zichtbaar is.
### Fase C — Werkwijzen verweven en doortrekken
**Tijdens Fase A én B respecteren**:
1. **Plan mode workflow** — eerst Plan, ExitPlanMode, dan code. Bij grote wendingen opnieuw plannen.
2. **TodoWrite** voor multi-step werk; markeer immediate completion.
3. **Verify via preview** voor elke observable verandering (de hook reminder doet dit al).
4. **`tsc --noEmit`** voor afronden van een stap.
5. **Comments/Dutch** consistent. WHY-comments over de invariant; geen WHAT-comments.
6. **MD3 tokens** alleen.
7. **Geen secrets in chat**`.env.local` blijft lokaal.
8. **Niet schrijven naar shared DB** zonder expliciete user-toestemming (geen `pg_notify` op shared channel).
9. **Source of truth = DB**. Zustand is projectie. localStorage = hint.
10. **Vóór elke fase**: kort statusrapport in de chat met wat er aankomt en waarom.
**Doortrekken naar hoofdproject** (out-of-scope deze run, maar geflagd):
- Na bewezen werking in research-repo: backport `product-workspace-store` + selectors + realtime-apply + resync-laag naar `Scrum4Me/stores/product-workspace/`.
- **Niet backporten**: de auth-stripped routes uit research. Main behoudt iron-session, `getAccessibleProduct()`, en alle product-access/sprint/personal filters in z'n SSE- en read-routes.
- **Wel backporten**: store-shape, selectors, race-safe `ensure*Loaded`, hidden-tab beleid, `resyncActiveScopes`, unknown-event fallback, restore-hint patroon, `force-dynamic` + `cache: 'no-store'`.
- Migratie main-project zal langer duren (DnD, sprint, jobs, tests). Apart plan.
## Bestandsmutaties (overzicht)
### Verwijderen na Fase A
- `stores/data-store.ts` (research-repo)
- `hooks/use-event-stream.ts` (research-repo)
- `hooks/use-browser-presence.ts` (research-repo) — komt deels terug in Fase B als helper voor visibility/online resync trigger
- `app/api/realtime/events/route.ts` (research-repo)
### Toevoegen Fase A (uit Scrum4Me)
- `stores/backlog-store.ts`
- `stores/planner-store.ts`
- `stores/selection-store.ts` (overschrijf)
- `stores/product-store.ts`
- `stores/products-store.ts`
- `lib/realtime/use-backlog-realtime.ts`
- `lib/task-status.ts`
- `app/api/realtime/backlog/route.ts` (zonder auth)
### Toevoegen Fase B (nieuw, conform doc)
- `stores/product-workspace/store.ts` (zustand factory)
- `stores/product-workspace/selectors.ts`
- `stores/product-workspace/types.ts`
- `stores/product-workspace/restore.ts` (localStorage hints)
- `stores/product-workspace/realtime-apply.ts` (SSE event → store)
- `stores/product-workspace/resync.ts` (`resyncActiveScopes`, `resyncLoadedScopes`)
- `tests/product-workspace/*.test.ts` (Vitest, install vitest als devDep)
### Te aanpassen in Fase B
- Alle `components/*.tsx` (nu shadcn select/table/card panels) → consumeren via selectors uit workspace-store
- `lib/realtime/use-backlog-realtime.ts` → dispatcht `applyRealtimeEvent` naar workspace-store i.p.v. `applyChange` naar backlog-store
- `event-stream-panel.tsx` → blijft bestaan (research-tap), maar leest events ook uit workspace-store of via een dunne `event-log-store` ernaast (in bounded-context-stijl: aparte log-store voor pure observatie hoort er niet thuis in de workspace-store)
## Verificatie
### Na Fase A (baseline)
1. `npm run dev` op port 3001
2. Pagina laadt, cascading werkt: product → PBI → story → tasks
3. Detail-card vult bij klik op task
4. Event-paneel toont realtime events (truncate + JSON-detail)
5. `npx tsc --noEmit` schoon
6. Vergelijk: `diff Scrum4Me/stores/backlog-store.ts Scrum4Me-Research/stores/backlog-store.ts` → identiek (modulo lokale interface-uitbreidingen waar gedocumenteerd)
### Na Fase B (target)
Alle acceptatiecriteria uit [zustand-store-rearchitecture.md §Acceptatiecriteria](./zustand-store-rearchitecture.md):
- Eén waarheid per entity in de store ✓
- Selectors als enige UI-leesweg ✓
- SSE patcht zonder full-page refresh ✓
- Hidden→visible herstelt missers binnen één resync-cyclus ✓
- Reconnect resync werkt zonder NOTIFY-replay ✓
- Directe task-edits zonder `entity:'task'` NOTIFY worden via unknown-event fallback zichtbaar ✓
- LocalStorage = hint, geen forced state ✓
- `force-dynamic` + `cache: 'no-store'` overal ✓
### Manuele preview-verificatie (na elke fase)
- TODO via TodoWrite tijdens uitvoer; preview-screenshot na grote stappen
- Tab-switch test: open page, switch tab, doe een wijziging via een ander mechanisme (psql na user-akkoord, of UI in main-project), keer terug → verwacht: zonder warnings + data gerefresht
## Open vragen / risico's
1. **Reset-import** uit hoofdproject: voor de research-repo strippen we auth/session-deps uit de gekopieerde routes (research-repo heeft geen auth-laag, draait lokaal). Belangrijk: **dit is een research-repo-keuze; main behoudt de volledige auth-filters**. Zie de waarschuwing onder "API-routes (read)" hierboven.
2. **`use-backlog-realtime` heeft mogelijk auth-headers/session-checks**: bevestigen tijdens copy. Indien zo: research-versie gebruikt geen auth, route is publiek-bereikbaar binnen lokale dev. Geldt alleen lokaal — geen wijziging aan main.
3. **Tests-deps** (vitest, @testing-library/react) toevoegen tijdens Fase B. Of pas in Fase B step 14 vanwege scope.
4. **Event-paneel toekomst**: blijft het in research-repo of stoten we het af zodra de workspace-store af is? Voorstel: behouden als observatie-tool, maar er aparte `event-log-store` (kleine UI store) voor maken zodat het niet meelift in de workspace-store.
5. **README.md** update na Fase B (optioneel) — kort beschrijven dat dit nu het canonical migratie-pad demonstreert.

View file

@ -1,212 +0,0 @@
---
title: "User-settings store (DB-backed user prefs)"
status: draft
audience: [contributor, ai-agent]
language: nl
last_updated: 2026-05-10
---
# User-settings store (DB-backed user prefs)
> **Locatie na approval:** verhuis dit bestand naar `docs/plans/user-settings-store.md` in de repo.
> Trigger voor dit plan: zichtbare hydratie-flits op het sprint-scherm in v1.3.3 ([PR #184](https://github.com/madhura68/Scrum4Me/pull/184)).
> De fix daar (useEffect-hydratie + `prefsLoaded`-gate) is een tijdelijke patch; deze migratie elimineert de flits volledig.
## Context
Filter- en view-prefs zitten nu verspreid over `localStorage` (en deels cookies).
Bij SSR weet de server niets van `localStorage`, dus bij users met saved-state ≠
default ontstaat één render-flits direct na hydratie. Daarnaast werken die prefs
alleen per browser — geen cross-device, en cross-tab-sync vereist `storage`-events.
Doel: **één `User.settings` JSON-veld** als single source of truth, met:
- Server-component leest het veld bij elke page-render → SSR-correct, geen flits
- Zustand-store met optimistic updates patroon (zoals `product-workspace-store`)
- Cross-tab sync via bestaande `LISTEN/NOTIFY` + SSE-bridge
- Cross-device persistence (login op andere browser/laptop ziet zelfde prefs)
---
## Scope (gefaseerd)
### Fase 0 — Infrastructuur
Aparte PR. Geen UI-wijziging; legt het fundament. Resultaat is een werkende store
zonder migraties; bestaande localStorage-flow blijft intact tot Fase 1.
| # | Bestand | Wat |
|---|---|---|
| 0.1 | `prisma/schema.prisma` | `settings Json @default("{}")` op `User` model + migration |
| 0.2 | `lib/user-settings.ts` | Zod-schema + types + `mergeSettings(prev, patch)` deep-merge helper + defaults |
| 0.3 | `actions/user-settings.ts` | `updateUserSettingsAction(patch: Partial<UserSettings>)` — auth-guard, Zod-validate, deep-merge in DB transactie, `NOTIFY scrum4me_changes 'user_settings:${userId}'` |
| 0.4 | `stores/user-settings/store.ts` | Zustand met `entities.settings: UserSettings`, `hydrate(initial)`, generieke `setPref(path, value)` met optimistic + rollback. Zelfde mutation-flow als `product-workspace-store` |
| 0.5 | `app/api/realtime/user-settings/route.ts` | SSE-route per user, `LISTEN user_settings:${userId}`, push patches |
| 0.6 | `components/shared/user-settings-bridge.tsx` | Server reads `prisma.user.findUnique({select:{settings:true}})`, geeft door als prop, client mount roept `store.hydrate()` aan + opent SSE |
| 0.7 | Mount in `app/(app)/layout.tsx` | Bridge bovenin de app-layout zodat de store altijd beschikbaar is voor alle authenticated pagina's |
| 0.8 | Tests | `__tests__/lib/user-settings.test.ts` (merge-logic), `__tests__/actions/user-settings.test.ts` (auth + validation), `__tests__/stores/user-settings.test.ts` (optimistic flow) |
**Demo/anon-fallback:** `useUserSettingsStore` detecteert `session.isDemo` of geen `userId`
en valt terug op in-memory state (geen server-write). Bridge wordt voor demo niet
gemount — defaults blijven actief, geen persistence-verwachting.
### Fase 1 — Migreer huidige flits-bronnen
| Component | localStorage-keys | → `settings`-pad |
|---|---|---|
| `components/sprint/sprint-backlog.tsx` | `scrum4me:sprint_pb_*` (6) | `views.sprintBacklog.{filterPriority,filterStatus,sort,sortDir,collapsedPbis,filterPopoverOpen}` |
| `components/backlog/pbi-list.tsx` | `scrum4me:pbi_*` (4) | `views.pbiList.{sort,filterPriority,filterStatus,sortDir}` |
| `components/backlog/story-panel.tsx` | `scrum4me:story_sort` (1) | `views.storyPanel.sort` |
| `components/jobs/jobs-column.tsx` | `${prefix}_filter_kind`, `${prefix}_filter_status` (2 dyn.) | `views.jobsColumns[prefix].{kinds,statuses}` |
| `stores/debug-store.ts` (via `status-bar-debug-toggle`) | `scrum4me:debug-mode` (1) | `devTools.debugMode` |
Per component:
- Verwijder `useState` + `useEffect`-hydratie + `useEffect`-write
- Vervang door `useUserSettingsStore(s => s.entities.settings.views.sprintBacklog?.filterStatus ?? 'OPEN')`
- Setter wordt `useUserSettingsStore.getState().setPref(['views','sprintBacklog','filterStatus'], value)`
- `prefsLoaded`-state en helpers (`readLocalStoragePref`) verdwijnen
- `lib/use-local-storage-pref.ts` wordt verwijderd (niet meer in gebruik)
**Migratie-pad voor bestaande users:** bij eerste mount, voor de eerste `setPref`-call,
leest een one-shot `useEffect` de oude localStorage-keys en pusht ze als één bulk-patch
naar de server. Daarna `localStorage.removeItem(...)` om geen verwarring te wekken.
Idempotent: als `settings.views.sprintBacklog.filterStatus` al gezet is, sla over.
### Fase 2 — Cookie-consolidatie (optioneel, later PR)
| Bron | Huidig | → `settings`-pad |
|---|---|---|
| `components/shared/split-pane.tsx` | `document.cookie` (`sp:` prefix) | `layout.splitPanePositions[cookieKey]` |
| `lib/active-sprint.ts` + `actions/active-sprint.ts` | server-side cookie per product | `layout.activeSprints[productId]` |
Server-component-lezers veranderen — apart traject met meer regression-risico.
Niet onderdeel van de eerste user-settings-PR.
### Fase 3 — Skip / al persistent
- `idea-md-editor.tsx` drafts — werk-in-progress, geen pref
- `iron-session` cookies — auth, andere zorg
- `User.active_product_id` — al in DB (kolom op model)
- Modal/popover open-state behalve `filterPopoverOpen` — ephemeral
---
## JSON-shape (Fase 1)
```ts
// lib/user-settings.ts
import { z } from 'zod'
export const UserSettingsSchema = z.object({
views: z.object({
sprintBacklog: z.object({
filterPriority: z.union([z.number().int().min(1).max(4), z.literal('all')]).optional(),
filterStatus: z.enum(['OPEN', 'IN_SPRINT', 'DONE', 'all']).optional(),
sort: z.enum(['priority', 'status', 'code']).optional(),
sortDir: z.enum(['asc', 'desc']).optional(),
collapsedPbis: z.array(z.string()).optional(),
filterPopoverOpen: z.boolean().optional(),
}).optional(),
pbiList: z.object({
sort: z.enum(['priority', 'code', 'date']).optional(),
filterPriority: z.union([z.number().int().min(1).max(4), z.literal('all')]).optional(),
filterStatus: z.enum(['ready', 'blocked', 'done', 'all']).optional(),
sortDir: z.enum(['asc', 'desc']).optional(),
}).optional(),
storyPanel: z.object({
sort: z.enum(['priority', 'code', 'date']).optional(),
}).optional(),
jobsColumns: z.record(z.string(), z.object({
kinds: z.array(z.string()),
statuses: z.array(z.string()),
})).optional(),
}).optional(),
devTools: z.object({
debugMode: z.boolean().optional(),
}).optional(),
}).strict()
export type UserSettings = z.infer<typeof UserSettingsSchema>
```
Defaults zijn impliciet (alle keys optioneel). Selectors in de store geven
fallback-waardes terug zodat consumers niet `?? 'OPEN'` hoeven te schrijven —
maar het mag, geen big deal.
---
## Realtime-notificatie
Bestaand kanaal `scrum4me_changes` blijft. Payload-conventie:
```json
{ "kind": "user_settings", "userId": "...", "patch": { "views": { ... } } }
```
`/api/realtime/user-settings/route.ts` filtert payloads op `userId === session.userId`.
Andere tabs van zelfde user krijgen patches binnen, store roept `applyServerPatch(patch)`
aan zonder optimistic flow.
---
## Verificatie (per fase)
### Fase 0
- [ ] `npm run verify && npm run build` groen
- [ ] Migration draait op fresh + bestaande DB zonder data-verlies
- [ ] `updateUserSettingsAction` weigert auth-loze calls (test)
- [ ] Zod-validatie geeft 422 bij invalid patch (test)
- [ ] Optimistic update + rollback gedraagt zich zoals `product-workspace-store` (test)
- [ ] SSE-route levert patches alleen aan zelfde user (manueel: open twee tabs als A, schrijf, zie update; tab van user B blijft stil)
### Fase 1
- [ ] Geen `localStorage.getItem` of `localStorage.setItem` meer in de gemigreerde componenten
- [ ] Sprint screen: refresh → filter direct correct, geen flits, geen hydration error in console
- [ ] Product backlog screen: idem
- [ ] Jobs page: idem (per kolom-instance)
- [ ] Two-tab test: filter wijzigen in tab A → tab B updatet binnen ~100ms
- [ ] Demo-user: filter wijzigen werkt binnen sessie, niet gepersisteerd na refresh (verwacht)
- [ ] One-shot localStorage-migratie: bestaande user met oude keys ziet bij eerste login zijn waardes terug; na refresh zijn de localStorage-keys leeg
### Fase 2
- [ ] Split-pane positie persistent en SSR-correct
- [ ] Active-sprint per product werkt zonder cookie
---
## Schatting
| Fase | Tijd |
|---|---|
| 0 — Infra | ~3 uur |
| 1 — Migratie | ~2 uur |
| 2 — Cookies | ~2 uur (apart) |
| Totaal Fase 0 + 1 | **~5 uur**, 1 PR (of 2 als we 0 en 1 splitsen) |
Aanbevolen: **Fase 0 + 1 in één PR** als de infra klein blijft, anders splitsen
per fase. Fase 2 is altijd een aparte PR.
---
## Open vragen
1. **Cross-device merge-conflict.** Twee tabs van zelfde user op verschillende
devices wijzigen tegelijk. Server-side: `last-write-wins` of `JSON_PATCH`-merge?
Voorstel: deep-merge per top-level path, dus `views.sprintBacklog.filterStatus`
en `views.pbiList.sort` botsen niet — laatste schrijver per veld wint.
2. **Storage-grens.** PostgreSQL JSON kolom kan ~1GB; we zitten op <5KB per user.
Geen concern.
3. **Schema-versionering.** Als we het JSON-schema later wijzigen: voorzichtig
migreren via Zod `.catch()` voor onbekende keys. Voor v1: start klein.
4. **One-shot localStorage-migratie weglaten?** Voor solo-dev-tool kan het
acceptabel zijn dat users hun saved filters verliezen bij de migratie. Scheelt
~30 minuten implementatie + tests.
---
## Eerste stappen na approval
1. Verhuis dit plan naar `docs/plans/user-settings-store.md` in een nieuwe branch (bv. `feat/user-settings-store`)
2. Maak via Scrum4Me-MCP een PBI met story + taken voor Fase 0 (volgens CLAUDE.md werkflow)
3. Start met taken in `sort_order`; commit per laag
4. Fase 1 als opvolg-PBI (of in dezelfde sprint, los gelabeld)

View file

@ -1,148 +0,0 @@
---
title: "Scrum4Me — v1.0 readiness"
status: active
audience: [maintainer, contributor]
language: nl
last_updated: 2026-05-04
---
# Scrum4Me — v1.0 readiness
**Versie:** v0.9.0 (zojuist gepusht naar productie via Vercel)
**Doel:** v1.0.0 als eerste stabiele release. Living document — bijwerken na elke sprint of merge naar `main`.
---
## Summary
De kernfunctionaliteit (auth, producten, PBI/story/task-hiërarchie, sprints, solo-paneel, REST-API, MCP-integratie, QR-pairing, mobile-shell) is **af en in productie**. Tests, lint, build en doc-link-checker zijn allemaal groen. Wat ontbreekt voor v1 is geen feature-werk maar **launch-discipline**: een paar UI-gaten dichten, ops-instrumentatie (error monitoring, rate-limiting beredeneren), accessibility-audit, en de stale backlog-index opschonen. Alle "Expliciet buiten scope voor v1"-items uit de functional spec ([docs/specs/functional.md:20](../specs/functional.md)) blijven bewust uit scope.
---
## What's already done
- **#3 Rate-limiting op alle mutation-endpoints** — `enforceUserRateLimit(scope, userId)` helper in `lib/rate-limit.ts` met 11 nieuwe scopes; toegepast op create-actions (PBI/Story/Task/Todo/Sprint/Product/Token), enqueueClaudeJob(s), answerQuestion, en API-routes (story log POST, avatar upload). Limits zijn ruim genoeg voor normaal gebruik, eng genoeg om abuse-loops te stoppen
- **#2 Sentry error-monitoring** — `@sentry/nextjs` geconfigureerd via PR [#85](https://github.com/madhura68/Scrum4Me/pull/85); SDK is no-op zonder DSN, activatie via Vercel env-vars
- **#1 Edit-icoon op Product** (todo `cmoq3ox51`) — pencil-icoon op dashboard-card via PR [#83](https://github.com/madhura68/Scrum4Me/pull/83); product-detail-header behoudt tekst
- v0.9.0 ([release](https://github.com/madhura68/Scrum4Me/releases/tag/v0.9.0)): mobile-shell met landscape-lock (PBI-11, 7 stories, 21 tasks)
- v0.4.0 t/m v0.8.x: ondermeer sprint-screen filter-popover + edit-iconen, PBI/story/task edit-icons, code-velden verplicht, demo read-only, M11 Claude-vragen-kanaal, M10 QR-pairing
- CI op `main` en PR's: lint + typecheck + prisma validate + test + build via [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml)
- 432 unit/integration tests · 60 test-files · doc-link-checker 86/86 valid
- Drie architectuur-beslissingen voor mobile geformaliseerd in [docs/architecture/project-structure.md](../architecture/project-structure.md)
---
## Now
Korte lijst (3-5 items) die je vóór de v1.0-tag wil afronden. Deze blokkeren een betekenisvolle launch.
### 1. ~~Edit-icoon op Product~~ ✅ klaar in PR [#83](https://github.com/madhura68/Scrum4Me/pull/83)
Verschoven naar *What's already done*. Pencil-icoon op dashboard-card; product-detail page-header behoudt tekst (matched naast andere text-acties).
### 2. Error monitoring (Sentry of vergelijkbaar)
CI vangt build-fouten af, maar er is geen runtime-monitoring. Voor een echte v1 wil je productie-fouten zien voordat een gebruiker het meldt. Vercel heeft native Sentry-integratie (Marketplace → Sentry).
Concreet:
- `npm i @sentry/nextjs`
- `npx @sentry/wizard@latest -i nextjs`
- DSN als env-var via Vercel project settings (development + production environments)
- Sample-rate conservatief (10% performance, 100% errors) — Hobby-plan-vriendelijk
- Bevestig dat Postgres-LISTEN/NOTIFY-fouten in worker-routes (`/api/realtime/*`) gevangen worden
### 3. ~~Rate-limiting op alle mutation-endpoints~~ ✅ klaar
Verschoven naar *What's already done*. Helper `enforceUserRateLimit(scope, userId)` in `lib/rate-limit.ts` toegepast op alle high-value create-paths.
### 4. Accessibility audit op happy-path
`@base-ui/react` levert WAI-ARIA defaults; we gebruiken semantische HTML; maar er is geen audit-bewijs.
Concreet:
- DevTools Lighthouse a11y-pass op `/login`, `/dashboard`, `/products/[id]`, `/products/[id]/sprint`, `/products/[id]/solo`, `/m/products/[id]`, `/m/products/[id]/solo`
- Score-doel ≥95 per pagina
- Fix wat onder de 95 valt — meestal contrast of missende labels
- Documenteer score in [docs/specs/functional.md § Niet-functionele vereisten](../specs/functional.md)
---
## Next
Belangrijk maar niet-blokkerend voor v1.
### Backlog-index sync
[docs/backlog/index.md](../backlog/index.md) toont M10 (ST-1001 t/m 1008) en M11 (ST-1101 t/m 1108) als unchecked, terwijl ze allemaal gemerged zijn. Loop één keer door en zet `[x]`. Is een 5-min-job die de doc weer betrouwbaar maakt voor wie 'm leest.
### Solo observaties (todo `cmohuu5h8`)
"Filters en sortering. blokjes kleiner maken 2 op een rij" — UX-polish op het Solo-paneel. Niet trivial: vereist een filter-popover-pattern (we hebben er net een uitgerold op het sprint-screen — herbruik kan).
### Algemene observaties (todo `cmohthfyw`)
"Dunne border om tekstvlakken (onzichtbaar als niet actief), default active PB kiezen, hover-card voor detail-info, landingpage AI-assisted/AI-driven framing." — verzameling kleinere UI-aanscherpingen, ieder eigen scope.
### ToDo prioriteit + AI-suggesties (todos `cmohtgdwf`, `cmohswbb9`)
Twee verwante todo's over de todo-feature uitbreiden. Past bij de strategische richting "AI-driven dev-flow" maar geen v1-blokker.
---
## Before launch
Must-do voor publieke aankondiging, maar mag pas vlak vóór v1.0-tag.
- [ ] **Smoke-test productie** — checklist klaar in [docs/runbooks/v1-smoke-test.md](../runbooks/v1-smoke-test.md), 11 secties, ~15 min
- [ ] **PWA-installatie test** op echt mobiel (Android + iOS) — bevestig manifest landscape, controleer iOS-fallback via CSS-overlay
- [x] **Demo-policy regression-pass** — code-side gefixt: 3 gaps gedicht (toggleTodo, archiveCompletedTodos, leaveProduct). Drielaags-block geverifieerd voor alle mutation-actions
- [x] **Privacy review** — Sentry sendDefaultPii=false; geen PII in logs; 4 debug-routes nu NODE_ENV-guarded (404 in productie)
- [x] **README + Quick start verifiëren** — test-count 69 → 445 gecorrigeerd, env-vars-tabel uitgebreid (CRON_SECRET, Sentry vars), CHANGELOG-link toegevoegd
- [x] **CHANGELOG.md** aangemaakt (Keep a Changelog formaat met [Unreleased] + [0.9.0])
- [ ] **Bump naar v1.0.0** + GitHub release met release-notes
---
## Later
Bewust uit scope voor v1 (uit functional spec § Expliciet buiten scope) — of grotere domein-uitbreidingen die hun eigen PBI verdienen.
- **Daily Scrum / Sprint Review / Retrospective**-schermen — v2
- **E-mail-uitnodigingsflow voor teams** — nu enkel via username
- **Notificaties + reminders** — out of scope
- **Native mobile app** — web-first; mobile-shell is genoeg
- **Tijdregistratie / burndown-charts** — buiten positionering
- **WIA AI agent** (todo `cmog2gzjb`) — eigen project-domein
- **Claude-code-integratie via tabel-trigger** (todo `cmohn3728`) — past bij M12-richting maar geen v1
- **Inspaningsmonitor-import** (todo `cmohul0ri`) — separate product
- **GitHub Issues / Linear / Jira-integratie** — v2
---
## Priority order (quick reference)
```
Now: ~~1. Edit-icoon op Product~~
~~2. Sentry/error-monitoring~~
~~3. Rate-limiting op mutation-endpoints~~
4. Accessibility-audit (Lighthouse a11y ≥95)
Next: 5. Backlog-index.md sync
6. Solo observaties (filters/sortering)
7. Algemene UI-observaties
8. Todo prioriteit + AI-suggesties
Before launch: 9. Smoke-test productie (desktop + mobile)
10. PWA-installatie test op echte mobiel
11. Demo-policy regression-pass
12. Privacy/PII review
13. README quick-start verificatie
14. CHANGELOG.md
15. Bump → v1.0.0 + release
Later: (zie sectie hierboven — v2-domein of buiten scope)
```
---
*Updated: 2026-05-04 (na v0.9.0 release). Refresh dit document na elke sprint of major merge.*