docs/specs/dialogs/idea.md: - Velden-table with bron-zod links - URL/state-pattern: dedicated route /ideas/[id] (afwijking van generieke modal-spec — rationale documented) - 4-tab layout spec - Full state-machine table with transition triggers + server actions - Server-action catalog with preconditions + foutcodes - 3-layer demo-policy (proxy + isDemo-guard + DemoTooltip), incl. wat demo WEL mag (download-md is read-only) - Special behaviors: Cmd/Ctrl+S, localStorage draft (lazy seed), useMemo-derived validation, status-badge tokens, connectedWorkers via solo-store - Realtime routing notes - Test-fixture inventory (90+ cases across 7 test files) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
167 lines
9.2 KiB
Markdown
167 lines
9.2 KiB
Markdown
---
|
|
title: "IdeaDialog Profiel"
|
|
status: active
|
|
audience: [ai-agent, contributor]
|
|
language: nl
|
|
last_updated: 2026-05-04
|
|
---
|
|
|
|
# IdeaDialog / IdeaDetailLayout Profiel
|
|
|
|
> Volgt **`docs/patterns/dialog.md`** (de generieke spec voor élke entity-dialog in Scrum4Me).
|
|
> Dit document beschrijft alleen de Idea-specifieke afwijkingen en keuzes — alle gedeelde regels (layout, motion, demo-policy, foutcodes, validatie, theming) staan in de generieke spec en worden hier niet herhaald.
|
|
|
|
> **Belangrijk:** als een regel in dit profiel botst met de generieke spec, wint de generieke spec. Documenteer hier de afwijking + reden, of pas de generieke spec aan.
|
|
|
|
---
|
|
|
|
## Velden
|
|
|
|
| Veld | Type | Mode | Validatie | Bron-zod |
|
|
|---|---|---|---|---|
|
|
| `title` | `string` (required) | beide | trim, 1-200 chars | `ideaCreateSchema.title` |
|
|
| `description` | `string \| null` | beide | optional, max 4000 chars, plain textarea | `ideaCreateSchema.description` |
|
|
| `product_id` | `string \| null` | beide | optional cuid; **vereist voordat Grill/Make Plan kan starten** (M12 grill-keuze 3) | `ideaCreateSchema.product_id` |
|
|
| `code` | `string` (auto) | read-only | `IDEA-NNN`, server-generated via `nextIdeaCode(userId)` op `User.idea_code_counter` | n.v.t. |
|
|
| `status` | `IdeaStatus` enum | read-only | door server gezet via state-machine | `lib/idea-status.ts canTransition` |
|
|
| `grill_md` | `string \| null` | edit-tab | bewerkbaar in `GRILLED \| PLAN_READY` | n.v.t. |
|
|
| `plan_md` | `string \| null` | edit-tab | bewerkbaar in `PLAN_READY` + yaml-frontmatter must parse | `ideaPlanMdFrontmatterSchema` |
|
|
| `archived` | `boolean` | read-only | via archive-actie | n.v.t. |
|
|
| `pbi_id` | `string \| null` | read-only | gezet door `materializeIdeaPlanAction`, `SetNull` als PBI verwijderd | n.v.t. |
|
|
|
|
---
|
|
|
|
## URL- of state-pattern
|
|
|
|
**Afwijking van generieke spec:** Idee gebruikt een **dedicated route** `/ideas/[id]` ipv een modal-dialog. Reden: het detail-scherm is rijker dan een modal kan dragen (4 tabs incl. md-editor + timeline) en de planningsgeschiedenis is een leesbaar artifact dat verdiend om bookmarkable te zijn.
|
|
|
|
- **Lijst-create**: state-based inline form bovenaan `/ideas` lijst (`IdeaList.showCreate`).
|
|
- **Detail / edit**: route `/ideas/[id]` met tab-switcher via query-param (`?tab=idee|grill|plan|timeline`).
|
|
- **Geen modal**: dus geen `Cmd/Ctrl+Enter`-submit op de detail-form (alleen op md-editor); `Esc` doet niets in het detail-scherm.
|
|
|
|
---
|
|
|
|
## Tabs (alleen op detail-route)
|
|
|
|
| Tab | Content | Editable in |
|
|
|---|---|---|
|
|
| `idee` | inline form (title, description, product_id) | `DRAFT \| GRILL_FAILED \| GRILLED \| PLAN_FAILED \| PLAN_READY` |
|
|
| `grill` | `grill_md` markdown render + Bewerk-knop | `GRILLED \| PLAN_READY` |
|
|
| `plan` | `plan_md` markdown render + Bewerk-knop | `PLAN_READY` |
|
|
| `timeline` | UNION van `IdeaLog` + `ClaudeQuestion` chronologisch | n.v.t. (read-only) |
|
|
|
|
`isIdeaEditable`, `isGrillMdEditable` en `isPlanMdEditable` helpers in `lib/idea-status.ts` bepalen de exacte regels.
|
|
|
|
---
|
|
|
|
## Status-machine
|
|
|
|
```
|
|
DRAFT ──Grill──▶ GRILLING ─done──▶ GRILLED ──Make Plan──▶ PLANNING ─done──▶ PLAN_READY ──Materialiseer──▶ PLANNED
|
|
│ fail │ fail ▲ │
|
|
▼ ▼ │ │
|
|
GRILL_FAILED PLAN_FAILED └─── re-grill / re-plan (append-context) │
|
|
│
|
|
PLANNED ◀── (PBI verwijderd: pbi_id=null, status blijft PLANNED tot Re-link) ────────────────────────────────┘
|
|
```
|
|
|
|
| Van | Naar | Trigger | Server-action |
|
|
|---|---|---|---|
|
|
| `DRAFT` | `GRILLING` | "Grill" knop | `startGrillJobAction` |
|
|
| `GRILLING` | `GRILLED` | worker → `update_idea_grill_md` | (MCP) |
|
|
| `GRILLING` | `GRILL_FAILED` | worker → `update_job_status('failed')` | (MCP) |
|
|
| `GRILLED` / `PLAN_FAILED` / `PLAN_READY` | `GRILLING` | "Grill" knop (re-grill) | `startGrillJobAction` |
|
|
| `GRILLED` / `PLAN_FAILED` / `PLAN_READY` | `PLANNING` | "Plan" knop | `startMakePlanJobAction` |
|
|
| `PLANNING` | `PLAN_READY` | worker → `update_idea_plan_md` (parser ok) | (MCP) |
|
|
| `PLANNING` | `PLAN_FAILED` | worker → `update_job_status('failed')` of parse-fail | (MCP) |
|
|
| `PLAN_READY` | `PLANNED` | "Maak PBI" knop | `materializeIdeaPlanAction` |
|
|
| `PLANNED` (pbi_id=null) | `PLAN_READY` | "Plan opnieuw beschikbaar maken" knop | `relinkIdeaPlanAction` |
|
|
| any | `*` archived | Archive-knop | `archiveIdeaAction` |
|
|
|
|
---
|
|
|
|
## Server actions
|
|
|
|
`actions/ideas.ts`:
|
|
|
|
| Actie | Precondition | Effect |
|
|
|---|---|---|
|
|
| `createIdeaAction(input)` | auth + niet-demo | nieuwe DRAFT-idea + auto-code |
|
|
| `updateIdeaAction(id, input)` | `isIdeaEditable(status)` | update title/description/product_id |
|
|
| `archiveIdeaAction(id)` / `unarchiveIdeaAction(id)` | scoped on user_id | flip `archived` |
|
|
| `deleteIdeaAction(id)` | `pbi_id === null` | hard delete (cascades naar IdeaLog) |
|
|
| `updateGrillMdAction(id, md)` | `isGrillMdEditable(status)` | update + IdeaLog{NOTE} |
|
|
| `updatePlanMdAction(id, md)` | `isPlanMdEditable(status)` + `parsePlanMd.ok` | update + IdeaLog{NOTE} |
|
|
| `startGrillJobAction(id)` | product+repo + worker actief + status in `GRILL_TRIGGERABLE_FROM` | enqueue ClaudeJob{kind:IDEA_GRILL} |
|
|
| `startMakePlanJobAction(id)` | idem + status in `MAKE_PLAN_TRIGGERABLE_FROM` | enqueue ClaudeJob{kind:IDEA_MAKE_PLAN} |
|
|
| `cancelIdeaJobAction(id)` | actieve job aanwezig | job→CANCELLED + status revert |
|
|
| `materializeIdeaPlanAction(id)` | `status===PLAN_READY` + `plan_md` parseable | atomic create PBI + stories + tasks; idea→PLANNED |
|
|
| `relinkIdeaPlanAction(id)` | `status===PLANNED && pbi_id===null` | status→PLAN_READY |
|
|
| `downloadIdeaMdAction(id, kind)` | scope (demo OK, read-only) | return md-string |
|
|
| `promoteTodoToIdeaAction(todoId)` (in `actions/todos.ts`) | todo niet archived + niet-demo | DRAFT-idea + Todo→archived |
|
|
|
|
Foutcodes: 400 = JSON parse, 401 = auth, 403 = demo, 404 = scope/not-found, 409 = idempotency/race, 422 = validatie/status-mismatch, 429 = rate-limit.
|
|
|
|
---
|
|
|
|
## Demo-policy (3-laag)
|
|
|
|
| Laag | Wat | Waar |
|
|
|---|---|---|
|
|
| 1 | `proxy.ts` blokt `POST/PATCH/DELETE /api/ideas*` | `proxy.ts` catch-all rule |
|
|
| 2 | `session.isDemo` guard in elke muteer-actie | `actions/ideas.ts` |
|
|
| 3 | `<DemoTooltip show={isDemo}>` rondom muteer-knoppen | `idea-row-actions.tsx`, `idea-list.tsx`, `idea-detail-layout.tsx`, `download-md-button.tsx` (NIET — read-only mag) |
|
|
|
|
Demo-user MAG: lijst zien, idee zien, navigeren tussen tabs, downloaden van md.
|
|
Demo-user MAG NIET: aanmaken, bewerken, archiveren, Grill, Plan, Materialiseer, Re-link, Promote-from-Todo.
|
|
|
|
---
|
|
|
|
## Special behaviors
|
|
|
|
### IdeaMdEditor
|
|
|
|
- **Cmd/Ctrl+S** triggert save (alleen in editor, niet in detail-form).
|
|
- **localStorage draft** per `(idea_id, kind)`: lazy read-on-mount via `useState(() => readSeed(...))` om setState-in-effect te vermijden. Drift met server → toast info bij restore.
|
|
- **Live yaml-validate** voor plan-kind: `useMemo(() => parsePlanMd(value))` → derived state, geen useEffect.
|
|
- **Submit-errors** los van validation-errors in state — server-side details overschrijven client-side validate als die er zijn.
|
|
|
|
### IdeaPbiLinkCard
|
|
|
|
- Drie states: PLANNED+pbi (groene link), PLANNED+pbi-null (oranje banner met Re-link knop), niet-PLANNED (return null).
|
|
|
|
### Status badges
|
|
|
|
- Status-tokens via `lib/idea-status-colors.ts` → `getIdeaStatusBadge(status)` → `{ label, classes, pulse? }`.
|
|
- `GRILLING` en `PLANNING` → `animate-pulse` om "actief" te signaleren.
|
|
|
|
### Connected workers
|
|
|
|
- `IdeaRowActions` leest `useSoloStore(s => s.connectedWorkers)` (M12 grill-keuze 16 — geen lift naar gedeelde store voor v1).
|
|
- Zonder worker: Grill / Make Plan disabled met tooltip "Geen Claude-worker actief". Materialiseer is server-side synchroon en heeft géén worker nodig.
|
|
|
|
---
|
|
|
|
## Realtime
|
|
|
|
SSE-stream `/api/realtime/notifications` levert idea-events (M12 T-502). Routing in `lib/realtime/use-notifications-realtime.ts`:
|
|
|
|
- `claude_job_*` payloads met `kind=IDEA_*` → `useIdeaStore.handleIdeaJobEvent`
|
|
- `entity:'question'` payloads met `idea_id` set → `useIdeaStore.handleIdeaQuestionEvent`
|
|
- Story-questions blijven in `useNotificationsStore`
|
|
|
|
`useIdeaStore` houdt optimistic state: `jobByIdea`, `ideaStatuses`, `openQuestionsByIdea`. Voor de detail-pagina is de server-state na `router.refresh()` source-of-truth — de store is een UI-cache.
|
|
|
|
---
|
|
|
|
## Test-fixtures
|
|
|
|
- `__tests__/actions/ideas-crud.test.ts` (39 cases) — alle CRUD + job-trigger + materialize + relink paden
|
|
- `__tests__/api/ideas.test.ts` (13 cases) — REST-laag
|
|
- `__tests__/stores/idea-store.test.ts` (7 cases) — Zustand event-handling
|
|
- `__tests__/lib/idea-status.test.ts` (15+ cases) — status mappers + transition guards
|
|
- `__tests__/lib/idea-schemas.test.ts` (16+ cases) — zod-validatie
|
|
- `__tests__/lib/idea-plan-parser.test.ts` (6 cases) — yaml-frontmatter
|
|
- `__tests__/proxy/demo-guard.test.ts` (9 cases) — incl. 3 idea-cases
|
|
|
|
Geen Playwright/MSW E2E voor v1 — handmatig E2E-script staat in `docs/plans/M12-ideas.md` "Verificatie".
|