fix(ci): docs:check-links groen — exclude docs/old/ + archiveer stale plans (#193)
CI faalde sinds #191 (docs cleanup) op pre-existing broken links: - docs/old/ bevat archief-docs met by-design stale paden - docs/plans/PBI-79*, M9*, M11* hadden geprojecteerde paden naar ../backlog/index.md (verplaatst naar docs/old/backlog/) en naar app-bestanden die nooit met de juiste relatieve prefix waren geschreven - docs/adr/0000* verwees naar docs-restructure-ai-lookup.md (verplaatst) - docs/glossary.md verwees naar /docs/backlog/index.md (verplaatst) Fixes: - scripts/check-doc-links.mjs: skip docs/old/ recursief - Move docs/plans/{PBI-79,M9,M11}*.md → docs/old/plans/ (allemaal merged PBIs; plans waren historisch) - docs/adr/0000-record-architecture-decisions.md: update pad naar archief - docs/glossary.md: verwijder dode "backlog index"-link Verificatie: `npm run docs:check-links` → ✓ All doc links valid (105 files) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0a842e6841
commit
2bef1a4c20
7 changed files with 10 additions and 5 deletions
|
|
@ -1,475 +0,0 @@
|
|||
---
|
||||
title: "M11 — Claude vraagt, gebruiker antwoordt"
|
||||
status: active
|
||||
audience: [maintainer, contributor]
|
||||
language: nl
|
||||
last_updated: 2026-05-03
|
||||
applies_to: [M11]
|
||||
---
|
||||
|
||||
# M11 — Claude vraagt, gebruiker antwoordt
|
||||
|
||||
Persistent vraag-antwoord-kanaal tussen Claude Code (via MCP) en de actieve Scrum4Me-gebruiker. Claude schrijft een vraag naar `claude_questions` als hij vastloopt op een keuze; een Postgres-trigger emit op het bestaande `scrum4me_changes`-kanaal; de app toont een notificatie-badge; iedereen met product-toegang kan antwoorden; Claude leest het antwoord (sync via polling of in latere sessie via `get_question_answer`) en gaat door.
|
||||
|
||||
Eerste concrete uitwerking van strategische **richting B** (verdiepen van de unieke AI-driven dev-flow).
|
||||
|
||||
Backlog-entries: zie [backlog.md § M11](../backlog/index.md#m11-claude-vraagt-gebruiker-antwoordt) (op te leveren in ST-1108).
|
||||
|
||||
**Beveiligingsuitgangspunten:**
|
||||
- Atomic answer via `updateMany WHERE status='open'` — concurrent dubbele submit kan niet
|
||||
- Demo-blok op `ask_user_question` (MCP) en `answerQuestion` (Server Action)
|
||||
- Access-check via `productAccessFilter` in DB-query én SSE-filter; vraag-tekst en antwoord komen pas via een aparte authenticated query
|
||||
- Cron-endpoint beveiligd via `Authorization: Bearer ${CRON_SECRET}`
|
||||
- Logging: alleen `question_id`, nooit vraag/antwoord-tekst (kan gevoelige info bevatten)
|
||||
|
||||
**Gekozen kaders (uit overleg):**
|
||||
- **Sync-model**: default async — `ask_user_question` retourneert direct met `question_id`; optionele `wait_seconds` (max 600) voor polling tot het antwoord er is
|
||||
- **Answer-policy**: iedereen met product-toegang mag antwoorden; story-assignee krijgt visuele *"wacht op jou"*-emphase
|
||||
- **Realtime**: hergebruik `scrum4me_changes`-kanaal (uitgebreid met `entity: 'question'`); aparte user-scoped SSE-route `/api/realtime/notifications` zodat solo-board-SSE product-scoped blijft
|
||||
|
||||
---
|
||||
|
||||
## ST-1101 — `ClaudeQuestion` schema + Postgres-trigger
|
||||
|
||||
**Bestanden**
|
||||
- `prisma/schema.prisma` — model `ClaudeQuestion` + relations op `User`/`Story`/`Task`/`Product`
|
||||
- `prisma/migrations/<ts>_add_claude_questions/migration.sql` — table-DDL + trigger
|
||||
- `vendor/scrum4me`-submodule in `mcp` — schema-sync ná merge
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. Schema-uitbreiding:
|
||||
|
||||
```prisma
|
||||
model ClaudeQuestion {
|
||||
id String @id @default(cuid())
|
||||
story_id String
|
||||
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
||||
task_id String?
|
||||
task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull)
|
||||
product_id String // gedenormaliseerd voor SSE-filter
|
||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||
asked_by String
|
||||
asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id])
|
||||
question String @db.Text
|
||||
options Json? // string[] voor multi-choice; null voor free-text
|
||||
status String // 'open' | 'answered' | 'cancelled' | 'expired'
|
||||
answer String? @db.Text
|
||||
answered_by String?
|
||||
answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id])
|
||||
answered_at DateTime?
|
||||
created_at DateTime @default(now())
|
||||
expires_at DateTime
|
||||
|
||||
@@index([story_id, status])
|
||||
@@index([product_id, status])
|
||||
@@index([status, expires_at])
|
||||
@@map("claude_questions")
|
||||
}
|
||||
```
|
||||
|
||||
Plus op `User`: `asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker")` en `answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer")`.
|
||||
|
||||
2. Migratie-SQL voegt naast tabel + indexes ook trigger toe (mirror van `notify_pairing_change` uit M10 ST-1001):
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION notify_question_change() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
story_row record;
|
||||
payload jsonb;
|
||||
BEGIN
|
||||
SELECT assignee_id INTO story_row FROM stories WHERE id = NEW.story_id;
|
||||
payload := jsonb_build_object(
|
||||
'op', CASE TG_OP WHEN 'INSERT' THEN 'I' ELSE 'U' END,
|
||||
'entity', 'question',
|
||||
'id', NEW.id,
|
||||
'product_id', NEW.product_id,
|
||||
'story_id', NEW.story_id,
|
||||
'task_id', NEW.task_id,
|
||||
'assignee_id', story_row.assignee_id,
|
||||
'status', NEW.status
|
||||
);
|
||||
PERFORM pg_notify('scrum4me_changes', payload::text);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER claude_questions_notify
|
||||
AFTER INSERT OR UPDATE ON claude_questions
|
||||
FOR EACH ROW EXECUTE FUNCTION notify_question_change();
|
||||
```
|
||||
|
||||
3. `npx prisma migrate dev --name add_claude_questions`
|
||||
|
||||
**Aandachtspunten**
|
||||
- `entity: 'question'` is een nieuwe waarde naast bestaande `'task'`/`'story'`. Solo-route in M8 filter't via `payload.entity` — moet `'question'`-events expliciet wegfilteren (niet emitten naar solo-clients)
|
||||
- `product_id` op de question is gedenormaliseerd uit `story.product_id` — voorkomt extra join in SSE-filter (zelfde keuze als `story.product_id` in M3)
|
||||
- `vendor/scrum4me`-submodule sync vereist na merge (drift-check `trig_015FFUnxjz9WMuhhWNGBQKFD`)
|
||||
|
||||
**Verificatie**
|
||||
- `npx prisma migrate dev` slaagt; `npx prisma validate` clean
|
||||
- `psql $DIRECT_URL -c "LISTEN scrum4me_changes;"` toont payload met `entity: 'question'` bij INSERT
|
||||
- Bestaande solo-flow nog steeds werkend (regressie-check)
|
||||
|
||||
---
|
||||
|
||||
## ST-1102 — MCP-tools (in `mcp`-repo)
|
||||
|
||||
**Bestanden**
|
||||
- `mcp/src/tools/ask-user-question.ts` — nieuw
|
||||
- `mcp/src/tools/get-question-answer.ts` — nieuw
|
||||
- `mcp/src/tools/list-open-questions.ts` — nieuw
|
||||
- `mcp/src/tools/cancel-question.ts` — nieuw
|
||||
- `mcp/src/index.ts` — register de vier tools
|
||||
- `mcp/scripts/smoke-test.ts` — uitbreiden met question-roundtrip
|
||||
- `mcp/README.md` — tool-tabel uitbreiden
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. **`ask_user_question`** (write-tool, sjabloon `create-todo.ts`):
|
||||
- Input: `{ story_id, question, options?, task_id?, wait_seconds? }` — `wait_seconds` 0–600 (Zod `.min(0).max(600)`)
|
||||
- `requireWriteAccess` (demo-blok)
|
||||
- Access-check: `userCanAccessProduct(story.product_id, auth.userId)`
|
||||
- Insert met `expires_at = now() + 24h`, `status = 'open'`
|
||||
- Als `wait_seconds === 0` (default): return `{ question_id, status: 'open' }`
|
||||
- Als `wait_seconds > 0`: poll elke 2s tot `status !== 'open'` of timeout. Bij answered: return `{ question_id, status, answer, answered_by, answered_at }`. Bij timeout: return `{ question_id, status: 'pending' }` zodat Claude met `get_question_answer` later kan ophalen
|
||||
- Polling-implementatie: `setInterval` met `Promise` en abort-signal voor schone cleanup
|
||||
|
||||
2. **`get_question_answer`** (read-tool):
|
||||
- Input: `{ question_id }`
|
||||
- Access-check via `userCanAccessProduct(question.product_id, auth.userId)`
|
||||
- Output: full row (`status`, `answer`, `answered_by`, `answered_at`, `expires_at`)
|
||||
|
||||
3. **`list_open_questions`** (read-tool):
|
||||
- Input: `{ story_id? }` (optionele filter)
|
||||
- Output: array van eigen vragen (`asked_by === auth.userId`) met status open of answered, max 50, geordend op `created_at desc`
|
||||
- Bedoeld voor Claude om bij begin van een sessie te zien of eerdere vragen inmiddels beantwoord zijn
|
||||
|
||||
4. **`cancel_question`** (write-tool):
|
||||
- Input: `{ question_id }`
|
||||
- Alleen de asker mag cancelen; `requireWriteAccess` voor demo-blok
|
||||
- Atomic `updateMany WHERE id=… AND status='open' AND asked_by=…`
|
||||
- Bedoeld voor wanneer Claude zelf de oplossing vindt en de vraag overbodig wordt
|
||||
|
||||
5. Smoke-test in `scripts/smoke-test.ts`: `ask_user_question` met `wait_seconds=5` + parallel `answerQuestion` (via REST of direct DB-write) → verifieer dat de tool het antwoord retourneert binnen het venster
|
||||
|
||||
**Aandachtspunten**
|
||||
- `wait_seconds` polling moet aborten als de MCP-cliënt disconnect (signal-check) — anders blijft de Node-process hangen op een dood socket
|
||||
- `options`-veld accepteert string-array; in zod als `z.array(z.string()).optional()`
|
||||
- Als `wait_seconds` > 300 raakt 'm Vercel-deploy onmogelijk (Vercel-functies cap op 300s) — maar de MCP-server draait *lokaal* bij Claude Code, dus 600s mag
|
||||
|
||||
**Verificatie**
|
||||
- MCP Inspector toont 4 nieuwe tools (totaal 13)
|
||||
- Smoke-test groen: ask + answer roundtrip binnen 5s
|
||||
- Demo-token op `ask_user_question` of `cancel_question` geeft `PERMISSION_DENIED`
|
||||
- `tsc --noEmit` clean op `mcp`
|
||||
|
||||
---
|
||||
|
||||
## ST-1103 — Server Actions voor de browser-UI
|
||||
|
||||
**Bestanden**
|
||||
- `actions/questions.ts` — nieuw
|
||||
- `__tests__/actions/questions.test.ts` — nieuw
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. **`answerQuestion(questionId, answer)`** (volgt `docs/patterns/server-action.md`):
|
||||
- `getSession` + `requireUser`; demo-blok via `if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' }`
|
||||
- Zod-input: `{ questionId: cuid, answer: string.min(1).max(4000) }`
|
||||
- Lookup question + access-check via `userCanAccessProduct(question.product_id, userId)`
|
||||
- Atomic `updateMany WHERE id=… AND status='open' AND expires_at > now()` met `data: { status: 'answered', answer, answered_by: userId, answered_at: now }`
|
||||
- Bij `count === 0`: disambigueer (al-answered → 'al beantwoord', expired → 'verlopen', geen access → 'geen toegang')
|
||||
- `revalidatePath('/', 'layout')` zodat badge-count overal updatet
|
||||
|
||||
2. **`cancelQuestionByAnswerer(questionId)`** — *uitgesteld naar v2*. Voor v1 alleen Claude (asker) kan annuleren via MCP. Als de UI later een dismiss-functie krijgt, komt het hier.
|
||||
|
||||
3. **Tests** `__tests__/actions/questions.test.ts` (6 cases):
|
||||
- happy answer → status='answered', `revalidatePath` aangeroepen
|
||||
- demo-user → error + geen DB-write
|
||||
- user zonder product-access → error
|
||||
- already-answered → race-error (`updateMany count=0` met status='answered' fallback)
|
||||
- expired → error
|
||||
- empty answer → Zod-validatie
|
||||
|
||||
**Aandachtspunten**
|
||||
- `revalidatePath('/', 'layout')` is correct (zelfde keuze als M9 `setActiveProductAction`) — badge zit in app-layout
|
||||
- Geen `revalidatePath` op `/sprint` of `/solo` nodig — die zien de question niet
|
||||
- Bij multi-tab: na answer in tab-1 verdwijnt het item in tab-2 via SSE-event, niet via revalidate. revalidate is voor de SSR-render-na-navigatie
|
||||
|
||||
**Verificatie**
|
||||
- `npm test` 6/6 voor questions
|
||||
- Handmatig: open vraag in browser, antwoord, badge-count zakt met 1
|
||||
- Demo-test: log in als demo, klik antwoord → toast "Niet beschikbaar in demo-modus"
|
||||
|
||||
---
|
||||
|
||||
## ST-1104 — User-scoped SSE-route `/api/realtime/notifications`
|
||||
|
||||
**Bestanden**
|
||||
- `app/api/realtime/notifications/route.ts` — nieuw
|
||||
- `app/api/realtime/solo/route.ts` — uitbreiden om `entity: 'question'` te filteren (anders krijgt solo-client question-events ongewenst door)
|
||||
- `__tests__/api/notifications-stream.test.ts` — nieuw (auth-cases)
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. Route Handler — sjabloon uit `app/api/realtime/solo/route.ts`:
|
||||
- `runtime: 'nodejs'`, `maxDuration: 300`, `dynamic: 'force-dynamic'`
|
||||
- Auth via iron-session cookie; 401 zonder
|
||||
- **User-scoped** (geen `?product_id=`-param). Bij connect: query `productAccessFilter(userId)` om alle accessible product-IDs te krijgen
|
||||
- LISTEN op `scrum4me_changes`; filter:
|
||||
- `payload.entity === 'question'` (anders skip)
|
||||
- `payload.product_id IN accessibleProductIds`
|
||||
- Initial-state-event direct na connect, **na LISTEN actief**: query `claude_questions` met `status='open'` voor deze user's accessible products. Stuur als `event: state\ndata: [{...question-summary...}]`. Voorkomt race tussen connect en LISTEN (zelfde fix als M10 ST-1004)
|
||||
- Auto-close bij hard-close 240s; client herconnect
|
||||
|
||||
2. Solo-route bijwerken: in `shouldEmit` toevoegen `if (payload.entity === 'question') return false`
|
||||
|
||||
3. Tests (auth-paden, full-stream blijft handmatig):
|
||||
- 401 zonder iron-session cookie
|
||||
- Bij connect met sessie: list van accessible products correct gefilterd
|
||||
- Question-event op een product zonder access → niet doorgegeven
|
||||
|
||||
**Aandachtspunten**
|
||||
- Twee parallelle SSE-streams in browser (solo-route op product-pagina + notifications-route in app-layout) — netwerk-overhead aanvaardbaar; Vercel rekent per-actieve-functie ongeacht aantal streams
|
||||
- Initial-state event content: een kleine summary (id, story_code, question, options?) per open vraag — voorkomt dat de bridge eerst een aparte fetch moet doen voor de initial badge-count
|
||||
- Path expliciet maken in een client `useNotificationsRealtime`-hook (volgt `useSoloRealtime`-pattern)
|
||||
|
||||
**Verificatie**
|
||||
- `curl -N --cookie session-jar /api/realtime/notifications` blijft openstaan, levert `event: state` direct
|
||||
- INSERT op `claude_questions` voor een toegankelijk product → event binnen 1s
|
||||
- INSERT voor een ontoegankelijk product → geen event
|
||||
- Solo-route op `/api/realtime/solo?product_id=…` levert geen question-events meer
|
||||
|
||||
---
|
||||
|
||||
## ST-1105 — Notifications-UI (Bell + Sheet + Answer-modal)
|
||||
|
||||
**Bestanden**
|
||||
- `components/shared/notifications-bell.tsx` — nieuw
|
||||
- `components/notifications/notifications-sheet.tsx` — nieuw
|
||||
- `components/notifications/answer-modal.tsx` — nieuw
|
||||
- `components/notifications/notifications-bridge.tsx` — nieuw, hookt SSE-listener aan store
|
||||
- `stores/notifications-store.ts` — nieuw
|
||||
- `lib/realtime/use-notifications-realtime.ts` — nieuw
|
||||
- `components/shared/nav-bar.tsx` — `<NotificationsBell />` toevoegen rechts (links van `<UserMenu>`)
|
||||
- `app/(app)/layout.tsx` — `<NotificationsBridge />` mounten (analoog aan `<SoloRealtimeBridge />`)
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. **`stores/notifications-store.ts`** — Zustand store; volgt `stores/solo-store.ts`-pattern:
|
||||
- State: `{ questions: Question[], pendingAnswerIds: Set<string> }`
|
||||
- Actions: `init(q[])`, `add(q)`, `update(q)`, `remove(id)`, `optimisticAnswer(id)`, `rollbackAnswer(id, q)`
|
||||
- Selectors: `openCount(userId)`, `forYouCount(userId)` (waar story-assignee = userId)
|
||||
|
||||
2. **`lib/realtime/use-notifications-realtime.ts`** — analoog aan `useSoloRealtime`. EventSource opent op `/api/realtime/notifications`, dispatcht `state`/`message`-events naar store via `add`/`update`/`remove`. Reconnect met exponential backoff.
|
||||
|
||||
3. **`<NotificationsBridge />`** — Server Component die initial questions ophaalt en aan de store geeft via `init`-prop. Mount in `(app)/layout.tsx` zodat de bridge altijd actief is wanneer user is ingelogd.
|
||||
|
||||
4. **`<NotificationsBell />`** — Client Component:
|
||||
- Lucide `Bell`-icon met badge: `openCount` (totaal) + accent-dot als `forYouCount > 0`
|
||||
- Klik: `setOpen(true)` op de Sheet
|
||||
- Geen badge als count === 0
|
||||
|
||||
5. **`<NotificationsSheet />`** — shadcn `Sheet` van rechts:
|
||||
- Header: "Vragen van Claude (N)"
|
||||
- Lijst gegroepeerd op product (analoog aan M5 todo-data-table-styling), elk item: story-code + truncated title, vraag-preview (line-clamp-2), assignee-emphase als forYou, "Beantwoord" knop opent answer-modal
|
||||
- Lege staat: "Geen openstaande vragen. Lekker bezig!"
|
||||
|
||||
6. **`<AnswerModal />`** — shadcn `Dialog`:
|
||||
- Story-context-link bovenaan (kleine kaart)
|
||||
- Volledige vraag-tekst
|
||||
- Als `options`: `<RadioGroup>` met opties; geen vrije tekst
|
||||
- Anders: `<Textarea>` (max 4000 chars, char-counter)
|
||||
- "Verstuur" + "Annuleer" knoppen; submit roept `answerQuestion`-action via `useTransition`
|
||||
- Demo-modus: knop disabled met tooltip
|
||||
|
||||
7. NavBar-edit: `<NotificationsBell />` rechts naast de huidige avatar-trigger. Nieuwe gap-spacing in NavBar's right-section.
|
||||
|
||||
**Aandachtspunten**
|
||||
- Bell-icon en avatar moeten visueel balanceren — hoogte/padding gelijktrekken
|
||||
- MD3-tokens uit `docs/design/styling.md`: badge `bg-error text-error-foreground` voor critical-count, `bg-primary` voor neutraal. Geen willekeurige Tailwind-kleuren
|
||||
- Optimistic-answer in store: voor het Server Action-resultaat zet item op pending; bij error rollback met sonner-error-toast
|
||||
- Sheet-content blijft open zodat de user meerdere vragen achter elkaar kan beantwoorden (zelfde patroon als ST-358 openstaande-stories-sheet)
|
||||
- ARIA: bell-icon heeft `aria-label="Notificaties — N open vragen"`, badge `role="status"`
|
||||
|
||||
**Verificatie**
|
||||
- Bell verschijnt in NavBar links van avatar; badge count = open question count
|
||||
- Klik opent Sheet; lijst rendert correct met assignee-emphase
|
||||
- Submit schiet event door — in tweede tab van zelfde user verdwijnt item binnen 1-2s
|
||||
- Demo-modus: Sheet rendert, Modal opent, "Verstuur" disabled
|
||||
- E2E-flow: Claude `ask_user_question` → bell-badge wordt 1 → klik → modal → submit → badge wordt 0 → Claude's `get_question_answer` levert antwoord
|
||||
|
||||
---
|
||||
|
||||
## ST-1106 — Demo-policy + access-rules + tests
|
||||
|
||||
**Bestanden**
|
||||
- `__tests__/actions/questions.test.ts` — uitbreiden met access-cases (al opgezet in ST-1103)
|
||||
- `__tests__/api/notifications-stream.test.ts` — access-cases
|
||||
- Documentatie-aanpassingen in `actions/questions.ts` en SSE-route met expliciete demo-blok-comment
|
||||
|
||||
**Stappen**
|
||||
1. Verifieer dat `requireProductWriter` alle Server-Action-mutaties al dekt (zou moeten — uit M3.5)
|
||||
2. Voeg expliciete demo-test toe: demo-user opent answer-modal → Verstuur disabled met tooltip
|
||||
3. Voeg access-test toe: user-A heeft geen access tot product van user-B → user-A's notification-stream krijgt geen events voor user-B's questions
|
||||
|
||||
**Aandachtspunten**
|
||||
- Story-assignee-emphase is **alleen visueel** — toegang is product-membership-breed. Dit is bewust: als de assignee niet beschikbaar is moet een andere member kunnen invallen
|
||||
- Demo kan een vraag wel **lezen** (transparantie over hoe de feature werkt) — alleen niet beantwoorden
|
||||
|
||||
**Verificatie**
|
||||
- 6+ tests groen (al gedekt in ST-1103/1104)
|
||||
- Handmatige cross-product-test met 2 users + 2 producten
|
||||
|
||||
---
|
||||
|
||||
## ST-1107 — Auto-expire + Vercel cron-cleanup
|
||||
|
||||
**Bestanden**
|
||||
- `app/api/cron/expire-questions/route.ts` — nieuw
|
||||
- `vercel.ts` — `crons`-entry toevoegen
|
||||
- `lib/env.ts` — `CRON_SECRET` toevoegen aan Zod-schema
|
||||
- `.env.example` — `CRON_SECRET` documenteren
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. **Cron-handler**:
|
||||
```ts
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const auth = request.headers.get('authorization')
|
||||
if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
|
||||
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const result = await prisma.claudeQuestion.updateMany({
|
||||
where: { status: 'open', expires_at: { lt: new Date() } },
|
||||
data: { status: 'expired' },
|
||||
})
|
||||
// Optioneel: ook M10 login_pairings cleanup hier (eerder geparkeerd)
|
||||
return Response.json({ expired: result.count })
|
||||
}
|
||||
```
|
||||
|
||||
2. **`vercel.ts`**:
|
||||
```ts
|
||||
export const config: VercelConfig = {
|
||||
// ... bestaande config
|
||||
crons: [{ path: '/api/cron/expire-questions', schedule: '0 4 * * *' }],
|
||||
}
|
||||
```
|
||||
|
||||
3. Documenteer in `.env.example`: `CRON_SECRET=<openssl rand -base64 32>`
|
||||
|
||||
**Aandachtspunten**
|
||||
- Vercel cron POST't standaard zonder body; auth is alleen via header
|
||||
- Op Hobby-plan zijn crons beperkt — check budget. M11 cron is 4x/dag, prima
|
||||
- Cron-trigger update't `claude_questions` → trigger fired → SSE-events → UI updates badge real-time. Geen extra plumbing nodig
|
||||
|
||||
**Verificatie**
|
||||
- Handmatig: `curl -X POST -H "Authorization: Bearer ${CRON_SECRET}" /api/cron/expire-questions` met een vraag waar `expires_at < now` → response `{expired: 1}`, vraag verdwijnt uit notifications
|
||||
- Onbevoegde call zonder secret → 401
|
||||
- Vercel dashboard toont cron-config na deploy
|
||||
|
||||
---
|
||||
|
||||
## ST-1108 — Documentatie + acceptatietest
|
||||
|
||||
**Bestanden**
|
||||
- `docs/api/rest-contract.md` — secties "SSE — Notifications" + "Cron — Expire questions"
|
||||
- `docs/architecture.md` — sectie "Vraag-antwoord-kanaal" met sequence-diagram
|
||||
- `docs/patterns/claude-question-channel.md` — herbruikbaar pattern-doc
|
||||
- `docs/backlog/index.md` — M11-tabel-rij + M11-sectie
|
||||
- `prisma/seed-data/parse-backlog.ts` — `M11: 'ACTIVE'`, `M10: 'COMPLETED'`, `M3.5: 'COMPLETED'`
|
||||
- `CLAUDE.md` — pattern-doc verwijzing in Implementatiepatronen-tabel
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. Backlog-tabel-rij + M11-sectie in `docs/backlog/index.md` (mirror M10-format met **Implementatieplan:** verwijzing naar dit doc)
|
||||
|
||||
2. `docs/architecture.md` § "Vraag-antwoord-kanaal":
|
||||
- Mermaid sequence-diagram: Claude → MCP → DB → trigger → SSE → user → Server Action → DB → trigger → polling-tool
|
||||
- Threat-model-tabel (replay, demo-block, access-leak, expiry, race)
|
||||
- "Waarom hergebruik scrum4me_changes-kanaal" sub-sectie
|
||||
|
||||
3. `docs/patterns/claude-question-channel.md` — generiek pattern voor toekomstige bidirectionele async-communicatie tussen MCP-agents en interactieve users
|
||||
|
||||
4. Parser-flip: M11 wordt nieuwe ACTIVE-milestone, M10 → COMPLETED. (Zelfde patroon als bij M10-start: chore-commit met vlag-flip + re-seed.)
|
||||
|
||||
5. **Acceptatie-scenario's** (zes, deels door unit-tests gedekt):
|
||||
1. **Sync happy path**: Claude `ask_user_question(wait_seconds=300)` → user antwoordt binnen 30s → MCP-tool retourneert het antwoord ✅
|
||||
2. **Async happy path**: `ask_user_question(wait_seconds=0)` → tool returnt direct → user antwoordt later → Claude `get_question_answer` → ziet antwoord ✅
|
||||
3. **Demo-block**: demo-user opent vraag → kan inhoud lezen → "Verstuur" disabled (UI + Server Action ✅)
|
||||
4. **Access-isolation**: vraag op product zonder access → onzichtbaar in andere user's notifications-bell (SSE-filter ✅)
|
||||
5. **Expiry**: vraag met `expires_at < now` → na cron-run niet meer in badge-count ✅
|
||||
6. **Race**: concurrent answer-poging op al-beantwoorde vraag → schone foutmelding (atomic `updateMany count=0` ✅)
|
||||
|
||||
**Aandachtspunten**
|
||||
- Acceptatie-scenario's 1-2 zijn handmatig (full Claude+browser cyclus); 3-6 worden in unit-tests vastgelegd
|
||||
- Pattern-doc moet ook beschrijven wanneer NIET te gebruiken (bv. wanneer een gewone API-call met sessie volstaat)
|
||||
|
||||
**Verificatie**
|
||||
- Alle docs gepubliceerd in repo
|
||||
- Backlog-parser-self-test: `npx tsx prisma/seed-data/parse-backlog.ts` toont M11 met `priority=4 sprint=ACTIVE`
|
||||
- 6/6 acceptatie-scenario's groen
|
||||
- `npm run lint && npx tsc --noEmit && npm test && npm run build` clean
|
||||
- `vendor/scrum4me`-submodule sync in mcp na merge
|
||||
|
||||
---
|
||||
|
||||
## Branch- en commit-strategie
|
||||
|
||||
Per [Branch & PR Strategy](../runbooks/branch-and-commit.md):
|
||||
- **Eén branch op Scrum4Me**: `feat/M11-claude-questions` afgesplitst van `main` ná M10-merge
|
||||
- **Aparte branch op mcp**: `feat/M11-question-tools`
|
||||
- Commits chronologisch per stap met ST-code in titel:
|
||||
|
||||
```
|
||||
chore(M11): swap demo-active sprint from M10 to M11
|
||||
feat(ST-1101): add ClaudeQuestion model + notify_question_change trigger
|
||||
feat(ST-1102): add 4 MCP question tools (in mcp)
|
||||
feat(ST-1103): add answerQuestion server action
|
||||
feat(ST-1104): add /api/realtime/notifications user-scoped SSE
|
||||
feat(ST-1104): filter entity='question' from solo-realtime stream
|
||||
feat(ST-1105): add Zustand notifications-store + realtime hook
|
||||
feat(ST-1105): add NotificationsBridge in app layout
|
||||
feat(ST-1105): add NotificationsBell + Sheet + AnswerModal
|
||||
chore(ST-1107): add CRON_SECRET to env schema
|
||||
feat(ST-1107): add /api/cron/expire-questions handler
|
||||
feat(ST-1107): wire vercel.ts cron entry
|
||||
docs(ST-1108): document notifications SSE + cron in api.md
|
||||
docs(ST-1108): add vraag-antwoord-kanaal flow to architecture
|
||||
docs(ST-1108): add claude-question-channel pattern doc
|
||||
chore(ST-1108): backlog M11 + parser ACTIVE-flip
|
||||
```
|
||||
|
||||
**Push + PR pas na handmatige acceptatie** van scenario 1 (sync happy path) + 3 (demo-block) op localhost.
|
||||
|
||||
**MCP-PR pas mergen ná Scrum4Me-PR** + submodule-sync — anders wijzen MCP-tools naar een schema-tabel die op main nog niet bestaat.
|
||||
|
||||
---
|
||||
|
||||
## Reseed-stap (eenmalig vóór ST-1101-implementatie)
|
||||
|
||||
Backlog-markdown moet eerst de M11-stories bevatten en de parser moet M11 als ACTIVE-milestone kennen voordat `mcp__scrum4me__get_claude_context` ze als next-story kan teruggeven. Workflow:
|
||||
|
||||
1. Doe ST-1108 backlog-edit + parser-flip eerst (commit `chore(M11): swap demo-active sprint from M10 to M11` + de backlog-uitbreiding)
|
||||
2. `npm run seed` — re-seed met M11=ACTIVE
|
||||
3. `mcp__scrum4me__get_claude_context` levert nu ST-1101 als next-story
|
||||
4. Verder met ST-1101-implementatie
|
||||
|
||||
> **Let op:** seed wist user-data. Doe dit op een dev-DB.
|
||||
|
||||
---
|
||||
|
||||
## Buiten scope (volgende milestones)
|
||||
|
||||
- **AI-suggested antwoorden** — Claude leest de codebase en stelt 3 mogelijke antwoorden voor; user kiest. Vereist tweede LLM-call per vraag.
|
||||
- **Mobile-push notifications** — bouwt op M10 paired-flow + service-worker. v3.
|
||||
- **Question-templates** — "ambiguous-naming"-vraag, "missing-test-case"-vraag etc. voor consistentie.
|
||||
- **Threading** — vervolgvraag op een antwoord. v1 is single-shot Q&A.
|
||||
- **File-uploads als antwoord** — bv. een screenshot.
|
||||
- **Stats/dashboard** — gemiddelde antwoord-tijd, meest-gestelde-vraagsoorten.
|
||||
- **Dismiss-per-user** — een member negeert een vraag voor zichzelf zonder 'm te beantwoorden.
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
---
|
||||
title: "M9 — Actief Product Backlog"
|
||||
status: active
|
||||
audience: [maintainer, contributor]
|
||||
language: nl
|
||||
last_updated: 2026-05-03
|
||||
applies_to: [M9]
|
||||
---
|
||||
|
||||
# M9 — Actief Product Backlog
|
||||
|
||||
Eén "actief Product Backlog" per gebruiker, persistent op `User.active_product_id`. NavBar wordt: Producten | Product Backlog | Sprint | Solo | Todo's. Zonder actief PB zijn Backlog/Sprint/Solo disabled. Sprint is alleen klikbaar als er een sprint met status `ACTIVE` bestaat. Vervangt de bestaande `last_product`-cookieflow.
|
||||
|
||||
Backlog-entries: zie [backlog.md § M9](../backlog/index.md#m9-actief-product-backlog).
|
||||
|
||||
---
|
||||
|
||||
## ST-901 — Database `user.active_product_id`
|
||||
|
||||
> Status: voltooid in commit `dad9a80`.
|
||||
|
||||
**Bestanden**
|
||||
- `prisma/schema.prisma` — model `User` uitgebreid + named relation
|
||||
- `prisma/migrations/20260427165329_add_user_active_product_id/migration.sql` — migratie
|
||||
|
||||
**Stappen**
|
||||
1. Op `User`: `active_product_id String? @db.Uuid` + relatie `active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)` + `@@index([active_product_id])`.
|
||||
2. Op `Product`: tegenrelatie `active_for_users User[] @relation("UserActiveProduct")` (anders conflicteert het met de bestaande `Product.user_id`-relatie).
|
||||
3. `npx prisma migrate dev --name add_user_active_product_id`.
|
||||
|
||||
**Aandachtspunten**
|
||||
- `vendor/scrum4me`-submodule in repo `mcp` heeft hetzelfde schema. Na merge moet daar `prisma generate && tsc --noEmit` slagen, anders breekt de wekelijkse drift-check (`trig_015FFUnxjz9WMuhhWNGBQKFD`).
|
||||
- Geen seed-wijziging nodig — `null` is correcte initiële staat.
|
||||
|
||||
**Verificatie**
|
||||
- `npx prisma migrate dev` slaagt
|
||||
- `npx prisma validate` zonder fouten
|
||||
- `prisma studio` toont kolom
|
||||
|
||||
---
|
||||
|
||||
## ST-902 — Server Actions: actief product zetten/wissen + auto-clear
|
||||
|
||||
**Bestanden**
|
||||
- `actions/active-product.ts` — nieuw, twee Server Actions
|
||||
- `actions/products.ts` — uitbreiden bij `archiveProductAction`
|
||||
- `actions/product-members.ts` — uitbreiden bij `leaveProductAction` en `removeMemberAction` (locatie verifiëren met grep)
|
||||
- `__tests__/actions/active-product.test.ts` — nieuw
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. **`setActiveProductAction({ productId })`** in `actions/active-product.ts`:
|
||||
- Volg `docs/patterns/server-action.md`
|
||||
- Zod: `z.object({ productId: z.string().uuid() })`
|
||||
- `getSession()` → 401 bij geen sessie
|
||||
- **Demo-guard**: `if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus.' }`
|
||||
- Toegangscheck: `prisma.product.findFirst({ where: { id: productId, archived: false, ...productAccessFilter(userId) } })` → `null` levert `{ ok: false, error: 'Product niet gevonden of geen toegang.' }`
|
||||
- `prisma.user.update({ where: { id: userId }, data: { active_product_id: productId } })`
|
||||
- `revalidatePath('/', 'layout')` — laat NavBar in alle routes opnieuw renderen
|
||||
- Return `{ ok: true }`
|
||||
|
||||
2. **`clearActiveProductAction()`** in hetzelfde bestand:
|
||||
- Geen input
|
||||
- `getSession()` + demo-guard
|
||||
- `prisma.user.update({ where: { id: userId }, data: { active_product_id: null } })`
|
||||
- `revalidatePath('/', 'layout')`
|
||||
|
||||
3. **Auto-clear bij toegangsverlies** — drie call-sites uitbreiden ná de hoofdmutatie:
|
||||
- `archiveProductAction(productId)`: `prisma.user.updateMany({ where: { active_product_id: productId }, data: { active_product_id: null } })`
|
||||
- `leaveProductAction(productId)`: `prisma.user.updateMany({ where: { id: userId, active_product_id: productId }, data: { active_product_id: null } })`
|
||||
- `removeMemberAction(productId, removedUserId)`: `prisma.user.updateMany({ where: { id: removedUserId, active_product_id: productId }, data: { active_product_id: null } })`
|
||||
- Eigenaarsverwijdering van een product wordt door FK `onDelete: SetNull` automatisch geregeld — geen extra code
|
||||
|
||||
4. **Tests** — `__tests__/actions/active-product.test.ts`:
|
||||
- setActive met onbekend product → `{ ok: false }`
|
||||
- setActive met archived product → `{ ok: false }`
|
||||
- setActive met product zonder access → `{ ok: false }`
|
||||
- setActive happy path → `users.active_product_id` gezet
|
||||
- Demo-user setActive → error + geen DB-mutatie
|
||||
- archiveProductAction op actief product → `active_product_id` gecleared voor alle eigenaren/leden
|
||||
|
||||
**Aandachtspunten**
|
||||
- Race-condition: setActive winnen ná auto-clear kan voorkomen. Layout-guard in ST-903 vangt dit op bij volgende request.
|
||||
- `revalidatePath('/', 'layout')` is correct — niet `revalidatePath('/dashboard')` (NavBar zit in root layout van `(app)`).
|
||||
- Geen `productAccessFilter` op `clearActiveProductAction` — eigen keuze wissen mag altijd.
|
||||
|
||||
**Verificatie**
|
||||
- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen
|
||||
- Handmatig: 2 users — A archiveert product, `users.active_product_id` van B wordt `null` in DB
|
||||
|
||||
---
|
||||
|
||||
## ST-903 — App-layout actief product + redirects
|
||||
|
||||
**Bestanden**
|
||||
- `app/(app)/layout.tsx` — uitbreiden met activeProduct-fetch + guard
|
||||
- `app/(app)/solo/page.tsx` — cookie-flow vervangen
|
||||
- `lib/cookies.ts` — `getLastProductCookie` / `setLastProductCookie` verwijderen
|
||||
- `components/shared/nav-bar.tsx` — nieuwe prop `activeProduct` accepteren (verdere UI-uitwerking in ST-904)
|
||||
- `components/solo/product-picker.tsx` — checken of nog gebruikt; anders weg
|
||||
|
||||
**Stappen**
|
||||
|
||||
1. **`app/(app)/layout.tsx`**:
|
||||
- User-query uitbreiden:
|
||||
```ts
|
||||
prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
select: {
|
||||
username: true,
|
||||
email: true,
|
||||
active_product_id: true,
|
||||
active_product: { select: { id: true, name: true, archived: true } },
|
||||
},
|
||||
})
|
||||
```
|
||||
- **Guard**: als `user.active_product_id` is gezet maar (`active_product === null` of `active_product.archived === true` of geen toegang via `productAccessFilter`):
|
||||
- `prisma.user.update(... active_product_id: null)` server-side
|
||||
- `redirect('/dashboard?notice=active-cleared')`
|
||||
- `<NavBar activeProduct={user.active_product ?? null} ... />` als nieuwe prop
|
||||
|
||||
2. **`app/(app)/solo/page.tsx`** — vervang volledig:
|
||||
```ts
|
||||
const session = await getSession()
|
||||
if (!session.userId) redirect('/login')
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
select: { active_product_id: true },
|
||||
})
|
||||
if (!user?.active_product_id) redirect('/dashboard?notice=no-active')
|
||||
redirect(`/products/${user.active_product_id}/solo`)
|
||||
```
|
||||
|
||||
3. **`lib/cookies.ts`**: verwijder `getLastProductCookie` en `setLastProductCookie`. Grep alle call-sites en pas aan/verwijder.
|
||||
|
||||
4. **Toast-handling** (server-redirect → client toast):
|
||||
- Klein client-component `<NoticeToast />` dat `useSearchParams` leest, `toast()` aanroept, querystring strippt via `router.replace(pathname)`
|
||||
- Plaats in `app/(app)/dashboard/page.tsx` (of layout) — alleen geactiveerde notices afhandelen
|
||||
- Twee waarden: `active-cleared` → "Je actieve product is niet meer beschikbaar."; `no-active` → "Selecteer eerst een actief product."
|
||||
|
||||
**Aandachtspunten**
|
||||
- Layout-guard draait per request (extra DB-query). Houd 'm in dezelfde Promise.all met de bestaande user/userRoles-fetch.
|
||||
- ProductPicker-fallback verdwijnt — switcher gebeurt in ST-904 via NavBar-dropdown.
|
||||
- `app/(app)/solo/page.tsx` blijft Server Component — alleen `redirect()` van `next/navigation`.
|
||||
- Een vorm van de cookie-helper kan ook door andere code gebruikt worden — verifieer de grep zorgvuldig vóór je verwijdert.
|
||||
|
||||
**Verificatie**
|
||||
- `npm run lint && npx tsc --noEmit && npm test && npm run build` groen
|
||||
- Login zonder active → NavBar krijgt `activeProduct={null}`
|
||||
- Login met active → NavBar krijgt object met id/name
|
||||
- Bezoek `/solo` met active → redirect naar `/products/[id]/solo` zonder cookie
|
||||
- Archiveer actief product (script of via andere user) → bij volgende request layout cleart, toast op `/dashboard`
|
||||
|
||||
---
|
||||
|
||||
## ST-904 — NavBar splits + disabled-states + switcher
|
||||
|
||||
> Plan nog te schrijven.
|
||||
|
||||
## ST-905 — Producten-scherm Activeer-knop
|
||||
|
||||
> Plan nog te schrijven.
|
||||
|
||||
## ST-906 — Edge cases — toegangsverlies en archivering
|
||||
|
||||
> Plan nog te schrijven.
|
||||
|
||||
## ST-907 — Documentatie en tests
|
||||
|
||||
> Plan nog te schrijven.
|
||||
|
|
@ -1,649 +0,0 @@
|
|||
# PBI-79: Product Backlog workflow — sprint-membership via vinkjes
|
||||
|
||||
> **MCP:** PBI-79 (`cmp13vrxd0001m017ta9aflg9`) in Scrum4Me product (`cmohrysyj0000rd17clnjy4tc`).
|
||||
>
|
||||
> **Review verwerkt:** Dit plan is een herziene versie na de review in [`product-backlog-workflow-plan-review.md`](product-backlog-workflow-plan-review.md). De vier P1-bevindingen zijn allemaal geadresseerd, evenals de vijf P2-punten. Zie de sectie *"Reactie op review"* onderaan voor de mapping.
|
||||
|
||||
---
|
||||
|
||||
## Implementatie-stand & scope-aanpassingen (post-testing)
|
||||
|
||||
> Deze sectie documenteert wat er sinds de eerste implementatie-pass is bijgewerkt op basis van gebruikerstests + nieuwe inzichten. De rest van het plan beneden geldt **behalve waar dit kopje dat overrulet**.
|
||||
|
||||
### Gerealiseerde commits (in volgorde)
|
||||
|
||||
| # | Commit | Story | Inhoud |
|
||||
|---|---|---|---|
|
||||
| 1 | 2af6f24 | ST-1333 | Active-sprint null-contract + clearActiveSprintAction |
|
||||
| 2 | 56c55e1 | ST-1334 | pendingSprintDraft slot (compacte intent-shape) |
|
||||
| 3 | b4a515e | ST-1343 | `lib/sprint-conflicts.ts` eligibility helpers |
|
||||
| 4 | e89fb71 | ST-1335 | Gescoped endpoints (`sprint-membership-summary`, `cross-sprint-blocks`) |
|
||||
| 5 | 89c2356 | ST-1336 | `sprintMembership`-slice + selectors in product-workspace-store |
|
||||
| 6 | 947d970 | ST-1337 | State A′ UI (metadata-dialog + sticky banner + PbiList ombouw) |
|
||||
| 7 | d21011c | ST-1339 | `createSprintWithSelectionAction` + banner wire-up |
|
||||
| 8 | 4c6e999 | ST-1340 | `commitSprintMembershipAction` + gerichte client-store patches |
|
||||
| 9 | 117616f | ST-1338 | State B vinkjes-UI + "Sprint opslaan"-knop |
|
||||
| 10 | b91d92a | ST-1341+1342 | `SprintEditDialog` + multi-OPEN sprints |
|
||||
| 11 | 0c36f4e | ST-1344 | `updateSprintAction` regression tests |
|
||||
| 12 | 8d6fbdf | bugfix | PBI-rij weer klikbaar voor selectie; vinkje als aparte trigger |
|
||||
| 13 | 35c6404 | bugfix | Cascade-restore alleen wanneer hint-story bij nieuwe PBI hoort |
|
||||
| 14 | d7d1112 | feat | Sprint-switch auto-select PBI/story + user-settings persist (3 keys) |
|
||||
|
||||
### Bugs gevonden tijdens testen (afgehandeld)
|
||||
|
||||
1. **Hele PBI-rij was de toggle in selectionMode.** Gevolg: rij-klik bulk-toggled stories en update de teller, maar PBI werd niet als focus geselecteerd → story-kolom bleef leeg.
|
||||
*Fix (8d6fbdf):* in `SortablePbiRow` selectionMode-branch wordt onClick weer `onSelect`; het tri-state icoon zit in een eigen `<button>` met `stopPropagation`.
|
||||
2. **Cascade-restore overschrijft PBI-switch.** Bij wisselen naar een andere PBI bleef de oude story (en dus zijn taken) zichtbaar omdat `setActivePbi`'s async hint-restore de vorige story-id terugzette zonder PBI-validatie.
|
||||
*Fix (35c6404):* hint wordt alleen toegepast als `storiesById[hint].pbi_id === pbiId`.
|
||||
3. **Tooltip-API mismatch.** `TooltipTrigger` van base-ui accepteert geen `asChild`; geprobeerd via render-prop maar uiteindelijk de hele knop in selectionMode in de Tooltip gewikkeld.
|
||||
|
||||
### Nieuwe feature (na implementatie toegevoegd) — sprint-switch auto-select
|
||||
|
||||
Bij wisselen van sprint via de switcher wordt **server-side** de inhoud van de sprint geresolved en als deze precies één PBI heeft (en die PBI exact één story binnen de sprint), worden beide automatisch geselecteerd. Alle drie selectie-velden worden atomair in user-settings weggeschreven zodat cross-device-restore klopt.
|
||||
|
||||
- Schema: `layout.activePbis` + `layout.activeStories` per product (beide nullable).
|
||||
- Helper: `setActiveSelectionInSettings(userId, productId, { sprintId, pbiId?, storyId? })`.
|
||||
- Server-action: `switchActiveSprintAction(productId, sprintId)` doet de auto-select-resolutie en returnt het tripel.
|
||||
- Sprint-switcher: roept de nieuwe action aan en synchroniseert de client-store gelijk (geen flash).
|
||||
- `ActiveSelectionHydrator` (nieuw): client-side effect dat user-settings-activePbi/activeStory naar de workspace-store spiegelt; wint van de bestaande localStorage hint-restore.
|
||||
|
||||
### Scope-aanpassing — pendingSprintDraft wordt **session-only**
|
||||
|
||||
**Was:** de draft (sprint-doel + per-PBI intent + per-PBI overrides) staat persistent in `user-settings.workflow.pendingSprintDraft` zodat de gebruiker na navigatie kan hervatten.
|
||||
|
||||
**Wordt:** de draft leeft alleen in de Zustand-store van de sessie. Bij wegnavigeren krijgt de gebruiker een `useDirtyCloseGuard`-confirm; bij doorgaan wordt de draft **weggegooid** (niet hervat-baar). Reden: de user geeft expliciet aan dat ongeslagen sprints geen rest-state mogen achterlaten in de DB.
|
||||
|
||||
Concrete wijzigingen:
|
||||
- `lib/user-settings.ts`: `workflow.pendingSprintDraft` kan blijven bestaan voor type-compatibiliteit maar wordt niet meer geschreven door de UI.
|
||||
- Actions `setPendingSprintDraftAction` + `clearPendingSprintDraftAction` worden gedeprecieerd (of behouden voor migratie van eventueel oude entries) maar **niet meer aangeroepen** door de UI.
|
||||
- Store `useUserSettingsStore.setPendingSprintDraft` / `upsertPbiIntent` / `upsertStoryOverride` blijven bestaan maar de server-roundtrip eruit; lokale state-only.
|
||||
- `useDirtyCloseGuard` op het banner-niveau triggert een confirm bij browser-back / route-wissel; bevestigen → `clearPendingSprintDraftAction` (om eventuele oude DB-entries op te ruimen) **+** lokale state-reset.
|
||||
|
||||
### Nieuwe feature — draft-sprint zichtbaar in sprint-switcher
|
||||
|
||||
Tijdens state A′ (er is een draft) toont de sprint-switcher de **draft-naam** (= `draft.goal`, ingekort) als extra entry bovenaan de dropdown met markering "Concept" of italic-styling. Hij is niet selecteerbaar als "actieve" sprint (want geen sprintId); klikken erop opent de banner-actie of doet niets bijzonders. Doel: visueel feedback geven dat er een onafgemaakte sprint loopt zonder die in de DB op te slaan.
|
||||
|
||||
Concreet:
|
||||
- Sprint-switcher krijgt prop `pendingDraftGoal?: string | null` (server-side leesbaar via user-settings store na hydration, of via `useUserSettingsStore` in de switcher-component).
|
||||
- Render bovenaan de dropdown (boven "— Geen actieve sprint —") wanneer aanwezig: *"⚙ Concept — [goal-prefix]"*.
|
||||
|
||||
### Wat blijft staan uit de oorspronkelijke ontwerpkeuzes
|
||||
|
||||
- Schema `layout.activeSprints` blijft nullable (key+null = bewust geen sprint).
|
||||
- Drie-states-model (A / A′ / B) blijft.
|
||||
- Tri-state PBI-vinkje, story-binair-vinkje, cross-sprint disabled blijven.
|
||||
- "Sprint opslaan"-knop met teller (state B) blijft.
|
||||
- Eligibility-filter + status-mutaties in dezelfde transactie blijven.
|
||||
- Endpoints gescoped op `pbiIds` blijven.
|
||||
- Multi-OPEN sprints toegestaan blijft.
|
||||
|
||||
### Wat nog te doen (na deze plan-update)
|
||||
|
||||
> Alle drie punten **afgerond** in commit `2a4ee6a`.
|
||||
|
||||
1. ~~**Implementeer scope-aanpassing**~~ — `setPendingSprintDraft` / `clearPendingSprintDraft` zijn nu local-only; `hydrate()` strip eventuele legacy DB-entries.
|
||||
2. ~~**Sprint-switcher concept-entry**~~ — `⚙ Concept — [goal]` verschijnt bovenaan de dropdown zodra er een draft loopt.
|
||||
3. ~~**Verifieer**~~ — `npm run verify` groen (826 tests). `SprintDraftLeaveGuard` registreert `beforeunload`-listener voor browser-refresh/close. In-app route-changes blijven via banner-Annuleren lopen.
|
||||
|
||||
### Bewust niet geïmplementeerd
|
||||
|
||||
- **Server-side persist van manuele PBI/story-klikken.** Vraag: "wordt de geselecteerde pbi ook opgeslagen". Antwoord: nee, momenteel alleen via sprint-switch auto-select. Manuele klikken gaan naar localStorage. Cross-device parity voor manuele klikken vereist extra server-roundtrips per klik; de helpers `setActivePbiInSettings` / `setActiveStoryInSettings` zijn voorbereid maar niet gewired. Op verzoek opnieuw oppakken in een vervolg-PBI.
|
||||
|
||||
### localStorage-gebruik (overzicht)
|
||||
|
||||
| Locatie | Doel |
|
||||
|---|---|
|
||||
| [stores/product-workspace/restore.ts](stores/product-workspace/restore.ts) | Per-browser hints `lastActivePbiId` / `lastActiveStoryId` / `lastActiveTaskId` per product. |
|
||||
| [stores/sprint-workspace/restore.ts](stores/sprint-workspace/restore.ts) | Idem voor de sprint-pagina. |
|
||||
| [lib/user-settings-migration.ts](lib/user-settings-migration.ts) | One-shot migratie van legacy prefs (PBI-76) naar user-settings. |
|
||||
| [components/ideas/idea-md-editor.tsx](components/ideas/idea-md-editor.tsx) | Auto-save van idee-markdown-draft (niet PBI-79-gerelateerd). |
|
||||
|
||||
`ActiveSelectionHydrator` (PBI-79) wint van de localStorage-hints voor PBI/story-selectie zodra user-settings expliciet iets bevat.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
De Product Backlog-pagina (`/products/[id]`) is het hart van Scrum4Me. De **lazy-load-basis bestaat al** (filter-first/background-remaining-PBI's + lazy stories/tasks per klik via [lib/product-backlog-pbis.ts](lib/product-backlog-pbis.ts), `ensurePbiLoaded`, `ensureStoryLoaded`). Dit plan bouwt daarop voort, het herontwerpt dat fundament niet.
|
||||
|
||||
Wat nog ontbreekt:
|
||||
|
||||
1. **Geen uniforme sprint-samenstelling-UI**. Sprint-aanmaak loopt nu via twee flows: `createSprintAction` (één pbi_id) en `createSprintWithPbisAction` (array, via `NewSprintDialog`). Geen UI-feedback over welke PBI's al in welke mate "in de huidige sprint zitten".
|
||||
2. **Stories aan/uit sprint per stuk** kan alleen via de Sprint-pagina, niet vanuit de backlog.
|
||||
3. **Geen pending/dirty-flow** voor sprint-mutaties — alle huidige acties zijn direct gecommit, wat zware multi-toggle-flows omslachtig maakt.
|
||||
|
||||
We bouwen een vinkje-gebaseerde workflow met drie states. Geen schemamutatie op de DB — `sprint_id` blijft op Story en Task. PBI-vinkjes zijn puur afgeleid. `task.sprint_id` blijft denormalisatie van `story.sprint_id` en wordt cascade-meegeupdate bij bulk-mutaties.
|
||||
|
||||
---
|
||||
|
||||
## Beslissingen (samenvatting)
|
||||
|
||||
| Onderdeel | Keuze |
|
||||
|---|---|
|
||||
| **Datamodel** | Ongewijzigd. `story.sprint_id` is unit-of-truth; PBI/task vinkjes afgeleid |
|
||||
| **Cross-sprint conflict** | Disabled vinkje + tooltip; **alleen** tegen andere OPEN sprints |
|
||||
| **State A** (geen sprint) | Alle PBI's, geen vinkjes, klassieke 3-koloms inspect |
|
||||
| **State A′ vorm** | Two-step: kleine modal (metadata) → sticky banner + inline vinkjes |
|
||||
| **State A′ annuleren** | Dirty-close confirm (`useDirtyCloseGuard`-pattern) |
|
||||
| **State A′ persistentie** | `user-settings.pendingSprintDraft[productId]` — compacte intent (zie hieronder), niet alle story-IDs |
|
||||
| **Lege sprint** | Toegestaan |
|
||||
| **State B vinkjes** | Tri-state op PBI (selector-afgeleid), binair op story; klikken muteert pending buffer |
|
||||
| **State B pending scope** | Alleen sprint-membership toggles |
|
||||
| **State B dirty-UI** | "Sprint opslaan"-knop altijd zichtbaar, disabled bij clean, met teller bij dirty |
|
||||
| **State B navigatie bij dirty** | Confirm-dialog |
|
||||
| **Sprint-switcher** | OPEN sprints + "Geen actieve sprint"-optie. CLOSED via bestaande sprint-pagina |
|
||||
| **Sprint-scope** | Per-user (huidig `user-settings.activeSprints[productId]`) |
|
||||
| **Multiple OPEN sprints** | Toegestaan — `createSprintAction`-uniqueness-check vervalt |
|
||||
| **Nieuwe story in state B** | `sprint_id = activeSprintId` direct bij aanmaak |
|
||||
| **Tasks-niveau** | Geen vinkjes. Cascade-meegeupdated met story |
|
||||
| **Sprint metadata edit** | `SprintEditDialog` (goal, dates) via edit-icoon |
|
||||
| **Sprint afsluiten** | Hergebruik bestaande `completeSprintAction` (per-story DONE/OPEN beslissing + PBI-promotie) — **niet** een nieuwe `closeSprintAction` |
|
||||
| **`story.status` bij membership-mutaties** | Add: `status='IN_SPRINT'` (én `sprint_id` gezet). Remove: `status='OPEN'` (én `sprint_id=NULL`). `task.sprint_id` cascadeert in **dezelfde transactie** |
|
||||
| **Eligibility voor toevoegen** | Server-resolve mag alleen stories met `sprint_id IS NULL` **en** `status != 'DONE'` toevoegen. Stories uit CLOSED/ARCHIVED/FAILED sprints met DONE-status zijn dus niet eligible — moeten eerst handmatig op OPEN gezet worden (of via re-open flow) |
|
||||
| **Active-sprint null-contract** | Schema nullable maken — `activeSprints[productId]: string \| null`. **Key-aanwezigheid heeft betekenis**: key ontbreekt → fallback-cascade (eerste OPEN, dan recent CLOSED). Key met `null`-waarde → expliciet *geen* actieve sprint, géén fallback |
|
||||
| **PBI-selectie-flow migratie** | Bestaande `selectionMode` + `NewSprintDialog` + `createSprintWithPbisAction` worden **omgebouwd** tot A′-draft-mode. Eén flow, geen feature-flag-parallellisme |
|
||||
| **Initial server-side load** | Bestaande `getProductBacklogPbis(productId, query, 'matching')` blijft basis — geen counts in deze call. Geen stories, geen taken |
|
||||
| **Background remaining-load** | Behoud huidige patroon: client laadt `?mode=remaining` via route handler |
|
||||
| **PBI-counts (state B tri-state)** | Aparte lazy summary-endpoint `GET /api/products/[id]/sprint-membership-summary?sprintId=X&pbiIds=<ids>` — **expliciet gescoped op pbiIds** (visible/loaded batch), nooit product-breed. Alleen aangeroepen in state B |
|
||||
| **Story-detail (description + taken)** | Lazy bij PBI-klik via bestaande `ensurePbiLoaded`/`ensureStoryLoaded` route handlers |
|
||||
| **Story-IDs voor A′ tri-state** | **Niet** brede `getStoryIdsByPbi(productId)`-fetch. Per PBI lazy via dezelfde `ensurePbiLoaded` als state A |
|
||||
| **Cross-sprint conflict-detectie** | Server-side bij commit (autoritatief). Client-hint via lichte `GET /api/products/[id]/cross-sprint-blocks?excludeSprintId=X&pbiIds=<ids>` — **gescoped op pbiIds** voor disabled-vinkjes |
|
||||
| **Data-access stijl** | Blijven bij **route handlers + `cache: 'no-store'` + `revalidatePath`** (huidige stijl). Géén Cache Components / `'use cache'` / `cacheTag` in dit plan |
|
||||
| **Sync na commit** | Server action retourneert affected ids → client patcht workspace-store gericht. **Geen `router.refresh()` of full page rehydration** |
|
||||
|
||||
---
|
||||
|
||||
## State A — geen actieve sprint geselecteerd
|
||||
|
||||
**UI:** bestaande 3-koloms layout uit [components/backlog/backlog-split-pane.tsx](components/backlog/backlog-split-pane.tsx) onveranderd. PBI-lijst | Story-panel | Task-panel. Geen vinkjes.
|
||||
|
||||
**Header-acties:** sprint-switcher toont "Geen actieve sprint" + dropdown van OPEN sprints + "— Geen actieve sprint —"-optie. Naast switcher: knop **"Nieuwe sprint"** → start A′ door metadata-modal te openen.
|
||||
|
||||
**Wijzigingen t.o.v. huidig gedrag:**
|
||||
- Sprint-switcher in [components/shared/sprint-switcher.tsx](components/shared/sprint-switcher.tsx) krijgt expliciete optie "— Geen actieve sprint —"; selectie roept (nieuwe) `clearActiveSprintAction(productId)` aan → schrijft `null` in user-settings.
|
||||
- De huidige "Start Sprint"-knop in [app/(app)/products/[id]/page.tsx](app/(app)/products/[id]/page.tsx) wordt "Nieuwe sprint" en triggert A′-flow i.p.v. direct `NewSprintDialog`.
|
||||
|
||||
---
|
||||
|
||||
## State A′ — sprint definiëren (ombouw van huidige selectionMode)
|
||||
|
||||
### Migratie-uitgangspunt
|
||||
|
||||
De bestaande PBI-selectie-flow in [components/backlog/pbi-list.tsx:219-523](components/backlog/pbi-list.tsx) heeft al:
|
||||
- `selectionMode` boolean en `selectedIds: Set<string>`
|
||||
- `toggleCheck(id)` voor PBI-toggles
|
||||
- `exitSelection()` voor cleanup
|
||||
- `NewSprintDialog` aanroep met `pbiIds`-array
|
||||
- Server-action `createSprintWithPbisAction` die alle stories van geselecteerde PBI's bulk-update
|
||||
|
||||
We **bouwen dit om** tot A′. Het oude `NewSprintDialog` wordt vervangen door de two-step flow (metadata-modal → banner). De selectie-state wordt uitgebreid van "PBI's only" naar "PBI's én individuele stories (overrides)". `createSprintWithPbisAction` wordt aangepast om óók override-lijsten te accepteren.
|
||||
|
||||
### Stap 1: metadata-modal
|
||||
|
||||
Klik "Nieuwe sprint" → kleine `Dialog` (Entity-Dialog-pattern uit [docs/patterns/dialog.md](docs/patterns/dialog.md)):
|
||||
- **Sprint-doel** (`sprint_goal`, verplicht)
|
||||
- **Startdatum** (optioneel, default = vandaag)
|
||||
- **Einddatum** (optioneel, default = +2 weken)
|
||||
- Knoppen: "Annuleren" | "Verder"
|
||||
|
||||
"Verder" valideert (Zod) en schrijft via `setPendingSprintDraftAction` naar user-settings. **Geen sprint in DB.**
|
||||
|
||||
### Stap 2: vinkjes + sticky banner (compacte intent-state)
|
||||
|
||||
Op de pagina verschijnt een **sticky banner**:
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Sprint definiëren — [doel] · X PBI's, Y stories │
|
||||
│ [Annuleren] [Sprint aanmaken] │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Op alle PBI-rijen en story-rijen verschijnen vinkjes — story-vinkjes pas zichtbaar als de PBI is geopend (via bestaande `ensurePbiLoaded`).
|
||||
|
||||
**Pending draft-state (compact, overrides per PBI):**
|
||||
|
||||
```ts
|
||||
pendingSprintDraft: {
|
||||
goal: string
|
||||
startAt?: string
|
||||
endAt?: string
|
||||
// Per-PBI bulk-intent:
|
||||
pbiIntent: {
|
||||
[pbiId]: 'all' | 'none' // default 'none' tot user PBI aanvinkt
|
||||
}
|
||||
// Per-PBI overrides (story-ids die afwijken van de PBI-intent):
|
||||
storyOverrides: {
|
||||
[pbiId]: {
|
||||
add: string[] // expliciet aan, ook al staat PBI op 'none'
|
||||
remove: string[] // expliciet uit, ook al staat PBI op 'all'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Waarom per-PBI overrides (i.p.v. één globale add/remove):** bij PBI-toggle (`'all' → 'none'`) of bij sessie-restore moet je zonder brede story-fetch betrouwbaar weten welke overrides bij welke PBI horen. Globale lijsten dwingen je tot een product-breed `getStoryIdsByPbi` om op te schonen — dat is precies wat we niet willen. Met per-PBI overrides is opruimen lokaal: bij PBI-toggle wis je `storyOverrides[pbiId]`, klaar.
|
||||
|
||||
**Tri-state-resolutie (selector, niet opgeslagen):**
|
||||
- PBI-vinkje weergave: bereken uit `pbiIntent[pbiId]` + de subset van zijn child-stories die geladen is + `storyOverrides[pbiId]`. Bij `intent='all'` en geen `remove` → ✓. Bij `intent='none'` en geen `add` → ☐. Anders ◐.
|
||||
- Story-vinkje: `(pbiIntent[pbiId] == 'all' || storyOverrides[pbiId]?.add?.includes(storyId)) && !storyOverrides[pbiId]?.remove?.includes(storyId)`.
|
||||
|
||||
**Toggle-semantiek:**
|
||||
- Klik PBI-vinkje ☐→✓: `pbiIntent[pbi] = 'all'`, wis `storyOverrides[pbi]`.
|
||||
- Klik PBI-vinkje ✓→☐: `pbiIntent[pbi] = 'none'`, wis `storyOverrides[pbi]`.
|
||||
- Klik story-vinkje (in geopende PBI): voeg toe aan `storyOverrides[pbi].add` of `.remove`, met cancel-out tegen de tegenoverliggende lijst van diezelfde PBI.
|
||||
|
||||
**Voordelen:** geen N×K JSON-blob per draft. Per-PBI scoping maakt cleanup lokaal en restore deterministisch.
|
||||
|
||||
**Annuleren** → dirty-close confirm → `clearPendingSprintDraftAction` → banner verdwijnt.
|
||||
|
||||
**Sprint aanmaken** → server action `createSprintWithSelectionAction(productId, metadata, pbiIntent, storyOverrides)`:
|
||||
1. Server resolveert intent → concrete `storyIdsToAddToSprint: string[]`:
|
||||
- Voor elke PBI met `intent = 'all'`: alle child-stories minus `storyOverrides[pbi].remove`
|
||||
- Plus alle stories in `storyOverrides[pbi].add` (over alle PBI's)
|
||||
2. **Eligibility-filter (server, autoritatief):** behoud alleen stories waarvoor `sprint_id IS NULL` **en** `status != 'DONE'`. Stories die niet voldoen (in andere sprint, of al DONE) komen in `conflicts.notEligible[]` met reden.
|
||||
3. **Cross-sprint-check** (gedekt door eligibility, maar separately rapporteren): geblokkeerde stories → `conflicts.crossSprint[]` met `{ storyId, sprintId, sprintName }`.
|
||||
4. Transactie:
|
||||
- Insert Sprint (status=OPEN)
|
||||
- `story.sprint_id = newSprintId, story.status = 'IN_SPRINT' WHERE id IN (eligibleStoryIds)`
|
||||
- `task.sprint_id = newSprintId WHERE story_id IN (eligibleStoryIds)` (cascade — task.status onveranderd)
|
||||
5. `clearPendingSprintDraftAction` + `setActiveSprintInSettings(productId, newSprintId)`
|
||||
6. Realtime-event broadcasting
|
||||
7. **Return:** `{ sprintId, affectedStoryIds, affectedPbiIds, conflicts: { notEligible, crossSprint } }`
|
||||
8. Client patcht workspace-store gericht: voeg sprintId toe aan stories/tasks, zet `story.status = 'IN_SPRINT'`, invalidate `pbiSummary`-counts voor affected PBI's via lazy summary-refetch (gescoped). Toast voor conflicts. **Geen page-refresh.**
|
||||
|
||||
### Persistent draft
|
||||
|
||||
Verlaten van de pagina/sessie tijdens A′ → `pendingSprintDraft` blijft in user-settings. Volgende bezoek: pagina detecteert draft → banner + vinkjes verschijnen automatisch.
|
||||
|
||||
---
|
||||
|
||||
## State B — actieve sprint geselecteerd
|
||||
|
||||
### UI
|
||||
|
||||
- **Header**: sprint-switcher toont actieve sprint. Edit-icoon ernaast → opent `SprintEditDialog` (alleen metadata: goal + dates).
|
||||
- **"Sprint opslaan"-knop**: altijd zichtbaar, disabled bij clean, geactiveerd met teller bij dirty: *"Sprint opslaan (3)"*.
|
||||
- **Sprint afsluiten**: bestaande `completeSprintAction`-flow blijft op de sprint-pagina (`/products/[id]/sprint/[sprintId]`); SprintEditDialog krijgt een link "Sprint afronden…" die naar die pagina navigeert. Geen duplicate flow.
|
||||
- **3-koloms layout**: ongewijzigd. PBI-vinkjes (tri-state via selector), story-vinkjes (binair, disabled-bij-conflict), geen task-vinkjes.
|
||||
|
||||
### Pending buffer (state B)
|
||||
|
||||
In [stores/product-workspace/store.ts](stores/product-workspace/store.ts) toevoegen — **arrays, niet Sets**:
|
||||
|
||||
```ts
|
||||
sprintMembershipPending: {
|
||||
adds: string[] // story-ids die in actieve sprint moeten
|
||||
removes: string[] // story-ids die uit actieve sprint moeten
|
||||
}
|
||||
```
|
||||
- `isDirty` selector: `adds.length + removes.length > 0`
|
||||
- Teller selector: `adds.length + removes.length`
|
||||
- Cancel-out: bij toggle terug wordt het ID uit de tegenoverliggende lijst gehaald
|
||||
|
||||
Arrays zijn JSON-serialiseerbaar (handig voor debugging/devtools) en spelen netjes met Zustand/Immer (geen mutable Set-valkuil).
|
||||
|
||||
### Tri-state vinkjes via selectors (geen opgeslagen state)
|
||||
|
||||
In [stores/product-workspace/store.ts](stores/product-workspace/store.ts):
|
||||
|
||||
```ts
|
||||
// Primitieven (opgeslagen):
|
||||
pbiSummary: {
|
||||
[pbiId]: {
|
||||
totalStoryCount: number // uit summary-endpoint
|
||||
inActiveSprintStoryCount: number // uit summary-endpoint, of 0 in state A
|
||||
}
|
||||
}
|
||||
loadedStoryIdsByPbi: { [pbiId]: string[] } // alleen voor stories die al geladen zijn
|
||||
storiesByPbi: { [pbiId]: Story[] | undefined }
|
||||
tasksByStory: { [storyId]: Task[] | undefined }
|
||||
sprintMembershipPending: { adds: string[], removes: string[] }
|
||||
crossSprintBlocks: { [storyId]: { sprintId: string, sprintName: string } } // lazy
|
||||
|
||||
// Selectors (afgeleid, gememoized):
|
||||
selectPbiTriState(pbiId): 'empty' | 'partial' | 'full'
|
||||
selectStoryEffectiveInSprint(storyId): boolean
|
||||
selectStoryIsBlocked(storyId): { sprintId, sprintName } | null
|
||||
```
|
||||
|
||||
`selectPbiTriState` rekent met `inActiveSprintStoryCount` + pending adds/removes voor stories van deze PBI (waarvan we de mapping kennen via `loadedStoryIdsByPbi` of via een lichte query bij PBI-load). Als de PBI niet geladen is, kan tri-state worden afgeleid uit de counts alleen (full = count==total, empty = count==0, partial = anders).
|
||||
|
||||
### Sprint opslaan
|
||||
|
||||
Server action `commitSprintMembershipAction(activeSprintId, adds[], removes[])`:
|
||||
1. **Eligibility-filter voor `adds` (server, autoritatief):** behoud alleen stories met `sprint_id IS NULL` **en** `status != 'DONE'`. Niet-eligible stories (cross-sprint-conflict, of DONE) komen in `conflicts.notEligible[]`.
|
||||
2. **`removes`-filter:** behoud alleen stories die feitelijk `sprint_id = activeSprintId` hebben (race-safety; story kan ondertussen al ergens anders heen verplaatst zijn).
|
||||
3. Transactie:
|
||||
- **Add**: `story.sprint_id = activeSprintId, story.status = 'IN_SPRINT' WHERE id IN (eligibleAdds)`
|
||||
- **Add**: `task.sprint_id = activeSprintId WHERE story_id IN (eligibleAdds)` (cascade, task.status onveranderd)
|
||||
- **Remove**: `story.sprint_id = NULL, story.status = 'OPEN' WHERE id IN (validRemoves)`
|
||||
- **Remove**: `task.sprint_id = NULL WHERE story_id IN (validRemoves)` (cascade)
|
||||
4. Realtime-events broadcasten
|
||||
5. **Return:** `{ affectedStoryIds, affectedPbiIds, affectedTaskIds, conflicts: { notEligible, alreadyRemoved } }`
|
||||
6. Client patcht store gericht:
|
||||
- Update `story.sprint_id` + `story.status` voor affected stories in `storiesById` / `storiesByPbi`
|
||||
- Update `task.sprint_id` voor affected tasks
|
||||
- Debounced refetch van `sprint-membership-summary` voor affected PBI's (**gescoped op `pbiIds=affectedPbiIds`**)
|
||||
- Wis pending buffer
|
||||
- Toast voor conflicts
|
||||
- **Geen `router.refresh()`.**
|
||||
|
||||
### Andere mutaties in state B
|
||||
|
||||
- **Story aanmaken** (StoryDialog): `sprint_id = activeSprintId` direct bij create. Verschijnt direct in sprint.
|
||||
- **PBI/Story/Task field-edit** (bestaande Entity Dialogs): onveranderd.
|
||||
- **Sprint-switcher wisselt bij dirty**: confirm-dialog.
|
||||
- **Wegnavigeren met dirty**: `useDirtyCloseGuard` → confirm-dialog.
|
||||
|
||||
---
|
||||
|
||||
## Cross-sprint conflict — afhandeling
|
||||
|
||||
**Client (hint-laag):** lazy fetch `GET /api/products/[id]/cross-sprint-blocks?excludeSprintId=X` bij state-B-load. Vult `crossSprintBlocks` in de store. Story-rij met `crossSprintBlocks[storyId] != null` → vinkje disabled, tooltip "Zit in Sprint [naam]".
|
||||
|
||||
**Server (autoritatieve check):** in `commitSprintMembershipAction` en `createSprintWithSelectionAction` opnieuw checken — race-conditie wordt afgevangen, conflicts worden geretourneerd als warning. Client toont toast voor geskippte stories.
|
||||
|
||||
Helper `lib/sprint-conflicts.ts` (nieuw) doet de check op een set story-IDs en geeft `{ allowed: string[], blocked: { storyId, sprintId, sprintName }[] }`.
|
||||
|
||||
---
|
||||
|
||||
## SprintEditDialog (nieuw)
|
||||
|
||||
`components/backlog/sprint-edit-dialog.tsx` — Entity-Dialog-pattern:
|
||||
- Velden: `sprint_goal`, `start_at`, `end_at`
|
||||
- Knop "Opslaan" → `updateSprintAction(sprintId, fields)`
|
||||
- Link "Sprint afronden…" → navigeert naar `/products/[id]/sprint/[sprintId]` (bestaande sprint-page met `completeSprintAction`)
|
||||
- **Geen** "Sprint afsluiten"-knop hier — hergebruik bestaande completion-flow met per-story DONE/OPEN beslissing en PBI-promotie.
|
||||
|
||||
Server action `updateSprintAction(sprintId, { goal?, start_at?, end_at? })`: validate met Zod, update Sprint-record, `revalidatePath('/products/[id]')`, retourneert affected sprint. Client patcht sprint-record in store.
|
||||
|
||||
---
|
||||
|
||||
## Dataflow
|
||||
|
||||
### Uitgangspunten
|
||||
|
||||
- **Blijf bij route handlers + `cache: 'no-store'`** (huidige patroon). Geen `'use cache'`/`cacheTag` in deze migratie — review's P2 zegt: meng deze stijlen niet half. Migratie naar Cache Components is een eigen project.
|
||||
- **Filter-first respecteren**: initial render levert alleen *matching* PBI-metadata; *remaining* op de achtergrond — beide via bestaande [getProductBacklogPbis](lib/product-backlog-pbis.ts).
|
||||
- **Geen aggregaten in initial query**: dat zou bij groei alsnog brede story-aggregaties bij elke render forceren.
|
||||
- **Counts apart via lazy endpoint**: alleen voor state B, alleen voor zichtbare PBI's (of bulk per sprint — beheerbaar omdat #PBI's per product bescheiden blijft).
|
||||
- **Geen brede `getStoryIdsByPbi`**: hergebruik bestaande `ensurePbiLoaded`/`ensureStoryLoaded` lazy-loads. Tri-state werkt op counts (uit summary-endpoint) zolang de PBI dichtgeklapt is; pas bij open-klik komen story-IDs in beeld voor accurate selector-state.
|
||||
- **Sync-model**: SSE-patches (al aanwezig) voor reactieve updates + `revalidatePath` na server-actions (huidige patroon) + gerichte client-store patches met de affected-IDs uit action-returns.
|
||||
|
||||
### Initial server-side load (page render)
|
||||
|
||||
Onveranderd t.o.v. huidige flow — geen nieuwe loader:
|
||||
|
||||
```ts
|
||||
// app/(app)/products/[id]/page.tsx (huidige code, behouden):
|
||||
const initialPbiQuery = productBacklogPbiQueryFromSettings(...)
|
||||
const pbis = await getProductBacklogPbis(id, initialPbiQuery, 'matching')
|
||||
// Geen stories, geen taken in initial render.
|
||||
```
|
||||
|
||||
Plus parallel:
|
||||
- `activeSprint = resolveActiveSprint(productId, userId)` — gewijzigd om explicit `null` te respecteren (zie hieronder).
|
||||
- `pendingSprintDraft = getUserSettings(userId).pendingSprintDraft?.[productId] ?? null`.
|
||||
|
||||
### Background remaining-load
|
||||
|
||||
Bestaande route handler `GET /api/products/[id]/backlog?mode=remaining` blijft. Client triggert na initial render om de overige PBI-metadata in de store te krijgen (zonder stories/tasks).
|
||||
|
||||
### Lazy per PBI-klik
|
||||
|
||||
Bestaande `ensurePbiLoaded(pbiId)` in [stores/product-workspace/store.ts](stores/product-workspace/store.ts) blijft. Fetch via route handler met `cache: 'no-store'`. Vult `storiesByPbi[pbiId]` + `loadedStoryIdsByPbi[pbiId]`.
|
||||
|
||||
### Lazy per story-klik
|
||||
|
||||
Bestaande `ensureStoryLoaded(storyId)` blijft (laadt taken).
|
||||
|
||||
### Sprint-membership summary (NIEUW — alleen state B, gescoped)
|
||||
|
||||
Nieuw route handler `GET /api/products/[id]/sprint-membership-summary?sprintId=X&pbiIds=<comma-separated>`:
|
||||
```ts
|
||||
// Response:
|
||||
{
|
||||
[pbiId: string]: { total: number, inSprint: number }
|
||||
}
|
||||
```
|
||||
|
||||
- **`pbiIds` is verplicht** — endpoint weigert product-brede aanroepen. Client geeft alleen visible/loaded PBI-IDs door.
|
||||
- Eén `groupBy` op `Story` waar `pbi_id IN (pbiIds)` (matching-filter werkt nog: we vragen alleen counts voor PBI's die al in viewport-batch staan).
|
||||
- Verwaarloosbare belasting omdat de query begrensd is op de doorgegeven set.
|
||||
|
||||
Aangeroepen door client wanneer state B actief wordt OF na sprint-switch, OF na een commit (gescoped op affected pbi-ids). Vult `pbiSummary` in de store.
|
||||
|
||||
In state A wordt **niet** aangeroepen.
|
||||
|
||||
### Cross-sprint blocks (NIEUW — alleen state B, gescoped)
|
||||
|
||||
Nieuw route handler `GET /api/products/[id]/cross-sprint-blocks?excludeSprintId=X&pbiIds=<comma-separated>`:
|
||||
```ts
|
||||
{
|
||||
[storyId: string]: { sprintId: string, sprintName: string }
|
||||
}
|
||||
```
|
||||
|
||||
- **`pbiIds` verplicht** — endpoint weigert product-brede scans. Begrenzing op visible/loaded batch.
|
||||
- Aangeroepen bij state B-load + na elke PBI-batch-load (zodat nieuwe PBI's hun blocks krijgen).
|
||||
- Vult `crossSprintBlocks` in de store voor disabled-vinkjes.
|
||||
- Server-side check bij commit blijft autoritatief — dit endpoint is alleen UX-hint.
|
||||
|
||||
### Active-sprint resolver (gewijzigd)
|
||||
|
||||
**Schema-contract (cruciaal, zit in [lib/user-settings.ts](lib/user-settings.ts)):**
|
||||
|
||||
```ts
|
||||
// Zod schema wijziging:
|
||||
activeSprints: z.record(z.string(), z.string().nullable()).optional()
|
||||
```
|
||||
|
||||
**Drie distincte states per `productId`:**
|
||||
|
||||
| Settings-staat | Betekenis |
|
||||
|---|---|
|
||||
| Key ontbreekt | Geen voorkeur ingesteld — fallback-cascade actief (eerste OPEN, dan recent CLOSED, dan `null`) |
|
||||
| Key bestaat met `string` | Die specifieke sprint is gekozen (mits gevonden in DB; anders fallback) |
|
||||
| Key bestaat met `null` | **Bewust geen actieve sprint** — geen fallback, blijft "Geen actieve sprint" |
|
||||
|
||||
**Wijzigingen in [lib/active-sprint.ts](lib/active-sprint.ts):**
|
||||
- `resolveActiveSprint(productId, userId)` checkt `key in activeSprints` (niet alleen truthy):
|
||||
- Key niet aanwezig → fallback-cascade
|
||||
- Key aanwezig, value=null → return null
|
||||
- Key aanwezig, value=string → die sprint
|
||||
- `setActiveSprintInSettings(productId, sprintId)` ongewijzigd (schrijft string).
|
||||
- **`clearActiveSprintInSettings(productId)` wordt aangepast**: i.p.v. de key te `delete`, schrijft het nu `null`. Dat is het verschil tussen "geen voorkeur" en "expliciet geen actieve sprint".
|
||||
|
||||
**[actions/active-sprint.ts](actions/active-sprint.ts):**
|
||||
- Nieuw: `clearActiveSprintAction(productId)` — gebruikt de aangepaste `clearActiveSprintInSettings` (schrijft null).
|
||||
- Bestaande `setActiveSprintAction` ongewijzigd.
|
||||
|
||||
### Sync na commit — gerichte client-store patches
|
||||
|
||||
Server actions retourneren expliciet affected IDs:
|
||||
```ts
|
||||
return { affectedStoryIds, affectedPbiIds, affectedTaskIds, conflicts }
|
||||
```
|
||||
|
||||
Client (na await):
|
||||
1. Patch `storiesById` + `tasksById` met nieuwe `sprint_id`-waarden.
|
||||
2. Voor elke `affectedPbiId`: fire-and-forget refetch van `sprint-membership-summary` (debounced 100ms) om counts te actualiseren.
|
||||
3. Wis pending buffer.
|
||||
4. **Geen `router.refresh()`.**
|
||||
|
||||
`revalidatePath` blijft in de server-actie voor andere users / lossely-coupled views, maar de huidige user's UI updateert via de gerichte patches.
|
||||
|
||||
### Data-load-volgorde overzicht
|
||||
|
||||
| Moment | Wat | Wie |
|
||||
|---|---|---|
|
||||
| Page render | Matching PBI's (metadata) + activeSprint + draft | Server (SSR) — bestaande flow |
|
||||
| Na hydratie | Remaining PBI's (metadata) | Client → bestaande `/api/.../backlog?mode=remaining` |
|
||||
| State B activeert | Sprint-membership-summary + cross-sprint-blocks | Client → nieuwe endpoints |
|
||||
| PBI-klik | Stories voor die PBI (full) | Client → bestaande `ensurePbiLoaded` |
|
||||
| Story-klik | Taken voor die story | Client → bestaande `ensureStoryLoaded` |
|
||||
| A→A′ start | Geen extra fetch — werk met `pendingSprintDraft` (compact) | |
|
||||
| A′ stories cherrypicken | Klik PBI → bestaande lazy-load voor die PBI | |
|
||||
| Sprint-switch | Refetch membership-summary + cross-sprint-blocks voor nieuwe sprint | Client |
|
||||
| SSE event | Patch lokale store | Client |
|
||||
| Na server-action commit | Affected IDs uit return → gerichte store-patches + debounced summary-refetch | Client |
|
||||
|
||||
---
|
||||
|
||||
## Critical files
|
||||
|
||||
### Te wijzigen
|
||||
|
||||
- [app/(app)/products/[id]/page.tsx](app/(app)/products/[id]/page.tsx) — state-detectie (A/A′/B); banner-rendering; "Nieuwe sprint"-knop opent metadata-modal (i.p.v. direct `NewSprintDialog`). **Initial query blijft `getProductBacklogPbis(id, query, 'matching')`** — geen counts hier.
|
||||
- [components/backlog/pbi-list.tsx](components/backlog/pbi-list.tsx) — bestaande `selectionMode` ombouwen tot A′-modus: vinkjes worden tri-state, lezen uit `pendingSprintDraft.pbiIntent` of (in state B) uit `selectPbiTriState`-selector. Verwijder de directe `NewSprintDialog`-trigger.
|
||||
- [components/backlog/story-panel.tsx](components/backlog/story-panel.tsx) — vinkje per story; lees uit selectors (`selectStoryEffectiveInSprint`, `selectStoryIsBlocked`); klik muteert `pendingSprintDraft.storyOverrides` of `sprintMembershipPending`.
|
||||
- [components/backlog/task-panel.tsx](components/backlog/task-panel.tsx) — geen wijzigingen aan task-flow.
|
||||
- [components/shared/sprint-switcher.tsx](components/shared/sprint-switcher.tsx) — "— Geen actieve sprint —"-optie; dirty-check bij wissel.
|
||||
- [stores/product-workspace/store.ts](stores/product-workspace/store.ts) — uitbreidingen: `pbiSummary`, `loadedStoryIdsByPbi`, `crossSprintBlocks`, `sprintMembershipPending` (arrays), selectors voor tri-state, gerichte patch-helpers voor server-action-returns.
|
||||
- [stores/user-settings/store.ts](stores/user-settings/store.ts) — `pendingSprintDraft[productId]: { goal, startAt?, endAt?, pbiIntent, storyOverrides: { [pbiId]: { add, remove } } } | null`; `activeSprints[productId]: string | null` (zie ook user-settings.ts hieronder).
|
||||
- **[lib/user-settings.ts](lib/user-settings.ts)** — Zod-schema strictness: `activeSprints` value nullable; `pendingSprintDraft` als optionele key per productId met de hier-gespecificeerde shape; migratie-tests aanpassen.
|
||||
- [actions/sprints.ts](actions/sprints.ts):
|
||||
- `createSprintAction` — drop OPEN-uniqueness-check (multi-OPEN toegestaan)
|
||||
- **`createSprintWithPbisAction` → uitbreiden naar `createSprintWithSelectionAction(productId, metadata, pbiIntent, storyOverrides)`**. Server resolveert intent → concrete story-IDs. Returnt affected IDs.
|
||||
- Nieuw: `commitSprintMembershipAction(sprintId, adds[], removes[])` — transactional, retourneert affected + conflicts.
|
||||
- Nieuw: `updateSprintAction(sprintId, { goal?, startAt?, endAt? })` — alleen metadata.
|
||||
- **GEEN** nieuwe `closeSprintAction` — `completeSprintAction` blijft de afrond-flow.
|
||||
- [actions/active-sprint.ts](actions/active-sprint.ts) — nieuwe `clearActiveSprintAction(productId)` (schrijft null). `setActiveSprintAction` ongewijzigd voor non-null.
|
||||
- [lib/active-sprint.ts](lib/active-sprint.ts) — `resolveActiveSprint` checkt key-aanwezigheid (niet truthy): key+null → return null zonder fallback; key+string → sprint; key ontbreekt → fallback-cascade. **`clearActiveSprintInSettings` schrijft nu `null` i.p.v. key te verwijderen** (essentieel voor het null-contract).
|
||||
|
||||
### Nieuw
|
||||
|
||||
- `app/api/products/[id]/sprint-membership-summary/route.ts` — lazy counts endpoint
|
||||
- `app/api/products/[id]/cross-sprint-blocks/route.ts` — lazy cross-sprint hint endpoint
|
||||
- `components/backlog/sprint-definition-banner.tsx` — sticky banner voor A′
|
||||
- `components/backlog/new-sprint-metadata-dialog.tsx` — stap 1 van A′
|
||||
- `components/backlog/sprint-edit-dialog.tsx` — metadata-edit in B
|
||||
- `lib/sprint-conflicts.ts` — cross-sprint check helpers
|
||||
- `actions/sprint-draft.ts` — `setPendingSprintDraftAction`, `clearPendingSprintDraftAction`
|
||||
|
||||
### Niet aangeraakt
|
||||
|
||||
- [prisma/schema.prisma](prisma/schema.prisma) — geen schemawijziging
|
||||
- Bestaande `completeSprintAction` en de sprint-pagina `/products/[id]/sprint/[sprintId]` — sprint-afronding-flow blijft daar
|
||||
- [components/backlog/task-panel.tsx](components/backlog/task-panel.tsx), task-dialog, pbi-dialog, story-dialog — Entity Dialogs onveranderd
|
||||
|
||||
---
|
||||
|
||||
## Hergebruik bestaande patronen
|
||||
|
||||
- **Entity-Dialog-pattern**: metadata-modal + sprint-edit-dialog
|
||||
- **useDirtyCloseGuard**: A′-annulering, B-navigatie
|
||||
- **Zustand optimistic pattern**: pending buffer + gerichte server-action-return-patches
|
||||
- **Realtime NOTIFY-payload**: sprint-membership events
|
||||
- **Server-action-pattern**: auth + Zod
|
||||
- **Filter-first/background-remaining**: blijft via [getProductBacklogPbis](lib/product-backlog-pbis.ts) en bestaande `/api/products/[id]/backlog?mode=X` route handler
|
||||
- **MD3-tokens + shadcn `<Checkbox>`** (tri-state via custom mapping)
|
||||
|
||||
---
|
||||
|
||||
## Verificatie
|
||||
|
||||
### End-to-end checks (handmatig + dev-server)
|
||||
|
||||
1. **State A pad**: zonder actieve sprint → geen vinkjes, switcher toont "Geen actieve sprint", klik PBI → stories tonen, klik story → taken tonen, Entity-Dialog edits direct gecommit.
|
||||
|
||||
2. **A → A′ → B happy path**: "Nieuwe sprint" → metadata-modal → "Verder" → banner verschijnt, vinkjes verschijnen op PBI's. Vink 2 PBI's met 5 child-stories totaal → banner toont "2 PBI's, 5 stories". Open één PBI en deselecteer 1 story (storyOverride.remove). Banner: "2 PBI's, 4 stories". Klik "Sprint aanmaken" → sprint actief, state B met afgeleide vinkjes, **geen page refresh** (controle via DevTools Network: alleen affected updates).
|
||||
|
||||
3. **A′ persistente draft**: start A′, vink dingen aan, navigeer weg → confirm-dialog → bevestig. Kom terug op pagina → banner + vinkjes hersteld.
|
||||
|
||||
4. **State B pending buffer**: vink een story aan → "Sprint opslaan (1)". Vink een story in sprint weg → "Sprint opslaan (2)". Vink eerste weer uit → "Sprint opslaan (1)" (cancel-out). Klik opslaan → store-patches, geen full reload.
|
||||
|
||||
5. **Cross-sprint blokkade**: maak twee OPEN sprints, story X in sprint A. Switch naar sprint B → story X heeft disabled vinkje, tooltip "Zit in Sprint [A]". Verplaats story X via sprint A's sprint-page → cross-sprint-blocks updaten via SSE-patch.
|
||||
|
||||
6. **Sprint metadata-edit**: edit-icoon → SprintEditDialog → wijzig goal → opslaan → direct gecommit, geen page-state-wijziging.
|
||||
|
||||
7. **Sprint afronden**: SprintEditDialog toont link "Sprint afronden…" → navigeert naar `/products/[id]/sprint/[sprintId]` → bestaande completion-flow ongewijzigd.
|
||||
|
||||
8. **Switcher-wissel bij dirty**: state B met pending toggles → wissel sprint → confirm-dialog. Cancel → blijft, buffer intact. Bevestig → buffer leeg, switch.
|
||||
|
||||
9. **"Geen actieve sprint" persistentie**: kies "— Geen actieve sprint —" in switcher → schrijf null. Refresh pagina → blijft state A, valt **niet** terug op nieuwste OPEN sprint.
|
||||
|
||||
### Geautomatiseerde tests (Vitest)
|
||||
|
||||
- `lib/sprint-conflicts.test.ts`: vrij, in-zelfde-sprint, in-andere-OPEN, in-CLOSED (niet blokkerend voor commit-laag).
|
||||
- `stores/product-workspace.test.ts`: pending buffer (arrays) toggle-cancel-out; tri-state-selector op verschillende load-staten (PBI niet geladen / geladen / met per-PBI overrides).
|
||||
- `actions/sprints.test.ts`:
|
||||
- `createSprintWithSelectionAction` resolve van per-PBI intent + per-PBI storyOverrides
|
||||
- **Eligibility-filter**: stories met `status='DONE'` of `sprint_id != NULL` worden geweigerd en komen in `conflicts.notEligible`
|
||||
- **Status-mutatie**: na add zijn betroffen stories `IN_SPRINT`; na remove zijn ze `OPEN`
|
||||
- **Task.sprint_id in dezelfde transactie** — assert via mock prisma dat beide updates één tx delen
|
||||
- Returns met `affectedStoryIds`, `affectedPbiIds`, `affectedTaskIds`, `conflicts`
|
||||
- `actions/commit-sprint-membership.test.ts`:
|
||||
- Race-conditie: story die ondertussen in andere sprint zit, eindigt in conflicts en wordt niet ge-update
|
||||
- Removes met onverwachte sprint_id (al verwijderd) eindigen in `conflicts.alreadyRemoved`
|
||||
- `lib/active-sprint.test.ts`:
|
||||
- Key+null → return null (geen fallback)
|
||||
- Key+string → die sprint (mits gevonden)
|
||||
- Key ontbreekt → fallback-cascade actief
|
||||
- `lib/user-settings.test.ts`:
|
||||
- Zod-schema accepteert nullable values in `activeSprints`
|
||||
- `pendingSprintDraft` met per-PBI overrides round-trippt
|
||||
- `actions/active-sprint.test.ts`:
|
||||
- `clearActiveSprintAction` schrijft `null`, **delete niet** de key — assert dat key blijft bestaan met null-value
|
||||
- Endpoint-tests voor de twee nieuwe route handlers:
|
||||
- `sprint-membership-summary` zonder `pbiIds`-param → 400
|
||||
- `cross-sprint-blocks` zonder `pbiIds`-param → 400
|
||||
- **Initial render doet géén story/task query** — assert via mock dat alleen `getProductBacklogPbis(_, _, 'matching')` is aangeroepen
|
||||
- **A′ start doet géén brede story-ID query** — assert dat geen call met product-wide scope uitgaat; per-PBI overrides cleanup werkt zonder fetch
|
||||
|
||||
### Code-validatie
|
||||
|
||||
```bash
|
||||
npm run verify && npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reactie op review
|
||||
|
||||
### Eerste review
|
||||
|
||||
| Review-punt | Hoe geadresseerd |
|
||||
|---|---|
|
||||
| **P1 — Initial summary kan te zwaar worden** | Geen counts in initial render. Bestaande `getProductBacklogPbis(_, _, 'matching')` blijft. Counts apart via lazy summary-endpoint, alleen in state B, gescoped op `pbiIds`. |
|
||||
| **P1 — `getStoryIdsByPbi(productId)` breekt lazy-loading** | Verwijderd. Hergebruik `ensurePbiLoaded` lazy per PBI. Pending draft-state is compact (per-PBI `pbiIntent` + per-PBI `storyOverrides`), niet alle story-IDs. |
|
||||
| **P1 — "Page herhydrateert" introduceert dure refresh** | Server actions retourneren `affectedStoryIds`/`affectedPbiIds`/`affectedTaskIds`. Client patcht workspace-store gericht. Geen `router.refresh()`. |
|
||||
| **P1 — `Sprint afsluiten` mag completion-semantiek niet overslaan** | `closeSprintAction` geschrapt. SprintEditDialog doet alleen metadata. Sprint-afronden gaat via bestaande `completeSprintAction` op sprint-page; SprintEditDialog krijgt link daarheen. |
|
||||
| **P2 — "Geen actieve sprint"-contract** | Schema nullable: `activeSprints[productId]: string \| null`. Sleutel-aanwezigheid heeft betekenis (key ontbreekt = fallback; key=null = bewust geen). `clearActiveSprintInSettings` schrijft null. |
|
||||
| **P2 — Cache Components vs huidige stijl** | Beslist: blijven bij route handlers + `cache: 'no-store'` + `revalidatePath`. Géén `'use cache'`/`cacheTag` in dit plan. |
|
||||
| **P2 — Bestaande PBI-selectieflow** | Ombouwen naar A′-mode. Eén flow, geen feature-flag-parallellisme. `createSprintWithPbisAction` wordt `createSprintWithSelectionAction`. |
|
||||
| **P2 — Store moet primitives bewaren** | `pbiSummary` slaat alleen `totalStoryCount`/`inActiveSprintStoryCount` op. Tri-state is een selector. `sprintMembershipPending` gebruikt arrays, geen Sets. |
|
||||
| **P2 — Filter-first/background-remaining ontbreekt** | Expliciet opgenomen: initial = matching, background = remaining via bestaand route-handler-patroon. |
|
||||
| **Tests die review zou toevoegen** | Allemaal opgenomen in test-sectie hierboven. |
|
||||
|
||||
### Tweede review (deze ronde)
|
||||
|
||||
| Punt | Hoe geadresseerd |
|
||||
|---|---|
|
||||
| **P1 — `story.status` bij membership-mutaties** | Add: `sprint_id=X` **én** `status='IN_SPRINT'`. Remove: `sprint_id=NULL` **én** `status='OPEN'`. Task.sprint_id mee in **dezelfde transactie**. Expliciet in pseudocode van `commitSprintMembershipAction` en `createSprintWithSelectionAction`. |
|
||||
| **P1 — Eligibility voor toevoegen** | Server-resolve filtert vóór mutatie: alleen stories met `sprint_id IS NULL` **en** `status != 'DONE'`. Niet-eligible → `conflicts.notEligible[]` in return, toast op client. Stories uit CLOSED/ARCHIVED/FAILED sprints met DONE-status zijn dus geblokkeerd. |
|
||||
| **P1 — A′ draft-shape moet per-PBI** | `storyOverrides` herstructureerd naar `{ [pbiId]: { add, remove } }`. Cleanup bij PBI-toggle is lokaal; restore is deterministisch zonder brede story-fetch. |
|
||||
| **P1 — Endpoint scoping** | `sprint-membership-summary` en `cross-sprint-blocks` vereisen verplichte `pbiIds`-query-parameter. Server weigert product-brede aanroepen. |
|
||||
| **P2 — `lib/user-settings.ts` expliciet** | Opgenomen in critical files. Zod-schema wijzigt: `activeSprints` nullable; `pendingSprintDraft` als optionele key. |
|
||||
| **P2 — `clearActiveSprintInSettings`-semantiek** | Schrijft nu `null` i.p.v. key te `delete`. Onderscheid: key ontbreekt = fallback; key=null = bewust geen actieve sprint. |
|
||||
| **P2 — Context-tekst stale** | Context-sectie herschreven: lazy-load-basis bestaat al; dit plan bouwt erop voort. |
|
||||
|
||||
---
|
||||
|
||||
## Volgende stap (na goedkeuring)
|
||||
|
||||
Per project-memory: PBI + stories + taken aanmaken via Scrum4Me-MCP, daarna implementatieplan koppelen, taken pas uitvoeren op verzoek.
|
||||
|
||||
Werk-splitsing (laag-voor-laag, met dataflow eerst maar zonder onnodige eager loads):
|
||||
|
||||
1. **Story 1 — Active-sprint null-contract** + `clearActiveSprintAction` + `resolveActiveSprint`-aanpassing + sprint-switcher uitbreiding ("— Geen actieve sprint —"-optie)
|
||||
2. **Story 2 — User-settings draft-slot** + `setPendingSprintDraftAction` / `clearPendingSprintDraftAction` (compacte intent-shape)
|
||||
3. **Story 3 — Sprint-membership-summary endpoint** + `crossSprintBlocks` endpoint + store-uitbreidingen (`pbiSummary`, `loadedStoryIdsByPbi`, `crossSprintBlocks`)
|
||||
4. **Story 4 — State B pending-buffer-slice** (arrays) + selectors voor tri-state + `selectStoryEffectiveInSprint` / `selectStoryIsBlocked`
|
||||
5. **Story 5 — A′ UI** (metadata-modal + sticky banner) + ombouw `selectionMode` in `PbiList` + persistente draft-restore
|
||||
6. **Story 6 — State B vinkjes-UI** (PBI tri-state, story binair, disabled-bij-conflict) + "Sprint opslaan"-knop met teller
|
||||
7. **Story 7 — `createSprintWithSelectionAction`** (uitbreiding van bestaande `createSprintWithPbisAction`) + server-side intent-resolve + cross-sprint guard + return-affected-IDs
|
||||
8. **Story 8 — `commitSprintMembershipAction`** + cross-sprint guard + gerichte client-store patches + SSE-broadcast
|
||||
9. **Story 9 — SprintEditDialog** (metadata) + `updateSprintAction` + link naar afrondings-flow
|
||||
10. **Story 10 — Multi-OPEN sprints** (drop uniqueness-check in `createSprintAction`)
|
||||
11. **Story 11 — Verificatie + tests** (Vitest + handmatige checklist)
|
||||
Loading…
Add table
Add a link
Reference in a new issue