diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..3f5b214 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,88 @@ +{ + "permissions": { + "allow": [ + "Bash(npx tsc *)", + "Bash(git add *)", + "Bash(git commit *)", + "Bash(git push *)", + "Bash(npx eslint *)", + "Bash(npm run *)", + "Bash(npx tsx *)", + "mcp__scrum4me__list_products", + "mcp__scrum4me__get_claude_context", + "Bash(gh pr *)", + "Bash(git -C /Users/janpetervisser/Development/Scrum4Me branch --show-current)", + "Bash(git -C /Users/janpetervisser/Development/Scrum4Me log --oneline main..HEAD)", + "Bash(git -C /Users/janpetervisser/Development/Scrum4Me checkout main)", + "Bash(git -C /Users/janpetervisser/Development/Scrum4Me pull --ff-only)", + "Bash(git -C /Users/janpetervisser/Development/Scrum4Me branch -d feat/ST-1001-qr-login-milestone-plan)", + "Bash(git -C /Users/janpetervisser/Development/Scrum4Me checkout -b feat/M10-qr-login)", + "Bash(git -C /Users/janpetervisser/Development/Scrum4Me log --oneline -3)", + "mcp__scrum4me__log_implementation", + "mcp__scrum4me__update_task_status", + "mcp__scrum4me__log_test_result", + "mcp__scrum4me__log_commit", + "Bash(npx vitest *)", + "Bash(echo \"=== exit: $? ===\")", + "Bash(npm test *)", + "Bash(echo \"exit: $?\")", + "Bash(npx prisma *)", + "Bash(npm install *)", + "Bash(git checkout *)", + "Bash(git pull *)", + "Bash(git branch *)", + "Read(//Users/janpetervisser/Development/**)", + "Bash(git -C /Users/janpetervisser/Development/scrum4me-mcp status -sb)", + "Bash(git -C /Users/janpetervisser/Development/scrum4me-mcp submodule status)", + "Bash(git -C /Users/janpetervisser/Development/scrum4me-mcp log --oneline -5)", + "Bash(git -C /Users/janpetervisser/Development/scrum4me-mcp/vendor/scrum4me log --oneline -3)", + "Bash(git -C /Users/janpetervisser/Development/scrum4me-mcp/vendor/scrum4me branch -a)", + "Bash(git fetch *)", + "Bash(git reset *)", + "mcp__scrum4me__update_task_plan", + "mcp__scrum4me__create_task", + "mcp__scrum4me__ask_user_question", + "Bash(git *)", + "mcp__scrum4me__create_pbi", + "mcp__scrum4me__create_story", + "mcp__scrum4me__health", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('mcpServers', {}\\), indent=2\\)\\)\")", + "Read(//Users/janpetervisser/.claude/**)", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d, indent=2\\)\\)\")", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('mcpServers',{}\\), indent=2\\)\\)\")", + "Bash(python3 -m json.tool)", + "mcp__scrum4me__wait_for_job", + "Bash(npx ctx7@latest docs /websites/github_en_rest \"How to fetch Copilot bot pull request reviews and identify them by author\")", + "Bash(npm i *)", + "Bash(curl *)", + "Bash(grep -E \"\\\\.\\(tsx|ts\\)$\")", + "mcp__scrum4me__update_job_status", + "Bash(node --env-file=.env.local node_modules/tsx/dist/cli.mjs ./scripts/check-jobs-tmp.ts)", + "Bash(node --env-file=.env.local node_modules/tsx/dist/cli.mjs ./scripts/check-workers-tmp.ts)", + "Bash(node --env-file=.env.local node_modules/prisma/build/index.js migrate deploy)", + "Bash(xargs grep *)", + "Bash(node --env-file=.env.local node_modules/prisma/build/index.js migrate status)", + "Bash(gh run *)", + "Bash(dir \"C:\\\\Users\\\\Madhu\\\\Projects\")", + "Bash(Get-ChildItem -Path \"C:\\\\Users\\\\Madhu\\\\Projects\\\\scrum4me-mcp\" -Recurse -Include \"*wait*\" -ErrorAction SilentlyContinue)", + "Bash(Select-Object FullName)", + "Bash(Get-ChildItem -Path \"C:\\\\Users\\\\Madhu\\\\Projects\\\\scrum4me-mcp\" -Force)", + "Bash(Format-Table -Property Name, PSIsContainer)", + "Bash(Sort-Object)", + "PowerShell(Push-Location \"C:\\\\Users\\\\Madhu\\\\Projects\\\\scrum4me-mcp\"; npx tsc --noEmit; $result = $?; Pop-Location; Write-Output \"typecheck ok: $result\")", + "PowerShell(git *)", + "mcp__scrum4me__verify_task_against_plan", + "Bash(mkdir -p docs/plans/archive)", + "Bash(rmdir .Plans)", + "Bash(mv .Plans/2026-04-27-claude-md-workflow-update.md docs/plans/archive/)", + "Bash(mv .Plans/2026-04-27-insert-milestone-tool.md docs/plans/archive/)", + "Bash(mv .Plans/2026-04-27-m8-realtime-solo.md docs/plans/archive/)", + "Bash(xargs sed *)", + "Bash(python3 *)" + ] + }, + "enableAllProjectMcpServers": true, + "enabledMcpjsonServers": [ + "scrum4me" + ] +} diff --git a/.env.example b/.env.example index ede2b3c..ab61549 100644 --- a/.env.example +++ b/.env.example @@ -14,30 +14,3 @@ NODE_ENV="development" # local dev (the route returns 401 if the Authorization header doesn't match). # Generate with: openssl rand -base64 32 CRON_SECRET="" - -# PBI-55 — Web Push (VAPID). All optional; app starts without these. -# Generate keys with: npx web-push generate-vapid-keys -NEXT_PUBLIC_VAPID_PUBLIC_KEY="" -VAPID_PRIVATE_KEY="" -# Must start with mailto: e.g. mailto:admin@example.com -VAPID_SUBJECT="mailto:admin@example.com" -# Shared secret for POST /api/internal/push/send — min 32 chars -# Generate with: openssl rand -base64 32 -INTERNAL_PUSH_SECRET="" - -# PBI-66 — Anthropic API key voor `npm run db:sync-model-prices`. -# Optional. Alleen nodig om wekelijks de model_prices tabel te synchroniseren. -# Genereer op https://console.anthropic.com/ → API Keys. -# /v1/models is een gratis metadata-call (geen tokens, geen credit nodig). -ANTHROPIC_API_KEY="" - -# v1-readiness item 2 — Sentry error monitoring. -# Optional. Without DSN, the SDK is a no-op (no network, no overhead). -# Get a DSN at https://sentry.io → Project → Settings → Client Keys (DSN). -NEXT_PUBLIC_SENTRY_DSN="" - -# Required ONLY if you want source-map upload during build (production deploy). -# In Vercel: project settings → Environment Variables → add as encrypted. -SENTRY_ORG="" -SENTRY_PROJECT="" -SENTRY_AUTH_TOKEN="" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8fda6f..e9b47e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,23 +5,11 @@ on: branches: [main] pull_request: branches: [main] - workflow_dispatch: - inputs: - target: - type: choice - description: Deploy target - options: [preview, production] - default: preview - -permissions: - contents: read - pull-requests: read jobs: ci: name: Lint, Typecheck, Test & Build runs-on: ubuntu-latest - if: github.event_name != 'workflow_dispatch' steps: - name: Checkout @@ -61,52 +49,11 @@ jobs: DIRECT_URL: ${{ secrets.DIRECT_URL }} SESSION_SECRET: ${{ secrets.SESSION_SECRET }} - changes: - name: Detect deploy-relevant changes - runs-on: ubuntu-latest - needs: ci - # Alleen relevant voor auto-deploy jobs; skip wanneer auto-deploy uit staat. - if: vars.AUTO_DEPLOY_ENABLED == 'true' && github.event_name != 'workflow_dispatch' - outputs: - code: ${{ steps.filter.outputs.code }} - steps: - - uses: actions/checkout@v5 - - uses: dorny/paths-filter@v3 - id: filter - with: - filters: | - code: - - 'app/**' - - 'components/**' - - 'lib/**' - - 'actions/**' - - 'stores/**' - - 'prisma/**' - - 'public/**' - - 'package.json' - - 'package-lock.json' - - 'next.config.ts' - - 'tsconfig.json' - - 'vercel.json' - - 'proxy.ts' - - 'middleware.ts' - - '.github/workflows/**' - deploy-preview: name: Deploy Preview (PR) runs-on: ubuntu-latest - needs: [ci, changes] - # Auto-deploy is uit. Gebruik "Run workflow" (workflow_dispatch) op de - # Actions-pagina voor handmatige deploys. Zet repo-variable - # AUTO_DEPLOY_ENABLED=true in Settings → Secrets and variables → Actions - # om PR-preview-deploys weer in te schakelen. - if: | - vars.AUTO_DEPLOY_ENABLED == 'true' - && github.event_name == 'pull_request' && ( - (needs.changes.outputs.code == 'true' - && !contains(github.event.pull_request.labels.*.name, 'skip-deploy')) - || contains(github.event.pull_request.labels.*.name, 'force-deploy') - ) + needs: ci + if: github.event_name == 'pull_request' steps: - name: Checkout @@ -133,15 +80,8 @@ jobs: deploy-production: name: Deploy Production (main) runs-on: ubuntu-latest - needs: [ci, changes] - # Auto-deploy is uit. Gebruik "Run workflow" (workflow_dispatch) → - # target=production voor handmatige productie-deploys. Zet repo-variable - # AUTO_DEPLOY_ENABLED=true om push-naar-main weer auto te deployen. - if: | - vars.AUTO_DEPLOY_ENABLED == 'true' - && github.ref == 'refs/heads/main' - && github.event_name == 'push' - && needs.changes.outputs.code == 'true' + needs: ci + if: github.ref == 'refs/heads/main' && github.event_name == 'push' steps: - name: Checkout @@ -170,42 +110,3 @@ jobs: env: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} - - deploy-manual: - name: Deploy Manual (workflow_dispatch) - runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' - - steps: - - name: Checkout - uses: actions/checkout@v5 - - - name: Setup Node.js - uses: actions/setup-node@v5 - with: - node-version: '24' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Install Vercel CLI - run: npm install -g vercel@latest - - - name: Run database migrations (production only) - if: inputs.target == 'production' - run: npx prisma migrate deploy - env: - DATABASE_URL: ${{ secrets.DATABASE_URL }} - DIRECT_URL: ${{ secrets.DIRECT_URL }} - - - name: Deploy - run: | - if [ "${{ inputs.target }}" = "production" ]; then - vercel deploy --prod --token=${{ secrets.VERCEL_TOKEN }} - else - vercel deploy --token=${{ secrets.VERCEL_TOKEN }} - fi - env: - VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} - VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} diff --git a/.gitignore b/.gitignore index fe6b79a..d20df70 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,6 @@ next-env.d.ts # Claude Code local settings .claude/settings.local.json -.claude/worktrees/ # Local plan/scratch files (per-developer, not shared) diff --git a/AGENTS.md b/AGENTS.md index 6d98658..da6aa78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,13 +11,3 @@ last_updated: 2026-05-03 This file is a redirect stub. All agent instructions live in **[CLAUDE.md](./CLAUDE.md)**. For Claude Code specifically, CLAUDE.md is loaded automatically. Start there. - -## Branch & PR-flow (quick reference) - -| Moment | Actie | Verbod | -|---|---|---| -| Start run | `git checkout -b feat/` | `gh pr create` | -| Na elke taak | `git add -A && git commit -m "(ST-XXX): "` | `git push` | -| Queue leeg | `git push -u origin <branch>` + `gh pr create` | — | - -Full details: [docs/runbooks/branch-and-commit.md § Agent-batch flow](./docs/runbooks/branch-and-commit.md) diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 40a8e6c..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,106 +0,0 @@ -# Changelog - -All notable changes to **Scrum4Me** are documented in this file. - -The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - ---- - -## [Unreleased] - ---- - -## [1.0.0] — 2026-05-04 - -**Eerste stabiele release** — MVP volgens functional spec is af, getest en in -productie. Geen breaking changes ten opzichte van 0.9.0; deze tag markeert de -launch-ready state na de v1-readiness-checklist (Now + Before-launch items). - -### Added -- Rate-limiting: `enforceUserRateLimit(scope, userId)` helper toegepast op alle - high-value mutation paths — PBI/Story/Task/Todo/Sprint/Product/Token create, - Claude job enqueue, answerQuestion, story-log POST, avatar upload. - ([#86](https://github.com/madhura68/Scrum4Me/pull/86)) -- Sentry error-monitoring scaffolding (`@sentry/nextjs`) met no-op fallback - zonder DSN. Activeer via `NEXT_PUBLIC_SENTRY_DSN` in Vercel env-vars. - ([#85](https://github.com/madhura68/Scrum4Me/pull/85)) -- `CHANGELOG.md` (Keep a Changelog formaat) + `docs/runbooks/v1-smoke-test.md` - — 11-secties pre-launch verificatie. ([#89](https://github.com/madhura68/Scrum4Me/pull/89)) - -### Changed -- A11y Lighthouse score op `/products/[id]` van 86 → ≥95: `aria-selected` → - `aria-pressed` op PBI-cards (correct ARIA role-attribute pairing); tap-targets - ≥28×28 px op hover-icon-buttons. ([#88](https://github.com/madhura68/Scrum4Me/pull/88)) -- A11y form-label associaties (`htmlFor` + `id`) op happy-path dialogen - (Story/Task + Promote-PBI/Story); auth-pages krijgen `<main>` landmark. - ([#87](https://github.com/madhura68/Scrum4Me/pull/87)) -- README: test-count 69 → 445, env-vars-tabel uitgebreid met `CRON_SECRET` en - Sentry-vars. ([#89](https://github.com/madhura68/Scrum4Me/pull/89)) - -### Fixed -- Demo-policy: drie mutation-paden zonder `isDemo`-check gedicht - (`toggleTodoAction`, `archiveCompletedTodosAction`, `leaveProductAction`). - ([#89](https://github.com/madhura68/Scrum4Me/pull/89)) - -### Security -- Vier debug-routes (`/debug-env`, `/debug-realtime`, `/api/debug/*`) krijgen - een NODE_ENV-guard → 404 in productie. ([#89](https://github.com/madhura68/Scrum4Me/pull/89)) - ---- - -## [0.9.0] — 2026-05-04 - -[GitHub Release](https://github.com/madhura68/Scrum4Me/releases/tag/v0.9.0) - -### Added -- **PBI-11: Mobile-shell met landscape-lock** ([#81](https://github.com/madhura68/Scrum4Me/pull/81)): - - Aparte route group `app/(mobile)/m/{settings,pair,products}/...` met eigen - layout (zonder NavBar/StatusBar/MinWidthBanner) - - `LandscapeGuard` (rotate-overlay in portrait), `MobileTabBar` (3 lucide-iconen) - - PWA-manifest met `"orientation": "landscape"` - - UA-redirect bij login: telefoons (`Mobi`-substring) → `/m/products/[active]/solo`, - tablets en desktop → `/dashboard` - - Gedeelde `lib/auth-guard.ts` `requireSession()` helper, hergebruikt door beide layouts - - Mobile-fullscreen voor entity-dialogen via gedeelde `entityDialogContentClasses` -- Sprint Product-Backlog kolom: filter-popover (prioriteit + status) en - edit-iconen op PBI/story/task-rijen. ([#79](https://github.com/madhura68/Scrum4Me/pull/79)) -- Edit-icoon op product-card in dashboard (consistent met PBI/story/task-pattern). - ([#83](https://github.com/madhura68/Scrum4Me/pull/83)) -- v1.0 readiness checklist in `docs/old/plans/v1-readiness.md`. - ([#82](https://github.com/madhura68/Scrum4Me/pull/82)) - -### Changed -- Refactor `app/(app)/layout.tsx` om gedeelde `requireSession()` te gebruiken - (gedrag onveranderd). ([#81](https://github.com/madhura68/Scrum4Me/pull/81)) -- `/m/pair` filesystem-verhuisd uit `(app)/` naar `(mobile)/` — URL onveranderd. - ([#81](https://github.com/madhura68/Scrum4Me/pull/81)) - ---- - -## [0.4.0] — eerder - -### Added -- M9 — Actief Product Backlog: persistente actieve PB-keuze, gesplitste - navigatie, disabled-states bij geen actief product - ---- - -## [0.3.1] — eerder - -Initiële stabilisatie-release. - ---- - -## Pre-0.3.x - -Foundation-werk (M0 t/m M8) is niet retroactief in dit changelog opgenomen. -Voor de volledige milestone-historie zie [docs/old/backlog/index.md](./docs/old/backlog/index.md). - ---- - -[Unreleased]: https://github.com/madhura68/Scrum4Me/compare/v1.0.0...HEAD -[1.0.0]: https://github.com/madhura68/Scrum4Me/releases/tag/v1.0.0 -[0.9.0]: https://github.com/madhura68/Scrum4Me/releases/tag/v0.9.0 -[0.4.0]: https://github.com/madhura68/Scrum4Me/commit/615f0c8 -[0.3.1]: https://github.com/madhura68/Scrum4Me/commit/ecc05dd diff --git a/CLAUDE.md b/CLAUDE.md index 06dc2fb..7816e5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ title: "CLAUDE.md — Scrum4Me" status: active audience: [ai-agent] language: nl -last_updated: 2026-05-11 +last_updated: 2026-05-03 --- # CLAUDE.md — Scrum4Me @@ -19,24 +19,26 @@ Desktop-first Scrum-app voor solo developers en kleine teams. Hiërarchie: produ | `docs/INDEX.md` | Gegenereerde index van alle docs — begin hier | | `docs/specs/functional.md` | Acceptatiecriteria, user flows | | `docs/architecture.md` | Breadcrumb → 6 topische arch-bestanden | +| `docs/backlog/index.md` | Implementatievolgorde, "done when"-criteria | | `docs/api/rest-contract.md` | REST API contract voor Claude Code | | `docs/design/styling.md` | **Lees vóór elk component** — MD3-tokens, shadcn | -| `docs/adr/` | Architecture Decision Records — tech-keuzes (base-ui vs Radix, sort-order, demo-policy, …) | -| `docs/architecture/` | 6 topische architecture-bestanden (data-model, auth, sprint-execution, …) — uitwerking van `docs/architecture.md` | -| `docs/runbooks/plan-to-pbi-flow.md` | **Na goedgekeurd plan** — PBI/Story/Task aanmaken via MCP, zónder direct uitvoeren | +| `docs/plans/<key>-*.md` | Implementatieplan per milestone | --- ## Hoe werk vinden -1. Branch aanmaken: `git checkout -b feat/<batch-slug>` — nog **geen** `gh pr create` -2. `mcp__scrum4me__get_claude_context` → pak de next story -3. Voer taken uit in `sort_order`; update status per taak -4. Lees het relevante patroon en styling vóór je begint -5. Verifieer: `npm run verify && npm run build` — `verify` = lint + typecheck + test -6. Commit per laag: `git add -A && git commit` — **geen** `git push` — zie [docs/runbooks/branch-and-commit.md](./docs/runbooks/branch-and-commit.md) -7. Herhaal stap 2–6 per story; branch blijft dezelfde -8. Queue leeg → `git push -u origin <branch>` + `gh pr create` +**Track A — MCP (aanbevolen):** +1. `mcp__scrum4me__get_claude_context` → pak de next story +2. Voer taken uit in `sort_order`; update status per taak +3. Lees het relevante patroon en styling vóór je begint +4. Verifieer: `npm run lint && npm test && npm run build` +5. Commit per laag — zie [docs/runbooks/branch-and-commit.md](./docs/runbooks/branch-and-commit.md) + +**Track B — manueel:** +1. Lees taak in `docs/backlog/index.md` +2. Zoek spec in `docs/specs/functional.md` +3. Lees patroon + styling → bouw → verifieer → vraag bevestiging → commit Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbooks/mcp-integration.md) @@ -46,15 +48,12 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo - **Styling:** nooit `bg-blue-500`; altijd MD3-tokens (`bg-primary`, `bg-status-done`, …) - **UI:** gebruik `@base-ui/react` met `render`-prop, niet Radix `asChild` -- **Push:** commits accumuleren lokaal per taak (`git add -A && git commit`); push + PR pas bij lege queue of na expliciete gebruikersbevestiging — zie [branch-and-commit.md](./docs/runbooks/branch-and-commit.md) +- **Push:** nooit pushen zonder expliciete gebruikersbevestiging — zie [branch-and-commit.md](./docs/runbooks/branch-and-commit.md) - **Demo:** drie lagen — proxy.ts + server action + UI disabled knop -- **Proxy:** `proxy.ts` in repo-root (géén `middleware.ts`) onverzegelt de iron-session, redirect niet-geauthenticeerde users op `/dashboard|/products|/ideas`, en blokkeert niet-GET API-writes voor demo-users behalve `/api/cron/*` - **Enum:** DB UPPER_SNAKE ↔ API lowercase — uitsluitend via `lib/task-status.ts` - **Foutcodes:** 400 = parse-fout, 422 = Zod-validatie, 403 = demo-token - **Server/client grens:** `*-server.ts` bevat DB/node-only; nooit importeren in client component -- **Worker/jobs:** `ClaudeJob` queue (`QUEUED → CLAIMED → RUNNING → DONE|FAILED|SKIPPED`); MCP-worker claimt via `wait_for_job` en sluit met `update_job_status` — zie [worker-idempotency.md](./docs/runbooks/worker-idempotency.md) -- **Model/mode per ClaudeJob:** kind-default → product → job-snapshot → `task.requires_opus`. Resolver in `scrum4me-mcp/src/lib/job-config.ts` (en gespiegeld in `lib/job-config.ts`) — zie [job-model-selection.md](./docs/runbooks/job-model-selection.md) -- **Deployment:** `npm run verify && npm run build` vóór elke PR. Selectieve deploy-controle (labels + path-filter): zie [docs/runbooks/deploy-control.md](./docs/runbooks/deploy-control.md) +- **Deployment:** `npm run lint && npm test && npm run build` vóór elke PR --- @@ -62,13 +61,12 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo | Laag | Technologie | |---|---| -| Framework | Next.js 16.2 (App Router) + React 19.2 — PPR/Cache Components beschikbaar | +| Framework | Next.js 16 (App Router) + React 19 | | Taal | TypeScript strict | -| Styling | Tailwind CSS v4 + shadcn/ui + MD3 via `app/styles/theme.css` | +| Styling | Tailwind CSS + shadcn/ui + MD3 via `app/styles/theme.css` | | State | Zustand + dnd-kit | -| DB | Prisma v7.8 + PostgreSQL (Neon) | +| DB | Prisma v7 + PostgreSQL (Neon) | | Auth | iron-session + bcryptjs | -| Test | Vitest (`__tests__/`, config in `vitest.config.ts`) | | Utilities | Zod, Sonner, Sharp, Vercel Analytics | --- @@ -81,20 +79,12 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo | Prisma singleton | `docs/patterns/prisma-client.md` | | Server Action (auth + Zod) | `docs/patterns/server-action.md` | | Route Handler (REST) | `docs/patterns/route-handler.md` | -| Workspace-store + realtime (PBI-74) | `docs/patterns/workspace-store.md` | | Zustand optimistic update | `docs/patterns/zustand-optimistic.md` | | Float sort_order / drag-and-drop | `docs/patterns/sort-order.md` | | Proxy / route protection | `docs/patterns/proxy.md` | | QR-pairing | `docs/patterns/qr-login.md` | | Claude ↔ user vraagkanaal | `docs/patterns/claude-question-channel.md` | | Entity Dialog (verplicht) | `docs/patterns/dialog.md` | -| Realtime NOTIFY-payload | `docs/patterns/realtime-notify-payload.md` | -| Story met UI-component | `docs/patterns/story-with-ui-component.md` | -| Web Push | `docs/patterns/web-push.md` | -| Job-config resolver (PBI-67) | `lib/job-config.ts` ↔ `scrum4me-mcp/src/lib/job-config.ts` | -| Debug-id op component-root | `docs/patterns/debug-id.md` | -| Debug-labels (BEM) | `docs/patterns/debug-labels.md` | -| Demo client-state (PBI-80) | `docs/patterns/demo-client-state.md` | --- @@ -107,18 +97,7 @@ SESSION_SECRET="" # min 32 chars CRON_SECRET="" # Bearer-secret /api/cron/* ``` -Volledig schema: `lib/env.ts`. Canonieke lijst: `.env.example` — bevat ook web-push (`VAPID_*`, `INTERNAL_PUSH_SECRET`), Sentry (`SENTRY_*`) en optioneel `ANTHROPIC_API_KEY`. - ---- - -## MCP & cron - -- **MCP-server (extern):** standalone Node-proces in `~/Development/scrum4me-mcp/` — Prisma-schema gesynced via `sync-schema.sh`. 30+ tools (`get_claude_context`, `wait_for_job`, `update_task_status`, …) -- **Bewuste duplicaten:** `lib/job-config.ts` (deze repo) en `scrum4me-mcp/src/lib/job-config.ts` (externe MCP) bevatten dezelfde resolver-logica; dit voorkomt dat de MCP-server Next-deps importeert. **Wijzig beide** bij elke job-config aanpassing -- **Cron (vercel.json):** - - `/api/cron/expire-questions` — dagelijks 04:00 UTC - - `/api/cron/cleanup-agent-artifacts` — dagelijks 03:00 UTC -- **Realtime:** SSE op `/api/realtime/*`, gevoed door PostgreSQL `LISTEN`/`NOTIFY` op kanaal `scrum4me_changes` (vereist `DIRECT_URL` voor pooler-bypass) +Volledig schema: `lib/env.ts`. Canonieke lijst: `.env.example`. --- @@ -131,24 +110,5 @@ PBI (niet: Feature/Epic) · Story (niet: Ticket) · Sprint Goal (niet: Objective ## Verificatie ```bash -npm run verify && npm run build # verify = lint + typecheck + test +npm run lint && npm test && npm run build ``` - -Worker job-status protocol (wanneer `DONE` / `SKIPPED` / `FAILED`): zie [docs/runbooks/worker-idempotency.md](./docs/runbooks/worker-idempotency.md). - -### Scripts - -| Commando | Doel | -|---|---| -| `npm run dev` | Next dev op poort 3000 (`predev` kill-port draait automatisch) | -| `npm test` | Vitest eenmalig (`vitest run`) | -| `npm run test:watch` | Vitest watch-mode | -| `npm test -- <pad>` | Eén bestand draaien — bv. `npm test -- lib/env` | -| `npm run seed` | Prisma seed via `prisma/seed.ts` | -| `npm run create-admin` | Admin-user toevoegen (`scripts/create-admin.ts`) | -| `npm run db:insert-milestone` | Milestone-script (`scripts/insert-milestone.ts`) | -| `npm run db:sync-model-prices` | Sync Anthropic-model-prijzen — vereist `ANTHROPIC_API_KEY` | -| `npm run docs` | Regenereer `docs/INDEX.md` + check links | -| `npm run diagrams` | Mermaid → SVG (`public/diagrams/architecture-{light,dark}.svg`) | - -> Vitest sluit `.claude/**` uit (relevant voor worktrees). `server-only` wordt via alias gemockt naar `tests/stubs/server-only.ts`, zodat `*-server.ts` modules laadbaar zijn in jsdom-tests. diff --git a/README.md b/README.md index 7cf3a14..1f2da30 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,6 @@ Scrum4Me biedt een lichtgewicht, web-based oplossing voor het beheren van sprint ## Documentation -- [CHANGELOG.md](CHANGELOG.md) — release-historie (Keep a Changelog) - [docs/INDEX.md](docs/INDEX.md) — generated index of all docs (front-matter driven) - [docs/glossary.md](docs/glossary.md) — domain terms (PBI, Story, MCP-job, etc.) - [CLAUDE.md](CLAUDE.md) / [AGENTS.md](AGENTS.md) — agent instructions @@ -123,12 +122,16 @@ Vul daarna `DATABASE_URL` en `SESSION_SECRET` in. `DIRECT_URL` is optioneel loka npx prisma db push ``` -4. Genereer Prisma Client: +4. Genereer Prisma Client en de ERD: ```bash -npx prisma generate +npm run db:erd ``` +Deze command voert lokaal `prisma generate` uit. Daardoor worden zowel de Prisma Client als `docs/assets/erd.svg` opnieuw opgebouwd. + +In CI en deployment wordt bewust alleen de Prisma Client gegenereerd met `prisma generate --generator client`. Het ERD-diagram gebruikt Mermaid/Puppeteer en wordt daarom niet in GitHub Actions of Vercel gegenereerd. + 5. Seed testdata indien nodig: ```bash @@ -149,7 +152,7 @@ npm run dev npm test ``` -Verwacht: alle 445 tests slagen, 0 failures. +Verwacht: alle 69 tests slagen, 0 failures. **API curl-tests (vereist lopende dev server + API token):** @@ -162,9 +165,19 @@ De curl-tests dekken alle 7 API-endpoints: auth (401), demo-blokkering (403), in ## Database -Het schema staat in `prisma/schema.prisma`; uitgebreide documentatie in [`docs/architecture/data-model.md`](./docs/architecture/data-model.md). +![ERD](./docs/assets/erd.svg) -Gebruik `npx prisma db push` om schema-wijzigingen naar de database te synchroniseren. `npx prisma generate` (of `prisma generate --generator client` in CI) genereert de Prisma Client. +De databasevisualisatie wordt lokaal gegenereerd uit `prisma/schema.prisma` via `prisma-erd-generator`. + +Handmatige generatie: + +```bash +npm run db:erd +``` + +Tijdens lokale development draait `npm run dev` naast Next.js ook `npm run db:erd:watch`. Bij wijzigingen in `prisma/schema.prisma` wordt `docs/assets/erd.svg` automatisch opnieuw gegenereerd. + +Gebruik `npx prisma db push` alleen om het schema naar de database te synchroniseren. Gebruik `npm run db:erd` om lokaal Prisma Client en de ERD te genereren. Gebruik in CI uitsluitend `npx prisma generate --generator client`. De app draait standaard op `http://localhost:3000`. @@ -175,6 +188,7 @@ npm run dev # lokale development server npm run lint # ESLint npm test # Vitest test suite npm run build # productiebuild zoals Vercel die verwacht +npm run db:erd # Prisma Client + docs/assets/erd.svg genereren ``` ### Environment variables @@ -184,15 +198,8 @@ Zie [.env.example](.env.example). | Variabele | Verplicht | Doel | |---|---:|---| | `DATABASE_URL` | Ja | PostgreSQL connection string voor Prisma | -| `DIRECT_URL` | Nee | Directe Neon connection string voor migraties (Prisma `directUrl`) | +| `DIRECT_URL` | Nee | Directe Neon connection string voor migraties | | `SESSION_SECRET` | Ja | Minimaal 32 tekens; gebruikt door iron-session | -| `CRON_SECRET` | Productie | Bearer-secret voor `/api/cron/*` routes — required als crons aan staan | -| `NEXT_PUBLIC_VAPID_PUBLIC_KEY` | Nee | VAPID public key voor Web Push — genereer met `npx web-push generate-vapid-keys` | -| `VAPID_PRIVATE_KEY` | Nee | VAPID private key voor Web Push | -| `VAPID_SUBJECT` | Nee | Contact URI voor Web Push (bijv. `mailto:admin@example.com`) | -| `INTERNAL_PUSH_SECRET` | Nee | Bearer-secret voor `/api/internal/push/*` routes (min 32 tekens) | -| `NEXT_PUBLIC_SENTRY_DSN` | Nee | Sentry DSN — zonder is de SDK een no-op | -| `SENTRY_ORG` / `SENTRY_PROJECT` / `SENTRY_AUTH_TOKEN` | Nee | Source-map upload tijdens build | | `NODE_ENV` | Nee | Wordt door Node/Vercel gezet | Vercel Analytics gebruikt geen project-specifieke environment variabele in deze app; de component staat in `app/layout.tsx`. @@ -247,20 +254,13 @@ Authorization: Bearer <token> | Methode | Endpoint | Doel | |---|---|---| -| `GET` | `/api/health` | Liveness; `?db=1` doet ook een DB-ping (geen auth) | | `GET` | `/api/products` | Actieve producten waarvoor de tokengebruiker eigenaar of teamlid is | -| `GET` | `/api/products/:id/next-story` | Hoogst geprioriteerde open story uit de actieve sprint | -| `GET` | `/api/products/:id/claude-context` | Bundled product / actieve sprint / next-story (met tasks) / open ideas voor MCP | +| `GET` | `/api/products/:id/next-story` | Volgende story uit de actieve sprint | | `GET` | `/api/sprints/:id/tasks?limit=10` | Eerste taken van een sprint | | `PATCH` | `/api/stories/:id/tasks/reorder` | Taakvolgorde aanpassen; alle IDs moeten bij de story horen | | `POST` | `/api/stories/:id/log` | Implementatieplan, testresultaat of commit vastleggen | -| `PATCH` | `/api/tasks/:id` | Taakstatus of `implementation_plan` bijwerken | -| `GET / POST` | `/api/ideas` · `GET / PATCH /api/ideas/:id` | Idea CRUD (M12 — vervangt voormalige `/api/todos`) | -| `GET` | `/api/jobs/:id/sub-tasks` | `sprint_task_executions` van een SPRINT_IMPLEMENTATION-job | -| `GET` | `/api/users/:id/avatar` | Avatar van een specifieke gebruiker | -| `POST / GET` | `/api/profile/avatar` | Eigen avatar uploaden of opvragen | - -Daarnaast leveren `/api/realtime/{backlog,solo,jobs,notifications}` SSE-streams en zijn er auth-helpers `/api/auth/pair/*` (QR-pairing, M10), interne push-routes onder `/api/internal/push/*`, en cron-handlers (`/api/cron/cleanup-agent-artifacts`, `/api/cron/expire-questions`). +| `PATCH` | `/api/tasks/:id` | Taakstatus of implementatieplan bijwerken | +| `POST` | `/api/todos` | Todo aanmaken binnen een productcontext | ### Security-regels @@ -287,4 +287,5 @@ De productieomgeving is gericht op Vercel + Neon. - [Functionele specificatie](docs/specs/functional.md) - [Technische architectuur](docs/architecture.md) +- [Backlog](docs/backlog/index.md) - [Agent-instructie audit](docs/decisions/agent-instructions-history.md) diff --git a/__tests__/actions/active-sprint-action.test.ts b/__tests__/actions/active-sprint-action.test.ts deleted file mode 100644 index b87a767..0000000 --- a/__tests__/actions/active-sprint-action.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('next/headers', () => ({ - cookies: vi.fn().mockResolvedValue({ - set: vi.fn(), - get: vi.fn(), - delete: vi.fn(), - }), -})) -vi.mock('iron-session', () => ({ - getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), -})) -vi.mock('@/lib/session', () => ({ - sessionOptions: { cookieName: 'test', password: 'test' }, -})) -vi.mock('@/lib/product-access', () => ({ - productAccessFilter: vi.fn().mockReturnValue({}), -})) -vi.mock('@/lib/prisma', () => ({ - prisma: { - sprint: { findFirst: vi.fn() }, - product: { findFirst: vi.fn() }, - user: { - findUnique: vi.fn(), - update: vi.fn().mockResolvedValue({}), - }, - $executeRaw: vi.fn().mockResolvedValue(1), - }, -})) - -import { prisma } from '@/lib/prisma' -import { clearActiveSprintAction } from '@/actions/active-sprint' - -const mockPrisma = prisma as unknown as { - product: { findFirst: ReturnType<typeof vi.fn> } - user: { - findUnique: ReturnType<typeof vi.fn> - update: ReturnType<typeof vi.fn> - } -} - -describe('clearActiveSprintAction', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('writes null instead of deleting the key', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) - mockPrisma.user.findUnique.mockResolvedValueOnce({ - settings: { layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } } }, - }) - - const result = await clearActiveSprintAction('p1') - - expect(result).toEqual({ success: true }) - const updateArg = mockPrisma.user.update.mock.calls[0][0] as { - data: { settings: { layout?: { activeSprints?: Record<string, string | null> } } } - } - expect(updateArg.data.settings.layout?.activeSprints).toEqual({ - p1: null, - p2: 'sprint-2', - }) - }) - - it('preserves other product keys when clearing one', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) - mockPrisma.user.findUnique.mockResolvedValueOnce({ - settings: { - layout: { - activeSprints: { p1: 'sprint-1', p2: 'sprint-2', p3: null }, - }, - }, - }) - - await clearActiveSprintAction('p1') - - const updateArg = mockPrisma.user.update.mock.calls[0][0] as { - data: { settings: { layout?: { activeSprints?: Record<string, string | null> } } } - } - expect(updateArg.data.settings.layout?.activeSprints).toEqual({ - p1: null, - p2: 'sprint-2', - p3: null, - }) - }) - - it('rejects when product is not accessible', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce(null) - - const result = await clearActiveSprintAction('p1') - - expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' }) - expect(mockPrisma.user.update).not.toHaveBeenCalled() - }) - - it('rejects invalid productId', async () => { - const result = await clearActiveSprintAction('') - - expect(result).toEqual({ error: 'Ongeldig product-id' }) - expect(mockPrisma.user.update).not.toHaveBeenCalled() - }) -}) diff --git a/__tests__/actions/auth.test.ts b/__tests__/actions/auth.test.ts deleted file mode 100644 index 7c8dd86..0000000 --- a/__tests__/actions/auth.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -const { - redirectMock, - verifyUserMock, - headerGetMock, - sessionSaveMock, - requireSessionMock, - prismaUserUpdateMock, - prismaUserRoleFindFirstMock, -} = vi.hoisted(() => ({ - redirectMock: vi.fn((path: string) => { throw new Error(`REDIRECT:${path}`) }), - verifyUserMock: vi.fn(), - headerGetMock: vi.fn(), - sessionSaveMock: vi.fn(), - requireSessionMock: vi.fn(), - prismaUserUpdateMock: vi.fn(), - prismaUserRoleFindFirstMock: vi.fn().mockResolvedValue(null), -})) - -vi.mock('next/navigation', () => ({ redirect: redirectMock })) -vi.mock('next/headers', () => ({ - cookies: vi.fn().mockResolvedValue({}), - headers: vi.fn().mockResolvedValue({ get: headerGetMock }), -})) -vi.mock('iron-session', () => ({ - getIronSession: vi.fn().mockResolvedValue({ - userId: '', - isDemo: false, - save: sessionSaveMock, - }), -})) -vi.mock('@/lib/session', () => ({ sessionOptions: { cookieName: 't', password: 't' } })) -vi.mock('@/lib/auth', () => ({ - verifyUser: verifyUserMock, - registerUser: vi.fn(), - hashPassword: vi.fn().mockResolvedValue('hashed'), -})) -vi.mock('@/lib/auth-guard', () => ({ requireSession: requireSessionMock })) -vi.mock('@/lib/prisma', () => ({ - prisma: { - user: { update: prismaUserUpdateMock }, - userRole: { findFirst: prismaUserRoleFindFirstMock }, - }, -})) -vi.mock('@/lib/rate-limit', () => ({ checkRateLimit: vi.fn().mockReturnValue(true) })) - -import { loginAction, resetPasswordAction } from '@/actions/auth' - -const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) Mobile/15E148 Safari/604.1' -const IPAD_UA = 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) Safari/604.1' -const DESKTOP_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) Chrome/124.0.0.0 Safari/537.36' - -function fd(username: string, password: string) { - const f = new FormData() - f.set('username', username) - f.set('password', password) - return f -} - -beforeEach(() => { - redirectMock.mockClear() - verifyUserMock.mockReset() - headerGetMock.mockReset() - sessionSaveMock.mockReset() - requireSessionMock.mockReset() - prismaUserUpdateMock.mockReset() - prismaUserRoleFindFirstMock.mockResolvedValue(null) -}) - -describe('loginAction UA-redirect', () => { - it('phone-UA + actief product → /m/products/[id]/solo', async () => { - verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' }) - headerGetMock.mockReturnValue(IPHONE_UA) - await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/m/products/p1/solo') - }) - - it('phone-UA zonder actief product → /m/settings', async () => { - verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: null }) - headerGetMock.mockReturnValue(IPHONE_UA) - await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/m/settings') - }) - - it('tablet-UA (iPad) → /dashboard', async () => { - verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' }) - headerGetMock.mockReturnValue(IPAD_UA) - await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/dashboard') - }) - - it('desktop-UA → /dashboard', async () => { - verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' }) - headerGetMock.mockReturnValue(DESKTOP_UA) - await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/dashboard') - }) - - it('geen UA-header → /dashboard', async () => { - verifyUserMock.mockResolvedValue({ id: 'u1', is_demo: false, active_product_id: 'p1' }) - headerGetMock.mockReturnValue(null) - await expect(loginAction(undefined, fd('alice', 'secret123'))).rejects.toThrow('REDIRECT:/dashboard') - }) - - it('demo-user op phone volgt dezelfde routing', async () => { - verifyUserMock.mockResolvedValue({ id: 'demo', is_demo: true, active_product_id: 'p1' }) - headerGetMock.mockReturnValue(IPHONE_UA) - await expect(loginAction(undefined, fd('demo', 'demo123pw'))).rejects.toThrow('REDIRECT:/m/products/p1/solo') - }) -}) - -describe('resetPasswordAction', () => { - function fdReset(password: string, confirm: string) { - const f = new FormData() - f.set('password', password) - f.set('confirm', confirm) - return f - } - - it('redirect /dashboard na succesvolle reset', async () => { - requireSessionMock.mockResolvedValue({ userId: 'u1' }) - prismaUserUpdateMock.mockResolvedValue({}) - await expect(resetPasswordAction(undefined, fdReset('nieuwpass1', 'nieuwpass1'))).rejects.toThrow('REDIRECT:/dashboard') - expect(prismaUserUpdateMock).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: 'u1' }, - data: expect.objectContaining({ password_hash: 'hashed', must_reset_password: false }), - }) - ) - }) - - it('fout als wachtwoorden niet overeenkomen', async () => { - requireSessionMock.mockResolvedValue({ userId: 'u1' }) - const result = await resetPasswordAction(undefined, fdReset('nieuwpass1', 'anderpass1')) - expect(result).toMatchObject({ error: expect.objectContaining({ confirm: expect.any(Array) }) }) - expect(prismaUserUpdateMock).not.toHaveBeenCalled() - }) - - it('fout als wachtwoord te kort is', async () => { - requireSessionMock.mockResolvedValue({ userId: 'u1' }) - const result = await resetPasswordAction(undefined, fdReset('kort', 'kort')) - expect(result).toMatchObject({ error: expect.objectContaining({ password: expect.any(Array) }) }) - }) -}) diff --git a/__tests__/actions/claude-jobs-batch.test.ts b/__tests__/actions/claude-jobs-batch.test.ts deleted file mode 100644 index 50c9be0..0000000 --- a/__tests__/actions/claude-jobs-batch.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Per-task batch enqueue is gedeprecateerd ten gunste van startSprintRunAction - * (zie actions/sprint-runs.ts). De functies blijven exporteerbaar als stub voor - * backwards-compat met UI-componenten die in F4 worden vervangen. - */ -import { describe, it, expect, vi } from 'vitest' - -vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('@/lib/auth', () => ({ getSession: vi.fn() })) -vi.mock('@/lib/prisma', () => ({ prisma: {} })) - -import { - previewEnqueueAllAction, - enqueueClaudeJobsBatchAction, -} from '@/actions/claude-jobs' - -describe('previewEnqueueAllAction (deprecated)', () => { - it('retourneert een deprecation-error', async () => { - const result = await previewEnqueueAllAction('prod-1') - expect(result).toMatchObject({ error: expect.stringContaining('vervangen') }) - }) -}) - -describe('enqueueClaudeJobsBatchAction (deprecated)', () => { - it('retourneert een deprecation-error', async () => { - const result = await enqueueClaudeJobsBatchAction('prod-1', ['t1', 't2']) - expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') }) - }) -}) diff --git a/__tests__/actions/claude-jobs.test.ts b/__tests__/actions/claude-jobs.test.ts index 484f185..1da99ef 100644 --- a/__tests__/actions/claude-jobs.test.ts +++ b/__tests__/actions/claude-jobs.test.ts @@ -1,46 +1,47 @@ -/** - * Per-task enqueue-acties zijn gedeprecateerd. cancelClaudeJobAction blijft - * actief — gebruikt voor het annuleren van losse jobs (bv. idea-jobs). - */ import { describe, it, expect, vi, beforeEach } from 'vitest' const { mockGetSession, + mockFindFirstTask, + mockFindManyTask, + mockFindFirstProduct, + mockFindFirstSprint, mockFindFirstJob, + mockCreateJob, mockUpdateJob, - mockUpdateManyJob, - mockUpdateManySprintTaskExecution, - mockTransaction, mockExecuteRaw, -} = vi.hoisted(() => { - const mockUpdateManyJob = vi.fn() - const mockUpdateManySprintTaskExecution = vi.fn() - const mockTransaction = vi.fn() - return { - mockGetSession: vi.fn(), - mockFindFirstJob: vi.fn(), - mockUpdateJob: vi.fn(), - mockUpdateManyJob, - mockUpdateManySprintTaskExecution, - mockTransaction, - mockExecuteRaw: vi.fn().mockResolvedValue(undefined), - } -}) + mockTransaction, +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockFindFirstTask: vi.fn(), + mockFindManyTask: vi.fn(), + mockFindFirstProduct: vi.fn(), + mockFindFirstSprint: vi.fn(), + mockFindFirstJob: vi.fn(), + mockCreateJob: vi.fn(), + mockUpdateJob: vi.fn(), + mockExecuteRaw: vi.fn().mockResolvedValue(undefined), + mockTransaction: vi.fn(), +})) vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) + +vi.mock('@/lib/auth', () => ({ + getSession: mockGetSession, +})) + vi.mock('@/lib/prisma', () => ({ prisma: { + task: { findFirst: mockFindFirstTask, findMany: mockFindManyTask }, + product: { findFirst: mockFindFirstProduct }, + sprint: { findFirst: mockFindFirstSprint }, claudeJob: { findFirst: mockFindFirstJob, + create: mockCreateJob, update: mockUpdateJob, - updateMany: mockUpdateManyJob, }, - sprintTaskExecution: { - updateMany: mockUpdateManySprintTaskExecution, - }, - $transaction: mockTransaction, $executeRaw: mockExecuteRaw, + $transaction: mockTransaction, }, })) @@ -48,194 +49,202 @@ import { enqueueClaudeJobAction, enqueueAllTodoJobsAction, cancelClaudeJobAction, - restartClaudeJobAction, } from '@/actions/claude-jobs' const SESSION_USER = { userId: 'user-1', isDemo: false } +const SESSION_DEMO = { userId: 'demo-1', isDemo: true } +const TASK_ID = 'task-cuid-1' +const JOB_ID = 'job-cuid-1' +const PRODUCT_ID = 'product-cuid-1' + +const MOCK_TASK = { id: TASK_ID, story: { product_id: PRODUCT_ID } } +const MOCK_JOB_QUEUED = { id: JOB_ID, status: 'QUEUED' as const, task_id: TASK_ID, product_id: PRODUCT_ID } + beforeEach(() => { vi.clearAllMocks() mockExecuteRaw.mockResolvedValue(undefined) - mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) => - fn({ - claudeJob: { updateMany: mockUpdateManyJob }, - sprintTaskExecution: { updateMany: mockUpdateManySprintTaskExecution }, - }) - ) }) -describe('enqueueClaudeJobAction (deprecated)', () => { - it('retourneert een deprecation-error', async () => { - const result = await enqueueClaudeJobAction('task-1') - expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') }) +describe('enqueueClaudeJobAction', () => { + it('happy path: creates job with QUEUED status', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstTask.mockResolvedValue(MOCK_TASK) + mockFindFirstJob.mockResolvedValue(null) + mockCreateJob.mockResolvedValue({ id: JOB_ID }) + + const result = await enqueueClaudeJobAction(TASK_ID) + + expect(result).toEqual({ success: true, jobId: JOB_ID }) + expect(mockCreateJob).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ status: 'QUEUED', task_id: TASK_ID }) }) + ) + }) + + it('blocks demo user', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) + + const result = await enqueueClaudeJobAction(TASK_ID) + + expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' }) + expect(mockCreateJob).not.toHaveBeenCalled() + }) + + it('returns error when task not found', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstTask.mockResolvedValue(null) + + const result = await enqueueClaudeJobAction(TASK_ID) + + expect(result).toMatchObject({ error: 'Task niet gevonden' }) + expect(mockCreateJob).not.toHaveBeenCalled() + }) + + it('idempotency: returns existing jobId when QUEUED job exists', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstTask.mockResolvedValue(MOCK_TASK) + mockFindFirstJob.mockResolvedValue({ id: JOB_ID }) + + const result = await enqueueClaudeJobAction(TASK_ID) + + expect(result).toMatchObject({ error: 'Er loopt al een agent voor deze task', jobId: JOB_ID }) + expect(mockCreateJob).not.toHaveBeenCalled() + }) + + it('allows new enqueue after terminal (DONE) job', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstTask.mockResolvedValue(MOCK_TASK) + mockFindFirstJob.mockResolvedValue(null) // no active job + mockCreateJob.mockResolvedValue({ id: 'new-job-id' }) + + const result = await enqueueClaudeJobAction(TASK_ID) + + expect(result).toEqual({ success: true, jobId: 'new-job-id' }) }) }) -describe('enqueueAllTodoJobsAction (deprecated)', () => { - it('retourneert een deprecation-error', async () => { - const result = await enqueueAllTodoJobsAction('prod-1') - expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') }) +describe('enqueueAllTodoJobsAction', () => { + it('happy path: scopes to active sprint + assignee, queues all queueable tasks', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) + mockFindManyTask.mockResolvedValue([{ id: 'task-a' }, { id: 'task-b' }]) + mockTransaction.mockResolvedValue([ + { id: 'job-a', task_id: 'task-a' }, + { id: 'job-b', task_id: 'task-b' }, + ]) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toEqual({ success: true, count: 2 }) + expect(mockFindManyTask).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: 'TO_DO', + story: { sprint_id: 'sprint-1', assignee_id: SESSION_USER.userId }, + }), + }) + ) + expect(mockExecuteRaw).toHaveBeenCalledTimes(2) + }) + + it('returns count=0 when product has no active sprint', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue(null) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toEqual({ success: true, count: 0 }) + expect(mockFindManyTask).not.toHaveBeenCalled() + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('returns count=0 when no queueable tasks in sprint+assignee scope', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) + mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' }) + mockFindManyTask.mockResolvedValue([]) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toEqual({ success: true, count: 0 }) + expect(mockTransaction).not.toHaveBeenCalled() + expect(mockExecuteRaw).not.toHaveBeenCalled() + }) + + it('blocks demo user', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' }) + expect(mockTransaction).not.toHaveBeenCalled() + }) + + it('returns error when product not accessible', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstProduct.mockResolvedValue(null) + + const result = await enqueueAllTodoJobsAction(PRODUCT_ID) + + expect(result).toMatchObject({ error: 'Geen toegang tot dit product' }) + expect(mockTransaction).not.toHaveBeenCalled() }) }) describe('cancelClaudeJobAction', () => { - it('cancelt een actieve job', async () => { + it('happy path: cancels QUEUED job', async () => { mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue({ - id: 'job-1', - status: 'QUEUED', - task_id: 'task-1', - product_id: 'prod-1', - }) - mockUpdateJob.mockResolvedValue(undefined) + mockFindFirstJob.mockResolvedValue(MOCK_JOB_QUEUED) + mockUpdateJob.mockResolvedValue({}) - const result = await cancelClaudeJobAction('job-1') + const result = await cancelClaudeJobAction(JOB_ID) expect(result).toEqual({ success: true }) - expect(mockUpdateJob).toHaveBeenCalledWith({ - where: { id: 'job-1' }, - data: expect.objectContaining({ status: 'CANCELLED' }), - }) + expect(mockUpdateJob).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: JOB_ID }, + data: expect.objectContaining({ status: 'CANCELLED' }), + }) + ) }) - it('weigert demo-sessie', async () => { - mockGetSession.mockResolvedValue({ userId: 'demo', isDemo: true }) + it('demo user is blocked', async () => { + mockGetSession.mockResolvedValue(SESSION_DEMO) - const result = await cancelClaudeJobAction('job-1') - expect(result).toMatchObject({ error: expect.stringContaining('demo') }) + const result = await cancelClaudeJobAction(JOB_ID) + + expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' }) expect(mockUpdateJob).not.toHaveBeenCalled() }) - it('retourneert error als job niet gevonden', async () => { + it('returns error when job not found (ownership check)', async () => { mockGetSession.mockResolvedValue(SESSION_USER) mockFindFirstJob.mockResolvedValue(null) - const result = await cancelClaudeJobAction('nonexistent') - expect(result).toMatchObject({ error: expect.stringContaining('niet gevonden') }) + const result = await cancelClaudeJobAction(JOB_ID) + + expect(result).toMatchObject({ error: 'Job niet gevonden' }) + expect(mockUpdateJob).not.toHaveBeenCalled() }) - it('weigert wanneer job niet meer actief is', async () => { + it('returns error when cancelling terminal (DONE) job', async () => { mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue({ - id: 'job-1', - status: 'DONE', - task_id: 'task-1', - product_id: 'prod-1', - }) + mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'DONE' as const }) - const result = await cancelClaudeJobAction('job-1') - expect(result).toMatchObject({ error: expect.stringContaining('actieve') }) - }) -}) - -describe('restartClaudeJobAction', () => { - const FAILED_JOB = { - id: 'job-1', - status: 'FAILED', - kind: 'TASK_IMPLEMENTATION', - task_id: 'task-1', - idea_id: null, - sprint_run_id: null, - product_id: 'prod-1', - } - - it('reset een FAILED job naar QUEUED (happy path)', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue(FAILED_JOB) - mockUpdateManyJob.mockResolvedValue({ count: 1 }) - - const result = await restartClaudeJobAction('job-1') - - expect(result).toEqual({ success: true }) - expect(mockUpdateManyJob).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ id: 'job-1', status: { in: ['FAILED', 'CANCELLED', 'SKIPPED'] } }), - data: expect.objectContaining({ status: 'QUEUED' }), - }) - ) - expect(mockExecuteRaw).toHaveBeenCalled() - }) - - it('reset een CANCELLED job naar QUEUED', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue({ ...FAILED_JOB, status: 'CANCELLED' }) - mockUpdateManyJob.mockResolvedValue({ count: 1 }) - - const result = await restartClaudeJobAction('job-1') - expect(result).toEqual({ success: true }) - }) - - it('reset een SKIPPED job naar QUEUED', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue({ ...FAILED_JOB, status: 'SKIPPED' }) - mockUpdateManyJob.mockResolvedValue({ count: 1 }) - - const result = await restartClaudeJobAction('job-1') - expect(result).toEqual({ success: true }) - }) - - it('weigert demo-sessie', async () => { - mockGetSession.mockResolvedValue({ userId: 'demo', isDemo: true }) - - const result = await restartClaudeJobAction('job-1') - expect(result).toMatchObject({ error: expect.stringContaining('demo') }) - expect(mockUpdateManyJob).not.toHaveBeenCalled() - }) - - it('retourneert error als job niet gevonden', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue(null) - - const result = await restartClaudeJobAction('job-1') - expect(result).toMatchObject({ error: expect.stringContaining('niet gevonden') }) - }) - - it('weigert wanneer job een niet-restartbare status heeft', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue({ ...FAILED_JOB, status: 'DONE' }) - - const result = await restartClaudeJobAction('job-1') - expect(result).toMatchObject({ error: expect.stringContaining('mislukte') }) - expect(mockUpdateManyJob).not.toHaveBeenCalled() - }) - - it('retourneert error bij race-conditie (updateMany count === 0)', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue(FAILED_JOB) - mockUpdateManyJob.mockResolvedValue({ count: 0 }) - - const result = await restartClaudeJobAction('job-1') - expect(result).toMatchObject({ error: expect.stringContaining('gewijzigd') }) - }) - - it('reset ook SprintTaskExecution-rows bij SPRINT_IMPLEMENTATION', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue({ - ...FAILED_JOB, - kind: 'SPRINT_IMPLEMENTATION', - sprint_run_id: 'run-1', - }) - mockUpdateManyJob.mockResolvedValue({ count: 1 }) - mockUpdateManySprintTaskExecution.mockResolvedValue({ count: 3 }) - - const result = await restartClaudeJobAction('job-1') - - expect(result).toEqual({ success: true }) - expect(mockUpdateManySprintTaskExecution).toHaveBeenCalledWith( - expect.objectContaining({ - where: { sprint_job_id: 'job-1' }, - data: expect.objectContaining({ status: 'PENDING' }), - }) - ) - }) - - it('reset geen SprintTaskExecution-rows bij TASK_IMPLEMENTATION', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue(FAILED_JOB) - mockUpdateManyJob.mockResolvedValue({ count: 1 }) - - await restartClaudeJobAction('job-1') - - expect(mockUpdateManySprintTaskExecution).not.toHaveBeenCalled() + const result = await cancelClaudeJobAction(JOB_ID) + + expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' }) + expect(mockUpdateJob).not.toHaveBeenCalled() + }) + + it('returns error when cancelling FAILED job', async () => { + mockGetSession.mockResolvedValue(SESSION_USER) + mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'FAILED' as const }) + + const result = await cancelClaudeJobAction(JOB_ID) + + expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' }) }) }) diff --git a/__tests__/actions/commit-sprint-membership.test.ts b/__tests__/actions/commit-sprint-membership.test.ts deleted file mode 100644 index af80547..0000000 --- a/__tests__/actions/commit-sprint-membership.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('next/headers', () => ({ - cookies: vi.fn().mockResolvedValue({ - set: vi.fn(), - get: vi.fn(), - delete: vi.fn(), - }), -})) -vi.mock('iron-session', () => ({ - getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), -})) -vi.mock('@/lib/session', () => ({ - sessionOptions: { cookieName: 'test', password: 'test' }, -})) -vi.mock('@/lib/product-access', () => ({ - productAccessFilter: vi.fn().mockReturnValue({}), - getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }), -})) -vi.mock('@/lib/rate-limit', () => ({ - enforceUserRateLimit: vi.fn().mockReturnValue(null), -})) -vi.mock('@/lib/code-server', () => ({ - createWithCodeRetry: vi.fn(), - generateNextSprintCode: vi.fn(), -})) -vi.mock('@/lib/active-sprint', () => ({ - setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined), -})) -vi.mock('@/lib/prisma', () => { - const txClient = { - sprint: { create: vi.fn() }, - story: { updateMany: vi.fn() }, - task: { updateMany: vi.fn() }, - } - return { - prisma: { - sprint: { findFirst: vi.fn() }, - story: { - findMany: vi.fn(), - updateMany: vi.fn(), - }, - task: { - findMany: vi.fn(), - updateMany: vi.fn(), - }, - $transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) => fn(txClient)), - __txClient: txClient, - }, - } -}) - -import { prisma } from '@/lib/prisma' -import { commitSprintMembershipAction } from '@/actions/sprints' - -type Mocked = { - sprint: { findFirst: ReturnType<typeof vi.fn> } - story: { - findMany: ReturnType<typeof vi.fn> - updateMany: ReturnType<typeof vi.fn> - } - task: { - findMany: ReturnType<typeof vi.fn> - updateMany: ReturnType<typeof vi.fn> - } - $transaction: ReturnType<typeof vi.fn> - __txClient: { - sprint: { create: ReturnType<typeof vi.fn> } - story: { updateMany: ReturnType<typeof vi.fn> } - task: { updateMany: ReturnType<typeof vi.fn> } - } -} -const mockPrisma = prisma as unknown as Mocked - -beforeEach(() => { - vi.clearAllMocks() - mockPrisma.sprint.findFirst.mockReset().mockResolvedValue({ - id: 'sprint-active', - product_id: 'product-1', - }) - mockPrisma.story.findMany.mockReset() - mockPrisma.story.updateMany.mockReset() - mockPrisma.task.findMany.mockReset() - mockPrisma.task.updateMany.mockReset() - mockPrisma.$transaction.mockImplementation( - async (fn: (tx: typeof mockPrisma.__txClient) => unknown) => - fn(mockPrisma.__txClient), - ) - mockPrisma.__txClient.story.updateMany.mockReset().mockResolvedValue({ count: 0 }) - mockPrisma.__txClient.task.updateMany.mockReset().mockResolvedValue({ count: 0 }) -}) - -describe('commitSprintMembershipAction', () => { - it('happy path: eligible adds + valid removes → transactie commits', async () => { - // adds-partition: alle eligible (sprint_id=null + niet DONE) - mockPrisma.story.findMany - // partition lookup - .mockResolvedValueOnce([ - { id: 's-add-1', sprint_id: null, status: 'OPEN', sprint: null }, - ]) - // removes-filter (sprint_id == activeSprintId) - .mockResolvedValueOnce([{ id: 's-rem-1' }]) - // affectedStories - .mockResolvedValueOnce([ - { pbi_id: 'pbiA' }, - { pbi_id: 'pbiB' }, - ]) - mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }]) - - const result = await commitSprintMembershipAction({ - activeSprintId: 'sprint-active', - adds: ['s-add-1'], - removes: ['s-rem-1'], - }) - - expect('success' in result).toBe(true) - if ('success' in result) { - expect(result.affectedStoryIds.sort()).toEqual(['s-add-1', 's-rem-1']) - expect(result.affectedPbiIds.sort()).toEqual(['pbiA', 'pbiB']) - expect(result.affectedTaskIds).toEqual(['t1']) - expect(result.conflicts.notEligible).toEqual([]) - expect(result.conflicts.alreadyRemoved).toEqual([]) - } - expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1) - expect(mockPrisma.__txClient.story.updateMany).toHaveBeenCalledTimes(2) - expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledTimes(2) - }) - - it('add met status=DONE → conflicts.notEligible, story niet ge-update', async () => { - mockPrisma.story.findMany - .mockResolvedValueOnce([ - { id: 's-done', sprint_id: null, status: 'DONE', sprint: null }, - ]) - // removes-filter (geen removes) - .mockResolvedValueOnce([]) - - const result = await commitSprintMembershipAction({ - activeSprintId: 'sprint-active', - adds: ['s-done'], - removes: [], - }) - - expect('success' in result).toBe(true) - if ('success' in result) { - expect(result.affectedStoryIds).toEqual([]) - expect(result.conflicts.notEligible).toEqual([ - { storyId: 's-done', reason: 'DONE' }, - ]) - } - // Geen transaction omdat er niets te commiten valt. - expect(mockPrisma.$transaction).not.toHaveBeenCalled() - }) - - it('add met sprint_id in andere OPEN sprint → conflicts.notEligible IN_OTHER_SPRINT', async () => { - mockPrisma.story.findMany - .mockResolvedValueOnce([ - { - id: 's-elsewhere', - sprint_id: 'sprint-other', - status: 'IN_SPRINT', - sprint: { id: 'sprint-other', code: 'SP-O', status: 'OPEN' }, - }, - ]) - .mockResolvedValueOnce([]) - - const result = await commitSprintMembershipAction({ - activeSprintId: 'sprint-active', - adds: ['s-elsewhere'], - removes: [], - }) - - if ('success' in result) { - expect(result.conflicts.notEligible).toEqual([ - { storyId: 's-elsewhere', reason: 'IN_OTHER_SPRINT' }, - ]) - } - }) - - it('remove voor story die niet in actieve sprint zit → conflicts.alreadyRemoved', async () => { - mockPrisma.story.findMany - // adds-partition (geen adds) - .mockResolvedValueOnce([]) - // removes-filter — race scenario: story zit niet meer in active sprint - .mockResolvedValueOnce([]) - - const result = await commitSprintMembershipAction({ - activeSprintId: 'sprint-active', - adds: [], - removes: ['s-was-removed'], - }) - - if ('success' in result) { - expect(result.affectedStoryIds).toEqual([]) - expect(result.conflicts.alreadyRemoved).toEqual(['s-was-removed']) - } - }) - - it('transactie: story.status=IN_SPRINT bij add, =OPEN bij remove', async () => { - mockPrisma.story.findMany - .mockResolvedValueOnce([ - { id: 's-add', sprint_id: null, status: 'OPEN', sprint: null }, - ]) - .mockResolvedValueOnce([{ id: 's-rem' }]) - .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) - mockPrisma.task.findMany.mockResolvedValueOnce([]) - - await commitSprintMembershipAction({ - activeSprintId: 'sprint-active', - adds: ['s-add'], - removes: ['s-rem'], - }) - - const calls = mockPrisma.__txClient.story.updateMany.mock.calls - // Add: status=IN_SPRINT + sprint_id=sprint-active - expect(calls[0][0].data).toEqual({ - sprint_id: 'sprint-active', - status: 'IN_SPRINT', - }) - // Remove: status=OPEN + sprint_id=null - expect(calls[1][0].data).toEqual({ sprint_id: null, status: 'OPEN' }) - }) - - it('task.sprint_id wordt in dezelfde transactie ge-update', async () => { - mockPrisma.story.findMany - .mockResolvedValueOnce([ - { id: 's-add', sprint_id: null, status: 'OPEN', sprint: null }, - ]) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) - mockPrisma.task.findMany.mockResolvedValueOnce([]) - - await commitSprintMembershipAction({ - activeSprintId: 'sprint-active', - adds: ['s-add'], - removes: [], - }) - - expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: { story_id: { in: ['s-add'] } }, - data: { sprint_id: 'sprint-active' }, - }), - ) - }) - - it('return: affectedStoryIds + affectedPbiIds + affectedTaskIds + conflicts', async () => { - mockPrisma.story.findMany - .mockResolvedValueOnce([ - { id: 's-add', sprint_id: null, status: 'OPEN', sprint: null }, - ]) - .mockResolvedValueOnce([{ id: 's-rem' }]) - .mockResolvedValueOnce([ - { pbi_id: 'pbiA' }, - { pbi_id: 'pbiB' }, - ]) - mockPrisma.task.findMany.mockResolvedValueOnce([ - { id: 't1' }, - { id: 't2' }, - ]) - - const result = await commitSprintMembershipAction({ - activeSprintId: 'sprint-active', - adds: ['s-add'], - removes: ['s-rem'], - }) - - expect(result).toMatchObject({ - success: true, - affectedStoryIds: expect.arrayContaining(['s-add', 's-rem']), - affectedPbiIds: expect.arrayContaining(['pbiA', 'pbiB']), - affectedTaskIds: expect.arrayContaining(['t1', 't2']), - }) - }) - - it('rejects when sprint is not accessible', async () => { - mockPrisma.sprint.findFirst.mockResolvedValue(null) - - const result = await commitSprintMembershipAction({ - activeSprintId: 'sprint-active', - adds: [], - removes: [], - }) - - expect('error' in result).toBe(true) - if ('error' in result) { - expect(result.code).toBe(403) - } - }) -}) diff --git a/__tests__/actions/create-sprint-with-selection.test.ts b/__tests__/actions/create-sprint-with-selection.test.ts deleted file mode 100644 index 444008a..0000000 --- a/__tests__/actions/create-sprint-with-selection.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('next/headers', () => ({ - cookies: vi.fn().mockResolvedValue({ - set: vi.fn(), - get: vi.fn(), - delete: vi.fn(), - }), -})) -vi.mock('iron-session', () => ({ - getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), -})) -vi.mock('@/lib/session', () => ({ - sessionOptions: { cookieName: 'test', password: 'test' }, -})) -vi.mock('@/lib/product-access', () => ({ - productAccessFilter: vi.fn().mockReturnValue({}), - getAccessibleProduct: vi.fn().mockResolvedValue({ - id: 'product-1', - user_id: 'user-1', - }), -})) -vi.mock('@/lib/rate-limit', () => ({ - enforceUserRateLimit: vi.fn().mockReturnValue(null), -})) -vi.mock('@/lib/code-server', () => ({ - createWithCodeRetry: vi.fn(async (_gen, fn) => fn('SP-1')), - generateNextSprintCode: vi.fn().mockResolvedValue('SP-1'), -})) -vi.mock('@/lib/active-sprint', () => ({ - setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined), -})) -vi.mock('@/lib/prisma', () => { - const txClient = { - sprint: { create: vi.fn() }, - story: { updateMany: vi.fn() }, - task: { updateMany: vi.fn() }, - } - return { - prisma: { - sprint: { - create: vi.fn(), - findFirst: vi.fn(), - update: vi.fn(), - }, - story: { - findMany: vi.fn(), - updateMany: vi.fn(), - }, - task: { - findMany: vi.fn(), - updateMany: vi.fn(), - }, - pbi: { findMany: vi.fn() }, - user: { - findUnique: vi.fn(), - update: vi.fn(), - }, - $transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) => fn(txClient)), - __txClient: txClient, - }, - } -}) - -import { prisma } from '@/lib/prisma' -import { - createSprintWithSelectionAction, - type CreateSprintWithSelectionInput, -} from '@/actions/sprints' - -type Mocked = { - sprint: { - create: ReturnType<typeof vi.fn> - findFirst: ReturnType<typeof vi.fn> - update: ReturnType<typeof vi.fn> - } - story: { - findMany: ReturnType<typeof vi.fn> - updateMany: ReturnType<typeof vi.fn> - } - task: { - findMany: ReturnType<typeof vi.fn> - updateMany: ReturnType<typeof vi.fn> - } - $transaction: ReturnType<typeof vi.fn> - __txClient: { - sprint: { create: ReturnType<typeof vi.fn> } - story: { updateMany: ReturnType<typeof vi.fn> } - task: { updateMany: ReturnType<typeof vi.fn> } - } -} -const mockPrisma = prisma as unknown as Mocked - -function baseInput( - overrides: Partial<CreateSprintWithSelectionInput> = {}, -): CreateSprintWithSelectionInput { - return { - productId: 'product-1', - metadata: { goal: 'Sprint 1' }, - pbiIntent: {}, - storyOverrides: {}, - ...overrides, - } -} - -beforeEach(() => { - vi.clearAllMocks() - mockPrisma.sprint.create.mockReset() - mockPrisma.story.findMany.mockReset() - mockPrisma.story.updateMany.mockReset() - mockPrisma.task.findMany.mockReset() - mockPrisma.task.updateMany.mockReset() - mockPrisma.$transaction.mockImplementation( - async (fn: (tx: typeof mockPrisma.__txClient) => unknown) => - fn(mockPrisma.__txClient), - ) - mockPrisma.__txClient.sprint.create - .mockReset() - .mockResolvedValue({ id: 'sprint-1', code: 'SP-1' }) - mockPrisma.__txClient.story.updateMany - .mockReset() - .mockResolvedValue({ count: 0 }) - mockPrisma.__txClient.task.updateMany - .mockReset() - .mockResolvedValue({ count: 0 }) -}) - -describe('createSprintWithSelectionAction', () => { - it('resolves intent=all naar alle child-stories en weert overrides.remove', async () => { - // Stap 1: stories voor PBI-A (intent=all). Plus eligibility-fetch. - mockPrisma.story.findMany - // resolve step (only for pbis with intent='all') - .mockResolvedValueOnce([ - { id: 's1', pbi_id: 'pbiA' }, - { id: 's2', pbi_id: 'pbiA' }, - { id: 's3', pbi_id: 'pbiA' }, - ]) - // partitionByEligibility — alle eligible - .mockResolvedValueOnce([ - { id: 's1', sprint_id: null, status: 'OPEN', sprint: null }, - { id: 's3', sprint_id: null, status: 'OPEN', sprint: null }, - ]) - // affectedStories - .mockResolvedValueOnce([ - { pbi_id: 'pbiA' }, - { pbi_id: 'pbiA' }, - ]) - mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }]) - - const result = await createSprintWithSelectionAction( - baseInput({ - pbiIntent: { pbiA: 'all' }, - storyOverrides: { pbiA: { add: [], remove: ['s2'] } }, - }), - ) - - expect('success' in result).toBe(true) - if ('success' in result) { - expect(result.affectedStoryIds).toEqual(['s1', 's3']) - expect(result.conflicts.notEligible).toEqual([]) - } - }) - - it('voegt storyOverrides.add toe over PBI heen (zelfs intent=none)', async () => { - // Geen PBI met intent=all → stap 1 wordt niet uitgevoerd. - mockPrisma.story.findMany - // partition - .mockResolvedValueOnce([ - { id: 's10', sprint_id: null, status: 'OPEN', sprint: null }, - ]) - // affectedStories - .mockResolvedValueOnce([{ pbi_id: 'pbiB' }]) - mockPrisma.task.findMany.mockResolvedValueOnce([]) - - const result = await createSprintWithSelectionAction( - baseInput({ - pbiIntent: { pbiB: 'none' }, - storyOverrides: { pbiB: { add: ['s10'], remove: [] } }, - }), - ) - - expect('success' in result).toBe(true) - if ('success' in result) { - expect(result.affectedStoryIds).toEqual(['s10']) - } - }) - - it('eligibility-filter classificeert DONE en cross-sprint stories', async () => { - mockPrisma.story.findMany - // resolve - .mockResolvedValueOnce([ - { id: 's1', pbi_id: 'pbiA' }, - { id: 's2', pbi_id: 'pbiA' }, - { id: 's3', pbi_id: 'pbiA' }, - ]) - // partition: s1=DONE, s2=eligible, s3=in andere OPEN sprint - .mockResolvedValueOnce([ - { id: 's1', sprint_id: null, status: 'DONE', sprint: null }, - { id: 's2', sprint_id: null, status: 'OPEN', sprint: null }, - { - id: 's3', - sprint_id: 'sprint-other', - status: 'IN_SPRINT', - sprint: { id: 'sprint-other', code: 'SP-O', status: 'OPEN' }, - }, - ]) - // affectedStories - .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) - mockPrisma.task.findMany.mockResolvedValueOnce([]) - - const result = await createSprintWithSelectionAction( - baseInput({ pbiIntent: { pbiA: 'all' } }), - ) - - expect('success' in result).toBe(true) - if ('success' in result) { - expect(result.affectedStoryIds).toEqual(['s2']) - expect(result.conflicts.notEligible.map((n) => n.storyId).sort()).toEqual( - ['s1', 's3'], - ) - expect(result.conflicts.crossSprint.map((c) => c.storyId)).toEqual(['s3']) - } - }) - - it('zet story.status=IN_SPRINT en task.sprint_id mee in dezelfde transactie', async () => { - mockPrisma.story.findMany - .mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }]) - .mockResolvedValueOnce([ - { id: 's1', sprint_id: null, status: 'OPEN', sprint: null }, - ]) - .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) - mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }]) - - await createSprintWithSelectionAction( - baseInput({ pbiIntent: { pbiA: 'all' } }), - ) - - expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1) - expect(mockPrisma.__txClient.story.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - sprint_id: 'sprint-1', - status: 'IN_SPRINT', - }), - }), - ) - expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ - data: { sprint_id: 'sprint-1' }, - }), - ) - }) - - it('returnt affectedStoryIds + affectedPbiIds + affectedTaskIds', async () => { - mockPrisma.story.findMany - .mockResolvedValueOnce([ - { id: 's1', pbi_id: 'pbiA' }, - { id: 's2', pbi_id: 'pbiB' }, - ]) - .mockResolvedValueOnce([ - { id: 's1', sprint_id: null, status: 'OPEN', sprint: null }, - { id: 's2', sprint_id: null, status: 'OPEN', sprint: null }, - ]) - .mockResolvedValueOnce([{ pbi_id: 'pbiA' }, { pbi_id: 'pbiB' }]) - mockPrisma.task.findMany.mockResolvedValueOnce([ - { id: 't1' }, - { id: 't2' }, - ]) - - const result = await createSprintWithSelectionAction( - baseInput({ pbiIntent: { pbiA: 'all', pbiB: 'all' } }), - ) - - expect('success' in result).toBe(true) - if ('success' in result) { - expect(result.affectedStoryIds.sort()).toEqual(['s1', 's2']) - expect(result.affectedPbiIds.sort()).toEqual(['pbiA', 'pbiB']) - expect(result.affectedTaskIds.sort()).toEqual(['t1', 't2']) - } - }) - - it('returnt error wanneer geen eligible stories overblijven', async () => { - mockPrisma.story.findMany - .mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }]) - // s1 is DONE → notEligible - .mockResolvedValueOnce([ - { id: 's1', sprint_id: null, status: 'DONE', sprint: null }, - ]) - - const result = await createSprintWithSelectionAction( - baseInput({ pbiIntent: { pbiA: 'all' } }), - ) - - expect('error' in result).toBe(true) - if ('error' in result) { - expect(result.code).toBe(422) - } - }) -}) diff --git a/__tests__/actions/ideas-crud.test.ts b/__tests__/actions/ideas-crud.test.ts deleted file mode 100644 index bf1ba41..0000000 --- a/__tests__/actions/ideas-crud.test.ts +++ /dev/null @@ -1,717 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -const { mockSession } = vi.hoisted(() => ({ - mockSession: { userId: 'user-1', isDemo: false }, -})) - -vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) -vi.mock('iron-session', () => ({ - getIronSession: vi.fn().mockImplementation(async () => mockSession), -})) -vi.mock('@/lib/session', () => ({ - sessionOptions: { cookieName: 'test', password: 'test-password-32-chars-minimum-len' }, -})) -vi.mock('@/lib/idea-code-server', () => ({ - nextIdeaCode: vi.fn().mockResolvedValue('IDEA-001'), -})) -vi.mock('@/lib/prisma', () => ({ - prisma: { - idea: { - create: vi.fn(), - findFirst: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - }, - ideaLog: { create: vi.fn() }, - claudeJob: { - findFirst: vi.fn(), - create: vi.fn(), - update: vi.fn(), - }, - claudeWorker: { - count: vi.fn(), - }, - pbi: { - findFirst: vi.fn(), - findMany: vi.fn(), - findUnique: vi.fn(), - create: vi.fn(), - delete: vi.fn(), - }, - story: { - findMany: vi.fn(), - create: vi.fn(), - }, - task: { - findMany: vi.fn(), - create: vi.fn(), - count: vi.fn(), - findUnique: vi.fn().mockResolvedValue(null), - }, - product: { - findUnique: vi.fn().mockResolvedValue(null), - }, - $transaction: vi.fn(), - $executeRaw: vi.fn().mockResolvedValue(0), - }, -})) - -import { prisma } from '@/lib/prisma' -import { - createIdeaAction, - updateIdeaAction, - archiveIdeaAction, - deleteIdeaAction, - updateGrillMdAction, - updatePlanMdAction, - uploadPlanMdAction, - downloadIdeaMdAction, - startGrillJobAction, - startMakePlanJobAction, - cancelIdeaJobAction, - materializeIdeaPlanAction, - relinkIdeaPlanAction, -} from '@/actions/ideas' - -type MockIdea = { - idea: { create: ReturnType<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> } - ideaLog: { create: ReturnType<typeof vi.fn> } - claudeJob: { findFirst: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } - claudeWorker: { count: ReturnType<typeof vi.fn> } - pbi: { findFirst: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn>; findUnique: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; delete: ReturnType<typeof vi.fn> } - story: { findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn> } - task: { findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; count: ReturnType<typeof vi.fn> } - $transaction: ReturnType<typeof vi.fn> - $executeRaw: ReturnType<typeof vi.fn> -} -const m = prisma as unknown as MockIdea - -beforeEach(() => { - vi.clearAllMocks() - mockSession.userId = 'user-1' - mockSession.isDemo = false - // Default: $transaction passes its callback through with our mocked prisma - m.$transaction.mockImplementation(async (arg: unknown) => { - if (typeof arg === 'function') { - return (arg as (tx: unknown) => unknown)(m) - } - return arg - }) -}) - -describe('createIdeaAction', () => { - it('happy path: creates DRAFT idea with auto-generated code', async () => { - m.idea.create.mockResolvedValueOnce({ id: 'idea-1', code: 'IDEA-001' }) - - const r = await createIdeaAction({ title: 'Plant-watering reminder' }) - expect(r).toEqual({ success: true, data: { id: 'idea-1', code: 'IDEA-001' } }) - expect(m.idea.create).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - user_id: 'user-1', - code: 'IDEA-001', - title: 'Plant-watering reminder', - status: 'DRAFT', - }), - }), - ) - }) - - it('rejects unauthenticated', async () => { - mockSession.userId = '' - const r = await createIdeaAction({ title: 'x' }) - expect(r).toMatchObject({ error: expect.stringMatching(/ingelogd/), code: 401 }) - expect(m.idea.create).not.toHaveBeenCalled() - }) - - it('rejects demo-user', async () => { - mockSession.isDemo = true - const r = await createIdeaAction({ title: 'x' }) - expect(r).toMatchObject({ error: expect.stringMatching(/demo/), code: 403 }) - expect(m.idea.create).not.toHaveBeenCalled() - }) - - it('rejects invalid title (zod 422)', async () => { - const r = await createIdeaAction({ title: ' ' }) - expect(r).toMatchObject({ code: 422 }) - expect(m.idea.create).not.toHaveBeenCalled() - }) -}) - -describe('updateIdeaAction', () => { - it('happy: updates editable idea (DRAFT)', async () => { - m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'DRAFT' }) - m.idea.update.mockResolvedValueOnce({}) - - const r = await updateIdeaAction('idea-1', { title: 'Updated' }) - expect(r).toEqual({ success: true }) - expect(m.idea.update).toHaveBeenCalledWith({ - where: { id: 'idea-1' }, - data: { title: 'Updated' }, - }) - }) - - it('blocks update on PLANNED (status-mismatch 422)', async () => { - m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'PLANNED' }) - const r = await updateIdeaAction('idea-1', { title: 'x' }) - expect(r).toMatchObject({ code: 422 }) - expect(m.idea.update).not.toHaveBeenCalled() - }) - - it('blocks update during GRILLING', async () => { - m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'GRILLING' }) - const r = await updateIdeaAction('idea-1', { title: 'x' }) - expect(r).toMatchObject({ code: 422 }) - }) - - it('returns 404 when idea belongs to another user', async () => { - m.idea.findFirst.mockResolvedValueOnce(null) - const r = await updateIdeaAction('idea-1', { title: 'x' }) - expect(r).toMatchObject({ code: 404 }) - }) -}) - -describe('deleteIdeaAction', () => { - it('happy: deletes idea without pbi', async () => { - m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', pbi_id: null }) - const r = await deleteIdeaAction('idea-1') - expect(r).toEqual({ success: true }) - expect(m.idea.delete).toHaveBeenCalledWith({ where: { id: 'idea-1' } }) - }) - - it('blocks deletion when PBI is linked', async () => { - m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', pbi_id: 'pbi-1' }) - const r = await deleteIdeaAction('idea-1') - expect(r).toMatchObject({ code: 422 }) - expect(m.idea.delete).not.toHaveBeenCalled() - }) -}) - -describe('archiveIdeaAction', () => { - it('archives owned idea', async () => { - m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1' }) - const r = await archiveIdeaAction('idea-1') - expect(r).toEqual({ success: true }) - expect(m.idea.update).toHaveBeenCalledWith({ - where: { id: 'idea-1' }, - data: { archived: true }, - }) - }) -}) - -describe('updateGrillMdAction', () => { - it('happy: updates grill_md in GRILLED', async () => { - m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLED' }) - const r = await updateGrillMdAction('idea-1', '# Updated grill') - expect(r).toEqual({ success: true }) - expect(m.$transaction).toHaveBeenCalled() - }) - - it('blocks in DRAFT', async () => { - m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' }) - const r = await updateGrillMdAction('idea-1', 'x') - expect(r).toMatchObject({ code: 422 }) - expect(m.$transaction).not.toHaveBeenCalled() - }) -}) - -describe('updatePlanMdAction', () => { - const VALID_PLAN = `--- -pbi: - title: Test - priority: 2 -stories: - - title: S1 - priority: 2 - tasks: - - title: T1 - priority: 2 ---- - -body -` - - it('happy: updates plan_md in PLAN_READY with valid yaml', async () => { - m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_READY' }) - const r = await updatePlanMdAction('idea-1', VALID_PLAN) - expect(r).toEqual({ success: true }) - }) - - it('rejects invalid yaml (parse-fail 422 with details)', async () => { - m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_READY' }) - const r = await updatePlanMdAction('idea-1', '# no frontmatter') - expect(r).toMatchObject({ code: 422 }) - expect((r as { details?: unknown }).details).toBeDefined() - }) - - it('blocks in PLANNED', async () => { - m.idea.findFirst.mockResolvedValueOnce({ status: 'PLANNED' }) - const r = await updatePlanMdAction('idea-1', VALID_PLAN) - expect(r).toMatchObject({ code: 422 }) - }) -}) - -describe('uploadPlanMdAction', () => { - const VALID_PLAN = `--- -pbi: - title: Uploaded - priority: 2 -stories: - - title: S1 - priority: 2 - tasks: - - title: T1 - priority: 2 ---- - -body -` - - it('happy: uploads from DRAFT — skips grill, sets PLAN_READY', async () => { - m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' }) - const r = await uploadPlanMdAction('idea-1', VALID_PLAN) - expect(r).toEqual({ success: true }) - expect(m.$transaction).toHaveBeenCalled() - const txnArg = m.$transaction.mock.calls.at(-1)?.[0] as unknown[] | undefined - expect(txnArg).toBeDefined() - // The first call in the transaction is the update — confirm status=PLAN_READY. - expect(m.idea.update).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ plan_md: VALID_PLAN, status: 'PLAN_READY' }), - }), - ) - }) - - it('happy: uploads from GRILLED', async () => { - m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLED' }) - const r = await uploadPlanMdAction('idea-1', VALID_PLAN) - expect(r).toEqual({ success: true }) - }) - - it('happy: overwrites existing plan from PLAN_READY', async () => { - m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_READY' }) - const r = await uploadPlanMdAction('idea-1', VALID_PLAN) - expect(r).toEqual({ success: true }) - }) - - it('happy: uploads from PLAN_FAILED (retry)', async () => { - m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_FAILED' }) - const r = await uploadPlanMdAction('idea-1', VALID_PLAN) - expect(r).toEqual({ success: true }) - }) - - it('rejects from PLANNED (already materialized)', async () => { - m.idea.findFirst.mockResolvedValueOnce({ status: 'PLANNED' }) - const r = await uploadPlanMdAction('idea-1', VALID_PLAN) - expect(r).toMatchObject({ code: 422 }) - expect(m.$transaction).not.toHaveBeenCalled() - }) - - it('rejects from GRILLING (job running)', async () => { - m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLING' }) - const r = await uploadPlanMdAction('idea-1', VALID_PLAN) - expect(r).toMatchObject({ code: 422 }) - }) - - it('rejects empty markdown', async () => { - const r = await uploadPlanMdAction('idea-1', ' \n ') - expect(r).toMatchObject({ code: 422 }) - // Should fail before touching DB - expect(m.idea.findFirst).not.toHaveBeenCalled() - }) - - it('rejects oversized markdown', async () => { - const huge = 'a'.repeat(100_001) - const r = await uploadPlanMdAction('idea-1', huge) - expect(r).toMatchObject({ code: 422 }) - expect(m.idea.findFirst).not.toHaveBeenCalled() - }) - - it('rejects invalid yaml (parse-fail 422 with details)', async () => { - m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' }) - const r = await uploadPlanMdAction('idea-1', '# no frontmatter') - expect(r).toMatchObject({ code: 422 }) - expect((r as { details?: unknown }).details).toBeDefined() - expect(m.$transaction).not.toHaveBeenCalled() - }) - - it('returns 404 when idea not found', async () => { - m.idea.findFirst.mockResolvedValueOnce(null) - const r = await uploadPlanMdAction('nope', VALID_PLAN) - expect(r).toMatchObject({ code: 404 }) - }) -}) - -describe('startGrillJobAction', () => { - const idea = { - id: 'idea-1', - status: 'DRAFT', - product_id: 'prod-1', - product: { id: 'prod-1', repo_url: 'https://github.com/x/y' }, - } - - beforeEach(() => { - m.idea.findFirst.mockResolvedValue(idea) - m.claudeJob.findFirst.mockResolvedValue(null) - m.claudeWorker.count.mockResolvedValue(1) - m.claudeJob.create.mockResolvedValue({ id: 'job-1' }) - }) - - it('happy path: creates IDEA_GRILL job, flips status to GRILLING', async () => { - const r = await startGrillJobAction('idea-1') - expect(r).toMatchObject({ success: true, data: { job_id: 'job-1' } }) - expect(m.$executeRaw).toHaveBeenCalled() - }) - - it('blocks demo-user', async () => { - mockSession.isDemo = true - const r = await startGrillJobAction('idea-1') - expect(r).toMatchObject({ code: 403 }) - expect(m.claudeJob.create).not.toHaveBeenCalled() - }) - - it('blocks when product has no repo_url', async () => { - m.idea.findFirst.mockResolvedValueOnce({ - ...idea, - product: { id: 'prod-1', repo_url: null }, - }) - const r = await startGrillJobAction('idea-1') - expect(r).toMatchObject({ code: 422, error: expect.stringMatching(/repo_url/i) }) - }) - - it('blocks when no idea is unlinked', async () => { - m.idea.findFirst.mockResolvedValueOnce({ ...idea, product_id: null, product: null }) - const r = await startGrillJobAction('idea-1') - expect(r).toMatchObject({ code: 422 }) - }) - - it('blocks when no worker is active', async () => { - m.claudeWorker.count.mockResolvedValueOnce(0) - const r = await startGrillJobAction('idea-1') - expect(r).toMatchObject({ code: 422, error: expect.stringMatching(/worker/i) }) - expect(m.claudeJob.create).not.toHaveBeenCalled() - }) - - it('blocks when an active job already exists (409)', async () => { - m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'existing-job' }) - const r = await startGrillJobAction('idea-1') - expect(r).toMatchObject({ code: 409 }) - }) - - it('blocks invalid status (PLANNING)', async () => { - m.idea.findFirst.mockResolvedValueOnce({ ...idea, status: 'PLANNING' }) - const r = await startGrillJobAction('idea-1') - expect(r).toMatchObject({ code: 422 }) - }) -}) - -describe('startMakePlanJobAction', () => { - const idea = { - id: 'idea-1', - status: 'GRILLED', - product_id: 'prod-1', - product: { id: 'prod-1', repo_url: 'https://github.com/x/y' }, - } - - beforeEach(() => { - m.idea.findFirst.mockResolvedValue(idea) - m.claudeJob.findFirst.mockResolvedValue(null) - m.claudeWorker.count.mockResolvedValue(1) - m.claudeJob.create.mockResolvedValue({ id: 'job-2' }) - }) - - it('happy: GRILLED → PLANNING', async () => { - const r = await startMakePlanJobAction('idea-1') - expect(r).toMatchObject({ success: true }) - }) - - it('blocks from DRAFT (must grill first)', async () => { - m.idea.findFirst.mockResolvedValueOnce({ ...idea, status: 'DRAFT' }) - const r = await startMakePlanJobAction('idea-1') - expect(r).toMatchObject({ code: 422 }) - }) -}) - -describe('cancelIdeaJobAction', () => { - it('grill cancel without prior grill_md → DRAFT', async () => { - m.idea.findFirst.mockResolvedValueOnce({ - id: 'idea-1', - status: 'GRILLING', - grill_md: null, - plan_md: null, - }) - m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'job-1', kind: 'IDEA_GRILL' }) - - const r = await cancelIdeaJobAction('idea-1') - expect(r).toEqual({ success: true }) - // Verify $transaction was called with 3 ops (job-update, idea-update, log) - expect(m.$transaction).toHaveBeenCalled() - }) - - it('grill re-grill cancel with prior grill_md → GRILLED', async () => { - m.idea.findFirst.mockResolvedValueOnce({ - id: 'idea-1', - status: 'GRILLING', - grill_md: '# old grill', - plan_md: null, - }) - m.claudeJob.findFirst.mockResolvedValueOnce({ id: 'job-1', kind: 'IDEA_GRILL' }) - - const r = await cancelIdeaJobAction('idea-1') - expect(r).toEqual({ success: true }) - }) - - it('returns 404 when no active job', async () => { - m.idea.findFirst.mockResolvedValueOnce({ - id: 'idea-1', - status: 'GRILLED', - grill_md: null, - plan_md: null, - }) - m.claudeJob.findFirst.mockResolvedValueOnce(null) - const r = await cancelIdeaJobAction('idea-1') - expect(r).toMatchObject({ code: 404 }) - }) -}) - -describe('materializeIdeaPlanAction', () => { - const VALID_PLAN = `--- -pbi: - title: New PBI - priority: 2 -stories: - - title: Story A - priority: 2 - tasks: - - title: Task A1 - priority: 2 - implementation_plan: "1. Doe X" - - title: Task A2 - priority: 2 - - title: Story B - priority: 3 - tasks: - - title: Task B1 - priority: 3 ---- - -body -` - - beforeEach(() => { - m.idea.findFirst.mockResolvedValue({ - id: 'idea-1', - status: 'PLAN_READY', - product_id: 'prod-1', - plan_md: VALID_PLAN, - }) - m.pbi.findMany.mockResolvedValue([]) - m.story.findMany.mockResolvedValue([]) - m.task.findMany.mockResolvedValue([]) - m.pbi.findFirst.mockResolvedValue(null) - m.pbi.create.mockResolvedValue({ id: 'pbi-1', code: 'PBI-1' }) - m.story.create - .mockResolvedValueOnce({ id: 's-A' }) - .mockResolvedValueOnce({ id: 's-B' }) - m.task.create - .mockResolvedValueOnce({ id: 't-A1' }) - .mockResolvedValueOnce({ id: 't-A2' }) - .mockResolvedValueOnce({ id: 't-B1' }) - }) - - it('happy: creates PBI + 2 stories + 3 tasks, links idea, returns ids; sort_order = parseCodeNumber(code)', async () => { - const r = await materializeIdeaPlanAction('idea-1') - expect(r).toMatchObject({ - success: true, - data: { - pbi_id: 'pbi-1', - pbi_code: 'PBI-1', - story_ids: ['s-A', 's-B'], - task_ids: ['t-A1', 't-A2', 't-B1'], - }, - }) - expect(m.pbi.create).toHaveBeenCalledTimes(1) - expect(m.story.create).toHaveBeenCalledTimes(2) - expect(m.task.create).toHaveBeenCalledTimes(3) - - // story sort_order = parseCodeNumber(auto-code): ST-001→1, ST-002→2 - expect(m.story.create.mock.calls[0][0].data.sort_order).toBe(1) - expect(m.story.create.mock.calls[1][0].data.sort_order).toBe(2) - - // task sort_order = parseCodeNumber(auto-code): T-1→1, T-2→2, T-3→3 - expect(m.task.create.mock.calls[0][0].data.sort_order).toBe(1) - expect(m.task.create.mock.calls[1][0].data.sort_order).toBe(2) - expect(m.task.create.mock.calls[2][0].data.sort_order).toBe(3) - }) - - it('blocks when not PLAN_READY (e.g. GRILLED)', async () => { - m.idea.findFirst.mockResolvedValueOnce({ - id: 'idea-1', - status: 'GRILLED', - product_id: 'prod-1', - plan_md: VALID_PLAN, - }) - const r = await materializeIdeaPlanAction('idea-1') - expect(r).toMatchObject({ code: 422 }) - expect(m.pbi.create).not.toHaveBeenCalled() - }) - - it('returns 422 with details on parse-fail', async () => { - m.idea.findFirst.mockResolvedValueOnce({ - id: 'idea-1', - status: 'PLAN_READY', - product_id: 'prod-1', - plan_md: '# no frontmatter', - }) - const r = await materializeIdeaPlanAction('idea-1') - expect(r).toMatchObject({ code: 422 }) - expect((r as { details?: unknown }).details).toBeDefined() - }) - - it('blocks demo-user', async () => { - mockSession.isDemo = true - const r = await materializeIdeaPlanAction('idea-1') - expect(r).toMatchObject({ code: 403 }) - }) - - it('returns 409 on P2002 race', async () => { - m.$transaction.mockImplementationOnce(async () => { - throw new Error('Unique constraint failed (P2002)') - }) - const r = await materializeIdeaPlanAction('idea-1') - expect(r).toMatchObject({ code: 409 }) - }) -}) - -describe('materializeIdeaPlanAction — existing PBI pre-check', () => { - const VALID_PLAN = `--- -pbi: - title: New PBI - priority: 2 -stories: - - title: Story A - priority: 2 - tasks: - - title: Task A1 - priority: 2 ---- - -body -` - - beforeEach(() => { - // Use a distinct userId to avoid sharing the rate-limit bucket with the - // materializeIdeaPlanAction describe block above. - mockSession.userId = 'user-precheck' - m.idea.findFirst.mockResolvedValue({ - id: 'idea-1', - status: 'PLAN_READY', - product_id: 'prod-1', - plan_md: VALID_PLAN, - pbi_id: 'old-pbi', - }) - m.pbi.findMany.mockResolvedValue([]) - m.story.findMany.mockResolvedValue([]) - m.task.findMany.mockResolvedValue([]) - m.pbi.findFirst.mockResolvedValue(null) - m.pbi.findUnique.mockResolvedValue({ code: 'PBI-X' }) - m.pbi.create.mockResolvedValue({ id: 'pbi-new', code: 'PBI-2' }) - m.pbi.delete.mockResolvedValue({}) - m.story.create.mockResolvedValue({ id: 's-1' }) - m.task.create.mockResolvedValue({ id: 't-1' }) - }) - - it('auto-vervang: deletes old PBI in transaction when no tasks executed', async () => { - m.task.count.mockResolvedValueOnce(0) - const r = await materializeIdeaPlanAction('idea-1') - expect(r).toMatchObject({ success: true, data: { pbi_id: 'pbi-new' } }) - expect(m.pbi.delete).toHaveBeenCalledWith({ where: { id: 'old-pbi' } }) - expect(m.pbi.create).toHaveBeenCalledTimes(1) - }) - - it('conflict-409: returns PBI_HAS_ACTIVE_TASKS when executed tasks exist', async () => { - m.task.count.mockResolvedValueOnce(1) - const r = await materializeIdeaPlanAction('idea-1') - expect(r).toMatchObject({ code: 409, error: 'PBI_HAS_ACTIVE_TASKS:PBI-X' }) - expect(m.pbi.create).not.toHaveBeenCalled() - expect(m.pbi.delete).not.toHaveBeenCalled() - }) - - it('alongside: skips old PBI delete and creates new PBI when allowAlongside=true', async () => { - m.task.count.mockResolvedValueOnce(1) - const r = await materializeIdeaPlanAction('idea-1', { allowAlongside: true }) - expect(r).toMatchObject({ success: true, data: { pbi_id: 'pbi-new' } }) - expect(m.pbi.delete).not.toHaveBeenCalled() - expect(m.pbi.create).toHaveBeenCalledTimes(1) - }) -}) - -describe('relinkIdeaPlanAction', () => { - it('happy: PLANNED with pbi_id=null → PLAN_READY', async () => { - m.idea.findFirst.mockResolvedValueOnce({ - id: 'idea-1', - status: 'PLANNED', - pbi_id: null, - }) - const r = await relinkIdeaPlanAction('idea-1') - expect(r).toEqual({ success: true }) - expect(m.$transaction).toHaveBeenCalled() - }) - - it('blocks when pbi still linked', async () => { - m.idea.findFirst.mockResolvedValueOnce({ - id: 'idea-1', - status: 'PLANNED', - pbi_id: 'pbi-1', - }) - const r = await relinkIdeaPlanAction('idea-1') - expect(r).toMatchObject({ code: 422 }) - }) - - it('blocks when not PLANNED', async () => { - m.idea.findFirst.mockResolvedValueOnce({ - id: 'idea-1', - status: 'PLAN_READY', - pbi_id: null, - }) - const r = await relinkIdeaPlanAction('idea-1') - expect(r).toMatchObject({ code: 422 }) - }) -}) - -describe('downloadIdeaMdAction', () => { - it('returns grill_md when present', async () => { - m.idea.findFirst.mockResolvedValueOnce({ - code: 'IDEA-001', - grill_md: '# Idee\nscope', - plan_md: null, - }) - const r = await downloadIdeaMdAction('idea-1', 'grill') - expect(r).toMatchObject({ - success: true, - data: { filename: 'IDEA-001-grill.md', markdown: '# Idee\nscope' }, - }) - }) - - it('404 when md not yet generated', async () => { - m.idea.findFirst.mockResolvedValueOnce({ - code: 'IDEA-001', - grill_md: null, - plan_md: null, - }) - const r = await downloadIdeaMdAction('idea-1', 'plan') - expect(r).toMatchObject({ code: 404 }) - }) - - it('demo MAY download (read-only operation)', async () => { - mockSession.isDemo = true - m.idea.findFirst.mockResolvedValueOnce({ - code: 'IDEA-001', - grill_md: 'x', - plan_md: null, - }) - const r = await downloadIdeaMdAction('idea-1', 'grill') - expect(r).toMatchObject({ success: true }) - }) -}) diff --git a/__tests__/actions/products.test.ts b/__tests__/actions/products.test.ts deleted file mode 100644 index ed538e0..0000000 --- a/__tests__/actions/products.test.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -const { - mockGetSession, - mockFindFirstProduct, - mockCreateProduct, - mockUpdateProduct, - mockCreateMember, - mockExecuteRaw, - mockTransaction, -} = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockFindFirstProduct: vi.fn(), - mockCreateProduct: vi.fn(), - mockUpdateProduct: vi.fn(), - mockCreateMember: vi.fn(), - mockExecuteRaw: vi.fn().mockResolvedValue(undefined), - mockTransaction: vi.fn(), -})) - -vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('next/navigation', () => ({ redirect: vi.fn() })) -vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) -vi.mock('iron-session', () => ({ - getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), -})) -vi.mock('@/lib/session', () => ({ - sessionOptions: { cookieName: 'test', password: 'test' }, -})) -vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) -vi.mock('@/lib/product-access', () => ({ - productAccessFilter: vi.fn().mockReturnValue({ OR: [{ user_id: 'user-1' }] }), -})) -vi.mock('@/lib/prisma', () => ({ - prisma: { - product: { findFirst: mockFindFirstProduct, create: mockCreateProduct, update: mockUpdateProduct }, - productMember: { create: mockCreateMember }, - $executeRaw: mockExecuteRaw, - $transaction: mockTransaction, - }, -})) - -import { createProductAction, updateProductAction } from '@/actions/products' -import { getIronSession } from 'iron-session' - -const mockSession = getIronSession as ReturnType<typeof vi.fn> - -const SESSION_USER = { userId: 'user-1', isDemo: false } -const SESSION_DEMO = { userId: 'demo-1', isDemo: true } -const PRODUCT_ID = 'product-1' - -const VALID_DATA = { - name: 'Test Product', - code: 'TP', - description: 'Een product', - repo_url: 'https://github.com/org/repo', - definition_of_done: 'Alles groen', - auto_pr: false, -} - -beforeEach(() => { - vi.clearAllMocks() - mockExecuteRaw.mockResolvedValue(undefined) - mockSession.mockResolvedValue(SESSION_USER) -}) - -// ============================================================= -// createProductAction -// ============================================================= -describe('createProductAction', () => { - it('happy path: maakt product + member aan en retourneert productId', async () => { - mockFindFirstProduct.mockResolvedValue(null) // geen dubbele code - mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) => { - return fn({ - product: { - create: vi.fn().mockResolvedValue({ id: PRODUCT_ID }), - }, - productMember: { - create: vi.fn().mockResolvedValue({}), - }, - }) - }) - - const result = await createProductAction(VALID_DATA) - - expect(result).toEqual({ success: true, productId: PRODUCT_ID }) - }) - - it('demo-user → error', async () => { - mockSession.mockResolvedValue(SESSION_DEMO) - - const result = await createProductAction(VALID_DATA) - - expect(result).toMatchObject({ error: expect.stringContaining('demo') }) - expect(mockTransaction).not.toHaveBeenCalled() - }) - - it('ongeldige repo_url (niet github) → validatiefout', async () => { - const result = await createProductAction({ ...VALID_DATA, repo_url: 'https://gitlab.com/org/repo' }) - - expect(result).toMatchObject({ error: expect.any(String) }) - expect(mockTransaction).not.toHaveBeenCalled() - }) - - it('dubbele code → error', async () => { - mockFindFirstProduct.mockResolvedValue({ id: 'other-product' }) - - const result = await createProductAction(VALID_DATA) - - expect(result).toMatchObject({ - code: 422, - fieldErrors: { code: expect.arrayContaining([expect.stringContaining('gebruik')]) }, - }) - expect(mockTransaction).not.toHaveBeenCalled() - }) - - it('naam ontbreekt → validatiefout', async () => { - const result = await createProductAction({ ...VALID_DATA, name: '' }) - - expect(result).toMatchObject({ error: expect.any(String) }) - }) -}) - -// ============================================================= -// updateProductAction -// ============================================================= -describe('updateProductAction', () => { - it('happy path: werkt product bij en stuurt pg_notify', async () => { - mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID }) - mockUpdateProduct.mockResolvedValue({ id: PRODUCT_ID }) - - const result = await updateProductAction(PRODUCT_ID, VALID_DATA) - - expect(result).toEqual({ success: true }) - expect(mockUpdateProduct).toHaveBeenCalled() - expect(mockExecuteRaw).toHaveBeenCalledTimes(1) - }) - - it('demo-user → error', async () => { - mockSession.mockResolvedValue(SESSION_DEMO) - - const result = await updateProductAction(PRODUCT_ID, VALID_DATA) - - expect(result).toMatchObject({ error: expect.stringContaining('demo') }) - expect(mockUpdateProduct).not.toHaveBeenCalled() - }) - - it('geen toegang tot product → error', async () => { - mockFindFirstProduct.mockResolvedValue(null) - - const result = await updateProductAction(PRODUCT_ID, VALID_DATA) - - expect(result).toMatchObject({ error: expect.stringContaining('toegang') }) - expect(mockUpdateProduct).not.toHaveBeenCalled() - }) - - it('ongeldige repo_url → validatiefout', async () => { - const result = await updateProductAction(PRODUCT_ID, { ...VALID_DATA, repo_url: 'https://bitbucket.org/x' }) - - expect(result).toMatchObject({ error: expect.any(String) }) - expect(mockUpdateProduct).not.toHaveBeenCalled() - }) -}) diff --git a/__tests__/actions/push.test.ts b/__tests__/actions/push.test.ts deleted file mode 100644 index 1e74a22..0000000 --- a/__tests__/actions/push.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -const { mockGetSession } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), -})) - -vi.mock('@/lib/auth', () => ({ - getSession: mockGetSession, -})) - -const { mockUpsert, mockDeleteMany } = vi.hoisted(() => ({ - mockUpsert: vi.fn(), - mockDeleteMany: vi.fn(), -})) - -vi.mock('@/lib/prisma', () => ({ - prisma: { - pushSubscription: { - upsert: mockUpsert, - deleteMany: mockDeleteMany, - }, - }, -})) - -import { subscribeToPushAction, unsubscribeFromPushAction } from '@/actions/push' - -const VALID_INPUT = { - endpoint: 'https://push.example.com/subscription/abc123', - keys: { p256dh: 'aBcDeFgH', auth: 'xYzAbC' }, -} - -const SESSION_USER = { userId: 'user-1', isDemo: false } -const SESSION_DEMO = { userId: 'demo-1', isDemo: true } - -beforeEach(() => { - vi.clearAllMocks() - mockUpsert.mockResolvedValue({}) - mockDeleteMany.mockResolvedValue({ count: 1 }) -}) - -describe('subscribeToPushAction', () => { - it('upserts subscription for authenticated user', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - await subscribeToPushAction(VALID_INPUT) - expect(mockUpsert).toHaveBeenCalledWith( - expect.objectContaining({ - where: { endpoint: VALID_INPUT.endpoint }, - create: expect.objectContaining({ user_id: 'user-1', endpoint: VALID_INPUT.endpoint }), - }) - ) - }) - - it('is idempotent — calling twice upserts twice without error', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - await subscribeToPushAction(VALID_INPUT) - await subscribeToPushAction(VALID_INPUT) - expect(mockUpsert).toHaveBeenCalledTimes(2) - }) - - it('returns without writing for demo user', async () => { - mockGetSession.mockResolvedValue(SESSION_DEMO) - await subscribeToPushAction(VALID_INPUT) - expect(mockUpsert).not.toHaveBeenCalled() - }) - - it('returns without writing when not authenticated', async () => { - mockGetSession.mockResolvedValue({}) - await subscribeToPushAction(VALID_INPUT) - expect(mockUpsert).not.toHaveBeenCalled() - }) - - it('returns without writing for invalid input', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - // @ts-expect-error intentionally invalid - await subscribeToPushAction({ endpoint: 'not-a-url', keys: {} }) - expect(mockUpsert).not.toHaveBeenCalled() - }) -}) - -describe('unsubscribeFromPushAction', () => { - it('deletes subscription scoped to user_id', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - await unsubscribeFromPushAction({ endpoint: VALID_INPUT.endpoint }) - expect(mockDeleteMany).toHaveBeenCalledWith({ - where: { endpoint: VALID_INPUT.endpoint, user_id: 'user-1' }, - }) - }) - - it('does not touch subscriptions of other users', async () => { - mockGetSession.mockResolvedValue({ userId: 'other-user', isDemo: false }) - await unsubscribeFromPushAction({ endpoint: VALID_INPUT.endpoint }) - expect(mockDeleteMany).toHaveBeenCalledWith( - expect.objectContaining({ where: expect.objectContaining({ user_id: 'other-user' }) }) - ) - }) - - it('returns without writing when not authenticated', async () => { - mockGetSession.mockResolvedValue({}) - await unsubscribeFromPushAction({ endpoint: VALID_INPUT.endpoint }) - expect(mockDeleteMany).not.toHaveBeenCalled() - }) -}) diff --git a/__tests__/actions/questions.test.ts b/__tests__/actions/questions.test.ts index ece85ff..22dd33d 100644 --- a/__tests__/actions/questions.test.ts +++ b/__tests__/actions/questions.test.ts @@ -16,9 +16,6 @@ vi.mock('@/lib/prisma', () => ({ findFirst: vi.fn(), updateMany: vi.fn(), }, - product: { - findFirst: vi.fn().mockResolvedValue({ id: 'product-1' }), - }, }, })) @@ -47,13 +44,7 @@ beforeEach(() => { describe('actions/questions — answerQuestion', () => { it('happy: status pending→answered, revalidatePath geroepen', async () => { mockGetSession.mockResolvedValue(SESSION_USER) - mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ - id: VALID_ID, - story_id: 'story-1', - idea_id: null, - product_id: 'product-1', - idea: null, - }) + mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ id: VALID_ID }) // access-check mockPrisma.claudeQuestion.updateMany.mockResolvedValueOnce({ count: 1 }) const res = await answerQuestion(VALID_ID, VALID_ANSWER) @@ -94,13 +85,7 @@ describe('actions/questions — answerQuestion', () => { it('al-answered: race-error met begrijpelijke melding', async () => { mockGetSession.mockResolvedValue(SESSION_USER) - mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ - id: VALID_ID, - story_id: 'story-1', - idea_id: null, - product_id: 'product-1', - idea: null, - }) + mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ id: VALID_ID }) // access-check mockPrisma.claudeQuestion.updateMany.mockResolvedValueOnce({ count: 0 }) mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ status: 'answered', @@ -114,13 +99,7 @@ describe('actions/questions — answerQuestion', () => { it('verlopen: updateMany count=0, nog open status maar voorbij expiry', async () => { mockGetSession.mockResolvedValue(SESSION_USER) - mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ - id: VALID_ID, - story_id: 'story-1', - idea_id: null, - product_id: 'product-1', - idea: null, - }) + mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ id: VALID_ID }) mockPrisma.claudeQuestion.updateMany.mockResolvedValueOnce({ count: 0 }) mockPrisma.claudeQuestion.findFirst.mockResolvedValueOnce({ status: 'open', diff --git a/__tests__/actions/settings.test.ts b/__tests__/actions/settings.test.ts deleted file mode 100644 index 415b059..0000000 --- a/__tests__/actions/settings.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -const { mockUserUpdate, mockGetIronSession } = vi.hoisted(() => ({ - mockUserUpdate: vi.fn(), - mockGetIronSession: vi.fn(), -})) - -vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) -vi.mock('iron-session', () => ({ getIronSession: mockGetIronSession })) -vi.mock('@/lib/session', () => ({ sessionOptions: { cookieName: 'test', password: 'test' } })) -vi.mock('@/lib/prisma', () => ({ - prisma: { user: { update: mockUserUpdate } }, -})) - -import { updateMinQuotaPctAction } from '@/actions/settings' - -const SESSION_USER = { userId: 'user-1', isDemo: false } -const SESSION_DEMO = { userId: 'demo-1', isDemo: true } -const SESSION_UNAUTH = { userId: undefined, isDemo: false } - -describe('updateMinQuotaPctAction', () => { - beforeEach(() => { - vi.clearAllMocks() - mockUserUpdate.mockResolvedValue({}) - }) - - it('returns error when not authenticated', async () => { - mockGetIronSession.mockResolvedValue(SESSION_UNAUTH) - const result = await updateMinQuotaPctAction(20) - expect(result).toMatchObject({ error: expect.any(String) }) - expect(mockUserUpdate).not.toHaveBeenCalled() - }) - - it('returns 403 error for demo session', async () => { - mockGetIronSession.mockResolvedValue(SESSION_DEMO) - const result = await updateMinQuotaPctAction(20) - expect(result).toMatchObject({ status: 403 }) - expect(mockUserUpdate).not.toHaveBeenCalled() - }) - - it('returns 422 error when value is 0 (below min)', async () => { - mockGetIronSession.mockResolvedValue(SESSION_USER) - const result = await updateMinQuotaPctAction(0) - expect(result).toMatchObject({ status: 422 }) - expect(mockUserUpdate).not.toHaveBeenCalled() - }) - - it('returns 422 error when value is 101 (above max)', async () => { - mockGetIronSession.mockResolvedValue(SESSION_USER) - const result = await updateMinQuotaPctAction(101) - expect(result).toMatchObject({ status: 422 }) - expect(mockUserUpdate).not.toHaveBeenCalled() - }) - - it('saves valid value and returns success', async () => { - mockGetIronSession.mockResolvedValue(SESSION_USER) - const result = await updateMinQuotaPctAction(35) - expect(result).toEqual({ success: true }) - expect(mockUserUpdate).toHaveBeenCalledWith({ - where: { id: 'user-1' }, - data: { min_quota_pct: 35 }, - }) - }) - - it('accepts boundary values 1 and 100', async () => { - mockGetIronSession.mockResolvedValue(SESSION_USER) - await updateMinQuotaPctAction(1) - await updateMinQuotaPctAction(100) - expect(mockUserUpdate).toHaveBeenCalledTimes(2) - }) -}) diff --git a/__tests__/actions/sprint-dates.test.ts b/__tests__/actions/sprint-dates.test.ts index af2474f..6cb59c2 100644 --- a/__tests__/actions/sprint-dates.test.ts +++ b/__tests__/actions/sprint-dates.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({ set: vi.fn(), get: vi.fn(), delete: vi.fn() }) })) +vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) vi.mock('iron-session', () => ({ getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), })) @@ -16,22 +16,16 @@ vi.mock('@/lib/prisma', () => ({ prisma: { sprint: { findFirst: vi.fn(), - findMany: vi.fn(), create: vi.fn(), update: vi.fn(), }, - user: { - findUnique: vi.fn().mockResolvedValue({ settings: {} }), - update: vi.fn().mockResolvedValue({}), - }, - $executeRaw: vi.fn().mockResolvedValue(1), }, })) import { prisma } from '@/lib/prisma' import { createSprintAction, updateSprintDatesAction } from '@/actions/sprints' -const mockSprint = prisma as unknown as { sprint: { findFirst: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } } +const mockSprint = prisma as unknown as { sprint: { findFirst: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } } function makeFormData(data: Record<string, string | null>) { const fd = new FormData() @@ -45,7 +39,6 @@ describe('createSprintAction — date validation', () => { beforeEach(() => { vi.clearAllMocks() mockSprint.sprint.findFirst.mockResolvedValue(null) - mockSprint.sprint.findMany.mockResolvedValue([]) mockSprint.sprint.create.mockResolvedValue({ id: 'sprint-1' }) }) @@ -60,9 +53,10 @@ describe('createSprintAction — date validation', () => { it('rejects end_date before start_date', async () => { const fd = makeFormData({ productId: 'product-1', sprint_goal: 'Doel', start_date: '2026-05-14', end_date: '2026-05-01' }) - const result = await createSprintAction(undefined, fd) as { code?: number; fieldErrors?: Record<string, string[]> } - expect(result.code).toBe(422) - expect(result.fieldErrors?.end_date?.[0]).toContain('Einddatum') + const result = await createSprintAction(undefined, fd) + expect(result.error).toBeTruthy() + const errors = result.error as Record<string, string[]> + expect(errors.end_date?.[0]).toContain('Einddatum') }) it('accepts no dates (both optional)', async () => { @@ -87,9 +81,10 @@ describe('updateSprintDatesAction — date validation', () => { it('rejects end_date before start_date', async () => { const fd = makeFormData({ id: 'sprint-1', start_date: '2026-05-10', end_date: '2026-05-05' }) - const result = await updateSprintDatesAction(undefined, fd) as { code?: number; fieldErrors?: Record<string, string[]> } - expect(result.code).toBe(422) - expect(result.fieldErrors?.end_date?.[0]).toContain('Einddatum') + const result = await updateSprintDatesAction(undefined, fd) + expect(result.error).toBeTruthy() + const errors = result.error as Record<string, string[]> + expect(errors.end_date?.[0]).toContain('Einddatum') }) it('blocks demo users', async () => { diff --git a/__tests__/actions/sprint-draft.test.ts b/__tests__/actions/sprint-draft.test.ts deleted file mode 100644 index f6fa3b1..0000000 --- a/__tests__/actions/sprint-draft.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('next/headers', () => ({ - cookies: vi.fn().mockResolvedValue({ - set: vi.fn(), - get: vi.fn(), - delete: vi.fn(), - }), -})) -vi.mock('iron-session', () => ({ - getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), -})) -vi.mock('@/lib/session', () => ({ - sessionOptions: { cookieName: 'test', password: 'test' }, -})) -vi.mock('@/lib/product-access', () => ({ - productAccessFilter: vi.fn().mockReturnValue({}), -})) -vi.mock('@/lib/prisma', () => ({ - prisma: { - product: { findFirst: vi.fn() }, - user: { - findUnique: vi.fn(), - update: vi.fn().mockResolvedValue({}), - }, - $executeRaw: vi.fn().mockResolvedValue(1), - }, -})) - -import { prisma } from '@/lib/prisma' -import { - clearPendingSprintDraftAction, - setPendingSprintDraftAction, -} from '@/actions/sprint-draft' -import type { PendingSprintDraft, UserSettings } from '@/lib/user-settings' - -const mockPrisma = prisma as unknown as { - product: { findFirst: ReturnType<typeof vi.fn> } - user: { - findUnique: ReturnType<typeof vi.fn> - update: ReturnType<typeof vi.fn> - } -} - -const validDraft: PendingSprintDraft = { - goal: 'Sprint 1', - pbiIntent: { pbiA: 'all' }, - storyOverrides: { pbiA: { add: [], remove: ['story-1'] } }, -} - -describe('setPendingSprintDraftAction', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPrisma.product.findFirst.mockReset() - mockPrisma.user.findUnique.mockReset() - mockPrisma.user.update.mockReset().mockResolvedValue({}) - }) - - it('persists draft for accessible product', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) - mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} }) - - const result = await setPendingSprintDraftAction('p1', validDraft) - - expect(result).toEqual({ success: true }) - const updateArg = mockPrisma.user.update.mock.calls[0][0] as { - data: { settings: UserSettings } - } - expect(updateArg.data.settings.workflow?.pendingSprintDraft?.p1).toMatchObject({ - goal: 'Sprint 1', - pbiIntent: { pbiA: 'all' }, - }) - }) - - it('preserves drafts for other products', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) - mockPrisma.user.findUnique.mockResolvedValueOnce({ - settings: { - workflow: { - pendingSprintDraft: { - p2: { goal: 'P2 draft', pbiIntent: {}, storyOverrides: {} }, - }, - }, - }, - }) - - await setPendingSprintDraftAction('p1', validDraft) - - const updateArg = mockPrisma.user.update.mock.calls[0][0] as { - data: { settings: UserSettings } - } - const drafts = updateArg.data.settings.workflow?.pendingSprintDraft - expect(Object.keys(drafts ?? {})).toEqual(expect.arrayContaining(['p1', 'p2'])) - }) - - it('rejects invalid draft (empty goal)', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) - - const result = await setPendingSprintDraftAction('p1', { - ...validDraft, - goal: '', - } as PendingSprintDraft) - - expect(result).toHaveProperty('error') - expect(mockPrisma.user.update).not.toHaveBeenCalled() - }) - - it('rejects when product not accessible', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce(null) - - const result = await setPendingSprintDraftAction('p1', validDraft) - - expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' }) - expect(mockPrisma.user.update).not.toHaveBeenCalled() - }) -}) - -describe('clearPendingSprintDraftAction', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPrisma.product.findFirst.mockReset() - mockPrisma.user.findUnique.mockReset() - mockPrisma.user.update.mockReset().mockResolvedValue({}) - }) - - it('removes draft key for product', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) - mockPrisma.user.findUnique.mockResolvedValueOnce({ - settings: { - workflow: { - pendingSprintDraft: { - p1: { goal: 'gone', pbiIntent: {}, storyOverrides: {} }, - p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} }, - }, - }, - }, - }) - - await clearPendingSprintDraftAction('p1') - - const updateArg = mockPrisma.user.update.mock.calls[0][0] as { - data: { settings: UserSettings } - } - expect(updateArg.data.settings.workflow?.pendingSprintDraft).toEqual({ - p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} }, - }) - }) - - it('is a no-op when there is no draft for the product', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) - mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} }) - - const result = await clearPendingSprintDraftAction('p1') - - expect(result).toEqual({ success: true }) - expect(mockPrisma.user.update).not.toHaveBeenCalled() - }) - - it('rejects when product not accessible', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce(null) - - const result = await clearPendingSprintDraftAction('p1') - - expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' }) - }) -}) diff --git a/__tests__/actions/sprint-runs.test.ts b/__tests__/actions/sprint-runs.test.ts deleted file mode 100644 index acf4396..0000000 --- a/__tests__/actions/sprint-runs.test.ts +++ /dev/null @@ -1,407 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) -vi.mock('iron-session', () => ({ - getIronSession: vi.fn(), -})) -vi.mock('@/lib/session', () => ({ - sessionOptions: { cookieName: 'test', password: 'test' }, -})) - -vi.mock('@/lib/prisma', () => ({ - prisma: { - sprint: { - findUnique: vi.fn(), - update: vi.fn(), - }, - sprintRun: { - findFirst: vi.fn(), - findUnique: vi.fn(), - create: vi.fn(), - update: vi.fn(), - }, - story: { - findMany: vi.fn(), - updateMany: vi.fn(), - }, - pbi: { - updateMany: vi.fn(), - }, - task: { - updateMany: vi.fn(), - findUnique: vi.fn().mockResolvedValue(null), - }, - claudeQuestion: { - findMany: vi.fn(), - }, - claudeJob: { - create: vi.fn(), - updateMany: vi.fn(), - }, - product: { - findUnique: vi.fn().mockResolvedValue(null), - }, - $transaction: vi.fn(), - }, -})) - -import { prisma } from '@/lib/prisma' -import { getIronSession } from 'iron-session' -import { - startSprintRunAction, - resumeSprintAction, - cancelSprintRunAction, -} from '@/actions/sprint-runs' - -const mockSession = getIronSession as ReturnType<typeof vi.fn> - -type Mocked = { - sprint: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } - sprintRun: { - findFirst: ReturnType<typeof vi.fn> - findUnique: ReturnType<typeof vi.fn> - create: ReturnType<typeof vi.fn> - update: ReturnType<typeof vi.fn> - } - story: { - findMany: ReturnType<typeof vi.fn> - updateMany: ReturnType<typeof vi.fn> - } - pbi: { updateMany: ReturnType<typeof vi.fn> } - task: { updateMany: ReturnType<typeof vi.fn> } - claudeQuestion: { findMany: ReturnType<typeof vi.fn> } - claudeJob: { - create: ReturnType<typeof vi.fn> - updateMany: ReturnType<typeof vi.fn> - } - $transaction: ReturnType<typeof vi.fn> -} -const mockPrisma = prisma as unknown as Mocked - -const SPRINT_OK = { - id: 'sprint-1', - status: 'OPEN', - product_id: 'prod-1', - product: { id: 'prod-1', pr_strategy: 'SPRINT' }, -} - -const STORY_OK = { - id: 'story-1', - pbi_id: 'pbi-1', - priority: 1, - sort_order: 1, - pbi: { - id: 'pbi-1', - code: 'PBI-1', - title: 'PBI', - status: 'READY', - priority: 1, - sort_order: 1, - }, - tasks: [ - { id: 'task-1', code: 'T-1', title: 'T1', priority: 1, sort_order: 1, implementation_plan: 'plan' }, - { id: 'task-2', code: 'T-2', title: 'T2', priority: 1, sort_order: 2, implementation_plan: 'plan' }, - ], -} - -beforeEach(() => { - vi.clearAllMocks() - mockSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) - mockPrisma.$transaction.mockImplementation( - async (run: (tx: typeof prisma) => Promise<unknown>) => run(prisma), - ) -}) - -describe('startSprintRunAction — happy path', () => { - it('maakt SprintRun + 2 ClaudeJobs voor 2 TO_DO tasks', async () => { - mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK) - mockPrisma.sprintRun.findFirst.mockResolvedValue(null) - mockPrisma.story.findMany.mockResolvedValue([STORY_OK]) - mockPrisma.claudeQuestion.findMany.mockResolvedValue([]) - mockPrisma.sprintRun.create.mockResolvedValue({ id: 'run-1' }) - mockPrisma.claudeJob.create.mockResolvedValue({ id: 'job-x' }) - - const result = await startSprintRunAction({ sprint_id: 'sprint-1' }) - - expect(result).toEqual({ ok: true, sprint_run_id: 'run-1', jobs_count: 2 }) - expect(mockPrisma.sprintRun.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - sprint_id: 'sprint-1', - started_by_id: 'user-1', - status: 'QUEUED', - pr_strategy: 'SPRINT', - }), - }) - expect(mockPrisma.claudeJob.create).toHaveBeenCalledTimes(2) - }) -}) - -describe('startSprintRunAction — pre-flight blockers', () => { - it('blokkeert wanneer task geen implementation_plan heeft', async () => { - mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK) - mockPrisma.sprintRun.findFirst.mockResolvedValue(null) - mockPrisma.story.findMany.mockResolvedValue([ - { - ...STORY_OK, - tasks: [ - { id: 'task-1', code: 'T-1', title: 'T1', priority: 1, sort_order: 1, implementation_plan: null }, - ], - }, - ]) - mockPrisma.claudeQuestion.findMany.mockResolvedValue([]) - - const result = await startSprintRunAction({ sprint_id: 'sprint-1' }) - - expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' }) - if (result.ok === false && 'blockers' in result) { - expect(result.blockers).toContainEqual({ - type: 'task_no_plan', - id: 'task-1', - label: 'T-1: T1', - }) - } - expect(mockPrisma.sprintRun.create).not.toHaveBeenCalled() - }) - - it('blokkeert wanneer er een open ClaudeQuestion in scope is', async () => { - mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK) - mockPrisma.sprintRun.findFirst.mockResolvedValue(null) - mockPrisma.story.findMany.mockResolvedValue([STORY_OK]) - mockPrisma.claudeQuestion.findMany.mockResolvedValue([ - { id: 'q-1', question: 'Welke route?' }, - ]) - - const result = await startSprintRunAction({ sprint_id: 'sprint-1' }) - - expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' }) - if (result.ok === false && 'blockers' in result) { - expect(result.blockers).toContainEqual({ - type: 'open_question', - id: 'q-1', - label: 'Welke route?', - }) - } - }) - - it('blokkeert wanneer een PBI BLOCKED of FAILED is', async () => { - mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK) - mockPrisma.sprintRun.findFirst.mockResolvedValue(null) - mockPrisma.story.findMany.mockResolvedValue([ - { ...STORY_OK, pbi: { ...STORY_OK.pbi, status: 'BLOCKED' } }, - ]) - mockPrisma.claudeQuestion.findMany.mockResolvedValue([]) - - const result = await startSprintRunAction({ sprint_id: 'sprint-1' }) - - expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' }) - if (result.ok === false && 'blockers' in result) { - expect(result.blockers).toContainEqual({ - type: 'pbi_blocked', - id: 'pbi-1', - label: 'PBI-1: PBI', - }) - } - }) -}) - -describe('startSprintRunAction — SPRINT_BATCH', () => { - const SPRINT_BATCH = { - ...SPRINT_OK, - product: { - id: 'prod-1', - pr_strategy: 'SPRINT_BATCH', - repo_url: 'https://github.com/example/main', - }, - } - - it('blokkeert task met afwijkende repo_url', async () => { - mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_BATCH) - mockPrisma.sprintRun.findFirst.mockResolvedValue(null) - mockPrisma.story.findMany.mockResolvedValue([ - { - ...STORY_OK, - tasks: [ - { - id: 'task-1', - code: 'T-1', - title: 'In main repo', - priority: 1, - sort_order: 1, - implementation_plan: 'plan', - repo_url: null, - }, - { - id: 'task-2', - code: 'T-2', - title: 'Cross-repo', - priority: 1, - sort_order: 2, - implementation_plan: 'plan', - repo_url: 'https://github.com/example/other', - }, - ], - }, - ]) - mockPrisma.claudeQuestion.findMany.mockResolvedValue([]) - - const result = await startSprintRunAction({ sprint_id: 'sprint-1' }) - - expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' }) - if (result.ok === false && 'blockers' in result) { - expect(result.blockers).toContainEqual({ - type: 'task_cross_repo', - id: 'task-2', - label: 'T-2: Cross-repo', - }) - } - expect(mockPrisma.sprintRun.create).not.toHaveBeenCalled() - }) - - it('staat tasks toe wanneer repo_url leeg is of gelijk aan product.repo_url', async () => { - mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_BATCH) - mockPrisma.sprintRun.findFirst.mockResolvedValue(null) - mockPrisma.story.findMany.mockResolvedValue([ - { - ...STORY_OK, - tasks: [ - { - id: 'task-1', - code: 'T-1', - title: 'No override', - priority: 1, - sort_order: 1, - implementation_plan: 'plan', - repo_url: null, - }, - { - id: 'task-2', - code: 'T-2', - title: 'Same repo', - priority: 1, - sort_order: 2, - implementation_plan: 'plan', - repo_url: 'https://github.com/example/main', - }, - ], - }, - ]) - mockPrisma.claudeQuestion.findMany.mockResolvedValue([]) - mockPrisma.sprintRun.create.mockResolvedValue({ id: 'run-batch' }) - mockPrisma.claudeJob.create.mockResolvedValue({ id: 'job-sprint' }) - - const result = await startSprintRunAction({ sprint_id: 'sprint-1' }) - - expect(result).toMatchObject({ ok: true, sprint_run_id: 'run-batch' }) - // Eén SPRINT_IMPLEMENTATION-job, niet per-task - expect(mockPrisma.claudeJob.create).toHaveBeenCalledTimes(1) - expect(mockPrisma.claudeJob.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - kind: 'SPRINT_IMPLEMENTATION', - sprint_run_id: 'run-batch', - product_id: 'prod-1', - }), - }) - }) -}) - -describe('startSprintRunAction — guards', () => { - it('weigert wanneer Sprint niet ACTIVE is', async () => { - mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'CLOSED' }) - - const result = await startSprintRunAction({ sprint_id: 'sprint-1' }) - expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_ACTIVE' }) - }) - - it('weigert wanneer er al een actieve SprintRun is', async () => { - mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK) - mockPrisma.sprintRun.findFirst.mockResolvedValue({ id: 'run-existing', status: 'RUNNING' }) - - const result = await startSprintRunAction({ sprint_id: 'sprint-1' }) - expect(result).toMatchObject({ ok: false, error: 'SPRINT_RUN_ALREADY_ACTIVE' }) - }) - - it('weigert demo-sessie', async () => { - mockSession.mockResolvedValue({ userId: 'demo', isDemo: true }) - - const result = await startSprintRunAction({ sprint_id: 'sprint-1' }) - expect(result).toMatchObject({ ok: false, code: 403 }) - }) -}) - -describe('resumeSprintAction', () => { - it('zet sprint en cascade-statuses terug en maakt nieuwe SprintRun', async () => { - // Eerste findUnique (resume) ziet de sprint nog op FAILED; - // de tweede call (binnen startSprintRunCore na de update) ziet ACTIVE. - mockPrisma.sprint.findUnique - .mockResolvedValueOnce({ ...SPRINT_OK, status: 'FAILED' }) - .mockResolvedValue(SPRINT_OK) - mockPrisma.sprintRun.findFirst.mockResolvedValue(null) - mockPrisma.story.findMany.mockImplementation(async (args: { select?: { pbi_id?: boolean } }) => { - if (args.select?.pbi_id) return [{ pbi_id: 'pbi-1' }] - return [STORY_OK] - }) - mockPrisma.claudeQuestion.findMany.mockResolvedValue([]) - mockPrisma.sprintRun.create.mockResolvedValue({ id: 'run-2' }) - mockPrisma.claudeJob.create.mockResolvedValue({ id: 'job-x' }) - - const result = await resumeSprintAction({ sprint_id: 'sprint-1' }) - - expect(result).toMatchObject({ ok: true, sprint_run_id: 'run-2' }) - expect(mockPrisma.sprint.update).toHaveBeenCalledWith({ - where: { id: 'sprint-1' }, - data: { status: 'OPEN', completed_at: null }, - }) - expect(mockPrisma.story.updateMany).toHaveBeenCalledWith({ - where: { sprint_id: 'sprint-1', status: 'FAILED' }, - data: { status: 'IN_SPRINT' }, - }) - expect(mockPrisma.task.updateMany).toHaveBeenCalledWith({ - where: { story: { sprint_id: 'sprint-1' }, status: 'FAILED' }, - data: { status: 'TO_DO' }, - }) - }) - - it('weigert als sprint niet FAILED is', async () => { - mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'OPEN' }) - - const result = await resumeSprintAction({ sprint_id: 'sprint-1' }) - expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_FAILED' }) - }) -}) - -describe('cancelSprintRunAction', () => { - it('zet SprintRun op CANCELLED en cancelt openstaande jobs', async () => { - mockPrisma.sprintRun.findUnique.mockResolvedValue({ - id: 'run-1', - status: 'RUNNING', - sprint_id: 'sprint-1', - }) - - const result = await cancelSprintRunAction({ sprint_run_id: 'run-1' }) - - expect(result).toEqual({ ok: true }) - expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith({ - where: { id: 'run-1' }, - data: expect.objectContaining({ status: 'CANCELLED' }), - }) - expect(mockPrisma.claudeJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ - sprint_run_id: 'run-1', - status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] }, - }), - data: expect.objectContaining({ status: 'CANCELLED' }), - })) - }) - - it('weigert wanneer SprintRun al DONE is', async () => { - mockPrisma.sprintRun.findUnique.mockResolvedValue({ - id: 'run-1', - status: 'DONE', - sprint_id: 'sprint-1', - }) - - const result = await cancelSprintRunAction({ sprint_run_id: 'run-1' }) - expect(result).toMatchObject({ ok: false, error: 'SPRINT_RUN_NOT_CANCELLABLE' }) - }) -}) diff --git a/__tests__/actions/sprints-cascade.test.ts b/__tests__/actions/sprints-cascade.test.ts index b501959..b302716 100644 --- a/__tests__/actions/sprints-cascade.test.ts +++ b/__tests__/actions/sprints-cascade.test.ts @@ -49,7 +49,7 @@ const mockPrisma = prisma as unknown as { $transaction: ReturnType<typeof vi.fn> } -const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'OPEN' } +const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'ACTIVE' } beforeEach(() => { vi.clearAllMocks() diff --git a/__tests__/actions/story-claim.test.ts b/__tests__/actions/story-claim.test.ts index bfcc402..6fba5e5 100644 --- a/__tests__/actions/story-claim.test.ts +++ b/__tests__/actions/story-claim.test.ts @@ -50,7 +50,7 @@ const mockRequireProductWriter = requireProductWriter as ReturnType<typeof vi.fn const mockGetIronSession = getIronSession as ReturnType<typeof vi.fn> const STORY = { id: 'story-1', product_id: 'product-1', assignee_id: null } -const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'OPEN' } +const SPRINT = { id: 'sprint-1', product_id: 'product-1', status: 'ACTIVE' } beforeEach(() => { vi.clearAllMocks() diff --git a/__tests__/actions/tasks-dialog.test.ts b/__tests__/actions/tasks-dialog.test.ts index bc3236f..877aac5 100644 --- a/__tests__/actions/tasks-dialog.test.ts +++ b/__tests__/actions/tasks-dialog.test.ts @@ -23,24 +23,6 @@ vi.mock('@/lib/prisma', () => ({ story: { findFirst: vi.fn(), findUniqueOrThrow: vi.fn(), - findMany: vi.fn(), - update: vi.fn(), - }, - pbi: { - findUniqueOrThrow: vi.fn(), - findMany: vi.fn(), - update: vi.fn(), - }, - sprint: { - findUniqueOrThrow: vi.fn(), - update: vi.fn(), - }, - claudeJob: { - findFirst: vi.fn(), - updateMany: vi.fn(), - }, - sprintRun: { - findUnique: vi.fn(), update: vi.fn(), }, $transaction: vi.fn(), @@ -62,24 +44,6 @@ const mockPrisma = prisma as unknown as { story: { findFirst: ReturnType<typeof vi.fn> findUniqueOrThrow: ReturnType<typeof vi.fn> - findMany: ReturnType<typeof vi.fn> - update: ReturnType<typeof vi.fn> - } - pbi: { - findUniqueOrThrow: ReturnType<typeof vi.fn> - findMany: ReturnType<typeof vi.fn> - update: ReturnType<typeof vi.fn> - } - sprint: { - findUniqueOrThrow: ReturnType<typeof vi.fn> - update: ReturnType<typeof vi.fn> - } - claudeJob: { - findFirst: ReturnType<typeof vi.fn> - updateMany: ReturnType<typeof vi.fn> - } - sprintRun: { - findUnique: ReturnType<typeof vi.fn> update: ReturnType<typeof vi.fn> } $transaction: ReturnType<typeof vi.fn> @@ -190,14 +154,7 @@ describe('saveTask — edit met status-promotie', () => { implementation_plan: null, }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ - id: 'story-1', - status: 'IN_SPRINT', - pbi_id: 'pbi-1', - sprint_id: null, - }) - mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }]) - mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) const result = await saveTask( { ...VALID_INPUT, status: 'DONE' }, diff --git a/__tests__/actions/update-sprint.test.ts b/__tests__/actions/update-sprint.test.ts deleted file mode 100644 index f51219d..0000000 --- a/__tests__/actions/update-sprint.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('next/headers', () => ({ - cookies: vi.fn().mockResolvedValue({ - set: vi.fn(), - get: vi.fn(), - delete: vi.fn(), - }), -})) -vi.mock('iron-session', () => ({ - getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), -})) -vi.mock('@/lib/session', () => ({ - sessionOptions: { cookieName: 'test', password: 'test' }, -})) -vi.mock('@/lib/product-access', () => ({ - productAccessFilter: vi.fn().mockReturnValue({}), - getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }), -})) -vi.mock('@/lib/rate-limit', () => ({ - enforceUserRateLimit: vi.fn().mockReturnValue(null), -})) -vi.mock('@/lib/code-server', () => ({ - createWithCodeRetry: vi.fn(), - generateNextSprintCode: vi.fn(), -})) -vi.mock('@/lib/active-sprint', () => ({ - setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined), -})) -vi.mock('@/lib/prisma', () => ({ - prisma: { - sprint: { - findFirst: vi.fn(), - update: vi.fn(), - }, - story: { - findMany: vi.fn(), - updateMany: vi.fn(), - }, - task: { - findMany: vi.fn(), - updateMany: vi.fn(), - }, - $transaction: vi.fn(), - }, -})) - -import { prisma } from '@/lib/prisma' -import { updateSprintAction } from '@/actions/sprints' - -type Mocked = { - sprint: { - findFirst: ReturnType<typeof vi.fn> - update: ReturnType<typeof vi.fn> - } -} -const mockPrisma = prisma as unknown as Mocked - -beforeEach(() => { - vi.clearAllMocks() - mockPrisma.sprint.findFirst.mockReset().mockResolvedValue({ - id: 'sprint-1', - product_id: 'product-1', - }) - mockPrisma.sprint.update.mockReset().mockResolvedValue({}) -}) - -describe('updateSprintAction', () => { - it('updates sprint_goal alone', async () => { - const result = await updateSprintAction({ - sprintId: 'sprint-1', - fields: { goal: 'Nieuw doel' }, - }) - - expect('success' in result).toBe(true) - expect(mockPrisma.sprint.update).toHaveBeenCalledWith({ - where: { id: 'sprint-1' }, - data: { sprint_goal: 'Nieuw doel' }, - }) - }) - - it('updates dates only', async () => { - await updateSprintAction({ - sprintId: 'sprint-1', - fields: { startAt: '2026-06-01', endAt: '2026-06-14' }, - }) - - expect(mockPrisma.sprint.update).toHaveBeenCalledWith({ - where: { id: 'sprint-1' }, - data: { - start_date: new Date('2026-06-01'), - end_date: new Date('2026-06-14'), - }, - }) - }) - - it('accepts null to clear a date', async () => { - await updateSprintAction({ - sprintId: 'sprint-1', - fields: { startAt: null }, - }) - - expect(mockPrisma.sprint.update).toHaveBeenCalledWith({ - where: { id: 'sprint-1' }, - data: { start_date: null }, - }) - }) - - it('rejects when sprint not accessible', async () => { - mockPrisma.sprint.findFirst.mockResolvedValue(null) - - const result = await updateSprintAction({ - sprintId: 'sprint-1', - fields: { goal: 'x' }, - }) - - expect('error' in result).toBe(true) - if ('error' in result) { - expect(result.code).toBe(403) - } - expect(mockPrisma.sprint.update).not.toHaveBeenCalled() - }) - - it('rejects empty goal', async () => { - const result = await updateSprintAction({ - sprintId: 'sprint-1', - fields: { goal: '' }, - }) - - expect('error' in result).toBe(true) - expect(mockPrisma.sprint.update).not.toHaveBeenCalled() - }) - - it('rejects when no fields are supplied', async () => { - const result = await updateSprintAction({ - sprintId: 'sprint-1', - fields: {}, - }) - - // Schema-refine should reject; OR action treats empty data as no-op success. - // Current implementation: refine forces minstens één veld → 422 error. - expect('error' in result).toBe(true) - if ('error' in result) { - expect(result.code).toBe(422) - } - }) -}) diff --git a/__tests__/actions/user-settings.test.ts b/__tests__/actions/user-settings.test.ts deleted file mode 100644 index 1fb53ad..0000000 --- a/__tests__/actions/user-settings.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) -vi.mock('iron-session', () => ({ - getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), -})) -vi.mock('@/lib/session', () => ({ - sessionOptions: { cookieName: 'test', password: 'test' }, -})) -vi.mock('@/lib/prisma', () => ({ - prisma: { - user: { findUnique: vi.fn() }, - $transaction: vi.fn(async (fn: (tx: unknown) => Promise<unknown>) => { - return fn({ - user: { - findUnique: vi.fn().mockResolvedValue({ settings: {} }), - update: vi.fn().mockResolvedValue({}), - }, - }) - }), - $executeRaw: vi.fn().mockResolvedValue(1), - }, -})) - -import { prisma } from '@/lib/prisma' -import { getIronSession } from 'iron-session' -import { updateUserSettingsAction } from '@/actions/user-settings' - -const mockPrisma = prisma as unknown as { - user: { findUnique: ReturnType<typeof vi.fn> } - $transaction: ReturnType<typeof vi.fn> - $executeRaw: ReturnType<typeof vi.fn> -} -const mockGetIronSession = getIronSession as ReturnType<typeof vi.fn> - -beforeEach(() => { - vi.clearAllMocks() - mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) - mockPrisma.$executeRaw.mockResolvedValue(1) -}) - -describe('updateUserSettingsAction', () => { - it('returns 401 when not logged in', async () => { - mockGetIronSession.mockResolvedValue({ userId: undefined, isDemo: false }) - const result = await updateUserSettingsAction({}) - expect(result).toEqual({ error: 'Niet ingelogd', code: 401 }) - }) - - it('returns 403 for demo accounts', async () => { - mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: true }) - const result = await updateUserSettingsAction({}) - expect('error' in result && result.code).toBe(403) - }) - - it('returns 422 when patch is invalid', async () => { - const result = await updateUserSettingsAction({ - views: { sprintBacklog: { filterStatus: 'NONSENSE' } }, - } as never) - expect('error' in result && result.code).toBe(422) - }) - - it('merges with current settings and emits notify on success', async () => { - const existingFindUnique = vi.fn().mockResolvedValue({ - settings: { views: { sprintBacklog: { sort: 'code' } } }, - }) - const update = vi.fn().mockResolvedValue({}) - mockPrisma.$transaction.mockImplementationOnce(async (fn: (tx: unknown) => Promise<unknown>) => { - return fn({ user: { findUnique: existingFindUnique, update } }) - }) - - const result = await updateUserSettingsAction({ - views: { sprintBacklog: { sortDir: 'desc' } }, - }) - - expect('success' in result && result.success).toBe(true) - expect(update).toHaveBeenCalledWith({ - where: { id: 'user-1' }, - data: { settings: { views: { sprintBacklog: { sort: 'code', sortDir: 'desc' } } } }, - }) - expect(mockPrisma.$executeRaw).toHaveBeenCalled() - }) -}) diff --git a/__tests__/api/backlog-realtime.test.ts b/__tests__/api/backlog-realtime.test.ts new file mode 100644 index 0000000..4898cda --- /dev/null +++ b/__tests__/api/backlog-realtime.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() })) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/product-access', () => ({ + getAccessibleProduct: vi.fn(), +})) + +import { getAccessibleProduct } from '@/lib/product-access' +import type { NextRequest } from 'next/server' +import { GET } from '@/app/api/realtime/backlog/route' +import { useBacklogStore } from '@/stores/backlog-store' + +const mockGetAccessibleProduct = getAccessibleProduct as ReturnType<typeof vi.fn> + +function makeReq(productId?: string): NextRequest { + const url = productId + ? `http://localhost/api/realtime/backlog?product_id=${productId}` + : 'http://localhost/api/realtime/backlog' + return { + signal: new AbortController().signal, + nextUrl: new URL(url), + } as unknown as NextRequest +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('GET /api/realtime/backlog', () => { + it('401 when not authenticated', async () => { + mockGetSession.mockResolvedValue({ userId: undefined, isDemo: false }) + const res = await GET(makeReq('prod-1')) + expect(res.status).toBe(401) + expect(mockGetAccessibleProduct).not.toHaveBeenCalled() + }) + + it('400 when product_id is missing', async () => { + mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + const res = await GET(makeReq()) + expect(res.status).toBe(400) + }) + + it('403 when user has no access to the product', async () => { + mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + mockGetAccessibleProduct.mockResolvedValue(null) + const res = await GET(makeReq('prod-1')) + expect(res.status).toBe(403) + expect(mockGetAccessibleProduct).toHaveBeenCalledWith('prod-1', 'user-1') + }) + + it('500 when DIRECT_URL and DATABASE_URL are absent', async () => { + mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + mockGetAccessibleProduct.mockResolvedValue({ id: 'prod-1' }) + + const before = { DIRECT_URL: process.env.DIRECT_URL, DATABASE_URL: process.env.DATABASE_URL } + delete process.env.DIRECT_URL + delete process.env.DATABASE_URL + try { + const res = await GET(makeReq('prod-1')) + expect(res.status).toBe(500) + } finally { + if (before.DIRECT_URL !== undefined) process.env.DIRECT_URL = before.DIRECT_URL + if (before.DATABASE_URL !== undefined) process.env.DATABASE_URL = before.DATABASE_URL + } + }) + + it('demo user is allowed (no 403) when product is accessible', async () => { + mockGetSession.mockResolvedValue({ userId: 'demo-user', isDemo: true }) + mockGetAccessibleProduct.mockResolvedValue({ id: 'prod-1' }) + + const before = { DIRECT_URL: process.env.DIRECT_URL, DATABASE_URL: process.env.DATABASE_URL } + delete process.env.DIRECT_URL + delete process.env.DATABASE_URL + try { + const res = await GET(makeReq('prod-1')) + // Fails at 500 (no DB URL) — not 403, confirming demo user is not blocked + expect(res.status).toBe(500) + } finally { + if (before.DIRECT_URL !== undefined) process.env.DIRECT_URL = before.DIRECT_URL + if (before.DATABASE_URL !== undefined) process.env.DATABASE_URL = before.DATABASE_URL + } + }) +}) + +// shouldEmit scope filter — white-box unit tests +describe('shouldEmit scope filter (via backlog-store reducer)', () => { + it('applyChange: pbi INSERT adds to pbis array', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} }) + const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Test', priority: 2, created_at: new Date(), status: 'ready' as const } + useBacklogStore.getState().applyChange('pbi', 'I', pbi) + expect(useBacklogStore.getState().pbis).toHaveLength(1) + expect(useBacklogStore.getState().pbis[0].id).toBe('pbi-1') + }) + + it('applyChange: pbi UPDATE patches existing pbi', () => { + const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Old', priority: 2, created_at: new Date(), status: 'ready' as const } + useBacklogStore.setState({ pbis: [pbi], storiesByPbi: {}, tasksByStory: {} }) + useBacklogStore.getState().applyChange('pbi', 'U', { id: 'pbi-1', title: 'New' }) + expect(useBacklogStore.getState().pbis[0].title).toBe('New') + }) + + it('applyChange: pbi DELETE removes pbi', () => { + const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Test', priority: 2, created_at: new Date(), status: 'ready' as const } + useBacklogStore.setState({ pbis: [pbi], storiesByPbi: {}, tasksByStory: {} }) + useBacklogStore.getState().applyChange('pbi', 'D', { id: 'pbi-1' }) + expect(useBacklogStore.getState().pbis).toHaveLength(0) + }) + + it('applyChange: story INSERT adds to storiesByPbi', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} }) + const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', created_at: new Date() } + useBacklogStore.getState().applyChange('story', 'I', story) + expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1) + }) + + it('applyChange: story DELETE removes from correct pbi bucket', () => { + const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', created_at: new Date() } + useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [story] }, tasksByStory: {} }) + useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' }) + expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0) + }) + + it('applyChange: task UPDATE patches task across story buckets', () => { + const task = { id: 'task-1', title: 'Old', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: 'story-1', created_at: new Date() } + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [task] } }) + useBacklogStore.getState().applyChange('task', 'U', { id: 'task-1', status: 'IN_PROGRESS' }) + expect(useBacklogStore.getState().tasksByStory['story-1'][0].status).toBe('IN_PROGRESS') + }) +}) diff --git a/__tests__/api/cron-cleanup-agent-artifacts.test.ts b/__tests__/api/cron-cleanup-agent-artifacts.test.ts index bd86923..188c558 100644 --- a/__tests__/api/cron-cleanup-agent-artifacts.test.ts +++ b/__tests__/api/cron-cleanup-agent-artifacts.test.ts @@ -41,7 +41,7 @@ describe('POST /api/cron/cleanup-agent-artifacts', () => { expect(mockPrisma.claudeJob.deleteMany).not.toHaveBeenCalled() }) - it('200 met juiste secret + deleteMany aangeroepen voor FAILED/CANCELLED/SKIPPED ouder dan 7 dagen', async () => { + it('200 met juiste secret + deleteMany aangeroepen voor FAILED/CANCELLED ouder dan 7 dagen', async () => { mockPrisma.claudeJob.deleteMany.mockResolvedValue({ count: 5 }) const res = await POST(makeReq({ authorization: 'Bearer ' + SECRET })) @@ -51,7 +51,7 @@ describe('POST /api/cron/cleanup-agent-artifacts', () => { expect(body.ran_at).toMatch(/^\d{4}-\d{2}-\d{2}T/) const arg = mockPrisma.claudeJob.deleteMany.mock.calls[0][0] - expect(arg.where.status).toEqual({ in: ['FAILED', 'CANCELLED', 'SKIPPED'] }) + expect(arg.where.status).toEqual({ in: ['FAILED', 'CANCELLED'] }) expect(arg.where.finished_at.lt).toBeInstanceOf(Date) // cutoff should be approximately 7 days ago diff --git a/__tests__/api/cross-sprint-blocks.test.ts b/__tests__/api/cross-sprint-blocks.test.ts deleted file mode 100644 index 5447900..0000000 --- a/__tests__/api/cross-sprint-blocks.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('@/lib/prisma', () => ({ - prisma: { - product: { findFirst: vi.fn() }, - story: { findMany: vi.fn() }, - }, -})) - -vi.mock('@/lib/api-auth', () => ({ - authenticateApiRequest: vi.fn(), -})) - -vi.mock('@/lib/product-access', () => ({ - productAccessFilter: vi.fn().mockReturnValue({}), -})) - -import { prisma } from '@/lib/prisma' -import { authenticateApiRequest } from '@/lib/api-auth' -import { GET } from '@/app/api/products/[id]/cross-sprint-blocks/route' - -const mockPrisma = prisma as unknown as { - product: { findFirst: ReturnType<typeof vi.fn> } - story: { findMany: ReturnType<typeof vi.fn> } -} -const mockAuth = authenticateApiRequest as unknown as ReturnType<typeof vi.fn> - -function makeRequest(url: string) { - return new Request(url) -} - -describe('GET /api/products/[id]/cross-sprint-blocks', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPrisma.product.findFirst.mockReset() - mockPrisma.story.findMany.mockReset() - mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' }) - }) - - it('returns blocking sprint info per story for happy path', async () => { - mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) - mockPrisma.story.findMany.mockResolvedValue([ - { - id: 'story-1', - sprint: { id: 'sprint-x', code: 'SP-X' }, - }, - { - id: 'story-2', - sprint: { id: 'sprint-y', code: 'SP-Y' }, - }, - ]) - - const req = makeRequest( - 'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1&pbiIds=pbiA', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - - expect(res.status).toBe(200) - const body = await res.json() - expect(body).toEqual({ - 'story-1': { sprintId: 'sprint-x', sprintName: 'SP-X' }, - 'story-2': { sprintId: 'sprint-y', sprintName: 'SP-Y' }, - }) - }) - - it('rejects when pbiIds is missing', async () => { - const req = makeRequest( - 'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - expect(res.status).toBe(400) - }) - - it('rejects when pbiIds is empty', async () => { - const req = makeRequest( - 'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - expect(res.status).toBe(400) - }) - - it('returns 404 when product is not accessible', async () => { - mockPrisma.product.findFirst.mockResolvedValue(null) - const req = makeRequest( - 'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - expect(res.status).toBe(404) - }) - - it('returns auth error when authenticate fails', async () => { - mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 }) - const req = makeRequest( - 'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - expect(res.status).toBe(401) - }) - - it('passes NOT excludeSprintId to prisma when provided', async () => { - mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) - mockPrisma.story.findMany.mockResolvedValue([]) - - const req = makeRequest( - 'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-active&pbiIds=pbiA', - ) - await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - - const callArg = mockPrisma.story.findMany.mock.calls[0][0] as { - where: Record<string, unknown> - } - expect(callArg.where).toMatchObject({ - pbi_id: { in: ['pbiA'] }, - product_id: 'p1', - sprint_id: { not: null }, - NOT: { sprint_id: 'sp-active' }, - sprint: { status: 'OPEN' }, - }) - }) -}) diff --git a/__tests__/api/ideas.test.ts b/__tests__/api/ideas.test.ts deleted file mode 100644 index 448cc6b..0000000 --- a/__tests__/api/ideas.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('@/lib/prisma', () => ({ - prisma: { - product: { findFirst: vi.fn() }, - idea: { - findFirst: vi.fn(), - findMany: vi.fn(), - create: vi.fn(), - update: vi.fn(), - }, - ideaLog: { findMany: vi.fn() }, - $transaction: vi.fn(), - }, -})) -vi.mock('@/lib/api-auth', () => ({ - authenticateApiRequest: vi.fn(), -})) -vi.mock('@/lib/idea-code-server', () => ({ - nextIdeaCode: vi.fn().mockResolvedValue('IDEA-001'), -})) - -import { prisma } from '@/lib/prisma' -import { authenticateApiRequest } from '@/lib/api-auth' -import { GET as getIdeas, POST as postIdea } from '@/app/api/ideas/route' -import { GET as getIdea, PATCH as patchIdea } from '@/app/api/ideas/[id]/route' - -type M = { - product: { findFirst: ReturnType<typeof vi.fn> } - idea: { findFirst: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn>; create: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> } - ideaLog: { findMany: ReturnType<typeof vi.fn> } - $transaction: ReturnType<typeof vi.fn> -} -const m = prisma as unknown as M -const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn> - -const NOW = new Date('2026-05-04T19:00:00Z') - -const IDEA_ROW = { - id: 'idea-1', - user_id: 'user-1', - code: 'IDEA-001', - title: 'Plant-watering reminder', - description: null, - status: 'DRAFT' as const, - product_id: null, - product: null, - pbi: null, - pbi_id: null, - archived: false, - grill_md: null, - plan_md: null, - created_at: NOW, - updated_at: NOW, -} - -function makeRequest(method: 'GET' | 'POST' | 'PATCH', url: string, body?: unknown): Request { - return new Request(`http://localhost${url}`, { - method, - headers: { - Authorization: 'Bearer test-token', - 'Content-Type': 'application/json', - }, - body: body !== undefined ? JSON.stringify(body) : undefined, - }) -} - -beforeEach(() => { - vi.clearAllMocks() - mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false }) - m.$transaction.mockImplementation(async (arg: unknown) => { - if (typeof arg === 'function') return (arg as (tx: unknown) => unknown)(m) - return arg - }) -}) - -describe('GET /api/ideas', () => { - it('returns user ideas (DTO shape)', async () => { - m.idea.findMany.mockResolvedValueOnce([IDEA_ROW]) - const res = await getIdeas(makeRequest('GET', '/api/ideas')) - expect(res.status).toBe(200) - const body = await res.json() - expect(body.ideas).toHaveLength(1) - expect(body.ideas[0]).toMatchObject({ - id: 'idea-1', - code: 'IDEA-001', - status: 'draft', - has_grill_md: false, - }) - }) - - it('rejects unauthenticated', async () => { - mockAuth.mockResolvedValueOnce({ error: 'Unauthorized', status: 401 }) - const res = await getIdeas(makeRequest('GET', '/api/ideas')) - expect(res.status).toBe(401) - }) - - it('filters by archived=false param', async () => { - m.idea.findMany.mockResolvedValueOnce([]) - await getIdeas(makeRequest('GET', '/api/ideas?archived=false')) - expect(m.idea.findMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ archived: false, user_id: 'user-1' }), - }), - ) - }) -}) - -describe('POST /api/ideas', () => { - it('creates idea and returns 201', async () => { - m.idea.create.mockResolvedValueOnce(IDEA_ROW) - const res = await postIdea(makeRequest('POST', '/api/ideas', { title: 'Plant-watering reminder' })) - expect(res.status).toBe(201) - const body = await res.json() - expect(body.idea).toMatchObject({ id: 'idea-1', code: 'IDEA-001', status: 'draft' }) - }) - - it('rejects demo with 403', async () => { - mockAuth.mockResolvedValueOnce({ userId: 'demo-1', isDemo: true }) - const res = await postIdea(makeRequest('POST', '/api/ideas', { title: 'x' })) - expect(res.status).toBe(403) - }) - - it('rejects empty title with 422', async () => { - const res = await postIdea(makeRequest('POST', '/api/ideas', { title: '' })) - expect(res.status).toBe(422) - }) - - it('rejects malformed JSON with 400', async () => { - const req = new Request('http://localhost/api/ideas', { - method: 'POST', - headers: { Authorization: 'Bearer test', 'Content-Type': 'application/json' }, - body: 'not-json', - }) - const res = await postIdea(req) - expect(res.status).toBe(400) - }) - - it('returns 404 when product_id refers to a foreign product', async () => { - m.product.findFirst.mockResolvedValueOnce(null) - const res = await postIdea( - makeRequest('POST', '/api/ideas', { - title: 'x', - product_id: 'cmohrysyj0000rd17clnjy4tc', - }), - ) - expect(res.status).toBe(404) - }) -}) - -describe('GET /api/ideas/[id]', () => { - it('returns idea + logs', async () => { - m.idea.findFirst.mockResolvedValueOnce(IDEA_ROW) - m.ideaLog.findMany.mockResolvedValueOnce([ - { id: 'l-1', type: 'NOTE', content: 'x', metadata: null, created_at: NOW }, - ]) - const ctx = { params: Promise.resolve({ id: 'idea-1' }) } - const res = await getIdea(makeRequest('GET', '/api/ideas/idea-1'), ctx) - expect(res.status).toBe(200) - const body = await res.json() - expect(body.idea).toMatchObject({ id: 'idea-1' }) - expect(body.logs).toHaveLength(1) - }) - - it('returns 404 (not 403) for foreign user — anti-enumeration', async () => { - m.idea.findFirst.mockResolvedValueOnce(null) - const ctx = { params: Promise.resolve({ id: 'idea-1' }) } - const res = await getIdea(makeRequest('GET', '/api/ideas/idea-1'), ctx) - expect(res.status).toBe(404) - }) -}) - -describe('PATCH /api/ideas/[id]', () => { - const ctx = { params: Promise.resolve({ id: 'idea-1' }) } - - it('updates editable idea', async () => { - m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'DRAFT' }) - m.idea.update.mockResolvedValueOnce({ ...IDEA_ROW, title: 'Updated' }) - const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'Updated' }), ctx) - expect(res.status).toBe(200) - }) - - it('blocks demo with 403', async () => { - mockAuth.mockResolvedValueOnce({ userId: 'demo-1', isDemo: true }) - const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'x' }), ctx) - expect(res.status).toBe(403) - }) - - it('blocks update on PLANNED with 422', async () => { - m.idea.findFirst.mockResolvedValueOnce({ id: 'idea-1', status: 'PLANNED' }) - const res = await patchIdea(makeRequest('PATCH', '/api/ideas/idea-1', { title: 'x' }), ctx) - expect(res.status).toBe(422) - }) -}) diff --git a/__tests__/api/next-story.test.ts b/__tests__/api/next-story.test.ts index fc549d8..cc5a86d 100644 --- a/__tests__/api/next-story.test.ts +++ b/__tests__/api/next-story.test.ts @@ -25,7 +25,7 @@ const mockPrisma = prisma as unknown as { } const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn> -const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'OPEN' } +const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'ACTIVE' } const STORY = { id: 'story-1', title: 'Account aanmaken', @@ -95,7 +95,7 @@ describe('GET /api/products/:id/next-story', () => { expect(data.tasks[0]).toMatchObject({ id: 'task-1', status: 'todo' }) }) - it('queries story ordered by sort_order only', async () => { + it('queries story ordered by priority then sort_order', async () => { mockPrisma.sprint.findFirst.mockResolvedValue(SPRINT) mockPrisma.story.findFirst.mockResolvedValue(STORY) @@ -103,7 +103,7 @@ describe('GET /api/products/:id/next-story', () => { expect(mockPrisma.story.findFirst).toHaveBeenCalledWith( expect.objectContaining({ - orderBy: [{ sort_order: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], }) ) }) diff --git a/__tests__/api/notifications-stream.test.ts b/__tests__/api/notifications-stream.test.ts index 59fd1a8..53fc590 100644 --- a/__tests__/api/notifications-stream.test.ts +++ b/__tests__/api/notifications-stream.test.ts @@ -10,7 +10,6 @@ vi.mock('@/lib/prisma', () => ({ prisma: { product: { findMany: vi.fn() }, claudeQuestion: { findMany: vi.fn() }, - idea: { findMany: vi.fn().mockResolvedValue([]) }, }, })) diff --git a/__tests__/api/push-send.test.ts b/__tests__/api/push-send.test.ts deleted file mode 100644 index 44bc616..0000000 --- a/__tests__/api/push-send.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('server-only', () => ({})) - -const { mockSendPushToUser } = vi.hoisted(() => ({ - mockSendPushToUser: vi.fn(), -})) - -vi.mock('@/lib/push-server', () => ({ - sendPushToUser: mockSendPushToUser, - enabled: true, -})) - -vi.hoisted(() => { - process.env.INTERNAL_PUSH_SECRET = 'a-valid-secret-that-is-at-least-32-chars' -}) - -import { POST } from '@/app/api/internal/push/send/route' - -const VALID_BODY = { - userId: 'user-1', - payload: { title: 'Hello', body: 'World', url: '/dashboard' }, -} -const SECRET = 'a-valid-secret-that-is-at-least-32-chars' - -function makeRequest(body: unknown, bearer?: string) { - return new Request('http://localhost/api/internal/push/send', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(bearer !== undefined ? { Authorization: bearer } : {}), - }, - body: JSON.stringify(body), - }) -} - -beforeEach(() => { - vi.clearAllMocks() - mockSendPushToUser.mockResolvedValue(undefined) -}) - -describe('POST /api/internal/push/send', () => { - it('returns 401 without authorization header', async () => { - const res = await POST(makeRequest(VALID_BODY)) - expect(res.status).toBe(401) - expect(mockSendPushToUser).not.toHaveBeenCalled() - }) - - it('returns 401 with wrong bearer secret', async () => { - const res = await POST(makeRequest(VALID_BODY, 'Bearer wrong-secret')) - expect(res.status).toBe(401) - }) - - it('returns 422 with invalid body', async () => { - const res = await POST(makeRequest({ userId: '', payload: {} }, `Bearer ${SECRET}`)) - expect(res.status).toBe(422) - expect(mockSendPushToUser).not.toHaveBeenCalled() - }) - - it('returns 204 and calls sendPushToUser on success', async () => { - const res = await POST(makeRequest(VALID_BODY, `Bearer ${SECRET}`)) - expect(res.status).toBe(204) - expect(mockSendPushToUser).toHaveBeenCalledWith('user-1', VALID_BODY.payload) - }) - - it('returns 400 for invalid JSON', async () => { - const req = new Request('http://localhost/api/internal/push/send', { - method: 'POST', - headers: { Authorization: `Bearer ${SECRET}`, 'Content-Type': 'application/json' }, - body: 'not-json', - }) - const res = await POST(req) - expect(res.status).toBe(400) - }) -}) diff --git a/__tests__/api/reorder.test.ts b/__tests__/api/reorder.test.ts new file mode 100644 index 0000000..cff62ae --- /dev/null +++ b/__tests__/api/reorder.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ + prisma: { + story: { + findFirst: vi.fn(), + }, + task: { + update: vi.fn(), + }, + $transaction: vi.fn(), + }, +})) + +vi.mock('@/lib/api-auth', () => ({ + authenticateApiRequest: vi.fn(), +})) + +import { prisma } from '@/lib/prisma' +import { authenticateApiRequest } from '@/lib/api-auth' +import { PATCH as patchReorder } from '@/app/api/stories/[id]/tasks/reorder/route' + +const mockPrisma = prisma as unknown as { + story: { findFirst: ReturnType<typeof vi.fn> } + task: { update: ReturnType<typeof vi.fn> } + $transaction: ReturnType<typeof vi.fn> +} +const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn> + +function makeStory(taskIds: string[]) { + return { + id: 'story-1', + product_id: 'prod-1', + tasks: taskIds.map(id => ({ id })), + } +} + +function makeRequest(body: unknown, storyId = 'story-1'): [Request, { params: Promise<{ id: string }> }] { + return [ + new Request(`http://localhost/api/stories/${storyId}/tasks/reorder`, { + method: 'PATCH', + headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id: storyId }) }, + ] +} + +describe('PATCH /api/stories/:id/tasks/reorder', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false }) + mockPrisma.$transaction.mockResolvedValue([]) + mockPrisma.task.update.mockResolvedValue({ id: 'task-1', sort_order: 1 }) + }) + + // TC-RO-06 — body validation fires before story lookup + it('returns 422 when task_ids is an empty array', async () => { + const res = await patchReorder(...makeRequest({ task_ids: [] })) + expect(res.status).toBe(422) + expect(mockPrisma.story.findFirst).not.toHaveBeenCalled() + }) + + // TC-RO-07 + it('returns 422 when task_ids is not an array', async () => { + const res = await patchReorder(...makeRequest({ task_ids: 'task-1' })) + expect(res.status).toBe(422) + expect(mockPrisma.story.findFirst).not.toHaveBeenCalled() + }) + + it('returns 422 when task_ids is missing entirely', async () => { + const res = await patchReorder(...makeRequest({})) + expect(res.status).toBe(422) + }) + + // TC-RO-08 + it('returns 422 when task_ids contains an ID not belonging to the story', async () => { + mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2'])) + + const res = await patchReorder(...makeRequest({ task_ids: ['task-1', 'task-from-other-story'] })) + const data = await res.json() + + expect(res.status).toBe(422) + expect(data.error).toContain('task-from-other-story') + }) + + // TC-RO-09 + it('reorders tasks and returns 200 with success: true', async () => { + mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2', 'task-3'])) + + const res = await patchReorder(...makeRequest({ task_ids: ['task-3', 'task-1', 'task-2'] })) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data).toEqual({ success: true }) + expect(mockPrisma.$transaction).toHaveBeenCalled() + }) + + it('updates each task with its new sort_order index', async () => { + mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2'])) + + await patchReorder(...makeRequest({ task_ids: ['task-2', 'task-1'] })) + + expect(mockPrisma.task.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'task-2' }, data: { sort_order: 1 } }) + ) + expect(mockPrisma.task.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'task-1' }, data: { sort_order: 2 } }) + ) + }) +}) diff --git a/__tests__/api/security.test.ts b/__tests__/api/security.test.ts index 9a1d508..4d37fdd 100644 --- a/__tests__/api/security.test.ts +++ b/__tests__/api/security.test.ts @@ -8,13 +8,10 @@ vi.mock('@/lib/prisma', () => ({ }, sprint: { findFirst: vi.fn(), - findUniqueOrThrow: vi.fn(), - update: vi.fn(), }, story: { findFirst: vi.fn(), findUniqueOrThrow: vi.fn(), - findMany: vi.fn(), update: vi.fn(), }, task: { @@ -22,19 +19,6 @@ vi.mock('@/lib/prisma', () => ({ update: vi.fn(), findMany: vi.fn(), }, - pbi: { - findUniqueOrThrow: vi.fn(), - findMany: vi.fn(), - update: vi.fn(), - }, - claudeJob: { - findFirst: vi.fn(), - updateMany: vi.fn(), - }, - sprintRun: { - findUnique: vi.fn(), - update: vi.fn(), - }, storyLog: { create: vi.fn(), }, @@ -54,20 +38,17 @@ import { authenticateApiRequest } from '@/lib/api-auth' import { GET as getProducts } from '@/app/api/products/route' import { GET as getNextStory } from '@/app/api/products/[id]/next-story/route' import { GET as getSprintTasks } from '@/app/api/sprints/[id]/tasks/route' +import { PATCH as patchReorder } from '@/app/api/stories/[id]/tasks/reorder/route' import { POST as postStoryLog } from '@/app/api/stories/[id]/log/route' import { PATCH as patchTask } from '@/app/api/tasks/[id]/route' +import { POST as postTodo } from '@/app/api/todos/route' const mockPrisma = prisma as unknown as { product: { findMany: ReturnType<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn> } - sprint: { - findFirst: ReturnType<typeof vi.fn> - findUniqueOrThrow: ReturnType<typeof vi.fn> - update: ReturnType<typeof vi.fn> - } + sprint: { findFirst: ReturnType<typeof vi.fn> } story: { findFirst: ReturnType<typeof vi.fn> findUniqueOrThrow: ReturnType<typeof vi.fn> - findMany: ReturnType<typeof vi.fn> update: ReturnType<typeof vi.fn> } task: { @@ -75,19 +56,6 @@ const mockPrisma = prisma as unknown as { update: ReturnType<typeof vi.fn> findMany: ReturnType<typeof vi.fn> } - pbi: { - findUniqueOrThrow: ReturnType<typeof vi.fn> - findMany: ReturnType<typeof vi.fn> - update: ReturnType<typeof vi.fn> - } - claudeJob: { - findFirst: ReturnType<typeof vi.fn> - updateMany: ReturnType<typeof vi.fn> - } - sprintRun: { - findUnique: ReturnType<typeof vi.fn> - update: ReturnType<typeof vi.fn> - } storyLog: { create: ReturnType<typeof vi.fn> } todo: { create: ReturnType<typeof vi.fn> } $transaction: ReturnType<typeof vi.fn> @@ -196,7 +164,7 @@ describe('GET /api/products/:id/next-story', () => { expect.objectContaining({ where: expect.objectContaining({ product_id: 'prod-other', - status: 'OPEN', + status: 'ACTIVE', product: expect.objectContaining({ OR: expect.arrayContaining([{ user_id: 'user-1' }]), }), @@ -275,6 +243,56 @@ describe('GET /api/sprints/:id/tasks', () => { }) }) +// ─── PATCH /api/stories/:id/tasks/reorder ──────────────────────────────────── + +describe('PATCH /api/stories/:id/tasks/reorder', () => { + const VALID_BODY = { task_ids: ['task-x'] } + + // TC-RO-01 + it('returns 401 when no valid token provided', async () => { + mockAuth.mockResolvedValue(UNAUTHORIZED) + const res = await patchReorder( + makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY), + routeCtx('story-1') + ) + expect(res.status).toBe(401) + }) + + // TC-RO-03 + it('returns 403 for demo users', async () => { + mockAuth.mockResolvedValue(DEMO_AUTH) + const res = await patchReorder( + makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY), + routeCtx('story-1') + ) + expect(res.status).toBe(403) + const data = await res.json() + expect(data.error).toBe('Niet beschikbaar in demo-modus') + }) + + // TC-RO-04 / TC-RO-05 + it('returns 404 when story is not accessible to the authenticated user', async () => { + mockAuth.mockResolvedValue(USER_2_AUTH) + mockPrisma.story.findFirst.mockResolvedValue(null) + + const res = await patchReorder( + makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY), + routeCtx('story-1') + ) + expect(res.status).toBe(404) + expect(mockPrisma.story.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: 'story-1', + product: expect.objectContaining({ + OR: expect.arrayContaining([{ user_id: 'user-2' }]), + }), + }), + }) + ) + }) +}) + // ─── POST /api/stories/:id/log ──────────────────────────────────────────────── describe('POST /api/stories/:id/log', () => { @@ -392,14 +410,7 @@ describe('PATCH /api/tasks/:id', () => { implementation_plan: null, }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ - id: 'story-1', - status: 'DONE', - pbi_id: 'pbi-1', - sprint_id: null, - }) - mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }]) - mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) const res = await patchTask( makePatch('http://localhost/api/tasks/task-1', { status: 'done' }), @@ -408,3 +419,46 @@ describe('PATCH /api/tasks/:id', () => { expect(res.status).toBe(200) }) }) + +// ─── POST /api/todos ────────────────────────────────────────────────────────── + +describe('POST /api/todos', () => { + // product_id is required by the Zod schema (z.string().min(1)) + const VALID_BODY = { title: 'Test todo', product_id: 'prod-1' } + + // TC-TD-01 + it('returns 401 when no valid token provided', async () => { + mockAuth.mockResolvedValue(UNAUTHORIZED) + const res = await postTodo(makePost('http://localhost/api/todos', VALID_BODY)) + expect(res.status).toBe(401) + }) + + // TC-TD-03 + it('returns 403 for demo users', async () => { + mockAuth.mockResolvedValue(DEMO_AUTH) + const res = await postTodo(makePost('http://localhost/api/todos', VALID_BODY)) + expect(res.status).toBe(403) + const data = await res.json() + expect(data.error).toBe('Niet beschikbaar in demo-modus') + }) + + // TC-TD-08 + it('returns 404 when product_id belongs to another user', async () => { + mockAuth.mockResolvedValue(USER_2_AUTH) + mockPrisma.product.findFirst.mockResolvedValue(null) + + const res = await postTodo( + makePost('http://localhost/api/todos', { title: 'Todo', product_id: 'prod-owned-by-user-1' }) + ) + expect(res.status).toBe(404) + // Verify it queries by user_id, not productAccessFilter + expect(mockPrisma.product.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: 'prod-owned-by-user-1', + user_id: 'user-2', + }), + }) + ) + }) +}) diff --git a/__tests__/api/sprint-membership-summary.test.ts b/__tests__/api/sprint-membership-summary.test.ts deleted file mode 100644 index c526210..0000000 --- a/__tests__/api/sprint-membership-summary.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('@/lib/prisma', () => ({ - prisma: { - product: { findFirst: vi.fn() }, - story: { groupBy: vi.fn() }, - }, -})) - -vi.mock('@/lib/api-auth', () => ({ - authenticateApiRequest: vi.fn(), -})) - -vi.mock('@/lib/product-access', () => ({ - productAccessFilter: vi.fn().mockReturnValue({}), -})) - -import { prisma } from '@/lib/prisma' -import { authenticateApiRequest } from '@/lib/api-auth' -import { GET } from '@/app/api/products/[id]/sprint-membership-summary/route' - -const mockPrisma = prisma as unknown as { - product: { findFirst: ReturnType<typeof vi.fn> } - story: { groupBy: ReturnType<typeof vi.fn> } -} -const mockAuth = authenticateApiRequest as unknown as ReturnType<typeof vi.fn> - -function makeRequest(url: string) { - return new Request(url) -} - -describe('GET /api/products/[id]/sprint-membership-summary', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPrisma.product.findFirst.mockReset() - mockPrisma.story.groupBy.mockReset() - mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' }) - }) - - it('returns counts per PBI for happy path', async () => { - mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) - mockPrisma.story.groupBy - .mockResolvedValueOnce([ - { pbi_id: 'pbiA', _count: { _all: 5 } }, - { pbi_id: 'pbiB', _count: { _all: 3 } }, - ]) - .mockResolvedValueOnce([{ pbi_id: 'pbiA', _count: { _all: 2 } }]) - - const req = makeRequest( - 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - - expect(res.status).toBe(200) - const body = await res.json() - expect(body).toEqual({ - pbiA: { total: 5, inSprint: 2 }, - pbiB: { total: 3, inSprint: 0 }, - }) - }) - - it('rejects when pbiIds is missing', async () => { - const req = makeRequest( - 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - expect(res.status).toBe(400) - }) - - it('rejects when pbiIds is empty', async () => { - const req = makeRequest( - 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - expect(res.status).toBe(400) - }) - - it('rejects when sprintId is missing', async () => { - const req = makeRequest( - 'http://localhost/api/products/p1/sprint-membership-summary?pbiIds=pbiA', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - expect(res.status).toBe(400) - }) - - it('returns 404 when product is not accessible', async () => { - mockPrisma.product.findFirst.mockResolvedValue(null) - const req = makeRequest( - 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - expect(res.status).toBe(404) - }) - - it('returns auth error when authenticate fails', async () => { - mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 }) - const req = makeRequest( - 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - expect(res.status).toBe(401) - }) - - it('returns zero counts for PBIs without stories', async () => { - mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) - mockPrisma.story.groupBy - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([]) - - const req = makeRequest( - 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - - const body = await res.json() - expect(body).toEqual({ - pbiA: { total: 0, inSprint: 0 }, - pbiB: { total: 0, inSprint: 0 }, - }) - }) -}) diff --git a/__tests__/api/sprint-tasks.test.ts b/__tests__/api/sprint-tasks.test.ts index c3ac8a9..c496e0d 100644 --- a/__tests__/api/sprint-tasks.test.ts +++ b/__tests__/api/sprint-tasks.test.ts @@ -25,7 +25,7 @@ const mockPrisma = prisma as unknown as { } const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn> -const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'OPEN' } +const SPRINT = { id: 'sprint-1', product_id: 'prod-1', status: 'ACTIVE' } function makeTask(n: number) { return { diff --git a/__tests__/api/story-log.test.ts b/__tests__/api/story-log.test.ts index 0a9b5df..2ba3025 100644 --- a/__tests__/api/story-log.test.ts +++ b/__tests__/api/story-log.test.ts @@ -129,7 +129,7 @@ describe('POST /api/stories/:id/log', () => { const res = await postStoryLog( ...makeRequest({ type: 'TEST_RESULT', content: 'Test gefaald.', status: 'FAILED' }) ) - await res.json() + const data = await res.json() expect(res.status).toBe(201) expect(mockPrisma.storyLog.create).toHaveBeenCalledWith( diff --git a/__tests__/api/tasks.test.ts b/__tests__/api/tasks.test.ts index 5862615..3b08da7 100644 --- a/__tests__/api/tasks.test.ts +++ b/__tests__/api/tasks.test.ts @@ -9,24 +9,6 @@ vi.mock('@/lib/prisma', () => ({ }, story: { findUniqueOrThrow: vi.fn(), - findMany: vi.fn(), - update: vi.fn(), - }, - pbi: { - findUniqueOrThrow: vi.fn(), - findMany: vi.fn(), - update: vi.fn(), - }, - sprint: { - findUniqueOrThrow: vi.fn(), - update: vi.fn(), - }, - claudeJob: { - findFirst: vi.fn(), - updateMany: vi.fn(), - }, - sprintRun: { - findUnique: vi.fn(), update: vi.fn(), }, $transaction: vi.fn(), @@ -49,24 +31,6 @@ const mockPrisma = prisma as unknown as { } story: { findUniqueOrThrow: ReturnType<typeof vi.fn> - findMany: ReturnType<typeof vi.fn> - update: ReturnType<typeof vi.fn> - } - pbi: { - findUniqueOrThrow: ReturnType<typeof vi.fn> - findMany: ReturnType<typeof vi.fn> - update: ReturnType<typeof vi.fn> - } - sprint: { - findUniqueOrThrow: ReturnType<typeof vi.fn> - update: ReturnType<typeof vi.fn> - } - claudeJob: { - findFirst: ReturnType<typeof vi.fn> - updateMany: ReturnType<typeof vi.fn> - } - sprintRun: { - findUnique: ReturnType<typeof vi.fn> update: ReturnType<typeof vi.fn> } $transaction: ReturnType<typeof vi.fn> @@ -111,14 +75,7 @@ describe('PATCH /api/tasks/:id', () => { }) // Default sibling state: only this task, already DONE → no story-promotion mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ - id: 'story-1', - status: 'DONE', - pbi_id: 'pbi-1', - sprint_id: null, - }) - mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }]) - mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) // Pass-through for $transaction so tests behave as if Prisma ran the run-fn directly. mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise<unknown>) => { return run(prisma) @@ -233,14 +190,7 @@ describe('PATCH /api/tasks/:id', () => { story_id: 'story-1', }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ - id: 'story-1', - status: 'IN_SPRINT', - pbi_id: 'pbi-1', - sprint_id: null, - }) - mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }]) - mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) const res = await patchTask(...makeRequest({ status: 'done' })) expect(res.status).toBe(200) diff --git a/__tests__/api/todos.test.ts b/__tests__/api/todos.test.ts new file mode 100644 index 0000000..abded32 --- /dev/null +++ b/__tests__/api/todos.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ + prisma: { + product: { + findFirst: vi.fn(), + }, + todo: { + create: vi.fn(), + }, + }, +})) + +vi.mock('@/lib/api-auth', () => ({ + authenticateApiRequest: vi.fn(), +})) + +import { prisma } from '@/lib/prisma' +import { authenticateApiRequest } from '@/lib/api-auth' +import { POST as postTodo } from '@/app/api/todos/route' + +const mockPrisma = prisma as unknown as { + product: { findFirst: ReturnType<typeof vi.fn> } + todo: { create: ReturnType<typeof vi.fn> } +} +const mockAuth = authenticateApiRequest as ReturnType<typeof vi.fn> + +const PRODUCT = { id: 'prod-1', name: 'DevPlanner', archived: false, user_id: 'user-1' } +const TODO_RESULT = { id: 'todo-1', title: 'Test todo', created_at: new Date('2026-04-30T10:00:00Z') } + +function makeRequest(body: unknown): Request { + return new Request('http://localhost/api/todos', { + method: 'POST', + headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('POST /api/todos', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false }) + mockPrisma.product.findFirst.mockResolvedValue(PRODUCT) + mockPrisma.todo.create.mockResolvedValue(TODO_RESULT) + }) + + // TC-TD-04 + it('returns 422 when title is missing', async () => { + const res = await postTodo(makeRequest({ product_id: 'prod-1' })) + expect(res.status).toBe(422) + }) + + // TC-TD-05 + it('returns 422 when title is empty string', async () => { + const res = await postTodo(makeRequest({ title: '', product_id: 'prod-1' })) + expect(res.status).toBe(422) + }) + + it('returns 422 when product_id is missing', async () => { + // product_id is required by the Zod schema (z.string().min(1)) + const res = await postTodo(makeRequest({ title: 'My todo' })) + expect(res.status).toBe(422) + }) + + it('returns 422 when product_id is empty string', async () => { + const res = await postTodo(makeRequest({ title: 'My todo', product_id: '' })) + expect(res.status).toBe(422) + }) + + // TC-TD-07 + it('creates todo with valid product_id and returns 201', async () => { + const res = await postTodo(makeRequest({ title: 'Test todo', product_id: 'prod-1' })) + const data = await res.json() + + expect(res.status).toBe(201) + expect(data).toMatchObject({ id: 'todo-1', title: 'Test todo' }) + expect(data).toHaveProperty('created_at') + expect(mockPrisma.todo.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + user_id: 'user-1', + product_id: 'prod-1', + title: 'Test todo', + }), + }) + ) + }) + + it('queries product by user_id (not productAccessFilter) to enforce ownership', async () => { + await postTodo(makeRequest({ title: 'Test todo', product_id: 'prod-1' })) + + expect(mockPrisma.product.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: 'prod-1', + user_id: 'user-1', + archived: false, + }), + }) + ) + }) + + it('returns 404 when product does not exist or is archived', async () => { + mockPrisma.product.findFirst.mockResolvedValue(null) + + const res = await postTodo(makeRequest({ title: 'My todo', product_id: 'nonexistent' })) + expect(res.status).toBe(404) + }) +}) diff --git a/__tests__/app/api/jobs/job-by-id-route.test.ts b/__tests__/app/api/jobs/job-by-id-route.test.ts deleted file mode 100644 index a9d2c1a..0000000 --- a/__tests__/app/api/jobs/job-by-id-route.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -const { mockGetSession, mockFindFirstJob, mockFindManyPrice } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockFindFirstJob: vi.fn(), - mockFindManyPrice: vi.fn(), -})) - -vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) -vi.mock('@/lib/prisma', () => ({ - prisma: { - claudeJob: { findFirst: mockFindFirstJob }, - modelPrice: { findMany: mockFindManyPrice }, - }, -})) - -import { GET } from '@/app/api/jobs/[id]/route' - -function makeParams(id = 'job-1'): { params: Promise<{ id: string }> } { - return { params: Promise.resolve({ id }) } -} - -function makeRequest(id = 'job-1'): Request { - return new Request(`http://localhost/api/jobs/${id}`) -} - -const RAW_JOB = { - id: 'job-1', - kind: 'TASK_IMPLEMENTATION' as const, - status: 'DONE' as const, - model_id: 'claude-sonnet-4-6', - input_tokens: 100, - output_tokens: 50, - cache_read_tokens: 0, - cache_write_tokens: 0, - branch: 'feat/test', - pr_url: null, - error: null, - summary: 'Done', - verify_result: 'ALIGNED' as const, - started_at: new Date('2026-01-01T10:00:00Z'), - finished_at: new Date('2026-01-01T10:05:00Z'), - created_at: new Date('2026-01-01T09:59:00Z'), - sprint_run_id: null, - task: { - code: 'T-42', - title: 'Some task', - description: null, - implementation_plan: 'Do the thing', - story: { code: 'S-10', pbi: { code: 'PBI-5' } }, - }, - idea: null, - product: { name: 'Scrum4Me', code: 'SCR' }, - sprint_run: null, -} - -describe('GET /api/jobs/:id', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetSession.mockResolvedValue({ userId: 'user-1' }) - mockFindFirstJob.mockResolvedValue(RAW_JOB) - mockFindManyPrice.mockResolvedValue([]) - }) - - it('returns 401 when not logged in', async () => { - mockGetSession.mockResolvedValue({ userId: undefined }) - const res = await GET(makeRequest() as never, makeParams()) - expect(res.status).toBe(401) - const body = await res.json() - expect(body.error).toBeTruthy() - }) - - it('returns 404 when job not found', async () => { - mockFindFirstJob.mockResolvedValue(null) - const res = await GET(makeRequest() as never, makeParams()) - expect(res.status).toBe(404) - const body = await res.json() - expect(body.error).toBeTruthy() - }) - - it('queries with user_id filter to prevent cross-user access', async () => { - await GET(makeRequest() as never, makeParams()) - expect(mockFindFirstJob).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: 'job-1', user_id: 'user-1' }, - }) - ) - }) - - it('returns 200 with mapped job shape including breadcrumb codes', async () => { - const res = await GET(makeRequest() as never, makeParams()) - expect(res.status).toBe(200) - const body = await res.json() - expect(body).toMatchObject({ - id: 'job-1', - kind: 'TASK_IMPLEMENTATION', - status: 'DONE', - taskCode: 'T-42', - taskTitle: 'Some task', - productCode: 'SCR', - storyCode: 'S-10', - pbiCode: 'PBI-5', - branch: 'feat/test', - }) - }) -}) diff --git a/__tests__/app/m-products-page.test.ts b/__tests__/app/m-products-page.test.ts deleted file mode 100644 index 8fd22c5..0000000 --- a/__tests__/app/m-products-page.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Lichte regressie-tests voor de mobile backlog-page. Server-component render -// vereist te veel mocking; we asserten op statische source-eigenschappen die -// kritisch zijn voor de mobile-shell (cookie-key gescheiden, /m/-paden). -import { describe, it, expect } from 'vitest' -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' - -const PAGE = resolve(process.cwd(), 'app/(mobile)/m/products/[id]/page.tsx') -const src = readFileSync(PAGE, 'utf-8') - -describe('mobile backlog page (ST-1137)', () => { - it('gebruikt gescheiden cookie-key (backlog-{id}-mobile)', () => { - // Beslissing C: tab-mode-gebruikers vervuilen desktop-split niet. - expect(src).toMatch(/cookieKey=\{`backlog-\$\{id\}-mobile`\}/) - }) - - it('closePath en TaskDialog redirect blijven onder /m/products/', () => { - expect(src).toContain('const closePath = `/m/products/${id}`') - }) - - it('hergebruikt BacklogHydrationWrapper + BacklogSplitPane (geen content-componenten dupliceren)', () => { - expect(src).toContain('BacklogHydrationWrapper') - expect(src).toContain('BacklogSplitPane') - expect(src).toContain('PbiList') - expect(src).toContain('StoryPanel') - expect(src).toContain('TaskPanel') - }) - - it('auth via requireSession() (gedeelde guard)', () => { - expect(src).toContain("from '@/lib/auth-guard'") - expect(src).toContain('requireSession()') - }) - - it('rendert TaskDialog op ?newTask en EditTaskLoader op ?editTask', () => { - expect(src).toContain('{newTask &&') - expect(src).toContain('{editTask && !newTask &&') - }) -}) diff --git a/__tests__/app/m-solo-page.test.ts b/__tests__/app/m-solo-page.test.ts deleted file mode 100644 index a464eb2..0000000 --- a/__tests__/app/m-solo-page.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -// ST-1138: regressie-vangnet voor mobile solo-page (server component). -import { describe, it, expect } from 'vitest' -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' - -const PAGE = resolve(process.cwd(), 'app/(mobile)/m/products/[id]/solo/page.tsx') -const TASK_DETAIL = resolve(process.cwd(), 'components/solo/task-detail-dialog.tsx') - -describe('mobile solo page (ST-1138)', () => { - const src = readFileSync(PAGE, 'utf-8') - - it('hergebruikt SoloBoard zonder content-aanpassingen', () => { - expect(src).toContain('SoloBoard') - expect(src).toContain("from '@/components/solo/solo-board'") - }) - - it('auth via gedeelde requireSession()', () => { - expect(src).toContain("from '@/lib/auth-guard'") - expect(src).toContain('requireSession()') - }) - - it('geeft NoActiveSprint terug als geen actieve sprint (zelfde gedrag als desktop)', () => { - expect(src).toContain('NoActiveSprint') - }) -}) - -describe('TaskDetailDialog erft mobile-fullscreen (ST-1138 T-332 verify-only)', () => { - // Beslissing A: TaskDetailDialog gebruikt entityDialogContentClasses; mobile-classes - // komen automatisch door uit T-317. Dit test bewijst de wiring blijft staan. - const src = readFileSync(TASK_DETAIL, 'utf-8') - - it('rendert DialogContent met entityDialogContentClasses (geen eigen className-override)', () => { - expect(src).toContain('className={entityDialogContentClasses}') - }) -}) diff --git a/__tests__/components/backlog/backlog-split-pane.test.tsx b/__tests__/components/backlog/backlog-split-pane.test.tsx index 27d7626..f57e53f 100644 --- a/__tests__/components/backlog/backlog-split-pane.test.tsx +++ b/__tests__/components/backlog/backlog-split-pane.test.tsx @@ -1,21 +1,9 @@ // @vitest-environment jsdom -import { describe, it, expect, beforeEach, vi } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { render, screen } from '@testing-library/react' - -vi.mock('@/actions/user-settings', () => ({ - updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }), -})) - -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import { useSelectionStore } from '@/stores/selection-store' import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane' -function setSelection(pbiId: string | null, storyId: string | null) { - useProductWorkspaceStore.setState((s) => { - s.context.activePbiId = pbiId - s.context.activeStoryId = storyId - }) -} - const PANES = [ <div key="a">PBI pane</div>, <div key="b">Stories pane</div>, @@ -34,7 +22,7 @@ function renderPane() { } beforeEach(() => { - setSelection(null, null) + useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null }) // Force mobile viewport Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) window.dispatchEvent(new Event('resize')) @@ -49,7 +37,7 @@ describe('BacklogSplitPane auto-switch', () => { it('auto-switches to tab 1 when PBI is selected', () => { const { rerender } = renderPane() - setSelection('pbi-1', null) + useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: null }) rerender( <BacklogSplitPane panes={PANES} @@ -64,7 +52,7 @@ describe('BacklogSplitPane auto-switch', () => { it('auto-switches to tab 2 when story is selected', () => { const { rerender } = renderPane() - setSelection('pbi-1', 'story-1') + useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' }) rerender( <BacklogSplitPane panes={PANES} @@ -79,11 +67,11 @@ describe('BacklogSplitPane auto-switch', () => { it('switches to tab 1 on cascade-reset (story cleared when new PBI selected)', () => { // Start with story selected (tab 2) - setSelection('pbi-1', 'story-1') + useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' }) const { rerender } = renderPane() // Cascade-reset: new PBI → story clears - setSelection('pbi-2', null) + useSelectionStore.setState({ selectedPbiId: 'pbi-2', selectedStoryId: null }) rerender( <BacklogSplitPane panes={PANES} diff --git a/__tests__/components/backlog/integration.test.tsx b/__tests__/components/backlog/integration.test.tsx index 0a41b94..928ccce 100644 --- a/__tests__/components/backlog/integration.test.tsx +++ b/__tests__/components/backlog/integration.test.tsx @@ -1,11 +1,8 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import type { - BacklogStory, - BacklogTask, -} from '@/stores/product-workspace/types' +import { useSelectionStore } from '@/stores/selection-store' +import { useBacklogStore } from '@/stores/backlog-store' // Mock next/navigation const mockPush = vi.fn() @@ -25,16 +22,15 @@ Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, wri // Mock server actions vi.mock('@/actions/stories', () => ({ + reorderStoriesAction: vi.fn().mockResolvedValue({ success: true }), reorderPbisAction: vi.fn().mockResolvedValue({ success: true }), updatePbiPriorityAction: vi.fn().mockResolvedValue({ success: true }), })) vi.mock('@/actions/pbis', () => ({ deletePbiAction: vi.fn().mockResolvedValue({ success: true }) })) -vi.mock('@/actions/user-settings', () => ({ - updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }), -})) +vi.mock('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) })) vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) -// Mock dnd-kit (still needed for PBI panel which supports drag-and-drop) +// Mock dnd-kit vi.mock('@dnd-kit/core', () => ({ DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>, PointerSensor: class {}, @@ -65,40 +61,19 @@ const PBI_ID = 'pbi-1' const ALT_PBI_ID = 'pbi-2' const STORY_ID = 'story-1' -const STORIES: BacklogStory[] = [ - { id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, sort_order: 1, status: 'OPEN', pbi_id: PBI_ID, sprint_id: null, created_at: new Date() }, +const STORIES = [ + { id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: PBI_ID, created_at: new Date() }, ] -const TASKS: BacklogTask[] = [ - { id: 'task-1', code: null, title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, +const TASKS = [ + { id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, ] function resetStores() { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = null - s.context.activePbiId = null - s.context.activeStoryId = null - s.context.activeTaskId = null - s.entities.pbisById = {} - s.entities.storiesById = Object.fromEntries(STORIES.map((st) => [st.id, st])) - s.entities.tasksById = Object.fromEntries(TASKS.map((t) => [t.id, t])) - s.relations.pbiIds = [] - s.relations.storyIdsByPbi = { [PBI_ID]: STORIES.map((st) => st.id) } - s.relations.taskIdsByStory = { [STORY_ID]: TASKS.map((t) => t.id) } - }) -} - -function selectPbi(pbiId: string | null) { - useProductWorkspaceStore.setState((s) => { - s.context.activePbiId = pbiId - s.context.activeStoryId = null - s.context.activeTaskId = null - }) -} - -function selectStory(pbiId: string | null, storyId: string | null) { - useProductWorkspaceStore.setState((s) => { - s.context.activePbiId = pbiId - s.context.activeStoryId = storyId + useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null }) + useBacklogStore.setState({ + pbis: [], + storiesByPbi: { [PBI_ID]: STORIES }, + tasksByStory: { [STORY_ID]: TASKS }, }) } @@ -114,40 +89,42 @@ describe('Backlog 3-pane integration', () => { }) it('StoryPanel shows stories when PBI is selected', () => { - selectPbi(PBI_ID) + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null }) render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />) expect(screen.getByText('Eerste story')).toBeTruthy() }) - it('clicking a story dispatches setActiveStory to the workspace-store', () => { - selectPbi(PBI_ID) + it('clicking a story dispatches selectStory to the store', () => { + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null }) render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />) fireEvent.click(screen.getByText('Eerste story')) - expect(useProductWorkspaceStore.getState().context.activeStoryId).toBe(STORY_ID) + expect(useSelectionStore.getState().selectedStoryId).toBe(STORY_ID) }) - it('cascade-reset: selecting different PBI clears activeStoryId', () => { - selectStory(PBI_ID, STORY_ID) - useProductWorkspaceStore.getState().setActivePbi(ALT_PBI_ID) - expect(useProductWorkspaceStore.getState().context.activeStoryId).toBeNull() + it('cascade-reset: selecting different PBI clears selectedStoryId', () => { + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) + useSelectionStore.getState().selectPbi(ALT_PBI_ID) + expect(useSelectionStore.getState().selectedStoryId).toBeNull() }) it('TaskPanel shows tasks after story is selected', () => { - selectStory(PBI_ID, STORY_ID) + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) render(<TaskPanel productId={PRODUCT_ID} isDemo={false} closePath={`/products/${PRODUCT_ID}`} />) expect(screen.getByText('Eerste taak')).toBeTruthy() }) it('TaskPanel shows empty state after cascade-reset', () => { - selectStory(PBI_ID, STORY_ID) + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) render(<TaskPanel productId={PRODUCT_ID} isDemo={false} closePath={`/products/${PRODUCT_ID}`} />) - useProductWorkspaceStore.getState().setActivePbi(ALT_PBI_ID) + // Reset via selectPbi + useSelectionStore.getState().selectPbi(ALT_PBI_ID) + // Re-render reflects new store state render(<TaskPanel productId={PRODUCT_ID} isDemo={false} closePath={`/products/${PRODUCT_ID}`} />) expect(screen.getAllByText('Selecteer een story om de taken te bekijken.').length).toBeGreaterThan(0) }) it('selected story card has isSelected highlight class applied', () => { - selectStory(PBI_ID, STORY_ID) + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) const { container } = render(<StoryPanel productId={PRODUCT_ID} isDemo={false} />) // bg-primary-container is applied when isSelected const selected = container.querySelector('.bg-primary-container') diff --git a/__tests__/components/backlog/new-sprint-trigger.test.tsx b/__tests__/components/backlog/new-sprint-trigger.test.tsx deleted file mode 100644 index 72c669e..0000000 --- a/__tests__/components/backlog/new-sprint-trigger.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { render, screen } from '@testing-library/react' -import '@testing-library/jest-dom' -import type { ReactNode } from 'react' - -const workflowMock: { - value: { pendingSprintDraft?: Record<string, unknown> } | undefined -} = { value: undefined } - -vi.mock('@/stores/user-settings/store', () => ({ - useUserSettingsStore: ( - selector: (s: { - entities: { - settings: { - workflow: { pendingSprintDraft?: Record<string, unknown> } | undefined - } - } - }) => unknown, - ) => selector({ entities: { settings: { workflow: workflowMock.value } } }), -})) - -vi.mock('./new-sprint-metadata-dialog', () => ({ - NewSprintMetadataDialog: () => null, -})) - -vi.mock('@/components/shared/demo-tooltip', () => ({ - DemoTooltip: ({ children }: { children: ReactNode }) => children, -})) - -import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger' - -beforeEach(() => { - workflowMock.value = undefined -}) - -describe('NewSprintTrigger', () => { - it('renders the button on an active product without a draft', () => { - render(<NewSprintTrigger productId="p1" isDemo={false} isActiveProduct={true} />) - expect(screen.getByText('Nieuwe sprint')).toBeInTheDocument() - }) - - it('renders nothing on a non-active product (G6)', () => { - const { container } = render( - <NewSprintTrigger productId="p1" isDemo={false} isActiveProduct={false} />, - ) - expect(container).toBeEmptyDOMElement() - }) - - it('renders nothing when a sprint draft is pending', () => { - workflowMock.value = { pendingSprintDraft: { p1: { goal: 'X' } } } - const { container } = render( - <NewSprintTrigger productId="p1" isDemo={false} isActiveProduct={true} />, - ) - expect(container).toBeEmptyDOMElement() - }) -}) diff --git a/__tests__/components/backlog/task-panel.test.tsx b/__tests__/components/backlog/task-panel.test.tsx index fc5cf7a..97a5894 100644 --- a/__tests__/components/backlog/task-panel.test.tsx +++ b/__tests__/components/backlog/task-panel.test.tsx @@ -1,40 +1,44 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import type { BacklogTask } from '@/stores/product-workspace/types' - -function resetWorkspace() { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = null - s.context.activePbiId = null - s.context.activeStoryId = null - s.context.activeTaskId = null - s.entities.pbisById = {} - s.entities.storiesById = {} - s.entities.tasksById = {} - s.relations.pbiIds = [] - s.relations.storyIdsByPbi = {} - s.relations.taskIdsByStory = {} - }) -} - -function setActiveStoryAndTasks(storyId: string | null, tasks: BacklogTask[] = []) { - useProductWorkspaceStore.setState((s) => { - s.context.activeStoryId = storyId - if (storyId) { - s.relations.taskIdsByStory[storyId] = tasks.map((t) => t.id) - for (const task of tasks) s.entities.tasksById[task.id] = task - } - }) -} +import { useSelectionStore } from '@/stores/selection-store' +import { useBacklogStore } from '@/stores/backlog-store' // Mock next/navigation const mockPush = vi.fn() vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) })) +// Mock reorderTasksAction +vi.mock('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) })) vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) +// Mock dnd-kit to avoid jsdom drag complexity +vi.mock('@dnd-kit/core', () => ({ + DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>, + PointerSensor: class {}, + KeyboardSensor: class {}, + useSensor: vi.fn(), + useSensors: vi.fn(() => []), + closestCenter: vi.fn(), + DragOverlay: () => null, +})) +vi.mock('@dnd-kit/sortable', () => ({ + SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}</>, + useSortable: () => ({ + attributes: {}, listeners: {}, setNodeRef: vi.fn(), + transform: null, transition: undefined, isDragging: false, + }), + rectSortingStrategy: {}, + sortableKeyboardCoordinates: {}, + arrayMove: (arr: unknown[], from: number, to: number) => { + const next = [...arr] + next.splice(from, 1) + next.splice(to, 0, arr[from]) + return next + }, +})) +vi.mock('@dnd-kit/utilities', () => ({ CSS: { Transform: { toString: () => '' } } })) + import { TaskPanel } from '@/components/backlog/task-panel' const PRODUCT_ID = 'prod-1' @@ -42,8 +46,8 @@ const STORY_ID = 'story-1' const CLOSE_PATH = `/products/${PRODUCT_ID}` const TASKS = [ - { id: 'task-1', code: null, title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, - { id: 'task-2', code: null, title: 'Tweede taak', description: null, priority: 3, status: 'IN_PROGRESS', sort_order: 2, story_id: STORY_ID, created_at: new Date() }, + { id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, + { id: 'task-2', title: 'Tweede taak', description: null, priority: 3, status: 'IN_PROGRESS', sort_order: 2, story_id: STORY_ID, created_at: new Date() }, ] function renderPanel(isDemo = false) { @@ -53,7 +57,8 @@ function renderPanel(isDemo = false) { describe('TaskPanel', () => { beforeEach(() => { mockPush.mockClear() - resetWorkspace() + useSelectionStore.setState({ selectedStoryId: null, selectedPbiId: null }) + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} }) }) it('shows empty state when no story is selected', () => { @@ -62,35 +67,40 @@ describe('TaskPanel', () => { }) it('shows empty state with action when story selected but no tasks', () => { - setActiveStoryAndTasks(STORY_ID, []) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } }) renderPanel() expect(screen.getByText('Nog geen taken voor deze story.')).toBeTruthy() expect(screen.getAllByText('+ Nieuwe taak').length).toBeGreaterThanOrEqual(1) }) it('renders task cards when tasks are present', () => { - setActiveStoryAndTasks(STORY_ID, TASKS) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) renderPanel() expect(screen.getByText('Eerste taak')).toBeTruthy() expect(screen.getByText('Tweede taak')).toBeTruthy() }) it('renders status badges on task cards', () => { - setActiveStoryAndTasks(STORY_ID, TASKS) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) renderPanel() expect(screen.getByText('To Do')).toBeTruthy() expect(screen.getByText('Bezig')).toBeTruthy() }) it('task cards are rendered inside a grid container', () => { - setActiveStoryAndTasks(STORY_ID, TASKS) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) const { container } = renderPanel() const grid = container.querySelector('.grid') expect(grid).toBeTruthy() }) it('clicking + button calls router.push with newTask params', () => { - setActiveStoryAndTasks(STORY_ID, []) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } }) renderPanel() const buttons = screen.getAllByText('+ Nieuwe taak') fireEvent.click(buttons[0]) @@ -98,18 +108,29 @@ describe('TaskPanel', () => { }) it('clicking task card calls router.push with editTask param', () => { - setActiveStoryAndTasks(STORY_ID, TASKS) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) renderPanel() fireEvent.click(screen.getByText('Eerste taak')) expect(mockPush).toHaveBeenCalledWith(`${CLOSE_PATH}?editTask=task-1`) }) it('+ button is disabled in demo mode', () => { - setActiveStoryAndTasks(STORY_ID, []) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } }) renderPanel(true) const btn = screen.getAllByText('+ Nieuwe taak')[0].closest('button') expect(btn).toBeTruthy() expect((btn as HTMLButtonElement).disabled).toBe(true) }) + it('cards have no drag listeners in demo mode (whole-card drag disabled)', () => { + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + // In demo mode, listeners ({} from useSortable mock) are not spread onto the card. + // The mock always returns empty listeners, so we just verify the cards render without error. + renderPanel(true) + expect(screen.getByText('Eerste taak')).toBeTruthy() + expect(screen.getByText('Tweede taak')).toBeTruthy() + }) }) diff --git a/__tests__/components/dashboard/product-list.test.tsx b/__tests__/components/dashboard/product-list.test.tsx deleted file mode 100644 index a532228..0000000 --- a/__tests__/components/dashboard/product-list.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' - -const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() })) -vi.mock('next/navigation', () => ({ useRouter: () => ({ push: pushMock, refresh: vi.fn() }) })) -vi.mock('@/actions/products', () => ({ restoreProductAction: vi.fn() })) -vi.mock('@/actions/active-product', () => ({ setActiveProductAction: vi.fn() })) -vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) -vi.mock('@/components/dialogs/product-dialog', () => ({ - ProductDialog: ({ open }: { open: boolean }) => (open ? <div role="dialog">ProductDialog</div> : null), -})) - -import { ProductList } from '@/components/dashboard/product-list' - -const PRODUCT = { - id: 'p1', - name: 'Mijn Product', - code: 'MP', - description: 'Een product', - repo_url: 'https://github.com/foo/bar', - definition_of_done: 'klaar als het werkt', - auto_pr: false, -} - -beforeEach(() => { - pushMock.mockClear() -}) - -describe('ProductList — edit-icoon (todo cmoq3ox51)', () => { - it('rendert pencil-icoon (Bewerk product) op active card, geen tekstknop "Bewerken"', () => { - render(<ProductList products={[PRODUCT]} isDemo={false} activeProductId="p1" />) - expect(screen.getByLabelText('Bewerk product')).toBeTruthy() - // Oude tekstknop is weg - expect(screen.queryByText('Bewerken')).toBeNull() - }) - - it('opent ProductDialog op klik (en stopt propagation zodat card-click niet navigeert)', () => { - render(<ProductList products={[PRODUCT]} isDemo={false} activeProductId="p1" />) - expect(screen.queryByRole('dialog')).toBeNull() - fireEvent.click(screen.getByLabelText('Bewerk product')) - expect(screen.getByRole('dialog')).toBeTruthy() - expect(pushMock).not.toHaveBeenCalled() // card-navigation niet getriggerd - }) - - it('demo-user: knop is disabled', () => { - render(<ProductList products={[PRODUCT]} isDemo={true} activeProductId="p1" />) - const btn = screen.getByLabelText('Bewerk product') as HTMLButtonElement - expect(btn.disabled).toBe(true) - }) - - it('toont geen edit-icoon bij gearchiveerde producten', () => { - render(<ProductList products={[PRODUCT]} isDemo={false} showArchived={true} activeProductId={null} />) - expect(screen.queryByLabelText('Bewerk product')).toBeNull() - }) -}) diff --git a/__tests__/components/dialogs/answer-modal.test.tsx b/__tests__/components/dialogs/answer-modal.test.tsx deleted file mode 100644 index 26aad0f..0000000 --- a/__tests__/components/dialogs/answer-modal.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import React from 'react' - -vi.mock('@/actions/questions', () => ({ - answerQuestion: vi.fn(), -})) -vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) -vi.mock('@/stores/notifications-store', () => ({ - useNotificationsStore: { - getState: () => ({ remove: vi.fn() }), - }, -})) -vi.mock('next/link', () => ({ - default: ({ href, children }: { href: string; children: React.ReactNode }) => ( - <a href={href}>{children}</a> - ), -})) - -import { AnswerModal } from '@/components/notifications/answer-modal' -import { answerQuestion } from '@/actions/questions' -import { toast } from 'sonner' -import type { NotificationQuestion } from '@/stores/notifications-store' - -const mockAnswerQuestion = answerQuestion as ReturnType<typeof vi.fn> -const mockToast = toast as unknown as { - success: ReturnType<typeof vi.fn> - error: ReturnType<typeof vi.fn> -} - -const QUESTION: NotificationQuestion = { - kind: 'idea', - id: 'q-1', - product_id: 'prod-1', - idea_id: 'idea-1', - idea_code: 'IDEA-42', - idea_title: 'Mijn Idee', - question: 'Wat denk jij?', - options: ['Optie A', 'Optie B'], - created_at: '2026-01-01T00:00:00Z', - expires_at: '2026-12-31T00:00:00Z', -} - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('AnswerModal — met opties', () => { - it('toont optieknoppen, textarea en Verstuur-knop', () => { - render(<AnswerModal question={QUESTION} isDemo={false} onClose={vi.fn()} />) - expect(screen.getByRole('button', { name: 'Optie A' })).toBeTruthy() - expect(screen.getByRole('button', { name: 'Optie B' })).toBeTruthy() - expect(screen.getByLabelText(/Antwoord op Claude/)).toBeTruthy() - expect(screen.getByRole('button', { name: 'Verstuur' })).toBeTruthy() - }) - - it('roept answerQuestion aan met optiewaarde bij klik op optieknop', async () => { - mockAnswerQuestion.mockResolvedValue({ ok: true }) - render(<AnswerModal question={QUESTION} isDemo={false} onClose={vi.fn()} />) - - fireEvent.click(screen.getByRole('button', { name: 'Optie A' })) - - await waitFor(() => { - expect(mockAnswerQuestion).toHaveBeenCalledWith('q-1', 'Optie A') - }) - }) - - it('roept answerQuestion aan met getypte tekst bij klik op Verstuur', async () => { - mockAnswerQuestion.mockResolvedValue({ ok: true }) - render(<AnswerModal question={QUESTION} isDemo={false} onClose={vi.fn()} />) - - fireEvent.change(screen.getByLabelText(/Antwoord op Claude/), { - target: { value: 'Mijn eigen antwoord' }, - }) - fireEvent.click(screen.getByRole('button', { name: 'Verstuur' })) - - await waitFor(() => { - expect(mockAnswerQuestion).toHaveBeenCalledWith('q-1', 'Mijn eigen antwoord') - }) - }) - - it('Verstuur-knop is disabled zolang het tekstveld leeg is', () => { - render(<AnswerModal question={QUESTION} isDemo={false} onClose={vi.fn()} />) - expect(screen.getByRole('button', { name: 'Verstuur' })).toHaveProperty('disabled', true) - }) -}) - -describe('AnswerModal — demo-modus', () => { - it('textarea is disabled en Verstuur is disabled bij isDemo=true', () => { - render(<AnswerModal question={QUESTION} isDemo={true} onClose={vi.fn()} />) - expect(screen.getByLabelText(/Antwoord op Claude/)).toHaveProperty('disabled', true) - expect(screen.getByRole('button', { name: 'Verstuur' })).toHaveProperty('disabled', true) - }) -}) - -describe('AnswerModal — geen vraag', () => { - it('rendert niets wanneer question null is', () => { - const { container } = render( - <AnswerModal question={null} isDemo={false} onClose={vi.fn()} />, - ) - expect(container.firstChild).toBeNull() - }) -}) diff --git a/__tests__/components/dialogs/product-dialog.test.tsx b/__tests__/components/dialogs/product-dialog.test.tsx deleted file mode 100644 index bbbb51c..0000000 --- a/__tests__/components/dialogs/product-dialog.test.tsx +++ /dev/null @@ -1,134 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' - -vi.mock('@/actions/products', () => ({ - createProductAction: vi.fn(), - updateProductAction: vi.fn(), -})) -vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) -vi.mock('@/stores/products-store', () => ({ - useProductsStore: vi.fn((selector: (s: { addProduct: () => void; updateProduct: () => void }) => unknown) => - selector({ addProduct: vi.fn(), updateProduct: vi.fn() }) - ), -})) - -import { ProductDialog } from '@/components/dialogs/product-dialog' -import { createProductAction, updateProductAction } from '@/actions/products' -import { toast } from 'sonner' - -const mockCreate = createProductAction as ReturnType<typeof vi.fn> -const mockUpdate = updateProductAction as ReturnType<typeof vi.fn> -const mockToast = toast as unknown as { - success: ReturnType<typeof vi.fn> - error: ReturnType<typeof vi.fn> -} - -const PRODUCT = { - id: 'prod-1', - name: 'Mijn Product', - code: 'MP', - description: 'Een product', - repo_url: 'https://github.com/org/repo', - definition_of_done: 'Alles groen', - auto_pr: false, -} - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('ProductDialog — create mode', () => { - it('rendert met lege velden en "Nieuw product" titel', () => { - render( - <ProductDialog mode="create" open={true} onOpenChange={vi.fn()} /> - ) - expect(screen.getByText('Nieuw product')).toBeTruthy() - expect(screen.getByLabelText(/Naam/)).toBeTruthy() - expect((screen.getByLabelText(/Naam/) as HTMLInputElement).value).toBe('') - }) - - it('toont validatiefout als naam leeg is bij submit', async () => { - render( - <ProductDialog mode="create" open={true} onOpenChange={vi.fn()} /> - ) - fireEvent.click(screen.getByRole('button', { name: 'Aanmaken' })) - - await waitFor(() => { - expect(screen.getByText('Naam is verplicht')).toBeTruthy() - }) - expect(mockCreate).not.toHaveBeenCalled() - }) - - it('roept createProductAction aan bij geldig formulier', async () => { - mockCreate.mockResolvedValue({ success: true, productId: 'new-prod' }) - - render( - <ProductDialog mode="create" open={true} onOpenChange={vi.fn()} /> - ) - - fireEvent.change(screen.getByLabelText(/Naam/), { target: { value: 'Nieuw Product' } }) - fireEvent.submit(document.getElementById('product-form')!) - - await waitFor(() => { - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ name: 'Nieuw Product' }) - ) - }) - expect(mockToast.success).toHaveBeenCalledWith('Product aangemaakt') - }) - - it('toont error-toast als createProductAction een error retourneert', async () => { - mockCreate.mockResolvedValue({ error: 'Code is al in gebruik' }) - - render( - <ProductDialog mode="create" open={true} onOpenChange={vi.fn()} /> - ) - - fireEvent.change(screen.getByLabelText(/Naam/), { target: { value: 'Test' } }) - fireEvent.submit(document.getElementById('product-form')!) - - await waitFor(() => { - expect(mockToast.error).toHaveBeenCalledWith('Code is al in gebruik') - }) - }) -}) - -describe('ProductDialog — edit mode', () => { - it('rendert met bestaande waarden vooringevuld', () => { - render( - <ProductDialog mode="edit" open={true} onOpenChange={vi.fn()} product={PRODUCT} /> - ) - expect(screen.getByText('Product bewerken')).toBeTruthy() - expect((screen.getByLabelText(/Naam/) as HTMLInputElement).value).toBe('Mijn Product') - }) - - it('roept updateProductAction aan bij opslaan', async () => { - mockUpdate.mockResolvedValue({ success: true }) - - render( - <ProductDialog mode="edit" open={true} onOpenChange={vi.fn()} product={PRODUCT} /> - ) - - fireEvent.change(screen.getByLabelText(/Naam/), { target: { value: 'Gewijzigd Product' } }) - fireEvent.submit(document.getElementById('product-form')!) - - await waitFor(() => { - expect(mockUpdate).toHaveBeenCalledWith( - PRODUCT.id, - expect.objectContaining({ name: 'Gewijzigd Product' }) - ) - }) - expect(mockToast.success).toHaveBeenCalledWith('Product opgeslagen') - }) -}) - -describe('ProductDialog — demo mode', () => { - it('submit-knop is disabled in demo-modus', () => { - render( - <ProductDialog mode="create" open={true} onOpenChange={vi.fn()} isDemo={true} /> - ) - const submitBtn = screen.getByRole('button', { name: 'Aanmaken' }) - expect(submitBtn).toHaveProperty('disabled', true) - }) -}) diff --git a/__tests__/components/ideas/idea-list.test.tsx b/__tests__/components/ideas/idea-list.test.tsx deleted file mode 100644 index 0e0a351..0000000 --- a/__tests__/components/ideas/idea-list.test.tsx +++ /dev/null @@ -1,277 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import '@testing-library/jest-dom' -import React from 'react' - -// --- Navigation mock --- -vi.mock('next/navigation', () => ({ - useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }), -})) - -// --- Actions mocks --- -vi.mock('@/actions/ideas', () => ({ - createIdeaAction: vi.fn(), - archiveIdeaAction: vi.fn(), -})) - -vi.mock('@/actions/user-settings', () => ({ - updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }), -})) - -// --- Sonner mock --- -vi.mock('sonner', () => ({ - toast: { error: vi.fn(), success: vi.fn() }, -})) - -// --- IdeaRowActions mock (complex component with many deps) --- -vi.mock('@/components/ideas/idea-row-actions', () => ({ - IdeaRowActions: () => <div data-testid="idea-row-actions" />, -})) - -// --- DemoTooltip mock --- -vi.mock('@/components/shared/demo-tooltip', () => ({ - DemoTooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>, -})) - -// --- Popover mock — controlled via open prop --- -vi.mock('@/components/ui/popover', () => { - const PopoverCtx = React.createContext<{ - open: boolean - onOpenChange: (v: boolean) => void - }>({ open: false, onOpenChange: () => {} }) - - return { - Popover: ({ - children, - open, - onOpenChange, - }: { - children: React.ReactNode - open?: boolean - onOpenChange?: (v: boolean) => void - }) => ( - <PopoverCtx.Provider value={{ open: open ?? false, onOpenChange: onOpenChange ?? (() => {}) }}> - {children} - </PopoverCtx.Provider> - ), - PopoverTrigger: ({ render: renderEl }: { render: React.ReactElement<{ onClick?: (e: React.MouseEvent) => void }> }) => { - const { open, onOpenChange } = React.useContext(PopoverCtx) - return React.cloneElement(renderEl, { - onClick: (e: React.MouseEvent) => { - onOpenChange(!open) - renderEl.props.onClick?.(e) - }, - }) - }, - PopoverContent: ({ children }: { children: React.ReactNode }) => { - const { open } = React.useContext(PopoverCtx) - return open ? <div data-testid="popover-content">{children}</div> : null - }, - } -}) - -// Import after mocks -import { useUserSettingsStore } from '@/stores/user-settings/store' -import { IdeaList } from '@/components/ideas/idea-list' -import { createIdeaAction } from '@/actions/ideas' -import type { IdeaDto } from '@/lib/idea-dto' - -const PRODUCTS = [ - { id: 'prod-1', name: 'Product A', repo_url: null }, - // repo_url ingesteld zodat de optietekst gewoon "Product B" is (zonder "(geen repo)" suffix) - { id: 'prod-2', name: 'Product B', repo_url: 'https://github.com/org/prod-b' }, -] - -// Minimal IdeaDto factory -function makeIdea(overrides: Partial<IdeaDto> = {}): IdeaDto { - return { - id: 'idea-1', - code: 'ID-1', - title: 'Test Idee', - description: null, - status: 'draft', - product_id: null, - product: null, - pbi_id: null, - pbi: null, - secondary_products: [], - archived: false, - has_grill_md: false, - has_plan_md: false, - created_at: '2024-01-01T00:00:00.000Z', - updated_at: '2024-01-01T00:00:00.000Z', - ...overrides, - } -} - -const IDEAS: IdeaDto[] = [ - makeIdea({ id: 'idea-1', code: 'ID-1', title: 'Idee Concept', status: 'draft' }), - makeIdea({ id: 'idea-2', code: 'ID-2', title: 'Idee Gegrilld', status: 'grilled' }), - makeIdea({ id: 'idea-3', code: 'ID-3', title: 'Idee Gepland', status: 'planned' }), -] - -beforeEach(() => { - vi.clearAllMocks() - useUserSettingsStore.getState().hydrate({}, false) -}) - -describe('IdeaList — filterpopover', () => { - it('toont de "Filters"-knop in de toolbar (geen inline chip-rij)', () => { - render(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />) - - // Filters-knop aanwezig - expect(screen.getByText('Filters')).toBeInTheDocument() - - // Status-labels zoals "Concept" mogen NIET los zichtbaar zijn zonder popover te openen - // (anders was de oude inline chip-rij er nog) - expect(screen.queryByRole('button', { name: 'Concept' })).not.toBeInTheDocument() - }) - - it('klik op "Filters" opent de popover en toont 11 statusopties', () => { - render(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />) - - // Popover nog niet open: content niet zichtbaar - expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() - - fireEvent.click(screen.getByText('Filters')) - - // Content verschijnt - expect(screen.getByTestId('popover-content')).toBeInTheDocument() - - // 11 statusopties + "Alle" = 12 buttons in de popover - // Controleer specifiek de 11 status-labels - const statusLabels = [ - 'Concept', 'Grillen', 'Gegrilld', 'Plannen', 'Plan klaar', - 'Plan beoordelen', 'Gepland', 'Grill mislukt', 'Plan mislukt', - 'Beoordeling mislukt', 'Plan beoordeeld', - ] - for (const label of statusLabels) { - expect(screen.getByRole('button', { name: label })).toBeInTheDocument() - } - }) - - it('klik op een statuschip schrijft de status naar de store', () => { - render(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />) - - fireEvent.click(screen.getByText('Filters')) - fireEvent.click(screen.getByRole('button', { name: 'Concept' })) - - const stored = - useUserSettingsStore.getState().entities.settings.views?.ideasList?.filterStatuses - expect(stored).toContain('draft') - }) - - it('gehydrateerde filter toont "Filters (1)" en filtert de tabel', () => { - useUserSettingsStore - .getState() - .hydrate({ views: { ideasList: { filterStatuses: ['draft'] } } }, false) - - render(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />) - - // Trigger toont het actieve filteraantal - expect(screen.getByText('Filters (1)')).toBeInTheDocument() - - // Alleen het concept-idee is zichtbaar; de andere twee worden weggefilterd - expect(screen.getByText('Idee Concept')).toBeInTheDocument() - expect(screen.queryByText('Idee Gegrilld')).not.toBeInTheDocument() - expect(screen.queryByText('Idee Gepland')).not.toBeInTheDocument() - }) - - it('"Wis filters" is disabled wanneer geen filter actief is', () => { - render(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />) - - fireEvent.click(screen.getByText('Filters')) - - const wisButton = screen.getByRole('button', { name: 'Wis filters' }) - expect(wisButton).toBeDisabled() - }) - - it('"Wis filters" is enabled en wist de filter wanneer een filter actief is', () => { - useUserSettingsStore - .getState() - .hydrate({ views: { ideasList: { filterStatuses: ['draft'] } } }, false) - - render(<IdeaList ideas={IDEAS} products={[]} isDemo={false} activeProductId={null} />) - - fireEvent.click(screen.getByText('Filters (1)')) - - const wisButton = screen.getByRole('button', { name: 'Wis filters' }) - expect(wisButton).not.toBeDisabled() - - fireEvent.click(wisButton) - - const stored = - useUserSettingsStore.getState().entities.settings.views?.ideasList?.filterStatuses - expect(stored).toEqual([]) - }) -}) - -describe('IdeaList — activeProductId voorvullen', () => { - // Hulpfunctie: vind een knop op basis van gedeeltelijke tekstinhoud. - // getByText() werkt hier betrouwbaarder dan getByRole({name}) voor knoppen - // met SVG-icoon omdat de accessible-name-berekening van Base UI knoppen in - // jsdom soms afwijkt van wat we verwachten. - function clickButton(label: string) { - const btn = Array.from(document.querySelectorAll('button')).find( - (b) => b.textContent?.trim().includes(label) - ) - if (!btn) throw new Error(`Knop met tekst "${label}" niet gevonden`) - fireEvent.click(btn) - } - - it('AC1: "Nieuw idee"-select is voorgevuld met het actieve product', async () => { - render( - <IdeaList ideas={[]} products={PRODUCTS} isDemo={false} activeProductId="prod-2" /> - ) - - clickButton('Nieuw idee') - - // Wacht tot het formulier verschijnt; create-form-select toont "Product B" (waarde 'prod-2'). - // De toolbar-select toont "Alle producten" (waarde 'all'), zodat displayValue uniek is. - const createFormSelect = await waitFor(() => screen.getByDisplayValue('Product B')) - expect(createFormSelect).toHaveValue('prod-2') - }) - - it('AC2: "Nieuw idee"-select staat op leeg wanneer activeProductId null is', async () => { - render( - <IdeaList ideas={[]} products={PRODUCTS} isDemo={false} activeProductId={null} /> - ) - - clickButton('Nieuw idee') - - // Toolbar-select toont "Alle producten"; create-form-select toont de placeholder (waarde ''). - const createFormSelect = await waitFor(() => - screen.getByDisplayValue('Geen product (kan later worden gekoppeld)') - ) - expect(createFormSelect).toHaveValue('') - }) - - it('AC3: "Snel idee" stuurt product_id gelijk aan activeProductId mee', async () => { - vi.mocked(createIdeaAction).mockResolvedValue({ data: { code: 'ID-99', id: 'idea-99' } } as never) - - render( - <IdeaList ideas={[]} products={PRODUCTS} isDemo={false} activeProductId="prod-2" /> - ) - - // Open "Snel idee"-formulier en wacht tot het verschijnt - clickButton('Snel idee') - await waitFor(() => screen.getByPlaceholderText('Titel *')) - - // Vul de verplichte titel in - fireEvent.change(screen.getByPlaceholderText('Titel *'), { - target: { value: 'Mijn snel idee' }, - }) - - // Klik Opslaan — startTransition roept createIdeaAction synchroon aan - clickButton('Opslaan') - - await waitFor(() => { - expect(createIdeaAction).toHaveBeenCalledWith({ - title: 'Mijn snel idee', - description: null, - product_id: 'prod-2', - }) - }) - }) -}) diff --git a/__tests__/components/jobs/job-card.test.tsx b/__tests__/components/jobs/job-card.test.tsx deleted file mode 100644 index 09bc3a2..0000000 --- a/__tests__/components/jobs/job-card.test.tsx +++ /dev/null @@ -1,85 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect } from 'vitest' -import { render, screen } from '@testing-library/react' -import '@testing-library/jest-dom' -import JobCard from '@/components/jobs/job-card' - -const BASE_PROPS = { - id: 'job-1', - kind: 'TASK_IMPLEMENTATION' as const, - status: 'RUNNING' as const, - productName: 'Scrum4Me', - productCode: 'S4M', - pbiCode: 'PBI-1', - storyCode: 'ST-1', - createdAt: new Date('2026-01-01T10:00:00Z'), -} - -describe('JobCard breadcrumb', () => { - it('TASK-job toont productCode, pbiCode en storyCode in de breadcrumb', () => { - render(<JobCard {...BASE_PROPS} />) - const breadcrumb = screen.getByText('S4M PBI-1 ST-1') - expect(breadcrumb).toBeInTheDocument() - }) - - it('TASK-job zonder productCode valt terug op productName in de breadcrumb', () => { - render(<JobCard {...BASE_PROPS} productCode={null} />) - expect(screen.getByText('Scrum4Me PBI-1 ST-1')).toBeInTheDocument() - }) - - it('TASK-job laat ontbrekende codes weg uit de breadcrumb', () => { - render(<JobCard {...BASE_PROPS} pbiCode={null} storyCode={null} />) - expect(screen.getByText('S4M')).toBeInTheDocument() - }) - - it('GRILL-job toont productCode en ideaCode', () => { - render( - <JobCard - {...BASE_PROPS} - kind="IDEA_GRILL" - productCode="S4M" - ideaCode="IDEA-5" - pbiCode={null} - storyCode={null} - />, - ) - expect(screen.getByText('S4M IDEA-5')).toBeInTheDocument() - }) - - it('SPRINT-job toont productCode en sprintCode', () => { - render( - <JobCard - {...BASE_PROPS} - kind="SPRINT_IMPLEMENTATION" - productCode="S4M" - sprintCode="SP-3" - pbiCode={null} - storyCode={null} - />, - ) - expect(screen.getByText('S4M SP-3')).toBeInTheDocument() - }) -}) - -describe('JobCard datumweergave', () => { - it('toont finishedAt als die beschikbaar is', () => { - const finishedAt = new Date('2026-03-15T14:30:00Z') - render(<JobCard {...BASE_PROPS} startedAt={new Date('2026-03-10T09:00:00Z')} finishedAt={finishedAt} />) - const formatted = finishedAt.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }) - expect(screen.getByText(formatted)).toBeInTheDocument() - }) - - it('toont startedAt als finishedAt ontbreekt', () => { - const startedAt = new Date('2026-03-10T09:00:00Z') - render(<JobCard {...BASE_PROPS} startedAt={startedAt} finishedAt={null} />) - const formatted = startedAt.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }) - expect(screen.getByText(formatted)).toBeInTheDocument() - }) - - it('toont createdAt als zowel finishedAt als startedAt ontbreken', () => { - const createdAt = new Date('2026-01-01T10:00:00Z') - render(<JobCard {...BASE_PROPS} createdAt={createdAt} startedAt={null} finishedAt={null} />) - const formatted = createdAt.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }) - expect(screen.getByText(formatted)).toBeInTheDocument() - }) -}) diff --git a/__tests__/components/jobs/job-detail-pane.test.tsx b/__tests__/components/jobs/job-detail-pane.test.tsx deleted file mode 100644 index 9a5d0f6..0000000 --- a/__tests__/components/jobs/job-detail-pane.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' -import '@testing-library/jest-dom' -import type { JobWithRelations } from '@/actions/jobs-page' - -vi.mock('@/actions/claude-jobs', () => ({ - restartClaudeJobAction: vi.fn(), -})) - -vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) - -import { restartClaudeJobAction } from '@/actions/claude-jobs' -import JobDetailPane from '@/components/jobs/job-detail-pane' - -const mockAction = restartClaudeJobAction as ReturnType<typeof vi.fn> - -function makeJob(status: JobWithRelations['status']): JobWithRelations { - return { - id: 'job-1', - kind: 'TASK_IMPLEMENTATION', - status, - taskCode: 'T-1', - taskTitle: 'Test taak', - ideaCode: null, - ideaTitle: null, - sprintGoal: null, - sprintCode: null, - productName: 'Scrum4Me', - productCode: null, - storyCode: null, - pbiCode: null, - modelId: null, - inputTokens: null, - outputTokens: null, - cacheReadTokens: null, - cacheWriteTokens: null, - costUsd: null, - branch: null, - prUrl: null, - error: null, - summary: null, - description: null, - verifyResult: null, - startedAt: null, - finishedAt: null, - createdAt: new Date('2026-01-01'), - sprintRunId: null, - } -} - -beforeEach(() => { - vi.clearAllMocks() - mockAction.mockResolvedValue({ success: true }) -}) - -describe('JobDetailPane restart button', () => { - it('toont de knop voor FAILED-jobs', () => { - render(<JobDetailPane job={makeJob('FAILED')} isDemo={false} />) - expect(screen.getByRole('button', { name: /opnieuw starten/i })).toBeInTheDocument() - }) - - it('toont de knop niet voor DONE-jobs', () => { - render(<JobDetailPane job={makeJob('DONE')} isDemo={false} />) - expect(screen.queryByRole('button', { name: /opnieuw starten/i })).not.toBeInTheDocument() - }) - - it('roept restartClaudeJobAction aan met het juiste id bij klik', () => { - render(<JobDetailPane job={makeJob('FAILED')} isDemo={false} />) - fireEvent.click(screen.getByRole('button', { name: /opnieuw starten/i })) - expect(mockAction).toHaveBeenCalledWith('job-1') - }) - - it('knop is disabled in demo-modus', () => { - render(<JobDetailPane job={makeJob('FAILED')} isDemo={true} />) - expect(screen.getByRole('button', { name: /opnieuw starten/i })).toBeDisabled() - }) -}) diff --git a/__tests__/components/mobile/landscape-guard.test.tsx b/__tests__/components/mobile/landscape-guard.test.tsx deleted file mode 100644 index ca3a4d1..0000000 --- a/__tests__/components/mobile/landscape-guard.test.tsx +++ /dev/null @@ -1,73 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, screen, act } from '@testing-library/react' -import { LandscapeGuard } from '@/components/mobile/landscape-guard' - -type Listener = (e: MediaQueryListEvent) => void - -function mockMatchMedia(initialPortrait: boolean) { - let matches = initialPortrait - let listener: Listener | null = null - - const mql = { - get matches() { return matches }, - media: '(orientation: portrait)', - onchange: null, - addEventListener: (_: string, l: Listener) => { listener = l }, - removeEventListener: () => { listener = null }, - addListener: () => {}, - removeListener: () => {}, - dispatchEvent: () => false, - } - - Object.defineProperty(window, 'matchMedia', { - writable: true, - configurable: true, - value: () => mql, - }) - - return { - setPortrait(p: boolean) { - matches = p - if (listener) listener({ matches: p } as MediaQueryListEvent) - }, - } -} - -describe('LandscapeGuard', () => { - beforeEach(() => {}) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it('renders children always', () => { - mockMatchMedia(false) - render(<LandscapeGuard><div>kids</div></LandscapeGuard>) - expect(screen.getByText('kids')).toBeTruthy() - }) - - it('shows overlay in portrait', () => { - mockMatchMedia(true) - render(<LandscapeGuard><div>kids</div></LandscapeGuard>) - expect(screen.getByRole('alert').textContent).toContain('Draai je telefoon naar landscape') - // children blijven in DOM (geen unmount → SSE-streams blijven leven) - expect(screen.getByText('kids')).toBeTruthy() - }) - - it('hides overlay in landscape', () => { - mockMatchMedia(false) - render(<LandscapeGuard><div>kids</div></LandscapeGuard>) - expect(screen.queryByRole('alert')).toBeNull() - }) - - it('toggles overlay on orientation change', () => { - const ctl = mockMatchMedia(false) - render(<LandscapeGuard><div>kids</div></LandscapeGuard>) - expect(screen.queryByRole('alert')).toBeNull() - act(() => ctl.setPortrait(true)) - expect(screen.getByRole('alert')).toBeTruthy() - act(() => ctl.setPortrait(false)) - expect(screen.queryByRole('alert')).toBeNull() - }) -}) diff --git a/__tests__/components/mobile/logout-button.test.tsx b/__tests__/components/mobile/logout-button.test.tsx deleted file mode 100644 index bffa8fe..0000000 --- a/__tests__/components/mobile/logout-button.test.tsx +++ /dev/null @@ -1,46 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' - -const { logoutMock } = vi.hoisted(() => ({ - logoutMock: vi.fn().mockResolvedValue(undefined), -})) -vi.mock('@/actions/auth', () => ({ logoutAction: logoutMock })) - -import { LogoutButton } from '@/components/mobile/logout-button' - -beforeEach(() => { - logoutMock.mockClear() -}) - -describe('LogoutButton', () => { - it('toont initieel alleen de Uitloggen-knop, geen dialog', () => { - render(<LogoutButton />) - expect(screen.getByRole('button', { name: /Uitloggen/ })).toBeTruthy() - expect(screen.queryByText(/Weet je zeker/)).toBeNull() - }) - - it('opent AlertDialog bij klikken op de knop', () => { - render(<LogoutButton />) - fireEvent.click(screen.getByRole('button', { name: /Uitloggen/ })) - expect(screen.getByText('Uitloggen?')).toBeTruthy() - expect(screen.getByText(/Weet je zeker/)).toBeTruthy() - }) - - it('roept logoutAction aan op bevestigen', async () => { - const { container } = render(<LogoutButton />) - fireEvent.click(screen.getByRole('button', { name: /Uitloggen/ })) - // Het body-portal wordt buiten container gerenderd; query op document.body. - const allButtons = Array.from(document.body.querySelectorAll('button')) - const confirmBtn = allButtons.find(b => b.textContent?.trim() === 'Uitloggen' && !container.contains(b)) ?? allButtons[allButtons.length - 1] - fireEvent.click(confirmBtn) - await waitFor(() => expect(logoutMock).toHaveBeenCalledTimes(1)) - }) - - it('roept logoutAction NIET aan bij annuleren', () => { - render(<LogoutButton />) - fireEvent.click(screen.getByRole('button', { name: /Uitloggen/ })) - fireEvent.click(screen.getByText('Annuleren')) - expect(logoutMock).not.toHaveBeenCalled() - }) -}) diff --git a/__tests__/components/mobile/mobile-tab-bar.test.tsx b/__tests__/components/mobile/mobile-tab-bar.test.tsx deleted file mode 100644 index 66d6170..0000000 --- a/__tests__/components/mobile/mobile-tab-bar.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi } from 'vitest' -import { render, screen } from '@testing-library/react' -import { MobileTabBar } from '@/components/mobile/mobile-tab-bar' - -let pathname = '/m/products/p1' -vi.mock('next/navigation', () => ({ - usePathname: () => pathname, -})) - -function setPathname(p: string) { pathname = p } - -describe('MobileTabBar', () => { - it('toont 3 tabs als activeProductId aanwezig is', () => { - setPathname('/m/products/p1') - render(<MobileTabBar activeProductId="p1" />) - expect(screen.getByLabelText('Backlog')).toBeTruthy() - expect(screen.getByLabelText('Solo')).toBeTruthy() - expect(screen.getByLabelText('Settings')).toBeTruthy() - }) - - it('toont alleen Settings als activeProductId null is', () => { - setPathname('/m/settings') - render(<MobileTabBar activeProductId={null} />) - expect(screen.queryByLabelText('Backlog')).toBeNull() - expect(screen.queryByLabelText('Solo')).toBeNull() - expect(screen.getByLabelText('Settings')).toBeTruthy() - }) - - it('Backlog-tab is aria-current op /m/products/[id]', () => { - setPathname('/m/products/p1') - render(<MobileTabBar activeProductId="p1" />) - expect(screen.getByLabelText('Backlog').getAttribute('aria-current')).toBe('page') - expect(screen.getByLabelText('Solo').getAttribute('aria-current')).toBeNull() - }) - - it('Solo-tab is aria-current op /m/products/[id]/solo', () => { - setPathname('/m/products/p1/solo') - render(<MobileTabBar activeProductId="p1" />) - expect(screen.getByLabelText('Solo').getAttribute('aria-current')).toBe('page') - expect(screen.getByLabelText('Backlog').getAttribute('aria-current')).toBeNull() - }) - - it('Settings-tab is aria-current op /m/settings', () => { - setPathname('/m/settings') - render(<MobileTabBar activeProductId="p1" />) - expect(screen.getByLabelText('Settings').getAttribute('aria-current')).toBe('page') - }) - - it('tap-targets >=44x44 (h-14 = 56px breedtevulling per tab)', () => { - setPathname('/m/products/p1') - render(<MobileTabBar activeProductId="p1" />) - const tab = screen.getByLabelText('Backlog') - expect(tab.className).toContain('h-14') - expect(tab.className).toContain('flex-1') - }) -}) diff --git a/__tests__/components/shared/entity-dialog-layout.test.ts b/__tests__/components/shared/entity-dialog-layout.test.ts deleted file mode 100644 index 294ed3a..0000000 --- a/__tests__/components/shared/entity-dialog-layout.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' -import { entityDialogContentClasses } from '@/components/shared/entity-dialog-layout' - -describe('entityDialogContentClasses', () => { - it('bevat mobile-fullscreen classes (<640px)', () => { - const cls = entityDialogContentClasses - expect(cls).toContain('max-sm:w-screen') - expect(cls).toContain('max-sm:h-screen') - expect(cls).toContain('max-sm:max-w-none') - expect(cls).toContain('max-sm:rounded-none') - }) - - it('behoudt desktop-classes (>=640px)', () => { - const cls = entityDialogContentClasses - expect(cls).toContain('sm:max-w-[90vw]') - expect(cls).toContain('sm:max-h-[85vh]') - expect(cls).toContain('lg:max-w-[50vw]') - }) -}) - -describe('alle entity-dialogen gebruiken entityDialogContentClasses', () => { - // Regressie-vangnet: voorkomt dat een dialog zijn eigen className meegeeft en - // daarmee de gedeelde mobile-fullscreen-classes ontwijkt. - const files = [ - 'app/_components/tasks/task-dialog.tsx', - 'components/solo/task-detail-dialog.tsx', - 'components/backlog/pbi-dialog.tsx', - 'components/backlog/story-dialog.tsx', - ] - for (const f of files) { - it(`${f} importeert + gebruikt entityDialogContentClasses`, () => { - const src = readFileSync(resolve(process.cwd(), f), 'utf-8') - expect(src).toContain('entityDialogContentClasses') - }) - } -}) diff --git a/__tests__/components/shared/nav-bar.test.tsx b/__tests__/components/shared/nav-bar.test.tsx deleted file mode 100644 index 28e9037..0000000 --- a/__tests__/components/shared/nav-bar.test.tsx +++ /dev/null @@ -1,179 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' -import '@testing-library/jest-dom' -import React from 'react' - -const pushMock = vi.fn() -const refreshMock = vi.fn() -const pathnameMock = vi.fn(() => '/dashboard') - -vi.mock('next/navigation', () => ({ - useRouter: () => ({ push: pushMock, refresh: refreshMock }), - usePathname: () => pathnameMock(), -})) - -vi.mock('@/actions/active-product', () => ({ - setActiveProductAction: vi.fn(), -})) - -vi.mock('sonner', () => ({ - toast: { error: vi.fn(), success: vi.fn() }, -})) - -vi.mock('@/components/ui/dropdown-menu', () => { - type Props = React.HTMLAttributes<HTMLDivElement> & { - children?: React.ReactNode - onClick?: () => void - } - const PassThrough = ({ children }: Props) => <>{children}</> - const Forwarding = ({ children, ...rest }: Props) => <div {...rest}>{children}</div> - return { - DropdownMenu: PassThrough, - DropdownMenuTrigger: Forwarding, - DropdownMenuContent: PassThrough, - DropdownMenuItem: ({ children, onClick, className }: Props) => ( - <button type="button" onClick={onClick} className={className} data-testid="dd-item"> - {children} - </button> - ), - DropdownMenuSeparator: () => null, - } -}) - -vi.mock('@/components/ui/tooltip', () => { - type Props = { children?: React.ReactNode } - const PassThrough = ({ children }: Props) => <>{children}</> - return { - Tooltip: PassThrough, - TooltipContent: PassThrough, - TooltipProvider: PassThrough, - TooltipTrigger: PassThrough, - } -}) - -vi.mock('@/components/shared/app-icon', () => ({ AppIcon: () => null })) -vi.mock('@/components/shared/user-menu', () => ({ UserMenu: () => null })) -vi.mock('@/components/shared/notifications-bell', () => ({ NotificationsBell: () => null })) -vi.mock('@/components/solo/nav-status-indicators', () => ({ - SoloNavStatusIndicators: () => null, -})) - -import { setActiveProductAction } from '@/actions/active-product' -import { toast } from 'sonner' -import { NavBar } from '@/components/shared/nav-bar' - -const actionMock = setActiveProductAction as unknown as ReturnType<typeof vi.fn> -const toastSuccess = toast.success as unknown as ReturnType<typeof vi.fn> - -const products = [ - { id: 'A', name: 'Alpha' }, - { id: 'B', name: 'Beta' }, -] - -function renderNavBar(overrides: { isDemo?: boolean; activeProductId?: string } = {}) { - const isDemo = overrides.isDemo ?? false - const activeId = overrides.activeProductId ?? 'A' - const activeProduct = products.find(p => p.id === activeId) ?? null - return render( - <NavBar - isDemo={isDemo} - roles={[]} - userId="u1" - username="user" - email={null} - activeProduct={activeProduct} - products={products} - hasActiveSprint={false} - minQuotaPct={100} - />, - ) -} - -beforeEach(() => { - vi.clearAllMocks() - actionMock.mockResolvedValue({ success: true }) - pathnameMock.mockReturnValue('/dashboard') -}) - -describe('NavBar — product switch', () => { - it('demo: clicking another product navigates via router.push without calling the action', () => { - renderNavBar({ isDemo: true, activeProductId: 'A' }) - fireEvent.click(screen.getByText('Beta')) - expect(pushMock).toHaveBeenCalledWith('/products/B') - expect(actionMock).not.toHaveBeenCalled() - expect(toastSuccess).not.toHaveBeenCalled() - }) - - it('non-demo: clicking another product calls setActiveProductAction', async () => { - renderNavBar({ isDemo: false, activeProductId: 'A' }) - fireEvent.click(screen.getByText('Beta')) - await Promise.resolve() - expect(actionMock).toHaveBeenCalledWith('B') - }) - - it('non-demo: on /products/A navigates to /products/B', async () => { - pathnameMock.mockReturnValue('/products/A') - renderNavBar({ isDemo: false, activeProductId: 'A' }) - fireEvent.click(screen.getByText('Beta')) - await Promise.resolve() - await Promise.resolve() - expect(pushMock).toHaveBeenCalledWith('/products/B') - expect(toastSuccess).toHaveBeenCalled() - }) - - it('non-demo: on /products/A/sprint/SPR1 navigates to /products/B/sprint', async () => { - pathnameMock.mockReturnValue('/products/A/sprint/SPR1') - renderNavBar({ isDemo: false, activeProductId: 'A' }) - fireEvent.click(screen.getByText('Beta')) - await Promise.resolve() - await Promise.resolve() - expect(pushMock).toHaveBeenCalledWith('/products/B/sprint') - expect(toastSuccess).toHaveBeenCalled() - }) - - it('non-demo: on /products/A/solo navigates to /products/B/solo', async () => { - pathnameMock.mockReturnValue('/products/A/solo') - renderNavBar({ isDemo: false, activeProductId: 'A' }) - fireEvent.click(screen.getByText('Beta')) - await Promise.resolve() - await Promise.resolve() - expect(pushMock).toHaveBeenCalledWith('/products/B/solo') - expect(toastSuccess).toHaveBeenCalled() - }) - - it('non-demo: on /dashboard calls router.refresh and not router.push', async () => { - pathnameMock.mockReturnValue('/dashboard') - renderNavBar({ isDemo: false, activeProductId: 'A' }) - fireEvent.click(screen.getByText('Beta')) - await Promise.resolve() - await Promise.resolve() - expect(refreshMock).toHaveBeenCalled() - expect(pushMock).not.toHaveBeenCalled() - expect(toastSuccess).toHaveBeenCalled() - }) -}) - -describe('NavBar — URL-derived active product (demo only)', () => { - it('demo: label and dropdown highlight follow pathname, not the activeProduct prop', () => { - pathnameMock.mockReturnValue('/products/B/sprint') - const { container } = renderNavBar({ isDemo: true, activeProductId: 'A' }) - const trigger = container.querySelector('[data-debug-id="nav-bar__product-switcher"]') - expect(trigger?.textContent).toContain('Beta') - expect(trigger?.textContent).not.toContain('Alpha') - const items = screen.getAllByTestId('dd-item') - const itemB = items.find(el => el.textContent?.includes('Beta')) - expect(itemB?.className).toContain('bg-primary-container') - const itemA = items.find(el => el.textContent?.includes('Alpha')) - expect(itemA?.className ?? '').not.toContain('bg-primary-container') - }) - - it('non-demo: pathname does NOT override the activeProduct prop', () => { - pathnameMock.mockReturnValue('/products/B/sprint') - renderNavBar({ isDemo: false, activeProductId: 'A' }) - // Label still reflects server-rendered activeProduct (Alpha) - const items = screen.getAllByTestId('dd-item') - const itemA = items.find(el => el.textContent?.includes('Alpha')) - expect(itemA?.className).toContain('bg-primary-container') - }) -}) diff --git a/__tests__/components/shared/sprint-switcher.test.tsx b/__tests__/components/shared/sprint-switcher.test.tsx deleted file mode 100644 index 8af2df1..0000000 --- a/__tests__/components/shared/sprint-switcher.test.tsx +++ /dev/null @@ -1,174 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' -import '@testing-library/jest-dom' -import React from 'react' - -const pushMock = vi.fn() -const refreshMock = vi.fn() -const pathnameMock = vi.fn(() => '/products/p1/sprint') - -vi.mock('next/navigation', () => ({ - useRouter: () => ({ push: pushMock, refresh: refreshMock }), - usePathname: () => pathnameMock(), -})) - -vi.mock('@/actions/active-sprint', () => ({ - setActiveSprintAction: vi.fn(), - switchActiveSprintAction: vi.fn(), - clearActiveSprintAction: vi.fn(), -})) - -vi.mock('sonner', () => ({ - toast: { error: vi.fn(), success: vi.fn() }, -})) - -const isDemoMock = { value: false } -const workflowMock: { - value: - | { pendingSprintDraft?: Record<string, { goal: string } | undefined> } - | undefined -} = { value: undefined } -// Mock-state shape moet alle paden dekken die SprintSwitcher selecteert: -// - s.context.isDemo (oude code) -// - s.entities.settings.workflow?.pendingSprintDraft?.[productId]?.goal (PBI-79) -type MockStoreState = { - context: { isDemo: boolean } - entities: { - settings: { - workflow?: { - pendingSprintDraft?: Record<string, { goal: string } | undefined> - } - } - } -} -vi.mock('@/stores/user-settings/store', () => ({ - useUserSettingsStore: (selector: (s: MockStoreState) => unknown) => - selector({ - context: { isDemo: isDemoMock.value }, - entities: { settings: { workflow: workflowMock.value } }, - }), -})) - -vi.mock('@/components/ui/dropdown-menu', () => { - type Props = { children?: React.ReactNode; onClick?: () => void; className?: string } - const PassThrough = ({ children }: Props) => <>{children}</> - return { - DropdownMenu: PassThrough, - DropdownMenuTrigger: PassThrough, - DropdownMenuContent: PassThrough, - DropdownMenuItem: ({ children, onClick, className }: Props) => ( - <button type="button" onClick={onClick} className={className}> - {children} - </button> - ), - DropdownMenuSeparator: () => null, - } -}) - -vi.mock('@/components/ui/tooltip', () => { - type Props = { children?: React.ReactNode } - const PassThrough = ({ children }: Props) => <>{children}</> - return { - Tooltip: PassThrough, - TooltipContent: PassThrough, - TooltipProvider: PassThrough, - TooltipTrigger: PassThrough, - } -}) - -import { switchActiveSprintAction } from '@/actions/active-sprint' -import { toast } from 'sonner' -import { SprintSwitcher } from '@/components/shared/sprint-switcher' - -const actionMock = switchActiveSprintAction as unknown as ReturnType<typeof vi.fn> -const toastError = toast.error as unknown as ReturnType<typeof vi.fn> -const toastSuccess = toast.success as unknown as ReturnType<typeof vi.fn> - -const sprints = [ - { id: 's1', code: 'SP-1', sprint_goal: 'Goal 1', status: 'open' as const }, - { id: 's2', code: 'SP-2', sprint_goal: 'Goal 2', status: 'open' as const }, -] - -beforeEach(() => { - vi.clearAllMocks() - isDemoMock.value = false - workflowMock.value = undefined - actionMock.mockResolvedValue({ success: true }) - pathnameMock.mockReturnValue('/products/p1/sprint') -}) - -describe('SprintSwitcher', () => { - it('demo: clicking another sprint navigates via router.push without calling the action', () => { - isDemoMock.value = true - render( - <SprintSwitcher - productId="p1" - sprints={sprints} - activeSprint={sprints[0]} - buildingSprintIds={[]} - />, - ) - fireEvent.click(screen.getByText('Goal 2')) - expect(pushMock).toHaveBeenCalledWith('/products/p1/sprint/s2') - expect(actionMock).not.toHaveBeenCalled() - expect(toastError).not.toHaveBeenCalled() - expect(toastSuccess).not.toHaveBeenCalled() - }) - - it('non-demo: clicking another sprint calls setActiveSprintAction', async () => { - isDemoMock.value = false - render( - <SprintSwitcher - productId="p1" - sprints={sprints} - activeSprint={sprints[0]} - buildingSprintIds={[]} - />, - ) - fireEvent.click(screen.getByText('Goal 2')) - // Wait microtask for the transition to flush. - await Promise.resolve() - expect(actionMock).toHaveBeenCalledWith('p1', 's2') - }) - - it('clicking the already-active sprint does nothing', () => { - isDemoMock.value = true - render( - <SprintSwitcher - productId="p1" - sprints={sprints} - activeSprint={sprints[0]} - buildingSprintIds={[]} - />, - ) - fireEvent.click(screen.getByText('Goal 1')) - expect(pushMock).not.toHaveBeenCalled() - expect(actionMock).not.toHaveBeenCalled() - }) - - it('shows the concept-sprint on the trigger when a draft is pending (G5)', () => { - workflowMock.value = { pendingSprintDraft: { p1: { goal: 'Test goal' } } } - render( - <SprintSwitcher - productId="p1" - sprints={sprints} - activeSprint={null} - buildingSprintIds={[]} - />, - ) - expect(screen.getByText('⚙ Concept — Test goal')).toBeInTheDocument() - }) - - it('shows no concept label on the trigger when no draft is pending', () => { - render( - <SprintSwitcher - productId="p1" - sprints={sprints} - activeSprint={sprints[0]} - buildingSprintIds={[]} - />, - ) - expect(screen.queryByText(/⚙ Concept/)).not.toBeInTheDocument() - }) -}) diff --git a/__tests__/components/solo/batch-enqueue-blocker-dialog.test.tsx b/__tests__/components/solo/batch-enqueue-blocker-dialog.test.tsx deleted file mode 100644 index e349227..0000000 --- a/__tests__/components/solo/batch-enqueue-blocker-dialog.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -// @vitest-environment jsdom -import '@testing-library/jest-dom' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' - -vi.mock('@/components/ui/dialog', () => ({ - Dialog: ({ open, children }: { open: boolean; onOpenChange?: (v: boolean) => void; children: React.ReactNode }) => - open ? <div data-testid="dialog">{children}</div> : null, - DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>, -})) -vi.mock('@/components/ui/button', () => ({ - Button: ({ - children, - onClick, - disabled, - variant, - }: { - children?: React.ReactNode - onClick?: () => void - disabled?: boolean - variant?: string - }) => ( - <button onClick={onClick} disabled={disabled} data-variant={variant}> - {children} - </button> - ), -})) -vi.mock('@/components/ui/tooltip', () => ({ - TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>, - Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>, - TooltipTrigger: ({ render: r, children }: { render?: React.ReactElement; children?: React.ReactNode }) => - r ? <>{r}</> : <>{children}</>, - TooltipContent: ({ children }: { children: React.ReactNode }) => ( - <span data-testid="tooltip-content">{children}</span> - ), -})) - -import { BatchEnqueueBlockerDialog } from '@/components/solo/batch-enqueue-blocker-dialog' - -const DEFAULT_PROPS = { - open: true, - onOpenChange: vi.fn(), - prefixCount: 3, - blockerReason: 'task-review' as const, - blockerLabel: 'Story X — Task Y (in review)', - onConfirm: vi.fn(), - onCancel: vi.fn(), -} - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('BatchEnqueueBlockerDialog', () => { - it('renders title and blocker info for task-review', () => { - render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} />) - - expect(screen.getByRole('heading')).toHaveTextContent('Blokkade gedetecteerd') - expect(screen.getByText(/Een taak staat op 'review'/)).toBeInTheDocument() - expect(screen.getByText(/Story X — Task Y/)).toBeInTheDocument() - }) - - it('renders correct blocker label for pbi-blocked', () => { - render( - <BatchEnqueueBlockerDialog - {...DEFAULT_PROPS} - blockerReason="pbi-blocked" - blockerLabel="PBI Z — geblokkeerd" - /> - ) - - expect(screen.getByText(/De PBI is geblokkeerd/)).toBeInTheDocument() - expect(screen.getByText(/PBI Z/)).toBeInTheDocument() - }) - - it('calls onConfirm when primary button is clicked', () => { - render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} />) - - fireEvent.click(screen.getByText(/Stuur 3 taken tot aan blokkade/)) - - expect(DEFAULT_PROPS.onConfirm).toHaveBeenCalledTimes(1) - }) - - it('calls onCancel when cancel button is clicked', () => { - render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} />) - - fireEvent.click(screen.getByText('Annuleer')) - - expect(DEFAULT_PROPS.onCancel).toHaveBeenCalledTimes(1) - }) - - it('disables confirm button and shows tooltip when prefixCount is 0', () => { - render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} prefixCount={0} />) - - const confirmBtn = screen.getByText(/Stuur 0/).closest('button') - expect(confirmBtn).toBeDisabled() - expect(screen.getByTestId('tooltip-content')).toHaveTextContent('Geen taken vóór blokkade') - }) - - it('does not render when open is false', () => { - render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} open={false} />) - - expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() - }) - - it('uses singular taak when prefixCount is 1', () => { - render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} prefixCount={1} />) - - expect(screen.getByText(/Stuur 1 taak tot aan blokkade/)).toBeInTheDocument() - expect(screen.getByText(/1 taak vóór de blokkade/)).toBeInTheDocument() - }) -}) diff --git a/__tests__/components/solo/solo-board-batch-enqueue.test.tsx b/__tests__/components/solo/solo-board-batch-enqueue.test.tsx deleted file mode 100644 index d47242d..0000000 --- a/__tests__/components/solo/solo-board-batch-enqueue.test.tsx +++ /dev/null @@ -1,207 +0,0 @@ -// @vitest-environment jsdom -import '@testing-library/jest-dom' -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' - -const { mockPreviewEnqueueAllAction, mockEnqueueClaudeJobsBatchAction } = vi.hoisted(() => ({ - mockPreviewEnqueueAllAction: vi.fn(), - mockEnqueueClaudeJobsBatchAction: vi.fn(), -})) - -vi.mock('@/actions/claude-jobs', () => ({ - previewEnqueueAllAction: mockPreviewEnqueueAllAction, - enqueueClaudeJobsBatchAction: mockEnqueueClaudeJobsBatchAction, - cancelClaudeJobAction: vi.fn(), - enqueueClaudeJobAction: vi.fn(), -})) -vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } })) -vi.mock('@dnd-kit/core', () => ({ - DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>, - DragOverlay: () => null, - PointerSensor: class {}, - useSensor: vi.fn(() => ({})), - useSensors: vi.fn(() => []), - closestCorners: vi.fn(), -})) -vi.mock('@/components/ui/button', () => ({ - Button: ({ - children, - onClick, - disabled, - }: { - children?: React.ReactNode - onClick?: () => void - disabled?: boolean - }) => ( - <button onClick={onClick} disabled={disabled}> - {children} - </button> - ), -})) -vi.mock('@/components/ui/dialog', () => ({ - Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => - open ? <div data-testid="dialog">{children}</div> : null, - DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>, - DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>, -})) -vi.mock('@/components/ui/tooltip', () => ({ - TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>, - Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>, - TooltipTrigger: ({ render: r, children }: { render?: React.ReactElement; children?: React.ReactNode }) => - r ? <>{r}</> : <>{children}</>, - TooltipContent: () => null, -})) -vi.mock('@/components/shared/demo-tooltip', () => ({ - DemoTooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>, -})) -vi.mock('@/components/split-pane/split-pane', () => ({ - SplitPane: ({ panes }: { panes: React.ReactNode[] }) => <>{panes}</>, -})) -vi.mock('@/components/solo/solo-column', () => ({ - SoloColumn: () => <div data-testid="solo-column" />, -})) -vi.mock('@/components/solo/solo-task-card', () => ({ - SoloTaskCardOverlay: () => null, -})) -vi.mock('@/components/solo/task-detail-dialog', () => ({ - TaskDetailDialog: () => null, -})) -vi.mock('@/components/solo/unassigned-stories-sheet', () => ({ - UnassignedStoriesSheet: () => null, -})) -vi.mock('@/lib/task-status', () => ({ - taskStatusToApi: (s: string) => s.toLowerCase(), -})) - -import { useSoloStore } from '@/stores/solo-store' -import { SoloBoard } from '@/components/solo/solo-board' -import { toast } from 'sonner' - -const PRODUCT_ID = 'prod-1' -const TODO_TASK = { - id: 't1', - title: 'Task 1', - description: null, - implementation_plan: null, - priority: 1, - sort_order: 1, - status: 'TO_DO' as const, - verify_only: false, - verify_required: 'ALIGNED_OR_PARTIAL' as const, - story_id: 'story-1', - story_code: 'ST-1', - story_title: 'Story 1', - task_code: 'ST-1.1', - pbi_code: null, - pbi_title: null, - pbi_description: null, -} - -const DEFAULT_PROPS = { - productId: PRODUCT_ID, - sprintGoal: 'Sprint goal', - tasks: [TODO_TASK], - unassignedStories: [], - isDemo: false, - currentUserId: 'user-1', -} - -const PREVIEW_NO_BLOCKER = { - tasks: [{ id: 't1', title: 'Task 1', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }], - blockerIndex: null, - blockerReason: null, -} - -const PREVIEW_WITH_BLOCKER = { - tasks: [ - { id: 't1', title: 'Task 1', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }, - { id: 't2', title: 'Task 2', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }, - { id: 't3', title: 'Task Review', status: 'REVIEW', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }, - ], - blockerIndex: 2, - blockerReason: 'task-review' as const, -} - -beforeEach(() => { - vi.clearAllMocks() - useSoloStore.setState({ tasks: {}, claudeJobsByTaskId: {}, connectedWorkers: 1 }) -}) - -describe('SoloBoard — batch-enqueue flow', () => { - it('no blocker: calls enqueueClaudeJobsBatchAction with TO_DO task IDs directly', async () => { - mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_NO_BLOCKER) - mockEnqueueClaudeJobsBatchAction.mockResolvedValue({ success: true, count: 1 }) - - render(<SoloBoard {...DEFAULT_PROPS} />) - - fireEvent.click(screen.getByText(/Start agents/)) - - await waitFor(() => { - expect(mockPreviewEnqueueAllAction).toHaveBeenCalledWith(PRODUCT_ID) - expect(mockEnqueueClaudeJobsBatchAction).toHaveBeenCalledWith(PRODUCT_ID, ['t1']) - expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('1 agent')) - }) - }) - - it('blocker: shows dialog when preview returns blockerIndex', async () => { - mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER) - - render(<SoloBoard {...DEFAULT_PROPS} />) - - fireEvent.click(screen.getByText(/Start agents/)) - - await waitFor(() => { - expect(screen.getByTestId('dialog')).toBeInTheDocument() - expect(screen.getByText(/Blokkade gedetecteerd/)).toBeInTheDocument() - }) - expect(mockEnqueueClaudeJobsBatchAction).not.toHaveBeenCalled() - }) - - it('blocker dialog confirm: enqueues prefix tasks and closes', async () => { - mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER) - mockEnqueueClaudeJobsBatchAction.mockResolvedValue({ success: true, count: 2 }) - - render(<SoloBoard {...DEFAULT_PROPS} />) - fireEvent.click(screen.getByText(/Start agents/)) - - await waitFor(() => screen.getByTestId('dialog')) - - fireEvent.click(screen.getByText(/Stuur 2 taken tot aan blokkade/)) - - await waitFor(() => { - expect(mockEnqueueClaudeJobsBatchAction).toHaveBeenCalledWith(PRODUCT_ID, ['t1', 't2']) - expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('2 agents')) - expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() - }) - }) - - it('blocker dialog cancel: closes dialog without enqueuing', async () => { - mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER) - - render(<SoloBoard {...DEFAULT_PROPS} />) - fireEvent.click(screen.getByText(/Start agents/)) - - await waitFor(() => screen.getByTestId('dialog')) - - fireEvent.click(screen.getByText('Annuleer')) - - await waitFor(() => { - expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() - }) - expect(mockEnqueueClaudeJobsBatchAction).not.toHaveBeenCalled() - }) - - it('preview error: shows toast without opening dialog', async () => { - mockPreviewEnqueueAllAction.mockResolvedValue({ error: 'Geen toegang' }) - - render(<SoloBoard {...DEFAULT_PROPS} />) - fireEvent.click(screen.getByText(/Start agents/)) - - await waitFor(() => { - expect(toast.error).toHaveBeenCalledWith('Geen toegang') - }) - expect(screen.queryByTestId('dialog')).not.toBeInTheDocument() - }) -}) diff --git a/__tests__/components/solo/solo-task-card.test.tsx b/__tests__/components/solo/solo-task-card.test.tsx deleted file mode 100644 index f7a8493..0000000 --- a/__tests__/components/solo/solo-task-card.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -// @vitest-environment jsdom -import '@testing-library/jest-dom' -import { describe, it, expect, vi } from 'vitest' -import { render, screen } from '@testing-library/react' -import type { SoloTask } from '@/components/solo/solo-board' - -vi.mock('@/components/ui/tooltip', () => ({ - TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>, - Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>, - TooltipTrigger: ({ children }: { children?: React.ReactNode }) => <>{children}</>, - TooltipContent: ({ children }: { children: React.ReactNode }) => <span data-testid="tooltip-content">{children}</span>, -})) -vi.mock('@dnd-kit/core', () => ({ - useDraggable: () => ({ attributes: {}, listeners: {}, setNodeRef: vi.fn(), transform: null, isDragging: false }), -})) -vi.mock('@/stores/solo-store', () => ({ - useSoloStore: () => null, -})) -vi.mock('@/components/shared/code-badge', () => ({ - CodeBadge: ({ code }: { code: string }) => <span data-testid="code-badge">{code}</span>, -})) - -import { SoloTaskCard, SoloTaskCardOverlay } from '@/components/solo/solo-task-card' - -function makeSoloTask(overrides: Partial<SoloTask> = {}): SoloTask { - return { - id: 'task-1', - title: 'Taak titel', - description: 'Omschrijving van de taak die langer is dan tachtig tekens voor test', - implementation_plan: null, - priority: 2, - sort_order: 0, - status: 'TO_DO', - verify_only: false, - verify_required: 'ALIGNED', - story_id: 'story-1', - story_code: 'ST-1', - story_title: 'Story titel', - task_code: 'T-1', - pbi_code: 'PBI-1', - pbi_title: 'PBI titel', - pbi_description: 'PBI omschrijving', - ...overrides, - } -} - -describe('SoloTaskCard', () => { - it('toont taaknaam, task_code, pbi_code, story_code, story_title', () => { - render(<SoloTaskCard task={makeSoloTask()} isDemo={false} onClick={vi.fn()} />) - expect(screen.getAllByText('Taak titel').length).toBeGreaterThan(0) - expect(screen.getAllByText('T-1').length).toBeGreaterThan(0) - expect(screen.getAllByText('PBI-1').length).toBeGreaterThan(0) - expect(screen.getByText('ST-1')).toBeInTheDocument() - expect(screen.getByText('Story titel')).toBeInTheDocument() - }) - - it('verbergt pbi_code badge als pbi_code null is', () => { - render(<SoloTaskCard task={makeSoloTask({ pbi_code: null })} isDemo={false} onClick={vi.fn()} />) - const badges = screen.queryAllByTestId('code-badge') - const codes = badges.map(b => b.textContent) - expect(codes).not.toContain('PBI-1') - }) - - it('verbergt description als description null is', () => { - const task = makeSoloTask({ description: null }) - render(<SoloTaskCard task={task} isDemo={false} onClick={vi.fn()} />) - expect(screen.queryByText(/Omschrijving/)).toBeNull() - }) - - it('toont description als tekst', () => { - render(<SoloTaskCard task={makeSoloTask()} isDemo={false} onClick={vi.fn()} />) - expect(screen.getAllByText('Omschrijving van de taak die langer is dan tachtig tekens voor test').length).toBeGreaterThan(0) - }) -}) - -describe('SoloTaskCardOverlay', () => { - it('toont taaknaam en codes zonder tooltip-wrappers', () => { - render(<SoloTaskCardOverlay task={makeSoloTask()} />) - expect(screen.getByText('Taak titel')).toBeInTheDocument() - expect(screen.getByText('T-1')).toBeInTheDocument() - expect(screen.getByText('PBI-1')).toBeInTheDocument() - expect(screen.queryAllByTestId('tooltip-content')).toHaveLength(0) - }) -}) diff --git a/__tests__/components/solo/task-detail-dialog.test.tsx b/__tests__/components/solo/task-detail-dialog.test.tsx index 6c56a22..3b767fc 100644 --- a/__tests__/components/solo/task-detail-dialog.test.tsx +++ b/__tests__/components/solo/task-detail-dialog.test.tsx @@ -65,9 +65,6 @@ const baseTask: SoloTask = { story_code: 'ST-100', story_title: 'Test Story', task_code: 'ST-100.1', - pbi_code: null, - pbi_title: null, - pbi_description: null, } const DEFAULT_PROPS = { diff --git a/__tests__/components/split-pane.test.tsx b/__tests__/components/split-pane.test.tsx index 40cb515..cd166c0 100644 --- a/__tests__/components/split-pane.test.tsx +++ b/__tests__/components/split-pane.test.tsx @@ -1,35 +1,28 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' - -vi.mock('@/actions/user-settings', () => ({ - updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }), -})) - import { SplitPane } from '@/components/split-pane/split-pane' -import { useUserSettingsStore } from '@/stores/user-settings/store' -function seedPositions(key: string, positions: number[]) { - useUserSettingsStore.setState((s) => { - s.entities.settings = { - layout: { - splitPanePositions: { [key]: positions }, - }, - } +// Helper to set a cookie +function setCookie(key: string, value: string) { + Object.defineProperty(document, 'cookie', { + writable: true, + configurable: true, + value: `sp:${key}=${encodeURIComponent(value)}`, }) } -function resetStore() { - useUserSettingsStore.setState((s) => { - s.entities.settings = {} - s.context.hydrated = false - s.context.isDemo = false +function clearCookies() { + Object.defineProperty(document, 'cookie', { + writable: true, + configurable: true, + value: '', }) } describe('SplitPane', () => { beforeEach(() => { - resetStore() + clearCookies() // Default: desktop viewport Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1440 }) window.dispatchEvent(new Event('resize')) @@ -71,8 +64,9 @@ describe('SplitPane', () => { expect(dividers).toHaveLength(2) }) - it('restores splits from user-settings store on mount', () => { - seedPositions('test-restore', [40, 60]) + it('restores splits from cookie on mount', () => { + const stored = JSON.stringify([40, 60]) + setCookie('test-restore', stored) const { container } = render( <SplitPane @@ -87,9 +81,8 @@ describe('SplitPane', () => { expect(paneDiv).toBeTruthy() }) - it('falls back to defaultSplit when persisted positions are invalid', () => { - // Wrong number of values for a 2-pane layout - seedPositions('test-invalid', [10, 30, 60]) + it('falls back to defaultSplit when cookie is invalid', () => { + setCookie('test-invalid', 'not-valid-json') const { container } = render( <SplitPane diff --git a/__tests__/components/sprint/sprint-task-dialog-mount.test.tsx b/__tests__/components/sprint/sprint-task-dialog-mount.test.tsx deleted file mode 100644 index 886dbfe..0000000 --- a/__tests__/components/sprint/sprint-task-dialog-mount.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' - -vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) })) -vi.mock('@/actions/tasks', () => ({ - saveTask: vi.fn(), - deleteTask: vi.fn(), -})) -vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) - -import { SprintTaskDialogMount } from '@/components/sprint/sprint-task-dialog-mount' -import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' -import type { SprintWorkspaceTaskDetail } from '@/stores/sprint-workspace/types' - -const TASK_DETAIL: SprintWorkspaceTaskDetail = { - id: 't1', - code: 'T-1', - title: 'Mijn taak', - description: 'Beschrijving', - priority: 2, - sort_order: 1, - status: 'in_progress', - story_id: 'story-1', - sprint_id: 'sprint-1', - created_at: new Date('2026-01-15'), - _detail: true, - implementation_plan: 'Stap 1\nStap 2', -} - -function resetStore() { - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = null - s.context.activeSprintId = null - s.context.activeStoryId = null - s.context.activeTaskId = null - s.entities.sprintsById = {} - s.entities.storiesById = {} - s.entities.tasksById = {} - s.relations.sprintIdsByProduct = {} - s.relations.storyIdsBySprint = {} - s.relations.taskIdsByStory = {} - s.loading.loadedProductSprintsIds = {} - s.loading.loadingProductId = null - s.loading.loadedSprintIds = {} - s.loading.loadingSprintId = null - s.loading.loadedStoryIds = {} - s.loading.loadedTaskIds = {} - s.loading.activeRequestId = null - s.pendingMutations = {} - }) -} - -beforeEach(() => { - resetStore() -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('SprintTaskDialogMount', () => { - it('rendert niets wanneer er geen active task is', () => { - const { container } = render( - <SprintTaskDialogMount productId="p1" isDemo={false} />, - ) - expect(container.textContent).toBe('') - }) - - it('rendert niets wanneer active task geen _detail heeft', () => { - useSprintWorkspaceStore.setState((s) => { - s.entities.tasksById['t1'] = { - id: 't1', - code: 'T-1', - title: 'Mijn taak', - description: null, - priority: 2, - sort_order: 1, - status: 'todo', - story_id: 'story-1', - sprint_id: 'sprint-1', - created_at: new Date(), - } - s.context.activeTaskId = 't1' - }) - - const { container } = render( - <SprintTaskDialogMount productId="p1" isDemo={false} />, - ) - expect(container.textContent).toBe('') - }) - - it('rendert TaskDialog met titel "Taak bewerken" wanneer detail aanwezig is', () => { - useSprintWorkspaceStore.setState((s) => { - s.entities.tasksById['t1'] = TASK_DETAIL - s.context.activeTaskId = 't1' - }) - - render(<SprintTaskDialogMount productId="p1" isDemo={false} />) - - expect(screen.getByText('Taak bewerken')).toBeTruthy() - expect((screen.getByLabelText(/Titel/) as HTMLInputElement).value).toBe('Mijn taak') - }) - - it('clear activeTaskId wanneer Annuleren wordt geklikt', async () => { - useSprintWorkspaceStore.setState((s) => { - s.entities.tasksById['t1'] = TASK_DETAIL - s.context.activeTaskId = 't1' - }) - - render(<SprintTaskDialogMount productId="p1" isDemo={false} />) - - fireEvent.click(screen.getByRole('button', { name: 'Annuleren' })) - - await waitFor(() => { - expect(useSprintWorkspaceStore.getState().context.activeTaskId).toBeNull() - }) - }) -}) diff --git a/__tests__/components/use-dialog-submit-shortcut.test.ts b/__tests__/components/use-dialog-submit-shortcut.test.ts deleted file mode 100644 index a53e041..0000000 --- a/__tests__/components/use-dialog-submit-shortcut.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi } from 'vitest' -import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut' - -function makeEvent(opts: Partial<KeyboardEvent>) { - return { - metaKey: false, - ctrlKey: false, - key: '', - preventDefault: vi.fn(), - ...opts, - } as unknown as React.KeyboardEvent -} - -describe('useDialogSubmitShortcut', () => { - it('triggert submit op Cmd+Enter', () => { - const submit = vi.fn() - const handler = useDialogSubmitShortcut(submit) - const e = makeEvent({ metaKey: true, key: 'Enter' }) - - handler(e) - - expect(submit).toHaveBeenCalledTimes(1) - expect(e.preventDefault).toHaveBeenCalled() - }) - - it('triggert submit op Ctrl+Enter', () => { - const submit = vi.fn() - const handler = useDialogSubmitShortcut(submit) - const e = makeEvent({ ctrlKey: true, key: 'Enter' }) - - handler(e) - - expect(submit).toHaveBeenCalledTimes(1) - }) - - it('triggert NIET op Enter zonder modifier', () => { - const submit = vi.fn() - const handler = useDialogSubmitShortcut(submit) - const e = makeEvent({ key: 'Enter' }) - - handler(e) - - expect(submit).not.toHaveBeenCalled() - expect(e.preventDefault).not.toHaveBeenCalled() - }) - - it('triggert NIET op Cmd+andere toets', () => { - const submit = vi.fn() - const handler = useDialogSubmitShortcut(submit) - const e = makeEvent({ metaKey: true, key: 'a' }) - - handler(e) - - expect(submit).not.toHaveBeenCalled() - }) -}) diff --git a/__tests__/components/use-dirty-close-guard.test.tsx b/__tests__/components/use-dirty-close-guard.test.tsx deleted file mode 100644 index 1220817..0000000 --- a/__tests__/components/use-dirty-close-guard.test.tsx +++ /dev/null @@ -1,50 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi } from 'vitest' -import { renderHook, act } from '@testing-library/react' -import { useDirtyCloseGuard } from '@/components/shared/use-dirty-close-guard' - -describe('useDirtyCloseGuard', () => { - it('sluit direct als form niet dirty is', () => { - const onClose = vi.fn() - const { result } = renderHook(() => useDirtyCloseGuard(false, onClose)) - - act(() => result.current.attemptClose()) - - expect(onClose).toHaveBeenCalledTimes(1) - expect(result.current.confirmOpen).toBe(false) - }) - - it('opent confirm als form dirty is', () => { - const onClose = vi.fn() - const { result } = renderHook(() => useDirtyCloseGuard(true, onClose)) - - act(() => result.current.attemptClose()) - - expect(onClose).not.toHaveBeenCalled() - expect(result.current.confirmOpen).toBe(true) - }) - - it('confirmDiscard sluit confirm en roept onClose', () => { - const onClose = vi.fn() - const { result } = renderHook(() => useDirtyCloseGuard(true, onClose)) - - act(() => result.current.attemptClose()) - expect(result.current.confirmOpen).toBe(true) - - act(() => result.current.confirmDiscard()) - - expect(onClose).toHaveBeenCalledTimes(1) - expect(result.current.confirmOpen).toBe(false) - }) - - it('setConfirmOpen(false) annuleert zonder onClose te roepen', () => { - const onClose = vi.fn() - const { result } = renderHook(() => useDirtyCloseGuard(true, onClose)) - - act(() => result.current.attemptClose()) - act(() => result.current.setConfirmOpen(false)) - - expect(onClose).not.toHaveBeenCalled() - expect(result.current.confirmOpen).toBe(false) - }) -}) diff --git a/__tests__/hooks/use-jobs-realtime.test.tsx b/__tests__/hooks/use-jobs-realtime.test.tsx deleted file mode 100644 index 49b9817..0000000 --- a/__tests__/hooks/use-jobs-realtime.test.tsx +++ /dev/null @@ -1,147 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { renderHook, act } from '@testing-library/react' -import { useJobsStore } from '@/stores/jobs-store' -import useJobsRealtime from '@/hooks/use-jobs-realtime' - -type Listener = (event: { data: string }) => void - -class MockEventSource { - static instance: MockEventSource | null = null - private listeners: Record<string, Listener[]> = {} - onerror: (() => void) | null = null - - constructor(_url: string) { - MockEventSource.instance = this - } - - addEventListener(type: string, listener: Listener) { - if (!this.listeners[type]) this.listeners[type] = [] - this.listeners[type].push(listener) - } - - dispatch(type: string, data: unknown) { - for (const l of this.listeners[type] ?? []) { - l({ data: JSON.stringify(data) }) - } - } - - close() {} -} - -const fullJob = { - id: 'job-unknown-1', - kind: 'TASK_IMPLEMENTATION', - status: 'RUNNING', - taskCode: 'T-1', - taskTitle: 'Test', - ideaCode: null, - ideaTitle: null, - sprintGoal: null, - sprintCode: null, - productName: 'Scrum4Me', - productCode: null, - storyCode: null, - pbiCode: null, - modelId: null, - inputTokens: null, - outputTokens: null, - cacheReadTokens: null, - cacheWriteTokens: null, - costUsd: null, - branch: null, - prUrl: null, - error: null, - summary: null, - description: null, - verifyResult: null, - startedAt: null, - finishedAt: null, - createdAt: new Date('2026-01-01'), - sprintRunId: null, -} - -beforeEach(() => { - vi.stubGlobal('EventSource', MockEventSource) - MockEventSource.instance = null - - // Lege store - useJobsStore.setState({ activeJobs: [], doneJobs: [], selectedJobId: null }) - - // fetch resolveert naar de volledige job - vi.stubGlobal( - 'fetch', - vi.fn().mockImplementation(async () => ({ - ok: true, - json: async () => fullJob, - })) - ) -}) - -afterEach(() => { - vi.unstubAllGlobals() - vi.restoreAllMocks() -}) - -describe('useJobsRealtime: fetch-on-unknown', () => { - it('haalt onbekende job op via REST bij message-event', async () => { - renderHook(() => useJobsRealtime()) - const es = MockEventSource.instance! - - // Dispatch twee events met hetzelfde onbekende job_id gelijktijdig - act(() => { - es.dispatch('message', { job_id: 'job-unknown-1', status: 'RUNNING' }) - es.dispatch('message', { job_id: 'job-unknown-1', status: 'RUNNING' }) - }) - - // Wacht op alle microtasks / fetch-promises - await act(async () => { - await Promise.resolve() - }) - - expect(fetch).toHaveBeenCalledTimes(1) - expect(fetch).toHaveBeenCalledWith('/api/jobs/job-unknown-1') - - const { activeJobs } = useJobsStore.getState() - expect(activeJobs.some(j => j.id === 'job-unknown-1')).toBe(true) - expect(activeJobs.find(j => j.id === 'job-unknown-1')?.taskTitle).toBe('Test') - }) - - it('gebruikt partial-upsert voor bekende jobs bij message-event', async () => { - // Zet een bekende job in de store - useJobsStore.setState({ - activeJobs: [{ ...fullJob, id: 'job-known-1', status: 'QUEUED' } as never], - doneJobs: [], - selectedJobId: null, - }) - - renderHook(() => useJobsRealtime()) - const es = MockEventSource.instance! - - act(() => { - es.dispatch('message', { job_id: 'job-known-1', status: 'RUNNING', branch: 'feat/x' }) - }) - - await act(async () => { await Promise.resolve() }) - - expect(fetch).not.toHaveBeenCalled() - const { activeJobs } = useJobsStore.getState() - expect(activeJobs.find(j => j.id === 'job-known-1')?.status).toBe('RUNNING') - }) - - it('haalt onbekende job op via REST bij jobs_initial-event', async () => { - renderHook(() => useJobsRealtime()) - const es = MockEventSource.instance! - - act(() => { - es.dispatch('jobs_initial', [{ job_id: 'job-unknown-1', status: 'RUNNING' }]) - }) - - await act(async () => { await Promise.resolve() }) - - expect(fetch).toHaveBeenCalledTimes(1) - expect(fetch).toHaveBeenCalledWith('/api/jobs/job-unknown-1') - const { activeJobs } = useJobsStore.getState() - expect(activeJobs.some(j => j.id === 'job-unknown-1')).toBe(true) - }) -}) diff --git a/__tests__/lib/active-sprint.test.ts b/__tests__/lib/active-sprint.test.ts deleted file mode 100644 index b2de7ef..0000000 --- a/__tests__/lib/active-sprint.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('@/lib/prisma', () => ({ - prisma: { - sprint: { findFirst: vi.fn() }, - user: { - findUnique: vi.fn(), - update: vi.fn().mockResolvedValue({}), - }, - $executeRaw: vi.fn().mockResolvedValue(1), - }, -})) - -import { prisma } from '@/lib/prisma' -import type { UserSettings } from '@/lib/user-settings' -import { - clearActiveSprintInSettings, - readStoredActiveSprintState, - resolveActiveSprint, -} from '@/lib/active-sprint' - -const mockPrisma = prisma as unknown as { - sprint: { findFirst: ReturnType<typeof vi.fn> } - user: { - findUnique: ReturnType<typeof vi.fn> - update: ReturnType<typeof vi.fn> - } - $executeRaw: ReturnType<typeof vi.fn> -} - -function withSettings(settings: UserSettings) { - mockPrisma.user.findUnique.mockResolvedValueOnce({ settings }) -} - -describe('readStoredActiveSprintState', () => { - it('returns unset when activeSprints map is absent', () => { - expect(readStoredActiveSprintState({}, 'p1')).toEqual({ kind: 'unset' }) - }) - - it('returns unset when productId key is absent', () => { - const settings: UserSettings = { - layout: { activeSprints: { p2: 'sprint-2' } }, - } - expect(readStoredActiveSprintState(settings, 'p1')).toEqual({ - kind: 'unset', - }) - }) - - it('returns cleared when key is present with null value', () => { - const settings: UserSettings = { - layout: { activeSprints: { p1: null } }, - } - expect(readStoredActiveSprintState(settings, 'p1')).toEqual({ - kind: 'cleared', - }) - }) - - it('returns set when key is present with string value', () => { - const settings: UserSettings = { - layout: { activeSprints: { p1: 'sprint-1' } }, - } - expect(readStoredActiveSprintState(settings, 'p1')).toEqual({ - kind: 'set', - sprintId: 'sprint-1', - }) - }) -}) - -describe('resolveActiveSprint', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('returns null without fallback when key is explicitly null (cleared)', async () => { - withSettings({ layout: { activeSprints: { p1: null } } }) - - const result = await resolveActiveSprint('p1', 'user-1') - - expect(result).toBeNull() - expect(mockPrisma.sprint.findFirst).not.toHaveBeenCalled() - }) - - it('returns the stored sprint when key is set and sprint exists', async () => { - withSettings({ layout: { activeSprints: { p1: 'sprint-1' } } }) - mockPrisma.sprint.findFirst.mockResolvedValueOnce({ - id: 'sprint-1', - code: 'SP-1', - status: 'OPEN', - }) - - const result = await resolveActiveSprint('p1', 'user-1') - - expect(result).toEqual({ id: 'sprint-1', code: 'SP-1', status: 'OPEN' }) - expect(mockPrisma.sprint.findFirst).toHaveBeenCalledTimes(1) - }) - - it('falls back when stored sprint is not found in DB', async () => { - withSettings({ layout: { activeSprints: { p1: 'stale-id' } } }) - mockPrisma.sprint.findFirst - .mockResolvedValueOnce(null) // stored lookup misses - .mockResolvedValueOnce({ id: 'sprint-open', code: 'SP-O', status: 'OPEN' }) - - const result = await resolveActiveSprint('p1', 'user-1') - - expect(result).toEqual({ - id: 'sprint-open', - code: 'SP-O', - status: 'OPEN', - }) - }) - - it('falls back to first OPEN sprint when key is absent', async () => { - withSettings({}) - mockPrisma.sprint.findFirst.mockResolvedValueOnce({ - id: 'sprint-open', - code: 'SP-O', - status: 'OPEN', - }) - - const result = await resolveActiveSprint('p1', 'user-1') - - expect(result).toEqual({ - id: 'sprint-open', - code: 'SP-O', - status: 'OPEN', - }) - }) - - it('falls back to recent CLOSED sprint when no OPEN exists', async () => { - withSettings({}) - mockPrisma.sprint.findFirst - .mockResolvedValueOnce(null) // no OPEN - .mockResolvedValueOnce({ - id: 'sprint-closed', - code: 'SP-C', - status: 'CLOSED', - }) - - const result = await resolveActiveSprint('p1', 'user-1') - - expect(result).toEqual({ - id: 'sprint-closed', - code: 'SP-C', - status: 'CLOSED', - }) - }) - - it('returns null when key absent and no sprints exist', async () => { - withSettings({}) - mockPrisma.sprint.findFirst.mockResolvedValue(null) - - const result = await resolveActiveSprint('p1', 'user-1') - - expect(result).toBeNull() - }) -}) - -describe('clearActiveSprintInSettings', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('writes null instead of deleting the key', async () => { - withSettings({ - layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } }, - }) - - await clearActiveSprintInSettings('user-1', 'p1') - - expect(mockPrisma.user.update).toHaveBeenCalledTimes(1) - const updateArg = mockPrisma.user.update.mock.calls[0][0] as { - data: { settings: UserSettings } - } - expect(updateArg.data.settings.layout?.activeSprints).toEqual({ - p1: null, - p2: 'sprint-2', - }) - }) - - it('adds the key with null when previously unset', async () => { - withSettings({}) - - await clearActiveSprintInSettings('user-1', 'p1') - - const updateArg = mockPrisma.user.update.mock.calls[0][0] as { - data: { settings: UserSettings } - } - expect(updateArg.data.settings.layout?.activeSprints).toEqual({ p1: null }) - }) -}) diff --git a/__tests__/lib/auth-guard.test.ts b/__tests__/lib/auth-guard.test.ts deleted file mode 100644 index ebfa9a5..0000000 --- a/__tests__/lib/auth-guard.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' - -const getSessionMock = vi.fn() -const isPairedSessionExpiredMock = vi.fn() -const redirectMock = vi.fn(() => { throw new Error('REDIRECT_CALLED') }) -const prismaUserRoleFindFirstMock = vi.fn() - -vi.mock('@/lib/auth', () => ({ getSession: getSessionMock })) -vi.mock('@/lib/auth/pairing', () => ({ isPairedSessionExpired: isPairedSessionExpiredMock })) -vi.mock('next/navigation', () => ({ redirect: redirectMock })) -vi.mock('@/lib/prisma', () => ({ - prisma: { userRole: { findFirst: prismaUserRoleFindFirstMock } }, -})) - -describe('requireSession', () => { - beforeEach(() => { - getSessionMock.mockReset() - isPairedSessionExpiredMock.mockReset() - redirectMock.mockClear() - }) - - afterEach(() => { - vi.resetModules() - }) - - it('redirect /login als userId ontbreekt', async () => { - getSessionMock.mockResolvedValue({ userId: undefined, destroy: vi.fn() }) - isPairedSessionExpiredMock.mockReturnValue(false) - const { requireSession } = await import('@/lib/auth-guard') - await expect(requireSession()).rejects.toThrow('REDIRECT_CALLED') - expect(redirectMock).toHaveBeenCalledWith('/login') - }) - - it('vernietigt + redirect /login als paired-sessie verlopen is', async () => { - const destroy = vi.fn().mockResolvedValue(undefined) - getSessionMock.mockResolvedValue({ userId: 'u1', destroy }) - isPairedSessionExpiredMock.mockReturnValue(true) - const { requireSession } = await import('@/lib/auth-guard') - await expect(requireSession()).rejects.toThrow('REDIRECT_CALLED') - expect(destroy).toHaveBeenCalled() - expect(redirectMock).toHaveBeenCalledWith('/login') - }) - - it('geeft sessie terug als alles ok', async () => { - const sess = { userId: 'u1', destroy: vi.fn() } - getSessionMock.mockResolvedValue(sess) - isPairedSessionExpiredMock.mockReturnValue(false) - const { requireSession } = await import('@/lib/auth-guard') - const result = await requireSession() - expect(result).toBe(sess) - expect(redirectMock).not.toHaveBeenCalled() - }) -}) diff --git a/__tests__/lib/chart-colors.test.ts b/__tests__/lib/chart-colors.test.ts index dc316bd..b8d0be2 100644 --- a/__tests__/lib/chart-colors.test.ts +++ b/__tests__/lib/chart-colors.test.ts @@ -34,7 +34,7 @@ describe('chart-colors', () => { it('JOB_STATUS_COLORS has all ClaudeJobStatus keys and non-empty values', () => { const keys: (keyof typeof JOB_STATUS_COLORS)[] = [ - 'queued', 'claimed', 'running', 'done', 'failed', 'cancelled', 'skipped', + 'queued', 'claimed', 'running', 'done', 'failed', 'cancelled', ] for (const key of keys) { expect(JOB_STATUS_COLORS[key]).toBeTruthy() diff --git a/__tests__/lib/code.test.ts b/__tests__/lib/code.test.ts deleted file mode 100644 index 7b83640..0000000 --- a/__tests__/lib/code.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, it, expect } from 'vitest' - -import { parseCodeNumber } from '@/lib/code' - -describe('parseCodeNumber', () => { - it('parses a standard story code', () => { - expect(parseCodeNumber('ST-001')).toBe(1) - }) - - it('parses a task code', () => { - expect(parseCodeNumber('T-42')).toBe(42) - }) - - it('parses a large number', () => { - expect(parseCodeNumber('ST-1000')).toBe(1000) - }) - - it('returns MAX_SAFE_INTEGER for a code with no trailing digits', () => { - expect(parseCodeNumber('FOO')).toBe(Number.MAX_SAFE_INTEGER) - }) - - it('returns MAX_SAFE_INTEGER for an empty string', () => { - expect(parseCodeNumber('')).toBe(Number.MAX_SAFE_INTEGER) - }) -}) diff --git a/__tests__/lib/debug.test.ts b/__tests__/lib/debug.test.ts deleted file mode 100644 index 12a1e33..0000000 --- a/__tests__/lib/debug.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' - -import { debugProps } from '@/lib/debug' - -describe('debugProps', () => { - it('returns data-debug-id attr in dev mode', () => { - const result = debugProps('sprint-board', 'SprintBoard', 'components/sprint/sprint-board.tsx') - expect(result).toEqual({ - 'data-debug-id': 'sprint-board', - }) - }) - - it('returns empty object in production mode', () => { - const original = process.env.NODE_ENV - try { - vi.stubEnv('NODE_ENV', 'production') - const result = debugProps('sprint-board', 'SprintBoard', 'components/sprint/sprint-board.tsx') - expect(result).toEqual({}) - } finally { - vi.stubEnv('NODE_ENV', original ?? 'test') - } - }) -}) diff --git a/__tests__/lib/idea-code.test.ts b/__tests__/lib/idea-code.test.ts deleted file mode 100644 index f0a9150..0000000 --- a/__tests__/lib/idea-code.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, it, expect } from 'vitest' - -import { formatIdeaCode } from '@/lib/idea-code' - -describe('formatIdeaCode', () => { - it('pads to 3 digits', () => { - expect(formatIdeaCode(1)).toBe('IDEA-001') - expect(formatIdeaCode(42)).toBe('IDEA-042') - expect(formatIdeaCode(999)).toBe('IDEA-999') - }) - - it('does not truncate beyond pad-width', () => { - expect(formatIdeaCode(1000)).toBe('IDEA-1000') - expect(formatIdeaCode(99999)).toBe('IDEA-99999') - }) -}) - -// Integration-style concurrency-test op nextIdeaCode is in -// __tests__/integration/ tests die de echte DB raken (zie M12 verificatie-stap). -// Hier alleen de pure formatter; de increment-logica leunt op Prisma's -// row-lock in $transaction die we per-database vertrouwen. diff --git a/__tests__/lib/idea-plan-parser.test.ts b/__tests__/lib/idea-plan-parser.test.ts deleted file mode 100644 index c279ea8..0000000 --- a/__tests__/lib/idea-plan-parser.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, it, expect } from 'vitest' - -import { parsePlanMd } from '@/lib/idea-plan-parser' - -const VALID = `--- -pbi: - title: Test PBI - priority: 2 -stories: - - title: Eerste flow - priority: 2 - tasks: - - title: Setup - priority: 2 - implementation_plan: | - 1. Doe X - 2. Doe Y ---- - -# Overwegingen - -Dit is de body, niet geparsed. -` - -describe('parsePlanMd', () => { - it('parses a valid plan', () => { - const r = parsePlanMd(VALID) - expect(r.ok).toBe(true) - if (r.ok) { - expect(r.plan.pbi.title).toBe('Test PBI') - expect(r.plan.stories).toHaveLength(1) - expect(r.plan.stories[0].tasks).toHaveLength(1) - expect(r.plan.stories[0].tasks[0].implementation_plan).toContain('Doe X') - expect(r.body).toContain('# Overwegingen') - } - }) - - it('rejects when frontmatter is missing', () => { - const r = parsePlanMd('# Just markdown\n\nNo frontmatter here.') - expect(r.ok).toBe(false) - if (!r.ok) { - expect(r.errors[0].line).toBe(1) - expect(r.errors[0].message).toMatch(/frontmatter/i) - } - }) - - it('reports yaml syntax error with line info', () => { - const broken = `--- -pbi: - title: Test - priority: [unclosed -stories: - - foo ---- - -body -` - const r = parsePlanMd(broken) - expect(r.ok).toBe(false) - if (!r.ok) { - expect(r.errors[0].message.length).toBeGreaterThan(0) - } - }) - - it('hints when markdown sneaks into frontmatter', () => { - // "1. **...**: [unclosed" triggers a YAMLParseError at the markdown line - // (plain-list-with-bold parses as valid YAML without an unclosed flow) - const broken = `--- -pbi: - title: Test - priority: 2 -stories: -1. **Toggle zichtbaar in productie**: [unclosed ---- - -body -` - const r = parsePlanMd(broken) - expect(r.ok).toBe(false) - if (!r.ok) { - expect(r.errors[0].hint).toMatch(/markdown/i) - expect(r.errors[0].line).toBeGreaterThan(1) - } - }) - - it('omits hint for non-markdown yaml errors', () => { - const broken = `--- -pbi: - title: Test - priority: [unclosed -stories: - - foo ---- -` - const r = parsePlanMd(broken) - expect(r.ok).toBe(false) - if (!r.ok) expect(r.errors[0].hint).toBeUndefined() - }) - - it('reports schema-validation error when pbi-section missing', () => { - const noPbi = `--- -stories: - - title: x - priority: 2 - tasks: - - title: y - priority: 2 ---- - -body -` - const r = parsePlanMd(noPbi) - expect(r.ok).toBe(false) - if (!r.ok) { - expect(r.errors.some((e) => e.message.includes('pbi'))).toBe(true) - } - }) - - it('rejects empty stories array', () => { - const noStories = `--- -pbi: - title: x - priority: 2 -stories: [] ---- - -body -` - const r = parsePlanMd(noStories) - expect(r.ok).toBe(false) - }) - - it('handles CRLF line endings', () => { - const crlf = VALID.replace(/\n/g, '\r\n') - const r = parsePlanMd(crlf) - expect(r.ok).toBe(true) - }) -}) diff --git a/__tests__/lib/idea-schemas.test.ts b/__tests__/lib/idea-schemas.test.ts deleted file mode 100644 index 637ce1c..0000000 --- a/__tests__/lib/idea-schemas.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { describe, it, expect } from 'vitest' - -import { - ideaCreateSchema, - ideaUpdateSchema, - ideaPlanMdFrontmatterSchema, -} from '@/lib/schemas/idea' - -describe('ideaCreateSchema', () => { - it('accepts minimal valid input', () => { - const r = ideaCreateSchema.safeParse({ title: 'Plant-watering reminder' }) - expect(r.success).toBe(true) - }) - - it('trims and enforces non-empty title', () => { - const r = ideaCreateSchema.safeParse({ title: ' ' }) - expect(r.success).toBe(false) - }) - - it('rejects oversized title and description', () => { - expect(ideaCreateSchema.safeParse({ title: 'x'.repeat(201) }).success).toBe(false) - expect( - ideaCreateSchema.safeParse({ title: 'ok', description: 'x'.repeat(4001) }).success, - ).toBe(false) - }) - - it('accepts cuid-like product_id', () => { - const r = ideaCreateSchema.safeParse({ - title: 'Idee', - product_id: 'cmohrysyj0000rd17clnjy4tc', - }) - expect(r.success).toBe(true) - }) - - it('rejects non-cuid product_id', () => { - const r = ideaCreateSchema.safeParse({ title: 'Idee', product_id: 'not-a-cuid' }) - expect(r.success).toBe(false) - }) -}) - -describe('ideaUpdateSchema', () => { - it('allows empty object (no-op update)', () => { - expect(ideaUpdateSchema.safeParse({}).success).toBe(true) - }) - - it('allows partial title update', () => { - expect(ideaUpdateSchema.safeParse({ title: 'Updated' }).success).toBe(true) - }) -}) - -describe('ideaPlanMdFrontmatterSchema', () => { - const validPlan = { - pbi: { title: 'Test PBI', priority: 2 }, - stories: [ - { - title: 'Eerste flow', - priority: 2, - tasks: [ - { title: 'Setup', priority: 2, implementation_plan: '1. Doe X' }, - ], - }, - ], - } - - it('accepts a minimal valid plan', () => { - expect(ideaPlanMdFrontmatterSchema.safeParse(validPlan).success).toBe(true) - }) - - it('requires at least one story', () => { - const r = ideaPlanMdFrontmatterSchema.safeParse({ ...validPlan, stories: [] }) - expect(r.success).toBe(false) - }) - - it('requires at least one task per story', () => { - const r = ideaPlanMdFrontmatterSchema.safeParse({ - ...validPlan, - stories: [{ ...validPlan.stories[0], tasks: [] }], - }) - expect(r.success).toBe(false) - }) - - it('validates priority bounds 1-4', () => { - expect( - ideaPlanMdFrontmatterSchema.safeParse({ - ...validPlan, - pbi: { ...validPlan.pbi, priority: 5 }, - }).success, - ).toBe(false) - expect( - ideaPlanMdFrontmatterSchema.safeParse({ - ...validPlan, - pbi: { ...validPlan.pbi, priority: 0 }, - }).success, - ).toBe(false) - }) - - it('accepts optional verify_required + verify_only', () => { - const r = ideaPlanMdFrontmatterSchema.safeParse({ - ...validPlan, - stories: [ - { - ...validPlan.stories[0], - tasks: [ - { - title: 'Verify-only task', - priority: 2, - verify_required: 'ALIGNED_OR_PARTIAL', - verify_only: true, - }, - ], - }, - ], - }) - expect(r.success).toBe(true) - }) - - it('rejects invalid verify_required enum', () => { - const r = ideaPlanMdFrontmatterSchema.safeParse({ - ...validPlan, - stories: [ - { - ...validPlan.stories[0], - tasks: [ - { title: 't', priority: 2, verify_required: 'INVALID' }, - ], - }, - ], - }) - expect(r.success).toBe(false) - }) - - it('accepts plan with task.priority omitted (inherits story-priority via materialize)', () => { - const r = ideaPlanMdFrontmatterSchema.safeParse({ - ...validPlan, - stories: [ - { - title: 'Story zonder task-priorities', - priority: 2, - tasks: [ - { title: 'Taak 1' }, // geen priority — moet geaccepteerd - { title: 'Taak 2', verify_required: 'ALIGNED' }, - ], - }, - ], - }) - expect(r.success).toBe(true) - }) -}) diff --git a/__tests__/lib/idea-status.test.ts b/__tests__/lib/idea-status.test.ts deleted file mode 100644 index b72692c..0000000 --- a/__tests__/lib/idea-status.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { describe, it, expect } from 'vitest' - -import { - ideaStatusToApi, - ideaStatusFromApi, - canTransition, - isIdeaEditable, - isGrillMdEditable, - isPlanMdEditable, - IDEA_STATUS_API_VALUES, -} from '@/lib/idea-status' - -describe('idea-status mappers', () => { - it('round-trips every API value', () => { - for (const api of IDEA_STATUS_API_VALUES) { - const db = ideaStatusFromApi(api) - expect(db).not.toBeNull() - expect(ideaStatusToApi(db!)).toBe(api) - } - }) - - it('returns null for invalid input', () => { - expect(ideaStatusFromApi('NOT_A_STATUS')).toBeNull() - }) - - it('is case-insensitive on the API side', () => { - expect(ideaStatusFromApi('PLAN_READY')).toBe('PLAN_READY') - expect(ideaStatusFromApi('Plan_Ready')).toBe('PLAN_READY') - }) -}) - -describe('canTransition', () => { - it('allows valid forward transitions', () => { - expect(canTransition('DRAFT', 'GRILLING')).toBe(true) - expect(canTransition('GRILLING', 'GRILLED')).toBe(true) - expect(canTransition('GRILLED', 'PLANNING')).toBe(true) - expect(canTransition('PLANNING', 'PLAN_READY')).toBe(true) - expect(canTransition('PLAN_READY', 'PLANNED')).toBe(true) - }) - - it('allows re-grill from GRILLED and PLAN_READY-ish states', () => { - expect(canTransition('GRILLED', 'GRILLING')).toBe(true) - expect(canTransition('PLAN_FAILED', 'PLANNING')).toBe(true) - expect(canTransition('PLAN_READY', 'GRILLING')).toBe(true) - }) - - it('allows fail-side transitions', () => { - expect(canTransition('GRILLING', 'GRILL_FAILED')).toBe(true) - expect(canTransition('PLANNING', 'PLAN_FAILED')).toBe(true) - }) - - it('allows recovery from failed states', () => { - expect(canTransition('GRILL_FAILED', 'GRILLING')).toBe(true) - expect(canTransition('PLAN_FAILED', 'GRILLED')).toBe(true) - }) - - it('allows PLANNED → PLAN_READY (relink) and PLANNED → GRILLING (re-grill)', () => { - expect(canTransition('PLANNED', 'PLAN_READY')).toBe(true) - expect(canTransition('PLANNED', 'GRILLING')).toBe(true) - expect(canTransition('PLANNED', 'DRAFT')).toBe(false) - }) - - it('canTransition to GRILLING from all statuses that allow re-grill', () => { - // GRILL_TRIGGERABLE_FROM in actions/ideas.ts — alle statussen die re-grill ondersteunen. - const regrill = ['DRAFT', 'GRILLED', 'GRILL_FAILED', 'PLAN_READY', 'PLANNED'] as const - for (const status of regrill) { - expect(canTransition(status, 'GRILLING')).toBe(true) - } - }) - - it('rejects invalid jumps', () => { - expect(canTransition('DRAFT', 'PLANNED')).toBe(false) - expect(canTransition('DRAFT', 'PLAN_READY')).toBe(false) - expect(canTransition('GRILLING', 'PLANNED')).toBe(false) - }) -}) - -describe('isIdeaEditable', () => { - it('allows edit in non-running, non-PLANNED states', () => { - expect(isIdeaEditable('DRAFT')).toBe(true) - expect(isIdeaEditable('GRILLED')).toBe(true) - expect(isIdeaEditable('GRILL_FAILED')).toBe(true) - expect(isIdeaEditable('PLAN_FAILED')).toBe(true) - expect(isIdeaEditable('PLAN_READY')).toBe(true) - }) - - it('blocks edit while a job is running or after PLANNED', () => { - expect(isIdeaEditable('GRILLING')).toBe(false) - expect(isIdeaEditable('PLANNING')).toBe(false) - expect(isIdeaEditable('PLANNED')).toBe(false) - }) -}) - -describe('isGrillMdEditable / isPlanMdEditable', () => { - it('grill_md only editable in GRILLED or PLAN_READY', () => { - expect(isGrillMdEditable('GRILLED')).toBe(true) - expect(isGrillMdEditable('PLAN_READY')).toBe(true) - expect(isGrillMdEditable('DRAFT')).toBe(false) - expect(isGrillMdEditable('PLANNED')).toBe(false) - }) - - it('plan_md only editable in PLAN_READY', () => { - expect(isPlanMdEditable('PLAN_READY')).toBe(true) - expect(isPlanMdEditable('GRILLED')).toBe(false) - expect(isPlanMdEditable('PLAN_FAILED')).toBe(false) - expect(isPlanMdEditable('PLANNED')).toBe(false) - }) -}) diff --git a/__tests__/lib/insights/agent-throughput.test.ts b/__tests__/lib/insights/agent-throughput.test.ts index 31bf46d..3465dd4 100644 --- a/__tests__/lib/insights/agent-throughput.test.ts +++ b/__tests__/lib/insights/agent-throughput.test.ts @@ -48,7 +48,7 @@ describe('getJobsPerDay', () => { // All days should have zero counts except the three we seeded const nonZero = result.perDay.filter( - d => d.done + d.failed + d.queued + d.claimed + d.running + d.cancelled + d.skipped > 0, + d => d.done + d.failed + d.queued + d.claimed + d.running + d.cancelled > 0, ) expect(nonZero).toHaveLength(3) diff --git a/__tests__/lib/insights/token-history.test.ts b/__tests__/lib/insights/token-history.test.ts deleted file mode 100644 index 39439b8..0000000 --- a/__tests__/lib/insights/token-history.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -const { mockQueryRaw } = vi.hoisted(() => ({ mockQueryRaw: vi.fn() })) - -vi.mock('@/lib/prisma', () => ({ - prisma: { $queryRaw: mockQueryRaw }, -})) - -import { - getSprintTokenHistory, - getDayTokenData, - getPbiTokenAggregates, -} from '@/lib/insights/token-history' - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('getSprintTokenHistory', () => { - it('returns mapped sprint rows', async () => { - mockQueryRaw.mockResolvedValueOnce([ - { sprint_id: 'sp-1', sprint_goal: 'Goal A', total_tokens: BigInt(5000), total_cost: 0.1, job_count: BigInt(2) }, - ]) - const rows = await getSprintTokenHistory('user-1') - expect(rows).toHaveLength(1) - expect(rows[0].sprintId).toBe('sp-1') - expect(rows[0].totalTokens).toBe(5000) - expect(rows[0].totalCostUsd).toBe(0.1) - expect(rows[0].jobCount).toBe(2) - }) - - it('returns zero cost when total_cost is null', async () => { - mockQueryRaw.mockResolvedValueOnce([ - { sprint_id: 'sp-2', sprint_goal: 'Goal B', total_tokens: BigInt(0), total_cost: null, job_count: BigInt(0) }, - ]) - const rows = await getSprintTokenHistory('user-1') - expect(rows[0].totalCostUsd).toBe(0) - }) -}) - -describe('getDayTokenData', () => { - it('returns empty array for empty sprintId', async () => { - const rows = await getDayTokenData('user-1', '') - expect(rows).toHaveLength(0) - expect(mockQueryRaw).not.toHaveBeenCalled() - }) - - it('maps day rows with ISO date string', async () => { - mockQueryRaw.mockResolvedValueOnce([ - { day: new Date('2026-05-01T00:00:00Z'), total_tokens: BigInt(2000), total_cost: 0.05 }, - ]) - const rows = await getDayTokenData('user-1', 'sprint-1') - expect(rows).toHaveLength(1) - expect(rows[0].day).toBe('2026-05-01') - expect(rows[0].totalTokens).toBe(2000) - }) -}) - -describe('getPbiTokenAggregates', () => { - it('returns empty array for empty sprintId', async () => { - const rows = await getPbiTokenAggregates('user-1', '') - expect(rows).toHaveLength(0) - expect(mockQueryRaw).not.toHaveBeenCalled() - }) - - it('maps pbi rows', async () => { - mockQueryRaw.mockResolvedValueOnce([ - { pbi_id: 'pbi-1', pbi_code: 'M1', pbi_title: 'First PBI', total_tokens: BigInt(3000), total_cost: 0.08 }, - ]) - const rows = await getPbiTokenAggregates('user-1', 'sprint-1') - expect(rows[0].pbiCode).toBe('M1') - expect(rows[0].totalTokens).toBe(3000) - }) -}) diff --git a/__tests__/lib/insights/token-stats.test.ts b/__tests__/lib/insights/token-stats.test.ts deleted file mode 100644 index 8614292..0000000 --- a/__tests__/lib/insights/token-stats.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -const { mockQueryRaw } = vi.hoisted(() => ({ mockQueryRaw: vi.fn() })) - -vi.mock('@/lib/prisma', () => ({ - prisma: { $queryRaw: mockQueryRaw }, -})) - -import { getTokenStats } from '@/lib/insights/token-stats' - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('getTokenStats', () => { - it('returns empty result for empty sprintId', async () => { - const result = await getTokenStats('user-1', '') - - expect(result.kpi.totalTokens).toBe(0) - expect(result.kpi.totalCostUsd).toBe(0) - expect(result.kpi.avgCostPerJob).toBe(0) - expect(result.kpi.jobCount).toBe(0) - expect(result.jobs).toHaveLength(0) - expect(mockQueryRaw).not.toHaveBeenCalled() - }) - - it('maps kpi rows correctly', async () => { - const kpiRows = [{ total_tokens: BigInt(10000), total_cost: 0.15, avg_cost: 0.05, job_count: BigInt(3) }] - const jobRows: unknown[] = [] - mockQueryRaw.mockResolvedValueOnce(kpiRows).mockResolvedValueOnce(jobRows) - - const result = await getTokenStats('user-1', 'sprint-1') - - expect(result.kpi.totalTokens).toBe(10000) - expect(result.kpi.totalCostUsd).toBe(0.15) - expect(result.kpi.avgCostPerJob).toBe(0.05) - expect(result.kpi.jobCount).toBe(3) - }) - - it('maps job rows and handles null token data', async () => { - const kpiRows = [{ total_tokens: BigInt(0), total_cost: null, avg_cost: null, job_count: BigInt(0) }] - const jobRows = [ - { - job_id: 'job-1', - task_title: 'My Task', - idea_code: null, - model_id: 'claude-sonnet-4-6', - input_tokens: null, - output_tokens: null, - cache_read_tokens: null, - cache_write_tokens: null, - cost_usd: null, - duration_seconds: 42, - }, - ] - mockQueryRaw.mockResolvedValueOnce(kpiRows).mockResolvedValueOnce(jobRows) - - const result = await getTokenStats('user-1', 'sprint-1') - - expect(result.jobs).toHaveLength(1) - const job = result.jobs[0] - expect(job.jobId).toBe('job-1') - expect(job.taskTitle).toBe('My Task') - expect(job.costUsd).toBeNull() - expect(job.durationSeconds).toBe(42) - }) -}) diff --git a/__tests__/lib/job-config.test.ts b/__tests__/lib/job-config.test.ts deleted file mode 100644 index 16b90b5..0000000 --- a/__tests__/lib/job-config.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - getKindDefault, - resolveJobConfig, - mapBudgetToEffort, -} from '@/lib/job-config' - -describe('mapBudgetToEffort', () => { - it.each([ - [0, null], - [-1, null], - [1, 'medium'], - [3000, 'medium'], - [6000, 'medium'], - [6001, 'high'], - [9000, 'high'], - [12000, 'high'], - [12001, 'xhigh'], - [18000, 'xhigh'], - [24000, 'xhigh'], - [24001, 'max'], - [50000, 'max'], - [100000, 'max'], - ])('budget %i → %s', (budget, expected) => { - expect(mapBudgetToEffort(budget)).toBe(expected) - }) -}) - -describe('KIND_DEFAULTS.allowed_tools — sync met scrum4me-mcp', () => { - it('TASK_IMPLEMENTATION bevat geen claim-tools', () => { - const cfg = getKindDefault('TASK_IMPLEMENTATION') - expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job') - expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__check_queue_empty') - expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__get_idea_context') - }) - - it('TASK_IMPLEMENTATION bevat de essentiële task-tools', () => { - const cfg = getKindDefault('TASK_IMPLEMENTATION') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_status') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_task_against_plan') - expect(cfg.allowed_tools).toContain('Bash') - expect(cfg.allowed_tools).toContain('Edit') - expect(cfg.allowed_tools).toContain('Write') - }) - - it('SPRINT_IMPLEMENTATION bevat sprint-specifieke tools maar GEEN job_heartbeat (runner doet die)', () => { - const cfg = getKindDefault('SPRINT_IMPLEMENTATION') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_execution') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_sprint_task') - expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__job_heartbeat') - }) - - it('IDEA_GRILL bevat update_idea_grill_md en geen wait_for_job', () => { - const cfg = getKindDefault('IDEA_GRILL') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_grill_md') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status') - expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job') - }) - - it('IDEA_MAKE_PLAN bevat update_idea_plan_md en geen wait_for_job', () => { - const cfg = getKindDefault('IDEA_MAKE_PLAN') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_plan_md') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision') - expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job') - }) - - it('alle kinds hebben non-null allowed_tools', () => { - for (const kind of [ - 'IDEA_GRILL', - 'IDEA_MAKE_PLAN', - 'PLAN_CHAT', - 'TASK_IMPLEMENTATION', - 'SPRINT_IMPLEMENTATION', - ]) { - const cfg = getKindDefault(kind) - expect(cfg.allowed_tools).not.toBeNull() - expect(Array.isArray(cfg.allowed_tools)).toBe(true) - } - }) -}) - -describe('resolveJobConfig — cascade (regression)', () => { - it('task.requires_opus overrult product.preferred_model', () => { - const cfg = resolveJobConfig( - { kind: 'TASK_IMPLEMENTATION' }, - { preferred_model: 'claude-sonnet-4-6' }, - { requires_opus: true }, - ) - expect(cfg.model).toBe('claude-opus-4-7') - }) - - it('product.preferred_permission_mode overrult bypassPermissions', () => { - const cfg = resolveJobConfig( - { kind: 'TASK_IMPLEMENTATION' }, - { preferred_permission_mode: 'acceptEdits' }, - ) - expect(cfg.permission_mode).toBe('acceptEdits') - }) -}) diff --git a/__tests__/lib/job-status.test.ts b/__tests__/lib/job-status.test.ts index dee082e..db8d1ab 100644 --- a/__tests__/lib/job-status.test.ts +++ b/__tests__/lib/job-status.test.ts @@ -27,14 +27,13 @@ describe('job-status mappers', () => { expect(jobStatusFromApi('QUEUED')).toBe('QUEUED') }) - it('maps all 7 DB statuses to API', () => { + it('maps all 6 DB statuses to API', () => { expect(jobStatusToApi('QUEUED')).toBe('queued') expect(jobStatusToApi('CLAIMED')).toBe('claimed') expect(jobStatusToApi('RUNNING')).toBe('running') expect(jobStatusToApi('DONE')).toBe('done') expect(jobStatusToApi('FAILED')).toBe('failed') expect(jobStatusToApi('CANCELLED')).toBe('cancelled') - expect(jobStatusToApi('SKIPPED')).toBe('skipped') }) it('ACTIVE_JOB_STATUSES contains exactly QUEUED, CLAIMED, RUNNING', () => { diff --git a/__tests__/lib/jobs-time-filter.test.ts b/__tests__/lib/jobs-time-filter.test.ts deleted file mode 100644 index 3e1be4b..0000000 --- a/__tests__/lib/jobs-time-filter.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { isWithinTimeWindow } from '@/lib/jobs-time-filter' - -const HOUR_MS = 60 * 60 * 1000 - -describe('isWithinTimeWindow', () => { - it("returns true for filter='all' regardless of age", () => { - const old = new Date(0) - expect(isWithinTimeWindow(old, 'all')).toBe(true) - }) - - describe("filter='1h'", () => { - const now = Date.now() - - it('returns true for a job created 30 minutes ago', () => { - const createdAt = new Date(now - 30 * 60 * 1000) - expect(isWithinTimeWindow(createdAt, '1h', now)).toBe(true) - }) - - it('returns false for a job created 90 minutes ago', () => { - const createdAt = new Date(now - 90 * 60 * 1000) - expect(isWithinTimeWindow(createdAt, '1h', now)).toBe(false) - }) - }) - - describe("filter='24h'", () => { - const now = Date.now() - - it('returns true for a job created 23 hours ago', () => { - const createdAt = new Date(now - 23 * HOUR_MS) - expect(isWithinTimeWindow(createdAt, '24h', now)).toBe(true) - }) - - it('returns false for a job created 25 hours ago', () => { - const createdAt = new Date(now - 25 * HOUR_MS) - expect(isWithinTimeWindow(createdAt, '24h', now)).toBe(false) - }) - }) - - describe('accepts both Date and ISO string for createdAt', () => { - const now = Date.now() - const recent = new Date(now - 30 * 60 * 1000) - - it('accepts a Date object', () => { - expect(isWithinTimeWindow(recent, '1h', now)).toBe(true) - }) - - it('accepts an ISO string', () => { - expect(isWithinTimeWindow(recent.toISOString(), '1h', now)).toBe(true) - }) - }) - - it('returns true for an invalid date string (fail-open)', () => { - expect(isWithinTimeWindow('not-a-date', '1h')).toBe(true) - }) -}) diff --git a/__tests__/lib/product-switch-path.test.ts b/__tests__/lib/product-switch-path.test.ts deleted file mode 100644 index 02983e9..0000000 --- a/__tests__/lib/product-switch-path.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { resolveProductSwitchTarget } from '@/lib/product-switch-path' - -describe('resolveProductSwitchTarget', () => { - it('returns null for non-product pages', () => { - expect(resolveProductSwitchTarget('/dashboard', 'new-id')).toBeNull() - expect(resolveProductSwitchTarget('/insights', 'new-id')).toBeNull() - expect(resolveProductSwitchTarget('/ideas', 'new-id')).toBeNull() - expect(resolveProductSwitchTarget('/jobs', 'new-id')).toBeNull() - expect(resolveProductSwitchTarget('/', 'new-id')).toBeNull() - }) - - it('maps /products/<old> to /products/<new>', () => { - expect(resolveProductSwitchTarget('/products/old-id', 'new-id')).toBe('/products/new-id') - }) - - it('maps /products/<old>/ to /products/<new>', () => { - expect(resolveProductSwitchTarget('/products/old-id/', 'new-id')).toBe('/products/new-id') - }) - - it('maps /products/<old>/sprint to /products/<new>/sprint', () => { - expect(resolveProductSwitchTarget('/products/old-id/sprint', 'new-id')).toBe( - '/products/new-id/sprint', - ) - }) - - it('maps /products/<old>/sprint/<sprintId> to /products/<new>/sprint', () => { - expect(resolveProductSwitchTarget('/products/old-id/sprint/abc123', 'new-id')).toBe( - '/products/new-id/sprint', - ) - }) - - it('maps /products/<old>/sprint/.../planning to /products/<new>/sprint', () => { - expect(resolveProductSwitchTarget('/products/old-id/sprint/abc123/planning', 'new-id')).toBe( - '/products/new-id/sprint', - ) - }) - - it('maps /products/<old>/solo to /products/<new>/solo', () => { - expect(resolveProductSwitchTarget('/products/old-id/solo', 'new-id')).toBe( - '/products/new-id/solo', - ) - }) - - it('falls back to /products/<new> for /products/<old>/settings', () => { - expect(resolveProductSwitchTarget('/products/old-id/settings', 'new-id')).toBe( - '/products/new-id', - ) - }) - - it('falls back to /products/<new> for unknown sub-segments', () => { - expect(resolveProductSwitchTarget('/products/old-id/unknown/deep', 'new-id')).toBe( - '/products/new-id', - ) - }) -}) diff --git a/__tests__/lib/push-client.test.ts b/__tests__/lib/push-client.test.ts deleted file mode 100644 index 761b6e1..0000000 --- a/__tests__/lib/push-client.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' - -vi.mock('@/actions/push', () => ({ - subscribeToPushAction: vi.fn(), - unsubscribeFromPushAction: vi.fn(), -})) - -import { urlBase64ToUint8Array } from '@/lib/push-client' - -describe('urlBase64ToUint8Array', () => { - it('converts a base64url-encoded VAPID public key to Uint8Array', () => { - // 65-byte uncompressed EC public key encoded as base64url (no padding) - const base64url = 'BNMxB-LJm6XvGGiJSsYLdumcYiM7q9s_1aM9i5lI8lVzZ7GYJw1QkQFmrknwFsI4dI-e1iyvUhYHjNpHJKJD3oc' - const result = urlBase64ToUint8Array(base64url) - expect(result).toBeInstanceOf(Uint8Array) - expect(result.length).toBe(65) - expect(result[0]).toBe(0x04) // uncompressed EC point prefix - }) - - it('handles base64url with padding', () => { - // simple known vector: "hello" = aGVsbG8= in base64 - const result = urlBase64ToUint8Array('aGVsbG8') - expect(result).toBeInstanceOf(Uint8Array) - expect(Array.from(result)).toEqual([104, 101, 108, 108, 111]) // "hello" - }) - - it('converts - and _ characters correctly', () => { - // base64url uses - and _ instead of + and / - const base64standard = 'AB+/AA==' - const base64url = 'AB-_AA' - const fromStd = urlBase64ToUint8Array(base64standard) - const fromUrl = urlBase64ToUint8Array(base64url) - expect(Array.from(fromStd)).toEqual(Array.from(fromUrl)) - }) -}) diff --git a/__tests__/lib/push-server.test.ts b/__tests__/lib/push-server.test.ts deleted file mode 100644 index 87af039..0000000 --- a/__tests__/lib/push-server.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('server-only', () => ({})) - -const { mockSendNotification } = vi.hoisted(() => ({ - mockSendNotification: vi.fn(), -})) - -vi.mock('web-push', () => ({ - default: { - setVapidDetails: vi.fn(), - sendNotification: mockSendNotification, - }, -})) - -vi.hoisted(() => { - process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY = 'pk' - process.env.VAPID_PRIVATE_KEY = 'sk' - process.env.VAPID_SUBJECT = 'mailto:test@example.com' -}) - -const { mockPushSubscription } = vi.hoisted(() => ({ - mockPushSubscription: { - findMany: vi.fn(), - update: vi.fn(), - delete: vi.fn(), - }, -})) -vi.mock('@/lib/prisma', () => ({ - prisma: { pushSubscription: mockPushSubscription }, -})) - -import { sendPushToUser } from '@/lib/push-server' - -const SUB = { id: 'sub-1', endpoint: 'https://push.example.com/1', p256dh: 'p256dh', auth: 'auth' } -const PAYLOAD = { title: 'Test', body: 'Body', url: '/test' } - -beforeEach(() => { - vi.clearAllMocks() - mockPushSubscription.findMany.mockResolvedValue([SUB]) - mockPushSubscription.update.mockResolvedValue(SUB) - mockPushSubscription.delete.mockResolvedValue(SUB) -}) - -describe('sendPushToUser', () => { - it('sends notification and updates last_used_at on success', async () => { - mockSendNotification.mockResolvedValue({ statusCode: 201 }) - await sendPushToUser('user-1', PAYLOAD) - expect(mockSendNotification).toHaveBeenCalledOnce() - expect(mockPushSubscription.update).toHaveBeenCalledWith({ - where: { id: SUB.id }, - data: { last_used_at: expect.any(Date) }, - }) - }) - - it('deletes subscription on 410 (expired)', async () => { - mockSendNotification.mockRejectedValue({ statusCode: 410 }) - await sendPushToUser('user-1', PAYLOAD) - expect(mockPushSubscription.delete).toHaveBeenCalledWith({ where: { id: SUB.id } }) - expect(mockPushSubscription.update).not.toHaveBeenCalled() - }) - - it('deletes subscription on 404 (not found)', async () => { - mockSendNotification.mockRejectedValue({ statusCode: 404 }) - await sendPushToUser('user-1', PAYLOAD) - expect(mockPushSubscription.delete).toHaveBeenCalledWith({ where: { id: SUB.id } }) - }) - - it('logs error but does not delete on other error status', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - mockSendNotification.mockRejectedValue({ statusCode: 500 }) - await sendPushToUser('user-1', PAYLOAD) - expect(mockPushSubscription.delete).not.toHaveBeenCalled() - expect(consoleSpy).toHaveBeenCalled() - consoleSpy.mockRestore() - }) -}) diff --git a/__tests__/lib/rate-limit.test.ts b/__tests__/lib/rate-limit.test.ts deleted file mode 100644 index aa9c636..0000000 --- a/__tests__/lib/rate-limit.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { checkRateLimit, enforceUserRateLimit, _resetRateLimit } from '@/lib/rate-limit' - -beforeEach(() => { - _resetRateLimit() -}) - -describe('checkRateLimit (legacy auth-keys)', () => { - it('staat de eerste request toe', () => { - expect(checkRateLimit('login:1.2.3.4')).toBe(true) - }) - - it('blokkeert na exceeding max (login: 10/min)', () => { - for (let i = 0; i < 10; i++) checkRateLimit('login:1.2.3.4') - expect(checkRateLimit('login:1.2.3.4')).toBe(false) - }) - - it('register heeft eigen lagere limiet (5/uur)', () => { - for (let i = 0; i < 5; i++) checkRateLimit('register:9.9.9.9') - expect(checkRateLimit('register:9.9.9.9')).toBe(false) - }) - - it('verschillende keys hebben hun eigen counter', () => { - for (let i = 0; i < 10; i++) checkRateLimit('login:1.1.1.1') - expect(checkRateLimit('login:1.1.1.1')).toBe(false) - expect(checkRateLimit('login:2.2.2.2')).toBe(true) - }) -}) - -describe('enforceUserRateLimit (v1-readiness #3 mutation-scopes)', () => { - it('returnt null bij eerste call', () => { - expect(enforceUserRateLimit('create-pbi', 'user-1')).toBeNull() - }) - - it('returnt 429-shape na exceeding limiet', () => { - // create-product limiet = 5/min - for (let i = 0; i < 5; i++) enforceUserRateLimit('create-product', 'user-1') - const result = enforceUserRateLimit('create-product', 'user-1') - expect(result).not.toBeNull() - expect(result?.code).toBe(429) - expect(result?.error).toContain('Te veel acties') - }) - - it('scope is per (action, user) — andere user heeft eigen quota', () => { - for (let i = 0; i < 5; i++) enforceUserRateLimit('create-product', 'user-A') - expect(enforceUserRateLimit('create-product', 'user-A')).not.toBeNull() - expect(enforceUserRateLimit('create-product', 'user-B')).toBeNull() - }) - - it('verschillende scopes voor dezelfde user vullen apart', () => { - for (let i = 0; i < 5; i++) enforceUserRateLimit('create-product', 'user-1') - expect(enforceUserRateLimit('create-product', 'user-1')).not.toBeNull() - // create-task heeft eigen counter - expect(enforceUserRateLimit('create-task', 'user-1')).toBeNull() - }) - - it('create-task limiet (100) is hoger dan create-pbi (30)', () => { - for (let i = 0; i < 30; i++) enforceUserRateLimit('create-pbi', 'u') - expect(enforceUserRateLimit('create-pbi', 'u')).not.toBeNull() - // create-task is nog niet hit - for (let i = 0; i < 30; i++) enforceUserRateLimit('create-task', 'u') - expect(enforceUserRateLimit('create-task', 'u')).toBeNull() - }) -}) diff --git a/__tests__/lib/sprint-conflicts.test.ts b/__tests__/lib/sprint-conflicts.test.ts deleted file mode 100644 index bf6edbe..0000000 --- a/__tests__/lib/sprint-conflicts.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import type { StoryStatus } from '@prisma/client' - -import { - getBlockingSprintMap, - isEligibleForSprint, - partitionByEligibility, -} from '@/lib/sprint-conflicts' - -function mockPrisma(stories: Array<Record<string, unknown>>) { - return { - story: { - findMany: vi.fn().mockResolvedValue(stories), - }, - } as unknown as Parameters<typeof partitionByEligibility>[0] -} - -describe('isEligibleForSprint', () => { - it('returns true for OPEN story without sprint', () => { - expect( - isEligibleForSprint({ sprint_id: null, status: 'OPEN' as StoryStatus }), - ).toBe(true) - }) - - it('returns true for IN_SPRINT story without sprint_id (edge: restoration)', () => { - expect( - isEligibleForSprint({ - sprint_id: null, - status: 'IN_SPRINT' as StoryStatus, - }), - ).toBe(true) - }) - - it('returns false for DONE story without sprint', () => { - expect( - isEligibleForSprint({ sprint_id: null, status: 'DONE' as StoryStatus }), - ).toBe(false) - }) - - it('returns false when story is in an OPEN sprint', () => { - expect( - isEligibleForSprint({ - sprint_id: 'abc', - status: 'IN_SPRINT' as StoryStatus, - sprint: { status: 'OPEN' }, - }), - ).toBe(false) - }) - - it('returns false when story is DONE (sprint_id irrelevant)', () => { - expect( - isEligibleForSprint({ - sprint_id: 'abc', - status: 'DONE' as StoryStatus, - sprint: { status: 'CLOSED' }, - }), - ).toBe(false) - }) - - it('returns true when story is in a CLOSED sprint (released back to planning)', () => { - expect( - isEligibleForSprint({ - sprint_id: 'abc', - status: 'IN_SPRINT' as StoryStatus, - sprint: { status: 'CLOSED' }, - }), - ).toBe(true) - }) - - it('returns true when story is in an ARCHIVED sprint', () => { - expect( - isEligibleForSprint({ - sprint_id: 'abc', - status: 'IN_SPRINT' as StoryStatus, - sprint: { status: 'ARCHIVED' }, - }), - ).toBe(true) - }) - - it('returns true when story is in a FAILED sprint', () => { - expect( - isEligibleForSprint({ - sprint_id: 'abc', - status: 'IN_SPRINT' as StoryStatus, - sprint: { status: 'FAILED' }, - }), - ).toBe(true) - }) - - it('returns false when sprint_id is set but sprint relation is missing (defensive)', () => { - // Zonder sprint-data weten we niet of die OPEN is, dus blijven we - // conservatief — niet eligible. - expect( - isEligibleForSprint({ - sprint_id: 'abc', - status: 'IN_SPRINT' as StoryStatus, - }), - ).toBe(false) - }) -}) - -describe('partitionByEligibility', () => { - it('returns empty partition for empty input', async () => { - const prisma = mockPrisma([]) - const result = await partitionByEligibility(prisma, []) - expect(result).toEqual({ eligible: [], notEligible: [], crossSprint: [] }) - }) - - it('classifies all eligible when stories are free + OPEN', async () => { - const prisma = mockPrisma([ - { id: 's1', sprint_id: null, status: 'OPEN', sprint: null }, - { id: 's2', sprint_id: null, status: 'IN_SPRINT', sprint: null }, - ]) - const result = await partitionByEligibility(prisma, ['s1', 's2']) - expect(result.eligible).toEqual(['s1', 's2']) - expect(result.notEligible).toEqual([]) - expect(result.crossSprint).toEqual([]) - }) - - it('marks DONE stories as notEligible with reason=DONE', async () => { - const prisma = mockPrisma([ - { id: 's1', sprint_id: null, status: 'DONE', sprint: null }, - ]) - const result = await partitionByEligibility(prisma, ['s1']) - expect(result.eligible).toEqual([]) - expect(result.notEligible).toEqual([{ storyId: 's1', reason: 'DONE' }]) - }) - - it('marks stories in other OPEN sprint as crossSprint + notEligible', async () => { - const prisma = mockPrisma([ - { - id: 's1', - sprint_id: 'sprint-other', - status: 'IN_SPRINT', - sprint: { id: 'sprint-other', code: 'SP-2', status: 'OPEN' }, - }, - ]) - const result = await partitionByEligibility(prisma, ['s1']) - expect(result.crossSprint).toEqual([ - { storyId: 's1', sprintId: 'sprint-other', sprintName: 'SP-2' }, - ]) - expect(result.notEligible).toEqual([ - { storyId: 's1', reason: 'IN_OTHER_SPRINT' }, - ]) - expect(result.eligible).toEqual([]) - }) - - it('classifies story in CLOSED sprint with status=OPEN as eligible (status reset already happened)', async () => { - const prisma = mockPrisma([ - { - id: 's1', - sprint_id: null, - status: 'OPEN', - sprint: null, - }, - ]) - const result = await partitionByEligibility(prisma, ['s1']) - expect(result.eligible).toEqual(['s1']) - }) - - it('frees stories from a CLOSED sprint — they become eligible again', async () => { - const prisma = mockPrisma([ - { - id: 's1', - sprint_id: 'sprint-closed', - status: 'IN_SPRINT', - sprint: { id: 'sprint-closed', code: 'SP-C', status: 'CLOSED' }, - }, - ]) - const result = await partitionByEligibility(prisma, ['s1']) - expect(result.eligible).toEqual(['s1']) - expect(result.crossSprint).toEqual([]) - expect(result.notEligible).toEqual([]) - }) - - it('frees stories from ARCHIVED and FAILED sprints', async () => { - const prisma = mockPrisma([ - { - id: 's1', - sprint_id: 'sprint-arch', - status: 'IN_SPRINT', - sprint: { id: 'sprint-arch', code: 'SP-A', status: 'ARCHIVED' }, - }, - { - id: 's2', - sprint_id: 'sprint-fail', - status: 'IN_SPRINT', - sprint: { id: 'sprint-fail', code: 'SP-F', status: 'FAILED' }, - }, - ]) - const result = await partitionByEligibility(prisma, ['s1', 's2']) - expect(result.eligible).toEqual(['s1', 's2']) - expect(result.notEligible).toEqual([]) - }) - - it('a DONE story in a CLOSED sprint is notEligible because DONE (sprint inactive)', async () => { - // Volgorde: niet-actieve sprint blokkeert niet meer, dus de DONE-check - // bepaalt de reason. Vroeger werd dit 'IN_OTHER_SPRINT' — dat was misleidend - // omdat de sprint helemaal niet meer actief was. - const prisma = mockPrisma([ - { - id: 's1', - sprint_id: 'sprint-closed', - status: 'DONE', - sprint: { id: 'sprint-closed', code: 'SP-C', status: 'CLOSED' }, - }, - ]) - const result = await partitionByEligibility(prisma, ['s1']) - expect(result.crossSprint).toEqual([]) - expect(result.notEligible).toEqual([{ storyId: 's1', reason: 'DONE' }]) - expect(result.eligible).toEqual([]) - }) - - it('respects excludeSprintId — story in same sprint is eligible', async () => { - const prisma = mockPrisma([ - { - id: 's1', - sprint_id: 'sprint-active', - status: 'IN_SPRINT', - sprint: { id: 'sprint-active', code: 'SP-A', status: 'OPEN' }, - }, - ]) - const result = await partitionByEligibility(prisma, ['s1'], 'sprint-active') - expect(result.eligible).toEqual(['s1']) - expect(result.crossSprint).toEqual([]) - }) -}) - -describe('getBlockingSprintMap', () => { - it('returns empty map for empty input', async () => { - const prisma = mockPrisma([]) - const result = await getBlockingSprintMap(prisma, 'p1', []) - expect(result.size).toBe(0) - }) - - it('returns blocking sprint info for stories in OPEN sprints', async () => { - const prisma = mockPrisma([ - { - id: 's1', - sprint_id: 'sprint-x', - sprint: { id: 'sprint-x', code: 'SP-X', status: 'OPEN' }, - }, - ]) - const result = await getBlockingSprintMap(prisma, 'p1', ['s1']) - expect(result.get('s1')).toEqual({ - sprintId: 'sprint-x', - sprintName: 'SP-X', - }) - }) - - it('excludes the active sprint from blocking', async () => { - const prisma = mockPrisma([ - { - id: 's1', - sprint_id: 'sprint-active', - sprint: { id: 'sprint-active', code: 'SP-A', status: 'OPEN' }, - }, - ]) - const result = await getBlockingSprintMap( - prisma, - 'p1', - ['s1'], - 'sprint-active', - ) - expect(result.size).toBe(0) - }) - - it('does not include CLOSED sprints (filtered at DB query level)', async () => { - // The prisma mock receives WHERE sprint.status='OPEN' so CLOSED stories - // are already filtered out before reaching this function's mapping logic. - const prisma = mockPrisma([]) - const result = await getBlockingSprintMap(prisma, 'p1', ['s1']) - expect(result.size).toBe(0) - }) -}) diff --git a/__tests__/lib/task-status.test.ts b/__tests__/lib/task-status.test.ts index 9f08a85..870b632 100644 --- a/__tests__/lib/task-status.test.ts +++ b/__tests__/lib/task-status.test.ts @@ -78,8 +78,8 @@ describe('task-status mappers', () => { expect(pbiStatusFromApi('todo')).toBeNull() }) - it('exposes alle vier API values', () => { - expect(PBI_STATUS_API_VALUES).toEqual(['ready', 'blocked', 'failed', 'done']) + it('exposes exactly three API values', () => { + expect(PBI_STATUS_API_VALUES).toEqual(['ready', 'blocked', 'done']) }) }) }) diff --git a/__tests__/lib/tasks-status-update.test.ts b/__tests__/lib/tasks-status-update.test.ts index ccaa2f6..418caa7 100644 --- a/__tests__/lib/tasks-status-update.test.ts +++ b/__tests__/lib/tasks-status-update.test.ts @@ -8,23 +8,6 @@ vi.mock('@/lib/prisma', () => ({ }, story: { findUniqueOrThrow: vi.fn(), - findMany: vi.fn(), - update: vi.fn(), - }, - pbi: { - findUniqueOrThrow: vi.fn(), - update: vi.fn(), - }, - sprint: { - findUniqueOrThrow: vi.fn(), - update: vi.fn(), - }, - claudeJob: { - findFirst: vi.fn(), - updateMany: vi.fn(), - }, - sprintRun: { - findUnique: vi.fn(), update: vi.fn(), }, $transaction: vi.fn(), @@ -32,35 +15,27 @@ vi.mock('@/lib/prisma', () => ({ })) import { prisma } from '@/lib/prisma' -import { propagateStatusUpwards } from '@/lib/tasks-status-update' +import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' -type MockedPrisma = { - task: { update: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn> } +const mockPrisma = prisma as unknown as { + task: { + update: ReturnType<typeof vi.fn> + findMany: ReturnType<typeof vi.fn> + } story: { findUniqueOrThrow: ReturnType<typeof vi.fn> - findMany: ReturnType<typeof vi.fn> - update: ReturnType<typeof vi.fn> - } - pbi: { - findUniqueOrThrow: ReturnType<typeof vi.fn> - update: ReturnType<typeof vi.fn> - } - sprint: { - findUniqueOrThrow: ReturnType<typeof vi.fn> - update: ReturnType<typeof vi.fn> - } - claudeJob: { - findFirst: ReturnType<typeof vi.fn> - updateMany: ReturnType<typeof vi.fn> - } - sprintRun: { - findUnique: ReturnType<typeof vi.fn> update: ReturnType<typeof vi.fn> } $transaction: ReturnType<typeof vi.fn> } -const mockPrisma = prisma as unknown as MockedPrisma +beforeEach(() => { + vi.clearAllMocks() + // Pass-through: $transaction(run) just calls run with the mocked prisma client. + mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise<unknown>) => { + return run(prisma) + }) +}) const TASK_BASE = { id: 'task-1', @@ -69,267 +44,110 @@ const TASK_BASE = { implementation_plan: null, } -beforeEach(() => { - vi.clearAllMocks() - mockPrisma.$transaction.mockImplementation( - async (run: (tx: typeof prisma) => Promise<unknown>) => run(prisma), - ) -}) - -describe('propagateStatusUpwards — story-niveau', () => { - it('zet story op DONE wanneer alle siblings DONE zijn', async () => { +describe('updateTaskStatusWithStoryPromotion', () => { + it('promotes story to DONE when last sibling task transitions to DONE', async () => { mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) mockPrisma.task.findMany.mockResolvedValue([ { status: 'DONE' }, { status: 'DONE' }, ]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ - id: 'story-1', - status: 'IN_SPRINT', - pbi_id: 'pbi-1', - sprint_id: null, - }) - mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) - mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) - const result = await propagateStatusUpwards('task-1', 'DONE') + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') - expect(result.storyChanged).toBe(true) + expect(result.storyStatusChange).toBe('promoted') + expect(result.storyId).toBe('story-1') expect(mockPrisma.story.update).toHaveBeenCalledWith({ where: { id: 'story-1' }, data: { status: 'DONE' }, }) }) - it('zet story op FAILED wanneer een task FAILED is, ongeacht andere tasks', async () => { - mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'FAILED' }) - mockPrisma.task.findMany.mockResolvedValue([ - { status: 'FAILED' }, - { status: 'DONE' }, - { status: 'TO_DO' }, - ]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ - id: 'story-1', - status: 'IN_SPRINT', - pbi_id: 'pbi-1', - sprint_id: null, - }) - mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) - mockPrisma.story.findMany.mockResolvedValue([{ status: 'FAILED' }]) - - const result = await propagateStatusUpwards('task-1', 'FAILED') - - expect(result.storyChanged).toBe(true) - expect(mockPrisma.story.update).toHaveBeenCalledWith({ - where: { id: 'story-1' }, - data: { status: 'FAILED' }, - }) - }) - - it('houdt story op IN_SPRINT als nog niet alle tasks DONE en geen FAILED', async () => { + it('does not promote when story is already DONE (idempotent)', async () => { mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) - mockPrisma.task.findMany.mockResolvedValue([ - { status: 'DONE' }, - { status: 'TO_DO' }, - ]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ - id: 'story-1', - status: 'IN_SPRINT', - pbi_id: 'pbi-1', - sprint_id: 'sprint-1', - }) - mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) - mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => { - if (args.where?.pbi_id) return [{ status: 'IN_SPRINT' }] - if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }] - return [] - }) - mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'OPEN' }) - ;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'READY' }]) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) - const result = await propagateStatusUpwards('task-1', 'DONE') + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') - expect(result.storyChanged).toBe(false) + expect(result.storyStatusChange).toBe(null) expect(mockPrisma.story.update).not.toHaveBeenCalled() }) - it('demoot story uit DONE als een task terug naar TO_DO gaat', async () => { - mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'TO_DO' }) + it('does not promote when not all siblings are DONE', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) mockPrisma.task.findMany.mockResolvedValue([ - { status: 'TO_DO' }, + { status: 'DONE' }, + { status: 'IN_PROGRESS' }, + ]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) + + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE') + + expect(result.storyStatusChange).toBe(null) + expect(mockPrisma.story.update).not.toHaveBeenCalled() + }) + + it('demotes story to IN_SPRINT when a task moves out of DONE on a DONE story', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) + mockPrisma.task.findMany.mockResolvedValue([ + { status: 'IN_PROGRESS' }, { status: 'DONE' }, ]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ - id: 'story-1', - status: 'DONE', - pbi_id: 'pbi-1', - sprint_id: 'sprint-1', - }) - mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' }) - mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => { - if (args.where?.pbi_id) return [{ status: 'IN_SPRINT' }, { status: 'DONE' }] - if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }] - return [] - }) - mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'CLOSED' }) - ;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'READY' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' }) - const result = await propagateStatusUpwards('task-1', 'TO_DO') + const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') - expect(result.storyChanged).toBe(true) + expect(result.storyStatusChange).toBe('demoted') expect(mockPrisma.story.update).toHaveBeenCalledWith({ where: { id: 'story-1' }, data: { status: 'IN_SPRINT' }, }) }) - it('zet story op OPEN als sprint_id null is en niet DONE/FAILED', async () => { + it('does not demote when story is not DONE', async () => { mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ - id: 'story-1', - status: 'IN_SPRINT', - pbi_id: 'pbi-1', - sprint_id: null, - }) - mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) - mockPrisma.story.findMany.mockResolvedValue([{ status: 'OPEN' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) - const result = await propagateStatusUpwards('task-1', 'IN_PROGRESS') + const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') - expect(result.storyChanged).toBe(true) - expect(mockPrisma.story.update).toHaveBeenCalledWith({ - where: { id: 'story-1' }, - data: { status: 'OPEN' }, - }) - }) -}) - -describe('propagateStatusUpwards — PBI BLOCKED met rust laten', () => { - it('overschrijft een handmatig BLOCKED PBI niet', async () => { - mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) - mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ - id: 'story-1', - status: 'IN_SPRINT', - pbi_id: 'pbi-1', - sprint_id: null, - }) - mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'BLOCKED' }) - - const result = await propagateStatusUpwards('task-1', 'DONE') - - expect(result.pbiChanged).toBe(false) - expect(mockPrisma.pbi.update).not.toHaveBeenCalled() - }) -}) - -describe('propagateStatusUpwards — sprint cascade tot SprintRun', () => { - it('zet bij FAILED de hele keten op FAILED en cancelt sibling-jobs', async () => { - mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'FAILED' }) - mockPrisma.task.findMany.mockResolvedValue([ - { status: 'FAILED' }, - { status: 'DONE' }, - ]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ - id: 'story-1', - status: 'IN_SPRINT', - pbi_id: 'pbi-1', - sprint_id: 'sprint-1', - }) - mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) - mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => { - if (args.where?.pbi_id) return [{ status: 'FAILED' }] - if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }] - return [] - }) - mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'OPEN' }) - // findMany on pbi: - ;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'FAILED' }]) - mockPrisma.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' }) - mockPrisma.sprintRun.findUnique.mockResolvedValue({ id: 'run-1', status: 'RUNNING' }) - - const result = await propagateStatusUpwards('task-1', 'FAILED') - - expect(result.storyChanged).toBe(true) - expect(result.pbiChanged).toBe(true) - expect(result.sprintChanged).toBe(true) - expect(result.sprintRunChanged).toBe(true) - - expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith(expect.objectContaining({ - where: { id: 'run-1' }, - data: expect.objectContaining({ status: 'FAILED', failed_task_id: 'task-1' }), - })) - expect(mockPrisma.claudeJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({ - where: expect.objectContaining({ - sprint_run_id: 'run-1', - status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] }, - id: { not: 'job-1' }, - }), - data: expect.objectContaining({ status: 'CANCELLED' }), - })) + expect(result.storyStatusChange).toBe(null) + expect(mockPrisma.story.update).not.toHaveBeenCalled() }) - it('zet bij alle DONE de SprintRun op DONE en Sprint op COMPLETED', async () => { - mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) - mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }]) - mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ - id: 'story-1', - status: 'IN_SPRINT', - pbi_id: 'pbi-1', - sprint_id: 'sprint-1', - }) - mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) - mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => { - if (args.where?.pbi_id) return [{ status: 'DONE' }] - if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }] - return [] - }) - mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'OPEN' }) - ;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'DONE' }]) - mockPrisma.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' }) - mockPrisma.sprintRun.findUnique.mockResolvedValue({ id: 'run-1', status: 'RUNNING' }) + it('updates the task regardless of story-status change', async () => { + mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) + mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) + mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) - const result = await propagateStatusUpwards('task-1', 'DONE') + await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS') - expect(result.sprintRunChanged).toBe(true) - expect(mockPrisma.sprint.update).toHaveBeenCalledWith(expect.objectContaining({ - where: { id: 'sprint-1' }, - data: expect.objectContaining({ status: 'CLOSED' }), - })) - expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith(expect.objectContaining({ - where: { id: 'run-1' }, - data: expect.objectContaining({ status: 'DONE' }), - })) + expect(mockPrisma.task.update).toHaveBeenCalledWith({ + where: { id: 'task-1' }, + data: { status: 'IN_PROGRESS' }, + select: expect.any(Object), + }) }) -}) -describe('propagateStatusUpwards — transactionele aanroep', () => { - it('gebruikt de meegegeven transaction client', async () => { + it('uses the provided transaction client when passed', async () => { const tx = { task: { update: vi.fn(), findMany: vi.fn() }, - story: { findUniqueOrThrow: vi.fn(), findMany: vi.fn(), update: vi.fn() }, - pbi: { findUniqueOrThrow: vi.fn(), findMany: vi.fn(), update: vi.fn() }, - sprint: { findUniqueOrThrow: vi.fn(), update: vi.fn() }, - claudeJob: { findFirst: vi.fn(), updateMany: vi.fn() }, - sprintRun: { findUnique: vi.fn(), update: vi.fn() }, + story: { findUniqueOrThrow: vi.fn(), update: vi.fn() }, } - tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' }) - tx.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }]) - tx.story.findUniqueOrThrow.mockResolvedValue({ - id: 'story-1', - status: 'OPEN', - pbi_id: 'pbi-1', - sprint_id: null, - }) - tx.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' }) - tx.story.findMany.mockResolvedValue([{ status: 'OPEN' }]) + tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' }) + tx.task.findMany.mockResolvedValue([{ status: 'DONE' }]) + tx.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' }) // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await propagateStatusUpwards('task-1', 'IN_PROGRESS', tx as any) + const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE', tx as any) - expect(result.storyChanged).toBe(false) - // $transaction wordt niet aangeroepen wanneer caller al een tx meegeeft. + expect(result.storyStatusChange).toBe('promoted') + // $transaction should NOT be called when caller already provides a tx. expect(mockPrisma.$transaction).not.toHaveBeenCalled() + expect(tx.story.update).toHaveBeenCalledWith({ + where: { id: 'story-1' }, + data: { status: 'DONE' }, + }) }) }) diff --git a/__tests__/lib/user-agent.test.ts b/__tests__/lib/user-agent.test.ts deleted file mode 100644 index f322b5c..0000000 --- a/__tests__/lib/user-agent.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { isPhoneUA } from '@/lib/user-agent' - -describe('isPhoneUA', () => { - it('iPhone Safari Mobile → true', () => { - const ua = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1' - expect(isPhoneUA(ua)).toBe(true) - }) - - it('Android Chrome (phone) → true', () => { - const ua = 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36' - expect(isPhoneUA(ua)).toBe(true) - }) - - it('iPad → false (geen Mobi)', () => { - const ua = 'Mozilla/5.0 (iPad; CPU OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/604.1' - expect(isPhoneUA(ua)).toBe(false) - }) - - it('Android tablet (Galaxy Tab) → false', () => { - const ua = 'Mozilla/5.0 (Linux; Android 14; SM-X910) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' - expect(isPhoneUA(ua)).toBe(false) - }) - - it('Desktop Chrome → false', () => { - const ua = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' - expect(isPhoneUA(ua)).toBe(false) - }) - - it('null → false', () => { - expect(isPhoneUA(null)).toBe(false) - }) - - it('lege string → false', () => { - expect(isPhoneUA('')).toBe(false) - }) -}) diff --git a/__tests__/lib/user-settings-migration.test.ts b/__tests__/lib/user-settings-migration.test.ts deleted file mode 100644 index 38346b4..0000000 --- a/__tests__/lib/user-settings-migration.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { - buildMigrationPatch, - clearLegacyStorage, -} from '@/lib/user-settings-migration' - -function clearAllCookies() { - for (const part of document.cookie.split(';')) { - const eq = part.indexOf('=') - const name = (eq < 0 ? part : part.slice(0, eq)).trim() - if (name) document.cookie = `${name}=; max-age=0; path=/` - } -} - -beforeEach(() => { - localStorage.clear() - clearAllCookies() -}) - -afterEach(() => { - localStorage.clear() - clearAllCookies() -}) - -describe('buildMigrationPatch', () => { - it('returns no data when nothing is stored', () => { - const result = buildMigrationPatch() - expect(result.hasData).toBe(false) - expect(result.patch).toEqual({}) - expect(result.legacyKeys).toEqual([]) - }) - - it('skips after marker is set to current version', () => { - localStorage.setItem('scrum4me:sprint_pb_filter_status', 'all') - localStorage.setItem('scrum4me:settings_migrated', 'v2') - const result = buildMigrationPatch() - expect(result.hasData).toBe(false) - }) - - it('still runs when only the v1 marker is set (re-migration)', () => { - localStorage.setItem('scrum4me:sprint_pb_filter_status', 'all') - localStorage.setItem('scrum4me:settings_migrated', 'v1') - const result = buildMigrationPatch() - expect(result.hasData).toBe(true) - }) - - it('extracts split-pane cookies into layout', () => { - document.cookie = `sp:backlog-p1=${encodeURIComponent(JSON.stringify([25, 35, 40]))}; path=/` - const result = buildMigrationPatch() - expect(result.patch.layout?.splitPanePositions).toEqual({ 'backlog-p1': [25, 35, 40] }) - expect(result.legacyCookies).toContain('sp:backlog-p1') - }) - - it('ignores split-pane cookies that do not sum to 100', () => { - document.cookie = `sp:bad=${encodeURIComponent(JSON.stringify([10, 20]))}; path=/` - const result = buildMigrationPatch() - expect(result.patch.layout).toBeUndefined() - }) - - it('extracts active-sprint cookies into layout.activeSprints', () => { - document.cookie = `active_sprint_prod-1=sprint-abc; path=/` - document.cookie = `active_sprint_prod-2=sprint-xyz; path=/` - const result = buildMigrationPatch() - expect(result.patch.layout?.activeSprints).toEqual({ - 'prod-1': 'sprint-abc', - 'prod-2': 'sprint-xyz', - }) - expect(result.legacyCookies).toContain('active_sprint_prod-1') - }) - - it('extracts sprint backlog prefs into nested patch', () => { - localStorage.setItem('scrum4me:sprint_pb_filter_status', 'all') - localStorage.setItem('scrum4me:sprint_pb_sort', 'priority') - localStorage.setItem('scrum4me:sprint_pb_sort_dir', 'desc') - localStorage.setItem('scrum4me:sprint_pb_collapsed', JSON.stringify(['pbi-1', 'pbi-2'])) - localStorage.setItem('scrum4me:sprint_pb_filter_popover_open', 'true') - - const result = buildMigrationPatch() - - expect(result.hasData).toBe(true) - expect(result.patch.views?.sprintBacklog).toEqual({ - filterStatus: 'all', - sort: 'priority', - sortDir: 'desc', - collapsedPbis: ['pbi-1', 'pbi-2'], - filterPopoverOpen: true, - }) - expect(result.legacyKeys).toContain('scrum4me:sprint_pb_filter_status') - expect(result.legacyKeys).toContain('scrum4me:sprint_pb_collapsed') - }) - - it('extracts pbi-list prefs', () => { - localStorage.setItem('scrum4me:pbi_sort', 'date') - localStorage.setItem('scrum4me:pbi_filter_priority', '2') - - const result = buildMigrationPatch() - expect(result.patch.views?.pbiList).toEqual({ sort: 'date', filterPriority: 2 }) - }) - - it('extracts story_sort', () => { - localStorage.setItem('scrum4me:story_sort', 'code') - const result = buildMigrationPatch() - expect(result.patch.views?.storyPanel).toEqual({ sort: 'code' }) - }) - - it('extracts debug-mode', () => { - localStorage.setItem('scrum4me:debug-mode', 'true') - const result = buildMigrationPatch() - expect(result.patch.devTools).toEqual({ debugMode: true }) - }) - - it('extracts jobs-column dynamic prefixes from CSV values', () => { - localStorage.setItem('queue_filter_kind', 'TASK_IMPLEMENTATION,SPRINT_IMPLEMENTATION') - localStorage.setItem('queue_filter_status', 'queued,running') - - const result = buildMigrationPatch() - expect(result.patch.views?.jobsColumns?.['queue']).toEqual({ - kinds: ['TASK_IMPLEMENTATION', 'SPRINT_IMPLEMENTATION'], - statuses: ['queued', 'running'], - }) - }) - - it('ignores invalid enum values', () => { - localStorage.setItem('scrum4me:sprint_pb_filter_status', 'BOGUS') - const result = buildMigrationPatch() - expect(result.hasData).toBe(false) - }) -}) - -describe('clearLegacyStorage', () => { - it('removes given keys and cookies and sets the v2 marker', () => { - localStorage.setItem('scrum4me:sprint_pb_sort', 'code') - document.cookie = 'sp:x=foo; path=/' - - clearLegacyStorage(['scrum4me:sprint_pb_sort'], ['sp:x']) - - expect(localStorage.getItem('scrum4me:sprint_pb_sort')).toBeNull() - expect(document.cookie).not.toContain('sp:x=foo') - expect(localStorage.getItem('scrum4me:settings_migrated')).toBe('v2') - }) - - it('sets marker even with empty lists (no-op migration)', () => { - clearLegacyStorage([], []) - expect(localStorage.getItem('scrum4me:settings_migrated')).toBe('v2') - }) -}) diff --git a/__tests__/lib/user-settings.test.ts b/__tests__/lib/user-settings.test.ts deleted file mode 100644 index 2e694d7..0000000 --- a/__tests__/lib/user-settings.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - DEFAULT_USER_SETTINGS, - UserSettingsSchema, - mergeSettings, - parseUserSettings, - type UserSettings, -} from '@/lib/user-settings' - -describe('mergeSettings', () => { - it('returns the patch when previous is empty', () => { - const result = mergeSettings({}, { views: { sprintBacklog: { sort: 'code' } } }) - expect(result).toEqual({ views: { sprintBacklog: { sort: 'code' } } }) - }) - - it('preserves existing keys when patch only sets new ones', () => { - const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } } - const result = mergeSettings(prev, { - views: { pbiList: { sort: 'date' } }, - }) - expect(result).toEqual({ - views: { - sprintBacklog: { sort: 'code' }, - pbiList: { sort: 'date' }, - }, - }) - }) - - it('merges nested objects without overwriting siblings', () => { - const prev: UserSettings = { - views: { sprintBacklog: { sort: 'code', sortDir: 'asc' } }, - } - const result = mergeSettings(prev, { - views: { sprintBacklog: { sort: 'priority' } }, - }) - expect(result).toEqual({ - views: { sprintBacklog: { sort: 'priority', sortDir: 'asc' } }, - }) - }) - - it('replaces arrays instead of appending', () => { - const prev: UserSettings = { - views: { sprintBacklog: { collapsedPbis: ['a', 'b'] } }, - } - const result = mergeSettings(prev, { - views: { sprintBacklog: { collapsedPbis: ['c'] } }, - }) - expect(result.views?.sprintBacklog?.collapsedPbis).toEqual(['c']) - }) - - it('does not mutate the previous object', () => { - const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } } - const snapshot = JSON.parse(JSON.stringify(prev)) - mergeSettings(prev, { views: { sprintBacklog: { sortDir: 'desc' } } }) - expect(prev).toEqual(snapshot) - }) - - it('skips undefined values in the patch', () => { - const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } } - const result = mergeSettings(prev, { views: undefined }) - expect(result).toEqual(prev) - }) -}) - -describe('parseUserSettings', () => { - it('returns defaults for null', () => { - expect(parseUserSettings(null)).toEqual(DEFAULT_USER_SETTINGS) - }) - - it('returns defaults for undefined', () => { - expect(parseUserSettings(undefined)).toEqual(DEFAULT_USER_SETTINGS) - }) - - it('returns defaults for invalid input', () => { - expect(parseUserSettings({ views: { sprintBacklog: { filterStatus: 'BOGUS' } } })) - .toEqual(DEFAULT_USER_SETTINGS) - }) - - it('passes valid settings through', () => { - const valid = { views: { sprintBacklog: { sort: 'code' as const } } } - expect(parseUserSettings(valid)).toEqual(valid) - }) -}) - -describe('UserSettingsSchema', () => { - it('rejects unknown top-level keys', () => { - const result = UserSettingsSchema.safeParse({ unknown: 1 }) - expect(result.success).toBe(false) - }) - - it('accepts an empty object', () => { - expect(UserSettingsSchema.safeParse({}).success).toBe(true) - }) - - it('accepts the full shape', () => { - const result = UserSettingsSchema.safeParse({ - views: { - sprintBacklog: { - filterPriority: 1, - filterStatus: 'OPEN', - sort: 'code', - sortDir: 'asc', - collapsedPbis: ['x'], - filterPopoverOpen: true, - }, - pbiList: { sort: 'priority', filterPriority: 'all', filterStatus: 'ready', sortDir: 'desc' }, - storyPanel: { sort: 'date' }, - jobsColumns: { 'queue:active': { kinds: ['TASK_IMPLEMENTATION'], statuses: [] } }, - jobs: { timeFilter: '24h' }, - ideasList: { filterStatuses: ['draft', 'planned'] }, - }, - devTools: { debugMode: true }, - layout: { - splitPanePositions: { 'backlog-pid': [25, 35, 40] }, - activeSprints: { 'product-1': 'sprint-1' }, - }, - }) - expect(result.success).toBe(true) - }) - - it('accepts views.jobs.timeFilter and returns it via parseUserSettings', () => { - const input = { views: { jobs: { timeFilter: '1h' as const } } } - const result = parseUserSettings(input) - expect(result).toEqual(input) - }) - - it('rejects an invalid views.jobs.timeFilter value', () => { - const result = UserSettingsSchema.safeParse({ views: { jobs: { timeFilter: 'BOGUS' } } }) - expect(result.success).toBe(false) - }) - - it('accepts layout-only settings', () => { - expect(UserSettingsSchema.safeParse({ - layout: { splitPanePositions: { x: [50, 50] }, activeSprints: { p: 's' } }, - }).success).toBe(true) - }) - - it('accepts null values in activeSprints (explicit "no active sprint")', () => { - const result = UserSettingsSchema.safeParse({ - layout: { activeSprints: { 'product-1': null, 'product-2': 'sprint-2' } }, - }) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.layout?.activeSprints).toEqual({ - 'product-1': null, - 'product-2': 'sprint-2', - }) - } - }) - - it('accepts pendingSprintDraft with per-PBI intent and overrides', () => { - const result = UserSettingsSchema.safeParse({ - workflow: { - pendingSprintDraft: { - 'product-1': { - goal: 'Sprint goal', - pbiIntent: { pbiA: 'all', pbiB: 'none' }, - storyOverrides: { - pbiA: { add: [], remove: ['story-1'] }, - pbiB: { add: ['story-2'], remove: [] }, - }, - }, - }, - }, - }) - expect(result.success).toBe(true) - }) - - it('fills empty defaults for pbiIntent and storyOverrides in draft', () => { - const result = UserSettingsSchema.safeParse({ - workflow: { pendingSprintDraft: { 'product-1': { goal: 'g' } } }, - }) - expect(result.success).toBe(true) - if (result.success) { - const draft = result.data.workflow?.pendingSprintDraft?.['product-1'] - expect(draft?.pbiIntent).toEqual({}) - expect(draft?.storyOverrides).toEqual({}) - } - }) - - it('rejects pendingSprintDraft with empty goal', () => { - const result = UserSettingsSchema.safeParse({ - workflow: { pendingSprintDraft: { 'p': { goal: '' } } }, - }) - expect(result.success).toBe(false) - }) - - it('rejects an invalid ideasList.filterStatuses value', () => { - const result = UserSettingsSchema.safeParse({ views: { ideasList: { filterStatuses: ['BOGUS'] } } }) - expect(result.success).toBe(false) - }) - - it('accepts an empty ideasList.filterStatuses array', () => { - const result = UserSettingsSchema.safeParse({ views: { ideasList: { filterStatuses: [] } } }) - expect(result.success).toBe(true) - }) - - it('rejects unknown intent value', () => { - const result = UserSettingsSchema.safeParse({ - workflow: { - pendingSprintDraft: { - p: { goal: 'x', pbiIntent: { a: 'partial' } }, - }, - }, - }) - expect(result.success).toBe(false) - }) -}) diff --git a/__tests__/proxy/demo-guard.test.ts b/__tests__/proxy/demo-guard.test.ts index 1ae94a2..f229a8f 100644 --- a/__tests__/proxy/demo-guard.test.ts +++ b/__tests__/proxy/demo-guard.test.ts @@ -30,26 +30,6 @@ beforeEach(() => { }) describe('proxy demo-guard', () => { - it('demo + POST /api/ideas → 403 (M12)', async () => { - mockUnsealData.mockResolvedValue({ userId: 'demo-user', isDemo: true }) - const req = makeRequest('POST', '/api/ideas', true) - const res = await proxy(req) - expect(res?.status).toBe(403) - }) - - it('demo + PATCH /api/ideas/abc → 403 (M12)', async () => { - mockUnsealData.mockResolvedValue({ userId: 'demo-user', isDemo: true }) - const req = makeRequest('PATCH', '/api/ideas/abc', true) - const res = await proxy(req) - expect(res?.status).toBe(403) - }) - - it('demo + GET /api/ideas → passthrough (M12)', async () => { - const req = makeRequest('GET', '/api/ideas', true) - const res = await proxy(req) - expect(res?.status).not.toBe(403) - }) - it('demo + POST /api/todos → 403', async () => { mockUnsealData.mockResolvedValue({ userId: 'demo-user', isDemo: true }) const req = makeRequest('POST', '/api/todos', true) diff --git a/__tests__/realtime/payload-contract.test.ts b/__tests__/realtime/payload-contract.test.ts new file mode 100644 index 0000000..b36bc09 --- /dev/null +++ b/__tests__/realtime/payload-contract.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useBacklogStore } from '@/stores/backlog-store' +import type { BacklogPbi, BacklogStory, BacklogTask } from '@/stores/backlog-store' + +const PBI: BacklogPbi = { + id: 'pbi-1', + code: 'PBI-1', + title: 'Realtime PBI', + priority: 2, + description: 'desc', + created_at: new Date('2024-01-01T00:00:00Z'), + status: 'ready', +} + +const STORY: BacklogStory = { + id: 'story-1', + code: 'ST-1', + title: 'Realtime story', + description: null, + acceptance_criteria: null, + priority: 2, + status: 'OPEN', + pbi_id: 'pbi-1', + created_at: new Date('2024-01-01T00:00:00Z'), +} + +const TASK: BacklogTask = { + id: 'task-1', + title: 'Realtime task', + description: null, + priority: 2, + status: 'TO_DO', + sort_order: 1, + story_id: 'story-1', + created_at: new Date('2024-01-01T00:00:00Z'), +} + +beforeEach(() => { + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} }) +}) + +// --------------------------------------------------------------------------- +// PBI +// --------------------------------------------------------------------------- + +describe('PBI payload contract', () => { + it('INSERT: entity appears in pbis with correct title and status', () => { + useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI }) + const state = useBacklogStore.getState() + expect(state.pbis).toHaveLength(1) + expect(state.pbis[0].id).toBe('pbi-1') + expect(state.pbis[0].title).toBe('Realtime PBI') + expect(state.pbis[0].status).toBe('ready') + }) + + it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => { + useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI }) + useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI }) + expect(useBacklogStore.getState().pbis).toHaveLength(1) + }) + + it('UPDATE: changed_fields partial merges into existing entity', () => { + useBacklogStore.setState({ pbis: [{ ...PBI }], storiesByPbi: {}, tasksByStory: {} }) + useBacklogStore.getState().applyChange('pbi', 'U', { id: 'pbi-1', title: 'Updated PBI', status: 'in_sprint' as const }) + const pbi = useBacklogStore.getState().pbis[0] + expect(pbi.title).toBe('Updated PBI') + expect(pbi.status).toBe('in_sprint') + expect(pbi.priority).toBe(2) // unchanged field retained + }) + + it('DELETE: entity is removed from pbis', () => { + useBacklogStore.setState({ pbis: [{ ...PBI }], storiesByPbi: {}, tasksByStory: {} }) + useBacklogStore.getState().applyChange('pbi', 'D', { id: 'pbi-1' }) + expect(useBacklogStore.getState().pbis).toHaveLength(0) + }) +}) + +// --------------------------------------------------------------------------- +// Story +// --------------------------------------------------------------------------- + +describe('Story payload contract', () => { + it('INSERT: entity appears in storiesByPbi[pbi_id] with correct title and status', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} }) + useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) + const bucket = useBacklogStore.getState().storiesByPbi['pbi-1'] + expect(bucket).toHaveLength(1) + expect(bucket[0].id).toBe('story-1') + expect(bucket[0].title).toBe('Realtime story') + expect(bucket[0].status).toBe('OPEN') + }) + + it('INSERT: creates bucket when pbi_id was not yet in storiesByPbi', () => { + useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) + expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1) + }) + + it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => { + useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) + useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) + expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1) + }) + + it('UPDATE: changed_fields partial merges into existing story', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [{ ...STORY }] }, tasksByStory: {} }) + useBacklogStore.getState().applyChange('story', 'U', { id: 'story-1', title: 'Updated story', status: 'IN_SPRINT' }) + const story = useBacklogStore.getState().storiesByPbi['pbi-1'][0] + expect(story.title).toBe('Updated story') + expect(story.status).toBe('IN_SPRINT') + expect(story.priority).toBe(2) // unchanged field retained + }) + + it('DELETE: entity is removed from its pbi bucket', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [{ ...STORY }] }, tasksByStory: {} }) + useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' }) + expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0) + }) +}) + +// --------------------------------------------------------------------------- +// Task +// --------------------------------------------------------------------------- + +describe('Task payload contract', () => { + it('INSERT: entity appears in tasksByStory[story_id] with correct title and status', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [] } }) + useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) + const bucket = useBacklogStore.getState().tasksByStory['story-1'] + expect(bucket).toHaveLength(1) + expect(bucket[0].id).toBe('task-1') + expect(bucket[0].title).toBe('Realtime task') + expect(bucket[0].status).toBe('TO_DO') + }) + + it('INSERT: creates bucket when story_id was not yet in tasksByStory', () => { + useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) + expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(1) + }) + + it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => { + useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) + useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) + expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(1) + }) + + it('UPDATE: changed_fields partial merges into existing task', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [{ ...TASK }] } }) + useBacklogStore.getState().applyChange('task', 'U', { id: 'task-1', title: 'Updated task', status: 'IN_PROGRESS' }) + const task = useBacklogStore.getState().tasksByStory['story-1'][0] + expect(task.title).toBe('Updated task') + expect(task.status).toBe('IN_PROGRESS') + expect(task.sort_order).toBe(1) // unchanged field retained + }) + + it('DELETE: entity is removed from its story bucket', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [{ ...TASK }] } }) + useBacklogStore.getState().applyChange('task', 'D', { id: 'task-1' }) + expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(0) + }) +}) diff --git a/__tests__/realtime/use-workspace-resync.test.tsx b/__tests__/realtime/use-workspace-resync.test.tsx deleted file mode 100644 index cbc50a5..0000000 --- a/__tests__/realtime/use-workspace-resync.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { renderHook } from '@testing-library/react' - -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import { useWorkspaceResync } from '@/lib/realtime/use-workspace-resync' - -let resyncSpy: ReturnType<typeof vi.fn> - -beforeEach(() => { - resyncSpy = vi.fn().mockResolvedValue(undefined) - useProductWorkspaceStore.setState((s) => { - s.resyncActiveScopes = resyncSpy as unknown as typeof s.resyncActiveScopes - }) - // visibilitychange handler leest document.visibilityState — default is 'visible' - Object.defineProperty(document, 'visibilityState', { - value: 'visible', - writable: true, - configurable: true, - }) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('useWorkspaceResync', () => { - it('triggert resyncActiveScopes("visible") op visibilitychange hidden→visible', () => { - renderHook(() => useWorkspaceResync()) - - Object.defineProperty(document, 'visibilityState', { - value: 'visible', - writable: true, - configurable: true, - }) - document.dispatchEvent(new Event('visibilitychange')) - - expect(resyncSpy).toHaveBeenCalledWith('visible') - }) - - it('triggert resyncActiveScopes("reconnect") op online-event', () => { - renderHook(() => useWorkspaceResync()) - window.dispatchEvent(new Event('online')) - expect(resyncSpy).toHaveBeenCalledWith('reconnect') - }) - - it('triggert geen resync bij visibilitychange naar hidden', () => { - renderHook(() => useWorkspaceResync()) - - Object.defineProperty(document, 'visibilityState', { - value: 'hidden', - writable: true, - configurable: true, - }) - document.dispatchEvent(new Event('visibilitychange')) - - expect(resyncSpy).not.toHaveBeenCalled() - }) - - it('cleanup verwijdert listeners bij unmount', () => { - const { unmount } = renderHook(() => useWorkspaceResync()) - unmount() - - window.dispatchEvent(new Event('online')) - document.dispatchEvent(new Event('visibilitychange')) - - expect(resyncSpy).not.toHaveBeenCalled() - }) -}) diff --git a/__tests__/review-plan-job.test.ts b/__tests__/review-plan-job.test.ts deleted file mode 100644 index 2b298dc..0000000 --- a/__tests__/review-plan-job.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { describe, it, expect } from 'vitest' - -/** - * Review-Plan Job Tests - * - * Tests for the IDEA_REVIEW_PLAN job kind and review-log schema validation. - */ - -// Sample review-log structure for testing -const sampleReviewLog = { - plan_file: 'I-042', - created_at: new Date().toISOString(), - rounds: [ - { - round: 0, - model: 'claude-3-5-haiku', - role: 'Structure Review', - focus: 'YAML parsing, format, syntax', - plan_before: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---', - plan_after: - '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n priority: 2\n---', - issues: [ - { - category: 'structure', - severity: 'warning', - suggestion: 'Add priority field to story', - }, - ], - score: 75, - plan_diff_lines: 1, - converged: false, - timestamp: new Date().toISOString(), - }, - { - round: 1, - model: 'claude-3-5-sonnet', - role: 'Logic & Patterns', - focus: 'Logic gaps, missing patterns, architecture fit', - plan_before: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---', - plan_after: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---', - issues: [ - { - category: 'logic', - severity: 'info', - suggestion: 'Consider adding acceptance criteria', - }, - ], - score: 80, - plan_diff_lines: 0, - converged: false, - timestamp: new Date().toISOString(), - }, - { - round: 2, - model: 'claude-opus-4-7', - role: 'Risk Assessment', - focus: 'Risk assessment, edge cases, refactoring', - plan_before: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---', - plan_after: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---', - issues: [], - score: 85, - plan_diff_lines: 0, - converged: true, - timestamp: new Date().toISOString(), - }, - ], - convergence: { - stable_at_round: 2, - final_diff_pct: 0.5, - convergence_metric: 'plan_stability', - }, - approval: { - status: 'approved', - timestamp: new Date().toISOString(), - }, - summary: 'Plan reviewed across three rounds. Minor structure improvements suggested. Plan approved.', -} - -describe('review-plan-job', () => { - describe('ReviewLog Schema', () => { - it('should have required top-level fields', () => { - expect(sampleReviewLog).toHaveProperty('plan_file') - expect(sampleReviewLog).toHaveProperty('created_at') - expect(sampleReviewLog).toHaveProperty('rounds') - expect(sampleReviewLog).toHaveProperty('convergence') - expect(sampleReviewLog).toHaveProperty('approval') - expect(sampleReviewLog).toHaveProperty('summary') - }) - - it('should have valid plan_file format', () => { - expect(typeof sampleReviewLog.plan_file).toBe('string') - expect(sampleReviewLog.plan_file.length).toBeGreaterThan(0) - }) - - it('should have valid ISO timestamps', () => { - const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/ - expect(sampleReviewLog.created_at).toMatch(isoRegex) - expect(sampleReviewLog.approval.timestamp).toMatch(isoRegex) - }) - - it('should have at least one round', () => { - expect(sampleReviewLog.rounds.length).toBeGreaterThan(0) - }) - - it('should have valid round structure', () => { - for (const round of sampleReviewLog.rounds) { - expect(round).toHaveProperty('round') - expect(round).toHaveProperty('model') - expect(round).toHaveProperty('role') - expect(round).toHaveProperty('focus') - expect(round).toHaveProperty('plan_before') - expect(round).toHaveProperty('plan_after') - expect(round).toHaveProperty('issues') - expect(round).toHaveProperty('score') - expect(round).toHaveProperty('plan_diff_lines') - expect(round).toHaveProperty('converged') - expect(round).toHaveProperty('timestamp') - - expect(typeof round.round).toBe('number') - expect(round.round).toBeGreaterThanOrEqual(0) - expect(typeof round.score).toBe('number') - expect(round.score).toBeGreaterThanOrEqual(0) - expect(round.score).toBeLessThanOrEqual(100) - expect(typeof round.plan_diff_lines).toBe('number') - expect(round.plan_diff_lines).toBeGreaterThanOrEqual(0) - } - }) - - it('should have valid issue structure per round', () => { - for (const round of sampleReviewLog.rounds) { - for (const issue of round.issues) { - expect(issue).toHaveProperty('category') - expect(issue).toHaveProperty('severity') - expect(issue).toHaveProperty('suggestion') - - expect(['structure', 'logic', 'risk', 'pattern']).toContain(issue.category) - expect(['error', 'warning', 'info']).toContain(issue.severity) - expect(typeof issue.suggestion).toBe('string') - expect(issue.suggestion.length).toBeGreaterThan(0) - } - } - }) - - it('should have valid convergence structure when present', () => { - if (sampleReviewLog.convergence) { - expect(sampleReviewLog.convergence).toHaveProperty('stable_at_round') - expect(sampleReviewLog.convergence).toHaveProperty('final_diff_pct') - expect(sampleReviewLog.convergence).toHaveProperty('convergence_metric') - - expect(typeof sampleReviewLog.convergence.stable_at_round).toBe('number') - expect(sampleReviewLog.convergence.stable_at_round).toBeGreaterThanOrEqual(0) - expect(typeof sampleReviewLog.convergence.final_diff_pct).toBe('number') - expect(sampleReviewLog.convergence.final_diff_pct).toBeGreaterThanOrEqual(0) - expect(sampleReviewLog.convergence.final_diff_pct).toBeLessThanOrEqual(100) - } - }) - - it('should have valid approval status', () => { - expect(['pending', 'approved', 'rejected']).toContain(sampleReviewLog.approval.status) - if (sampleReviewLog.approval.status !== 'pending') { - expect(sampleReviewLog.approval.timestamp).toBeDefined() - } - }) - - it('should have non-empty summary', () => { - expect(typeof sampleReviewLog.summary).toBe('string') - expect(sampleReviewLog.summary.length).toBeGreaterThan(0) - }) - }) - - describe('Convergence Detection', () => { - it('should detect convergence when diff_pct < 5% for two consecutive rounds', () => { - // Simulate convergence: round 0 has 1 diff line, rounds 1-2 have 0 diffs - const totalLines = 50 - const diff0 = 1 - const diff1 = 0 - const diff2 = 0 - - const pct0 = (diff0 / totalLines) * 100 // 2% - const pct1 = (diff1 / totalLines) * 100 // 0% - const pct2 = (diff2 / totalLines) * 100 // 0% - - expect(pct0).toBeLessThan(5) // Should converge - expect(pct1).toBeLessThan(5) // Should converge - expect(pct2).toBeLessThan(5) // Should converge - }) - - it('should not detect convergence when diff_pct >= 5%', () => { - const totalLines = 50 - const diff = 3 // 6% change - - const pct = (diff / totalLines) * 100 - expect(pct).toBeGreaterThanOrEqual(5) - }) - }) - - describe('Status Transitions', () => { - it('should transition REVIEWING_PLAN → PLAN_REVIEWED when approved', () => { - const log = { ...sampleReviewLog, approval: { status: 'approved', timestamp: new Date().toISOString() } } - expect(log.approval.status).toBe('approved') - // In actual implementation: update_idea_plan_reviewed({ approval_status: 'approved' }) - // → idea.status = 'PLAN_REVIEWED' - }) - - it('should transition REVIEWING_PLAN → PLAN_REVIEW_FAILED when rejected', () => { - const log = { ...sampleReviewLog, approval: { status: 'rejected' } } - expect(log.approval.status).toBe('rejected') - // In actual implementation: update_idea_plan_reviewed({ approval_status: 'rejected' }) - // → idea.status = 'PLAN_REVIEW_FAILED' - }) - }) -}) diff --git a/__tests__/stores/idea-store.test.ts b/__tests__/stores/idea-store.test.ts deleted file mode 100644 index 37d7413..0000000 --- a/__tests__/stores/idea-store.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' - -import { useIdeaStore } from '@/stores/idea-store' - -beforeEach(() => { - // Reset store between tests — Zustand persists state across tests otherwise. - useIdeaStore.setState({ - jobByIdea: {}, - ideaStatuses: {}, - openQuestionsByIdea: {}, - }) -}) - -describe('useIdeaStore — handleIdeaJobEvent', () => { - it('queued IDEA_GRILL → ideaStatuses[id] = grilling', () => { - useIdeaStore.getState().handleIdeaJobEvent({ - type: 'claude_job_enqueued', - job_id: 'job-1', - idea_id: 'idea-1', - user_id: 'u-1', - kind: 'IDEA_GRILL', - status: 'queued', - }) - const s = useIdeaStore.getState() - expect(s.jobByIdea['idea-1']?.status).toBe('queued') - expect(s.ideaStatuses['idea-1']).toBe('grilling') - }) - - it('failed IDEA_GRILL → ideaStatuses[id] = grill_failed', () => { - useIdeaStore.getState().handleIdeaJobEvent({ - type: 'claude_job_status', - job_id: 'job-1', - idea_id: 'idea-1', - user_id: 'u-1', - kind: 'IDEA_GRILL', - status: 'failed', - error: 'oops', - }) - expect(useIdeaStore.getState().ideaStatuses['idea-1']).toBe('grill_failed') - expect(useIdeaStore.getState().jobByIdea['idea-1']?.error).toBe('oops') - }) - - it('failed IDEA_MAKE_PLAN → plan_failed', () => { - useIdeaStore.getState().handleIdeaJobEvent({ - type: 'claude_job_status', - job_id: 'job-2', - idea_id: 'idea-2', - user_id: 'u-1', - kind: 'IDEA_MAKE_PLAN', - status: 'failed', - }) - expect(useIdeaStore.getState().ideaStatuses['idea-2']).toBe('plan_failed') - }) - - it('done does NOT auto-derive status (server is source-of-truth)', () => { - useIdeaStore.getState().setIdeaStatus('idea-3', 'grilled') - useIdeaStore.getState().handleIdeaJobEvent({ - type: 'claude_job_status', - job_id: 'job-3', - idea_id: 'idea-3', - user_id: 'u-1', - kind: 'IDEA_GRILL', - status: 'done', - }) - expect(useIdeaStore.getState().ideaStatuses['idea-3']).toBe('grilled') - }) -}) - -describe('useIdeaStore — handleIdeaQuestionEvent', () => { - it('non-open status removes question from list', () => { - useIdeaStore.getState().initQuestions('idea-1', [ - { - id: 'q-1', - idea_id: 'idea-1', - question: 'Q', - options: null, - status: 'open', - created_at: '', - expires_at: '', - }, - ]) - useIdeaStore.getState().handleIdeaQuestionEvent({ - op: 'U', - entity: 'question', - id: 'q-1', - product_id: 'p-1', - story_id: null, - idea_id: 'idea-1', - status: 'answered', - }) - expect(useIdeaStore.getState().openQuestionsByIdea['idea-1']).toEqual([]) - }) - - it('open status keeps existing list (no detail in payload)', () => { - const q = { - id: 'q-1', - idea_id: 'idea-1', - question: 'Q', - options: null, - status: 'open' as const, - created_at: '', - expires_at: '', - } - useIdeaStore.getState().initQuestions('idea-1', [q]) - useIdeaStore.getState().handleIdeaQuestionEvent({ - op: 'I', - entity: 'question', - id: 'q-2', - product_id: 'p-1', - story_id: null, - idea_id: 'idea-1', - status: 'open', - }) - // List length blijft 1 (server-fetch leveert de detail) - expect(useIdeaStore.getState().openQuestionsByIdea['idea-1']).toHaveLength(1) - }) -}) - -describe('useIdeaStore — clearForIdea', () => { - it('removes job + status + questions for one idea, leaves others', () => { - const s = useIdeaStore.getState() - s.setJobStatus({ - job_id: 'j-1', - idea_id: 'idea-1', - kind: 'IDEA_GRILL', - status: 'running', - }) - s.setJobStatus({ - job_id: 'j-2', - idea_id: 'idea-2', - kind: 'IDEA_GRILL', - status: 'running', - }) - s.setIdeaStatus('idea-1', 'grilling') - s.setIdeaStatus('idea-2', 'grilling') - - s.clearForIdea('idea-1') - - const after = useIdeaStore.getState() - expect(after.jobByIdea['idea-1']).toBeUndefined() - expect(after.jobByIdea['idea-2']).toBeDefined() - expect(after.ideaStatuses['idea-1']).toBeUndefined() - expect(after.ideaStatuses['idea-2']).toBe('grilling') - }) -}) diff --git a/__tests__/stores/product-workspace/restore.test.ts b/__tests__/stores/product-workspace/restore.test.ts deleted file mode 100644 index baa8120..0000000 --- a/__tests__/stores/product-workspace/restore.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - clearHints, - readHints, - writePbiHint, - writeProductHint, - writeStoryHint, - writeTaskHint, -} from '@/stores/product-workspace/restore' - -describe('readHints', () => { - it('retourneert lege defaults wanneer localStorage leeg is', () => { - const hints = readHints() - expect(hints.lastActiveProductId).toBeNull() - expect(hints.perProduct).toEqual({}) - }) - - it('herstelt hints uit localStorage', () => { - localStorage.setItem( - 'product-workspace-hints', - JSON.stringify({ - lastActiveProductId: 'p1', - perProduct: { p1: { lastActivePbiId: 'pbi-1' } }, - }), - ) - const hints = readHints() - expect(hints.lastActiveProductId).toBe('p1') - expect(hints.perProduct.p1.lastActivePbiId).toBe('pbi-1') - }) - - it('valt terug op defaults bij ongeldige JSON', () => { - localStorage.setItem('product-workspace-hints', '{not-json') - const hints = readHints() - expect(hints.lastActiveProductId).toBeNull() - expect(hints.perProduct).toEqual({}) - }) - - it('valt terug op defaults bij verkeerde shape', () => { - localStorage.setItem('product-workspace-hints', '"just a string"') - const hints = readHints() - expect(hints.perProduct).toEqual({}) - }) -}) - -describe('writeProductHint', () => { - it('schrijft lastActiveProductId', () => { - writeProductHint('p1') - expect(readHints().lastActiveProductId).toBe('p1') - }) - - it('overschrijft bestaande waarde', () => { - writeProductHint('p1') - writeProductHint('p2') - expect(readHints().lastActiveProductId).toBe('p2') - }) - - it('accepteert null om hint te wissen', () => { - writeProductHint('p1') - writeProductHint(null) - expect(readHints().lastActiveProductId).toBeNull() - }) -}) - -describe('writePbiHint', () => { - it('schrijft lastActivePbiId per productId', () => { - writePbiHint('prod-1', 'pbi-a') - writePbiHint('prod-2', 'pbi-b') - const hints = readHints() - expect(hints.perProduct['prod-1'].lastActivePbiId).toBe('pbi-a') - expect(hints.perProduct['prod-2'].lastActivePbiId).toBe('pbi-b') - }) - - it('null wist child story- en task-hints', () => { - writePbiHint('prod-1', 'pbi-1') - writeStoryHint('prod-1', 's-1') - writeTaskHint('prod-1', 't-1') - writePbiHint('prod-1', null) - const hints = readHints() - expect(hints.perProduct['prod-1'].lastActivePbiId).toBeNull() - expect(hints.perProduct['prod-1'].lastActiveStoryId).toBeNull() - expect(hints.perProduct['prod-1'].lastActiveTaskId).toBeNull() - }) -}) - -describe('writeStoryHint', () => { - it('schrijft lastActiveStoryId per productId', () => { - writeStoryHint('prod-1', 's-1') - expect(readHints().perProduct['prod-1'].lastActiveStoryId).toBe('s-1') - }) - - it('null wist child task-hint', () => { - writeStoryHint('prod-1', 's-1') - writeTaskHint('prod-1', 't-1') - writeStoryHint('prod-1', null) - expect(readHints().perProduct['prod-1'].lastActiveStoryId).toBeNull() - expect(readHints().perProduct['prod-1'].lastActiveTaskId).toBeNull() - }) -}) - -describe('writeTaskHint', () => { - it('schrijft lastActiveTaskId per productId', () => { - writeTaskHint('prod-1', 't-1') - expect(readHints().perProduct['prod-1'].lastActiveTaskId).toBe('t-1') - }) -}) - -describe('clearHints', () => { - it('verwijdert alle hints', () => { - writeProductHint('p1') - writePbiHint('p1', 'pbi-1') - clearHints() - const hints = readHints() - expect(hints.lastActiveProductId).toBeNull() - expect(hints.perProduct).toEqual({}) - }) -}) diff --git a/__tests__/stores/product-workspace/screen-state.test.ts b/__tests__/stores/product-workspace/screen-state.test.ts deleted file mode 100644 index 7463fff..0000000 --- a/__tests__/stores/product-workspace/screen-state.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - deriveScreenState, - type ScreenStateInput, -} from '@/stores/product-workspace/screen-state' - -const base: ScreenStateInput = { - activeSprintItem: null, - buildingSprintIds: [], - hasPendingDraft: false, - pendingAdds: [], - pendingRemoves: [], -} - -describe('deriveScreenState', () => { - it('returns NO_SPRINT without draft or active sprint', () => { - expect(deriveScreenState(base)).toEqual({ kind: 'NO_SPRINT' }) - }) - - it('returns DRAFT when a pending draft exists', () => { - expect(deriveScreenState({ ...base, hasPendingDraft: true })).toEqual({ - kind: 'DRAFT', - }) - }) - - it('lets a draft win over an active sprint with pending changes', () => { - expect( - deriveScreenState({ - ...base, - hasPendingDraft: true, - activeSprintItem: { id: 's1' }, - pendingAdds: ['x'], - }), - ).toEqual({ kind: 'DRAFT' }) - }) - - it('returns ACTIVE for an active sprint with no pending changes', () => { - expect( - deriveScreenState({ ...base, activeSprintItem: { id: 's1' } }), - ).toEqual({ kind: 'ACTIVE', building: false }) - }) - - it('flags building when the active sprint is in buildingSprintIds', () => { - expect( - deriveScreenState({ - ...base, - activeSprintItem: { id: 's1' }, - buildingSprintIds: ['s1'], - }), - ).toEqual({ kind: 'ACTIVE', building: true }) - }) - - it('returns EDITING when there are pending adds', () => { - expect( - deriveScreenState({ - ...base, - activeSprintItem: { id: 's1' }, - pendingAdds: ['x'], - }), - ).toEqual({ kind: 'EDITING', building: false }) - }) - - it('returns EDITING when there are pending removes', () => { - expect( - deriveScreenState({ - ...base, - activeSprintItem: { id: 's1' }, - pendingRemoves: ['y'], - }), - ).toEqual({ kind: 'EDITING', building: false }) - }) - - it('flags building on EDITING when the active sprint is building', () => { - expect( - deriveScreenState({ - ...base, - activeSprintItem: { id: 's1' }, - pendingAdds: ['x'], - buildingSprintIds: ['s1'], - }), - ).toEqual({ kind: 'EDITING', building: true }) - }) -}) diff --git a/__tests__/stores/product-workspace/sprint-membership.test.ts b/__tests__/stores/product-workspace/sprint-membership.test.ts deleted file mode 100644 index 6f271de..0000000 --- a/__tests__/stores/product-workspace/sprint-membership.test.ts +++ /dev/null @@ -1,341 +0,0 @@ -// @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import { - selectIsDirty, - selectPbiTriState, - selectPendingCount, - selectStoryEffectiveInSprint, - selectStoryIsBlocked, -} from '@/stores/product-workspace/selectors' -import type { BacklogStory } from '@/stores/product-workspace/types' - -function resetMembership() { - useProductWorkspaceStore.setState((s) => { - s.entities.storiesById = {} - s.relations.storyIdsByPbi = {} - s.sprintMembership = { - pbiSummary: {}, - crossSprintBlocks: {}, - pending: { adds: [], removes: [] }, - loadedSummaryForSprintId: null, - } - }) -} - -function seedStory(id: string, pbiId: string, sprintId: string | null): BacklogStory { - return { - id, - code: id, - title: id, - description: null, - acceptance_criteria: null, - priority: 2, - sort_order: 1, - status: sprintId ? 'IN_SPRINT' : 'OPEN', - pbi_id: pbiId, - sprint_id: sprintId, - created_at: new Date('2026-01-01'), - } -} - -beforeEach(() => { - resetMembership() -}) - -describe('toggleStorySprintMembership', () => { - it('adds storyId to pending.adds when currently not in sprint', () => { - useProductWorkspaceStore.getState().toggleStorySprintMembership('s1', false) - const pending = useProductWorkspaceStore.getState().sprintMembership.pending - expect(pending.adds).toEqual(['s1']) - expect(pending.removes).toEqual([]) - }) - - it('adds storyId to pending.removes when currently in sprint', () => { - useProductWorkspaceStore.getState().toggleStorySprintMembership('s1', true) - const pending = useProductWorkspaceStore.getState().sprintMembership.pending - expect(pending.removes).toEqual(['s1']) - expect(pending.adds).toEqual([]) - }) - - it('cancels out: toggle add → toggle remove same story (in-sprint) clears pending', () => { - const store = useProductWorkspaceStore.getState() - store.toggleStorySprintMembership('s1', false) // adds - // Story now appears to be "in sprint" via pending; calling with true should cancel - store.toggleStorySprintMembership('s1', false) // second click with same baseline - const pending = useProductWorkspaceStore.getState().sprintMembership.pending - expect(pending.adds).toEqual([]) - expect(pending.removes).toEqual([]) - }) - - it('removes from pending.removes when toggled back', () => { - const store = useProductWorkspaceStore.getState() - store.toggleStorySprintMembership('s1', true) - store.toggleStorySprintMembership('s1', true) - const pending = useProductWorkspaceStore.getState().sprintMembership.pending - expect(pending.removes).toEqual([]) - expect(pending.adds).toEqual([]) - }) - - it('resetSprintMembershipPending empties both arrays', () => { - const store = useProductWorkspaceStore.getState() - store.toggleStorySprintMembership('s1', false) - store.toggleStorySprintMembership('s2', true) - store.resetSprintMembershipPending() - const pending = useProductWorkspaceStore.getState().sprintMembership.pending - expect(pending.adds).toEqual([]) - expect(pending.removes).toEqual([]) - }) -}) - -describe('selectPbiTriState', () => { - function seedSummary(pbiId: string, total: number, inSprint: number) { - useProductWorkspaceStore.setState((s) => { - s.sprintMembership.pbiSummary[pbiId] = { - totalStoryCount: total, - inActiveSprintStoryCount: inSprint, - } - }) - } - - it('returns empty for PBI without summary', () => { - expect( - selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), - ).toBe('empty') - }) - - it('returns empty when totalStoryCount == 0', () => { - seedSummary('pbi-1', 0, 0) - expect( - selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), - ).toBe('empty') - }) - - it('returns full when all stories in sprint (no pending)', () => { - seedSummary('pbi-1', 3, 3) - expect( - selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), - ).toBe('full') - }) - - it('returns partial when some stories in sprint', () => { - seedSummary('pbi-1', 3, 2) - expect( - selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), - ).toBe('partial') - }) - - it('returns empty when inSprint == 0', () => { - seedSummary('pbi-1', 3, 0) - expect( - selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), - ).toBe('empty') - }) - - it('applies pending adds when stories are loaded for the PBI', () => { - seedSummary('pbi-1', 3, 1) - useProductWorkspaceStore.setState((s) => { - s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3'] - s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-1') - s.entities.storiesById['s2'] = seedStory('s2', 'pbi-1', null) - s.entities.storiesById['s3'] = seedStory('s3', 'pbi-1', null) - s.sprintMembership.pending.adds = ['s2', 's3'] - }) - expect( - selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), - ).toBe('full') - }) - - it('applies pending removes when stories are loaded for the PBI', () => { - seedSummary('pbi-1', 3, 3) - useProductWorkspaceStore.setState((s) => { - s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3'] - s.sprintMembership.pending.removes = ['s2'] - }) - expect( - selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), - ).toBe('partial') - }) - - it('ignores pending entries for stories of other PBIs', () => { - seedSummary('pbi-1', 3, 3) - useProductWorkspaceStore.setState((s) => { - s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3'] - s.sprintMembership.pending.removes = ['s99'] // not in pbi-1 - }) - expect( - selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), - ).toBe('full') - }) -}) - -describe('selectStoryEffectiveInSprint', () => { - it('returns true when story.sprint_id matches activeSprintId and no pending', () => { - useProductWorkspaceStore.setState((s) => { - s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A') - }) - expect( - selectStoryEffectiveInSprint( - useProductWorkspaceStore.getState(), - 's1', - 'sprint-A', - ), - ).toBe(true) - }) - - it('returns false when story.sprint_id is null', () => { - useProductWorkspaceStore.setState((s) => { - s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', null) - }) - expect( - selectStoryEffectiveInSprint( - useProductWorkspaceStore.getState(), - 's1', - 'sprint-A', - ), - ).toBe(false) - }) - - it('returns true when story in pending.adds even if DB says no', () => { - useProductWorkspaceStore.setState((s) => { - s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', null) - s.sprintMembership.pending.adds = ['s1'] - }) - expect( - selectStoryEffectiveInSprint( - useProductWorkspaceStore.getState(), - 's1', - 'sprint-A', - ), - ).toBe(true) - }) - - it('returns false when story in pending.removes even if DB says yes', () => { - useProductWorkspaceStore.setState((s) => { - s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A') - s.sprintMembership.pending.removes = ['s1'] - }) - expect( - selectStoryEffectiveInSprint( - useProductWorkspaceStore.getState(), - 's1', - 'sprint-A', - ), - ).toBe(false) - }) - - it('returns false when activeSprintId is null', () => { - useProductWorkspaceStore.setState((s) => { - s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A') - }) - expect( - selectStoryEffectiveInSprint( - useProductWorkspaceStore.getState(), - 's1', - null, - ), - ).toBe(false) - }) -}) - -describe('selectStoryIsBlocked', () => { - it('returns null when no block', () => { - expect( - selectStoryIsBlocked(useProductWorkspaceStore.getState(), 's1'), - ).toBeNull() - }) - - it('returns block info when story is in another sprint', () => { - useProductWorkspaceStore.setState((s) => { - s.sprintMembership.crossSprintBlocks['s1'] = { - sprintId: 'sprint-x', - sprintName: 'SP-X', - } - }) - expect( - selectStoryIsBlocked(useProductWorkspaceStore.getState(), 's1'), - ).toEqual({ sprintId: 'sprint-x', sprintName: 'SP-X' }) - }) -}) - -describe('selectIsDirty + selectPendingCount', () => { - it('clean by default', () => { - expect(selectIsDirty(useProductWorkspaceStore.getState())).toBe(false) - expect(selectPendingCount(useProductWorkspaceStore.getState())).toBe(0) - }) - - it('counts adds + removes', () => { - useProductWorkspaceStore.setState((s) => { - s.sprintMembership.pending = { - adds: ['a1', 'a2'], - removes: ['r1'], - } - }) - expect(selectIsDirty(useProductWorkspaceStore.getState())).toBe(true) - expect(selectPendingCount(useProductWorkspaceStore.getState())).toBe(3) - }) -}) - -describe('fetch helpers', () => { - it('fetchSprintMembershipSummary populates store and gates by sprintId', async () => { - const originalFetch = globalThis.fetch - const responseBody = { - pbiA: { totalStoryCount: 5, inActiveSprintStoryCount: 2 }, - } - globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify(responseBody), { status: 200 }), - ) as unknown as typeof fetch - try { - await useProductWorkspaceStore - .getState() - .fetchSprintMembershipSummary('prod-1', 'sprint-A', ['pbiA']) - - const slice = useProductWorkspaceStore.getState().sprintMembership - expect(slice.pbiSummary.pbiA).toEqual({ - totalStoryCount: 5, - inActiveSprintStoryCount: 2, - }) - expect(slice.loadedSummaryForSprintId).toBe('sprint-A') - } finally { - globalThis.fetch = originalFetch - } - }) - - it('fetchCrossSprintBlocks populates store', async () => { - const originalFetch = globalThis.fetch - const responseBody = { - 's1': { sprintId: 'sprint-x', sprintName: 'SP-X' }, - } - globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify(responseBody), { status: 200 }), - ) as unknown as typeof fetch - try { - await useProductWorkspaceStore - .getState() - .fetchCrossSprintBlocks('prod-1', 'sprint-A', ['pbiA']) - - const slice = useProductWorkspaceStore.getState().sprintMembership - expect(slice.crossSprintBlocks['s1']).toEqual({ - sprintId: 'sprint-x', - sprintName: 'SP-X', - }) - } finally { - globalThis.fetch = originalFetch - } - }) - - it('fetchSprintMembershipSummary is a no-op for empty pbiIds', async () => { - const fetchSpy = vi.fn() - const originalFetch = globalThis.fetch - globalThis.fetch = fetchSpy as unknown as typeof fetch - try { - await useProductWorkspaceStore - .getState() - .fetchSprintMembershipSummary('prod-1', 'sprint-A', []) - expect(fetchSpy).not.toHaveBeenCalled() - } finally { - globalThis.fetch = originalFetch - } - }) -}) diff --git a/__tests__/stores/product-workspace/store.test.ts b/__tests__/stores/product-workspace/store.test.ts deleted file mode 100644 index ff86cfc..0000000 --- a/__tests__/stores/product-workspace/store.test.ts +++ /dev/null @@ -1,890 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import type { - BacklogPbi, - BacklogStory, - BacklogTask, - ProductBacklogSnapshot, - TaskDetail, -} from '@/stores/product-workspace/types' - -// G5: snapshot original actions on module-load; restore in beforeEach. -// vi.fn-spies on actions could leak across tests otherwise. -const originalActions = (() => { - const s = useProductWorkspaceStore.getState() - return { - hydrateSnapshot: s.hydrateSnapshot, - setActiveProduct: s.setActiveProduct, - setActivePbi: s.setActivePbi, - setActiveStory: s.setActiveStory, - setActiveTask: s.setActiveTask, - ensureProductLoaded: s.ensureProductLoaded, - ensurePbiLoaded: s.ensurePbiLoaded, - ensureStoryLoaded: s.ensureStoryLoaded, - ensureTaskLoaded: s.ensureTaskLoaded, - applyRealtimeEvent: s.applyRealtimeEvent, - resyncActiveScopes: s.resyncActiveScopes, - resyncLoadedScopes: s.resyncLoadedScopes, - applyOptimisticMutation: s.applyOptimisticMutation, - rollbackMutation: s.rollbackMutation, - settleMutation: s.settleMutation, - setRealtimeStatus: s.setRealtimeStatus, - } -})() - -function resetStore() { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = null - s.context.activePbiId = null - s.context.activeStoryId = null - s.context.activeTaskId = null - s.entities.pbisById = {} - s.entities.storiesById = {} - s.entities.tasksById = {} - s.relations.pbiIds = [] - s.relations.storyIdsByPbi = {} - s.relations.taskIdsByStory = {} - s.loading.loadedProductId = null - s.loading.loadingProductId = null - s.loading.loadedPbiIds = {} - s.loading.loadedStoryIds = {} - s.loading.loadedTaskIds = {} - s.loading.activeRequestId = null - s.sync.realtimeStatus = 'connecting' - s.sync.lastEventAt = null - s.sync.lastResyncAt = null - s.sync.resyncReason = null - s.pendingMutations = {} - s.sprintMembership = { - pbiSummary: {}, - crossSprintBlocks: {}, - pending: { adds: [], removes: [] }, - loadedSummaryForSprintId: null, - } - Object.assign(s, originalActions) - }) -} - -beforeEach(() => { - resetStore() -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -function makePbi(overrides: Partial<BacklogPbi> & { id: string }): BacklogPbi { - return { - id: overrides.id, - code: overrides.code ?? overrides.id, - title: overrides.title ?? `PBI ${overrides.id}`, - priority: overrides.priority ?? 2, - sort_order: overrides.sort_order ?? 1, - description: overrides.description ?? null, - created_at: overrides.created_at ?? new Date('2026-01-01'), - status: overrides.status ?? 'ready', - } -} - -function makeStory(overrides: Partial<BacklogStory> & { id: string; pbi_id: string }): BacklogStory { - return { - id: overrides.id, - code: overrides.code ?? overrides.id, - title: overrides.title ?? `Story ${overrides.id}`, - description: overrides.description ?? null, - acceptance_criteria: overrides.acceptance_criteria ?? null, - priority: overrides.priority ?? 2, - sort_order: overrides.sort_order ?? 1, - status: overrides.status ?? 'OPEN', - pbi_id: overrides.pbi_id, - sprint_id: overrides.sprint_id ?? null, - created_at: overrides.created_at ?? new Date('2026-01-01'), - } -} - -function makeTask(overrides: Partial<BacklogTask> & { id: string; story_id: string }): BacklogTask { - return { - id: overrides.id, - code: overrides.code ?? null, - title: overrides.title ?? `Task ${overrides.id}`, - description: overrides.description ?? null, - priority: overrides.priority ?? 2, - sort_order: overrides.sort_order ?? 1, - status: overrides.status ?? 'TO_DO', - story_id: overrides.story_id, - created_at: overrides.created_at ?? new Date('2026-01-01'), - } -} - -function snapshotWith( - pbis: BacklogPbi[], - storiesByPbi: Record<string, BacklogStory[]> = {}, - tasksByStory: Record<string, BacklogTask[]> = {}, - product?: { id: string; name: string }, -): ProductBacklogSnapshot { - return { product, pbis, storiesByPbi, tasksByStory } -} - -// G7: mock fetch — never let it fall through to real network -// G8: mockImplementation per call so each fetch gets a fresh Response -function mockFetchSequence( - responses: Array<unknown | ((url: string, init?: RequestInit) => unknown)>, -) { - let i = 0 - return vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string, init?: RequestInit) => { - const r = responses[Math.min(i, responses.length - 1)] - i += 1 - const body = typeof r === 'function' ? (r as (u: string, i?: RequestInit) => unknown)(url, init) : r - return new Response(JSON.stringify(body ?? null), { status: 200 }) - }) as unknown as typeof fetch) -} - -// ───────────────────────────────────────────────────────────────────────── -// hydrateSnapshot -// ───────────────────────────────────────────────────────────────────────── - -describe('hydrateSnapshot', () => { - it('vult entities en relations met gesorteerde id-lijsten', () => { - const pbiA = makePbi({ id: 'pbi-a', priority: 2, sort_order: 2 }) - const pbiB = makePbi({ id: 'pbi-b', priority: 1, sort_order: 5 }) - const pbiC = makePbi({ id: 'pbi-c', priority: 2, sort_order: 1 }) - const storyB1 = makeStory({ id: 'st-1', pbi_id: 'pbi-b', sort_order: 2 }) - const storyB2 = makeStory({ id: 'st-2', pbi_id: 'pbi-b', sort_order: 1 }) - const taskA = makeTask({ id: 'tk-2', story_id: 'st-1', sort_order: 2 }) - const taskB = makeTask({ id: 'tk-1', story_id: 'st-1', sort_order: 1 }) - - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - [pbiA, pbiB, pbiC], - { 'pbi-b': [storyB1, storyB2] }, - { 'st-1': [taskA, taskB] }, - { id: 'prod-1', name: 'Product 1' }, - ), - ) - - const s = useProductWorkspaceStore.getState() - expect(s.entities.pbisById['pbi-a']).toBe(pbiA) - expect(s.entities.pbisById['pbi-b']).toBe(pbiB) - expect(s.entities.pbisById['pbi-c']).toBe(pbiC) - // pbi-b heeft priority 1 (komt eerst), dan pbi-c (sort_order 1) en pbi-a (sort_order 2) - expect(s.relations.pbiIds).toEqual(['pbi-b', 'pbi-c', 'pbi-a']) - expect(s.relations.storyIdsByPbi['pbi-b']).toEqual(['st-2', 'st-1']) - expect(s.relations.taskIdsByStory['st-1']).toEqual(['tk-1', 'tk-2']) - expect(s.context.activeProduct).toEqual({ id: 'prod-1', name: 'Product 1' }) - expect(s.loading.loadedProductId).toBe('prod-1') - }) - - it('normaliseert API-statussen naar het interne store-contract', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - [makePbi({ id: 'pbi-1', status: 'READY' as BacklogPbi['status'] })], - { - 'pbi-1': [ - makeStory({ id: 'st-1', pbi_id: 'pbi-1', status: 'in_sprint' }), - ], - }, - { - 'st-1': [makeTask({ id: 'tk-1', story_id: 'st-1', status: 'todo' })], - }, - ), - ) - - const s = useProductWorkspaceStore.getState() - expect(s.entities.pbisById['pbi-1'].status).toBe('ready') - expect(s.entities.storiesById['st-1'].status).toBe('IN_SPRINT') - expect(s.entities.tasksById['tk-1'].status).toBe('TO_DO') - }) - - it('reset bestaande entities en relations bij her-hydratie', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([makePbi({ id: 'old-pbi' })]), - ) - expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['old-pbi']) - - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([makePbi({ id: 'new-pbi' })]), - ) - const s = useProductWorkspaceStore.getState() - expect(s.entities.pbisById['old-pbi']).toBeUndefined() - expect(s.entities.pbisById['new-pbi']).toBeDefined() - expect(s.relations.pbiIds).toEqual(['new-pbi']) - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// Selection cascade -// ───────────────────────────────────────────────────────────────────────── - -describe('selection cascade', () => { - it('setActivePbi reset story+task; setActiveStory reset task', () => { - useProductWorkspaceStore.setState((s) => { - s.context.activePbiId = 'pbi-old' - s.context.activeStoryId = 'st-old' - s.context.activeTaskId = 'tk-old' - }) - - useProductWorkspaceStore.getState().setActivePbi('pbi-new') - let s = useProductWorkspaceStore.getState() - expect(s.context.activePbiId).toBe('pbi-new') - expect(s.context.activeStoryId).toBeNull() - expect(s.context.activeTaskId).toBeNull() - - useProductWorkspaceStore.setState((draft) => { - draft.context.activeStoryId = 'st-old' - draft.context.activeTaskId = 'tk-old' - }) - useProductWorkspaceStore.getState().setActiveStory('st-new') - s = useProductWorkspaceStore.getState() - expect(s.context.activeStoryId).toBe('st-new') - expect(s.context.activeTaskId).toBeNull() - }) - - it('setActiveProduct(null) ruimt entities en relations op', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - [makePbi({ id: 'p-1' })], - { 'p-1': [makeStory({ id: 's-1', pbi_id: 'p-1' })] }, - { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] }, - { id: 'prod-1', name: 'Product 1' }, - ), - ) - - useProductWorkspaceStore.getState().setActiveProduct(null) - const s = useProductWorkspaceStore.getState() - expect(s.context.activeProduct).toBeNull() - expect(s.context.activePbiId).toBeNull() - expect(s.context.activeStoryId).toBeNull() - expect(s.context.activeTaskId).toBeNull() - expect(s.entities.pbisById).toEqual({}) - expect(s.entities.storiesById).toEqual({}) - expect(s.entities.tasksById).toEqual({}) - expect(s.relations.pbiIds).toEqual([]) - expect(s.relations.storyIdsByPbi).toEqual({}) - expect(s.relations.taskIdsByStory).toEqual({}) - expect(s.loading.loadedProductId).toBeNull() - }) - - it('setActiveProduct kan alleen context zetten zonder full backlog load', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - [makePbi({ id: 'p-1' })], - { 'p-1': [makeStory({ id: 's-1', pbi_id: 'p-1' })] }, - { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] }, - { id: 'prod-1', name: 'Product 1' }, - ), - ) - useProductWorkspaceStore.setState((s) => { - s.context.activePbiId = 'p-1' - s.context.activeStoryId = 's-1' - }) - const fetchSpy = vi.spyOn(globalThis, 'fetch') - - useProductWorkspaceStore - .getState() - .setActiveProduct( - { id: 'prod-1', name: 'Product 1' }, - { load: false, preserveSelection: true }, - ) - - const s = useProductWorkspaceStore.getState() - expect(fetchSpy).not.toHaveBeenCalled() - expect(s.context.activePbiId).toBe('p-1') - expect(s.context.activeStoryId).toBe('s-1') - expect(s.entities.pbisById['p-1']).toBeDefined() - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// applyRealtimeEvent -// ───────────────────────────────────────────────────────────────────────── - -describe('applyRealtimeEvent — pbi', () => { - beforeEach(() => { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - }) - - it('I — voegt PBI toe en sorteert', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([makePbi({ id: 'a', priority: 2, sort_order: 5 })]), - ) - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'pbi', - op: 'I', - id: 'b', - product_id: 'prod-1', - code: 'B', - title: 'New PBI', - priority: 1, - sort_order: 1, - created_at: new Date('2026-02-01').toISOString(), - status: 'ready', - }) - const s = useProductWorkspaceStore.getState() - expect(s.entities.pbisById['b']).toBeDefined() - expect(s.relations.pbiIds).toEqual(['b', 'a']) - }) - - it('I — idempotent voor bestaande id', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([makePbi({ id: 'a' })]), - ) - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'pbi', - op: 'I', - id: 'a', - product_id: 'prod-1', - title: 'mutated', - }) - const s = useProductWorkspaceStore.getState() - expect(s.entities.pbisById['a'].title).toBe('PBI a') // niet overschreven - expect(s.relations.pbiIds).toEqual(['a']) - }) - - it('U — patch + her-sorteert', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([ - makePbi({ id: 'a', priority: 2, sort_order: 1 }), - makePbi({ id: 'b', priority: 2, sort_order: 2 }), - ]), - ) - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'pbi', - op: 'U', - id: 'b', - product_id: 'prod-1', - priority: 1, - }) - const s = useProductWorkspaceStore.getState() - expect(s.relations.pbiIds).toEqual(['b', 'a']) - }) - - it('D — verwijdert PBI inclusief child stories en tasks', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - [makePbi({ id: 'p1' })], - { p1: [makeStory({ id: 's1', pbi_id: 'p1' })] }, - { s1: [makeTask({ id: 't1', story_id: 's1' })] }, - ), - ) - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'pbi', - op: 'D', - id: 'p1', - product_id: 'prod-1', - }) - const s = useProductWorkspaceStore.getState() - expect(s.entities.pbisById['p1']).toBeUndefined() - expect(s.entities.storiesById['s1']).toBeUndefined() - expect(s.entities.tasksById['t1']).toBeUndefined() - expect(s.relations.pbiIds).toEqual([]) - expect(s.relations.storyIdsByPbi['p1']).toBeUndefined() - expect(s.relations.taskIdsByStory['s1']).toBeUndefined() - }) - - it('D — clear actieve PBI selectie als die onder de verwijderde PBI viel', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([makePbi({ id: 'p1' })]), - ) - useProductWorkspaceStore.setState((s) => { - s.context.activePbiId = 'p1' - s.context.activeStoryId = 's-x' - s.context.activeTaskId = 't-x' - }) - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'pbi', - op: 'D', - id: 'p1', - product_id: 'prod-1', - }) - const s = useProductWorkspaceStore.getState() - expect(s.context.activePbiId).toBeNull() - expect(s.context.activeStoryId).toBeNull() - expect(s.context.activeTaskId).toBeNull() - }) -}) - -describe('applyRealtimeEvent — story parent-move', () => { - beforeEach(() => { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - [makePbi({ id: 'p1' }), makePbi({ id: 'p2' })], - { - p1: [makeStory({ id: 's1', pbi_id: 'p1' })], - p2: [], - }, - ), - ) - }) - - it('U met andere pbi_id verplaatst story naar nieuwe parent-lijst', () => { - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'story', - op: 'U', - id: 's1', - product_id: 'prod-1', - pbi_id: 'p2', - }) - const s = useProductWorkspaceStore.getState() - expect(s.relations.storyIdsByPbi['p1']).toEqual([]) - expect(s.relations.storyIdsByPbi['p2']).toEqual(['s1']) - expect(s.entities.storiesById['s1'].pbi_id).toBe('p2') - }) -}) - -describe('applyRealtimeEvent — task parent-move', () => { - beforeEach(() => { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - [makePbi({ id: 'p1' })], - { p1: [makeStory({ id: 's1', pbi_id: 'p1' }), makeStory({ id: 's2', pbi_id: 'p1' })] }, - { - s1: [makeTask({ id: 't1', story_id: 's1' })], - s2: [], - }, - ), - ) - }) - - it('U met andere story_id verplaatst task naar nieuwe parent', () => { - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'task', - op: 'U', - id: 't1', - product_id: 'prod-1', - story_id: 's2', - }) - const s = useProductWorkspaceStore.getState() - expect(s.relations.taskIdsByStory['s1']).toEqual([]) - expect(s.relations.taskIdsByStory['s2']).toEqual(['t1']) - expect(s.entities.tasksById['t1'].story_id).toBe('s2') - }) -}) - -describe('applyRealtimeEvent — andere product genegeerd', () => { - it('event met ander product_id raakt de store niet', () => { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([makePbi({ id: 'a' })]), - ) - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'pbi', - op: 'I', - id: 'b', - product_id: 'prod-2', - title: 'Other product', - priority: 1, - sort_order: 1, - }) - const s = useProductWorkspaceStore.getState() - expect(s.entities.pbisById['b']).toBeUndefined() - expect(s.relations.pbiIds).toEqual(['a']) - }) -}) - -describe('applyRealtimeEvent — unknown entity → resync trigger', () => { - function withSpy(): ReturnType<typeof vi.fn> { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - const spy = vi.fn().mockResolvedValue(undefined) - useProductWorkspaceStore.setState((s) => { - s.resyncActiveScopes = spy as unknown as typeof s.resyncActiveScopes - }) - return spy - } - - it('unknown entity (b.v. comment) met matching product triggert resync', () => { - const spy = withSpy() - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'comment', - op: 'I', - id: 'cm-1', - product_id: 'prod-1', - } as unknown as Record<string, unknown>) - expect(spy).toHaveBeenCalledWith('unknown-event') - }) - - it('unknown entity met ander product_id triggert geen resync', () => { - const spy = withSpy() - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'comment', - op: 'I', - id: 'cm-1', - product_id: 'prod-2', - } as unknown as Record<string, unknown>) - expect(spy).not.toHaveBeenCalled() - }) - - it('claude_job_status (type-veld) triggert geen resync', () => { - const spy = withSpy() - useProductWorkspaceStore.getState().applyRealtimeEvent({ - type: 'claude_job_status', - job_id: 'job-1', - product_id: 'prod-1', - status: 'queued', - } as unknown as Record<string, unknown>) - expect(spy).not.toHaveBeenCalled() - }) - - it('worker_heartbeat (type-veld) triggert geen resync', () => { - const spy = withSpy() - useProductWorkspaceStore.getState().applyRealtimeEvent({ - type: 'worker_heartbeat', - worker_id: 'w-1', - product_id: 'prod-1', - } as unknown as Record<string, unknown>) - expect(spy).not.toHaveBeenCalled() - }) - - it('claude_job_enqueued (type-veld) triggert geen resync', () => { - const spy = withSpy() - useProductWorkspaceStore.getState().applyRealtimeEvent({ - type: 'claude_job_enqueued', - job_id: 'job-2', - product_id: 'prod-1', - kind: 'PER_TASK', - } as unknown as Record<string, unknown>) - expect(spy).not.toHaveBeenCalled() - }) - - it('payload zonder entity en zonder type wordt genegeerd', () => { - const spy = withSpy() - useProductWorkspaceStore.getState().applyRealtimeEvent({ - product_id: 'prod-1', - something: 'else', - } as unknown as Record<string, unknown>) - expect(spy).not.toHaveBeenCalled() - }) - - it('question-event met entity-veld maar zonder pbi/story/task triggert resync', () => { - // question is geen pbi/story/task entity dus telt als unknown wanneer - // hij geen 'type' draagt — dat zou een nieuwe entiteit kunnen zijn die - // we nog niet kennen. - const spy = withSpy() - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'question', - op: 'I', - id: 'q-1', - product_id: 'prod-1', - } as unknown as Record<string, unknown>) - expect(spy).toHaveBeenCalledWith('unknown-event') - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// ensure*Loaded fetches + race-safe guard + sortering -// ───────────────────────────────────────────────────────────────────────── - -describe('ensureProductLoaded', () => { - it('fetcht backlog snapshot en hydreert met sortering', async () => { - const snapshot: ProductBacklogSnapshot = { - product: { id: 'prod-1', name: 'Product 1' }, - pbis: [ - makePbi({ id: 'a', priority: 2, sort_order: 5 }), - makePbi({ id: 'b', priority: 1, sort_order: 9 }), - ], - storiesByPbi: {}, - tasksByStory: {}, - } - const fetchSpy = mockFetchSequence([snapshot]) - - await useProductWorkspaceStore.getState().ensureProductLoaded('prod-1') - - expect(fetchSpy).toHaveBeenCalledWith( - '/api/products/prod-1/backlog', - expect.objectContaining({ cache: 'no-store' }), - ) - const s = useProductWorkspaceStore.getState() - expect(s.relations.pbiIds).toEqual(['b', 'a']) - expect(s.loading.loadedProductId).toBe('prod-1') - expect(s.loading.loadedPbiIds['a']).toBe(true) - expect(s.loading.loadedPbiIds['b']).toBe(true) - }) -}) - -describe('race-safe ensure*Loaded — activeRequestId guard', () => { - it('oudere in-flight ensurePbiLoaded mag nieuwere selectie niet overschrijven', async () => { - let resolveOld: ((stories: BacklogStory[]) => void) | null = null - - vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string) => { - if (url === '/api/pbis/pbi-old/stories') { - const stories = await new Promise<BacklogStory[]>((resolve) => { - resolveOld = resolve - }) - return new Response(JSON.stringify(stories), { status: 200 }) - } - if (url === '/api/pbis/pbi-new/stories') { - return new Response( - JSON.stringify([makeStory({ id: 'new-st', pbi_id: 'pbi-new' })]), - { status: 200 }, - ) - } - return new Response('null', { status: 200 }) - }) as unknown as typeof fetch) - - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - s.context.activePbiId = 'pbi-old' - s.loading.activeRequestId = 'req-old' - }) - const oldPromise = useProductWorkspaceStore - .getState() - .ensurePbiLoaded('pbi-old', 'req-old') - - // gebruiker selecteert ondertussen pbi-new - useProductWorkspaceStore.setState((s) => { - s.context.activePbiId = 'pbi-new' - s.loading.activeRequestId = 'req-new' - }) - await useProductWorkspaceStore.getState().ensurePbiLoaded('pbi-new', 'req-new') - - expect(useProductWorkspaceStore.getState().entities.storiesById['new-st']).toBeDefined() - - // resolve de oude fetch — guard moet de stale data weigeren - resolveOld!([makeStory({ id: 'old-st', pbi_id: 'pbi-old' })]) - await oldPromise - - const s = useProductWorkspaceStore.getState() - expect(s.context.activePbiId).toBe('pbi-new') - expect(s.entities.storiesById['old-st']).toBeUndefined() - expect(s.entities.storiesById['new-st']).toBeDefined() - }) -}) - -describe('ensureTaskLoaded — zet detail-flag', () => { - it('verrijkt task naar TaskDetail met _detail: true', async () => { - mockFetchSequence([ - { - id: 't-1', - title: 'Task 1', - description: 'desc', - priority: 1, - sort_order: 1, - status: 'todo', - story_id: 's-1', - created_at: new Date('2026-02-01').toISOString(), - implementation_plan: 'detailed plan here', - }, - ]) - - await useProductWorkspaceStore.getState().ensureTaskLoaded('t-1') - const task = useProductWorkspaceStore.getState().entities.tasksById['t-1'] as TaskDetail - expect(task._detail).toBe(true) - expect(task.status).toBe('TO_DO') - expect(task.implementation_plan).toBe('detailed plan here') - expect(useProductWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true) - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// resyncActiveScopes -// ───────────────────────────────────────────────────────────────────────── - -describe('resyncActiveScopes', () => { - it('triggert ensure-keten voor alle actieve scopes en zet sync velden', async () => { - const fetchSpy = mockFetchSequence([ - // ensureProductLoaded - { product: { id: 'prod-1', name: 'P' }, pbis: [], storiesByPbi: {}, tasksByStory: {} }, - // ensurePbiLoaded - [], - // ensureStoryLoaded - [], - // ensureTaskLoaded - { - id: 't-1', - title: 'T', - description: null, - priority: 1, - sort_order: 1, - status: 'todo', - story_id: 's-1', - created_at: '2026-02-01', - }, - ]) - - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'P' } - s.context.activePbiId = 'pbi-1' - s.context.activeStoryId = 's-1' - s.context.activeTaskId = 't-1' - }) - - await useProductWorkspaceStore.getState().resyncActiveScopes('manual') - - const calls = fetchSpy.mock.calls.map(([url]) => url) - expect(calls).toContain('/api/products/prod-1/backlog') - expect(calls).toContain('/api/pbis/pbi-1/stories') - expect(calls).toContain('/api/stories/s-1/tasks') - expect(calls).toContain('/api/tasks/t-1') - - const s = useProductWorkspaceStore.getState() - expect(s.sync.lastResyncAt).toBeTypeOf('number') - expect(s.sync.resyncReason).toBe('manual') - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// Optimistic mutations -// ───────────────────────────────────────────────────────────────────────── - -// ───────────────────────────────────────────────────────────────────────── -// Restore-hint integratie (Story 4) -// ───────────────────────────────────────────────────────────────────────── - -describe('restore-hint flow — setters persisteren hints', () => { - it('setActiveProduct schrijft lastActiveProductId', () => { - useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) - const raw = localStorage.getItem('product-workspace-hints') - expect(raw).not.toBeNull() - const hints = JSON.parse(raw!) - expect(hints.lastActiveProductId).toBe('prod-1') - }) - - it('setActivePbi schrijft lastActivePbiId per product', () => { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'P1' } - }) - useProductWorkspaceStore.getState().setActivePbi('pbi-a') - const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!) - expect(hints.perProduct['prod-1'].lastActivePbiId).toBe('pbi-a') - }) - - it('setActiveStory schrijft lastActiveStoryId per product', () => { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'P1' } - }) - useProductWorkspaceStore.getState().setActiveStory('story-a') - const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!) - expect(hints.perProduct['prod-1'].lastActiveStoryId).toBe('story-a') - }) - - it('setActiveTask schrijft lastActiveTaskId per product', () => { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'P1' } - }) - useProductWorkspaceStore.getState().setActiveTask('task-a') - const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!) - expect(hints.perProduct['prod-1'].lastActiveTaskId).toBe('task-a') - }) -}) - -describe('restore-hint flow — chain triggert na ensure*Loaded', () => { - it('hint die NIET in entities zit wordt genegeerd', async () => { - // Schrijf een hint voor een PBI die niet bestaat - localStorage.setItem( - 'product-workspace-hints', - JSON.stringify({ - lastActiveProductId: 'prod-1', - perProduct: { 'prod-1': { lastActivePbiId: 'ghost-pbi' } }, - }), - ) - // Mock ensureProductLoaded zodat hij een lege snapshot terugstuurt — geen - // ghost-pbi in entities. - mockFetchSequence([ - { product: { id: 'prod-1', name: 'P1' }, pbis: [], storiesByPbi: {}, tasksByStory: {} }, - ]) - - useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) - // Wacht tot async restore-flow afgewikkeld is. - await new Promise((r) => setTimeout(r, 20)) - - expect(useProductWorkspaceStore.getState().context.activePbiId).toBeNull() - }) - - it('hint die wel in entities zit wordt toegepast', async () => { - const validPbi = makePbi({ id: 'pbi-known' }) - localStorage.setItem( - 'product-workspace-hints', - JSON.stringify({ - lastActiveProductId: 'prod-1', - perProduct: { 'prod-1': { lastActivePbiId: 'pbi-known' } }, - }), - ) - mockFetchSequence([ - // ensureProductLoaded levert pbi-known - { - product: { id: 'prod-1', name: 'P1' }, - pbis: [validPbi], - storiesByPbi: {}, - tasksByStory: {}, - }, - // ensurePbiLoaded triggered door setActivePbi(hint) — geen stories - [], - ]) - - useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) - await new Promise((r) => setTimeout(r, 30)) - - expect(useProductWorkspaceStore.getState().context.activePbiId).toBe('pbi-known') - }) -}) - -describe('optimistic mutations', () => { - it('rollback herstelt vorige pbi-order', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([ - makePbi({ id: 'a', priority: 2, sort_order: 1 }), - makePbi({ id: 'b', priority: 2, sort_order: 2 }), - ]), - ) - const prevOrder = [...useProductWorkspaceStore.getState().relations.pbiIds] - - const id = useProductWorkspaceStore.getState().applyOptimisticMutation({ - kind: 'pbi-order', - prevPbiIds: prevOrder, - }) - // simuleer de optimistic order-wijziging buiten de mutation - useProductWorkspaceStore.setState((s) => { - s.relations.pbiIds = ['b', 'a'] - }) - expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['b', 'a']) - - useProductWorkspaceStore.getState().rollbackMutation(id) - expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(prevOrder) - expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeUndefined() - }) - - it('settle ruimt pending op zonder state te wijzigen', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([makePbi({ id: 'a' })]), - ) - const id = useProductWorkspaceStore.getState().applyOptimisticMutation({ - kind: 'pbi-order', - prevPbiIds: ['a'], - }) - expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeDefined() - - useProductWorkspaceStore.getState().settleMutation(id) - expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeUndefined() - expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['a']) - }) - - it('SSE-echo van een al-bestaande PBI is idempotent', () => { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'P' } - }) - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([makePbi({ id: 'a', title: 'Origineel' })]), - ) - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'pbi', - op: 'I', - id: 'a', - product_id: 'prod-1', - title: 'echo', - }) - expect(useProductWorkspaceStore.getState().entities.pbisById['a'].title).toBe('Origineel') - expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['a']) - }) -}) diff --git a/__tests__/stores/solo-store-realtime.test.ts b/__tests__/stores/solo-store-realtime.test.ts index 2047a77..f61a7f8 100644 --- a/__tests__/stores/solo-store-realtime.test.ts +++ b/__tests__/stores/solo-store-realtime.test.ts @@ -17,9 +17,6 @@ const baseTask = (id: string, overrides: Partial<SoloTask> = {}): SoloTask => ({ story_code: 'ST-100', story_title: 'Original Story', task_code: 'ST-100.1', - pbi_code: null, - pbi_title: null, - pbi_description: null, ...overrides, }) diff --git a/__tests__/stores/solo-workspace/store.test.ts b/__tests__/stores/solo-workspace/store.test.ts deleted file mode 100644 index b31000d..0000000 --- a/__tests__/stores/solo-workspace/store.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useSoloStore } from '@/stores/solo-store' -import type { RealtimeEvent } from '@/stores/solo-store' -import type { SoloTask, SoloWorkspaceSnapshot } from '@/stores/solo-workspace/types' - -function baseTask(id: string, overrides: Partial<SoloTask> = {}): SoloTask { - return { - id, - title: `Task ${id}`, - description: null, - implementation_plan: null, - priority: 1, - sort_order: 1, - status: 'TO_DO', - verify_only: false, - verify_required: 'ALIGNED_OR_PARTIAL', - story_id: 'story-1', - story_code: 'ST-1', - story_title: 'Story 1', - task_code: `ST-1.${id}`, - pbi_code: null, - pbi_title: null, - pbi_description: null, - ...overrides, - } -} - -function snapshot(tasks: SoloTask[]): SoloWorkspaceSnapshot { - return { - product: { id: 'prod-1', name: 'Product 1' }, - sprint: { id: 'sprint-1', sprint_goal: 'Goal' }, - activeUserId: 'user-1', - tasks, - unassignedStories: [ - { id: 'story-b', code: 'ST-2', title: 'Story B', tasks: [] }, - { id: 'story-a', code: 'ST-1', title: 'Story A', tasks: [] }, - ], - } -} - -function taskEvent(overrides: Partial<RealtimeEvent>): RealtimeEvent { - return { - op: 'U', - entity: 'task', - id: 'task-1', - story_id: 'story-1', - product_id: 'prod-1', - sprint_id: 'sprint-1', - assignee_id: 'user-1', - ...overrides, - } -} - -beforeEach(() => { - useSoloStore.setState({ - context: { activeProduct: null, activeSprint: null, activeUserId: null }, - entities: { tasksById: {}, unassignedStoriesById: {}, jobsByTaskId: {} }, - relations: { - taskIdsByColumn: { TO_DO: [], IN_PROGRESS: [], DONE: [] }, - unassignedStoryIds: [], - }, - loading: { - loadedProductId: null, - loadedSprintId: null, - loadingSprintId: null, - activeRequestId: null, - }, - sync: { - realtimeStatus: 'connecting', - showConnectingIndicator: false, - lastEventAt: null, - lastResyncAt: null, - resyncReason: null, - }, - pendingOps: new Set(), - tasks: {}, - unassignedStoriesById: {}, - claudeJobsByTaskId: {}, - }) - vi.restoreAllMocks() -}) - -describe('solo workspace store', () => { - it('hydrateert genormaliseerde taken, kolomrelaties en unassigned stories', () => { - useSoloStore.getState().hydrateSnapshot( - snapshot([ - baseTask('task-2', { status: 'DONE', sort_order: 2 }), - baseTask('task-1', { status: 'TO_DO', sort_order: 1 }), - baseTask('task-3', { status: 'REVIEW', sort_order: 3 }), - ]), - ) - - const s = useSoloStore.getState() - expect(s.context.activeSprint?.id).toBe('sprint-1') - expect(s.relations.taskIdsByColumn.TO_DO).toEqual(['task-1']) - expect(s.relations.taskIdsByColumn.IN_PROGRESS).toEqual(['task-3']) - expect(s.relations.taskIdsByColumn.DONE).toEqual(['task-2']) - expect(s.relations.unassignedStoryIds).toEqual(['story-a', 'story-b']) - }) - - it('past realtime task updates toe en herbouwt kolomrelaties', () => { - useSoloStore.getState().hydrateSnapshot(snapshot([baseTask('task-1')])) - useSoloStore.getState().handleRealtimeEvent( - taskEvent({ status: 'DONE', sort_order: 5, title: 'Done task' }), - ) - - const s = useSoloStore.getState() - expect(s.tasks['task-1'].status).toBe('DONE') - expect(s.tasks['task-1'].title).toBe('Done task') - expect(s.relations.taskIdsByColumn.TO_DO).toEqual([]) - expect(s.relations.taskIdsByColumn.DONE).toEqual(['task-1']) - }) - - it('resynct actieve scopes via de solo-workspace route', async () => { - useSoloStore.getState().hydrateSnapshot(snapshot([baseTask('task-1')])) - const next = snapshot([baseTask('task-1', { status: 'IN_PROGRESS' })]) - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(JSON.stringify(next), { status: 200 }), - ) - - await useSoloStore.getState().resyncActiveScopes('manual') - - expect(fetchSpy).toHaveBeenCalledWith( - '/api/products/prod-1/solo-workspace?sprint_id=sprint-1', - expect.objectContaining({ cache: 'no-store' }), - ) - const s = useSoloStore.getState() - expect(s.tasks['task-1'].status).toBe('IN_PROGRESS') - expect(s.sync.resyncReason).toBe('manual') - }) -}) diff --git a/__tests__/stores/sprint-workspace/restore.test.ts b/__tests__/stores/sprint-workspace/restore.test.ts deleted file mode 100644 index 66c626f..0000000 --- a/__tests__/stores/sprint-workspace/restore.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - clearHints, - readHints, - writeProductHint, - writeSprintHint, - writeStoryHint, - writeTaskHint, -} from '@/stores/sprint-workspace/restore' - -describe('readHints', () => { - it('retourneert lege defaults wanneer localStorage leeg is', () => { - const hints = readHints() - expect(hints.lastActiveProductId).toBeNull() - expect(hints.perProduct).toEqual({}) - expect(hints.perSprint).toEqual({}) - }) - - it('herstelt hints uit localStorage', () => { - localStorage.setItem( - 'sprint-workspace-hints', - JSON.stringify({ - lastActiveProductId: 'p1', - perProduct: { p1: { lastActiveSprintId: 'sp-1' } }, - perSprint: { 'sp-1': { lastActiveStoryId: 's-1' } }, - }), - ) - const hints = readHints() - expect(hints.lastActiveProductId).toBe('p1') - expect(hints.perProduct.p1.lastActiveSprintId).toBe('sp-1') - expect(hints.perSprint['sp-1'].lastActiveStoryId).toBe('s-1') - }) - - it('valt terug op defaults bij ongeldige JSON', () => { - localStorage.setItem('sprint-workspace-hints', '{not-json') - const hints = readHints() - expect(hints.lastActiveProductId).toBeNull() - expect(hints.perProduct).toEqual({}) - expect(hints.perSprint).toEqual({}) - }) - - it('valt terug op defaults bij verkeerde shape', () => { - localStorage.setItem('sprint-workspace-hints', '"just a string"') - const hints = readHints() - expect(hints.perProduct).toEqual({}) - expect(hints.perSprint).toEqual({}) - }) -}) - -describe('writeProductHint', () => { - it('schrijft lastActiveProductId', () => { - writeProductHint('p1') - expect(readHints().lastActiveProductId).toBe('p1') - }) - - it('overschrijft bestaande waarde', () => { - writeProductHint('p1') - writeProductHint('p2') - expect(readHints().lastActiveProductId).toBe('p2') - }) - - it('accepteert null om hint te wissen', () => { - writeProductHint('p1') - writeProductHint(null) - expect(readHints().lastActiveProductId).toBeNull() - }) -}) - -describe('writeSprintHint', () => { - it('schrijft lastActiveSprintId per productId', () => { - writeSprintHint('prod-1', 'sp-a') - writeSprintHint('prod-2', 'sp-b') - const hints = readHints() - expect(hints.perProduct['prod-1'].lastActiveSprintId).toBe('sp-a') - expect(hints.perProduct['prod-2'].lastActiveSprintId).toBe('sp-b') - }) - - it('accepteert null om sprint-hint te wissen', () => { - writeSprintHint('prod-1', 'sp-a') - writeSprintHint('prod-1', null) - expect(readHints().perProduct['prod-1'].lastActiveSprintId).toBeNull() - }) -}) - -describe('writeStoryHint', () => { - it('schrijft lastActiveStoryId per sprintId', () => { - writeStoryHint('sp-1', 's-1') - expect(readHints().perSprint['sp-1'].lastActiveStoryId).toBe('s-1') - }) - - it('null wist child task-hint', () => { - writeStoryHint('sp-1', 's-1') - writeTaskHint('sp-1', 't-1') - writeStoryHint('sp-1', null) - expect(readHints().perSprint['sp-1'].lastActiveStoryId).toBeNull() - expect(readHints().perSprint['sp-1'].lastActiveTaskId).toBeNull() - }) -}) - -describe('writeTaskHint', () => { - it('schrijft lastActiveTaskId per sprintId', () => { - writeTaskHint('sp-1', 't-1') - expect(readHints().perSprint['sp-1'].lastActiveTaskId).toBe('t-1') - }) -}) - -describe('clearHints', () => { - it('verwijdert alle hints', () => { - writeProductHint('p1') - writeSprintHint('p1', 'sp-1') - writeStoryHint('sp-1', 's-1') - clearHints() - const hints = readHints() - expect(hints.lastActiveProductId).toBeNull() - expect(hints.perProduct).toEqual({}) - expect(hints.perSprint).toEqual({}) - }) -}) diff --git a/__tests__/stores/sprint-workspace/store.test.ts b/__tests__/stores/sprint-workspace/store.test.ts deleted file mode 100644 index 5fa0502..0000000 --- a/__tests__/stores/sprint-workspace/store.test.ts +++ /dev/null @@ -1,875 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' -import type { - SprintWorkspaceSnapshot, - SprintWorkspaceSprint, - SprintWorkspaceStory, - SprintWorkspaceTask, - SprintWorkspaceTaskDetail, -} from '@/stores/sprint-workspace/types' - -// G5: snapshot original actions on module-load; restore in beforeEach. -const originalActions = (() => { - const s = useSprintWorkspaceStore.getState() - return { - hydrateSnapshot: s.hydrateSnapshot, - hydrateProductSprints: s.hydrateProductSprints, - setActiveProduct: s.setActiveProduct, - setActiveSprint: s.setActiveSprint, - setActiveStory: s.setActiveStory, - setActiveTask: s.setActiveTask, - ensureProductSprintsLoaded: s.ensureProductSprintsLoaded, - ensureSprintLoaded: s.ensureSprintLoaded, - ensureStoryLoaded: s.ensureStoryLoaded, - ensureTaskLoaded: s.ensureTaskLoaded, - applyRealtimeEvent: s.applyRealtimeEvent, - resyncActiveScopes: s.resyncActiveScopes, - resyncLoadedScopes: s.resyncLoadedScopes, - applyOptimisticMutation: s.applyOptimisticMutation, - rollbackMutation: s.rollbackMutation, - settleMutation: s.settleMutation, - setRealtimeStatus: s.setRealtimeStatus, - } -})() - -function resetStore() { - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = null - s.context.activeSprintId = null - s.context.activeStoryId = null - s.context.activeTaskId = null - s.entities.sprintsById = {} - s.entities.storiesById = {} - s.entities.tasksById = {} - s.relations.sprintIdsByProduct = {} - s.relations.storyIdsBySprint = {} - s.relations.taskIdsByStory = {} - s.loading.loadedProductSprintsIds = {} - s.loading.loadingProductId = null - s.loading.loadedSprintIds = {} - s.loading.loadingSprintId = null - s.loading.loadedStoryIds = {} - s.loading.loadedTaskIds = {} - s.loading.activeRequestId = null - s.sync.realtimeStatus = 'connecting' - s.sync.lastEventAt = null - s.sync.lastResyncAt = null - s.sync.resyncReason = null - s.pendingMutations = {} - Object.assign(s, originalActions) - }) -} - -beforeEach(() => { - resetStore() -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -function makeSprint( - overrides: Partial<SprintWorkspaceSprint> & { id: string; product_id: string }, -): SprintWorkspaceSprint { - return { - id: overrides.id, - product_id: overrides.product_id, - code: overrides.code ?? `S-${overrides.id}`, - sprint_goal: overrides.sprint_goal ?? `Goal ${overrides.id}`, - status: overrides.status ?? 'OPEN', - start_date: overrides.start_date ?? '2026-04-01', - end_date: overrides.end_date ?? '2026-04-14', - created_at: overrides.created_at ?? new Date('2026-03-15'), - completed_at: overrides.completed_at ?? null, - } -} - -function makeStory( - overrides: Partial<SprintWorkspaceStory> & { id: string; pbi_id: string }, -): SprintWorkspaceStory { - return { - id: overrides.id, - code: overrides.code ?? overrides.id, - title: overrides.title ?? `Story ${overrides.id}`, - description: overrides.description ?? null, - acceptance_criteria: overrides.acceptance_criteria ?? null, - priority: overrides.priority ?? 2, - sort_order: overrides.sort_order ?? 1, - status: overrides.status ?? 'OPEN', - pbi_id: overrides.pbi_id, - sprint_id: overrides.sprint_id ?? null, - created_at: overrides.created_at ?? new Date('2026-01-01'), - } -} - -function makeTask( - overrides: Partial<SprintWorkspaceTask> & { id: string; story_id: string }, -): SprintWorkspaceTask { - return { - id: overrides.id, - code: overrides.code ?? null, - title: overrides.title ?? `Task ${overrides.id}`, - description: overrides.description ?? null, - priority: overrides.priority ?? 2, - sort_order: overrides.sort_order ?? 1, - status: overrides.status ?? 'TO_DO', - story_id: overrides.story_id, - sprint_id: overrides.sprint_id ?? null, - created_at: overrides.created_at ?? new Date('2026-01-01'), - } -} - -function snapshotWith( - sprint: SprintWorkspaceSprint | undefined, - stories: SprintWorkspaceStory[] = [], - tasksByStory: Record<string, SprintWorkspaceTask[]> = {}, - product?: { id: string; name: string }, -): SprintWorkspaceSnapshot { - return { product, sprint, stories, tasksByStory } -} - -// G7/G8: mock fetch via mockImplementation (vers Response per call) -function mockFetchSequence( - responses: Array<unknown | ((url: string, init?: RequestInit) => unknown)>, -) { - let i = 0 - return vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string, init?: RequestInit) => { - const r = responses[Math.min(i, responses.length - 1)] - i += 1 - const body = typeof r === 'function' ? (r as (u: string, i?: RequestInit) => unknown)(url, init) : r - return new Response(JSON.stringify(body ?? null), { status: 200 }) - }) as unknown as typeof fetch) -} - -// ───────────────────────────────────────────────────────────────────────── -// hydrateSnapshot -// ───────────────────────────────────────────────────────────────────────── - -describe('hydrateSnapshot', () => { - it('vult entities, relations en loaded-marker', () => { - const sprint = makeSprint({ id: 'sp-1', product_id: 'prod-1' }) - const storyA = makeStory({ id: 's-a', pbi_id: 'pbi-1', sprint_id: 'sp-1', sort_order: 2 }) - const storyB = makeStory({ id: 's-b', pbi_id: 'pbi-1', sprint_id: 'sp-1', sort_order: 1 }) - const taskA = makeTask({ id: 't-a', story_id: 's-a', sort_order: 2 }) - const taskB = makeTask({ id: 't-b', story_id: 's-a', sort_order: 1 }) - - useSprintWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - sprint, - [storyA, storyB], - { 's-a': [taskA, taskB] }, - { id: 'prod-1', name: 'Product 1' }, - ), - ) - - const s = useSprintWorkspaceStore.getState() - expect(s.entities.sprintsById['sp-1']).toEqual(sprint) - expect(s.entities.storiesById['s-a']).toEqual(storyA) - expect(s.entities.storiesById['s-b']).toEqual(storyB) - // sort_order: storyB (1) before storyA (2) - expect(s.relations.storyIdsBySprint['sp-1']).toEqual(['s-b', 's-a']) - // sort_order: taskB (1) before taskA (2) - expect(s.relations.taskIdsByStory['s-a']).toEqual(['t-b', 't-a']) - expect(s.context.activeProduct).toEqual({ id: 'prod-1', name: 'Product 1' }) - expect(s.loading.loadedSprintIds['sp-1']).toBe(true) - }) - - it('normaliseert API-statussen naar het interne store-contract', () => { - useSprintWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - makeSprint({ id: 'sp-1', product_id: 'prod-1' }), - [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1', status: 'in_sprint' })], - { 's-1': [makeTask({ id: 't-1', story_id: 's-1', status: 'todo' })] }, - ), - ) - - const s = useSprintWorkspaceStore.getState() - expect(s.entities.storiesById['s-1'].status).toBe('IN_SPRINT') - expect(s.entities.tasksById['t-1'].status).toBe('TO_DO') - }) -}) - -describe('hydrateProductSprints', () => { - it('sorteert OPEN voor CLOSED, dan op start_date desc', () => { - const closedOld = makeSprint({ - id: 'sp-closed-old', - product_id: 'prod-1', - status: 'CLOSED', - start_date: '2026-01-01', - }) - const openNew = makeSprint({ - id: 'sp-open-new', - product_id: 'prod-1', - status: 'OPEN', - start_date: '2026-04-01', - }) - const openOld = makeSprint({ - id: 'sp-open-old', - product_id: 'prod-1', - status: 'OPEN', - start_date: '2026-02-01', - }) - - useSprintWorkspaceStore - .getState() - .hydrateProductSprints('prod-1', [closedOld, openOld, openNew]) - - const s = useSprintWorkspaceStore.getState() - expect(s.relations.sprintIdsByProduct['prod-1']).toEqual([ - 'sp-open-new', - 'sp-open-old', - 'sp-closed-old', - ]) - expect(s.loading.loadedProductSprintsIds['prod-1']).toBe(true) - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// Selection cascade -// ───────────────────────────────────────────────────────────────────────── - -describe('selection cascade', () => { - it('setActiveSprint reset story+task; setActiveStory reset task', () => { - useSprintWorkspaceStore.setState((s) => { - s.context.activeSprintId = 'sp-old' - s.context.activeStoryId = 's-old' - s.context.activeTaskId = 't-old' - }) - - useSprintWorkspaceStore.getState().setActiveSprint('sp-new') - let s = useSprintWorkspaceStore.getState() - expect(s.context.activeSprintId).toBe('sp-new') - expect(s.context.activeStoryId).toBeNull() - expect(s.context.activeTaskId).toBeNull() - - useSprintWorkspaceStore.setState((draft) => { - draft.context.activeStoryId = 's-old' - draft.context.activeTaskId = 't-old' - }) - useSprintWorkspaceStore.getState().setActiveStory('s-new') - s = useSprintWorkspaceStore.getState() - expect(s.context.activeStoryId).toBe('s-new') - expect(s.context.activeTaskId).toBeNull() - }) - - it('setActiveProduct(null) ruimt entities en relations op', () => { - useSprintWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - makeSprint({ id: 'sp-1', product_id: 'prod-1' }), - [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], - { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] }, - { id: 'prod-1', name: 'Product 1' }, - ), - ) - - useSprintWorkspaceStore.getState().setActiveProduct(null) - const s = useSprintWorkspaceStore.getState() - expect(s.context.activeProduct).toBeNull() - expect(s.context.activeSprintId).toBeNull() - expect(s.entities.sprintsById).toEqual({}) - expect(s.entities.storiesById).toEqual({}) - expect(s.entities.tasksById).toEqual({}) - expect(s.relations.sprintIdsByProduct).toEqual({}) - expect(s.relations.storyIdsBySprint).toEqual({}) - expect(s.relations.taskIdsByStory).toEqual({}) - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// applyRealtimeEvent -// ───────────────────────────────────────────────────────────────────────── - -describe('applyRealtimeEvent — sprint', () => { - beforeEach(() => { - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - }) - - it('I — voegt sprint toe aan product-lijst', () => { - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'sprint', - op: 'I', - id: 'sp-new', - product_id: 'prod-1', - code: 'S-1', - sprint_goal: 'New Sprint', - status: 'OPEN', - start_date: '2026-05-01', - end_date: '2026-05-14', - created_at: new Date('2026-04-15').toISOString(), - }) - const s = useSprintWorkspaceStore.getState() - expect(s.entities.sprintsById['sp-new']).toBeDefined() - expect(s.relations.sprintIdsByProduct['prod-1']).toContain('sp-new') - }) - - it('I — idempotent voor bestaande id', () => { - useSprintWorkspaceStore - .getState() - .hydrateProductSprints('prod-1', [ - makeSprint({ id: 'sp-1', product_id: 'prod-1', sprint_goal: 'Origineel' }), - ]) - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'sprint', - op: 'I', - id: 'sp-1', - product_id: 'prod-1', - sprint_goal: 'echo', - }) - expect(useSprintWorkspaceStore.getState().entities.sprintsById['sp-1'].sprint_goal).toBe( - 'Origineel', - ) - }) - - it('U — patch + her-sorteert', () => { - useSprintWorkspaceStore - .getState() - .hydrateProductSprints('prod-1', [ - makeSprint({ - id: 'sp-a', - product_id: 'prod-1', - status: 'OPEN', - start_date: '2026-04-01', - }), - makeSprint({ - id: 'sp-b', - product_id: 'prod-1', - status: 'OPEN', - start_date: '2026-03-01', - }), - ]) - // sp-a (newer) komt eerst - expect( - useSprintWorkspaceStore.getState().relations.sprintIdsByProduct['prod-1'], - ).toEqual(['sp-a', 'sp-b']) - - // Sluit sp-a → moet naar achteren - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'sprint', - op: 'U', - id: 'sp-a', - product_id: 'prod-1', - status: 'CLOSED', - }) - expect( - useSprintWorkspaceStore.getState().relations.sprintIdsByProduct['prod-1'], - ).toEqual(['sp-b', 'sp-a']) - }) - - it('D — verwijdert sprint inclusief child stories en tasks', () => { - useSprintWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - makeSprint({ id: 'sp-1', product_id: 'prod-1' }), - [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], - { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] }, - { id: 'prod-1', name: 'Product 1' }, - ), - ) - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'sprint', - op: 'D', - id: 'sp-1', - product_id: 'prod-1', - }) - const s = useSprintWorkspaceStore.getState() - expect(s.entities.sprintsById['sp-1']).toBeUndefined() - expect(s.entities.storiesById['s-1']).toBeUndefined() - expect(s.entities.tasksById['t-1']).toBeUndefined() - expect(s.relations.storyIdsBySprint['sp-1']).toBeUndefined() - expect(s.relations.taskIdsByStory['s-1']).toBeUndefined() - }) - - it('D — clear actieve sprint selectie als die de verwijderde sprint was', () => { - useSprintWorkspaceStore.getState().hydrateSnapshot( - snapshotWith(makeSprint({ id: 'sp-1', product_id: 'prod-1' }), []), - ) - useSprintWorkspaceStore.setState((s) => { - s.context.activeSprintId = 'sp-1' - s.context.activeStoryId = 's-x' - s.context.activeTaskId = 't-x' - }) - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'sprint', - op: 'D', - id: 'sp-1', - product_id: 'prod-1', - }) - const s = useSprintWorkspaceStore.getState() - expect(s.context.activeSprintId).toBeNull() - expect(s.context.activeStoryId).toBeNull() - expect(s.context.activeTaskId).toBeNull() - }) -}) - -describe('applyRealtimeEvent — story sprint-move', () => { - beforeEach(() => { - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - useSprintWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - makeSprint({ id: 'sp-1', product_id: 'prod-1' }), - [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], - ), - ) - }) - - it('U met andere sprint_id verplaatst story uit sprint-relatie', () => { - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'story', - op: 'U', - id: 's-1', - product_id: 'prod-1', - pbi_id: 'pbi-1', - sprint_id: 'sp-other', - }) - const s = useSprintWorkspaceStore.getState() - expect(s.relations.storyIdsBySprint['sp-1']).toEqual([]) - expect(s.relations.storyIdsBySprint['sp-other']).toEqual(['s-1']) - expect(s.entities.storiesById['s-1'].sprint_id).toBe('sp-other') - }) - - it('U met sprint_id=null haalt story uit sprint-relatie', () => { - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'story', - op: 'U', - id: 's-1', - product_id: 'prod-1', - pbi_id: 'pbi-1', - sprint_id: null, - }) - expect(useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1']).toEqual([]) - expect(useSprintWorkspaceStore.getState().entities.storiesById['s-1'].sprint_id).toBeNull() - }) -}) - -describe('applyRealtimeEvent — task parent-move', () => { - beforeEach(() => { - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - useSprintWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - makeSprint({ id: 'sp-1', product_id: 'prod-1' }), - [ - makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' }), - makeStory({ id: 's-2', pbi_id: 'pbi-1', sprint_id: 'sp-1' }), - ], - { - 's-1': [makeTask({ id: 't-1', story_id: 's-1' })], - 's-2': [], - }, - ), - ) - }) - - it('U met andere story_id verplaatst task naar nieuwe parent', () => { - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'task', - op: 'U', - id: 't-1', - product_id: 'prod-1', - story_id: 's-2', - }) - const s = useSprintWorkspaceStore.getState() - expect(s.relations.taskIdsByStory['s-1']).toEqual([]) - expect(s.relations.taskIdsByStory['s-2']).toEqual(['t-1']) - expect(s.entities.tasksById['t-1'].story_id).toBe('s-2') - }) -}) - -describe('applyRealtimeEvent — andere product genegeerd', () => { - it('event met ander product_id raakt de store niet', () => { - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - useSprintWorkspaceStore - .getState() - .hydrateProductSprints('prod-1', [makeSprint({ id: 'sp-1', product_id: 'prod-1' })]) - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'sprint', - op: 'I', - id: 'sp-other', - product_id: 'prod-2', - code: 'X', - }) - const s = useSprintWorkspaceStore.getState() - expect(s.entities.sprintsById['sp-other']).toBeUndefined() - }) -}) - -describe('applyRealtimeEvent — unknown entity → resync trigger', () => { - function withSpy(): ReturnType<typeof vi.fn> { - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - const spy = vi.fn().mockResolvedValue(undefined) - useSprintWorkspaceStore.setState((s) => { - s.resyncActiveScopes = spy as unknown as typeof s.resyncActiveScopes - }) - return spy - } - - it('unknown entity met matching product triggert resync', () => { - const spy = withSpy() - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'comment', - op: 'I', - id: 'cm-1', - product_id: 'prod-1', - }) - expect(spy).toHaveBeenCalledWith('unknown-event') - }) - - it('unknown entity met ander product_id triggert geen resync', () => { - const spy = withSpy() - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'comment', - op: 'I', - id: 'cm-1', - product_id: 'prod-2', - }) - expect(spy).not.toHaveBeenCalled() - }) - - it('claude_job_status (type-veld) triggert geen resync', () => { - const spy = withSpy() - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - type: 'claude_job_status', - job_id: 'job-1', - product_id: 'prod-1', - status: 'queued', - }) - expect(spy).not.toHaveBeenCalled() - }) - - it('worker_heartbeat (type-veld) triggert geen resync', () => { - const spy = withSpy() - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - type: 'worker_heartbeat', - worker_id: 'w-1', - product_id: 'prod-1', - }) - expect(spy).not.toHaveBeenCalled() - }) - - it('payload zonder entity en zonder type wordt genegeerd', () => { - const spy = withSpy() - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - product_id: 'prod-1', - something: 'else', - }) - expect(spy).not.toHaveBeenCalled() - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// ensure*Loaded -// ───────────────────────────────────────────────────────────────────────── - -describe('ensureProductSprintsLoaded', () => { - it('fetcht sprint-list en hydreert met sortering', async () => { - const sprints = [ - makeSprint({ - id: 'sp-old', - product_id: 'prod-1', - status: 'CLOSED', - start_date: '2026-01-01', - }), - makeSprint({ - id: 'sp-new', - product_id: 'prod-1', - status: 'OPEN', - start_date: '2026-04-01', - }), - ] - const fetchSpy = mockFetchSequence([sprints]) - - await useSprintWorkspaceStore.getState().ensureProductSprintsLoaded('prod-1') - - expect(fetchSpy).toHaveBeenCalledWith( - '/api/products/prod-1/sprints', - expect.objectContaining({ cache: 'no-store' }), - ) - const s = useSprintWorkspaceStore.getState() - expect(s.relations.sprintIdsByProduct['prod-1']).toEqual(['sp-new', 'sp-old']) - expect(s.loading.loadedProductSprintsIds['prod-1']).toBe(true) - }) -}) - -describe('ensureSprintLoaded', () => { - it('fetcht sprint-snapshot en hydreert', async () => { - const sprint = makeSprint({ id: 'sp-1', product_id: 'prod-1' }) - const snapshot: SprintWorkspaceSnapshot = { - product: { id: 'prod-1', name: 'P1' }, - sprint, - stories: [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], - tasksByStory: { - 's-1': [makeTask({ id: 't-1', story_id: 's-1' })], - }, - } - const fetchSpy = mockFetchSequence([snapshot]) - - await useSprintWorkspaceStore.getState().ensureSprintLoaded('sp-1') - - expect(fetchSpy).toHaveBeenCalledWith( - '/api/sprints/sp-1/workspace', - expect.objectContaining({ cache: 'no-store' }), - ) - const s = useSprintWorkspaceStore.getState() - expect(s.entities.sprintsById['sp-1']).toBeDefined() - expect(s.relations.storyIdsBySprint['sp-1']).toEqual(['s-1']) - expect(s.relations.taskIdsByStory['s-1']).toEqual(['t-1']) - expect(s.loading.loadedSprintIds['sp-1']).toBe(true) - }) -}) - -describe('race-safe ensure*Loaded — activeRequestId guard', () => { - it('oudere in-flight ensureSprintLoaded mag nieuwere selectie niet overschrijven', async () => { - let resolveOld: ((snap: SprintWorkspaceSnapshot) => void) | null = null - - vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string) => { - if (url === '/api/sprints/sp-old/workspace') { - const snap = await new Promise<SprintWorkspaceSnapshot>((resolve) => { - resolveOld = resolve - }) - return new Response(JSON.stringify(snap), { status: 200 }) - } - if (url === '/api/sprints/sp-new/workspace') { - return new Response( - JSON.stringify({ - sprint: makeSprint({ id: 'sp-new', product_id: 'prod-1' }), - stories: [makeStory({ id: 'new-st', pbi_id: 'p', sprint_id: 'sp-new' })], - tasksByStory: {}, - }), - { status: 200 }, - ) - } - return new Response('null', { status: 200 }) - }) as unknown as typeof fetch) - - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - s.context.activeSprintId = 'sp-old' - s.loading.activeRequestId = 'req-old' - }) - const oldPromise = useSprintWorkspaceStore - .getState() - .ensureSprintLoaded('sp-old', 'req-old') - - useSprintWorkspaceStore.setState((s) => { - s.context.activeSprintId = 'sp-new' - s.loading.activeRequestId = 'req-new' - }) - await useSprintWorkspaceStore - .getState() - .ensureSprintLoaded('sp-new', 'req-new') - - expect(useSprintWorkspaceStore.getState().entities.storiesById['new-st']).toBeDefined() - - resolveOld!({ - sprint: makeSprint({ id: 'sp-old', product_id: 'prod-1' }), - stories: [makeStory({ id: 'old-st', pbi_id: 'p', sprint_id: 'sp-old' })], - tasksByStory: {}, - }) - await oldPromise - - const s = useSprintWorkspaceStore.getState() - expect(s.context.activeSprintId).toBe('sp-new') - expect(s.entities.storiesById['old-st']).toBeUndefined() - expect(s.entities.storiesById['new-st']).toBeDefined() - }) -}) - -describe('ensureTaskLoaded — zet detail-flag', () => { - it('verrijkt task naar TaskDetail met _detail: true', async () => { - mockFetchSequence([ - { - id: 't-1', - code: 'C1', - title: 'Task 1', - description: 'desc', - priority: 1, - sort_order: 1, - status: 'todo', - story_id: 's-1', - sprint_id: 'sp-1', - created_at: new Date('2026-02-01').toISOString(), - implementation_plan: 'detailed plan here', - }, - ]) - - await useSprintWorkspaceStore.getState().ensureTaskLoaded('t-1') - const task = useSprintWorkspaceStore.getState().entities.tasksById[ - 't-1' - ] as SprintWorkspaceTaskDetail - expect(task._detail).toBe(true) - expect(task.status).toBe('TO_DO') - expect(task.implementation_plan).toBe('detailed plan here') - expect(useSprintWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true) - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// resyncActiveScopes -// ───────────────────────────────────────────────────────────────────────── - -describe('resyncActiveScopes', () => { - it('triggert ensure-keten voor alle actieve scopes en zet sync velden', async () => { - const fetchSpy = mockFetchSequence([ - // ensureProductSprintsLoaded - [], - // ensureSprintLoaded - { - sprint: makeSprint({ id: 'sp-1', product_id: 'prod-1' }), - stories: [], - tasksByStory: {}, - }, - // ensureStoryLoaded - [], - // ensureTaskLoaded - { - id: 't-1', - title: 'T', - description: null, - priority: 1, - sort_order: 1, - status: 'todo', - story_id: 's-1', - sprint_id: 'sp-1', - created_at: '2026-02-01', - }, - ]) - - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'P' } - s.context.activeSprintId = 'sp-1' - s.context.activeStoryId = 's-1' - s.context.activeTaskId = 't-1' - }) - - await useSprintWorkspaceStore.getState().resyncActiveScopes('manual') - - const calls = fetchSpy.mock.calls.map(([url]) => url) - expect(calls).toContain('/api/products/prod-1/sprints') - expect(calls).toContain('/api/sprints/sp-1/workspace') - expect(calls).toContain('/api/stories/s-1/tasks') - expect(calls).toContain('/api/tasks/t-1') - - const s = useSprintWorkspaceStore.getState() - expect(s.sync.lastResyncAt).toBeTypeOf('number') - expect(s.sync.resyncReason).toBe('manual') - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// Restore-hint flow -// ───────────────────────────────────────────────────────────────────────── - -describe('restore-hint flow — setters persisteren hints', () => { - it('setActiveProduct schrijft lastActiveProductId', () => { - useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) - const raw = localStorage.getItem('sprint-workspace-hints') - expect(raw).not.toBeNull() - const hints = JSON.parse(raw!) - expect(hints.lastActiveProductId).toBe('prod-1') - }) - - it('setActiveSprint schrijft lastActiveSprintId per product', () => { - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'P1' } - }) - useSprintWorkspaceStore.getState().setActiveSprint('sp-a') - const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!) - expect(hints.perProduct['prod-1'].lastActiveSprintId).toBe('sp-a') - }) - - it('setActiveStory schrijft lastActiveStoryId per sprint', () => { - useSprintWorkspaceStore.setState((s) => { - s.context.activeSprintId = 'sp-1' - }) - useSprintWorkspaceStore.getState().setActiveStory('s-a') - const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!) - expect(hints.perSprint['sp-1'].lastActiveStoryId).toBe('s-a') - }) - - it('setActiveTask schrijft lastActiveTaskId per sprint', () => { - useSprintWorkspaceStore.setState((s) => { - s.context.activeSprintId = 'sp-1' - }) - useSprintWorkspaceStore.getState().setActiveTask('t-a') - const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!) - expect(hints.perSprint['sp-1'].lastActiveTaskId).toBe('t-a') - }) -}) - -describe('restore-hint flow — chain triggert na ensure*Loaded', () => { - it('hint die NIET in entities zit wordt genegeerd', async () => { - localStorage.setItem( - 'sprint-workspace-hints', - JSON.stringify({ - lastActiveProductId: 'prod-1', - perProduct: { 'prod-1': { lastActiveSprintId: 'ghost-sprint' } }, - perSprint: {}, - }), - ) - mockFetchSequence([[]]) - - useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) - await new Promise((r) => setTimeout(r, 20)) - - expect(useSprintWorkspaceStore.getState().context.activeSprintId).toBeNull() - }) - - it('hint die wel in entities zit wordt toegepast', async () => { - const validSprint = makeSprint({ id: 'sp-known', product_id: 'prod-1' }) - localStorage.setItem( - 'sprint-workspace-hints', - JSON.stringify({ - lastActiveProductId: 'prod-1', - perProduct: { 'prod-1': { lastActiveSprintId: 'sp-known' } }, - perSprint: {}, - }), - ) - mockFetchSequence([ - // ensureProductSprintsLoaded — levert sp-known - [validSprint], - // ensureSprintLoaded triggered door setActiveSprint(hint) - { sprint: validSprint, stories: [], tasksByStory: {} }, - ]) - - useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) - await new Promise((r) => setTimeout(r, 30)) - - expect(useSprintWorkspaceStore.getState().context.activeSprintId).toBe('sp-known') - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// Optimistic mutations -// ───────────────────────────────────────────────────────────────────────── - -describe('optimistic mutations', () => { - it('SSE-echo van een al-bestaande sprint is idempotent', () => { - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'P' } - }) - useSprintWorkspaceStore - .getState() - .hydrateProductSprints('prod-1', [ - makeSprint({ id: 'sp-1', product_id: 'prod-1', sprint_goal: 'Origineel' }), - ]) - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'sprint', - op: 'I', - id: 'sp-1', - product_id: 'prod-1', - sprint_goal: 'echo', - }) - expect( - useSprintWorkspaceStore.getState().entities.sprintsById['sp-1'].sprint_goal, - ).toBe('Origineel') - }) -}) diff --git a/__tests__/stores/user-settings.test.ts b/__tests__/stores/user-settings.test.ts deleted file mode 100644 index e159bf8..0000000 --- a/__tests__/stores/user-settings.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const updateAction = vi.fn() -const setDraftAction = vi.fn() -const clearDraftAction = vi.fn() - -vi.mock('@/actions/user-settings', () => ({ - updateUserSettingsAction: (...args: unknown[]) => updateAction(...args), -})) - -vi.mock('@/actions/sprint-draft', () => ({ - setPendingSprintDraftAction: (...args: unknown[]) => setDraftAction(...args), - clearPendingSprintDraftAction: (...args: unknown[]) => - clearDraftAction(...args), -})) - -import { useUserSettingsStore } from '@/stores/user-settings/store' -import type { PendingSprintDraft } from '@/lib/user-settings' - -function resetStore() { - useUserSettingsStore.setState((s) => { - s.entities.settings = {} - s.context.hydrated = false - s.context.isDemo = false - s.pendingMutations = {} - }) -} - -beforeEach(() => { - resetStore() - updateAction.mockReset() - setDraftAction.mockReset() - clearDraftAction.mockReset() -}) - -afterEach(() => { - resetStore() -}) - -describe('useUserSettingsStore', () => { - it('hydrate sets entities and context', () => { - useUserSettingsStore.getState().hydrate( - { views: { sprintBacklog: { sort: 'code' } } }, - false, - ) - const s = useUserSettingsStore.getState() - expect(s.entities.settings.views?.sprintBacklog?.sort).toBe('code') - expect(s.context.hydrated).toBe(true) - expect(s.context.isDemo).toBe(false) - }) - - it('setPref updates state optimistically and settles on success', async () => { - useUserSettingsStore.getState().hydrate({}, false) - updateAction.mockResolvedValueOnce({ - success: true, - settings: { views: { sprintBacklog: { filterStatus: 'all' } } }, - }) - - await useUserSettingsStore - .getState() - .setPref(['views', 'sprintBacklog', 'filterStatus'], 'all') - - const s = useUserSettingsStore.getState() - expect(s.entities.settings.views?.sprintBacklog?.filterStatus).toBe('all') - expect(Object.keys(s.pendingMutations)).toHaveLength(0) - expect(updateAction).toHaveBeenCalledWith({ - views: { sprintBacklog: { filterStatus: 'all' } }, - }) - }) - - it('setPref rolls back on server error', async () => { - useUserSettingsStore.getState().hydrate( - { views: { sprintBacklog: { sort: 'code' } } }, - false, - ) - updateAction.mockResolvedValueOnce({ error: 'boom', code: 422 }) - - await useUserSettingsStore - .getState() - .setPref(['views', 'sprintBacklog', 'sort'], 'priority') - - const s = useUserSettingsStore.getState() - expect(s.entities.settings.views?.sprintBacklog?.sort).toBe('code') - expect(Object.keys(s.pendingMutations)).toHaveLength(0) - }) - - it('setPref skips server-call for demo accounts', async () => { - useUserSettingsStore.getState().hydrate({}, true) - - await useUserSettingsStore - .getState() - .setPref(['devTools', 'debugMode'], true) - - const s = useUserSettingsStore.getState() - expect(s.entities.settings.devTools?.debugMode).toBe(true) - expect(updateAction).not.toHaveBeenCalled() - }) - - it('setPendingSprintDraft persists draft lokaal (session-only, geen server-call)', async () => { - useUserSettingsStore.getState().hydrate({}, false) - - const draft: PendingSprintDraft = { - goal: 'Sprint 1', - pbiIntent: { pbiA: 'all' }, - storyOverrides: {}, - } - await useUserSettingsStore - .getState() - .setPendingSprintDraft('product-1', draft) - - const s = useUserSettingsStore.getState() - expect( - s.entities.settings.workflow?.pendingSprintDraft?.['product-1'], - ).toMatchObject({ goal: 'Sprint 1' }) - expect(setDraftAction).not.toHaveBeenCalled() - }) - - it('hydrate strips workflow.pendingSprintDraft uit legacy server-state', () => { - useUserSettingsStore.getState().hydrate( - { - workflow: { - pendingSprintDraft: { - 'product-1': { - goal: 'Legacy draft', - pbiIntent: {}, - storyOverrides: {}, - }, - }, - }, - }, - false, - ) - - const s = useUserSettingsStore.getState() - expect(s.entities.settings.workflow?.pendingSprintDraft).toBeUndefined() - }) - - it('clearPendingSprintDraft verwijdert de key lokaal zonder server-call', async () => { - useUserSettingsStore.getState().hydrate({}, false) - await useUserSettingsStore.getState().setPendingSprintDraft('product-1', { - goal: 'Old', - pbiIntent: {}, - storyOverrides: {}, - }) - - await useUserSettingsStore - .getState() - .clearPendingSprintDraft('product-1') - - const s = useUserSettingsStore.getState() - expect( - s.entities.settings.workflow?.pendingSprintDraft?.['product-1'], - ).toBeUndefined() - expect(clearDraftAction).not.toHaveBeenCalled() - }) - - it('upsertPbiIntent updates intent and wipes storyOverrides for that PBI', async () => { - useUserSettingsStore.getState().hydrate({}, false) - await useUserSettingsStore.getState().setPendingSprintDraft('product-1', { - goal: 'g', - pbiIntent: { pbiA: 'none' }, - storyOverrides: { - pbiA: { add: ['s-1'], remove: [] }, - pbiB: { add: [], remove: ['s-2'] }, - }, - }) - - await useUserSettingsStore - .getState() - .upsertPbiIntent('product-1', 'pbiA', 'all') - - const draft = - useUserSettingsStore.getState().entities.settings.workflow - ?.pendingSprintDraft?.['product-1'] - expect(draft?.pbiIntent.pbiA).toBe('all') - expect(draft?.storyOverrides.pbiA).toBeUndefined() - expect(draft?.storyOverrides.pbiB).toEqual({ add: [], remove: ['s-2'] }) - }) - - it('upsertStoryOverride add adds to add[] and removes from remove[]', async () => { - useUserSettingsStore.getState().hydrate({}, false) - await useUserSettingsStore.getState().setPendingSprintDraft('product-1', { - goal: 'g', - pbiIntent: {}, - storyOverrides: { - pbiA: { add: [], remove: ['story-1'] }, - }, - }) - - await useUserSettingsStore - .getState() - .upsertStoryOverride('product-1', 'pbiA', 'story-1', 'add') - - const draft = - useUserSettingsStore.getState().entities.settings.workflow - ?.pendingSprintDraft?.['product-1'] - expect(draft?.storyOverrides.pbiA).toEqual({ - add: ['story-1'], - remove: [], - }) - }) - - it('upsertStoryOverride clear removes from both arrays and drops empty entry', async () => { - useUserSettingsStore.getState().hydrate({}, false) - await useUserSettingsStore.getState().setPendingSprintDraft('product-1', { - goal: 'g', - pbiIntent: {}, - storyOverrides: { - pbiA: { add: ['story-1'], remove: [] }, - }, - }) - - await useUserSettingsStore - .getState() - .upsertStoryOverride('product-1', 'pbiA', 'story-1', 'clear') - - const draft = - useUserSettingsStore.getState().entities.settings.workflow - ?.pendingSprintDraft?.['product-1'] - expect(draft?.storyOverrides.pbiA).toBeUndefined() - }) - - it('applyServerPatch merges without optimistic state', () => { - useUserSettingsStore.getState().hydrate( - { views: { sprintBacklog: { sort: 'code' } } }, - false, - ) - - useUserSettingsStore.getState().applyServerPatch({ - views: { sprintBacklog: { sortDir: 'desc' } }, - }) - - const s = useUserSettingsStore.getState() - expect(s.entities.settings.views?.sprintBacklog).toEqual({ - sort: 'code', - sortDir: 'desc', - }) - expect(Object.keys(s.pendingMutations)).toHaveLength(0) - }) -}) diff --git a/actions/active-sprint.ts b/actions/active-sprint.ts deleted file mode 100644 index e774376..0000000 --- a/actions/active-sprint.ts +++ /dev/null @@ -1,164 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' -import { cookies } from 'next/headers' -import { getIronSession } from 'iron-session' -import { z } from 'zod' -import { prisma } from '@/lib/prisma' -import { SessionData, sessionOptions } from '@/lib/session' -import { productAccessFilter } from '@/lib/product-access' -import { - clearActiveSprintInSettings, - setActiveSelectionInSettings, - setActiveSprintInSettings, -} from '@/lib/active-sprint' - -async function getSession() { - return getIronSession<SessionData>(await cookies(), sessionOptions) -} - -const setSchema = z.object({ - productId: z.string().min(1), - sprintId: z.string().min(1), -}) - -const clearSchema = z.object({ - productId: z.string().min(1), -}) - -export async function setActiveSprintAction(productId: string, sprintId: string) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - const parsed = setSchema.safeParse({ productId, sprintId }) - if (!parsed.success) return { error: 'Ongeldig product- of sprint-id' } - - const sprint = await prisma.sprint.findFirst({ - where: { - id: parsed.data.sprintId, - product_id: parsed.data.productId, - product: productAccessFilter(session.userId), - }, - select: { id: true }, - }) - if (!sprint) return { error: 'Sprint niet gevonden of niet toegankelijk' } - - await setActiveSprintInSettings(session.userId, parsed.data.productId, parsed.data.sprintId) - revalidatePath('/', 'layout') - return { success: true, sprintId: parsed.data.sprintId } -} - -export async function clearActiveSprintAction(productId: string) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - const parsed = clearSchema.safeParse({ productId }) - if (!parsed.success) return { error: 'Ongeldig product-id' } - - const product = await prisma.product.findFirst({ - where: { id: parsed.data.productId, ...productAccessFilter(session.userId) }, - select: { id: true }, - }) - if (!product) return { error: 'Product niet gevonden of niet toegankelijk' } - - await clearActiveSprintInSettings(session.userId, parsed.data.productId) - revalidatePath('/', 'layout') - return { success: true } -} - -const selectionSchema = z.object({ - productId: z.string().min(1), - sprintId: z.string().min(1), -}) - -/** - * PBI-79: kies een sprint en auto-select zijn enige PBI/story (indien - * singleton). Resultaat wordt server-side bepaald + atomair in user-settings - * weggeschreven (sprint+pbi+story) zodat cross-device-restore klopt. - */ -export async function switchActiveSprintAction( - productId: string, - sprintId: string, -): Promise< - | { - success: true - sprintId: string - pbiId: string | null - storyId: string | null - } - | { error: string } -> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - const parsed = selectionSchema.safeParse({ productId, sprintId }) - if (!parsed.success) return { error: 'Ongeldig product- of sprint-id' } - - const sprint = await prisma.sprint.findFirst({ - where: { - id: parsed.data.sprintId, - product_id: parsed.data.productId, - product: productAccessFilter(session.userId), - }, - select: { id: true }, - }) - if (!sprint) return { error: 'Sprint niet gevonden of niet toegankelijk' } - - // Auto-select: alleen wanneer sprint exact één PBI heeft. Story-auto-select - // alleen wanneer die PBI exact één story binnen deze sprint heeft. - const sprintStories = await prisma.story.findMany({ - where: { - sprint_id: parsed.data.sprintId, - product_id: parsed.data.productId, - }, - select: { id: true, pbi_id: true }, - }) - const uniquePbiIds = Array.from(new Set(sprintStories.map((s) => s.pbi_id))) - let autoPbiId: string | null = null - let autoStoryId: string | null = null - if (uniquePbiIds.length === 1) { - autoPbiId = uniquePbiIds[0] - const storiesForPbi = sprintStories.filter((s) => s.pbi_id === autoPbiId) - if (storiesForPbi.length === 1) { - autoStoryId = storiesForPbi[0].id - } - } - - await setActiveSelectionInSettings(session.userId, parsed.data.productId, { - sprintId: parsed.data.sprintId, - pbiId: autoPbiId, - storyId: autoStoryId, - }) - revalidatePath('/', 'layout') - - return { - success: true, - sprintId: parsed.data.sprintId, - pbiId: autoPbiId, - storyId: autoStoryId, - } -} - -export async function syncActiveSprintCookieAction(productId: string, sprintId: string) { - const session = await getSession() - if (!session.userId) return - if (session.isDemo) return - - const parsed = setSchema.safeParse({ productId, sprintId }) - if (!parsed.success) return - - const sprint = await prisma.sprint.findFirst({ - where: { - id: parsed.data.sprintId, - product_id: parsed.data.productId, - product: productAccessFilter(session.userId), - }, - select: { id: true }, - }) - if (!sprint) return - - await setActiveSprintInSettings(session.userId, parsed.data.productId, parsed.data.sprintId) -} diff --git a/actions/admin/jobs.ts b/actions/admin/jobs.ts deleted file mode 100644 index e6a81e0..0000000 --- a/actions/admin/jobs.ts +++ /dev/null @@ -1,41 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' -import { z } from 'zod' -import { prisma } from '@/lib/prisma' -import { requireAdmin } from '@/lib/auth-guard' - -const cuidSchema = z.string().cuid() - -export async function cancelJobAction(jobId: string) { - await requireAdmin() - - const parsed = cuidSchema.safeParse(jobId) - if (!parsed.success) throw new Error('Ongeldig job-id') - - const job = await prisma.claudeJob.findUnique({ - where: { id: parsed.data }, - select: { status: true }, - }) - - if (!job) throw new Error('Job niet gevonden') - if (job.status === 'DONE' || job.status === 'FAILED' || job.status === 'CANCELLED' || job.status === 'SKIPPED') { - throw new Error('Job is al in eindstatus') - } - - await prisma.claudeJob.update({ - where: { id: parsed.data }, - data: { status: 'CANCELLED', finished_at: new Date() }, - }) - revalidatePath('/admin/jobs') -} - -export async function deleteJobAction(jobId: string) { - await requireAdmin() - - const parsed = cuidSchema.safeParse(jobId) - if (!parsed.success) throw new Error('Ongeldig job-id') - - await prisma.claudeJob.delete({ where: { id: parsed.data } }) - revalidatePath('/admin/jobs') -} diff --git a/actions/admin/products.ts b/actions/admin/products.ts deleted file mode 100644 index b6f4ad0..0000000 --- a/actions/admin/products.ts +++ /dev/null @@ -1,86 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' -import { z } from 'zod' -import { prisma } from '@/lib/prisma' -import { requireAdmin } from '@/lib/auth-guard' - -const adminProductSchema = z.object({ - name: z.string().min(1).max(100), - description: z.string().optional(), - repo_url: z.string().url().optional().or(z.literal('')), - definition_of_done: z.string().min(1), - auto_pr: z.boolean().default(false), - owner_user_id: z.string().cuid(), -}) - -const adminProductUpdateSchema = adminProductSchema.omit({ owner_user_id: true }) - -export async function adminCreateProductAction(data: unknown) { - await requireAdmin() - - const parsed = adminProductSchema.safeParse(data) - if (!parsed.success) throw new Error(parsed.error.message) - - const owner = await prisma.user.findUnique({ where: { id: parsed.data.owner_user_id } }) - if (!owner) throw new Error('Eigenaar niet gevonden') - - await prisma.product.create({ - data: { - user_id: parsed.data.owner_user_id, - name: parsed.data.name, - description: parsed.data.description, - repo_url: parsed.data.repo_url || null, - definition_of_done: parsed.data.definition_of_done, - auto_pr: parsed.data.auto_pr, - }, - }) - revalidatePath('/admin/products') -} - -export async function adminUpdateProductAction(productId: string, data: unknown) { - await requireAdmin() - - const parsed = adminProductUpdateSchema.safeParse(data) - if (!parsed.success) throw new Error(parsed.error.message) - - await prisma.product.update({ - where: { id: productId }, - data: { - name: parsed.data.name, - description: parsed.data.description, - repo_url: parsed.data.repo_url || null, - definition_of_done: parsed.data.definition_of_done, - auto_pr: parsed.data.auto_pr, - }, - }) - revalidatePath('/admin/products') -} - -export async function adminArchiveProductAction(productId: string, archived: boolean) { - await requireAdmin() - await prisma.product.update({ where: { id: productId }, data: { archived } }) - revalidatePath('/admin/products') -} - -export async function adminDeleteProductAction(productId: string) { - await requireAdmin() - await prisma.product.delete({ where: { id: productId } }) - revalidatePath('/admin/products') -} - -export async function adminAddMemberAction(productId: string, userId: string) { - await requireAdmin() - await prisma.productMember.upsert({ - where: { product_id_user_id: { product_id: productId, user_id: userId } }, - create: { product_id: productId, user_id: userId }, - update: {}, - }) - revalidatePath(`/admin/products/${productId}`) -} - -export async function adminRemoveMemberAction(productId: string, userId: string) { - await requireAdmin() - await prisma.productMember.deleteMany({ where: { product_id: productId, user_id: userId } }) - revalidatePath(`/admin/products/${productId}`) -} diff --git a/actions/admin/users.ts b/actions/admin/users.ts deleted file mode 100644 index c7698fa..0000000 --- a/actions/admin/users.ts +++ /dev/null @@ -1,43 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' -import { z } from 'zod' -import { Role } from '@prisma/client' -import { prisma } from '@/lib/prisma' -import { requireAdmin } from '@/lib/auth-guard' - -export async function deleteUserAction(userId: string) { - const session = await requireAdmin() - if (userId === session.userId) { - throw new Error('Zelfverwijdering niet toegestaan') - } - await prisma.user.delete({ where: { id: userId } }) - revalidatePath('/admin/users') -} - -const rolesSchema = z.array(z.nativeEnum(Role)) - -export async function updateUserRolesAction(userId: string, roles: Role[]) { - const session = await requireAdmin() - - const parsed = rolesSchema.safeParse(roles) - if (!parsed.success) { - throw new Error('Ongeldige rol-waarden') - } - - if (userId === session.userId && !parsed.data.includes(Role.ADMIN)) { - throw new Error('Kan eigen ADMIN-rol niet verwijderen') - } - - await prisma.$transaction([ - prisma.userRole.deleteMany({ where: { user_id: userId } }), - ...parsed.data.map((role) => prisma.userRole.create({ data: { user_id: userId, role } })), - ]) - revalidatePath('/admin/users') -} - -export async function setMustResetPasswordAction(userId: string, value: boolean) { - await requireAdmin() - await prisma.user.update({ where: { id: userId }, data: { must_reset_password: value } }) - revalidatePath('/admin/users') -} diff --git a/actions/api-tokens.ts b/actions/api-tokens.ts index 136692b..3964342 100644 --- a/actions/api-tokens.ts +++ b/actions/api-tokens.ts @@ -6,7 +6,6 @@ import { getIronSession } from 'iron-session' import { createHash, randomBytes } from 'crypto' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' -import { enforceUserRateLimit } from '@/lib/rate-limit' async function getSession() { return getIronSession<SessionData>(await cookies(), sessionOptions) @@ -17,9 +16,6 @@ export async function createApiTokenAction(_prevState: unknown, formData: FormDa if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - const limited = enforceUserRateLimit('create-token', session.userId) - if (limited) return limited - const label = (formData.get('label') as string | null)?.trim() || null // Max 10 active tokens diff --git a/actions/auth.ts b/actions/auth.ts index a08c502..fc4a163 100644 --- a/actions/auth.ts +++ b/actions/auth.ts @@ -4,12 +4,9 @@ import { redirect } from 'next/navigation' import { cookies, headers } from 'next/headers' import { getIronSession } from 'iron-session' import { z } from 'zod' -import { prisma } from '@/lib/prisma' -import { registerUser, verifyUser, hashPassword } from '@/lib/auth' +import { registerUser, verifyUser } from '@/lib/auth' import { SessionData, sessionOptions } from '@/lib/session' import { checkRateLimit } from '@/lib/rate-limit' -import { isPhoneUA } from '@/lib/user-agent' -import { requireSession } from '@/lib/auth-guard' async function getClientIp(): Promise<string> { const h = await headers() @@ -47,7 +44,6 @@ export async function registerAction(_prevState: unknown, formData: FormData) { const session = await getIronSession<SessionData>(await cookies(), sessionOptions) session.userId = result.user!.id session.isDemo = false - session.isAdmin = false await session.save() redirect('/dashboard') @@ -73,22 +69,11 @@ export async function loginAction(_prevState: unknown, formData: FormData) { return { error: 'Onjuiste gebruikersnaam of wachtwoord' } } - const adminRole = await prisma.userRole.findFirst({ - where: { user_id: user.id, role: 'ADMIN' }, - }) const session = await getIronSession<SessionData>(await cookies(), sessionOptions) session.userId = user.id session.isDemo = user.is_demo - session.isAdmin = !!adminRole await session.save() - // PBI-11 / ST-1135: telefoon-UA's krijgen de mobile-shell. - // Tablets en desktop volgen het bestaande /dashboard-pad. - const ua = (await headers()).get('user-agent') - if (isPhoneUA(ua)) { - redirect(user.active_product_id ? `/m/products/${user.active_product_id}/solo` : '/m/settings') - } - redirect('/dashboard') } @@ -97,39 +82,3 @@ export async function logoutAction() { session.destroy() redirect('/login') } - -const resetPasswordSchema = z - .object({ - password: z.string().min(8, 'Wachtwoord moet minimaal 8 tekens bevatten'), - confirm: z.string(), - }) - .superRefine((data, ctx) => { - if (data.password !== data.confirm) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Wachtwoorden komen niet overeen', - path: ['confirm'], - }) - } - }) - -export async function resetPasswordAction(_prevState: unknown, formData: FormData) { - const session = await requireSession() - - const parsed = resetPasswordSchema.safeParse({ - password: formData.get('password'), - confirm: formData.get('confirm'), - }) - - if (!parsed.success) { - return { error: parsed.error.flatten().fieldErrors } - } - - const hash = await hashPassword(parsed.data.password) - await prisma.user.update({ - where: { id: session.userId }, - data: { password_hash: hash, must_reset_password: false }, - }) - - redirect('/dashboard') -} diff --git a/actions/claude-jobs.ts b/actions/claude-jobs.ts index 258fd1a..17c55d3 100644 --- a/actions/claude-jobs.ts +++ b/actions/claude-jobs.ts @@ -1,9 +1,9 @@ 'use server' import { revalidatePath } from 'next/cache' -import { type ClaudeJobStatus } from '@prisma/client' import { prisma } from '@/lib/prisma' import { getSession } from '@/lib/auth' +import { productAccessFilter } from '@/lib/product-access' import { ACTIVE_JOB_STATUSES, jobStatusToApi } from '@/lib/job-status' type EnqueueResult = @@ -16,65 +16,113 @@ type EnqueueAllResult = type CancelResult = { success: true } | { error: string } -type RestartResult = { success: true } | { error: string } -const RESTARTABLE_STATUSES: ClaudeJobStatus[] = ['FAILED', 'CANCELLED', 'SKIPPED'] +export async function enqueueClaudeJobAction(taskId: string): Promise<EnqueueResult> { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } -export type PreviewTask = { - id: string - title: string - status: string - story_title: string - pbi_id: string - pbi_status: string + if (!taskId) return { error: 'task_id is verplicht' } + + // Resolve task + product access in one query + const task = await prisma.task.findFirst({ + where: { + id: taskId, + story: { product: productAccessFilter(session.userId) }, + }, + select: { id: true, story: { select: { product_id: true } } }, + }) + if (!task) return { error: 'Task niet gevonden' } + + const productId = task.story.product_id + + // Idempotency: weiger als er al een actieve job voor deze task bestaat + const existing = await prisma.claudeJob.findFirst({ + where: { task_id: taskId, status: { in: ACTIVE_JOB_STATUSES } }, + select: { id: true }, + }) + if (existing) { + return { error: 'Er loopt al een agent voor deze task', jobId: existing.id } + } + + const job = await prisma.claudeJob.create({ + data: { user_id: session.userId, product_id: productId, task_id: taskId, status: 'QUEUED' }, + }) + + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_enqueued', + job_id: job.id, + task_id: taskId, + user_id: session.userId, + product_id: productId, + status: 'queued', + })}::text) + ` + + revalidatePath(`/products/${productId}/solo`) + return { success: true, jobId: job.id } } -type PreflightResult = - | { error: string } - | { tasks: PreviewTask[]; blockerIndex: number | null; blockerReason: 'task-review' | 'pbi-blocked' | null } +export async function enqueueAllTodoJobsAction(productId: string): Promise<EnqueueAllResult> { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } -/** - * @deprecated Vervangen door startSprintRunAction in actions/sprint-runs.ts. - * Per-task starts zijn niet meer toegestaan — een sprint draait nu als geheel. - * Wordt verwijderd zodra de UI is omgebouwd (F4). - */ -export async function enqueueClaudeJobAction(_taskId: string): Promise<EnqueueResult> { - return { - error: - 'Per-task starten is niet meer mogelijk. Gebruik "Start Sprint" voor de hele actieve sprint.', - } -} + if (!productId) return { error: 'product_id is verplicht' } -/** - * @deprecated Vervangen door startSprintRunAction in actions/sprint-runs.ts. - */ -export async function enqueueAllTodoJobsAction(_productId: string): Promise<EnqueueAllResult> { - return { - error: - '"Alle TO_DO als jobs queueen" is vervangen door "Start Sprint". Gebruik startSprintRunAction.', - } -} + const product = await prisma.product.findFirst({ + where: { id: productId, ...productAccessFilter(session.userId) }, + select: { id: true }, + }) + if (!product) return { error: 'Geen toegang tot dit product' } -/** - * @deprecated Vervangen door pre-flight in startSprintRunAction (actions/sprint-runs.ts). - */ -export async function previewEnqueueAllAction(_productId: string): Promise<PreflightResult> { - return { - error: - 'Per-product preview is vervangen door de pre-flight check in startSprintRunAction.', - } -} + const userId = session.userId -/** - * @deprecated Vervangen door startSprintRunAction in actions/sprint-runs.ts. - */ -export async function enqueueClaudeJobsBatchAction( - _productId: string, - _taskIds: string[] -): Promise<EnqueueAllResult> { - return { - error: - 'Batch-queue per task is vervangen door "Start Sprint". Gebruik startSprintRunAction.', + // Match het scope dat de gebruiker op het Solo Paneel ziet: + // alleen TO_DO-taken in de actieve sprint, in stories die aan deze + // gebruiker zijn toegewezen. Anders queue je per ongeluk taken die + // niet in de huidige sprint zitten of aan iemand anders toebehoren. + const sprint = await prisma.sprint.findFirst({ + where: { product_id: productId, status: 'ACTIVE' }, + select: { id: true }, + }) + if (!sprint) return { success: true, count: 0 } + + const tasks = await prisma.task.findMany({ + where: { + status: 'TO_DO', + story: { sprint_id: sprint.id, assignee_id: userId }, + claude_jobs: { none: { status: { in: ACTIVE_JOB_STATUSES } } }, + }, + select: { id: true }, + }) + + if (tasks.length === 0) return { success: true, count: 0 } + + const created = await prisma.$transaction( + tasks.map(t => + prisma.claudeJob.create({ + data: { user_id: userId, product_id: productId, task_id: t.id, status: 'QUEUED' }, + select: { id: true, task_id: true }, + }) + ) + ) + + for (const job of created) { + await prisma.$executeRaw` + SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ + type: 'claude_job_enqueued', + job_id: job.id, + task_id: job.task_id, + user_id: userId, + product_id: productId, + status: 'queued', + })}::text) + ` } + + revalidatePath(`/products/${productId}/solo`) + return { success: true, count: created.length } } export async function cancelClaudeJobAction(jobId: string): Promise<CancelResult> { @@ -113,76 +161,3 @@ export async function cancelClaudeJobAction(jobId: string): Promise<CancelResult revalidatePath(`/products/${job.product_id}/solo`) return { success: true } } - -export async function restartClaudeJobAction(jobId: string): Promise<RestartResult> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - if (!jobId) return { error: 'job_id is verplicht' } - - const job = await prisma.claudeJob.findFirst({ - where: { id: jobId, user_id: session.userId }, - select: { id: true, status: true, kind: true, task_id: true, idea_id: true, sprint_run_id: true, product_id: true }, - }) - if (!job) return { error: 'Job niet gevonden' } - if (!RESTARTABLE_STATUSES.includes(job.status)) { - return { error: 'Alleen mislukte, geannuleerde of overgeslagen jobs kunnen opnieuw gestart worden' } - } - - const updated = await prisma.$transaction(async (tx) => { - const result = await tx.claudeJob.updateMany({ - where: { id: jobId, status: { in: RESTARTABLE_STATUSES } }, - data: { - status: 'QUEUED', - retry_count: { increment: 1 }, - claimed_by_token_id: null, - claimed_at: null, - started_at: null, - finished_at: null, - pushed_at: null, - verify_result: null, - error: null, - summary: null, - branch: null, - head_sha: null, - lease_until: null, - }, - }) - if (result.count === 0) return 0 - if (job.kind === 'SPRINT_IMPLEMENTATION') { - await tx.sprintTaskExecution.updateMany({ - where: { sprint_job_id: jobId }, - data: { - status: 'PENDING', - verify_result: null, - verify_summary: null, - skip_reason: null, - head_sha: null, - started_at: null, - finished_at: null, - }, - }) - } - return result.count - }) - if (updated === 0) { - return { error: 'Job-status is gewijzigd; herlaad en probeer opnieuw' } - } - - await prisma.$executeRaw` - SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ - type: 'claude_job_status', - job_id: jobId, - kind: job.kind, - task_id: job.task_id, - idea_id: job.idea_id, - sprint_run_id: job.sprint_run_id, - user_id: session.userId, - product_id: job.product_id, - status: jobStatusToApi('QUEUED'), - })}::text) - ` - - revalidatePath('/jobs') - return { success: true } -} diff --git a/actions/ideas.ts b/actions/ideas.ts deleted file mode 100644 index 63bae6d..0000000 --- a/actions/ideas.ts +++ /dev/null @@ -1,862 +0,0 @@ -'use server' - -// Server-actions voor de Idea-entity (M12). Volgt docs/patterns/server-action.md: -// auth → demo-guard → rate-limit → zod-validate → user_id-scope-check → write -// → revalidatePath. Idee is strikt user_id-only (zie M12 grill-keuze 8) — er -// is GEEN productAccessFilter; idee is privé voor de eigenaar, ook als-ie -// gekoppeld is aan een team-product. - -import { revalidatePath } from 'next/cache' -import { cookies } from 'next/headers' -import { getIronSession } from 'iron-session' - -import { z } from 'zod' - -import { prisma } from '@/lib/prisma' -import { getJobConfigSnapshot } from '@/lib/job-config-snapshot' -import { SessionData, sessionOptions } from '@/lib/session' -import { enforceUserRateLimit } from '@/lib/rate-limit' -import { ideaCreateSchema, ideaUpdateSchema } from '@/lib/schemas/idea' -import { canTransition, isGrillMdEditable, isIdeaEditable, isPlanMdEditable } from '@/lib/idea-status' -import { nextIdeaCode } from '@/lib/idea-code-server' -import { parsePlanMd } from '@/lib/idea-plan-parser' -import { ACTIVE_JOB_STATUSES } from '@/lib/job-status' -import { parseCodeNumber } from '@/lib/code' - -import type { ClaudeJobKind, Idea, IdeaStatus } from '@prisma/client' - -// Worker-presence: aligned met /api/realtime/solo. -const WORKER_FRESH_MS = 15_000 -async function countActiveWorkers(userId: string): Promise<number> { - return prisma.claudeWorker.count({ - where: { - user_id: userId, - last_seen_at: { gt: new Date(Date.now() - WORKER_FRESH_MS) }, - }, - }) -} - -async function getSession() { - return getIronSession<SessionData>(await cookies(), sessionOptions) -} - -// Standaard error-shape voor consistente UI-rendering — zie ook actions/todos.ts. -type ActionResult<T = void> = - | { success: true; data?: T } - | { error: string; code?: number; details?: unknown } - -// --------------------------------------------------------------------------- -// CRUD - -export async function createIdeaAction(input: { - title: string - description?: string | null - product_id?: string | null -}): Promise<ActionResult<{ id: string; code: string }>> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 401 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const limited = enforceUserRateLimit('create-idea', session.userId) - if (limited) return limited - - const parsed = ideaCreateSchema.safeParse(input) - if (!parsed.success) { - return { error: 'Validatie mislukt', code: 422, details: parsed.error.flatten().fieldErrors } - } - - const userId = session.userId - // Atomair: code + create in dezelfde transactie zodat een crash tussenin geen - // counter-gat veroorzaakt zonder bijbehorend idee. - const idea = await prisma.$transaction(async (tx) => { - const code = await nextIdeaCode(userId, tx) - return tx.idea.create({ - data: { - user_id: userId, - product_id: parsed.data.product_id ?? null, - code, - title: parsed.data.title, - description: parsed.data.description ?? null, - status: 'DRAFT', - }, - select: { id: true, code: true }, - }) - }) - - revalidatePath('/ideas') - return { success: true, data: idea } -} - -export async function updateIdeaAction( - id: string, - input: { title?: string; description?: string | null; product_id?: string | null }, -): Promise<ActionResult> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 401 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const parsed = ideaUpdateSchema.safeParse(input) - if (!parsed.success) { - return { error: 'Validatie mislukt', code: 422, details: parsed.error.flatten().fieldErrors } - } - - const idea = await prisma.idea.findFirst({ - where: { id, user_id: session.userId }, - select: { id: true, status: true }, - }) - if (!idea) return { error: 'Idee niet gevonden', code: 404 } - if (!isIdeaEditable(idea.status)) { - return { error: `Idee is niet bewerkbaar in status ${idea.status}`, code: 422 } - } - - await prisma.idea.update({ - where: { id }, - data: { - ...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}), - ...(parsed.data.description !== undefined ? { description: parsed.data.description } : {}), - ...(parsed.data.product_id !== undefined ? { product_id: parsed.data.product_id } : {}), - }, - }) - revalidatePath('/ideas') - revalidatePath(`/ideas/${id}`) - return { success: true } -} - -export async function archiveIdeaAction(id: string): Promise<ActionResult> { - return setArchived(id, true) -} - -export async function unarchiveIdeaAction(id: string): Promise<ActionResult> { - return setArchived(id, false) -} - -async function setArchived(id: string, archived: boolean): Promise<ActionResult> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 401 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const found = await prisma.idea.findFirst({ - where: { id, user_id: session.userId }, - select: { id: true }, - }) - if (!found) return { error: 'Idee niet gevonden', code: 404 } - - await prisma.idea.update({ where: { id }, data: { archived } }) - revalidatePath('/ideas') - revalidatePath(`/ideas/${id}`) - return { success: true } -} - -export async function deleteIdeaAction(id: string): Promise<ActionResult> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 401 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const idea = await prisma.idea.findFirst({ - where: { id, user_id: session.userId }, - select: { id: true, pbi_id: true }, - }) - if (!idea) return { error: 'Idee niet gevonden', code: 404 } - if (idea.pbi_id !== null) { - return { - error: 'Verwijder eerst de gekoppelde PBI; daarna kun je het idee weggooien.', - code: 422, - } - } - - await prisma.idea.delete({ where: { id } }) - revalidatePath('/ideas') - return { success: true } -} - -// --------------------------------------------------------------------------- -// Secondary products - -const secondaryProductsSchema = z.object({ - ideaId: z.string().cuid(), - productIds: z.array(z.string().cuid()).max(10), -}) - -export async function updateSecondaryProductsAction( - ideaId: string, - productIds: string[], -): Promise<ActionResult> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 401 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const parsed = secondaryProductsSchema.safeParse({ ideaId, productIds }) - if (!parsed.success) return { error: 'Ongeldige invoer', code: 422 } - - const idea = await prisma.idea.findFirst({ - where: { id: parsed.data.ideaId, user_id: session.userId }, - select: { id: true, product_id: true }, - }) - if (!idea) return { error: 'Idee niet gevonden', code: 404 } - - // Verwijder primair product uit de lijst (mag niet dubbel) - const filtered = parsed.data.productIds.filter((pid) => pid !== idea.product_id) - - // Valideer dat alle gevraagde producten toegankelijk zijn voor de user - if (filtered.length > 0) { - const { productAccessFilter } = await import('@/lib/product-access') - const accessible = await prisma.product.findMany({ - where: { id: { in: filtered }, ...productAccessFilter(session.userId) }, - select: { id: true }, - }) - if (accessible.length !== filtered.length) - return { error: 'Een of meer producten zijn niet toegankelijk', code: 403 } - } - - // Atomisch: verwijder alle bestaande, voeg nieuwe in - await prisma.$transaction([ - prisma.ideaProduct.deleteMany({ where: { idea_id: idea.id } }), - ...(filtered.length > 0 - ? [ - prisma.ideaProduct.createMany({ - data: filtered.map((pid) => ({ idea_id: idea.id, product_id: pid })), - skipDuplicates: true, - }), - ] - : []), - ]) - - revalidatePath('/ideas/' + idea.id, 'page') - revalidatePath('/ideas', 'page') - return { success: true } -} - -// --------------------------------------------------------------------------- -// Markdown-edits (grill_md & plan_md handmatig fine-tunen) - -export async function updateGrillMdAction( - id: string, - markdown: string, -): Promise<ActionResult> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 401 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const limited = enforceUserRateLimit('edit-idea-md', session.userId) - if (limited) return limited - - const idea = await loadOwnedIdea(id, session.userId, ['status']) - if (!idea) return { error: 'Idee niet gevonden', code: 404 } - if (!isGrillMdEditable(idea.status)) { - return { - error: `grill_md alleen bewerkbaar in GRILLED of PLAN_READY (huidige status: ${idea.status})`, - code: 422, - } - } - - await prisma.$transaction([ - prisma.idea.update({ where: { id }, data: { grill_md: markdown } }), - prisma.ideaLog.create({ - data: { - idea_id: id, - type: 'NOTE', - content: 'User-edited grill_md', - metadata: { length: markdown.length }, - }, - }), - ]) - - revalidatePath(`/ideas/${id}`) - return { success: true } -} - -export async function updatePlanMdAction( - id: string, - markdown: string, -): Promise<ActionResult> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 401 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const limited = enforceUserRateLimit('edit-idea-md', session.userId) - if (limited) return limited - - const idea = await loadOwnedIdea(id, session.userId, ['status']) - if (!idea) return { error: 'Idee niet gevonden', code: 404 } - if (!isPlanMdEditable(idea.status)) { - return { - error: `plan_md alleen bewerkbaar in PLAN_READY (huidige status: ${idea.status})`, - code: 422, - } - } - - // Validate frontmatter — voorkomt dat een onparseerbaar plan in de DB belandt - // en bij Materialiseer pas faalt. - const parsed = parsePlanMd(markdown) - if (!parsed.ok) { - return { - error: 'plan_md is niet parseerbaar', - code: 422, - details: parsed.errors, - } - } - - await prisma.$transaction([ - prisma.idea.update({ where: { id }, data: { plan_md: markdown } }), - prisma.ideaLog.create({ - data: { - idea_id: id, - type: 'NOTE', - content: 'User-edited plan_md', - metadata: { length: markdown.length }, - }, - }), - ]) - - revalidatePath(`/ideas/${id}`) - return { success: true } -} - -// --------------------------------------------------------------------------- -// Upload — gebruiker plakt/uploadt zelf een plan.md in plaats van de Make-Plan -// AI-flow. Skipt grill als gewenst. Status springt direct naar PLAN_READY. -// Bij parse-failure: NIET opslaan (return 422), zodat een onparseerbaar plan -// nooit in de DB belandt. Geen worker nodig — synchrone parser. - -const UPLOAD_PLAN_FROM: IdeaStatus[] = ['DRAFT', 'GRILLED', 'PLAN_FAILED', 'PLAN_READY'] -const MAX_PLAN_MD_LENGTH = 100_000 - -export async function uploadPlanMdAction( - id: string, - markdown: string, -): Promise<ActionResult> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 401 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const limited = enforceUserRateLimit('upload-idea-plan', session.userId) - if (limited) return limited - - if (typeof markdown !== 'string' || markdown.trim().length === 0) { - return { error: 'plan_md is leeg', code: 422 } - } - if (markdown.length > MAX_PLAN_MD_LENGTH) { - return { - error: `plan_md is te groot (${markdown.length} > ${MAX_PLAN_MD_LENGTH} chars)`, - code: 422, - } - } - - const idea = await loadOwnedIdea(id, session.userId, ['status']) - if (!idea) return { error: 'Idee niet gevonden', code: 404 } - if (!UPLOAD_PLAN_FROM.includes(idea.status)) { - return { - error: `Upload plan alleen toegestaan vanuit ${UPLOAD_PLAN_FROM.join('/')} (huidige status: ${idea.status})`, - code: 422, - } - } - - const parsed = parsePlanMd(markdown) - if (!parsed.ok) { - return { - error: 'plan_md is niet parseerbaar', - code: 422, - details: parsed.errors, - } - } - - await prisma.$transaction([ - prisma.idea.update({ - where: { id }, - data: { plan_md: markdown, status: 'PLAN_READY' }, - }), - prisma.ideaLog.create({ - data: { - idea_id: id, - type: 'NOTE', - content: 'User-uploaded plan_md', - metadata: { length: markdown.length, from_status: idea.status }, - }, - }), - ]) - - revalidatePath(`/ideas/${id}`) - return { success: true } -} - -// --------------------------------------------------------------------------- -// Download — geeft de raw markdown terug; UI bouwt een Blob. - -export async function downloadIdeaMdAction( - id: string, - kind: 'grill' | 'plan', -): Promise<ActionResult<{ filename: string; markdown: string }>> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 401 } - // Demo MAG downloaden — read-only operatie, geen mutatie. - - const idea = await loadOwnedIdea(id, session.userId, ['code', 'grill_md', 'plan_md']) - if (!idea) return { error: 'Idee niet gevonden', code: 404 } - - const md = kind === 'grill' ? idea.grill_md : idea.plan_md - if (!md) { - return { error: `Geen ${kind}_md beschikbaar voor dit idee`, code: 404 } - } - - return { - success: true, - data: { filename: `${idea.code}-${kind}.md`, markdown: md }, - } -} - -// --------------------------------------------------------------------------- -// Job-triggers (Grill Me / Make Plan / Cancel) - -const GRILL_TRIGGERABLE_FROM: IdeaStatus[] = ['DRAFT', 'GRILLED', 'GRILL_FAILED', 'PLAN_READY', 'PLANNED'] -const MAKE_PLAN_TRIGGERABLE_FROM: IdeaStatus[] = ['GRILLED', 'PLAN_FAILED', 'PLAN_READY'] -const REVIEW_PLAN_TRIGGERABLE_FROM: IdeaStatus[] = ['PLAN_READY', 'PLAN_REVIEWED'] - -export async function startGrillJobAction(id: string): Promise<ActionResult<{ job_id: string }>> { - return startIdeaJob(id, 'IDEA_GRILL', 'GRILLING', GRILL_TRIGGERABLE_FROM) -} - -export async function startMakePlanJobAction(id: string): Promise<ActionResult<{ job_id: string }>> { - return startIdeaJob(id, 'IDEA_MAKE_PLAN', 'PLANNING', MAKE_PLAN_TRIGGERABLE_FROM) -} - -export async function startReviewPlanJobAction(id: string): Promise<ActionResult<{ job_id: string }>> { - return startIdeaJob(id, 'IDEA_REVIEW_PLAN', 'REVIEWING_PLAN', REVIEW_PLAN_TRIGGERABLE_FROM) -} - -async function startIdeaJob( - id: string, - kind: ClaudeJobKind, - newStatus: IdeaStatus, - allowedFrom: IdeaStatus[], -): Promise<ActionResult<{ job_id: string }>> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 401 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const limited = enforceUserRateLimit('start-idea-job', session.userId) - if (limited) return limited - - // Laad idee + product (voor repo_url-validatie) - const idea = await prisma.idea.findFirst({ - where: { id, user_id: session.userId }, - select: { - id: true, - status: true, - product_id: true, - product: { select: { id: true, repo_url: true } }, - }, - }) - if (!idea) return { error: 'Idee niet gevonden', code: 404 } - if (!allowedFrom.includes(idea.status)) { - return { - error: `Actie niet toegestaan in status ${idea.status}`, - code: 422, - } - } - if (!canTransition(idea.status, newStatus)) { - return { error: `Status-transitie ${idea.status}→${newStatus} ongeldig`, code: 422 } - } - - // Product-met-repo verplicht (M12 grill-keuze 3) - if (!idea.product_id || !idea.product?.repo_url) { - return { - error: 'Idee moet gekoppeld zijn aan een product met repo_url voordat je dit kunt starten.', - code: 422, - } - } - - // Idempotency: weiger als er al een actieve job loopt voor dit idee. - const existing = await prisma.claudeJob.findFirst({ - where: { idea_id: id, status: { in: ACTIVE_JOB_STATUSES } }, - select: { id: true }, - }) - if (existing) { - return { - error: 'Er loopt al een actieve agent voor dit idee.', - code: 409, - details: { job_id: existing.id }, - } - } - - // Worker-presence — server-side check, naast UI-side disabled-rule. - const workers = await countActiveWorkers(session.userId) - if (workers === 0) { - return { - error: 'Geen Claude-worker actief. Start een lokale wait_for_job-loop en probeer opnieuw.', - code: 422, - } - } - - const ideaSnapshot = await getJobConfigSnapshot({ kind, productId: idea.product_id! }) - - // Atomic: create job + flip idea-status + log. - const job = await prisma.$transaction(async (tx) => { - const j = await tx.claudeJob.create({ - data: { - user_id: session.userId, - product_id: idea.product_id!, - idea_id: id, - kind, - status: 'QUEUED', - ...ideaSnapshot, - }, - select: { id: true }, - }) - await tx.idea.update({ where: { id }, data: { status: newStatus } }) - await tx.ideaLog.create({ - data: { - idea_id: id, - type: 'JOB_EVENT', - content: `${kind} queued`, - metadata: { job_id: j.id, kind }, - }, - }) - return j - }) - - // Manual pg_notify zoals enqueueClaudeJobAction in actions/claude-jobs.ts. - await prisma.$executeRaw` - SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ - type: 'claude_job_enqueued', - job_id: job.id, - idea_id: id, - user_id: session.userId, - product_id: idea.product_id, - kind, - status: 'queued', - })}::text) - ` - - revalidatePath('/ideas') - revalidatePath(`/ideas/${id}`) - return { success: true, data: { job_id: job.id } } -} - -export async function cancelIdeaJobAction(id: string): Promise<ActionResult> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 401 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const idea = await prisma.idea.findFirst({ - where: { id, user_id: session.userId }, - select: { id: true, status: true, grill_md: true, plan_md: true }, - }) - if (!idea) return { error: 'Idee niet gevonden', code: 404 } - - // Vind de actieve job — meest recente in QUEUED|CLAIMED|RUNNING. - const job = await prisma.claudeJob.findFirst({ - where: { idea_id: id, status: { in: ACTIVE_JOB_STATUSES } }, - orderBy: { created_at: 'desc' }, - select: { id: true, kind: true }, - }) - if (!job) return { error: 'Geen actieve job om te annuleren', code: 404 } - - // Bepaal terugval-status. Bij een lopende grill: terug naar GRILLED als er - // al eerder grill_md was, anders DRAFT. Bij plan-job: PLAN_READY als er al - // plan_md was (re-plan-cancel), anders GRILLED. Bij review-plan: terug naar - // PLAN_READY (review kan altijd opnieuw gestart worden). - let revertStatus: IdeaStatus - if (job.kind === 'IDEA_GRILL') { - revertStatus = idea.grill_md ? 'GRILLED' : 'DRAFT' - } else if (job.kind === 'IDEA_MAKE_PLAN') { - revertStatus = idea.plan_md ? 'PLAN_READY' : 'GRILLED' - } else if (job.kind === 'IDEA_REVIEW_PLAN') { - revertStatus = 'PLAN_READY' - } else { - return { error: `Job kind ${job.kind} hoort niet bij een idee`, code: 422 } - } - - await prisma.$transaction([ - prisma.claudeJob.update({ - where: { id: job.id }, - data: { status: 'CANCELLED', finished_at: new Date(), error: 'user_cancelled' }, - }), - prisma.idea.update({ where: { id }, data: { status: revertStatus } }), - prisma.ideaLog.create({ - data: { - idea_id: id, - type: 'JOB_EVENT', - content: `${job.kind} cancelled by user`, - metadata: { job_id: job.id, revert_status: revertStatus }, - }, - }), - ]) - - await prisma.$executeRaw` - SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ - type: 'claude_job_status', - job_id: job.id, - idea_id: id, - user_id: session.userId, - kind: job.kind, - status: 'cancelled', - })}::text) - ` - - revalidatePath('/ideas') - revalidatePath(`/ideas/${id}`) - return { success: true } -} - -// --------------------------------------------------------------------------- -// Materialize: parse plan_md → INSERT PBI + stories + taken (atomic) - -const PBI_AUTO_RE = /^PBI-(\d+)$/ -const STORY_AUTO_RE = /^ST-(\d+)$/ -const TASK_AUTO_RE = /^T-(\d+)$/ - -function nextNumber(existing: (string | null)[], re: RegExp): number { - let max = 0 - for (const c of existing) { - if (!c) continue - const m = c.match(re) - if (m) { - const n = Number.parseInt(m[1], 10) - if (!Number.isNaN(n) && n > max) max = n - } - } - return max + 1 -} - -export async function materializeIdeaPlanAction( - id: string, - options?: { allowAlongside?: boolean }, -): Promise<ActionResult<{ pbi_id: string; pbi_code: string; story_ids: string[]; task_ids: string[] }>> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 401 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const limited = enforceUserRateLimit('materialize-idea', session.userId) - if (limited) return limited - - const idea = await prisma.idea.findFirst({ - where: { id, user_id: session.userId }, - select: { id: true, status: true, product_id: true, plan_md: true, pbi_id: true }, - }) - if (!idea) return { error: 'Idee niet gevonden', code: 404 } - if (idea.status !== 'PLAN_READY') { - return { - error: `Materialiseren alleen toegestaan in PLAN_READY (huidige status: ${idea.status})`, - code: 422, - } - } - if (!idea.product_id) { - return { error: 'Idee mist een gekoppeld product', code: 422 } - } - if (!idea.plan_md) { - return { error: 'Idee heeft geen plan_md', code: 422 } - } - - const parsed = parsePlanMd(idea.plan_md) - if (!parsed.ok) { - return { error: 'plan_md is niet parseerbaar', code: 422, details: parsed.errors } - } - - const productId = idea.product_id - const plan = parsed.plan - - let oldPbiId: string | null = null - if (idea.pbi_id) { - const executedCount = await prisma.task.count({ - where: { - story: { pbi_id: idea.pbi_id }, - status: { in: ['DONE', 'IN_PROGRESS'] }, - }, - }) - if (executedCount > 0 && !options?.allowAlongside) { - const existingPbi = await prisma.pbi.findUnique({ - where: { id: idea.pbi_id }, - select: { code: true }, - }) - return { - error: `PBI_HAS_ACTIVE_TASKS:${existingPbi?.code ?? idea.pbi_id}`, - code: 409, - } - } - if (executedCount === 0) { - oldPbiId = idea.pbi_id - } - // executedCount > 0 && allowAlongside: doorgaan zonder delete - } - - try { - const result = await prisma.$transaction(async (tx) => { - if (oldPbiId) { - await tx.pbi.delete({ where: { id: oldPbiId } }) - } - - // Codes: één keer SELECT max per type binnen de transactie. Bij P2002 - // (race met andere materialize) abort de transactie en gooien we 409. - const [existingPbis, existingStories, existingTasks] = await Promise.all([ - tx.pbi.findMany({ where: { product_id: productId }, select: { code: true } }), - tx.story.findMany({ where: { product_id: productId }, select: { code: true } }), - tx.task.findMany({ where: { product_id: productId }, select: { code: true } }), - ]) - let nextPbiN = nextNumber(existingPbis.map((p) => p.code), PBI_AUTO_RE) - let nextStoryN = nextNumber(existingStories.map((s) => s.code), STORY_AUTO_RE) - let nextTaskN = nextNumber(existingTasks.map((t) => t.code), TASK_AUTO_RE) - - // sort_order: vraag de huidige max binnen het product op (per priority) - const lastPbi = await tx.pbi.findFirst({ - where: { product_id: productId, priority: plan.pbi.priority }, - orderBy: { sort_order: 'desc' }, - select: { sort_order: true }, - }) - const pbiSortOrder = (lastPbi?.sort_order ?? 0) + 1.0 - - const pbi = await tx.pbi.create({ - data: { - product_id: productId, - code: `PBI-${nextPbiN++}`, - title: plan.pbi.title, - description: plan.pbi.description ?? null, - priority: plan.pbi.priority, - sort_order: pbiSortOrder, - }, - select: { id: true, code: true }, - }) - - const storyIds: string[] = [] - const taskIds: string[] = [] - - for (let si = 0; si < plan.stories.length; si++) { - const s = plan.stories[si] - const storyCode = `ST-${String(nextStoryN++).padStart(3, '0')}` - const story = await tx.story.create({ - data: { - pbi_id: pbi.id, - product_id: productId, - code: storyCode, - title: s.title, - description: s.description ?? null, - acceptance_criteria: s.acceptance_criteria ?? null, - priority: s.priority, - sort_order: parseCodeNumber(storyCode), - status: 'OPEN', - }, - select: { id: true }, - }) - storyIds.push(story.id) - - for (let ti = 0; ti < s.tasks.length; ti++) { - const t = s.tasks[ti] - const taskCode = `T-${nextTaskN++}` - const task = await tx.task.create({ - data: { - story_id: story.id, - product_id: productId, - code: taskCode, - title: t.title, - description: t.description ?? null, - implementation_plan: t.implementation_plan ?? null, - // Erf priority van de story zodat YAML-volgorde gerespecteerd - // blijft. Worker sorteert op `priority ASC, sort_order ASC`; - // gemixte task-priorities binnen één story zouden anders de - // YAML-volgorde verstoren (zie plan-fix task-volgorde-na-upload). - priority: s.priority, - sort_order: parseCodeNumber(taskCode), - status: 'TO_DO', - verify_required: t.verify_required ?? 'ALIGNED_OR_PARTIAL', - verify_only: t.verify_only ?? false, - }, - select: { id: true }, - }) - taskIds.push(task.id) - } - } - - // Link idea → PBI + status PLANNED - await tx.idea.update({ - where: { id }, - data: { pbi_id: pbi.id, status: 'PLANNED' }, - }) - - // Audit log - await tx.ideaLog.create({ - data: { - idea_id: id, - type: 'PLAN_RESULT', - content: `Materialized into ${pbi.code} (${plan.stories.length} stories, ${taskIds.length} tasks)`, - metadata: { - pbi_id: pbi.id, - pbi_code: pbi.code, - story_count: storyIds.length, - task_count: taskIds.length, - }, - }, - }) - - return { pbi_id: pbi.id, pbi_code: pbi.code, story_ids: storyIds, task_ids: taskIds } - }) - - revalidatePath(`/ideas/${id}`) - revalidatePath(`/products/${productId}/backlog`) - return { success: true, data: result } - } catch (err) { - // P2002 op code = race met andere materialize. Andere fouten = bug. - const msg = err instanceof Error ? err.message : String(err) - if (msg.includes('P2002') || msg.includes('Unique constraint')) { - return { - error: 'Code-conflict tijdens materialiseren (race). Probeer opnieuw.', - code: 409, - } - } - throw err - } -} - -// --------------------------------------------------------------------------- -// Re-link: een idee in PLANNED waarvan de PBI handmatig is verwijderd -// (Pbi.id → null door de SetNull-FK). Gebruiker klikt expliciet "Re-link plan" -// om terug naar PLAN_READY te gaan en eventueel opnieuw te materialiseren. - -export async function relinkIdeaPlanAction(id: string): Promise<ActionResult> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 401 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const idea = await prisma.idea.findFirst({ - where: { id, user_id: session.userId }, - select: { id: true, status: true, pbi_id: true }, - }) - if (!idea) return { error: 'Idee niet gevonden', code: 404 } - if (idea.status !== 'PLANNED' || idea.pbi_id !== null) { - return { - error: 'Re-link kan alleen wanneer status=PLANNED én PBI is verwijderd', - code: 422, - } - } - - await prisma.$transaction([ - prisma.idea.update({ where: { id }, data: { status: 'PLAN_READY' } }), - prisma.ideaLog.create({ - data: { - idea_id: id, - type: 'NOTE', - content: 'PBI was deleted; relinked to PLAN_READY', - }, - }), - ]) - - revalidatePath(`/ideas/${id}`) - return { success: true } -} - -// --------------------------------------------------------------------------- -// Helpers - -type IdeaSelect = Array<keyof Idea> - -async function loadOwnedIdea<S extends IdeaSelect>( - id: string, - userId: string, - fields: S, -): Promise<Pick<Idea, S[number]> | null> { - const select = Object.fromEntries(fields.map((f) => [f, true])) as { - [K in S[number]]: true - } - return prisma.idea.findFirst({ - where: { id, user_id: userId }, - select, - }) as Promise<Pick<Idea, S[number]> | null> -} diff --git a/actions/jobs-page.ts b/actions/jobs-page.ts deleted file mode 100644 index 22876a5..0000000 --- a/actions/jobs-page.ts +++ /dev/null @@ -1,35 +0,0 @@ -'use server' - -import { prisma } from '@/lib/prisma' -import { getSession } from '@/lib/auth' -import { JOB_INCLUDE, mapJob, buildPriceMap } from '@/lib/jobs-mapper' -import type { RawJob, JobWithRelations, PriceRow } from '@/lib/jobs-mapper' - -export type { JobWithRelations } from '@/lib/jobs-mapper' - -export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelations[]; doneJobs: JobWithRelations[] } | null> { - const session = await getSession() - if (!session.userId) return null - - const [active, done, prices] = await Promise.all([ - prisma.claudeJob.findMany({ - where: { user_id: session.userId, status: { notIn: ['DONE'] } }, - include: JOB_INCLUDE, - orderBy: { created_at: 'desc' }, - }), - prisma.claudeJob.findMany({ - where: { user_id: session.userId, status: 'DONE' }, - include: JOB_INCLUDE, - orderBy: { created_at: 'desc' }, - take: 100, - }), - prisma.modelPrice.findMany(), - ]) - - const priceMap = buildPriceMap(prices as unknown as PriceRow[]) - - return { - activeJobs: active.map((j) => mapJob(j as unknown as RawJob, priceMap)), - doneJobs: done.map((j) => mapJob(j as unknown as RawJob, priceMap)), - } -} diff --git a/actions/pbis.ts b/actions/pbis.ts index 2d7aeb1..f2221e8 100644 --- a/actions/pbis.ts +++ b/actions/pbis.ts @@ -3,28 +3,44 @@ import { revalidatePath } from 'next/cache' import { cookies } from 'next/headers' import { getIronSession } from 'iron-session' +import { z } from 'zod' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { getAccessibleProduct } from '@/lib/product-access' -import { isValidCode, normalizeCode } from '@/lib/code' +import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code' import { createWithCodeRetry, generateNextPbiCode } from '@/lib/code-server' import { pbiStatusFromApi } from '@/lib/task-status' -import { createPbiSchema, updatePbiSchema } from '@/lib/schemas/pbi' -import { enforceUserRateLimit } from '@/lib/rate-limit' async function getSession() { return getIronSession<SessionData>(await cookies(), sessionOptions) } -type PbiFieldErrors = Record<string, string[]> +const codeField = z.string().max(MAX_CODE_LENGTH).optional() + +const statusField = z.enum(['ready', 'blocked', 'done']).optional() + +const createPbiSchema = z.object({ + productId: z.string(), + code: codeField, + title: z.string().min(1, 'Titel is verplicht').max(200), + description: z.string().max(2000).optional(), + priority: z.coerce.number().int().min(1).max(4), + status: statusField, +}) + +const updatePbiSchema = z.object({ + id: z.string(), + code: codeField, + title: z.string().min(1, 'Titel is verplicht').max(200), + description: z.string().max(2000).optional(), + priority: z.coerce.number().int().min(1).max(4), + status: statusField, +}) export async function createPbiAction(_prevState: unknown, formData: FormData) { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 403 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const limited = enforceUserRateLimit('create-pbi', session.userId) - if (limited) return limited + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const parsed = createPbiSchema.safeParse({ productId: formData.get('productId'), @@ -34,34 +50,18 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) { priority: formData.get('priority'), status: (formData.get('status') as string) || undefined, }) - if (!parsed.success) { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: parsed.error.flatten().fieldErrors as PbiFieldErrors, - } - } + if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } const product = await getAccessibleProduct(parsed.data.productId, session.userId) - if (!product) return { error: 'Product niet gevonden', code: 403 } + if (!product) return { error: 'Product niet gevonden' } const manualCode = normalizeCode(parsed.data.code) if (manualCode !== null && !isValidCode(manualCode)) { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] } as PbiFieldErrors, - } + return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } } } if (manualCode) { const dup = await prisma.pbi.findFirst({ where: { product_id: parsed.data.productId, code: manualCode } }) - if (dup) { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: { code: ['Deze code is al in gebruik binnen dit product'] } as PbiFieldErrors, - } - } + if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } } } const last = await prisma.pbi.findFirst({ @@ -72,7 +72,7 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) { const status = parsed.data.status ? pbiStatusFromApi(parsed.data.status) ?? undefined : undefined - const insert = (code: string) => + const insert = (code: string | null) => prisma.pbi.create({ data: { product_id: parsed.data.productId, @@ -94,11 +94,7 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) { (code) => insert(code), ) } catch { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: { code: ['Kon geen unieke code genereren — probeer opnieuw'] } as PbiFieldErrors, - } + return { error: { code: ['Kon geen unieke code genereren — probeer opnieuw'] } } } revalidatePath(`/products/${parsed.data.productId}`) @@ -107,8 +103,8 @@ export async function createPbiAction(_prevState: unknown, formData: FormData) { export async function updatePbiAction(_prevState: unknown, formData: FormData) { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 403 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const parsed = updatePbiSchema.safeParse({ id: formData.get('id'), @@ -118,41 +114,25 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) { priority: formData.get('priority'), status: (formData.get('status') as string) || undefined, }) - if (!parsed.success) { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: parsed.error.flatten().fieldErrors as PbiFieldErrors, - } - } + if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } const pbi = await prisma.pbi.findFirst({ where: { id: parsed.data.id }, include: { product: true }, }) - if (!pbi) return { error: 'PBI niet gevonden', code: 403 } + if (!pbi) return { error: 'PBI niet gevonden' } const accessible = await getAccessibleProduct(pbi.product_id, session.userId) - if (!accessible) return { error: 'PBI niet gevonden', code: 403 } + if (!accessible) return { error: 'PBI niet gevonden' } const code = normalizeCode(parsed.data.code) if (code !== null && !isValidCode(code)) { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] } as PbiFieldErrors, - } + return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } } } if (code) { const dup = await prisma.pbi.findFirst({ where: { product_id: pbi.product_id, code, NOT: { id: parsed.data.id } }, }) - if (dup) { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: { code: ['Deze code is al in gebruik binnen dit product'] } as PbiFieldErrors, - } - } + if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } } } const status = parsed.data.status ? pbiStatusFromApi(parsed.data.status) ?? undefined : undefined @@ -160,7 +140,7 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) { await prisma.pbi.update({ where: { id: parsed.data.id }, data: { - ...(code ? { code } : {}), + code, title: parsed.data.title, description: parsed.data.description ?? null, priority: parsed.data.priority, @@ -174,16 +154,16 @@ export async function updatePbiAction(_prevState: unknown, formData: FormData) { export async function deletePbiAction(id: string) { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 403 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const pbi = await prisma.pbi.findFirst({ where: { id }, include: { product: true }, }) - if (!pbi) return { error: 'PBI niet gevonden', code: 403 } + if (!pbi) return { error: 'PBI niet gevonden' } const accessible = await getAccessibleProduct(pbi.product_id, session.userId) - if (!accessible) return { error: 'PBI niet gevonden', code: 403 } + if (!accessible) return { error: 'PBI niet gevonden' } await prisma.pbi.delete({ where: { id } }) diff --git a/actions/products.ts b/actions/products.ts index 4292269..892569f 100644 --- a/actions/products.ts +++ b/actions/products.ts @@ -9,12 +9,8 @@ import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { Role } from '@prisma/client' import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code' -import { productAccessFilter } from '@/lib/product-access' -import { productSchema as productInput, type ProductInput } from '@/lib/schemas/product' -import { enforceUserRateLimit } from '@/lib/rate-limit' -// Legacy FormData schema for ProductForm components (other constraints than dialog) -const productFormDataSchema = z.object({ +const productSchema = z.object({ name: z.string().min(1, 'Naam is verplicht').max(100, 'Naam mag maximaal 100 tekens bevatten'), code: z .string() @@ -32,141 +28,16 @@ const productFormDataSchema = z.object({ .max(500, 'Definition of Done mag maximaal 500 tekens bevatten'), }) -type ProductFieldErrors = Partial<Record<keyof ProductInput, string[]>> -type ProductActionResult = - | { success: true; productId: string } - | { error: string; code?: number; fieldErrors?: ProductFieldErrors } -type UpdateProductResult = - | { success: true } - | { error: string; code?: number; fieldErrors?: ProductFieldErrors } - async function getSession() { return getIronSession<SessionData>(await cookies(), sessionOptions) } -// Data-object API used by ProductDialog -export async function createProductAction(data: ProductInput): Promise<ProductActionResult> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 403 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const limited = enforceUserRateLimit('create-product', session.userId) - if (limited) return limited - - const parsed = productInput.safeParse(data) - if (!parsed.success) { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: parsed.error.flatten().fieldErrors as ProductFieldErrors, - } - } - - const code = normalizeCode(parsed.data.code) - if (code !== null && !isValidCode(code)) { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] }, - } - } - - if (code) { - const dup = await prisma.product.findFirst({ where: { user_id: session.userId, code } }) - if (dup) return { error: 'Validatie mislukt', code: 422, fieldErrors: { code: ['Code is al in gebruik'] } } - } - - const userId = session.userId - const product = await prisma.$transaction(async (tx) => { - const p = await tx.product.create({ - data: { - user_id: userId, - name: parsed.data.name, - code: code ?? null, - description: parsed.data.description ?? null, - repo_url: parsed.data.repo_url ?? null, - definition_of_done: parsed.data.definition_of_done ?? '', - auto_pr: parsed.data.auto_pr, - }, - }) - await tx.productMember.create({ data: { product_id: p.id, user_id: userId } }) - return p - }) - - revalidatePath('/products') - revalidatePath('/dashboard') - return { success: true, productId: product.id } -} - -// Data-object API used by ProductDialog -export async function updateProductAction(id: string, data: ProductInput): Promise<UpdateProductResult> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 403 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const parsed = productInput.safeParse(data) - if (!parsed.success) { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: parsed.error.flatten().fieldErrors as ProductFieldErrors, - } - } - - const product = await prisma.product.findFirst({ - where: { id, ...productAccessFilter(session.userId) }, - select: { id: true }, - }) - if (!product) return { error: 'Product niet gevonden of geen toegang', code: 403 } - - const code = normalizeCode(parsed.data.code) - if (code !== null && !isValidCode(code)) { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] }, - } - } - - const userId = session.userId - - await prisma.product.update({ - where: { id }, - data: { - name: parsed.data.name, - code: code ?? null, - description: parsed.data.description ?? null, - repo_url: parsed.data.repo_url ?? null, - ...(parsed.data.definition_of_done !== undefined && { - definition_of_done: parsed.data.definition_of_done, - }), - auto_pr: parsed.data.auto_pr, - }, - }) - - await prisma.$executeRaw` - SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ - type: 'product_updated', - product_id: id, - user_id: userId, - })}::text) - ` - - revalidatePath(`/products/${id}`) - revalidatePath('/dashboard') - return { success: true } -} - -// FormData-based actions for existing ProductForm components -export async function createProductFormAction(_prevState: unknown, formData: FormData) { +export async function createProductAction(_prevState: unknown, formData: FormData) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - const limited = enforceUserRateLimit('create-product', session.userId) - if (limited) return limited - - const parsed = productFormDataSchema.safeParse({ + const parsed = productSchema.safeParse({ name: formData.get('name'), code: (formData.get('code') as string) || undefined, description: formData.get('description') || undefined, @@ -207,7 +78,7 @@ export async function createProductFormAction(_prevState: unknown, formData: For redirect(`/products/${product.id}`) } -export async function updateProductFormAction(_prevState: unknown, formData: FormData) { +export async function updateProductAction(_prevState: unknown, formData: FormData) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } @@ -215,7 +86,7 @@ export async function updateProductFormAction(_prevState: unknown, formData: For const id = formData.get('id') as string if (!id) return { error: 'Product niet gevonden' } - const parsed = productFormDataSchema.safeParse({ + const parsed = productSchema.safeParse({ name: formData.get('name'), code: (formData.get('code') as string) || undefined, description: formData.get('description') || undefined, @@ -366,7 +237,6 @@ export async function removeProductMemberAction(productId: string, memberId: str export async function leaveProductAction(productId: string) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } await prisma.$transaction([ prisma.user.updateMany({ @@ -396,27 +266,3 @@ export async function updateAutoPrAction(id: string, auto_pr: boolean) { revalidatePath(`/products/${id}/settings`) return { success: true } } - -export async function updatePrStrategyAction( - id: string, - pr_strategy: 'SPRINT' | 'STORY' | 'SPRINT_BATCH', -) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - const parsed = z - .object({ pr_strategy: z.enum(['SPRINT', 'STORY', 'SPRINT_BATCH']) }) - .safeParse({ pr_strategy }) - if (!parsed.success) return { error: 'Ongeldige waarde voor pr_strategy' } - - const product = await prisma.product.findFirst({ where: { id, user_id: session.userId } }) - if (!product) return { error: 'Product niet gevonden' } - - await prisma.product.update({ - where: { id }, - data: { pr_strategy: parsed.data.pr_strategy }, - }) - revalidatePath(`/products/${id}/settings`) - return { success: true } -} diff --git a/actions/push.ts b/actions/push.ts deleted file mode 100644 index ec9a216..0000000 --- a/actions/push.ts +++ /dev/null @@ -1,52 +0,0 @@ -'use server' - -import { z } from 'zod' -import { prisma } from '@/lib/prisma' -import { getSession } from '@/lib/auth' - -const subscribeSchema = z.object({ - endpoint: z.string().url(), - keys: z.object({ - p256dh: z.string().min(1), - auth: z.string().min(1), - }), - userAgent: z.string().optional(), -}) - -export type SubscribeToPushInput = z.infer<typeof subscribeSchema> - -export async function subscribeToPushAction(input: SubscribeToPushInput): Promise<void> { - const session = await getSession() - if (!session.userId) return - if (session.isDemo) return - - const parsed = subscribeSchema.safeParse(input) - if (!parsed.success) return - - const { endpoint, keys, userAgent } = parsed.data - await prisma.pushSubscription.upsert({ - where: { endpoint }, - create: { - user_id: session.userId, - endpoint, - p256dh: keys.p256dh, - auth: keys.auth, - user_agent: userAgent ?? null, - }, - update: { - user_id: session.userId, - p256dh: keys.p256dh, - auth: keys.auth, - last_used_at: new Date(), - }, - }) -} - -export async function unsubscribeFromPushAction(args: { endpoint: string }): Promise<void> { - const session = await getSession() - if (!session.userId) return - - await prisma.pushSubscription.deleteMany({ - where: { endpoint: args.endpoint, user_id: session.userId }, - }) -} diff --git a/actions/questions.ts b/actions/questions.ts index 5a35f10..19a45bc 100644 --- a/actions/questions.ts +++ b/actions/questions.ts @@ -12,11 +12,15 @@ // realtime updates voor andere clients. import { revalidatePath } from 'next/cache' +import { z } from 'zod' import { prisma } from '@/lib/prisma' import { getSession } from '@/lib/auth' import { productAccessFilter } from '@/lib/product-access' -import { answerQuestionSchema } from '@/lib/schemas/question-answer' -import { enforceUserRateLimit } from '@/lib/rate-limit' + +const inputSchema = z.object({ + questionId: z.string().cuid(), + answer: z.string().min(1).max(4000), +}) type ActionResult = { ok: true } | { ok: false; error: string } @@ -28,52 +32,24 @@ export async function answerQuestion( if (!session.userId) return { ok: false, error: 'Niet ingelogd' } if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' } - const limited = enforceUserRateLimit('answer-question', session.userId) - if (limited) return { ok: false, error: limited.error } - - const parsed = answerQuestionSchema.safeParse({ questionId, answer }) + const parsed = inputSchema.safeParse({ questionId, answer }) if (!parsed.success) { const first = parsed.error.issues[0]?.message ?? 'Ongeldige invoer' return { ok: false, error: first } } - // Access-check (M12-aware): - // - Story-questions: iedereen met product-membership mag antwoorden - // (consistent met Scrum self-organizing). - // - Idea-questions: strikt user_id-only — alleen de eigenaar van het - // gekoppelde idee mag antwoorden (M12 grill-keuze 8). - // App-level routing omdat Prisma 7 `{ not: null }` filters in WHERE niet - // accepteert; we fetchen de relevante FK-keys en checken in TS. + // Access-check: gebruiker moet toegang hebben tot het product van de vraag. + // Iedereen met product-membership mag antwoorden — niet alleen de story- + // assignee — consistent met Scrum self-organizing. const question = await prisma.claudeQuestion.findFirst({ - where: { id: parsed.data.questionId }, - select: { - id: true, - story_id: true, - idea_id: true, - product_id: true, - idea: { select: { user_id: true } }, + where: { + id: parsed.data.questionId, + product: productAccessFilter(session.userId), }, + select: { id: true }, }) if (!question) return { ok: false, error: 'Vraag niet gevonden of geen toegang' } - if (question.idea_id) { - // Idea-question: alleen idea-eigenaar. - if (question.idea?.user_id !== session.userId) { - return { ok: false, error: 'Vraag niet gevonden of geen toegang' } - } - } else if (question.story_id) { - // Story-question: bestaand product-access-pad. - const productAccess = await prisma.product.findFirst({ - where: { id: question.product_id, ...productAccessFilter(session.userId) }, - select: { id: true }, - }) - if (!productAccess) { - return { ok: false, error: 'Vraag niet gevonden of geen toegang' } - } - } else { - return { ok: false, error: 'Vraag heeft geen story of idea' } - } - // Atomic state-transitie: alleen open + niet-verlopen vragen worden beantwoord. // Concurrent dubbele submit: PostgreSQL row-locking laat één caller count=1 // zien, de rest count=0 → disambiguatie hieronder. diff --git a/actions/settings.ts b/actions/settings.ts deleted file mode 100644 index 17b8a8e..0000000 --- a/actions/settings.ts +++ /dev/null @@ -1,49 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' -import { cookies } from 'next/headers' -import { getIronSession } from 'iron-session' -import { prisma } from '@/lib/prisma' -import { SessionData, sessionOptions } from '@/lib/session' -import { minQuotaPctSchema } from '@/lib/schemas/user' - -async function getSession() { - return getIronSession<SessionData>(await cookies(), sessionOptions) -} - -export async function updateRolesAction(roles: string[]) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - const validRoles = ['PRODUCT_OWNER', 'SCRUM_MASTER', 'DEVELOPER'] - const filtered = roles.filter(r => validRoles.includes(r)) - if (filtered.length === 0) return { error: 'Minimaal één rol is verplicht' } - - await prisma.$transaction([ - prisma.userRole.deleteMany({ where: { user_id: session.userId } }), - prisma.userRole.createMany({ - data: filtered.map(role => ({ user_id: session.userId, role: role as 'PRODUCT_OWNER' | 'SCRUM_MASTER' | 'DEVELOPER' })), - }), - ]) - - revalidatePath('/settings') - return { success: true } -} - -export async function updateMinQuotaPctAction(value: number) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', status: 403 } - - const parsed = minQuotaPctSchema.safeParse(value) - if (!parsed.success) return { error: 'Waarde moet tussen 1 en 100 liggen', status: 422 } - - await prisma.user.update({ - where: { id: session.userId }, - data: { min_quota_pct: parsed.data }, - }) - - revalidatePath('/settings') - return { success: true } -} diff --git a/actions/sprint-draft.ts b/actions/sprint-draft.ts deleted file mode 100644 index 37beb54..0000000 --- a/actions/sprint-draft.ts +++ /dev/null @@ -1,121 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' -import { cookies } from 'next/headers' -import { getIronSession } from 'iron-session' -import { z } from 'zod' -import type { Prisma } from '@prisma/client' -import { prisma } from '@/lib/prisma' -import { SessionData, sessionOptions } from '@/lib/session' -import { productAccessFilter } from '@/lib/product-access' -import { - mergeSettings, - parseUserSettings, - type PendingSprintDraft, - type UserSettings, -} from '@/lib/user-settings' - -async function getSession() { - return getIronSession<SessionData>(await cookies(), sessionOptions) -} - -const StoryOverridesSchema = z.object({ - add: z.array(z.string()), - remove: z.array(z.string()), -}).strict() - -const DraftSchema = z.object({ - goal: z.string().min(1), - startAt: z.string().date().optional(), - endAt: z.string().date().optional(), - pbiIntent: z.record(z.string(), z.enum(['all', 'none'])).default({}), - storyOverrides: z.record(z.string(), StoryOverridesSchema).default({}), -}).strict() - -const SetSchema = z.object({ - productId: z.string().min(1), - draft: DraftSchema, -}) - -const ClearSchema = z.object({ - productId: z.string().min(1), -}) - -async function ensureProductAccess(userId: string, productId: string) { - return prisma.product.findFirst({ - where: { id: productId, ...productAccessFilter(userId) }, - select: { id: true }, - }) -} - -async function readUserSettings(userId: string): Promise<UserSettings> { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { settings: true }, - }) - return parseUserSettings(user?.settings) -} - -async function writeUserSettings(userId: string, next: UserSettings) { - await prisma.user.update({ - where: { id: userId }, - data: { settings: next as unknown as Prisma.InputJsonValue }, - }) -} - -export async function setPendingSprintDraftAction( - productId: string, - draft: PendingSprintDraft, -) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - const parsed = SetSchema.safeParse({ productId, draft }) - if (!parsed.success) { - return { error: 'Ongeldige draft', issues: parsed.error.issues } - } - - const product = await ensureProductAccess(session.userId, parsed.data.productId) - if (!product) return { error: 'Product niet gevonden of niet toegankelijk' } - - const current = await readUserSettings(session.userId) - const patch: Partial<UserSettings> = { - workflow: { - pendingSprintDraft: { - ...(current.workflow?.pendingSprintDraft ?? {}), - [parsed.data.productId]: parsed.data.draft, - }, - }, - } - await writeUserSettings(session.userId, mergeSettings(current, patch)) - revalidatePath('/', 'layout') - return { success: true } -} - -export async function clearPendingSprintDraftAction(productId: string) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - const parsed = ClearSchema.safeParse({ productId }) - if (!parsed.success) return { error: 'Ongeldig product-id' } - - const product = await ensureProductAccess(session.userId, parsed.data.productId) - if (!product) return { error: 'Product niet gevonden of niet toegankelijk' } - - const current = await readUserSettings(session.userId) - const existingMap = current.workflow?.pendingSprintDraft - if (!existingMap || !(parsed.data.productId in existingMap)) { - return { success: true } - } - const nextMap = { ...existingMap } - delete nextMap[parsed.data.productId] - const next: UserSettings = { - ...current, - workflow: { ...current.workflow, pendingSprintDraft: nextMap }, - } - await writeUserSettings(session.userId, next) - revalidatePath('/', 'layout') - return { success: true } -} diff --git a/actions/sprint-runs.ts b/actions/sprint-runs.ts deleted file mode 100644 index 8b232d0..0000000 --- a/actions/sprint-runs.ts +++ /dev/null @@ -1,494 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' -import { cookies } from 'next/headers' -import { getIronSession } from 'iron-session' -import { z } from 'zod' -import { Prisma } from '@prisma/client' -import { prisma } from '@/lib/prisma' -import { SessionData, sessionOptions } from '@/lib/session' -import { parsePauseContext } from '@/lib/pause-context' -import { getJobConfigSnapshot } from '@/lib/job-config-snapshot' - -async function getSession() { - return getIronSession<SessionData>(await cookies(), sessionOptions) -} - -export type PreFlightBlockerType = - | 'task_no_plan' - | 'open_question' - | 'pbi_blocked' - | 'task_cross_repo' - -export interface PreFlightBlocker { - type: PreFlightBlockerType - id: string - label: string -} - -const StartSprintRunInput = z.object({ sprint_id: z.string().min(1) }) -const ResumeSprintInput = z.object({ sprint_id: z.string().min(1) }) -const CancelSprintRunInput = z.object({ sprint_run_id: z.string().min(1) }) - -interface StartResultOk { - ok: true - sprint_run_id: string - jobs_count: number -} - -interface StartResultBlocked { - ok: false - error: 'PRE_FLIGHT_BLOCKED' - blockers: PreFlightBlocker[] -} - -interface ErrorResult { - ok: false - error: string - code: number -} - -type StartResult = StartResultOk | StartResultBlocked | ErrorResult - -// startSprintRunCore is gedeeld tussen startSprintRunAction en resumeSprintAction. -// Voert de pre-flight uit, maakt een SprintRun + ClaudeJobs (in PBI→Story→Task -// volgorde) binnen één transactie. Aanroeper levert sprint_id, user_id en de -// transactionele Prisma-client. -async function startSprintRunCore( - tx: Prisma.TransactionClient, - sprint_id: string, - user_id: string, -): Promise<StartResultOk | StartResultBlocked | ErrorResult> { - const sprint = await tx.sprint.findUnique({ - where: { id: sprint_id }, - include: { product: true }, - }) - if (!sprint) return { ok: false, error: 'SPRINT_NOT_FOUND', code: 404 } - if (sprint.status !== 'OPEN') - return { ok: false, error: 'SPRINT_NOT_ACTIVE', code: 400 } - - const activeRun = await tx.sprintRun.findFirst({ - where: { - sprint_id, - status: { in: ['QUEUED', 'RUNNING', 'PAUSED'] }, - }, - }) - if (activeRun) - return { ok: false, error: 'SPRINT_RUN_ALREADY_ACTIVE', code: 409 } - - const stories = await tx.story.findMany({ - where: { sprint_id, status: { not: 'DONE' } }, - include: { - pbi: true, - tasks: { - // EXCLUDED-taken worden hier impliciet uitgesloten: de filter is strikt - // TO_DO, dus EXCLUDED/IN_PROGRESS/REVIEW/DONE/FAILED tasks komen niet - // terecht in pre-flight blockers, jobs of SprintTaskExecution-rijen. - where: { status: 'TO_DO' }, - orderBy: [{ sort_order: 'asc' }], - }, - }, - orderBy: [{ sort_order: 'asc' }], - }) - - const blockers: PreFlightBlocker[] = [] - - for (const s of stories) { - for (const t of s.tasks) { - if (!t.implementation_plan) { - blockers.push({ - type: 'task_no_plan', - id: t.id, - label: `${t.code}: ${t.title}`, - }) - } - } - } - - const openQuestions = await tx.claudeQuestion.findMany({ - where: { story: { sprint_id }, status: 'open' }, - select: { id: true, question: true }, - }) - for (const q of openQuestions) { - blockers.push({ - type: 'open_question', - id: q.id, - label: q.question.slice(0, 80), - }) - } - - const seenPbi = new Set<string>() - for (const s of stories) { - if (seenPbi.has(s.pbi.id)) continue - seenPbi.add(s.pbi.id) - if (s.pbi.status === 'BLOCKED' || s.pbi.status === 'FAILED') { - blockers.push({ - type: 'pbi_blocked', - id: s.pbi.id, - label: `${s.pbi.code}: ${s.pbi.title}`, - }) - } - } - - // PBI-50: SPRINT_BATCH cross-repo blocker. Eén product-worktree = - // alle tasks moeten in product.repo_url werken; task.repo_url-override - // is incompatibel met deze flow. - if (sprint.product.pr_strategy === 'SPRINT_BATCH') { - for (const s of stories) { - for (const t of s.tasks) { - if (t.repo_url && t.repo_url !== sprint.product.repo_url) { - blockers.push({ - type: 'task_cross_repo', - id: t.id, - label: `${t.code}: ${t.title}`, - }) - } - } - } - } - - if (blockers.length > 0) { - return { ok: false, error: 'PRE_FLIGHT_BLOCKED', blockers } - } - - const sprintRun = await tx.sprintRun.create({ - data: { - sprint_id, - started_by_id: user_id, - status: 'QUEUED', - pr_strategy: sprint.product.pr_strategy, - started_at: new Date(), - }, - }) - - const orderedTasks = stories - .slice() - .sort( - (a, b) => - a.pbi.priority - b.pbi.priority || - a.pbi.sort_order - b.pbi.sort_order || - a.sort_order - b.sort_order, - ) - .flatMap((s) => s.tasks) - - // PBI-50: SPRINT_BATCH levert één SPRINT_IMPLEMENTATION-job die alle - // tasks in één claude-sessie afhandelt. SprintTaskExecution-rows worden - // server-side bij claim aangemaakt zodat order/base_sha consistent zijn - // met de worktree-state op claim-tijd. - if (sprint.product.pr_strategy === 'SPRINT_BATCH') { - const sprintSnapshot = await getJobConfigSnapshot({ - kind: 'SPRINT_IMPLEMENTATION', - productId: sprint.product_id, - }) - await tx.claudeJob.create({ - data: { - user_id, - product_id: sprint.product_id, - task_id: null, - idea_id: null, - sprint_run_id: sprintRun.id, - kind: 'SPRINT_IMPLEMENTATION', - status: 'QUEUED', - ...sprintSnapshot, - }, - }) - return { ok: true, sprint_run_id: sprintRun.id, jobs_count: 1 } - } - - // STORY / SPRINT (per-task): bestaand pad. Snapshot per task zodat - // task.requires_opus de cascade kan overrulen. - for (const t of orderedTasks) { - const taskSnapshot = await getJobConfigSnapshot({ - kind: 'TASK_IMPLEMENTATION', - productId: sprint.product_id, - taskId: t.id, - }) - await tx.claudeJob.create({ - data: { - user_id, - product_id: sprint.product_id, - task_id: t.id, - sprint_run_id: sprintRun.id, - kind: 'TASK_IMPLEMENTATION', - status: 'QUEUED', - ...taskSnapshot, - }, - }) - } - - return { ok: true, sprint_run_id: sprintRun.id, jobs_count: orderedTasks.length } -} - -export async function startSprintRunAction(input: unknown): Promise<StartResult> { - const session = await getSession() - if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 } - if (session.isDemo) - return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 } - - const parsed = StartSprintRunInput.safeParse(input) - if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 } - - const userId = session.userId - const result = await prisma.$transaction((tx) => - startSprintRunCore(tx, parsed.data.sprint_id, userId), - ) - - if (result.ok) { - revalidatePath(`/sprints/${parsed.data.sprint_id}`) - } - return result -} - -export async function resumeSprintAction(input: unknown): Promise<StartResult> { - const session = await getSession() - if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 } - if (session.isDemo) - return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 } - - const parsed = ResumeSprintInput.safeParse(input) - if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 } - - const userId = session.userId - const sprint_id = parsed.data.sprint_id - - const result = await prisma.$transaction(async (tx) => { - const sprint = await tx.sprint.findUnique({ where: { id: sprint_id } }) - if (!sprint) - return { ok: false as const, error: 'SPRINT_NOT_FOUND', code: 404 } - if (sprint.status !== 'FAILED') - return { ok: false as const, error: 'SPRINT_NOT_FAILED', code: 400 } - - // Sprint terug naar ACTIVE - await tx.sprint.update({ - where: { id: sprint_id }, - data: { status: 'OPEN', completed_at: null }, - }) - - // FAILED stories binnen sprint terug naar IN_SPRINT (DONE blijft) - await tx.story.updateMany({ - where: { sprint_id, status: 'FAILED' }, - data: { status: 'IN_SPRINT' }, - }) - - // PBIs van die stories: FAILED → READY (BLOCKED met rust laten) - const storyPbiIds = ( - await tx.story.findMany({ - where: { sprint_id }, - select: { pbi_id: true }, - distinct: ['pbi_id'], - }) - ).map((s) => s.pbi_id) - await tx.pbi.updateMany({ - where: { id: { in: storyPbiIds }, status: 'FAILED' }, - data: { status: 'READY' }, - }) - - // FAILED tasks → TO_DO (DONE blijft) - await tx.task.updateMany({ - where: { story: { sprint_id }, status: 'FAILED' }, - data: { status: 'TO_DO' }, - }) - - return startSprintRunCore(tx, sprint_id, userId) - }) - - if (result.ok) { - revalidatePath(`/sprints/${sprint_id}`) - } - return result -} - -const ResumePausedSprintRunInput = z.object({ sprint_run_id: z.string().min(1) }) - -interface ResumePausedResultOk { - ok: true -} - -type ResumePausedResult = ResumePausedResultOk | ErrorResult - -export async function resumePausedSprintRunAction( - input: unknown, -): Promise<ResumePausedResult> { - const session = await getSession() - if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 } - if (session.isDemo) - return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 } - - const parsed = ResumePausedSprintRunInput.safeParse(input) - if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 } - - const sprint_run_id = parsed.data.sprint_run_id - - const userId = session.userId - const result = await prisma.$transaction(async (tx) => { - const run = await tx.sprintRun.findUnique({ - where: { id: sprint_run_id }, - select: { - id: true, - status: true, - sprint_id: true, - pr_strategy: true, - branch: true, - pause_context: true, - }, - }) - if (!run) return { ok: false as const, error: 'SPRINT_RUN_NOT_FOUND', code: 404 } - if (run.status !== 'PAUSED') - return { ok: false as const, error: 'SPRINT_RUN_NOT_PAUSED', code: 400 } - - const ctx = parsePauseContext(run.pause_context) - if (ctx) { - await tx.claudeQuestion.updateMany({ - where: { id: ctx.claude_question_id, status: 'open' }, - data: { status: 'closed' }, - }) - } - - // PBI-50: SPRINT_BATCH resume-pad — als de SprintRun hangt aan een - // SPRINT_IMPLEMENTATION-job en er nog onafgemaakte SprintTaskExecution-rows - // zijn (PENDING/RUNNING), maak NIEUWE SprintRun met previous_run_id + - // hergebruikte branch + nieuwe SPRINT_IMPLEMENTATION-job. Oude SprintRun - // gaat naar CANCELLED. - const sprintJob = await tx.claudeJob.findFirst({ - where: { sprint_run_id, kind: 'SPRINT_IMPLEMENTATION' }, - select: { id: true, product_id: true }, - }) - if (sprintJob) { - const remaining = await tx.sprintTaskExecution.count({ - where: { - sprint_job_id: sprintJob.id, - status: { in: ['PENDING', 'RUNNING'] }, - }, - }) - if (remaining > 0) { - const newRun = await tx.sprintRun.create({ - data: { - sprint_id: run.sprint_id, - started_by_id: userId, - status: 'QUEUED', - pr_strategy: run.pr_strategy, - branch: run.branch, - previous_run_id: run.id, - started_at: new Date(), - }, - }) - const resumeSnapshot = await getJobConfigSnapshot({ - kind: 'SPRINT_IMPLEMENTATION', - productId: sprintJob.product_id, - }) - await tx.claudeJob.create({ - data: { - user_id: userId, - product_id: sprintJob.product_id, - task_id: null, - idea_id: null, - sprint_run_id: newRun.id, - kind: 'SPRINT_IMPLEMENTATION', - status: 'QUEUED', - ...resumeSnapshot, - }, - }) - await tx.sprintRun.update({ - where: { id: sprint_run_id }, - data: { - status: 'CANCELLED', - pause_context: Prisma.JsonNull, - finished_at: new Date(), - }, - }) - return { ok: true as const, sprint_id: run.sprint_id, finalStatus: 'QUEUED' as const } - } - } - - const activeClaims = await tx.claudeJob.count({ - where: { sprint_run_id, status: { in: ['CLAIMED', 'RUNNING'] } }, - }) - const queuedJobs = await tx.claudeJob.count({ - where: { sprint_run_id, status: 'QUEUED' }, - }) - - // PBI-49 P0: een STORY auto-merge MERGE_CONFLICT komt NA dat alle tasks - // al DONE zijn. Terug naar QUEUED zou de SprintRun voor altijd laten - // hangen — geen QUEUED job. Bij volledige scope-completion transitie - // direct naar DONE; de dev heeft het conflict opgelost, de PR is van hen. - let nextStatus: 'RUNNING' | 'QUEUED' | 'DONE' - let finishedAt: Date | undefined - if (activeClaims === 0 && queuedJobs === 0) { - nextStatus = 'DONE' - finishedAt = new Date() - } else if (activeClaims > 0) { - nextStatus = 'RUNNING' - } else { - nextStatus = 'QUEUED' - } - - await tx.sprintRun.update({ - where: { id: sprint_run_id }, - data: { - status: nextStatus, - pause_context: Prisma.JsonNull, - ...(finishedAt ? { finished_at: finishedAt } : {}), - }, - }) - - return { ok: true as const, sprint_id: run.sprint_id, finalStatus: nextStatus } - }) - - if (result.ok && 'sprint_id' in result) { - revalidatePath(`/sprints/${result.sprint_id}`) - return { ok: true } - } - return result -} - -interface CancelResultOk { - ok: true -} - -type CancelResult = CancelResultOk | ErrorResult - -export async function cancelSprintRunAction(input: unknown): Promise<CancelResult> { - const session = await getSession() - if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 } - if (session.isDemo) - return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 } - - const parsed = CancelSprintRunInput.safeParse(input) - if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 } - - const sprint_run_id = parsed.data.sprint_run_id - - const result = await prisma.$transaction(async (tx) => { - const run = await tx.sprintRun.findUnique({ where: { id: sprint_run_id } }) - if (!run) - return { ok: false as const, error: 'SPRINT_RUN_NOT_FOUND', code: 404 } - if (!['QUEUED', 'RUNNING', 'PAUSED'].includes(run.status)) - return { ok: false as const, error: 'SPRINT_RUN_NOT_CANCELLABLE', code: 400 } - - await tx.sprintRun.update({ - where: { id: sprint_run_id }, - data: { status: 'CANCELLED', finished_at: new Date() }, - }) - - // Cancel openstaande task-jobs binnen deze run. - // Tasks/Stories/PBIs/Sprint blijven hun status — cancel ≠ fail. - await tx.claudeJob.updateMany({ - where: { - sprint_run_id, - status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] }, - }, - data: { - status: 'CANCELLED', - finished_at: new Date(), - }, - }) - - return { ok: true as const, sprint_id: run.sprint_id } - }) - - if (result.ok && 'sprint_id' in result) { - revalidatePath(`/sprints/${result.sprint_id}`) - return { ok: true } - } - return result -} diff --git a/actions/sprints.ts b/actions/sprints.ts index 8ccc80e..8eb2292 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -3,369 +3,10 @@ import { revalidatePath } from 'next/cache' import { cookies } from 'next/headers' import { getIronSession } from 'iron-session' +import { z } from 'zod' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access' -import { - createSprintSchema, - updateSprintDatesSchema, - updateSprintGoalSchema, -} from '@/lib/schemas/sprint' -import { enforceUserRateLimit } from '@/lib/rate-limit' -import { propagateStatusUpwards } from '@/lib/tasks-status-update' -import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server' -import { setActiveSprintInSettings } from '@/lib/active-sprint' -import { partitionByEligibility } from '@/lib/sprint-conflicts' -import { z } from 'zod' - -const StoryOverrideSchema = z.object({ - add: z.array(z.string()), - remove: z.array(z.string()), -}) - -const createSprintWithSelectionSchema = z.object({ - productId: z.string().min(1), - metadata: z.object({ - goal: z.string().min(1).max(2000), - startAt: z.string().date().optional(), - endAt: z.string().date().optional(), - }), - pbiIntent: z.record(z.string(), z.enum(['all', 'none'])).default({}), - storyOverrides: z.record(z.string(), StoryOverrideSchema).default({}), -}) - -export type CreateSprintWithSelectionInput = z.infer< - typeof createSprintWithSelectionSchema -> - -type SprintCreateConflicts = { - notEligible: { storyId: string; reason: 'DONE' | 'IN_OTHER_SPRINT' }[] - crossSprint: { storyId: string; sprintId: string; sprintName: string }[] -} - -export type CreateSprintWithSelectionResult = - | { - success: true - sprintId: string - affectedStoryIds: string[] - affectedPbiIds: string[] - affectedTaskIds: string[] - conflicts: SprintCreateConflicts - } - | { error: string; code: number } - -const updateSprintSchema = z.object({ - sprintId: z.string().min(1), - fields: z - .object({ - goal: z.string().min(1).max(2000).optional(), - startAt: z.string().date().nullable().optional(), - endAt: z.string().date().nullable().optional(), - }) - .refine( - (data) => Object.keys(data).length > 0, - 'Minstens één veld vereist', - ), -}) - -export type UpdateSprintInput = z.infer<typeof updateSprintSchema> - -export type UpdateSprintResult = - | { success: true; sprintId: string } - | { error: string; code: number } - -export async function updateSprintAction( - input: UpdateSprintInput, -): Promise<UpdateSprintResult> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 403 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const parsed = updateSprintSchema.safeParse(input) - if (!parsed.success) return { error: 'Validatie mislukt', code: 422 } - - const sprint = await prisma.sprint.findFirst({ - where: { - id: parsed.data.sprintId, - product: productAccessFilter(session.userId), - }, - select: { id: true, product_id: true }, - }) - if (!sprint) return { error: 'Sprint niet gevonden', code: 403 } - - const data: { sprint_goal?: string; start_date?: Date | null; end_date?: Date | null } = {} - if (parsed.data.fields.goal !== undefined) { - data.sprint_goal = parsed.data.fields.goal - } - if (parsed.data.fields.startAt !== undefined) { - data.start_date = parseDate(parsed.data.fields.startAt) - } - if (parsed.data.fields.endAt !== undefined) { - data.end_date = parseDate(parsed.data.fields.endAt) - } - - await prisma.sprint.update({ - where: { id: parsed.data.sprintId }, - data, - }) - revalidatePath(`/products/${sprint.product_id}`, 'layout') - - return { success: true, sprintId: parsed.data.sprintId } -} - -const commitSprintMembershipSchema = z.object({ - activeSprintId: z.string().min(1), - adds: z.array(z.string()), - removes: z.array(z.string()), -}) - -export type CommitSprintMembershipInput = z.infer< - typeof commitSprintMembershipSchema -> - -type CommitConflicts = { - notEligible: { storyId: string; reason: 'DONE' | 'IN_OTHER_SPRINT' }[] - alreadyRemoved: string[] -} - -export type CommitSprintMembershipResult = - | { - success: true - affectedStoryIds: string[] - affectedPbiIds: string[] - affectedTaskIds: string[] - conflicts: CommitConflicts - } - | { error: string; code: number } - -export async function commitSprintMembershipAction( - input: CommitSprintMembershipInput, -): Promise<CommitSprintMembershipResult> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 403 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const parsed = commitSprintMembershipSchema.safeParse(input) - if (!parsed.success) return { error: 'Validatie mislukt', code: 422 } - - // Sprint moet bestaan en bereikbaar zijn via product-access. - const sprint = await prisma.sprint.findFirst({ - where: { - id: parsed.data.activeSprintId, - product: productAccessFilter(session.userId), - }, - select: { id: true, product_id: true }, - }) - if (!sprint) { - return { error: 'Sprint niet gevonden of niet toegankelijk', code: 403 } - } - - // Filter adds via eligibility (sprint_id IS NULL en niet DONE; andere OPEN - // sprint → conflicts.notEligible + crossSprint). - const addPartition = await partitionByEligibility( - prisma, - parsed.data.adds, - parsed.data.activeSprintId, - ) - const eligibleAdds = addPartition.eligible - const notEligibleAdds = addPartition.notEligible - - // Race-safety voor removes: alleen stories die feitelijk in de actieve - // sprint zitten worden verwijderd. - const removeRows = - parsed.data.removes.length > 0 - ? await prisma.story.findMany({ - where: { - id: { in: parsed.data.removes }, - sprint_id: parsed.data.activeSprintId, - }, - select: { id: true }, - }) - : [] - const validRemoves = removeRows.map((r) => r.id) - const validRemoveSet = new Set(validRemoves) - const alreadyRemoved = parsed.data.removes.filter( - (id) => !validRemoveSet.has(id), - ) - - if (eligibleAdds.length === 0 && validRemoves.length === 0) { - // Geen werk te doen — geef toch een success-shape terug zodat de client - // pending buffer kan resetten + conflicts kan tonen. - return { - success: true, - affectedStoryIds: [], - affectedPbiIds: [], - affectedTaskIds: [], - conflicts: { notEligible: notEligibleAdds, alreadyRemoved }, - } - } - - await prisma.$transaction(async (tx) => { - if (eligibleAdds.length > 0) { - await tx.story.updateMany({ - where: { id: { in: eligibleAdds } }, - data: { sprint_id: parsed.data.activeSprintId, status: 'IN_SPRINT' }, - }) - await tx.task.updateMany({ - where: { story_id: { in: eligibleAdds } }, - data: { sprint_id: parsed.data.activeSprintId }, - }) - } - if (validRemoves.length > 0) { - await tx.story.updateMany({ - where: { id: { in: validRemoves } }, - data: { sprint_id: null, status: 'OPEN' }, - }) - await tx.task.updateMany({ - where: { story_id: { in: validRemoves } }, - data: { sprint_id: null }, - }) - } - }) - - const affectedStoryIds = [...eligibleAdds, ...validRemoves] - const affectedStories = - affectedStoryIds.length > 0 - ? await prisma.story.findMany({ - where: { id: { in: affectedStoryIds } }, - select: { pbi_id: true }, - }) - : [] - const affectedPbiIds = Array.from( - new Set(affectedStories.map((s) => s.pbi_id)), - ) - const affectedTasks = - affectedStoryIds.length > 0 - ? await prisma.task.findMany({ - where: { story_id: { in: affectedStoryIds } }, - select: { id: true }, - }) - : [] - const affectedTaskIds = affectedTasks.map((t) => t.id) - - revalidatePath(`/products/${sprint.product_id}`, 'layout') - - return { - success: true, - affectedStoryIds, - affectedPbiIds, - affectedTaskIds, - conflicts: { notEligible: notEligibleAdds, alreadyRemoved }, - } -} - -export async function createSprintWithSelectionAction( - input: CreateSprintWithSelectionInput, -): Promise<CreateSprintWithSelectionResult> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 403 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const limited = enforceUserRateLimit('create-sprint', session.userId) - if (limited) return { error: limited.error, code: limited.code } - - const parsed = createSprintWithSelectionSchema.safeParse(input) - if (!parsed.success) return { error: 'Validatie mislukt', code: 422 } - - const product = await getAccessibleProduct(parsed.data.productId, session.userId) - if (!product) return { error: 'Product niet gevonden', code: 403 } - - // Resolveer intent + per-PBI overrides naar concrete story-IDs. - const allPbiAllIds = Object.entries(parsed.data.pbiIntent) - .filter(([, intent]) => intent === 'all') - .map(([pbiId]) => pbiId) - - // Stap 1: alle child-stories voor PBI's met intent='all'. - let candidate: string[] = [] - if (allPbiAllIds.length > 0) { - const rows = await prisma.story.findMany({ - where: { pbi_id: { in: allPbiAllIds }, product_id: parsed.data.productId }, - select: { id: true, pbi_id: true }, - }) - const removedSet = new Set<string>() - for (const [pbiId, override] of Object.entries(parsed.data.storyOverrides)) { - for (const id of override.remove) removedSet.add(`${pbiId}:${id}`) - } - candidate = rows - .filter((row) => !removedSet.has(`${row.pbi_id}:${row.id}`)) - .map((row) => row.id) - } - - // Stap 2: storyOverrides.add — werkt voor zowel intent='none' als 'all' (extra - // toevoegingen). Dedupliceren met candidates uit stap 1. - const candidateSet = new Set(candidate) - for (const override of Object.values(parsed.data.storyOverrides)) { - for (const id of override.add) candidateSet.add(id) - } - const candidateIds = Array.from(candidateSet) - - // Eligibility-filter (incl. cross-sprint guard). - const partition = await partitionByEligibility(prisma, candidateIds) - - if (partition.eligible.length === 0) { - return { - error: 'Geen eligible stories voor deze sprint', - code: 422, - } - } - - const sprint = await createWithCodeRetry( - () => generateNextSprintCode(parsed.data.productId), - (code) => - prisma.$transaction(async (tx) => { - const created = await tx.sprint.create({ - data: { - product_id: parsed.data.productId, - code, - sprint_goal: parsed.data.metadata.goal, - status: 'OPEN', - start_date: parseDate(parsed.data.metadata.startAt), - end_date: parseDate(parsed.data.metadata.endAt), - }, - }) - await tx.story.updateMany({ - where: { id: { in: partition.eligible } }, - data: { sprint_id: created.id, status: 'IN_SPRINT' }, - }) - await tx.task.updateMany({ - where: { story_id: { in: partition.eligible } }, - data: { sprint_id: created.id }, - }) - return created - }), - ) - - // Snapshot affected pbi/task IDs voor client-store patches. - const affectedStories = await prisma.story.findMany({ - where: { id: { in: partition.eligible } }, - select: { pbi_id: true }, - }) - const affectedPbiIds = Array.from(new Set(affectedStories.map((s) => s.pbi_id))) - const affectedTasks = await prisma.task.findMany({ - where: { story_id: { in: partition.eligible } }, - select: { id: true }, - }) - const affectedTaskIds = affectedTasks.map((t) => t.id) - - await setActiveSprintInSettings( - session.userId, - parsed.data.productId, - sprint.id, - ) - revalidatePath(`/products/${parsed.data.productId}`, 'layout') - - return { - success: true, - sprintId: sprint.id, - affectedStoryIds: partition.eligible, - affectedPbiIds, - affectedTaskIds, - conflicts: { - notEligible: partition.notEligible, - crossSprint: partition.crossSprint, - }, - } -} async function getSession() { return getIronSession<SessionData>(await cookies(), sessionOptions) @@ -375,110 +16,74 @@ function hasDuplicateIds(ids: string[]) { return new Set(ids).size !== ids.length } -type SprintFieldErrors = Record<string, string[]> +const dateField = z.string().optional().nullable().transform(v => (v && v.trim() !== '' ? new Date(v) : null)) + +function validateDateOrder(data: { start_date: Date | null; end_date: Date | null }, ctx: z.RefinementCtx) { + if (data.start_date && data.end_date && data.end_date < data.start_date) { + ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['end_date'], message: 'Einddatum moet na startdatum liggen' }) + } +} export async function createSprintAction(_prevState: unknown, formData: FormData) { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 403 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - const limited = enforceUserRateLimit('create-sprint', session.userId) - if (limited) return limited - - const parsed = createSprintSchema.safeParse({ + const parsed = z.object({ + productId: z.string(), + sprint_goal: z.string().min(1, 'Sprint Goal is verplicht').max(500), + start_date: dateField, + end_date: dateField, + }).superRefine(validateDateOrder).safeParse({ productId: formData.get('productId'), sprint_goal: formData.get('sprint_goal'), start_date: formData.get('start_date'), end_date: formData.get('end_date'), - pbi_id: formData.get('pbi_id'), }) - if (!parsed.success) { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: parsed.error.flatten().fieldErrors as SprintFieldErrors, - } - } + if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } const product = await getAccessibleProduct(parsed.data.productId, session.userId) - if (!product) return { error: 'Product niet gevonden', code: 403 } + if (!product) return { error: 'Product niet gevonden' } - // PBI-79 / ST-1342: multi-OPEN sprints toegestaan. Bestaande OPEN sprints - // op hetzelfde product zijn geen reden meer om aanmaak te blokkeren — - // cross-sprint-conflicts worden per-story afgevangen in de membership- - // commit-flow. + const existing = await prisma.sprint.findFirst({ + where: { product_id: parsed.data.productId, status: 'ACTIVE' }, + }) + if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id } - const sprint = await createWithCodeRetry( - () => generateNextSprintCode(parsed.data.productId), - (code) => - prisma.sprint.create({ - data: { - product_id: parsed.data.productId, - code, - sprint_goal: parsed.data.sprint_goal, - status: 'OPEN', - start_date: parsed.data.start_date, - end_date: parsed.data.end_date, - }, - }), - ) + const sprint = await prisma.sprint.create({ + data: { + product_id: parsed.data.productId, + sprint_goal: parsed.data.sprint_goal, + status: 'ACTIVE', + start_date: parsed.data.start_date, + end_date: parsed.data.end_date, + }, + }) - if (parsed.data.pbi_id) { - const pbi = await prisma.pbi.findFirst({ - where: { id: parsed.data.pbi_id, product_id: parsed.data.productId }, - select: { id: true }, - }) - if (pbi) { - const stories = await prisma.story.findMany({ - where: { pbi_id: pbi.id, sprint_id: null }, - orderBy: [{ sort_order: 'asc' }], - select: { id: true }, - }) - if (stories.length > 0) { - const storyIds = stories.map(s => s.id) - await prisma.$transaction([ - ...stories.map((s, i) => - prisma.story.update({ - where: { id: s.id }, - data: { sprint_id: sprint.id, status: 'IN_SPRINT' }, - }), - ), - prisma.task.updateMany({ - where: { story_id: { in: storyIds }, sprint_id: null }, - data: { sprint_id: sprint.id }, - }), - ]) - } - } - } - - await setActiveSprintInSettings(session.userId, parsed.data.productId, sprint.id) - revalidatePath(`/products/${parsed.data.productId}`, 'layout') + revalidatePath(`/products/${parsed.data.productId}`) return { success: true, sprintId: sprint.id } } export async function updateSprintDatesAction(_prevState: unknown, formData: FormData) { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 403 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - const parsed = updateSprintDatesSchema.safeParse({ + const parsed = z.object({ + id: z.string(), + start_date: dateField, + end_date: dateField, + }).superRefine(validateDateOrder).safeParse({ id: formData.get('id'), start_date: formData.get('start_date'), end_date: formData.get('end_date'), }) - if (!parsed.success) { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: parsed.error.flatten().fieldErrors as SprintFieldErrors, - } - } + if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } const sprint = await prisma.sprint.findFirst({ where: { id: parsed.data.id, product: productAccessFilter(session.userId) }, }) - if (!sprint) return { error: 'Sprint niet gevonden', code: 403 } + if (!sprint) return { error: 'Sprint niet gevonden' } await prisma.sprint.update({ where: { id: parsed.data.id }, @@ -490,27 +95,19 @@ export async function updateSprintDatesAction(_prevState: unknown, formData: For export async function updateSprintGoalAction(_prevState: unknown, formData: FormData) { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 403 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - const parsed = updateSprintGoalSchema.safeParse({ - id: formData.get('id'), - sprint_goal: formData.get('sprint_goal'), - }) - if (!parsed.success) { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: parsed.error.flatten().fieldErrors as SprintFieldErrors, - } - } + const id = formData.get('id') as string + const sprint_goal = formData.get('sprint_goal') as string + if (!sprint_goal?.trim()) return { error: 'Sprint Goal is verplicht' } const sprint = await prisma.sprint.findFirst({ - where: { id: parsed.data.id, product: productAccessFilter(session.userId) }, + where: { id, product: productAccessFilter(session.userId) }, }) - if (!sprint) return { error: 'Sprint niet gevonden', code: 403 } + if (!sprint) return { error: 'Sprint niet gevonden' } - await prisma.sprint.update({ where: { id: parsed.data.id }, data: { sprint_goal: parsed.data.sprint_goal } }) + await prisma.sprint.update({ where: { id }, data: { sprint_goal } }) revalidatePath(`/products/${sprint.product_id}/sprint`) return { success: true } } @@ -531,9 +128,14 @@ export async function addStoryToSprintAction(sprintId: string, storyId: string) if (!story) return { error: 'Story niet gevonden' } if (story.product_id !== sprint.product_id) return { error: 'Story hoort niet bij deze Sprint' } + const last = await prisma.story.findFirst({ + where: { sprint_id: sprintId }, + orderBy: { sort_order: 'desc' }, + }) + await prisma.story.update({ where: { id: storyId }, - data: { sprint_id: sprintId, status: 'IN_SPRINT' }, + data: { sprint_id: sprintId, status: 'IN_SPRINT', sort_order: (last?.sort_order ?? 0) + 1.0 }, }) revalidatePath(`/products/${sprint.product_id}/sprint`) @@ -562,6 +164,32 @@ export async function removeStoryFromSprintAction(storyId: string) { return { success: true } } +export async function reorderSprintStoriesAction(sprintId: string, orderedIds: string[]) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (hasDuplicateIds(orderedIds)) return { error: 'Ongeldige Sprint Backlog-volgorde' } + + const sprint = await prisma.sprint.findFirst({ + where: { id: sprintId, product: productAccessFilter(session.userId) }, + }) + if (!sprint) return { error: 'Sprint niet gevonden' } + + const stories = await prisma.story.findMany({ + where: { id: { in: orderedIds }, sprint_id: sprintId, product_id: sprint.product_id }, + select: { id: true }, + }) + if (stories.length !== orderedIds.length) return { error: 'Ongeldige Sprint Backlog-volgorde' } + + await prisma.$transaction( + orderedIds.map((id, i) => + prisma.story.update({ where: { id }, data: { sort_order: i + 1.0 } }) + ) + ) + + revalidatePath(`/products/${sprint.product_id}/sprint`) + return { success: true } +} export async function completeSprintAction( sprintId: string, @@ -623,7 +251,7 @@ export async function completeSprintAction( ), prisma.sprint.update({ where: { id: sprintId }, - data: { status: 'CLOSED', completed_at: new Date() }, + data: { status: 'COMPLETED', completed_at: new Date() }, }), ]) @@ -631,104 +259,3 @@ export async function completeSprintAction( revalidatePath(`/products/${sprint.product_id}/sprint`) return { success: true } } - -export async function setAllSprintTasksDoneAction( - sprintId: string, -): Promise<{ ok: true } | { ok: false; error: string }> { - const session = await getSession() - if (!session.userId) return { ok: false, error: 'Niet ingelogd' } - if (session.isDemo) return { ok: false, error: 'Niet beschikbaar in demo-modus' } - - const sprint = await prisma.sprint.findFirst({ - where: { id: sprintId, product: productAccessFilter(session.userId) }, - select: { id: true, product_id: true }, - }) - if (!sprint) return { ok: false, error: 'Sprint niet gevonden' } - - const tasks = await prisma.task.findMany({ - where: { sprint_id: sprintId, product_id: sprint.product_id }, - select: { id: true }, - }) - - await prisma.$transaction(async (tx) => { - for (const task of tasks) { - await propagateStatusUpwards(task.id, 'DONE', tx) - } - }) - - revalidatePath(`/products/${sprint.product_id}/sprint`) - return { ok: true } -} - -const createSprintWithPbisSchema = z.object({ - productId: z.string().min(1), - sprint_goal: z.string().min(1).max(2000), - start_date: z.string().nullable().optional(), - end_date: z.string().nullable().optional(), - pbi_ids: z.array(z.string().min(1)).min(1), -}) - -function parseDate(value: string | null | undefined): Date | null { - if (!value) return null - const d = new Date(value) - return Number.isNaN(d.getTime()) ? null : d -} - -export async function createSprintWithPbisAction(input: { - productId: string - sprint_goal: string - start_date?: string | null - end_date?: string | null - pbi_ids: string[] -}): Promise<{ success: true; sprintId: string } | { error: string; code: number }> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 403 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const limited = enforceUserRateLimit('create-sprint', session.userId) - if (limited) return { error: limited.error, code: limited.code } - - const parsed = createSprintWithPbisSchema.safeParse(input) - if (!parsed.success) return { error: 'Validatie mislukt', code: 422 } - - const product = await getAccessibleProduct(parsed.data.productId, session.userId) - if (!product) return { error: 'Product niet gevonden', code: 403 } - - const pbis = await prisma.pbi.findMany({ - where: { id: { in: parsed.data.pbi_ids }, product_id: parsed.data.productId }, - select: { id: true }, - }) - if (pbis.length !== parsed.data.pbi_ids.length) { - return { error: "Een of meer PBI's behoren niet tot dit product", code: 422 } - } - - const sprint = await createWithCodeRetry( - () => generateNextSprintCode(parsed.data.productId), - (code) => - prisma.$transaction(async (tx) => { - const created = await tx.sprint.create({ - data: { - product_id: parsed.data.productId, - code, - sprint_goal: parsed.data.sprint_goal, - status: 'OPEN', - start_date: parseDate(parsed.data.start_date), - end_date: parseDate(parsed.data.end_date), - }, - }) - await tx.story.updateMany({ - where: { pbi_id: { in: parsed.data.pbi_ids } }, - data: { sprint_id: created.id, status: 'IN_SPRINT' }, - }) - await tx.task.updateMany({ - where: { story: { pbi_id: { in: parsed.data.pbi_ids } } }, - data: { sprint_id: created.id }, - }) - return created - }), - ) - - await setActiveSprintInSettings(session.userId, parsed.data.productId, sprint.id) - revalidatePath(`/products/${parsed.data.productId}`, 'layout') - return { success: true, sprintId: sprint.id } -} diff --git a/actions/stories.ts b/actions/stories.ts index dbac04a..cb18a3d 100644 --- a/actions/stories.ts +++ b/actions/stories.ts @@ -3,21 +3,18 @@ import { revalidatePath } from 'next/cache' import { cookies } from 'next/headers' import { getIronSession } from 'iron-session' +import { z } from 'zod' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access' import { requireProductWriter } from '@/lib/auth' -import { isValidCode, normalizeCode, parseCodeNumber } from '@/lib/code' +import { isValidCode, MAX_CODE_LENGTH, normalizeCode } from '@/lib/code' import { createWithCodeRetry, generateNextStoryCode } from '@/lib/code-server' -import { createStorySchema, updateStorySchema } from '@/lib/schemas/story' -import { enforceUserRateLimit } from '@/lib/rate-limit' async function getSession() { return getIronSession<SessionData>(await cookies(), sessionOptions) } -type StoryFieldErrors = Record<string, string[]> - async function verifyStoryAccess(storyId: string, userId: string) { return prisma.story.findFirst({ where: { id: storyId, product: productAccessFilter(userId) }, @@ -29,13 +26,31 @@ function hasDuplicateIds(ids: string[]) { return new Set(ids).size !== ids.length } +const codeField = z.string().max(MAX_CODE_LENGTH).optional() + +const createStorySchema = z.object({ + pbiId: z.string(), + productId: z.string(), + code: codeField, + title: z.string().min(1, 'Titel is verplicht').max(200), + description: z.string().max(2000).optional(), + acceptance_criteria: z.string().max(2000).optional(), + priority: z.coerce.number().int().min(1).max(4), +}) + +const updateStorySchema = z.object({ + id: z.string(), + code: codeField, + title: z.string().min(1, 'Titel is verplicht').max(200), + description: z.string().max(2000).optional(), + acceptance_criteria: z.string().max(2000).optional(), + priority: z.coerce.number().int().min(1).max(4), +}) + export async function createStoryAction(_prevState: unknown, formData: FormData) { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 403 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const limited = enforceUserRateLimit('create-story', session.userId) - if (limited) return limited + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const parsed = createStorySchema.safeParse({ pbiId: formData.get('pbiId'), @@ -46,39 +61,29 @@ export async function createStoryAction(_prevState: unknown, formData: FormData) acceptance_criteria: formData.get('acceptance_criteria') || undefined, priority: formData.get('priority') ?? 2, }) - if (!parsed.success) { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: parsed.error.flatten().fieldErrors as StoryFieldErrors, - } - } + if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } const pbi = await prisma.pbi.findFirst({ where: { id: parsed.data.pbiId, product: productAccessFilter(session.userId) }, }) - if (!pbi) return { error: 'PBI niet gevonden', code: 403 } + if (!pbi) return { error: 'PBI niet gevonden' } const manualCode = normalizeCode(parsed.data.code) if (manualCode !== null && !isValidCode(manualCode)) { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] } as StoryFieldErrors, - } + return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } } } if (manualCode) { const dup = await prisma.story.findFirst({ where: { product_id: pbi.product_id, code: manualCode } }) - if (dup) { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: { code: ['Deze code is al in gebruik binnen dit product'] } as StoryFieldErrors, - } - } + if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } } } - const insert = (code: string) => + const last = await prisma.story.findFirst({ + where: { pbi_id: parsed.data.pbiId, priority: parsed.data.priority }, + orderBy: { sort_order: 'desc' }, + }) + const sort_order = (last?.sort_order ?? 0) + 1.0 + + const insert = (code: string | null) => prisma.story.create({ data: { pbi_id: parsed.data.pbiId, @@ -88,7 +93,7 @@ export async function createStoryAction(_prevState: unknown, formData: FormData) description: parsed.data.description ?? null, acceptance_criteria: parsed.data.acceptance_criteria ?? null, priority: parsed.data.priority, - sort_order: parseCodeNumber(code), + sort_order, status: 'OPEN', }, }) @@ -102,11 +107,7 @@ export async function createStoryAction(_prevState: unknown, formData: FormData) (code) => insert(code), ) } catch { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: { code: ['Kon geen unieke code genereren — probeer opnieuw'] } as StoryFieldErrors, - } + return { error: { code: ['Kon geen unieke code genereren — probeer opnieuw'] } } } revalidatePath(`/products/${pbi.product_id}`) @@ -115,8 +116,8 @@ export async function createStoryAction(_prevState: unknown, formData: FormData) export async function updateStoryAction(_prevState: unknown, formData: FormData) { const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 403 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } const parsed = updateStorySchema.safeParse({ id: formData.get('id'), @@ -126,42 +127,26 @@ export async function updateStoryAction(_prevState: unknown, formData: FormData) acceptance_criteria: formData.get('acceptance_criteria') || undefined, priority: formData.get('priority'), }) - if (!parsed.success) { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: parsed.error.flatten().fieldErrors as StoryFieldErrors, - } - } + if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } const story = await verifyStoryAccess(parsed.data.id, session.userId) - if (!story) return { error: 'Story niet gevonden', code: 403 } + if (!story) return { error: 'Story niet gevonden' } const code = normalizeCode(parsed.data.code) if (code !== null && !isValidCode(code)) { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: { code: ['Alleen letters, cijfers, punten, koppeltekens of underscores'] } as StoryFieldErrors, - } + return { error: { code: ['Code mag alleen letters, cijfers, punten, koppeltekens of underscores bevatten'] } } } if (code) { const dup = await prisma.story.findFirst({ where: { product_id: story.product_id, code, NOT: { id: parsed.data.id } }, }) - if (dup) { - return { - error: 'Validatie mislukt', - code: 422, - fieldErrors: { code: ['Deze code is al in gebruik binnen dit product'] } as StoryFieldErrors, - } - } + if (dup) return { error: { code: ['Deze code is al in gebruik binnen dit product'] } } } await prisma.story.update({ where: { id: parsed.data.id }, data: { - ...(code ? { code, sort_order: parseCodeNumber(code) } : {}), + code, title: parsed.data.title, description: parsed.data.description ?? null, acceptance_criteria: parsed.data.acceptance_criteria ?? null, @@ -343,7 +328,7 @@ export async function claimAllUnassignedInActiveSprintAction(productId: string) const userId = session.userId const sprint = await prisma.sprint.findFirst({ - where: { product_id: productId, status: 'OPEN' }, + where: { product_id: productId, status: 'ACTIVE' }, }) if (!sprint) return { error: 'Geen actieve sprint gevonden' } @@ -357,3 +342,43 @@ export async function claimAllUnassignedInActiveSprintAction(productId: string) return { success: true, count: result.count } } +export async function reorderStoriesAction( + pbiId: string, + productId: string, + orderedIds: string[], + newPriority?: number +) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (hasDuplicateIds(orderedIds)) return { error: 'Ongeldige story-volgorde' } + if (newPriority !== undefined && (!Number.isInteger(newPriority) || newPriority < 1 || newPriority > 4)) { + return { error: 'Ongeldige prioriteit' } + } + + const pbi = await prisma.pbi.findFirst({ + where: { id: pbiId, product: productAccessFilter(session.userId) }, + }) + if (!pbi) return { error: 'PBI niet gevonden' } + + const stories = await prisma.story.findMany({ + where: { id: { in: orderedIds }, pbi_id: pbiId, product_id: pbi.product_id }, + select: { id: true }, + }) + if (stories.length !== orderedIds.length) return { error: 'Ongeldige story-volgorde' } + + await prisma.$transaction( + orderedIds.map((id, i) => + prisma.story.update({ + where: { id }, + data: { + sort_order: i + 1.0, + ...(newPriority !== undefined ? { priority: newPriority } : {}), + }, + }) + ) + ) + + revalidatePath(`/products/${pbi.product_id}`) + return { success: true } +} diff --git a/actions/tasks.ts b/actions/tasks.ts index 1a4ef45..d3cc5c6 100644 --- a/actions/tasks.ts +++ b/actions/tasks.ts @@ -9,10 +9,7 @@ import { SessionData, sessionOptions } from '@/lib/session' import { productAccessFilter } from '@/lib/product-access' import { requireProductWriter } from '@/lib/auth' import { taskSchema as sharedTaskSchema, type TaskInput } from '@/lib/schemas/task' -import { propagateStatusUpwards } from '@/lib/tasks-status-update' -import { normalizeCode, parseCodeNumber } from '@/lib/code' -import { createWithCodeRetry, generateNextTaskCode, isCodeUniqueConflict } from '@/lib/code-server' -import { enforceUserRateLimit } from '@/lib/rate-limit' +import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' async function getSession() { return getIronSession<SessionData>(await cookies(), sessionOptions) @@ -23,7 +20,6 @@ export type SaveTaskResult = | { ok: true; task: { id: string; title: string; status: string } } | { ok: false; code: 422; error: 'validation'; fieldErrors: Record<string, string[]> } | { ok: false; code: 403; error: 'demo_readonly' | 'forbidden' } - | { ok: false; code: 429; error: 'rate_limited' } | { ok: false; code: 500; error: 'server_error' } export type DeleteTaskResult = @@ -41,12 +37,6 @@ export async function saveTask( if (!session.userId) return { ok: false, code: 403, error: 'forbidden' } if (session.isDemo) return { ok: false, code: 403, error: 'demo_readonly' } - // Rate-limit alleen op create-path; edits zijn laag-volume. - if (!context.taskId) { - const limited = enforceUserRateLimit('create-task', session.userId) - if (limited) return { ok: false, code: 429, error: 'rate_limited' } - } - const parsed = sharedTaskSchema.safeParse(input) if (!parsed.success) { return { @@ -57,8 +47,7 @@ export async function saveTask( } } - const { title, description, implementation_plan, priority, status, code: rawCode } = parsed.data - const inputCode = normalizeCode(rawCode ?? null) + const { title, description, implementation_plan, priority, status } = parsed.data const scope = productAccessFilter(session.userId) try { @@ -80,13 +69,12 @@ export async function saveTask( description: description ?? null, implementation_plan: implementation_plan ?? null, priority, - ...(inputCode ? { code: inputCode, sort_order: parseCodeNumber(inputCode) } : {}), }, select: { id: true, title: true, status: true }, }) if (statusChanged) { - const result = await propagateStatusUpwards(taskId, status, tx) + const result = await updateTaskStatusWithStoryPromotion(taskId, status, tx) return { id: result.task.id, title: result.task.title, status: result.task.status } } return updated @@ -103,46 +91,34 @@ export async function saveTask( const story = await prisma.story.findFirst({ where: { id: context.storyId, product: scope }, - select: { sprint_id: true, product_id: true }, + select: { sprint_id: true }, }) if (!story) return { ok: false, code: 403, error: 'forbidden' } - const productId = story.product_id - const sprintId = story.sprint_id ?? null - const storyId = context.storyId + const last = await prisma.task.findFirst({ + where: { story_id: context.storyId }, + orderBy: { sort_order: 'desc' }, + select: { sort_order: true }, + }) - const task = await createWithCodeRetry( - () => (inputCode ? Promise.resolve(inputCode) : generateNextTaskCode(productId)), - (code) => - prisma.task.create({ - data: { - story_id: storyId, - product_id: productId, - sprint_id: sprintId, - code, - title, - description: description ?? null, - implementation_plan: implementation_plan ?? null, - priority, - sort_order: parseCodeNumber(code), - status: 'TO_DO', - }, - select: { id: true, title: true, status: true }, - }), - ) + const task = await prisma.task.create({ + data: { + story_id: context.storyId, + sprint_id: story.sprint_id ?? null, + title, + description: description ?? null, + implementation_plan: implementation_plan ?? null, + priority, + sort_order: (last?.sort_order ?? 0) + 1.0, + status: 'TO_DO', + }, + select: { id: true, title: true, status: true }, + }) revalidatePath(`/products/${context.productId}/sprint`) revalidatePath(`/products/${context.productId}`) return { ok: true, task: { ...task, status: task.status.toString() } } - } catch (e) { - if (inputCode && isCodeUniqueConflict(e)) { - return { - ok: false, - code: 422, - error: 'validation', - fieldErrors: { code: ['Code bestaat al binnen dit product'] }, - } - } + } catch { return { ok: false, code: 500, error: 'server_error' } } } @@ -183,9 +159,6 @@ export async function createTaskAction(_prevState: unknown, formData: FormData) if (!session.userId) return { error: 'Niet ingelogd' } if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - const limited = enforceUserRateLimit('create-task', session.userId) - if (limited) return limited - const storyId = formData.get('storyId') as string const sprintId = formData.get('sprintId') as string @@ -201,24 +174,22 @@ export async function createTaskAction(_prevState: unknown, formData: FormData) }) if (!story) return { error: 'Story niet gevonden' } - const productId = story.product_id - const task = await createWithCodeRetry( - () => generateNextTaskCode(productId), - (code) => - prisma.task.create({ - data: { - story_id: storyId, - product_id: productId, - sprint_id: sprintId || null, - code, - title: parsed.data.title, - description: parsed.data.description ?? null, - priority: parsed.data.priority, - sort_order: parseCodeNumber(code), - status: 'TO_DO', - }, - }), - ) + const last = await prisma.task.findFirst({ + where: { story_id: storyId }, + orderBy: { sort_order: 'desc' }, + }) + + const task = await prisma.task.create({ + data: { + story_id: storyId, + sprint_id: sprintId || null, + title: parsed.data.title, + description: parsed.data.description ?? null, + priority: parsed.data.priority, + sort_order: (last?.sort_order ?? 0) + 1.0, + status: 'TO_DO', + }, + }) revalidatePath(`/products/${story.product_id}/sprint/planning`) return { success: true, task } @@ -263,7 +234,7 @@ export async function updateTaskStatusAction(id: string, status: 'TO_DO' | 'IN_P }) if (!task) return { error: 'Taak niet gevonden' } - await propagateStatusUpwards(id, status) + await updateTaskStatusWithStoryPromotion(id, status) // /solo bewust niet revalideren: dat zou de page soft-navigaten en de // open SSE-stream sluiten. De Solo Paneel-flow leunt op optimistic @@ -322,3 +293,22 @@ export async function updateTaskPlanAction(taskId: string, productId: string, im return { success: true } } +export async function reorderTasksAction(storyId: string, orderedIds: string[]) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const story = await prisma.story.findFirst({ + where: { id: storyId, product: productAccessFilter(session.userId) }, + }) + if (!story) return { error: 'Story niet gevonden' } + + await prisma.$transaction( + orderedIds.map((id, i) => + prisma.task.update({ where: { id }, data: { sort_order: i + 1.0 } }) + ) + ) + + revalidatePath(`/products/${story.product_id}/sprint/planning`) + return { success: true } +} diff --git a/actions/todos.ts b/actions/todos.ts new file mode 100644 index 0000000..ecfde5f --- /dev/null +++ b/actions/todos.ts @@ -0,0 +1,249 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { z } from 'zod' +import { prisma } from '@/lib/prisma' +import { SessionData, sessionOptions } from '@/lib/session' +import { productAccessFilter } from '@/lib/product-access' + +async function getSession() { + return getIronSession<SessionData>(await cookies(), sessionOptions) +} + +export async function createTodoAction(_prevState: unknown, formData: FormData) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const title = (formData.get('title') as string)?.trim() + const description = (formData.get('description') as string)?.trim() || null + const raw = (formData.get('productId') as string)?.trim() + const productId = (raw && raw !== 'all') ? raw : null + + if (!title) return { error: 'Titel is verplicht' } + if (description && description.length > 2000) return { error: 'Beschrijving is langer dan 2000 tekens' } + + if (productId) { + const product = await prisma.product.findFirst({ + where: { id: productId, ...productAccessFilter(session.userId), archived: false }, + }) + if (!product) return { error: 'Product niet gevonden' } + } + + await prisma.todo.create({ + data: { user_id: session.userId, product_id: productId, title, description }, + }) + revalidatePath('/todos') + return { success: true } +} + +export async function toggleTodoAction(id: string, done: boolean) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + + const todo = await prisma.todo.findFirst({ where: { id, user_id: session.userId } }) + if (!todo) return { error: 'Todo niet gevonden' } + + await prisma.todo.update({ where: { id }, data: { done } }) + revalidatePath('/todos') + return { success: true } +} + +export async function archiveCompletedTodosAction() { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + + await prisma.todo.updateMany({ + where: { user_id: session.userId, done: true, archived: false }, + data: { archived: true }, + }) + revalidatePath('/todos') + return { success: true } +} + +export async function updateTodoAction(_prevState: unknown, formData: FormData) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const id = (formData.get('id') as string)?.trim() + const title = (formData.get('title') as string)?.trim() + const description = (formData.get('description') as string)?.trim() || null + const raw = (formData.get('productId') as string)?.trim() + const productId = raw || null + const done = formData.get('done') === 'on' + + if (!id) return { error: 'Ongeldige todo' } + if (!title) return { error: 'Titel is verplicht' } + if (description && description.length > 2000) return { error: 'Beschrijving is langer dan 2000 tekens' } + + const todo = await prisma.todo.findFirst({ + where: { id, user_id: session.userId }, + }) + if (!todo) return { error: 'Todo niet gevonden' } + + if (productId) { + const product = await prisma.product.findFirst({ + where: { id: productId, ...productAccessFilter(session.userId), archived: false }, + }) + if (!product) return { error: 'Product niet gevonden' } + } + + await prisma.todo.update({ + where: { id }, + data: { title, description, product_id: productId, done }, + }) + revalidatePath('/todos') + return { success: true } +} + +export async function archiveSelectedTodosAction(ids: string[]) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (!ids.length) return { error: 'Geen todos geselecteerd' } + + const owned = await prisma.todo.findMany({ + where: { id: { in: ids }, user_id: session.userId }, + select: { id: true }, + }) + if (owned.length !== ids.length) return { error: 'Ongeldige selectie' } + + await prisma.todo.updateMany({ + where: { id: { in: ids }, user_id: session.userId }, + data: { archived: true }, + }) + revalidatePath('/todos') + return { success: true } +} + +const promotePbiSchema = z.object({ + todoId: z.string(), + productId: z.string(), + title: z.string().min(1).max(200), + priority: z.coerce.number().int().min(1).max(4), +}) + +export async function promoteTodoToPbiAction(_prevState: unknown, formData: FormData) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = promotePbiSchema.safeParse({ + todoId: formData.get('todoId'), + productId: formData.get('productId'), + title: formData.get('title'), + priority: formData.get('priority'), + }) + if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } + + const product = await prisma.product.findFirst({ + where: { id: parsed.data.productId, ...productAccessFilter(session.userId) }, + }) + if (!product) return { error: 'Product niet gevonden' } + + const todo = await prisma.todo.findFirst({ + where: { id: parsed.data.todoId, user_id: session.userId }, + }) + if (!todo) return { error: 'Todo niet gevonden' } + + const last = await prisma.pbi.findFirst({ + where: { product_id: parsed.data.productId, priority: parsed.data.priority }, + orderBy: { sort_order: 'desc' }, + }) + + await prisma.$transaction([ + prisma.pbi.create({ + data: { + product_id: parsed.data.productId, + title: parsed.data.title, + priority: parsed.data.priority, + sort_order: (last?.sort_order ?? 0) + 1.0, + }, + }), + prisma.todo.deleteMany({ where: { id: parsed.data.todoId, user_id: session.userId } }), + ]) + + revalidatePath('/todos') + revalidatePath(`/products/${parsed.data.productId}`) + return { success: true } +} + +const promoteStorySchema = z.object({ + todoId: z.string(), + productId: z.string(), + pbiId: z.string(), + title: z.string().min(1).max(200), + priority: z.coerce.number().int().min(1).max(4), +}) + +export async function promoteTodoToStoryAction(_prevState: unknown, formData: FormData) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const parsed = promoteStorySchema.safeParse({ + todoId: formData.get('todoId'), + productId: formData.get('productId'), + pbiId: formData.get('pbiId'), + title: formData.get('title'), + priority: formData.get('priority'), + }) + if (!parsed.success) return { error: parsed.error.flatten().fieldErrors } + + const todo = await prisma.todo.findFirst({ + where: { id: parsed.data.todoId, user_id: session.userId }, + }) + if (!todo) return { error: 'Todo niet gevonden' } + + const pbi = await prisma.pbi.findFirst({ + where: { id: parsed.data.pbiId, product: productAccessFilter(session.userId) }, + }) + if (!pbi) return { error: 'PBI niet gevonden' } + if (todo.product_id !== null && todo.product_id !== pbi.product_id) return { error: 'Todo hoort niet bij dit product' } + + const last = await prisma.story.findFirst({ + where: { pbi_id: parsed.data.pbiId, priority: parsed.data.priority }, + orderBy: { sort_order: 'desc' }, + }) + + await prisma.$transaction([ + prisma.story.create({ + data: { + pbi_id: parsed.data.pbiId, + product_id: pbi.product_id, + title: parsed.data.title, + priority: parsed.data.priority, + sort_order: (last?.sort_order ?? 0) + 1.0, + status: 'OPEN', + }, + }), + prisma.todo.deleteMany({ where: { id: parsed.data.todoId, user_id: session.userId } }), + ]) + + revalidatePath('/todos') + revalidatePath(`/products/${pbi.product_id}`) + return { success: true } +} + +export async function updateRolesAction(roles: string[]) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const validRoles = ['PRODUCT_OWNER', 'SCRUM_MASTER', 'DEVELOPER'] + const filtered = roles.filter(r => validRoles.includes(r)) + if (filtered.length === 0) return { error: 'Minimaal één rol is verplicht' } + + await prisma.$transaction([ + prisma.userRole.deleteMany({ where: { user_id: session.userId } }), + prisma.userRole.createMany({ + data: filtered.map(role => ({ user_id: session.userId, role: role as 'PRODUCT_OWNER' | 'SCRUM_MASTER' | 'DEVELOPER' })), + }), + ]) + + revalidatePath('/settings') + return { success: true } +} diff --git a/actions/user-questions.ts b/actions/user-questions.ts deleted file mode 100644 index d43c6d7..0000000 --- a/actions/user-questions.ts +++ /dev/null @@ -1,106 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' -import { cookies } from 'next/headers' -import { getIronSession } from 'iron-session' -import { z } from 'zod' - -import { prisma } from '@/lib/prisma' -import { SessionData, sessionOptions } from '@/lib/session' -import { enforceUserRateLimit } from '@/lib/rate-limit' -import { ACTIVE_JOB_STATUSES } from '@/lib/job-status' -import { getJobConfigSnapshot } from '@/lib/job-config-snapshot' - -async function getSession() { - return getIronSession<SessionData>(await cookies(), sessionOptions) -} - -type ActionResult<T = void> = - | { success: true; data?: T } - | { error: string; code?: number } - -const createSchema = z.object({ - ideaId: z.string().cuid(), - question: z.string().min(1).max(2000), -}) - -export async function createUserQuestionAction( - ideaId: string, - question: string, -): Promise<ActionResult<{ questionId: string; jobId: string }>> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 401 } - if (session.isDemo) return { error: 'Demo-gebruikers kunnen geen vragen stellen', code: 403 } - - const limited = enforceUserRateLimit('create-user-question', session.userId) - if (limited) return limited - - const parsed = createSchema.safeParse({ ideaId, question }) - if (!parsed.success) return { error: 'Ongeldige invoer', code: 422 } - - const idea = await prisma.idea.findFirst({ - where: { id: parsed.data.ideaId, user_id: session.userId }, - select: { id: true, plan_md: true, product_id: true }, - }) - if (!idea) return { error: 'Idee niet gevonden', code: 404 } - if (!idea.plan_md) return { error: 'Er is nog geen plan voor dit idee', code: 422 } - if (!idea.product_id) return { error: 'Koppel eerst een product aan dit idee', code: 422 } - - // Idempotency: weiger als er al een actieve PLAN_CHAT job loopt voor dit idee. - const existing = await prisma.claudeJob.findFirst({ - where: { - idea_id: parsed.data.ideaId, - kind: 'PLAN_CHAT', - status: { in: ACTIVE_JOB_STATUSES }, - }, - select: { id: true }, - }) - if (existing) return { error: 'Er loopt al een actieve PLAN_CHAT voor dit idee', code: 409 } - - const snapshot = await getJobConfigSnapshot({ kind: 'PLAN_CHAT', productId: idea.product_id }) - - const [uq, job] = await prisma.$transaction([ - prisma.userQuestion.create({ - data: { - idea_id: parsed.data.ideaId, - user_id: session.userId, - question: parsed.data.question, - }, - }), - prisma.claudeJob.create({ - data: { - user_id: session.userId, - product_id: idea.product_id, - idea_id: parsed.data.ideaId, - kind: 'PLAN_CHAT', - status: 'QUEUED', - ...snapshot, - }, - }), - ]) - - await prisma.ideaLog.create({ - data: { - idea_id: parsed.data.ideaId, - type: 'JOB_EVENT', - content: 'PLAN_CHAT queued', - metadata: { job_id: job.id, kind: 'PLAN_CHAT', user_question_id: uq.id }, - }, - }) - - await prisma.$executeRaw` - SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ - type: 'claude_job_enqueued', - job_id: job.id, - idea_id: parsed.data.ideaId, - user_id: session.userId, - product_id: idea.product_id, - kind: 'PLAN_CHAT', - status: 'queued', - })}::text) - ` - - revalidatePath('/ideas/' + parsed.data.ideaId, 'page') - - return { success: true, data: { questionId: uq.id, jobId: job.id } } -} diff --git a/actions/user-settings.ts b/actions/user-settings.ts deleted file mode 100644 index e3a9cbb..0000000 --- a/actions/user-settings.ts +++ /dev/null @@ -1,62 +0,0 @@ -'use server' - -import { cookies } from 'next/headers' -import { getIronSession } from 'iron-session' -import { Prisma } from '@prisma/client' -import { prisma } from '@/lib/prisma' -import { SessionData, sessionOptions } from '@/lib/session' -import { - UserSettingsSchema, - mergeSettings, - parseUserSettings, - type UserSettings, -} from '@/lib/user-settings' - -async function getSession() { - return getIronSession<SessionData>(await cookies(), sessionOptions) -} - -export type UpdateUserSettingsResult = - | { success: true; settings: UserSettings } - | { error: string; code: 401 | 403 | 422; fieldErrors?: Record<string, string[]> } - -export async function updateUserSettingsAction( - patch: Partial<UserSettings>, -): Promise<UpdateUserSettingsResult> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 401 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const parsed = UserSettingsSchema.partial().safeParse(patch) - if (!parsed.success) { - return { - error: 'Ongeldige settings-patch', - code: 422, - fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>, - } - } - - const merged = await prisma.$transaction(async (tx) => { - const user = await tx.user.findUnique({ - where: { id: session.userId! }, - select: { settings: true }, - }) - const current = parseUserSettings(user?.settings) - const next = mergeSettings(current, parsed.data) - await tx.user.update({ - where: { id: session.userId! }, - data: { settings: next as unknown as Prisma.InputJsonValue }, - }) - return next - }) - - await prisma.$executeRaw` - SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ - kind: 'user_settings', - userId: session.userId, - patch: parsed.data, - })}::text) - ` - - return { success: true, settings: merged } -} diff --git a/app/(app)/admin/jobs/page.tsx b/app/(app)/admin/jobs/page.tsx deleted file mode 100644 index 8a3ba85..0000000 --- a/app/(app)/admin/jobs/page.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { requireAdmin } from '@/lib/auth-guard' -import { prisma } from '@/lib/prisma' -import { JobsTable } from '@/components/admin/jobs-table' - -export default async function AdminJobsPage() { - await requireAdmin() - - const jobs = await prisma.claudeJob.findMany({ - orderBy: { created_at: 'desc' }, - take: 100, - select: { - id: true, - kind: true, - status: true, - created_at: true, - branch: true, - pr_url: true, - error: true, - model_id: true, - input_tokens: true, - output_tokens: true, - cache_read_tokens: true, - cache_write_tokens: true, - actual_thinking_tokens: true, - requested_model: true, - requested_thinking_budget: true, - requested_permission_mode: true, - user: { select: { username: true } }, - product: { select: { name: true } }, - }, - }) - - const prices = await prisma.modelPrice.findMany() - const priceMap = new Map(prices.map((p) => [p.model_id, p])) - - const jobsWithCost = jobs.map((job) => { - const p = job.model_id ? priceMap.get(job.model_id) : undefined - if (!p || job.input_tokens == null) return { ...job, cost_usd: null } - const cost = - (job.input_tokens ?? 0) * Number(p.input_price_per_1m) / 1_000_000 + - (job.output_tokens ?? 0) * Number(p.output_price_per_1m) / 1_000_000 + - (job.cache_read_tokens ?? 0) * Number(p.cache_read_price_per_1m) / 1_000_000 + - (job.cache_write_tokens ?? 0) * Number(p.cache_write_price_per_1m) / 1_000_000 + - (job.actual_thinking_tokens ?? 0) * Number(p.input_price_per_1m) / 1_000_000 - return { ...job, cost_usd: cost } - }) - - return ( - <div> - <h1 className="text-xl font-semibold mb-4">Claude Jobs</h1> - <JobsTable jobs={jobsWithCost} /> - </div> - ) -} diff --git a/app/(app)/admin/layout.tsx b/app/(app)/admin/layout.tsx deleted file mode 100644 index 6c2c912..0000000 --- a/app/(app)/admin/layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { requireAdmin } from '@/lib/auth-guard' -import Link from 'next/link' - -export default async function AdminLayout({ children }: { children: React.ReactNode }) { - await requireAdmin() - return ( - <div className="flex min-h-screen"> - <nav className="w-48 border-r p-4 flex flex-col gap-2"> - <Link href="/admin/users" className="text-sm font-medium text-foreground hover:text-primary">Gebruikers</Link> - <Link href="/admin/jobs" className="text-sm font-medium text-foreground hover:text-primary">Claude Jobs</Link> - <Link href="/admin/products" className="text-sm font-medium text-foreground hover:text-primary">Producten</Link> - </nav> - <main className="flex-1 p-6">{children}</main> - </div> - ) -} diff --git a/app/(app)/admin/page.tsx b/app/(app)/admin/page.tsx deleted file mode 100644 index f07ba33..0000000 --- a/app/(app)/admin/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from 'next/navigation' - -export default function AdminPage() { - redirect('/admin/users') -} diff --git a/app/(app)/admin/products/page.tsx b/app/(app)/admin/products/page.tsx deleted file mode 100644 index 11081d3..0000000 --- a/app/(app)/admin/products/page.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { requireAdmin } from '@/lib/auth-guard' -import { prisma } from '@/lib/prisma' -import { ProductsTable } from '@/components/admin/products-table' - -export default async function AdminProductsPage() { - await requireAdmin() - - const products = await prisma.product.findMany({ - orderBy: { created_at: 'desc' }, - select: { - id: true, - name: true, - archived: true, - created_at: true, - user: { select: { username: true } }, - _count: { select: { members: true, pbis: true } }, - }, - }) - - return ( - <div> - <h1 className="text-xl font-semibold mb-4">Producten</h1> - <ProductsTable products={products} /> - </div> - ) -} diff --git a/app/(app)/admin/users/page.tsx b/app/(app)/admin/users/page.tsx deleted file mode 100644 index 6d3543d..0000000 --- a/app/(app)/admin/users/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { requireAdmin } from '@/lib/auth-guard' -import { prisma } from '@/lib/prisma' -import { UsersTable } from '@/components/admin/users-table' - -export default async function AdminUsersPage() { - const session = await requireAdmin() - - const users = await prisma.user.findMany({ - include: { roles: { select: { role: true } } }, - orderBy: { created_at: 'desc' }, - }) - - return ( - <div className="space-y-4"> - <h1 className="text-xl font-semibold text-foreground">Gebruikers</h1> - <UsersTable users={users} currentUserId={session.userId} /> - </div> - ) -} diff --git a/app/(app)/dashboard/loading.tsx b/app/(app)/dashboard/loading.tsx index 40800ab..656e323 100644 --- a/app/(app)/dashboard/loading.tsx +++ b/app/(app)/dashboard/loading.tsx @@ -1,12 +1,10 @@ -import { Skeleton } from '@/components/ui/skeleton' - export default function Loading() { return ( - <div className="p-6 max-w-4xl mx-auto w-full"> - <Skeleton className="h-6 w-32 mb-6" /> + <div className="p-6 max-w-4xl mx-auto w-full animate-pulse"> + <div className="h-6 w-32 bg-border rounded mb-6" /> <div className="grid gap-3"> - {[1, 2, 3].map((i) => ( - <Skeleton key={i} className="h-20 rounded-xl" /> + {[1, 2, 3].map(i => ( + <div key={i} className="h-20 bg-border/50 rounded-xl" /> ))} </div> </div> diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 95a84d4..9497e54 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -4,8 +4,8 @@ import { SessionData, sessionOptions } from '@/lib/session' import { prisma } from '@/lib/prisma' import { productAccessFilter } from '@/lib/product-access' import Link from 'next/link' +import { Button } from '@/components/ui/button' import { ProductList } from '@/components/dashboard/product-list' -import { NewProductButton } from '@/components/dashboard/new-product-button' interface Props { searchParams: Promise<{ archived?: string }> @@ -43,7 +43,9 @@ export default async function DashboardPage({ searchParams }: Props) { </Link> )} </div> - {!session.isDemo && !showArchived && <NewProductButton />} + {!session.isDemo && !showArchived && ( + <Button nativeButton={false} render={<Link href="/products/new" />}>+ Nieuw product</Button> + )} </div> <ProductList diff --git a/app/(app)/ideas/[id]/page.tsx b/app/(app)/ideas/[id]/page.tsx deleted file mode 100644 index 80d946c..0000000 --- a/app/(app)/ideas/[id]/page.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { cookies } from 'next/headers' -import { notFound } from 'next/navigation' -import { getIronSession } from 'iron-session' - -import { SessionData, sessionOptions } from '@/lib/session' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' -import { ideaToDto } from '@/lib/idea-dto' -import { IdeaDetailLayout } from '@/components/ideas/idea-detail-layout' -import { loadIdeaSyncData } from './sync-tab-server' -import type { ReviewLog } from '@/components/ideas/review-log-viewer' - -export const dynamic = 'force-dynamic' - -interface PageProps { - params: Promise<{ id: string }> - searchParams: Promise<{ tab?: string }> -} - -export default async function IdeaDetailPage({ params, searchParams }: PageProps) { - const session = await getIronSession<SessionData>(await cookies(), sessionOptions) - if (!session.userId) notFound() // proxy.ts redirect zou ons al moeten hebben - - const { id } = await params - const { tab } = await searchParams - - // M12: strikt user_id-only — 404 (niet 403) voor andere users (anti-enum). - const idea = await prisma.idea.findFirst({ - where: { id, user_id: session.userId }, - select: { - id: true, - user_id: true, - product_id: true, - code: true, - title: true, - description: true, - status: true, - pbi_id: true, - archived: true, - grill_md: true, - plan_md: true, - plan_review_log: true, - reviewed_at: true, - created_at: true, - updated_at: true, - product: { select: { id: true, name: true, repo_url: true } }, - pbi: { select: { id: true, code: true, title: true } }, - secondary_products: { select: { id: true, product_id: true, product: { select: { id: true, name: true } } } }, - }, - }) - if (!idea) notFound() - - // Producten voor de "koppel product"-dropdown in de form-tab. - const products = await prisma.product.findMany({ - where: { ...productAccessFilter(session.userId), archived: false }, - orderBy: { name: 'asc' }, - select: { id: true, name: true, repo_url: true }, - }) - - // Recent logs (laatste 100) voor de Timeline-tab. - const logs = await prisma.ideaLog.findMany({ - where: { idea_id: id }, - orderBy: { created_at: 'desc' }, - take: 100, - select: { - id: true, - type: true, - content: true, - metadata: true, - created_at: true, - }, - }) - - // Open vragen voor dit idee — voor de Timeline-tab. - const questions = await prisma.claudeQuestion.findMany({ - where: { idea_id: id }, - orderBy: { created_at: 'desc' }, - take: 50, - select: { - id: true, - question: true, - options: true, - status: true, - answer: true, - created_at: true, - expires_at: true, - }, - }) - - const userQuestionsRaw = await prisma.userQuestion.findMany({ - where: { idea_id: id }, - orderBy: { created_at: 'asc' }, - take: 100, - select: { id: true, question: true, answer: true, status: true, created_at: true }, - }) - - // Sync-tab data — alleen geladen als idea PLANNED is en pbi_id gevuld. - // loadIdeaSyncData past zelf user_id-scope toe en retourneert null als - // het idee geen pbi heeft. - const syncData = - idea.status === 'PLANNED' && idea.pbi_id - ? await loadIdeaSyncData(id, session.userId) - : null - - return ( - <IdeaDetailLayout - idea={ideaToDto(idea)} - grill_md={idea.grill_md} - plan_md={idea.plan_md} - plan_review_log={(idea.plan_review_log as ReviewLog | null) ?? null} - products={products} - logs={logs.map((l) => ({ - id: l.id, - type: l.type, - content: l.content, - metadata: l.metadata, - created_at: l.created_at.toISOString(), - }))} - questions={questions.map((q) => ({ - id: q.id, - question: q.question, - options: Array.isArray(q.options) ? (q.options as string[]) : null, - status: q.status as 'open' | 'answered' | 'cancelled' | 'expired', - answer: q.answer ?? null, - created_at: q.created_at.toISOString(), - expires_at: q.expires_at.toISOString(), - }))} - userQuestions={userQuestionsRaw.map((uq) => ({ - id: uq.id, - question: uq.question, - answer: uq.answer, - status: uq.status as 'pending' | 'answered', - created_at: uq.created_at.toISOString(), - }))} - isDemo={session.isDemo ?? false} - initialTab={tab ?? 'idee'} - syncData={syncData} - /> - ) -} diff --git a/app/(app)/ideas/[id]/sync-tab-server.ts b/app/(app)/ideas/[id]/sync-tab-server.ts deleted file mode 100644 index ae93465..0000000 --- a/app/(app)/ideas/[id]/sync-tab-server.ts +++ /dev/null @@ -1,85 +0,0 @@ -import 'server-only' -import { prisma } from '@/lib/prisma' - -// Server-only loader voor de Sync-tab op /ideas/[id]. -// Joint Idea → PBI → Stories → Tasks → ClaudeJobs + StoryLog. -// Auth-scope: strikt user_id-only conform M12-keuze 2. -// -// Returns null wanneer: -// - idea bestaat niet of behoort niet aan user -// - idea heeft geen pbi_id (status !== PLANNED, dus sync-tab niet relevant) -// -// Caller (page.tsx) moet de tab niet renderen als deze null retourneert. -export async function loadIdeaSyncData(ideaId: string, userId: string) { - const idea = await prisma.idea.findFirst({ - where: { id: ideaId, user_id: userId }, - select: { - id: true, - code: true, - title: true, - status: true, - pbi_id: true, - product: { select: { id: true, name: true, repo_url: true } }, - pbi: { - select: { - id: true, - code: true, - title: true, - pr_url: true, - pr_merged_at: true, - stories: { - orderBy: { sort_order: 'asc' }, - select: { - id: true, - code: true, - title: true, - status: true, - tasks: { - orderBy: { sort_order: 'asc' }, - select: { - id: true, - code: true, - title: true, - status: true, - claude_jobs: { - where: { kind: 'TASK_IMPLEMENTATION' }, - orderBy: { created_at: 'desc' }, - select: { - id: true, - status: true, - branch: true, - pushed_at: true, - pr_url: true, - error: true, - summary: true, - created_at: true, - finished_at: true, - }, - }, - }, - }, - logs: { - orderBy: { created_at: 'desc' }, - take: 20, - select: { - id: true, - type: true, - content: true, - status: true, - commit_hash: true, - commit_message: true, - created_at: true, - }, - }, - }, - }, - }, - }, - }, - }) - - if (!idea || !idea.pbi) return null - return idea -} - -export type IdeaSyncData = NonNullable<Awaited<ReturnType<typeof loadIdeaSyncData>>> diff --git a/app/(app)/ideas/page.tsx b/app/(app)/ideas/page.tsx deleted file mode 100644 index 1c4fd5e..0000000 --- a/app/(app)/ideas/page.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { cookies } from 'next/headers' -import { getIronSession } from 'iron-session' - -import { SessionData, sessionOptions } from '@/lib/session' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' -import { ideaToDto } from '@/lib/idea-dto' -import { IdeaList } from '@/components/ideas/idea-list' - -export const dynamic = 'force-dynamic' - -export default async function IdeasPage() { - const session = await getIronSession<SessionData>(await cookies(), sessionOptions) - - // M12: idee is strikt user_id-only (geen productAccessFilter — Q8). - const ideas = await prisma.idea.findMany({ - where: { user_id: session.userId, archived: false }, - orderBy: { created_at: 'desc' }, - include: { - product: { select: { id: true, name: true, repo_url: true } }, - secondary_products: { include: { product: { select: { id: true, name: true } } } }, - }, - take: 200, - }) - - // Productenlijst voor de filter-dropdown + voor "Nieuw idee"-form. - // Producten zijn product-scoped (kan team-shared zijn) — productAccessFilter - // is hier dus wél juist. - const products = await prisma.product.findMany({ - where: { ...productAccessFilter(session.userId), archived: false }, - orderBy: { name: 'asc' }, - select: { id: true, name: true, repo_url: true }, - }) - - const user = await prisma.user.findUnique({ - where: { id: session.userId }, - select: { active_product_id: true }, - }) - - const activeProductId = - user?.active_product_id && products.some((p) => p.id === user.active_product_id) - ? user.active_product_id - : null - - return ( - <div className="p-6 max-w-5xl mx-auto w-full"> - <header className="mb-6 flex items-baseline justify-between"> - <h1 className="text-xl font-medium text-foreground">Ideeën</h1> - <p className="text-sm text-muted-foreground"> - Lichtgewicht voorstellen die je via Grill Me en Make Plan tot een PBI laat groeien. - </p> - </header> - - <IdeaList - ideas={ideas.map((i) => ideaToDto(i))} - products={products} - isDemo={session.isDemo ?? false} - activeProductId={activeProductId} - /> - </div> - ) -} diff --git a/app/(app)/insights/components/agent-throughput.tsx b/app/(app)/insights/components/agent-throughput.tsx index a43dd96..820e64f 100644 --- a/app/(app)/insights/components/agent-throughput.tsx +++ b/app/(app)/insights/components/agent-throughput.tsx @@ -33,7 +33,7 @@ function formatDuration(seconds: number | null): string { return m > 0 ? `${m}m ${s}s` : `${s}s` } -const STACKED_STATUSES = ['queued', 'claimed', 'running', 'done', 'failed', 'cancelled', 'skipped'] as const +const STACKED_STATUSES = ['queued', 'claimed', 'running', 'done', 'failed', 'cancelled'] as const export function AgentThroughputCard({ data, productList, currentProductId }: Props) { const router = useRouter() @@ -44,7 +44,7 @@ export function AgentThroughputCard({ data, productList, currentProductId }: Pro const { perDay, kpi } = data const isEmpty = perDay.every( - d => d.done + d.failed + d.queued + d.claimed + d.running + d.cancelled + d.skipped === 0, + d => d.done + d.failed + d.queued + d.claimed + d.running + d.cancelled === 0, ) function handleProductChange(value: string | null) { diff --git a/app/(app)/insights/components/alignment-trend.tsx b/app/(app)/insights/components/alignment-trend.tsx index 1718188..45375d1 100644 --- a/app/(app)/insights/components/alignment-trend.tsx +++ b/app/(app)/insights/components/alignment-trend.tsx @@ -15,7 +15,7 @@ interface Props { } interface TooltipPayload { - payload?: { total: number; alignedRatio: number; sprintCode: string; sprintGoal: string } + payload?: { total: number; alignedRatio: number; sprintGoal: string } } function CustomTooltip({ active, payload }: { active?: boolean; payload?: TooltipPayload[] }) { @@ -25,10 +25,7 @@ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Toolti const aligned = Math.round((d.alignedRatio / 100) * d.total) return ( <div className="rounded border border-border bg-surface-container px-3 py-2 text-sm shadow"> - <p className="font-medium text-foreground"> - <span className="font-mono text-xs text-muted-foreground mr-2">{d.sprintCode}</span> - {d.sprintGoal} - </p> + <p className="font-medium text-foreground">{d.sprintGoal}</p> <p className="text-muted-foreground"> {aligned} / {d.total} aligned ({d.alignedRatio}%) </p> @@ -36,6 +33,10 @@ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Toolti ) } +function sprintLabel(goal: string): string { + return goal.length > 20 ? goal.slice(0, 18) + '…' : goal +} + export function AlignmentTrend({ trend }: Props) { if (trend.length === 0) { return ( @@ -47,7 +48,7 @@ export function AlignmentTrend({ trend }: Props) { const data = trend.map(p => ({ ...p, - label: p.sprintCode, + label: sprintLabel(p.sprintGoal), })) return ( diff --git a/app/(app)/insights/components/cost-analysis.tsx b/app/(app)/insights/components/cost-analysis.tsx deleted file mode 100644 index f147ae3..0000000 --- a/app/(app)/insights/components/cost-analysis.tsx +++ /dev/null @@ -1,272 +0,0 @@ -'use client' - -import { useTransition } from 'react' -import { useRouter, usePathname, useSearchParams } from 'next/navigation' -import { - BarChart, - Bar, - XAxis, - YAxis, - Tooltip, - PieChart, - Pie, - Cell, - ResponsiveContainer, -} from 'recharts' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import type { - Period, - CostKpi, - CostByDayRow, - CostByModelRow, - CostByKindRow, - CacheEfficiency, -} from '@/lib/insights/cost-analysis' - -interface Props { - period: Period - kpi: CostKpi - byDay: CostByDayRow[] - byModel: CostByModelRow[] - byKind: CostByKindRow[] - cache: CacheEfficiency -} - -const PERIOD_LABELS: Record<Period, string> = { - '7d': 'Laatste 7 dagen', - '30d': 'Laatste 30 dagen', - '90d': 'Laatste 90 dagen', - mtd: 'Deze maand', -} - -const KIND_LABELS: Record<string, string> = { - TASK_IMPLEMENTATION: 'Task impl.', - IDEA_GRILL: 'Idea grill', - IDEA_MAKE_PLAN: 'Idea plan', - PLAN_CHAT: 'Plan chat', - SPRINT_IMPLEMENTATION: 'Sprint impl.', -} - -function fmtUsd(n: number, decimals = 2): string { - return '$' + n.toFixed(decimals) -} - -function shortenModel(modelId: string): string { - return modelId.replace(/^claude-/, '') -} - -export function CostAnalysisCard({ period, kpi, byDay, byModel, byKind, cache }: Props) { - const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() - const [isPending, startTransition] = useTransition() - - function handlePeriodChange(value: string | null) { - if (value === null) return - startTransition(() => { - const params = new URLSearchParams(searchParams.toString()) - params.set('period', value) - router.replace(`${pathname}?${params.toString()}`) - }) - } - - const periodSelector = ( - <Select value={period} onValueChange={handlePeriodChange}> - <SelectTrigger className="w-44" disabled={isPending}> - <SelectValue /> - </SelectTrigger> - <SelectContent> - {(Object.keys(PERIOD_LABELS) as Period[]).map(p => ( - <SelectItem key={p} value={p}> - {PERIOD_LABELS[p]} - </SelectItem> - ))} - </SelectContent> - </Select> - ) - - if (kpi.jobCount === 0) { - return ( - <div className="space-y-4"> - <div className="flex items-start justify-between gap-4 flex-wrap"> - <p className="text-muted-foreground text-sm">Geen jobs in deze periode.</p> - {periodSelector} - </div> - </div> - ) - } - - const cacheData = [ - { name: 'Cache', value: cache.cacheReadTokens }, - { name: 'Uncached input', value: cache.uncachedInputTokens }, - ] - const cacheColors = ['var(--status-done)', 'var(--muted-foreground)'] - - const modelData = byModel.map(m => ({ ...m, label: shortenModel(m.modelId) })) - const kindData = byKind.map(k => ({ ...k, label: KIND_LABELS[k.kind] ?? k.kind })) - - return ( - <div className="space-y-4"> - {/* KPI strip + period selector */} - <div className="flex items-start justify-between gap-4 flex-wrap"> - <div className="flex gap-6 flex-wrap"> - <div> - <div className="text-2xl font-semibold text-foreground">{fmtUsd(kpi.totalCostUsd)}</div> - <div className="text-xs text-muted-foreground">Totaal kosten</div> - </div> - <div> - <div className="text-2xl font-semibold text-foreground">{fmtUsd(kpi.avgPerDayUsd)}</div> - <div className="text-xs text-muted-foreground">Gem. per dag</div> - </div> - <div> - <div className="text-2xl font-semibold text-status-done"> - {fmtUsd(kpi.cacheSavingsUsd)} - </div> - <div className="text-xs text-muted-foreground">Cache-besparing</div> - </div> - <div> - <div className="text-2xl font-semibold text-foreground"> - {kpi.topModelId ? fmtUsd(kpi.topModelCostUsd) : '—'} - </div> - <div className="text-xs text-muted-foreground"> - Top model{kpi.topModelId ? `: ${shortenModel(kpi.topModelId)}` : ''} - </div> - </div> - </div> - {periodSelector} - </div> - - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - {/* Daily cost */} - <div className="space-y-1"> - <div className="text-sm text-muted-foreground">Kosten per dag</div> - {byDay.length === 0 ? ( - <p className="text-sm text-muted-foreground py-8 text-center">Geen data</p> - ) : ( - <ResponsiveContainer width="100%" height={200}> - <BarChart data={byDay}> - <XAxis - dataKey="day" - tick={{ fontSize: 11 }} - stroke="var(--muted-foreground)" - tickFormatter={v => (v as string).slice(5)} - /> - <YAxis - tick={{ fontSize: 11 }} - stroke="var(--muted-foreground)" - tickFormatter={v => fmtUsd(v as number, 2)} - /> - <Tooltip - formatter={value => [fmtUsd(Number(value), 4), 'Kosten']} - /> - <Bar dataKey="costUsd" fill="var(--chart-1)" /> - </BarChart> - </ResponsiveContainer> - )} - </div> - - {/* Per model */} - <div className="space-y-1"> - <div className="text-sm text-muted-foreground">Kosten per model</div> - {modelData.length === 0 ? ( - <p className="text-sm text-muted-foreground py-8 text-center">Geen data</p> - ) : ( - <ResponsiveContainer width="100%" height={200}> - <BarChart data={modelData} layout="vertical"> - <XAxis - type="number" - tick={{ fontSize: 11 }} - stroke="var(--muted-foreground)" - tickFormatter={v => fmtUsd(v as number, 2)} - /> - <YAxis - type="category" - dataKey="label" - tick={{ fontSize: 11 }} - stroke="var(--muted-foreground)" - width={120} - /> - <Tooltip formatter={value => [fmtUsd(Number(value), 4), 'Kosten']} /> - <Bar dataKey="costUsd" fill="var(--chart-2)" /> - </BarChart> - </ResponsiveContainer> - )} - </div> - - {/* Per kind */} - <div className="space-y-1"> - <div className="text-sm text-muted-foreground">Kosten per job-kind</div> - {kindData.length === 0 ? ( - <p className="text-sm text-muted-foreground py-8 text-center">Geen data</p> - ) : ( - <ResponsiveContainer width="100%" height={200}> - <BarChart data={kindData} layout="vertical"> - <XAxis - type="number" - tick={{ fontSize: 11 }} - stroke="var(--muted-foreground)" - tickFormatter={v => fmtUsd(v as number, 2)} - /> - <YAxis - type="category" - dataKey="label" - tick={{ fontSize: 11 }} - stroke="var(--muted-foreground)" - width={120} - /> - <Tooltip formatter={value => [fmtUsd(Number(value), 4), 'Kosten']} /> - <Bar dataKey="costUsd" fill="var(--chart-3)" /> - </BarChart> - </ResponsiveContainer> - )} - </div> - - {/* Cache efficiency */} - <div className="space-y-1"> - <div className="text-sm text-muted-foreground">Cache efficiency</div> - {cache.cacheReadTokens + cache.uncachedInputTokens === 0 ? ( - <p className="text-sm text-muted-foreground py-8 text-center">Geen data</p> - ) : ( - <> - <ResponsiveContainer width="100%" height={160}> - <PieChart> - <Pie - data={cacheData} - dataKey="value" - nameKey="name" - innerRadius={40} - outerRadius={70} - > - {cacheData.map((_, i) => ( - <Cell key={i} fill={cacheColors[i]} /> - ))} - </Pie> - <Tooltip - formatter={(value, name) => [ - Number(value).toLocaleString() + ' tokens', - String(name), - ]} - /> - </PieChart> - </ResponsiveContainer> - <p className="text-sm text-foreground text-center"> - <span className="font-semibold">{(cache.cacheHitRatio * 100).toFixed(1)}%</span>{' '} - cache hit ·{' '} - <span className="text-status-done font-semibold"> - {fmtUsd(cache.savingsUsd)} - </span>{' '} - bespaard - </p> - </> - )} - </div> - </div> - </div> - ) -} diff --git a/app/(app)/insights/components/sprint-info-strip.tsx b/app/(app)/insights/components/sprint-info-strip.tsx index ed3c15b..3d85a33 100644 --- a/app/(app)/insights/components/sprint-info-strip.tsx +++ b/app/(app)/insights/components/sprint-info-strip.tsx @@ -2,7 +2,6 @@ interface SprintInfo { sprintId: string - sprintCode: string productName: string sprintGoal: string taskCount: number @@ -34,7 +33,6 @@ export function SprintInfoStrip({ sprints }: Props) { className="flex items-center gap-3 rounded-lg border border-border bg-surface-container px-3 py-2 text-sm" > <span className="font-medium text-foreground">{s.productName}</span> - <span className="font-mono text-xs text-muted-foreground">{s.sprintCode}</span> <span className="text-muted-foreground">{truncate(s.sprintGoal, 60)}</span> <span className={`font-mono tabular-nums ${daysLeftColor(s.daysLeft)}`}> {s.daysLeft > 0 ? `${s.daysLeft}d over` : `${Math.abs(s.daysLeft)}d over tijd`} diff --git a/app/(app)/insights/components/token-usage.tsx b/app/(app)/insights/components/token-usage.tsx deleted file mode 100644 index 4bb4f90..0000000 --- a/app/(app)/insights/components/token-usage.tsx +++ /dev/null @@ -1,109 +0,0 @@ -'use client' - -import { useState, useMemo } from 'react' -import type { TokenKpi, TokenJobRow } from '@/lib/insights/token-stats' - -export interface TokenUsageCardProps { - kpi: TokenKpi - jobs: TokenJobRow[] -} - -type SortKey = 'cost' | 'duration' - -function fmt(n: number | null, decimals = 0): string { - if (n === null) return '—' - return n.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals }) -} - -function fmtCost(n: number | null): string { - if (n === null) return '—' - return '$' + n.toFixed(4) -} - -function jobLabel(job: TokenJobRow): string { - const label = job.taskTitle ?? job.ideaCode ?? job.jobId - return label.length > 40 ? label.slice(0, 37) + '…' : label -} - -export function TokenUsageCard({ kpi, jobs }: TokenUsageCardProps) { - const [sortKey, setSortKey] = useState<SortKey>('cost') - - const sorted = useMemo(() => { - return [...jobs].sort((a, b) => { - if (sortKey === 'cost') return (b.costUsd ?? -Infinity) - (a.costUsd ?? -Infinity) - return (b.durationSeconds ?? -Infinity) - (a.durationSeconds ?? -Infinity) - }) - }, [jobs, sortKey]) - - if (kpi.jobCount === 0) { - return <p className="text-muted-foreground">Geen token-data</p> - } - - return ( - <div className="space-y-4"> - {/* KPI strip */} - <div className="flex gap-6"> - <div> - <div className="text-2xl font-semibold text-foreground"> - {kpi.totalTokens.toLocaleString()} - </div> - <div className="text-xs text-muted-foreground">Totaal tokens</div> - </div> - <div> - <div className="text-2xl font-semibold text-foreground"> - ${kpi.totalCostUsd.toFixed(4)} - </div> - <div className="text-xs text-muted-foreground">Kosten (USD)</div> - </div> - <div> - <div className="text-2xl font-semibold text-foreground"> - {kpi.avgCostPerJob ? '$' + kpi.avgCostPerJob.toFixed(4) : '—'} - </div> - <div className="text-xs text-muted-foreground">Gem. per job</div> - </div> - </div> - - {/* Sortable table */} - <div className="overflow-x-auto"> - <table className="w-full text-sm"> - <thead> - <tr className="border-b border-border bg-muted text-muted-foreground text-xs uppercase tracking-wide"> - <th className="py-2 pr-3 text-left font-medium">Taak</th> - <th className="py-2 pr-3 text-left font-medium">Model</th> - <th className="py-2 pr-3 text-right font-medium">Input</th> - <th className="py-2 pr-3 text-right font-medium">Output</th> - <th className="py-2 pr-3 text-right font-medium">Cache-R</th> - <th className="py-2 pr-3 text-right font-medium">Cache-W</th> - <th - className={`py-2 pr-3 text-right font-medium cursor-pointer select-none ${sortKey === 'cost' ? 'text-primary' : ''}`} - onClick={() => setSortKey('cost')} - > - Kosten (USD) {sortKey === 'cost' ? '▾' : ''} - </th> - <th - className={`py-2 text-right font-medium cursor-pointer select-none ${sortKey === 'duration' ? 'text-primary' : ''}`} - onClick={() => setSortKey('duration')} - > - Duur (s) {sortKey === 'duration' ? '▾' : ''} - </th> - </tr> - </thead> - <tbody> - {sorted.map(job => ( - <tr key={job.jobId} className="border-b border-border last:border-0"> - <td className="py-2 pr-3 text-foreground max-w-48 truncate">{jobLabel(job)}</td> - <td className="py-2 pr-3 text-muted-foreground whitespace-nowrap">{job.modelId ?? '—'}</td> - <td className="py-2 pr-3 text-right tabular-nums">{fmt(job.inputTokens)}</td> - <td className="py-2 pr-3 text-right tabular-nums">{fmt(job.outputTokens)}</td> - <td className="py-2 pr-3 text-right tabular-nums">{fmt(job.cacheReadTokens)}</td> - <td className="py-2 pr-3 text-right tabular-nums">{fmt(job.cacheWriteTokens)}</td> - <td className="py-2 pr-3 text-right tabular-nums">{fmtCost(job.costUsd)}</td> - <td className="py-2 text-right tabular-nums">{fmt(job.durationSeconds, 1)}</td> - </tr> - ))} - </tbody> - </table> - </div> - </div> - ) -} diff --git a/app/(app)/insights/components/velocity-chart.tsx b/app/(app)/insights/components/velocity-chart.tsx index a05df7f..7cd2d9e 100644 --- a/app/(app)/insights/components/velocity-chart.tsx +++ b/app/(app)/insights/components/velocity-chart.tsx @@ -35,9 +35,11 @@ export function VelocityChart({ data }: Props) { type Row = { sprintLabel: string } & Record<string, number | string> const grouped = new Map<string, Row>() for (const s of sprints) { + const label = + s.sprintGoal.length > 14 ? s.sprintGoal.slice(0, 14) + '…' : s.sprintGoal const key = `${s.sprintId}` if (!grouped.has(key)) { - grouped.set(key, { sprintLabel: s.sprintCode }) + grouped.set(key, { sprintLabel: label }) } grouped.get(key)![s.productName] = s.doneCount } diff --git a/app/(app)/insights/page.tsx b/app/(app)/insights/page.tsx index 802258c..77164d5 100644 --- a/app/(app)/insights/page.tsx +++ b/app/(app)/insights/page.tsx @@ -7,15 +7,6 @@ import { getBurndownData } from '@/lib/insights/burndown' import { getSprintStatusBreakdown } from '@/lib/insights/sprint-status' import { getVerifyResultStats, getAlignmentTrend } from '@/lib/insights/verify-stats' import { getJobsPerDay } from '@/lib/insights/agent-throughput' -import { getTokenStats } from '@/lib/insights/token-stats' -import { - getCostKpi, - getCostByDay, - getCostByModel, - getCostByKind, - getCacheEfficiency, - type Period, -} from '@/lib/insights/cost-analysis' import { getVelocity } from '@/lib/insights/velocity' import { getBacklogHealth } from '@/lib/insights/backlog-health' import { SprintInfoStrip } from './components/sprint-info-strip' @@ -24,8 +15,6 @@ import { SprintStatusDonut } from './components/sprint-status-donut' import { PlanQualityCard } from './components/plan-quality' import { AlignmentTrend } from './components/alignment-trend' import { AgentThroughputCard } from './components/agent-throughput' -import { CostAnalysisCard } from './components/cost-analysis' -import { TokenUsageCard } from './components/token-usage' import { VelocityChart } from './components/velocity-chart' import { BacklogHealthCard } from './components/backlog-health' @@ -33,13 +22,7 @@ const DAY_MS = 86_400_000 const ASSUMED_SPRINT_DAYS = 14 interface InsightsPageProps { - searchParams: Promise<{ product?: string; period?: string }> -} - -const VALID_PERIODS = ['7d', '30d', '90d', 'mtd'] as const - -function parsePeriod(raw: string | undefined): Period { - return (VALID_PERIODS as readonly string[]).includes(raw ?? '') ? (raw as Period) : '30d' + searchParams: Promise<{ product?: string }> } function MissingDatesNotice({ productId, productName }: { productId: string; productName: string }) { @@ -56,8 +39,7 @@ function MissingDatesNotice({ productId, productName }: { productId: string; pro export default async function InsightsPage({ searchParams }: InsightsPageProps) { const session = await getIronSession<SessionData>(await cookies(), sessionOptions) const userId = session.userId! - const { product: filterProductId, period: rawPeriod } = await searchParams - const period = parsePeriod(rawPeriod) + const { product: filterProductId } = await searchParams const [ burndownSprints, @@ -69,19 +51,13 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps) jobsPerDay, velocity, backlogHealth, - costKpi, - costByDay, - costByModel, - costByKind, - cacheEff, ] = await Promise.all([ getBurndownData(userId), getSprintStatusBreakdown(userId), prisma.sprint.findMany({ - where: { status: 'OPEN', product: productAccessFilter(userId) }, + where: { status: 'ACTIVE', product: productAccessFilter(userId) }, select: { id: true, - code: true, sprint_goal: true, created_at: true, product: { select: { id: true, name: true } }, @@ -98,24 +74,13 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps) getJobsPerDay(userId, 14, filterProductId), getVelocity(userId, 5), getBacklogHealth(userId), - getCostKpi(userId, period), - getCostByDay(userId, period), - getCostByModel(userId, period), - getCostByKind(userId, period), - getCacheEfficiency(userId, period), ]) - const activeSprintId = activeSprints.find(s => s.product.id === filterProductId)?.id ?? '' - const tokenStats = await (activeSprints.length > 0 && filterProductId - ? getTokenStats(userId, activeSprintId) - : Promise.resolve({ kpi: { totalTokens: 0, totalCostUsd: 0, avgCostPerJob: 0, jobCount: 0 }, jobs: [] })) - // Date.now is an impure call but used once per request — safe in a Server Component. // eslint-disable-next-line react-hooks/purity const nowMs = Date.now() const sprintInfos = activeSprints.map(s => ({ sprintId: s.id, - sprintCode: s.code, productId: s.product.id, productName: s.product.name, sprintGoal: s.sprint_goal, @@ -160,19 +125,6 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps) )} </section> - {/* Cost analyse */} - <section className="space-y-3"> - <h2 className="text-lg font-medium text-foreground">Cost analyse</h2> - <CostAnalysisCard - period={period} - kpi={costKpi} - byDay={costByDay} - byModel={costByModel} - byKind={costByKind} - cache={cacheEff} - /> - </section> - {/* Plan-quality */} <section className="space-y-3"> <h2 className="text-lg font-medium text-foreground">Plan-quality</h2> @@ -190,12 +142,6 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps) /> </section> - {/* Token usage */} - <section className="space-y-3"> - <h2 className="text-lg font-medium text-foreground">Token gebruik</h2> - <TokenUsageCard kpi={tokenStats.kpi} jobs={tokenStats.jobs} /> - </section> - {/* Velocity */} <section className="space-y-3"> <h2 className="text-lg font-medium text-foreground">Velocity</h2> diff --git a/app/(app)/jobs/page.tsx b/app/(app)/jobs/page.tsx deleted file mode 100644 index 25c571b..0000000 --- a/app/(app)/jobs/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { redirect } from 'next/navigation' -import { getSession } from '@/lib/auth' -import { fetchJobsPageData } from '@/actions/jobs-page' -import JobsBoard from '@/components/jobs/jobs-board' -import JobsTimeFilter from '@/components/jobs/jobs-time-filter' - -export const metadata = { title: 'Jobs — Scrum4Me' } - -export default async function JobsPage() { - const session = await getSession() - if (!session.userId) redirect('/login') - - const data = await fetchJobsPageData() - if (!data) redirect('/login') - - return ( - <main className="flex-1 flex flex-col overflow-hidden"> - <div className="px-6 py-4 border-b shrink-0 flex items-center justify-between gap-3"> - <h1 className="text-lg font-semibold">Jobs</h1> - <JobsTimeFilter /> - </div> - <div className="flex-1 overflow-hidden"> - <JobsBoard initialActiveJobs={data.activeJobs} initialDoneJobs={data.doneJobs} isDemo={session.isDemo ?? false} /> - </div> - </main> - ) -} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 15eab5a..384828b 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,25 +1,36 @@ import { redirect } from 'next/navigation' -import { requireSession } from '@/lib/auth-guard' +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { SessionData, sessionOptions } from '@/lib/session' +import { isPairedSessionExpired } from '@/lib/auth/pairing' import { prisma } from '@/lib/prisma' import { productAccessFilter } from '@/lib/product-access' -import { resolveActiveSprint } from '@/lib/active-sprint' import { NavBar } from '@/components/shared/nav-bar' import { MinWidthBanner } from '@/components/shared/min-width-banner' import { StatusBar } from '@/components/shared/status-bar' import { SoloRealtimeBridge } from '@/components/solo/realtime-bridge' import { NotificationsBridge } from '@/components/notifications/notifications-bridge' -import { UserSettingsBridge } from '@/components/shared/user-settings-bridge' -import { parseUserSettings } from '@/lib/user-settings' import { AlertToast } from '@/components/shared/alert-toast' import { Suspense } from 'react' export default async function AppLayout({ children }: { children: React.ReactNode }) { - const session = await requireSession() + const session = await getIronSession<SessionData>(await cookies(), sessionOptions) + + if (!session.userId) { + redirect('/login') + } + + // ST-1002 (M10): paired-sessies (via QR-pairing) hebben een eigen kortere TTL. + // Vervallen → vernietig en stuur naar /login. + if (isPairedSessionExpired(session)) { + session.destroy() + redirect('/login') + } const [user, userRoles, accessibleProducts] = await Promise.all([ prisma.user.findUnique({ where: { id: session.userId }, - select: { username: true, email: true, active_product_id: true, min_quota_pct: true, settings: true }, + select: { username: true, email: true, active_product_id: true }, }), prisma.userRole.findMany({ where: { user_id: session.userId }, @@ -47,8 +58,11 @@ export default async function AppLayout({ children }: { children: React.ReactNod }) if (product) { activeProduct = product - const resolved = await resolveActiveSprint(product.id, session.userId) - hasActiveSprint = !!resolved + const sprint = await prisma.sprint.findFirst({ + where: { product_id: product.id, status: 'ACTIVE' }, + select: { id: true }, + }) + hasActiveSprint = !!sprint } else { await prisma.user.update({ where: { id: session.userId }, @@ -72,7 +86,6 @@ export default async function AppLayout({ children }: { children: React.ReactNod activeProduct={activeProduct} products={accessibleProducts} hasActiveSprint={hasActiveSprint} - minQuotaPct={user.min_quota_pct} /> <MinWidthBanner /> <main id="main-content" className="flex-1 flex flex-col overflow-y-auto min-h-0"> @@ -81,10 +94,6 @@ export default async function AppLayout({ children }: { children: React.ReactNod <StatusBar /> <SoloRealtimeBridge productId={activeProduct?.id ?? null} /> <NotificationsBridge userId={session.userId} /> - <UserSettingsBridge - initial={parseUserSettings(user.settings)} - isDemo={session.isDemo ?? false} - /> <Suspense> <AlertToast /> </Suspense> diff --git a/app/(mobile)/m/pair/page.tsx b/app/(app)/m/pair/page.tsx similarity index 79% rename from app/(mobile)/m/pair/page.tsx rename to app/(app)/m/pair/page.tsx index ba7430a..ee665c2 100644 --- a/app/(mobile)/m/pair/page.tsx +++ b/app/(app)/m/pair/page.tsx @@ -1,8 +1,7 @@ // ST-1005: Mobiele bevestigingspagina voor de QR-pairing-flow (M10). // -// Server Component achter de (mobile)/layout.tsx auth-guard (route group -// (mobile) per ST-1134/PBI-11) — onbekende mobielen worden eerst naar /login -// gestuurd. Bewust géén searchParams +// Server Component achter de bestaande (app)/layout.tsx auth-guard — onbekende +// mobielen worden eerst naar /login gestuurd. Bewust géén searchParams // uitlezen: het mobileSecret zit in het URL-fragment (#id=…&s=…), wat alleen // client-side leesbaar is. De Client Component PairConfirmation parseert // location.hash en doet de Server Action-calls. diff --git a/app/(mobile)/m/pair/pair-confirmation.tsx b/app/(app)/m/pair/pair-confirmation.tsx similarity index 100% rename from app/(mobile)/m/pair/pair-confirmation.tsx rename to app/(app)/m/pair/pair-confirmation.tsx diff --git a/app/(app)/manual/[[...slug]]/page.tsx b/app/(app)/manual/[[...slug]]/page.tsx deleted file mode 100644 index ccc330b..0000000 --- a/app/(app)/manual/[[...slug]]/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { Metadata } from 'next' -import { notFound } from 'next/navigation' -import { getManualChapter, getManualToc } from '@/lib/manual-server' -import { MarkdownView } from '../_components/markdown-view' - -type Params = { slug?: string[] } - -export async function generateStaticParams(): Promise<Params[]> { - return getManualToc().map((entry) => ({ - slug: entry.slug.length > 0 ? [...entry.slug] : undefined, - })) -} - -export async function generateMetadata({ - params, -}: { - params: Promise<Params> -}): Promise<Metadata> { - const { slug = [] } = await params - const chapter = getManualChapter(slug) - if (!chapter) return { title: 'Manual — not found' } - return { - title: `${chapter.entry.title} — Scrum4Me Manual`, - description: chapter.entry.description.slice(0, 200), - } -} - -export default async function ManualChapterPage({ - params, -}: { - params: Promise<Params> -}) { - const { slug = [] } = await params - const chapter = getManualChapter(slug) - if (!chapter) notFound() - - return ( - <div className="mx-auto w-full max-w-3xl px-6 py-8"> - <MarkdownView markdown={chapter.body} /> - </div> - ) -} diff --git a/app/(app)/manual/_components/manual-sidebar.tsx b/app/(app)/manual/_components/manual-sidebar.tsx deleted file mode 100644 index 9643ed3..0000000 --- a/app/(app)/manual/_components/manual-sidebar.tsx +++ /dev/null @@ -1,51 +0,0 @@ -'use client' - -import Link from 'next/link' -import { usePathname } from 'next/navigation' -import { cn } from '@/lib/utils' -import type { ManualEntry } from '@/lib/manual.generated' - -type Props = { - toc: readonly ManualEntry[] -} - -function entryHref(entry: ManualEntry): string { - if (entry.slug.length === 0) return '/manual' - return '/manual/' + entry.slug.join('/') -} - -export function ManualSidebar({ toc }: Props) { - const pathname = usePathname() - - return ( - <nav - aria-label="Manual chapters" - className="sticky top-20 hidden h-[calc(100vh-6rem)] w-64 shrink-0 overflow-y-auto border-r border-border px-4 py-6 lg:block" - > - <p className="mb-2 px-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"> - Manual - </p> - <ul className="space-y-1"> - {toc.map((entry) => { - const href = entryHref(entry) - const isActive = pathname === href - return ( - <li key={href}> - <Link - href={href} - className={cn( - 'block rounded-md px-3 py-2 text-sm transition-colors', - isActive - ? 'bg-primary/10 font-medium text-primary' - : 'text-foreground hover:bg-muted hover:text-foreground' - )} - > - {entry.title} - </Link> - </li> - ) - })} - </ul> - </nav> - ) -} diff --git a/app/(app)/manual/_components/markdown-view.tsx b/app/(app)/manual/_components/markdown-view.tsx deleted file mode 100644 index 421477f..0000000 --- a/app/(app)/manual/_components/markdown-view.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import ReactMarkdown from 'react-markdown' -import remarkGfm from 'remark-gfm' -import rehypeSlug from 'rehype-slug' -import rehypeAutolinkHeadings from 'rehype-autolink-headings' -import { MermaidBlock } from './mermaid-block' - -type Props = { - markdown: string -} - -export function MarkdownView({ markdown }: Props) { - return ( - <article className="prose prose-neutral max-w-none dark:prose-invert prose-headings:scroll-mt-20 prose-a:text-primary prose-code:rounded prose-code:bg-muted prose-code:px-1 prose-code:py-0.5 prose-code:text-sm prose-pre:bg-muted prose-pre:text-foreground"> - <ReactMarkdown - remarkPlugins={[remarkGfm]} - rehypePlugins={[ - rehypeSlug, - [rehypeAutolinkHeadings, { behavior: 'wrap' }], - ]} - components={{ - code(props) { - const { className, children } = props as { - className?: string - children?: React.ReactNode - } - const match = /language-(\w+)/.exec(className ?? '') - const lang = match?.[1] - const text = String(children ?? '').replace(/\n$/, '') - if (lang === 'mermaid') { - return <MermaidBlock source={text} /> - } - return ( - <code className={className}>{children}</code> - ) - }, - }} - > - {markdown} - </ReactMarkdown> - </article> - ) -} diff --git a/app/(app)/manual/_components/mermaid-block.tsx b/app/(app)/manual/_components/mermaid-block.tsx deleted file mode 100644 index 66e52db..0000000 --- a/app/(app)/manual/_components/mermaid-block.tsx +++ /dev/null @@ -1,73 +0,0 @@ -'use client' - -import { useEffect, useId, useRef, useState } from 'react' - -type Props = { - source: string -} - -let mermaidPromise: Promise<typeof import('mermaid').default> | null = null - -function loadMermaid() { - if (!mermaidPromise) { - mermaidPromise = import('mermaid').then((mod) => { - const mermaid = mod.default - mermaid.initialize({ - startOnLoad: false, - theme: 'default', - securityLevel: 'strict', - fontFamily: 'inherit', - }) - return mermaid - }) - } - return mermaidPromise -} - -export function MermaidBlock({ source }: Props) { - const id = useId().replace(/[^a-zA-Z0-9]/g, '') - const containerRef = useRef<HTMLDivElement | null>(null) - const [error, setError] = useState<string | null>(null) - - useEffect(() => { - let cancelled = false - loadMermaid() - .then(async (mermaid) => { - if (cancelled) return - try { - const { svg } = await mermaid.render(`mermaid-${id}`, source) - if (cancelled) return - if (containerRef.current) containerRef.current.innerHTML = svg - setError(null) - } catch (err) { - if (cancelled) return - setError(err instanceof Error ? err.message : String(err)) - } - }) - .catch((err) => { - if (cancelled) return - setError(err instanceof Error ? err.message : String(err)) - }) - return () => { - cancelled = true - } - }, [id, source]) - - if (error) { - return ( - <pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs text-destructive"> - <code> - {`Mermaid render failed: ${error}\n\n${source}`} - </code> - </pre> - ) - } - - return ( - <div - ref={containerRef} - className="my-4 flex justify-center overflow-x-auto rounded-md bg-muted p-4 [&_svg]:max-w-full" - aria-label="Diagram" - /> - ) -} diff --git a/app/(app)/manual/layout.tsx b/app/(app)/manual/layout.tsx deleted file mode 100644 index 06ebc7e..0000000 --- a/app/(app)/manual/layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { getManualToc } from '@/lib/manual-server' -import { ManualSidebar } from './_components/manual-sidebar' - -export default function ManualLayout({ - children, -}: { - children: React.ReactNode -}) { - const toc = getManualToc() - return ( - <div className="flex w-full"> - <ManualSidebar toc={toc} /> - <main className="min-w-0 flex-1">{children}</main> - </div> - ) -} diff --git a/app/(app)/products/[id]/loading.tsx b/app/(app)/products/[id]/loading.tsx index 8dcb9c1..795b2c5 100644 --- a/app/(app)/products/[id]/loading.tsx +++ b/app/(app)/products/[id]/loading.tsx @@ -1 +1,34 @@ -export { default } from '@/components/loading/backlog-page-skeleton' +export default function Loading() { + return ( + <div className="flex flex-col h-full animate-pulse"> + {/* Header skeleton */} + <div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-between"> + <div className="space-y-1.5"> + <div className="h-4 w-32 bg-border rounded" /> + <div className="h-3 w-48 bg-border/60 rounded" /> + </div> + <div className="h-7 w-24 bg-border rounded" /> + </div> + + {/* Split pane skeleton */} + <div className="flex-1 flex overflow-hidden"> + {/* Left */} + <div className="w-2/5 border-r border-border p-4 space-y-3"> + <div className="h-4 w-24 bg-border rounded" /> + {[1, 2, 3, 4, 5].map(i => ( + <div key={i} className="h-8 bg-border/50 rounded" /> + ))} + </div> + {/* Right */} + <div className="flex-1 p-4 space-y-3"> + <div className="h-4 w-16 bg-border rounded" /> + <div className="flex gap-2 flex-wrap"> + {[1, 2, 3].map(i => ( + <div key={i} className="w-28 h-24 bg-border/50 rounded-lg" /> + ))} + </div> + </div> + </div> + </div> + ) +} diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index 161b4dc..85519dd 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -4,25 +4,17 @@ import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' import { prisma } from '@/lib/prisma' import { pbiStatusToApi } from '@/lib/task-status' -import { getSprintSwitcherData } from '@/lib/sprint-switcher-data' import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane' import { PbiList } from '@/components/backlog/pbi-list' import { StoryPanel } from '@/components/backlog/story-panel' import type { Story } from '@/components/backlog/story-panel' import { TaskPanel } from '@/components/backlog/task-panel' import { BacklogHydrationWrapper } from '@/components/backlog/backlog-hydration-wrapper' -import { UrlTaskSync } from '@/components/backlog/url-task-sync' import { TaskDialog } from '@/app/_components/tasks/task-dialog' import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader' import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton' -import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger' -import { SprintDraftBanner } from '@/components/backlog/sprint-draft-banner' -import { SprintDraftLeaveGuard } from '@/components/backlog/sprint-draft-leave-guard' -import { SaveSprintButton } from '@/components/backlog/save-sprint-button' -import { ActiveSelectionHydrator } from '@/components/backlog/active-selection-hydrator' +import { StartSprintButton } from '@/components/sprint/start-sprint-button' import { ActivateProductButton } from '@/components/shared/activate-product-button' -import { EditProductButton } from '@/components/products/edit-product-button' -import { SprintSwitcher } from '@/components/shared/sprint-switcher' import Link from 'next/link' interface Props { @@ -40,13 +32,10 @@ export default async function ProductBacklogPage({ params, searchParams }: Props const product = await getAccessibleProduct(id, session.userId) if (!product) notFound() - const [user, switcherData] = await Promise.all([ + const [activeSprint, user] = await Promise.all([ + prisma.sprint.findFirst({ where: { product_id: id, status: 'ACTIVE' } }), prisma.user.findUnique({ where: { id: session.userId! }, select: { active_product_id: true } }), - getSprintSwitcherData(id, { userId: session.userId }), ]) - const { sprintItems, buildingSprintIds, activeSprintItem } = switcherData - const hasOpenSprint = sprintItems.some(s => s.status === 'open') - const isActiveProduct = user?.active_product_id === id const pbis = await prisma.pbi.findMany({ where: { product_id: id }, @@ -56,7 +45,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props const [stories, tasks] = await Promise.all([ prisma.story.findMany({ where: { product_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], select: { id: true, code: true, @@ -64,10 +53,8 @@ export default async function ProductBacklogPage({ params, searchParams }: Props description: true, acceptance_criteria: true, priority: true, - sort_order: true, status: true, pbi_id: true, - sprint_id: true, created_at: true, }, }), @@ -75,7 +62,6 @@ export default async function ProductBacklogPage({ params, searchParams }: Props where: { story: { pbi: { product_id: id } } }, select: { id: true, - code: true, title: true, description: true, priority: true, @@ -84,11 +70,11 @@ export default async function ProductBacklogPage({ params, searchParams }: Props story_id: true, created_at: true, }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], }), ]) - // Group stories by PBI id (status uit DB blijft UPPER_SNAKE in dit hydratie-pad) + // Group stories by PBI id const storiesByPbi: Record<string, Story[]> = {} for (const story of stories) { if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = [] @@ -106,50 +92,18 @@ export default async function ProductBacklogPage({ params, searchParams }: Props return ( <div className="flex flex-col h-full"> - {/* Product header — sprint-switcher gecentreerd, actions rechts */} - <div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center gap-3"> - <div className="flex-1" /> - <div className="flex items-center justify-center"> - {isActiveProduct && ( - <SprintSwitcher - productId={id} - sprints={sprintItems} - activeSprint={activeSprintItem} - buildingSprintIds={buildingSprintIds} - /> - )} - </div> - <div className="flex-1 flex items-center gap-3 justify-end"> - {!isActiveProduct && ( + {/* Product header — actions only; product-naam zit al in NavBar */} + <div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-end"> + <div className="flex items-center gap-3"> + {user?.active_product_id !== id && ( <ActivateProductButton productId={id} isDemo={isDemo} redirectTo={`/products/${id}`} /> )} - {hasOpenSprint && ( + {activeSprint ? ( <Link href={`/products/${id}/sprint`} className="text-xs text-primary hover:underline font-medium"> Sprint actief → </Link> - )} - {activeSprintItem && !isDemo && ( - <SaveSprintButton activeSprintId={activeSprintItem.id} /> - )} - {!isDemo && ( - <NewSprintTrigger - productId={id} - isDemo={isDemo} - isActiveProduct={isActiveProduct} - /> - )} - {!isDemo && product.user_id === session.userId && ( - <EditProductButton - product={{ - id: product.id, - name: product.name, - code: product.code, - description: product.description, - repo_url: product.repo_url, - definition_of_done: product.definition_of_done, - auto_pr: product.auto_pr, - }} - /> + ) : ( + !isDemo && <StartSprintButton productId={id} /> )} <Link href={`/products/${id}/settings`} @@ -160,23 +114,16 @@ export default async function ProductBacklogPage({ params, searchParams }: Props </div> </div> - {/* Sprint definition banner (state A′) + beforeunload-guard */} - <SprintDraftBanner productId={id} /> - <SprintDraftLeaveGuard productId={id} /> - {/* Split pane */} <div className="flex-1 overflow-hidden"> <BacklogHydrationWrapper productId={id} - productName={product.name} initialData={{ - pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, sort_order: p.sort_order, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), + pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), storiesByPbi, tasksByStory, }} > - <UrlTaskSync /> - <ActiveSelectionHydrator productId={id} /> <BacklogSplitPane cookieKey={`backlog-${id}`} defaultSplit={[20, 45, 35]} @@ -186,13 +133,11 @@ export default async function ProductBacklogPage({ params, searchParams }: Props key="pbi" productId={id} isDemo={isDemo} - activeSprintId={activeSprintItem?.id ?? null} />, <StoryPanel key="story" productId={id} isDemo={isDemo} - activeSprintId={activeSprintItem?.id ?? null} />, <TaskPanel key="tasks" diff --git a/app/(app)/products/[id]/settings/page.tsx b/app/(app)/products/[id]/settings/page.tsx index 046a994..11748b0 100644 --- a/app/(app)/products/[id]/settings/page.tsx +++ b/app/(app)/products/[id]/settings/page.tsx @@ -6,9 +6,8 @@ import { prisma } from '@/lib/prisma' import { ProductForm } from '@/components/products/product-form' import { ArchiveProductButton } from '@/components/products/archive-product-button' import { TeamManager } from '@/components/products/team-manager' -import { updateProductFormAction } from '@/actions/products' +import { updateProductAction } from '@/actions/products' import { AutoPrToggle } from '@/components/products/auto-pr-toggle' -import { PrStrategySelect } from '@/components/products/pr-strategy-select' import Link from 'next/link' interface Props { @@ -45,7 +44,7 @@ export default async function ProductSettingsPage({ params }: Props) { </div> <ProductForm - action={updateProductFormAction} + action={updateProductAction} submitLabel="Opslaan" defaultValues={{ id: product.id, @@ -67,17 +66,6 @@ export default async function ProductSettingsPage({ params }: Props) { <AutoPrToggle productId={id} initialValue={product.auto_pr} /> </div> - <div className="mt-8 pt-6 border-t border-border space-y-3"> - <div> - <h2 className="text-sm font-medium text-foreground">PR-strategie</h2> - <p className="text-xs text-muted-foreground mt-0.5"> - Bepaalt hoe de sprint zijn werk oplevert: één PR voor de hele sprint - of een PR per story die automatisch wordt gemerged na groene CI. - </p> - </div> - <PrStrategySelect productId={id} initialValue={product.pr_strategy} /> - </div> - <div className="mt-8 pt-6 border-t border-border space-y-3"> <div> <h2 className="text-sm font-medium text-foreground">Team</h2> diff --git a/app/(app)/products/[id]/solo/page.tsx b/app/(app)/products/[id]/solo/page.tsx index b19ec4a..3ad8a25 100644 --- a/app/(app)/products/[id]/solo/page.tsx +++ b/app/(app)/products/[id]/solo/page.tsx @@ -1,12 +1,11 @@ import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' -import { getSprintSwitcherData } from '@/lib/sprint-switcher-data' -import { getSoloWorkspaceSnapshot } from '@/lib/solo-workspace-server' +import { prisma } from '@/lib/prisma' import { SoloBoard } from '@/components/solo/solo-board' -import { SoloHydrationWrapper } from '@/components/solo/solo-hydration-wrapper' import { NoActiveSprint } from '@/components/solo/no-active-sprint' -import { SprintSwitcher } from '@/components/shared/sprint-switcher' +import type { SoloTask } from '@/components/solo/solo-board' +import type { UnassignedStory } from '@/components/solo/unassigned-stories-sheet' interface Props { params: Promise<{ id: string }> @@ -20,45 +19,100 @@ export default async function SoloProductPage({ params }: Props) { const product = await getAccessibleProduct(id, session.userId) if (!product) notFound() - const initialData = await getSoloWorkspaceSnapshot(id, session.userId) - const switcherData = await getSprintSwitcherData(id, { - activeSprintId: initialData?.sprint.id ?? null, + const sprint = await prisma.sprint.findFirst({ + where: { product_id: id, status: 'ACTIVE' }, }) - const switcherBar = ( - <div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-center"> - <SprintSwitcher - productId={id} - sprints={switcherData.sprintItems} - activeSprint={switcherData.activeSprintItem} - buildingSprintIds={switcherData.buildingSprintIds} - /> - </div> - ) - - if (!initialData) { + if (!sprint) { return ( <div className="flex flex-col h-full"> - {switcherBar} <NoActiveSprint productId={id} productName={product.name} /> </div> ) } + const [rawTasks, rawUnassigned] = await Promise.all([ + prisma.task.findMany({ + where: { + story: { + sprint_id: sprint.id, + assignee_id: session.userId, + }, + }, + include: { + story: { + select: { + id: true, + code: true, + title: true, + tasks: { select: { id: true }, orderBy: { sort_order: 'asc' } }, + }, + }, + }, + orderBy: [ + { story: { sort_order: 'asc' } }, + { priority: 'asc' }, + { sort_order: 'asc' }, + ], + }), + prisma.story.findMany({ + where: { sprint_id: sprint.id, assignee_id: null }, + select: { + id: true, + code: true, + title: true, + tasks: { + select: { id: true, title: true, description: true, priority: true, status: true }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + }, + }, + orderBy: { sort_order: 'asc' }, + }), + ]) + + const tasks: SoloTask[] = rawTasks.map(t => { + const positionInStory = t.story.tasks.findIndex(st => st.id === t.id) + const taskCode = + t.story.code && positionInStory >= 0 ? `${t.story.code}.${positionInStory + 1}` : null + return { + id: t.id, + title: t.title, + description: t.description, + implementation_plan: t.implementation_plan, + priority: t.priority, + sort_order: t.sort_order, + status: t.status as SoloTask['status'], + verify_only: t.verify_only, + verify_required: t.verify_required as SoloTask['verify_required'], + story_id: t.story.id, + story_code: t.story.code, + story_title: t.story.title, + task_code: taskCode, + } + }) + + const unassignedStories: UnassignedStory[] = rawUnassigned.map(s => ({ + id: s.id, + code: s.code, + title: s.title, + tasks: s.tasks.map(t => ({ + id: t.id, + title: t.title, + description: t.description, + priority: t.priority, + status: t.status, + })), + })) + return ( - <div className="flex flex-col h-full"> - {switcherBar} - <div className="flex-1 min-h-0"> - <SoloHydrationWrapper initialData={initialData}> - <SoloBoard - key={initialData.sprint.id} - productId={id} - sprintGoal={initialData.sprint.sprint_goal} - isDemo={session.isDemo ?? false} - repoUrl={product.repo_url} - /> - </SoloHydrationWrapper> - </div> - </div> + <SoloBoard + productId={id} + sprintGoal={sprint.sprint_goal} + tasks={tasks} + unassignedStories={unassignedStories} + isDemo={session.isDemo ?? false} + currentUserId={session.userId} + repoUrl={product.repo_url} + /> ) } diff --git a/app/(app)/products/[id]/sprint/[sprintId]/loading.tsx b/app/(app)/products/[id]/sprint/[sprintId]/loading.tsx deleted file mode 100644 index 8dcb9c1..0000000 --- a/app/(app)/products/[id]/sprint/[sprintId]/loading.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/components/loading/backlog-page-skeleton' diff --git a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx deleted file mode 100644 index 2981c47..0000000 --- a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { notFound, redirect } from 'next/navigation' -import { getSession } from '@/lib/auth' -import { getAccessibleProduct } from '@/lib/product-access' -import { prisma } from '@/lib/prisma' -import { pbiStatusToApi } from '@/lib/task-status' -import { SprintBoardClient } from '@/components/sprint/sprint-board-client' -import { - SprintHydrationWrapper, - type SprintHydrationData, -} from '@/components/sprint/sprint-hydration-wrapper' -import { SprintTaskDialogMount } from '@/components/sprint/sprint-task-dialog-mount' -import { SprintUrlTaskSync } from '@/components/sprint/sprint-url-task-sync' -import { SyncActiveSprintCookie } from '@/components/sprint/sync-active-sprint-cookie' -import { getSprintSwitcherData } from '@/lib/sprint-switcher-data' -import { SprintHeader } from '@/components/sprint/sprint-header' -import { SprintRunControls } from '@/components/sprint/sprint-run-controls' -import { parsePauseContext } from '@/lib/pause-context' -import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog' -import type { SprintWorkspaceTask } from '@/stores/sprint-workspace/types' -import { TaskDialog } from '@/app/_components/tasks/task-dialog' -import Link from 'next/link' - -interface Props { - params: Promise<{ id: string; sprintId: string }> - searchParams: Promise<{ - newTask?: string - storyId?: string - }> -} - -export default async function SprintBoardPage({ params, searchParams }: Props) { - const { id, sprintId } = await params - const { newTask, storyId: storyIdParam } = await searchParams - - const session = await getSession() - if (!session.userId) redirect('/login') - - const product = await getAccessibleProduct(id, session.userId) - if (!product) notFound() - - const sprint = await prisma.sprint.findFirst({ - where: { id: sprintId, product_id: id }, - select: { - id: true, - code: true, - sprint_goal: true, - status: true, - start_date: true, - end_date: true, - }, - }) - if (!sprint) notFound() - - const switcherData = await getSprintSwitcherData(id, { activeSprintId: sprint.id }) - - const activeSprintRun = await prisma.sprintRun.findFirst({ - where: { - sprint_id: sprint.id, - status: { in: ['QUEUED', 'RUNNING', 'PAUSED'] }, - }, - select: { id: true, status: true, pause_context: true }, - orderBy: { created_at: 'desc' }, - }) - const pauseContext = - activeSprintRun?.status === 'PAUSED' - ? parsePauseContext(activeSprintRun.pause_context) - : null - - // Sprint stories with full task data and assignee - const [sprintStories, productMembers] = await Promise.all([ - prisma.story.findMany({ - where: { sprint_id: sprint.id }, - orderBy: { sort_order: 'asc' }, - include: { - tasks: { orderBy: [{ sort_order: 'asc' }] }, - assignee: { select: { id: true, username: true } }, - }, - }), - prisma.productMember.findMany({ - where: { product_id: id }, - include: { user: { select: { id: true, username: true } } }, - }), - ]) - - // All members who can be assigned: owner + product members - const members: ProductMember[] = [ - { userId: product.user_id, username: (await prisma.user.findUnique({ where: { id: product.user_id }, select: { username: true } }))?.username ?? 'Eigenaar' }, - ...productMembers.map(m => ({ userId: m.user_id, username: m.user.username })), - ] - - const sprintStoryItems: SprintStory[] = sprintStories.map(s => ({ - id: s.id, - code: s.code, - title: s.title, - description: s.description, - acceptance_criteria: s.acceptance_criteria, - pbi_id: s.pbi_id, - sprint_id: s.sprint_id, - created_at: s.created_at, - priority: s.priority, - sort_order: s.sort_order, - status: s.status, - taskCount: s.tasks.length, - doneCount: s.tasks.filter(t => t.status === 'DONE').length, - assignee_id: s.assignee_id, - assignee_username: s.assignee?.username ?? null, - })) - - const tasksByStoryWorkspace: Record<string, SprintWorkspaceTask[]> = {} - for (const story of sprintStories) { - tasksByStoryWorkspace[story.id] = story.tasks.map(t => ({ - id: t.id, - code: t.code, - title: t.title, - description: t.description, - priority: t.priority, - sort_order: t.sort_order, - status: t.status, - story_id: t.story_id, - sprint_id: t.sprint_id, - created_at: t.created_at, - })) - } - - // All PBIs with their stories for the left (product backlog) panel - const pbis = await prisma.pbi.findMany({ - where: { product_id: id }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], - include: { - stories: { - orderBy: [{ sort_order: 'asc' }], - }, - }, - }) - - const pbisWithStories: PbiWithStories[] = pbis - .filter(pbi => pbi.stories.length > 0) - .map(pbi => ({ - id: pbi.id, - code: pbi.code, - title: pbi.title, - priority: pbi.priority, - status: pbiStatusToApi(pbi.status), - description: pbi.description, - stories: pbi.stories.map(s => ({ - id: s.id, - code: s.code, - title: s.title, - description: s.description, - acceptance_criteria: s.acceptance_criteria, - pbi_id: s.pbi_id, - sprint_id: s.sprint_id, - created_at: s.created_at, - priority: s.priority, - sort_order: s.sort_order, - status: s.status, - taskCount: 0, - doneCount: 0, - assignee_id: null, - assignee_username: null, - })), - })) - - const isDemo = session.isDemo ?? false - const closePath = `/products/${id}/sprint/${sprint.id}` - - const hydrationData: SprintHydrationData = { - sprint: { - id: sprint.id, - product_id: id, - code: sprint.code, - sprint_goal: sprint.sprint_goal, - status: sprint.status as 'OPEN' | 'CLOSED', - start_date: sprint.start_date ? sprint.start_date.toISOString().slice(0, 10) : null, - end_date: sprint.end_date ? sprint.end_date.toISOString().slice(0, 10) : null, - created_at: new Date(), - completed_at: null, - }, - stories: sprintStoryItems, - tasksByStory: tasksByStoryWorkspace, - } - - return ( - <div id="wrapper2" className="flex flex-col h-full"> - <SyncActiveSprintCookie productId={id} sprintId={sprint.id} /> - <SprintHeader - productId={id} - productName={product.name} - sprint={sprint} - isDemo={isDemo} - sprintStories={sprintStoryItems} - switcherSprints={switcherData.sprintItems} - switcherActiveSprint={switcherData.activeSprintItem} - switcherBuildingSprintIds={switcherData.buildingSprintIds} - /> - - <div className="border-b border-border bg-surface-container-low px-4 py-2 shrink-0"> - <SprintRunControls - sprintId={sprint.id} - productId={id} - sprintStatus={sprint.status} - activeSprintRunId={activeSprintRun?.id ?? null} - activeSprintRunStatus={activeSprintRun?.status ?? null} - pauseContext={pauseContext} - isDemo={isDemo} - /> - </div> - - <div className="flex-1 overflow-hidden"> - <SprintHydrationWrapper - initialData={hydrationData} - productId={id} - productName={product.name} - > - <SprintBoardClient - key={sprint.id} - productId={id} - sprintId={sprint.id} - pbisWithStories={pbisWithStories} - isDemo={isDemo} - currentUserId={session.userId} - members={members} - /> - <SprintTaskDialogMount productId={id} isDemo={isDemo} /> - <SprintUrlTaskSync /> - </SprintHydrationWrapper> - </div> - - <div className="border-t border-border px-4 py-2 bg-surface-container-low shrink-0"> - <Link href={`/products/${id}`} className="text-sm text-muted-foreground hover:text-foreground"> - ← Product Backlog - </Link> - </div> - - {newTask && ( - <TaskDialog - storyId={storyIdParam} - productId={id} - closePath={closePath} - isDemo={isDemo} - /> - )} - </div> - ) -} diff --git a/app/(app)/products/[id]/sprint/[sprintId]/planning/loading.tsx b/app/(app)/products/[id]/sprint/[sprintId]/planning/loading.tsx deleted file mode 100644 index 8dcb9c1..0000000 --- a/app/(app)/products/[id]/sprint/[sprintId]/planning/loading.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@/components/loading/backlog-page-skeleton' diff --git a/app/(app)/products/[id]/sprint/loading.tsx b/app/(app)/products/[id]/sprint/loading.tsx new file mode 100644 index 0000000..795b2c5 --- /dev/null +++ b/app/(app)/products/[id]/sprint/loading.tsx @@ -0,0 +1,34 @@ +export default function Loading() { + return ( + <div className="flex flex-col h-full animate-pulse"> + {/* Header skeleton */} + <div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-between"> + <div className="space-y-1.5"> + <div className="h-4 w-32 bg-border rounded" /> + <div className="h-3 w-48 bg-border/60 rounded" /> + </div> + <div className="h-7 w-24 bg-border rounded" /> + </div> + + {/* Split pane skeleton */} + <div className="flex-1 flex overflow-hidden"> + {/* Left */} + <div className="w-2/5 border-r border-border p-4 space-y-3"> + <div className="h-4 w-24 bg-border rounded" /> + {[1, 2, 3, 4, 5].map(i => ( + <div key={i} className="h-8 bg-border/50 rounded" /> + ))} + </div> + {/* Right */} + <div className="flex-1 p-4 space-y-3"> + <div className="h-4 w-16 bg-border rounded" /> + <div className="flex gap-2 flex-wrap"> + {[1, 2, 3].map(i => ( + <div key={i} className="w-28 h-24 bg-border/50 rounded-lg" /> + ))} + </div> + </div> + </div> + </div> + ) +} diff --git a/app/(app)/products/[id]/sprint/page.tsx b/app/(app)/products/[id]/sprint/page.tsx index 5f0e6ab..e8a6b9e 100644 --- a/app/(app)/products/[id]/sprint/page.tsx +++ b/app/(app)/products/[id]/sprint/page.tsx @@ -1,17 +1,179 @@ -import { redirect } from 'next/navigation' -import { requireSession } from '@/lib/auth-guard' -import { resolveActiveSprint } from '@/lib/active-sprint' +import { Suspense } from 'react' +import { notFound, redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { getAccessibleProduct } from '@/lib/product-access' +import { prisma } from '@/lib/prisma' +import { SprintBoardClient } from '@/components/sprint/sprint-board-client' +import { SprintHeader } from '@/components/sprint/sprint-header' +import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog' +import type { Task } from '@/components/sprint/task-list' +import { TaskDialog } from '@/app/_components/tasks/task-dialog' +import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader' +import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton' +import Link from 'next/link' interface Props { params: Promise<{ id: string }> + searchParams: Promise<{ + newTask?: string + storyId?: string + editTask?: string + }> } -export default async function SprintRedirectPage({ params }: Props) { +export default async function SprintBoardPage({ params, searchParams }: Props) { const { id } = await params - const session = await requireSession() - const active = await resolveActiveSprint(id, session.userId) - if (!active) { - redirect(`/products/${id}?alert=no_sprint`) + const { newTask, storyId: storyIdParam, editTask } = await searchParams + + const session = await getSession() + if (!session.userId) redirect('/login') + + const product = await getAccessibleProduct(id, session.userId) + if (!product) notFound() + + const sprint = await prisma.sprint.findFirst({ + where: { product_id: id, status: 'ACTIVE' }, + select: { + id: true, + sprint_goal: true, + status: true, + start_date: true, + end_date: true, + }, + }) + if (!sprint) redirect(`/products/${id}`) + + // Sprint stories with full task data and assignee + const [sprintStories, productMembers] = await Promise.all([ + prisma.story.findMany({ + where: { sprint_id: sprint.id }, + orderBy: { sort_order: 'asc' }, + include: { + tasks: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }] }, + assignee: { select: { id: true, username: true } }, + }, + }), + prisma.productMember.findMany({ + where: { product_id: id }, + include: { user: { select: { id: true, username: true } } }, + }), + ]) + + // All members who can be assigned: owner + product members + const members: ProductMember[] = [ + { userId: product.user_id, username: (await prisma.user.findUnique({ where: { id: product.user_id }, select: { username: true } }))?.username ?? 'Eigenaar' }, + ...productMembers.map(m => ({ userId: m.user_id, username: m.user.username })), + ] + + const sprintStoryItems: SprintStory[] = sprintStories.map(s => ({ + id: s.id, + code: s.code, + title: s.title, + priority: s.priority, + status: s.status, + taskCount: s.tasks.length, + doneCount: s.tasks.filter(t => t.status === 'DONE').length, + assignee_id: s.assignee_id, + assignee_username: s.assignee?.username ?? null, + })) + + const tasksByStory: Record<string, Task[]> = {} + for (const story of sprintStories) { + tasksByStory[story.id] = story.tasks.map(t => ({ + id: t.id, + title: t.title, + description: t.description, + priority: t.priority, + status: t.status, + story_id: t.story_id, + sprint_id: t.sprint_id, + })) } - redirect(`/products/${id}/sprint/${active.id}`) + + // All PBIs with their stories for the left (product backlog) panel + const pbis = await prisma.pbi.findMany({ + where: { product_id: id }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + include: { + stories: { + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + }, + }, + }) + + const pbisWithStories: PbiWithStories[] = pbis + .filter(pbi => pbi.stories.length > 0) + .map(pbi => ({ + id: pbi.id, + code: pbi.code, + title: pbi.title, + stories: pbi.stories.map(s => ({ + id: s.id, + code: s.code, + title: s.title, + priority: s.priority, + status: s.status, + taskCount: 0, + doneCount: 0, + assignee_id: null, + assignee_username: null, + })), + })) + + const sprintStoryIdList = sprintStories.map(s => s.id) + const isDemo = session.isDemo ?? false + const closePath = `/products/${id}/sprint` + + return ( + <div className="flex flex-col h-full"> + <SprintHeader + productId={id} + productName={product.name} + sprint={sprint} + isDemo={isDemo} + sprintStories={sprintStoryItems} + /> + + <div className="flex-1 overflow-hidden"> + <SprintBoardClient + productId={id} + sprintId={sprint.id} + stories={sprintStoryItems} + pbisWithStories={pbisWithStories} + sprintStoryIdList={sprintStoryIdList} + tasksByStory={tasksByStory} + isDemo={isDemo} + currentUserId={session.userId} + members={members} + /> + </div> + + <div className="border-t border-border px-4 py-2 bg-surface-container-low shrink-0"> + <Link href={`/products/${id}`} className="text-sm text-muted-foreground hover:text-foreground"> + ← Product Backlog + </Link> + </div> + + {newTask && ( + <TaskDialog + storyId={storyIdParam} + productId={id} + closePath={closePath} + isDemo={isDemo} + /> + )} + + {editTask && !newTask && ( + <Suspense fallback={<TaskDialogSkeleton />}> + <EditTaskLoader + taskId={editTask} + userId={session.userId} + productId={id} + closePath={closePath} + isDemo={isDemo} + /> + </Suspense> + )} + </div> + ) } diff --git a/app/(app)/products/[id]/sprint/planning/loading.tsx b/app/(app)/products/[id]/sprint/planning/loading.tsx new file mode 100644 index 0000000..795b2c5 --- /dev/null +++ b/app/(app)/products/[id]/sprint/planning/loading.tsx @@ -0,0 +1,34 @@ +export default function Loading() { + return ( + <div className="flex flex-col h-full animate-pulse"> + {/* Header skeleton */} + <div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-between"> + <div className="space-y-1.5"> + <div className="h-4 w-32 bg-border rounded" /> + <div className="h-3 w-48 bg-border/60 rounded" /> + </div> + <div className="h-7 w-24 bg-border rounded" /> + </div> + + {/* Split pane skeleton */} + <div className="flex-1 flex overflow-hidden"> + {/* Left */} + <div className="w-2/5 border-r border-border p-4 space-y-3"> + <div className="h-4 w-24 bg-border rounded" /> + {[1, 2, 3, 4, 5].map(i => ( + <div key={i} className="h-8 bg-border/50 rounded" /> + ))} + </div> + {/* Right */} + <div className="flex-1 p-4 space-y-3"> + <div className="h-4 w-16 bg-border rounded" /> + <div className="flex gap-2 flex-wrap"> + {[1, 2, 3].map(i => ( + <div key={i} className="w-28 h-24 bg-border/50 rounded-lg" /> + ))} + </div> + </div> + </div> + </div> + ) +} diff --git a/app/(app)/products/[id]/sprint/[sprintId]/planning/page.tsx b/app/(app)/products/[id]/sprint/planning/page.tsx similarity index 50% rename from app/(app)/products/[id]/sprint/[sprintId]/planning/page.tsx rename to app/(app)/products/[id]/sprint/planning/page.tsx index d2a017a..8256d6f 100644 --- a/app/(app)/products/[id]/sprint/[sprintId]/planning/page.tsx +++ b/app/(app)/products/[id]/sprint/planning/page.tsx @@ -1,10 +1,10 @@ import { redirect } from 'next/navigation' interface Props { - params: Promise<{ id: string; sprintId: string }> + params: Promise<{ id: string }> } export default async function SprintPlanningRedirect({ params }: Props) { - const { id, sprintId } = await params - redirect(`/products/${id}/sprint/${sprintId}`) + const { id } = await params + redirect(`/products/${id}/sprint`) } diff --git a/app/(app)/products/new/page.tsx b/app/(app)/products/new/page.tsx index 377076d..ba768a7 100644 --- a/app/(app)/products/new/page.tsx +++ b/app/(app)/products/new/page.tsx @@ -3,7 +3,7 @@ import { getIronSession } from 'iron-session' import { redirect } from 'next/navigation' import { SessionData, sessionOptions } from '@/lib/session' import { ProductForm } from '@/components/products/product-form' -import { createProductFormAction } from '@/actions/products' +import { createProductAction } from '@/actions/products' export default async function NewProductPage() { const session = await getIronSession<SessionData>(await cookies(), sessionOptions) @@ -12,7 +12,7 @@ export default async function NewProductPage() { return ( <div className="p-6 max-w-2xl mx-auto w-full"> <h1 className="text-xl font-medium text-foreground mb-6">Nieuw product</h1> - <ProductForm action={createProductFormAction} submitLabel="Product aanmaken" /> + <ProductForm action={createProductAction} submitLabel="Product aanmaken" /> </div> ) } diff --git a/app/(app)/settings/loading.tsx b/app/(app)/settings/loading.tsx index 11488b0..07f3dd9 100644 --- a/app/(app)/settings/loading.tsx +++ b/app/(app)/settings/loading.tsx @@ -1,11 +1,9 @@ -import { Skeleton } from '@/components/ui/skeleton' - export default function Loading() { return ( - <div className="p-6 max-w-2xl mx-auto w-full space-y-4"> - <Skeleton className="h-6 w-28" /> - {[1, 2, 3, 4].map((i) => ( - <Skeleton key={i} className="h-28 rounded-xl" /> + <div className="p-6 max-w-2xl mx-auto w-full space-y-4 animate-pulse"> + <div className="h-6 w-28 bg-border rounded" /> + {[1, 2, 3, 4].map(i => ( + <div key={i} className="h-28 bg-border/50 rounded-xl" /> ))} </div> ) diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 88daf20..b299e45 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -5,7 +5,6 @@ import { prisma } from '@/lib/prisma' import { RoleManager } from '@/components/settings/role-manager' import { LeaveProductButton } from '@/components/settings/leave-product-button' import { ProfileEditor } from '@/components/settings/profile-editor' -import { MinQuotaEditor } from '@/components/settings/min-quota-editor' import { ActivateProductButton } from '@/components/shared/activate-product-button' import Link from 'next/link' @@ -15,7 +14,7 @@ export default async function SettingsPage() { const [user, userRoles, ownedProducts, memberships] = await Promise.all([ prisma.user.findUnique({ where: { id: session.userId }, - select: { username: true, email: true, bio: true, bio_detail: true, avatar_data: true, updated_at: true, active_product_id: true, min_quota_pct: true }, + select: { username: true, email: true, bio: true, bio_detail: true, avatar_data: true, updated_at: true, active_product_id: true }, }), prisma.userRole.findMany({ where: { user_id: session.userId } }), prisma.product.findMany({ @@ -158,19 +157,6 @@ export default async function SettingsPage() { )} </div> - <div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-4"> - <div> - <h2 className="text-sm font-medium text-foreground">Worker-instellingen</h2> - <p className="text-xs text-muted-foreground mt-0.5"> - Drempelwaarden voor de Claude-worker. - </p> - </div> - <MinQuotaEditor - currentValue={user?.min_quota_pct ?? 20} - isDemo={session.isDemo ?? false} - /> - </div> - <div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-3"> <div className="flex items-center justify-between"> <h2 className="text-sm font-medium text-foreground">API Tokens</h2> diff --git a/app/(app)/todos/loading.tsx b/app/(app)/todos/loading.tsx new file mode 100644 index 0000000..61d4ec9 --- /dev/null +++ b/app/(app)/todos/loading.tsx @@ -0,0 +1,19 @@ +export default function Loading() { + return ( + <div className="p-6 max-w-2xl mx-auto w-full animate-pulse"> + <div className="h-6 w-20 bg-border rounded mb-6" /> + <div className="flex gap-3 mb-4"> + <div className="h-8 w-32 bg-border/50 rounded-lg" /> + <div className="flex-1" /> + <div className="h-8 w-8 bg-border/50 rounded-lg" /> + </div> + <div className="rounded-xl border border-border overflow-hidden"> + <div className="h-10 bg-border/30" /> + {[1, 2, 3, 4, 5].map(i => ( + <div key={i} className="h-12 border-t border-border bg-border/20" /> + ))} + </div> + <div className="mt-4 h-24 bg-border/30 rounded-xl" /> + </div> + ) +} diff --git a/app/(app)/todos/page.tsx b/app/(app)/todos/page.tsx new file mode 100644 index 0000000..27f07f4 --- /dev/null +++ b/app/(app)/todos/page.tsx @@ -0,0 +1,47 @@ +import { cookies } from 'next/headers' +import { getIronSession } from 'iron-session' +import { SessionData, sessionOptions } from '@/lib/session' +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' +import { TodoList } from '@/components/todos/todo-list' + +export default async function TodosPage() { + const session = await getIronSession<SessionData>(await cookies(), sessionOptions) + + const todos = await prisma.todo.findMany({ + where: { user_id: session.userId, archived: false }, + orderBy: { created_at: 'asc' }, + include: { product: { select: { name: true } } }, + }) + + const products = await prisma.product.findMany({ + where: { ...productAccessFilter(session.userId), archived: false }, + orderBy: { name: 'asc' }, + include: { + pbis: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], select: { id: true, title: true } }, + }, + }) + + return ( + <div className="p-6 max-w-2xl mx-auto w-full"> + <h1 className="text-xl font-medium text-foreground mb-6">Todo's</h1> + <TodoList + todos={todos.map(t => ({ + id: t.id, + title: t.title, + description: t.description ?? null, + done: t.done, + created_at: t.created_at.toISOString(), + product_id: t.product_id ?? null, + product_name: t.product?.name ?? null, + }))} + products={products.map(p => ({ + id: p.id, + name: p.name, + pbis: p.pbis, + }))} + isDemo={session.isDemo ?? false} + /> + </div> + ) +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 2e0afcb..4a225f9 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -5,7 +5,7 @@ import { QrLoginButton } from './qr-login-button' export default function LoginPage() { return ( - <main className="min-h-screen bg-background flex items-center justify-center p-4"> + <div className="min-h-screen bg-background flex items-center justify-center p-4"> <div className="w-full max-w-sm space-y-6"> {/* Logo / titel */} @@ -42,6 +42,6 @@ export default function LoginPage() { </div> </div> - </main> + </div> ) } diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 5a948df..ee1c6aa 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -4,7 +4,7 @@ import { AuthForm } from '@/components/auth/auth-form' export default function RegisterPage() { return ( - <main className="min-h-screen bg-background flex items-center justify-center p-4"> + <div className="min-h-screen bg-background flex items-center justify-center p-4"> <div className="w-full max-w-sm space-y-6"> {/* Logo / titel */} @@ -26,6 +26,6 @@ export default function RegisterPage() { </div> </div> - </main> + </div> ) } diff --git a/app/(auth)/reset-password/page.tsx b/app/(auth)/reset-password/page.tsx deleted file mode 100644 index c38ec5f..0000000 --- a/app/(auth)/reset-password/page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { redirect } from 'next/navigation' -import { getSession } from '@/lib/auth' -import { prisma } from '@/lib/prisma' -import { ResetPasswordForm } from './reset-form' - -export default async function ResetPasswordPage() { - const session = await getSession() - if (!session.userId) { - redirect('/login') - } - - const user = await prisma.user.findUnique({ - where: { id: session.userId }, - select: { must_reset_password: true }, - }) - - if (!user?.must_reset_password) { - redirect('/dashboard') - } - - return ( - <main className="min-h-screen bg-background flex items-center justify-center p-4"> - <div className="w-full max-w-sm space-y-6"> - <div className="text-center space-y-1"> - <h1 className="text-2xl font-medium text-foreground">Wachtwoord wijzigen</h1> - <p className="text-sm text-muted-foreground"> - Kies een nieuw wachtwoord om verder te gaan. - </p> - </div> - - <div className="bg-surface-container-low rounded-xl p-6 space-y-4 border border-border"> - <ResetPasswordForm /> - </div> - </div> - </main> - ) -} diff --git a/app/(auth)/reset-password/reset-form.tsx b/app/(auth)/reset-password/reset-form.tsx deleted file mode 100644 index 85f44f2..0000000 --- a/app/(auth)/reset-password/reset-form.tsx +++ /dev/null @@ -1,74 +0,0 @@ -'use client' - -import { useActionState } from 'react' -import { useFormStatus } from 'react-dom' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { resetPasswordAction } from '@/actions/auth' - -type ActionResult = { error: string | Record<string, string[]> } | undefined - -function SubmitButton() { - const { pending } = useFormStatus() - return ( - <Button type="submit" className="w-full" disabled={pending}> - {pending ? 'Even wachten…' : 'Wachtwoord opslaan'} - </Button> - ) -} - -function getErrorMessage(error: ActionResult): string | null { - if (!error) return null - if (typeof error.error === 'string') return error.error - const first = Object.values(error.error).flat()[0] - return first ?? null -} - -export function ResetPasswordForm() { - const [state, formAction] = useActionState(resetPasswordAction, undefined) - const errorMessage = getErrorMessage(state) - - return ( - <form action={formAction} className="space-y-4"> - <div className="space-y-2"> - <label htmlFor="password" className="text-sm font-medium text-foreground"> - Nieuw wachtwoord - </label> - <Input - id="password" - name="password" - type="password" - autoComplete="new-password" - required - minLength={8} - placeholder="••••••••" - className="bg-input-background border-border focus-visible:ring-primary" - /> - </div> - - <div className="space-y-2"> - <label htmlFor="confirm" className="text-sm font-medium text-foreground"> - Bevestig wachtwoord - </label> - <Input - id="confirm" - name="confirm" - type="password" - autoComplete="new-password" - required - minLength={8} - placeholder="••••••••" - className="bg-input-background border-border focus-visible:ring-primary" - /> - </div> - - {errorMessage && ( - <div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error"> - {errorMessage} - </div> - )} - - <SubmitButton /> - </form> - ) -} diff --git a/app/(mobile)/layout.tsx b/app/(mobile)/layout.tsx deleted file mode 100644 index 2f067dc..0000000 --- a/app/(mobile)/layout.tsx +++ /dev/null @@ -1,40 +0,0 @@ -// PBI-11 / ST-1134: Mobile shell-layout. Eigen route group (mobile) — nested -// layout in (app)/ kan parent NavBar/StatusBar/MinWidthBanner niet onderdrukken. -// Auth via gedeelde requireSession() (lib/auth-guard.ts), hergebruikt door -// (app)/layout.tsx. - -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' -import { requireSession } from '@/lib/auth-guard' -import { LandscapeGuard } from '@/components/mobile/landscape-guard' -import { MobileTabBar } from '@/components/mobile/mobile-tab-bar' - -export default async function MobileLayout({ children }: { children: React.ReactNode }) { - const session = await requireSession() - - // Active product nodig voor de tab-bar (Backlog/Solo-tabs verbergen als geen actief product). - const user = await prisma.user.findUnique({ - where: { id: session.userId }, - select: { active_product_id: true }, - }) - - let activeProductId: string | null = null - if (user?.active_product_id) { - const product = await prisma.product.findFirst({ - where: { id: user.active_product_id, archived: false, ...productAccessFilter(session.userId) }, - select: { id: true }, - }) - activeProductId = product?.id ?? null - } - - return ( - <div className="h-screen bg-background flex flex-col overflow-hidden"> - <LandscapeGuard> - <main id="main-content" className="flex-1 flex flex-col overflow-y-auto min-h-0 pb-14"> - {children} - </main> - <MobileTabBar activeProductId={activeProductId} /> - </LandscapeGuard> - </div> - ) -} diff --git a/app/(mobile)/m/products/[id]/page.tsx b/app/(mobile)/m/products/[id]/page.tsx deleted file mode 100644 index 4aa5815..0000000 --- a/app/(mobile)/m/products/[id]/page.tsx +++ /dev/null @@ -1,150 +0,0 @@ -// PBI-11 / ST-1137: Mobile Product Backlog. Wraps de 3-paneel-backlog in de -// mobile-shell. BacklogSplitPane rendert automatisch tab-mode op <1024px -// (uit ST-1116). Cookie-key gescheiden van desktop zodat tab-mode-gebruikers -// de desktop-split niet vervuilen (beslissing C in docs/plans/PBI-11-mobile-shell.md). - -import { Suspense } from 'react' -import { notFound } from 'next/navigation' -import { getAccessibleProduct } from '@/lib/product-access' -import { prisma } from '@/lib/prisma' -import { pbiStatusToApi } from '@/lib/task-status' -import { requireSession } from '@/lib/auth-guard' -import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane' -import { PbiList } from '@/components/backlog/pbi-list' -import { StoryPanel } from '@/components/backlog/story-panel' -import type { Story } from '@/components/backlog/story-panel' -import { TaskPanel } from '@/components/backlog/task-panel' -import { BacklogHydrationWrapper } from '@/components/backlog/backlog-hydration-wrapper' -import { UrlTaskSync } from '@/components/backlog/url-task-sync' -import { TaskDialog } from '@/app/_components/tasks/task-dialog' -import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader' -import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton' - -interface Props { - params: Promise<{ id: string }> - searchParams: Promise<{ newTask?: string; storyId?: string; editTask?: string }> -} - -export default async function MobileProductBacklogPage({ params, searchParams }: Props) { - const { id } = await params - const { newTask, storyId: storyIdParam, editTask } = await searchParams - const closePath = `/m/products/${id}` - - const session = await requireSession() - const product = await getAccessibleProduct(id, session.userId) - if (!product) notFound() - - const pbis = await prisma.pbi.findMany({ - where: { product_id: id }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], - }) - - const [stories, tasks] = await Promise.all([ - prisma.story.findMany({ - where: { product_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - select: { - id: true, - code: true, - title: true, - description: true, - acceptance_criteria: true, - priority: true, - sort_order: true, - status: true, - pbi_id: true, - sprint_id: true, - created_at: true, - }, - }), - prisma.task.findMany({ - where: { story: { pbi: { product_id: id } } }, - select: { - id: true, - code: true, - title: true, - description: true, - priority: true, - status: true, - sort_order: true, - story_id: true, - created_at: true, - }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - }), - ]) - - const storiesByPbi: Record<string, Story[]> = {} - for (const story of stories) { - if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = [] - storiesByPbi[story.pbi_id].push(story) - } - - const tasksByStory: Record<string, typeof tasks> = {} - for (const task of tasks) { - if (!tasksByStory[task.story_id]) tasksByStory[task.story_id] = [] - tasksByStory[task.story_id].push(task) - } - - const isDemo = session.isDemo ?? false - - return ( - <div className="flex flex-col h-full"> - <BacklogHydrationWrapper - productId={id} - productName={product.name} - initialData={{ - pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, sort_order: p.sort_order, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), - storiesByPbi, - tasksByStory, - }} - > - <UrlTaskSync /> - <BacklogSplitPane - cookieKey={`backlog-${id}-mobile`} - defaultSplit={[20, 45, 35]} - tabLabels={['PBI\'s', 'Stories', 'Taken']} - panes={[ - <PbiList - key="pbi" - productId={id} - isDemo={isDemo} - />, - <StoryPanel - key="story" - productId={id} - isDemo={isDemo} - />, - <TaskPanel - key="tasks" - productId={id} - isDemo={isDemo} - closePath={closePath} - />, - ]} - /> - </BacklogHydrationWrapper> - - {newTask && ( - <TaskDialog - storyId={storyIdParam} - productId={id} - closePath={closePath} - isDemo={isDemo} - /> - )} - - {editTask && !newTask && ( - <Suspense fallback={<TaskDialogSkeleton />}> - <EditTaskLoader - taskId={editTask} - userId={session.userId} - productId={id} - closePath={closePath} - isDemo={isDemo} - /> - </Suspense> - )} - </div> - ) -} diff --git a/app/(mobile)/m/products/[id]/solo/page.tsx b/app/(mobile)/m/products/[id]/solo/page.tsx deleted file mode 100644 index c86c2cf..0000000 --- a/app/(mobile)/m/products/[id]/solo/page.tsx +++ /dev/null @@ -1,47 +0,0 @@ -// PBI-11 / ST-1138: Mobile Solo Paneel — wraps de bestaande SoloBoard zonder -// content-aanpassingen. 3-koloms-kanban blijft (overflow-x scrollt zijwaarts). -// TaskDetailDialog krijgt full-screen-mobile via gedeelde -// entityDialogContentClasses (beslissing A in docs/plans/PBI-11-mobile-shell.md; -// ingebouwd via ST-1133/T-317). - -import { notFound } from 'next/navigation' -import { getAccessibleProduct } from '@/lib/product-access' -import { requireSession } from '@/lib/auth-guard' -import { getSoloWorkspaceSnapshot } from '@/lib/solo-workspace-server' -import { SoloBoard } from '@/components/solo/solo-board' -import { SoloHydrationWrapper } from '@/components/solo/solo-hydration-wrapper' -import { NoActiveSprint } from '@/components/solo/no-active-sprint' - -interface Props { - params: Promise<{ id: string }> -} - -export default async function MobileSoloProductPage({ params }: Props) { - const { id } = await params - const session = await requireSession() - - const product = await getAccessibleProduct(id, session.userId) - if (!product) notFound() - - const initialData = await getSoloWorkspaceSnapshot(id, session.userId) - - if (!initialData) { - return ( - <div className="flex flex-col h-full"> - <NoActiveSprint productId={id} productName={product.name} /> - </div> - ) - } - - return ( - <SoloHydrationWrapper initialData={initialData}> - <SoloBoard - key={initialData.sprint.id} - productId={id} - sprintGoal={initialData.sprint.sprint_goal} - isDemo={session.isDemo ?? false} - repoUrl={product.repo_url} - /> - </SoloHydrationWrapper> - ) -} diff --git a/app/(mobile)/m/settings/page.tsx b/app/(mobile)/m/settings/page.tsx deleted file mode 100644 index d1a7070..0000000 --- a/app/(mobile)/m/settings/page.tsx +++ /dev/null @@ -1,92 +0,0 @@ -// PBI-11 / ST-1136: Mobile Settings — read-only account, product-selector, -// QR-pairing-instructie, logout. Eigenlijke productactivering loopt via de -// bestaande setActiveProductAction (ActivateProductButton). - -import Link from 'next/link' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' -import { requireSession } from '@/lib/auth-guard' -import { ActivateProductButton } from '@/components/shared/activate-product-button' -import { LogoutButton } from '@/components/mobile/logout-button' -import { Badge } from '@/components/ui/badge' - -export const metadata = { - title: 'Settings', -} - -export default async function MobileSettingsPage() { - const session = await requireSession() - - const [user, products] = await Promise.all([ - prisma.user.findUnique({ - where: { id: session.userId }, - select: { username: true, is_demo: true, active_product_id: true }, - }), - prisma.product.findMany({ - where: { archived: false, ...productAccessFilter(session.userId) }, - orderBy: { name: 'asc' }, - select: { id: true, name: true }, - }), - ]) - - const isDemo = user?.is_demo ?? false - - return ( - <div className="px-4 py-6 space-y-6 max-w-md mx-auto w-full"> - <h1 className="text-xl font-semibold">Settings</h1> - - <section aria-labelledby="account-heading" className="space-y-2"> - <h2 id="account-heading" className="text-sm font-medium text-muted-foreground uppercase tracking-wide">Account</h2> - <div className="flex items-center gap-2"> - <span className="text-base font-medium">{user?.username ?? '—'}</span> - {isDemo && ( - <Badge className="bg-status-todo/15 text-status-todo border-status-todo/30">Demo</Badge> - )} - </div> - </section> - - <section aria-labelledby="product-heading" className="space-y-2"> - <h2 id="product-heading" className="text-sm font-medium text-muted-foreground uppercase tracking-wide">Actief product</h2> - {products.length === 0 ? ( - <p className="text-sm text-muted-foreground">Geen producten beschikbaar.</p> - ) : ( - <ul className="divide-y divide-border rounded border border-border"> - {products.map((p) => { - const active = p.id === user?.active_product_id - return ( - <li key={p.id} className="flex items-center justify-between px-3 py-3"> - <div className="flex items-center gap-2 min-w-0"> - <span className="text-sm truncate">{p.name}</span> - {active && ( - <Badge className="bg-primary/15 text-primary border-primary/30">Actief</Badge> - )} - </div> - {!active && ( - <ActivateProductButton - productId={p.id} - isDemo={isDemo} - redirectTo={`/m/products/${p.id}/solo`} - label="Activeer" - /> - )} - </li> - ) - })} - </ul> - )} - </section> - - <section aria-labelledby="qr-heading" className="space-y-2"> - <h2 id="qr-heading" className="text-sm font-medium text-muted-foreground uppercase tracking-wide">Inloggen op desktop</h2> - <p className="text-sm text-muted-foreground"> - Open <Link href="/login" className="text-primary hover:underline">scrum4me.app/login</Link> op je desktop om in te loggen via QR-code. QR-pairing start vanaf de desktop. - </p> - </section> - - <section aria-labelledby="logout-heading" className="space-y-2 pt-2"> - <h2 id="logout-heading" className="sr-only">Uitloggen</h2> - <LogoutButton /> - </section> - </div> - ) -} diff --git a/app/_components/tasks/edit-task-loader.tsx b/app/_components/tasks/edit-task-loader.tsx index 9c03194..f66cce9 100644 --- a/app/_components/tasks/edit-task-loader.tsx +++ b/app/_components/tasks/edit-task-loader.tsx @@ -25,7 +25,6 @@ export async function EditTaskLoader({ }, select: { id: true, - code: true, title: true, description: true, implementation_plan: true, diff --git a/app/_components/tasks/status-select.tsx b/app/_components/tasks/status-select.tsx index 298c350..5ba794d 100644 --- a/app/_components/tasks/status-select.tsx +++ b/app/_components/tasks/status-select.tsx @@ -14,12 +14,9 @@ const STATUS_CONFIG: Record<TaskStatus, { label: string; dot: string }> = { IN_PROGRESS: { label: 'Bezig', dot: 'bg-status-in-progress' }, REVIEW: { label: 'Review', dot: 'bg-status-review' }, DONE: { label: 'Klaar', dot: 'bg-status-done' }, - FAILED: { label: 'Gefaald', dot: 'bg-status-failed' }, - EXCLUDED: { label: 'Uitgesloten', dot: 'bg-muted-foreground/40' }, } -// FAILED ontbreekt bewust: alleen via sprint-cascade gezet, niet handmatig kiesbaar. -const STATUS_ORDER: TaskStatus[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE', 'EXCLUDED'] +const STATUS_ORDER: TaskStatus[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE'] function StatusIndicator({ status }: { status: TaskStatus }) { return ( diff --git a/app/_components/tasks/task-dialog-skeleton.tsx b/app/_components/tasks/task-dialog-skeleton.tsx index 823abb7..bb6a66b 100644 --- a/app/_components/tasks/task-dialog-skeleton.tsx +++ b/app/_components/tasks/task-dialog-skeleton.tsx @@ -1,11 +1,5 @@ import { Skeleton } from '@/components/ui/skeleton' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' -import { - entityDialogBodyClasses, - entityDialogContentClasses, - entityDialogFooterClasses, - entityDialogHeaderClasses, -} from '@/components/shared/entity-dialog-layout' import { cn } from '@/lib/utils' export function TaskDialogSkeleton() { @@ -14,54 +8,32 @@ export function TaskDialogSkeleton() { <DialogContent showCloseButton={false} className={cn( - entityDialogContentClasses, + 'flex flex-col p-0 gap-0', + 'max-h-[90vh] w-full max-w-[calc(100%-2rem)]', + 'sm:max-w-[90vw] sm:max-h-[85vh]', + 'lg:max-w-[50vw] lg:min-w-[480px]', '[animation-delay:200ms] [animation-fill-mode:backwards]', )} > <DialogTitle className="sr-only">Taak laden…</DialogTitle> - <div className={entityDialogHeaderClasses}> - <div className="flex items-center gap-2"> - <Skeleton className="h-7 w-40" /> - <Skeleton className="h-5 w-14 rounded-full" /> - </div> - <Skeleton className="h-4 w-28" /> + {/* Header */} + <div className="px-6 pt-5 pb-4 border-b border-outline-variant shrink-0"> + <Skeleton className="h-7 w-40" /> </div> - <div className={entityDialogBodyClasses}> - <div> - <Skeleton className="h-4 w-12 mb-2" /> - <Skeleton className="h-9 w-full" /> - </div> - <div> - <Skeleton className="h-4 w-12 mb-2" /> - <Skeleton className="h-14 w-full" /> - </div> - <div> - <Skeleton className="h-4 w-24 mb-2" /> - <Skeleton className="h-20 w-full" /> - </div> - <div> - <Skeleton className="h-4 w-32 mb-2" /> - <Skeleton className="h-32 w-full" /> - </div> - <div> - <Skeleton className="h-4 w-16 mb-2" /> - <Skeleton className="h-9 w-64" /> - </div> - <div> - <Skeleton className="h-4 w-12 mb-2" /> - <Skeleton className="h-9 w-48" /> - </div> + {/* Body — 3 bars mimicking title + description + plan */} + <div className="flex-1 px-6 py-6 space-y-6"> + <Skeleton className="h-14 w-full" /> + <Skeleton className="h-24 w-full" /> + <Skeleton className="h-32 w-full" /> </div> - <div className={entityDialogFooterClasses}> - <div className="flex items-center justify-between gap-2"> - <Skeleton className="h-9 w-28" /> - <div className="flex gap-2"> - <Skeleton className="h-9 w-24" /> - <Skeleton className="h-9 w-24" /> - </div> + {/* Footer */} + <div className="border-t border-outline-variant px-6 py-4 shrink-0"> + <div className="flex justify-end gap-2"> + <Skeleton className="h-8 w-24" /> + <Skeleton className="h-8 w-24" /> </div> </div> </DialogContent> diff --git a/app/_components/tasks/task-dialog.tsx b/app/_components/tasks/task-dialog.tsx index 638d834..2426dc1 100644 --- a/app/_components/tasks/task-dialog.tsx +++ b/app/_components/tasks/task-dialog.tsx @@ -27,27 +27,13 @@ import { } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { CodeBadge } from '@/components/shared/code-badge' -import { MAX_CODE_LENGTH } from '@/lib/code' import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { - useDirtyCloseGuard, - DirtyCloseGuardDialog, -} from '@/components/shared/use-dirty-close-guard' -import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut' -import { - entityDialogBodyClasses, - entityDialogContentClasses, - entityDialogFooterClasses, - entityDialogHeaderClasses, -} from '@/components/shared/entity-dialog-layout' import { PrioritySegmented } from './priority-segmented' import { StatusSelect } from './status-select' import { cn } from '@/lib/utils' export interface TaskDialogTask { id: string - code: string | null title: string description: string | null implementation_plan: string | null @@ -60,9 +46,7 @@ interface TaskDialogProps { task?: TaskDialogTask storyId?: string productId: string - closePath?: string - onClose?: () => void - onSaved?: (taskId: string) => void + closePath: string isDemo?: boolean } @@ -77,15 +61,16 @@ function CharCount({ value, max }: { value: string; max: number }) { } const textareaClass = cn( - 'flex w-full rounded-lg border border-border bg-input-background px-2.5 py-2 text-sm', + 'flex w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-sm', 'transition-colors outline-none placeholder:text-muted-foreground resize-none', 'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50', 'overflow-y-auto', ) -export function TaskDialog({ task, storyId, productId, closePath, onClose, onSaved, isDemo = false }: TaskDialogProps) { +export function TaskDialog({ task, storyId, productId, closePath, isDemo = false }: TaskDialogProps) { const router = useRouter() const [isPending, startTransition] = useTransition() + const [confirmClose, setConfirmClose] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) const isEdit = !!task @@ -93,7 +78,6 @@ export function TaskDialog({ task, storyId, productId, closePath, onClose, onSav resolver: zodResolver(taskSchema), mode: 'onTouched', defaultValues: { - code: task?.code ?? '', title: task?.title ?? '', description: task?.description ?? '', implementation_plan: task?.implementation_plan ?? '', @@ -102,13 +86,24 @@ export function TaskDialog({ task, storyId, productId, closePath, onClose, onSav }, }) - function close() { - if (onClose) { onClose(); return } - if (closePath) router.push(closePath) + function handleClose() { + router.push(closePath) } - const closeGuard = useDirtyCloseGuard(form.formState.isDirty, close) - const handleKeyDown = useDialogSubmitShortcut(() => form.handleSubmit(onSubmit)()) + function handleAttemptClose() { + if (form.formState.isDirty) { + setConfirmClose(true) + } else { + handleClose() + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { + e.preventDefault() + form.handleSubmit(onSubmit)() + } + } function onSubmit(data: TaskInput) { startTransition(async () => { @@ -120,8 +115,7 @@ export function TaskDialog({ task, storyId, productId, closePath, onClose, onSav if (result.ok) { toast.success(isEdit ? 'Taak opgeslagen' : 'Taak aangemaakt') - onSaved?.(result.task.id) - close() + router.push(closePath) return } @@ -156,7 +150,7 @@ export function TaskDialog({ task, storyId, productId, closePath, onClose, onSav const result = await deleteTask(task.id, { productId }) if (result.ok) { toast.success('Taak verwijderd') - close() + router.push(closePath) return } if (result.code === 403) { @@ -173,20 +167,22 @@ export function TaskDialog({ task, storyId, productId, closePath, onClose, onSav return ( <> - <Dialog open onOpenChange={(open) => { if (!open) closeGuard.attemptClose() }}> + <Dialog open onOpenChange={(open) => { if (!open) handleAttemptClose() }}> <DialogContent showCloseButton={false} onKeyDown={handleKeyDown} - className={entityDialogContentClasses} + className={cn( + 'flex flex-col p-0 gap-0', + 'max-h-[90vh] w-full max-w-[calc(100%-2rem)]', + 'sm:max-w-[90vw] sm:max-h-[85vh]', + 'lg:max-w-[50vw] lg:min-w-[480px]', + )} > {/* Sticky header */} - <div className={entityDialogHeaderClasses}> - <div className="flex items-center gap-2"> - <DialogTitle className="text-xl font-semibold"> - {isEdit ? 'Taak bewerken' : 'Nieuwe taak'} - </DialogTitle> - {isEdit && task?.code && <CodeBadge code={task.code} />} - </div> + <div className="flex items-center justify-between px-6 pt-5 pb-4 border-b border-outline-variant shrink-0"> + <DialogTitle className="text-xl font-semibold"> + {isEdit ? 'Taak bewerken' : 'Nieuwe taak'} + </DialogTitle> {isEdit && ( <span className="text-xs text-muted-foreground"> Aangemaakt:{' '} @@ -200,35 +196,13 @@ export function TaskDialog({ task, storyId, productId, closePath, onClose, onSav </div> {/* Scrollable form body */} - <div className={entityDialogBodyClasses}> - {/* Code */} - <div> - <label htmlFor="task-code" className="text-sm font-medium mb-2 block"> - Code - </label> - <Input - id="task-code" - {...form.register('code')} - aria-invalid={!!form.formState.errors.code} - placeholder="auto (T-1, T-2, ...)" - className="font-mono" - maxLength={MAX_CODE_LENGTH} - onKeyDown={(e) => { if (e.key === 'Enter') e.preventDefault() }} - /> - {form.formState.errors.code && ( - <p className="text-xs text-destructive mt-1"> - {form.formState.errors.code.message} - </p> - )} - </div> - + <div className="flex-1 overflow-y-auto px-6 py-6 space-y-6"> {/* Title */} <div> - <label htmlFor="task-title" className="text-sm font-medium mb-2 block"> + <label className="text-sm font-medium mb-2 block"> Titel <span className="text-destructive">*</span> </label> <Input - id="task-title" {...form.register('title')} aria-invalid={!!form.formState.errors.title} autoFocus @@ -245,14 +219,13 @@ export function TaskDialog({ task, storyId, productId, closePath, onClose, onSav {/* Description */} <div> - <label htmlFor="task-description" className="text-sm font-medium mb-2 block">Omschrijving</label> + <label className="text-sm font-medium mb-2 block">Omschrijving</label> <Controller control={form.control} name="description" render={({ field }) => ( <> <TextareaAutosize - id="task-description" {...field} value={field.value ?? ''} aria-invalid={!!form.formState.errors.description} @@ -281,14 +254,13 @@ export function TaskDialog({ task, storyId, productId, closePath, onClose, onSav {/* Implementation plan */} <div> - <label htmlFor="task-implementation-plan" className="text-sm font-medium mb-2 block">Implementatieplan</label> + <label className="text-sm font-medium mb-2 block">Implementatieplan</label> <Controller control={form.control} name="implementation_plan" render={({ field }) => ( <> <TextareaAutosize - id="task-implementation-plan" {...field} value={field.value ?? ''} aria-invalid={!!form.formState.errors.implementation_plan} @@ -351,7 +323,7 @@ export function TaskDialog({ task, storyId, productId, closePath, onClose, onSav </div> {/* Sticky footer */} - <div className={entityDialogFooterClasses}> + <div className="border-t border-outline-variant px-6 py-4 shrink-0"> <div className="flex items-center justify-between gap-2"> {isEdit ? ( <DemoTooltip show={isDemo}> @@ -372,7 +344,7 @@ export function TaskDialog({ task, storyId, productId, closePath, onClose, onSav <Button type="button" variant="ghost" - onClick={closeGuard.attemptClose} + onClick={handleAttemptClose} disabled={isPending} > Annuleren @@ -402,7 +374,27 @@ export function TaskDialog({ task, storyId, productId, closePath, onClose, onSav </Dialog> {/* Dirty-check confirm */} - <DirtyCloseGuardDialog guard={closeGuard} /> + <AlertDialog open={confirmClose} onOpenChange={setConfirmClose}> + <AlertDialogContent size="sm"> + <AlertDialogHeader> + <AlertDialogTitle>Wijzigingen niet opgeslagen</AlertDialogTitle> + <AlertDialogDescription> + Wil je de wijzigingen weggooien? + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel onClick={() => setConfirmClose(false)}> + Terug + </AlertDialogCancel> + <AlertDialogAction + variant="destructive" + onClick={() => { setConfirmClose(false); handleClose() }} + > + Weggooien + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> {/* Delete confirm */} <AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}> diff --git a/app/api/cron/cleanup-agent-artifacts/route.ts b/app/api/cron/cleanup-agent-artifacts/route.ts index a923867..7dae4c4 100644 --- a/app/api/cron/cleanup-agent-artifacts/route.ts +++ b/app/api/cron/cleanup-agent-artifacts/route.ts @@ -15,7 +15,7 @@ export async function POST(request: Request) { const { count: deleted } = await prisma.claudeJob.deleteMany({ where: { - status: { in: ['FAILED', 'CANCELLED', 'SKIPPED'] }, + status: { in: ['FAILED', 'CANCELLED'] }, finished_at: { lt: cutoff }, }, }) diff --git a/app/api/debug/emit-test-notify/route.ts b/app/api/debug/emit-test-notify/route.ts index ae1de28..e480258 100644 --- a/app/api/debug/emit-test-notify/route.ts +++ b/app/api/debug/emit-test-notify/route.ts @@ -12,11 +12,6 @@ export const dynamic = 'force-dynamic' const CHANNEL = 'scrum4me_changes' export async function POST(request: Request) { - // Productie-guard: anonieme test-emit op pg_notify is niet voor productie. - if (process.env.NODE_ENV === 'production') { - return new Response('Not found', { status: 404 }) - } - const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL if (!directUrl) { return Response.json({ error: 'DIRECT_URL/DATABASE_URL niet gezet' }, { status: 500 }) diff --git a/app/api/debug/realtime-stream/route.ts b/app/api/debug/realtime-stream/route.ts index 1a02765..e909bfc 100644 --- a/app/api/debug/realtime-stream/route.ts +++ b/app/api/debug/realtime-stream/route.ts @@ -16,11 +16,6 @@ export const maxDuration = 300 const CHANNEL = 'scrum4me_changes' export async function GET(request: NextRequest) { - // Productie-guard: deze debug-stream lekt rauw alle pg_notify-events. - if (process.env.NODE_ENV === 'production') { - return new Response('Not found', { status: 404 }) - } - const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL if (!directUrl) { return Response.json({ error: 'DIRECT_URL/DATABASE_URL niet gezet' }, { status: 500 }) diff --git a/app/api/ideas/[id]/route.ts b/app/api/ideas/[id]/route.ts deleted file mode 100644 index 4c547c3..0000000 --- a/app/api/ideas/[id]/route.ts +++ /dev/null @@ -1,91 +0,0 @@ -// Per-idea REST endpoints (M12). user_id-strict scope, 404 (niet 403) bij -// foreign user om enumeratie te vermijden. - -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { ideaUpdateSchema } from '@/lib/schemas/idea' -import { isIdeaEditable } from '@/lib/idea-status' -import { ideaToDto } from '@/lib/idea-dto' - -interface RouteContext { - params: Promise<{ id: string }> -} - -export async function GET(request: Request, ctx: RouteContext) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await ctx.params - - const idea = await prisma.idea.findFirst({ - where: { id, user_id: auth.userId }, - include: { - product: { select: { id: true, name: true, repo_url: true } }, - pbi: { select: { id: true, code: true, title: true } }, - }, - }) - if (!idea) { - return Response.json({ error: 'Idee niet gevonden' }, { status: 404 }) - } - - // Recente logs (max 50) — handig voor MCP tools die context willen ophalen. - const logs = await prisma.ideaLog.findMany({ - where: { idea_id: id }, - orderBy: { created_at: 'desc' }, - take: 50, - select: { id: true, type: true, content: true, metadata: true, created_at: true }, - }) - - return Response.json({ idea: ideaToDto(idea), logs }) -} - -export async function PATCH(request: Request, ctx: RouteContext) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - if (auth.isDemo) { - return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) - } - - const { id } = await ctx.params - - let body: unknown - try { - body = await request.json() - } catch { - return Response.json({ error: 'Malformed JSON' }, { status: 400 }) - } - const parsed = ideaUpdateSchema.safeParse(body) - if (!parsed.success) { - return Response.json({ error: parsed.error.flatten() }, { status: 422 }) - } - - const idea = await prisma.idea.findFirst({ - where: { id, user_id: auth.userId }, - select: { id: true, status: true }, - }) - if (!idea) { - return Response.json({ error: 'Idee niet gevonden' }, { status: 404 }) - } - if (!isIdeaEditable(idea.status)) { - return Response.json( - { error: `Idee niet bewerkbaar in status ${idea.status}` }, - { status: 422 }, - ) - } - - const updated = await prisma.idea.update({ - where: { id }, - data: { - ...(parsed.data.title !== undefined ? { title: parsed.data.title } : {}), - ...(parsed.data.description !== undefined ? { description: parsed.data.description } : {}), - ...(parsed.data.product_id !== undefined ? { product_id: parsed.data.product_id } : {}), - }, - include: { product: { select: { id: true, name: true, repo_url: true } } }, - }) - - return Response.json({ idea: ideaToDto(updated) }) -} diff --git a/app/api/ideas/route.ts b/app/api/ideas/route.ts deleted file mode 100644 index 7da26ac..0000000 --- a/app/api/ideas/route.ts +++ /dev/null @@ -1,97 +0,0 @@ -// REST endpoints voor de Idee-entity (M12). -// - Strikt user_id-only — geen productAccessFilter. -// - Auth via session OF API-token (zelfde patroon als /api/todos). -// - Demo blokkeert POST/PATCH/DELETE (proxy.ts laag + 403 hier als second-line). - -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { ideaCreateSchema } from '@/lib/schemas/idea' -import { ideaStatusFromApi, ideaStatusToApi } from '@/lib/idea-status' -import { nextIdeaCode } from '@/lib/idea-code-server' -import { ideaToDto } from '@/lib/idea-dto' - -export async function GET(request: Request) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const url = new URL(request.url) - const archivedParam = url.searchParams.get('archived') - const productIdParam = url.searchParams.get('product_id') - const statusParam = url.searchParams.get('status') - - const archived = - archivedParam === 'true' ? true : archivedParam === 'false' ? false : undefined - const status = statusParam ? ideaStatusFromApi(statusParam) ?? undefined : undefined - - const ideas = await prisma.idea.findMany({ - where: { - user_id: auth.userId, - ...(archived !== undefined ? { archived } : {}), - ...(productIdParam ? { product_id: productIdParam } : {}), - ...(status ? { status } : {}), - }, - include: { - product: { select: { id: true, name: true, repo_url: true } }, - secondary_products: { include: { product: { select: { id: true, name: true } } } }, - }, - orderBy: { created_at: 'desc' }, - take: 200, - }) - - return Response.json({ ideas: ideas.map(ideaToDto) }) -} - -export async function POST(request: Request) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - if (auth.isDemo) { - return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) - } - - let body: unknown - try { - body = await request.json() - } catch { - return Response.json({ error: 'Malformed JSON' }, { status: 400 }) - } - const parsed = ideaCreateSchema.safeParse(body) - if (!parsed.success) { - return Response.json({ error: parsed.error.flatten() }, { status: 422 }) - } - - // Optionele product-binding: alleen toelaten als gebruiker eigenaar/member is. - if (parsed.data.product_id) { - const product = await prisma.product.findFirst({ - where: { id: parsed.data.product_id, user_id: auth.userId, archived: false }, - select: { id: true }, - }) - if (!product) { - return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) - } - } - - const userId = auth.userId - const idea = await prisma.$transaction(async (tx) => { - const code = await nextIdeaCode(userId, tx) - return tx.idea.create({ - data: { - user_id: userId, - product_id: parsed.data.product_id ?? null, - code, - title: parsed.data.title, - description: parsed.data.description ?? null, - status: 'DRAFT', - }, - include: { product: { select: { id: true, name: true, repo_url: true } } }, - }) - }) - - return Response.json( - { idea: { ...ideaToDto(idea), status: ideaStatusToApi(idea.status) } }, - { status: 201 }, - ) -} diff --git a/app/api/internal/push/send/route.ts b/app/api/internal/push/send/route.ts deleted file mode 100644 index 4891e59..0000000 --- a/app/api/internal/push/send/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { timingSafeEqual } from 'crypto' -import { z } from 'zod' -import { sendPushToUser } from '@/lib/push-server' - -const schema = z.object({ - userId: z.string().min(1), - payload: z.object({ - title: z.string().max(80), - body: z.string().max(300), - url: z.string().startsWith('/').or(z.string().url()), - tag: z.string().optional(), - }), -}) - -export async function POST(req: Request) { - if (!process.env.INTERNAL_PUSH_SECRET) { - return new Response(null, { status: 503 }) - } - - const authHeader = req.headers.get('authorization') ?? '' - const expected = `Bearer ${process.env.INTERNAL_PUSH_SECRET}` - let authorized = false - try { - authorized = - authHeader.length === expected.length && - timingSafeEqual(Buffer.from(authHeader), Buffer.from(expected)) - } catch { - authorized = false - } - if (!authorized) { - return new Response(null, { status: 401 }) - } - - let body: unknown - try { - body = await req.json() - } catch { - return new Response(null, { status: 400 }) - } - - const parsed = schema.safeParse(body) - if (!parsed.success) { - return Response.json({ errors: parsed.error.flatten().fieldErrors }, { status: 422 }) - } - - await sendPushToUser(parsed.data.userId, parsed.data.payload) - return new Response(null, { status: 204 }) -} diff --git a/app/api/internal/push/test-send/route.ts b/app/api/internal/push/test-send/route.ts deleted file mode 100644 index 7359f46..0000000 --- a/app/api/internal/push/test-send/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from 'zod' -import { requireAdmin } from '@/lib/auth-guard' -import { sendPushToUser } from '@/lib/push-server' - -const schema = z.object({ - title: z.string().max(80).optional(), - body: z.string().max(300).optional(), - url: z.string().optional(), -}) - -export async function POST(req: Request) { - const session = await requireAdmin() - - let input: z.infer<typeof schema> = {} - try { - const raw = await req.json() - const parsed = schema.safeParse(raw) - if (parsed.success) input = parsed.data - } catch { - // body is optional — use defaults - } - - await sendPushToUser(session.userId, { - title: input.title ?? 'Test push', - body: input.body ?? 'Admin test notification', - url: input.url ?? '/', - }) - - return new Response(null, { status: 204 }) -} diff --git a/app/api/jobs/[id]/route.ts b/app/api/jobs/[id]/route.ts deleted file mode 100644 index fd11b89..0000000 --- a/app/api/jobs/[id]/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { NextRequest } from 'next/server' -import { getSession } from '@/lib/auth' -import { prisma } from '@/lib/prisma' -import { JOB_INCLUDE, buildPriceMap, mapJob } from '@/lib/jobs-mapper' -import type { PriceRow, RawJob } from '@/lib/jobs-mapper' - -export async function GET( - _request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const session = await getSession() - if (!session.userId) { - return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) - } - - const { id } = await params - - const job = await prisma.claudeJob.findFirst({ - where: { id, user_id: session.userId }, - include: JOB_INCLUDE, - }) - - if (!job) { - return Response.json({ error: 'Job niet gevonden' }, { status: 404 }) - } - - const prices = await prisma.modelPrice.findMany() - const priceMap = buildPriceMap(prices as PriceRow[]) - return Response.json(mapJob(job as RawJob, priceMap)) -} diff --git a/app/api/jobs/[id]/sub-tasks/route.ts b/app/api/jobs/[id]/sub-tasks/route.ts deleted file mode 100644 index 7e90822..0000000 --- a/app/api/jobs/[id]/sub-tasks/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { NextRequest } from 'next/server' -import { getSession } from '@/lib/auth' -import { prisma } from '@/lib/prisma' - -export async function GET( - _request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const session = await getSession() - if (!session.userId) { - return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) - } - const userId = session.userId - const { id } = await params - - const job = await prisma.claudeJob.findFirst({ - where: { id, user_id: userId }, - select: { kind: true }, - }) - - if (!job || job.kind !== 'SPRINT_IMPLEMENTATION') { - return Response.json([], { status: 200 }) - } - - const executions = await prisma.sprintTaskExecution.findMany({ - where: { sprint_job_id: id }, - include: { task: { select: { code: true, title: true } } }, - orderBy: { order: 'asc' }, - }) - - return Response.json( - executions.map(e => ({ - id: e.id, - taskCode: e.task.code, - taskTitle: e.task.title, - status: e.status, - })) - ) -} diff --git a/app/api/pbis/[id]/stories/route.ts b/app/api/pbis/[id]/stories/route.ts deleted file mode 100644 index 67de693..0000000 --- a/app/api/pbis/[id]/stories/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -// PBI-74 / T-870: GET /api/pbis/:id/stories -// -// Levert stories binnen een PBI voor ensurePbiLoaded. Access-control via -// product-eigenaarschap van het bovenliggende PBI. -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' -import { storyStatusToApi } from '@/lib/task-status' - -export const dynamic = 'force-dynamic' - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - - const pbi = await prisma.pbi.findFirst({ - where: { id, product: productAccessFilter(auth.userId) }, - select: { id: true }, - }) - if (!pbi) { - return Response.json({ error: 'PBI niet gevonden' }, { status: 404 }) - } - - const stories = await prisma.story.findMany({ - where: { pbi_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - select: { - id: true, - code: true, - title: true, - description: true, - acceptance_criteria: true, - priority: true, - sort_order: true, - status: true, - pbi_id: true, - sprint_id: true, - created_at: true, - }, - }) - - return Response.json( - stories.map((s) => ({ ...s, status: storyStatusToApi(s.status) })), - ) -} diff --git a/app/api/products/[id]/backlog/route.ts b/app/api/products/[id]/backlog/route.ts deleted file mode 100644 index 9badc35..0000000 --- a/app/api/products/[id]/backlog/route.ts +++ /dev/null @@ -1,101 +0,0 @@ -// PBI-74 / T-870: GET /api/products/:id/backlog -// -// Levert een volledige ProductBacklogSnapshot voor de workspace-store -// (ensureProductLoaded). Auth + access-control consistent met andere -// product-routes (authenticateApiRequest + productAccessFilter). -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' -import { pbiStatusToApi, storyStatusToApi, taskStatusToApi } from '@/lib/task-status' - -export const dynamic = 'force-dynamic' - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - - const product = await prisma.product.findFirst({ - where: { id, ...productAccessFilter(auth.userId) }, - select: { id: true, name: true }, - }) - if (!product) { - return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) - } - - const [pbis, stories, tasks] = await Promise.all([ - prisma.pbi.findMany({ - where: { product_id: id }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }, { created_at: 'asc' }], - select: { - id: true, - code: true, - title: true, - priority: true, - sort_order: true, - description: true, - created_at: true, - status: true, - }, - }), - prisma.story.findMany({ - where: { product_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - select: { - id: true, - code: true, - title: true, - description: true, - acceptance_criteria: true, - priority: true, - sort_order: true, - status: true, - pbi_id: true, - sprint_id: true, - created_at: true, - }, - }), - prisma.task.findMany({ - where: { story: { product_id: id } }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - select: { - id: true, - code: true, - title: true, - description: true, - priority: true, - sort_order: true, - status: true, - story_id: true, - created_at: true, - }, - }), - ]) - - const storiesByPbi: Record<string, unknown[]> = {} - for (const story of stories) { - const apiStory = { ...story, status: storyStatusToApi(story.status) } - if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = [] - storiesByPbi[story.pbi_id].push(apiStory) - } - - const tasksByStory: Record<string, unknown[]> = {} - for (const task of tasks) { - const apiTask = { ...task, status: taskStatusToApi(task.status) } - if (!tasksByStory[task.story_id]) tasksByStory[task.story_id] = [] - tasksByStory[task.story_id].push(apiTask) - } - - return Response.json({ - product, - pbis: pbis.map((p) => ({ ...p, status: pbiStatusToApi(p.status) })), - storiesByPbi, - tasksByStory, - }) -} diff --git a/app/api/products/[id]/claude-context/route.ts b/app/api/products/[id]/claude-context/route.ts index 556d6d7..5387a0f 100644 --- a/app/api/products/[id]/claude-context/route.ts +++ b/app/api/products/[id]/claude-context/route.ts @@ -29,26 +29,14 @@ export async function GET( return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) } - const [activeSprint, openIdeas] = await Promise.all([ + const [activeSprint, openTodos] = await Promise.all([ prisma.sprint.findFirst({ - where: { product_id: id, status: 'OPEN' }, + where: { product_id: id, status: 'ACTIVE' }, select: { id: true, sprint_goal: true, status: true }, }), - prisma.idea.findMany({ - where: { - user_id: auth.userId, - product_id: id, - archived: false, - status: { not: 'PLANNED' }, - }, - select: { - id: true, - code: true, - title: true, - description: true, - status: true, - created_at: true, - }, + prisma.todo.findMany({ + where: { user_id: auth.userId, product_id: id, done: false, archived: false }, + select: { id: true, title: true, description: true, created_at: true }, orderBy: { created_at: 'asc' }, take: 50, }), @@ -58,7 +46,7 @@ export async function GET( if (activeSprint) { const story = await prisma.story.findFirst({ where: { sprint_id: activeSprint.id, status: 'IN_SPRINT' }, - orderBy: [{ sort_order: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], include: { tasks: { orderBy: { sort_order: 'asc' }, @@ -101,6 +89,6 @@ export async function GET( product, active_sprint: activeSprint, next_story: nextStoryPayload, - open_ideas: openIdeas, + open_todos: openTodos, }) } diff --git a/app/api/products/[id]/cross-sprint-blocks/route.ts b/app/api/products/[id]/cross-sprint-blocks/route.ts deleted file mode 100644 index ba10da2..0000000 --- a/app/api/products/[id]/cross-sprint-blocks/route.ts +++ /dev/null @@ -1,74 +0,0 @@ -// PBI-79 / T-929: GET /api/products/:id/cross-sprint-blocks -// -// Lichte UX-hint voor disabled-vinkjes: welke stories binnen pbiIds zitten in -// een andere OPEN sprint (excludeSprintId expliciet uitgesloten). Server-side -// commit-actions blijven autoritatief — dit endpoint is alleen voor UI. -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' - -export const dynamic = 'force-dynamic' - -function parsePbiIds(raw: string | null): string[] | null { - if (!raw) return null - const ids = raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - return ids.length === 0 ? null : ids -} - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id: productId } = await params - const url = new URL(request.url) - const excludeSprintId = url.searchParams.get('excludeSprintId') ?? undefined - const pbiIds = parsePbiIds(url.searchParams.get('pbiIds')) - - if (!pbiIds) { - return Response.json( - { error: 'pbiIds is verplicht (comma-separated)' }, - { status: 400 }, - ) - } - - const product = await prisma.product.findFirst({ - where: { id: productId, ...productAccessFilter(auth.userId) }, - select: { id: true }, - }) - if (!product) { - return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) - } - - const stories = await prisma.story.findMany({ - where: { - pbi_id: { in: pbiIds }, - product_id: productId, - sprint_id: { not: null }, - ...(excludeSprintId ? { NOT: { sprint_id: excludeSprintId } } : {}), - sprint: { status: 'OPEN' }, - }, - select: { - id: true, - sprint: { select: { id: true, code: true } }, - }, - }) - - const result: Record<string, { sprintId: string; sprintName: string }> = {} - for (const story of stories) { - if (!story.sprint) continue - result[story.id] = { - sprintId: story.sprint.id, - sprintName: story.sprint.code, - } - } - - return Response.json(result) -} diff --git a/app/api/products/[id]/next-story/route.ts b/app/api/products/[id]/next-story/route.ts index f2dd414..cbd6944 100644 --- a/app/api/products/[id]/next-story/route.ts +++ b/app/api/products/[id]/next-story/route.ts @@ -15,7 +15,7 @@ export async function GET( const { id } = await params const sprint = await prisma.sprint.findFirst({ - where: { product_id: id, status: 'OPEN', product: productAccessFilter(auth.userId) }, + where: { product_id: id, status: 'ACTIVE', product: productAccessFilter(auth.userId) }, }) if (!sprint) { return Response.json({ error: 'Geen actieve Sprint gevonden' }, { status: 404 }) @@ -23,7 +23,7 @@ export async function GET( const story = await prisma.story.findFirst({ where: { sprint_id: sprint.id, status: 'IN_SPRINT' }, - orderBy: [{ sort_order: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], include: { tasks: { orderBy: { sort_order: 'asc' }, diff --git a/app/api/products/[id]/solo-workspace/route.ts b/app/api/products/[id]/solo-workspace/route.ts deleted file mode 100644 index 36d438e..0000000 --- a/app/api/products/[id]/solo-workspace/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { authenticateApiRequest } from '@/lib/api-auth' -import { getSoloWorkspaceSnapshot } from '@/lib/solo-workspace-server' - -export const dynamic = 'force-dynamic' - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - const url = new URL(request.url) - const sprintId = url.searchParams.get('sprint_id') - const snapshot = await getSoloWorkspaceSnapshot(id, auth.userId, sprintId) - - if (!snapshot) { - return Response.json({ error: 'Solo workspace niet gevonden' }, { status: 404 }) - } - - return Response.json(snapshot) -} diff --git a/app/api/products/[id]/sprint-membership-summary/route.ts b/app/api/products/[id]/sprint-membership-summary/route.ts deleted file mode 100644 index 16f6b6d..0000000 --- a/app/api/products/[id]/sprint-membership-summary/route.ts +++ /dev/null @@ -1,87 +0,0 @@ -// PBI-79 / T-928: GET /api/products/:id/sprint-membership-summary -// -// Levert per PBI {total, inSprint} counts, gescoped op de doorgegeven pbiIds. -// Endpoint weigert product-brede aanroepen (pbiIds is verplicht). Eén groupBy -// + één count-by-sprint waar pbi_id IN (pbiIds). -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' - -export const dynamic = 'force-dynamic' - -function parsePbiIds(raw: string | null): string[] | null { - if (!raw) return null - const ids = raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - return ids.length === 0 ? null : ids -} - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id: productId } = await params - const url = new URL(request.url) - const sprintId = url.searchParams.get('sprintId') - const pbiIds = parsePbiIds(url.searchParams.get('pbiIds')) - - if (!sprintId) { - return Response.json({ error: 'sprintId is verplicht' }, { status: 400 }) - } - if (!pbiIds) { - return Response.json( - { error: 'pbiIds is verplicht (comma-separated)' }, - { status: 400 }, - ) - } - - const product = await prisma.product.findFirst({ - where: { id: productId, ...productAccessFilter(auth.userId) }, - select: { id: true }, - }) - if (!product) { - return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) - } - - const [totals, inSprint] = await Promise.all([ - prisma.story.groupBy({ - by: ['pbi_id'], - where: { pbi_id: { in: pbiIds }, product_id: productId }, - _count: { _all: true }, - }), - prisma.story.groupBy({ - by: ['pbi_id'], - where: { - pbi_id: { in: pbiIds }, - product_id: productId, - sprint_id: sprintId, - }, - _count: { _all: true }, - }), - ]) - - const inSprintByPbi = new Map<string, number>() - for (const row of inSprint) { - inSprintByPbi.set(row.pbi_id, row._count._all) - } - - const result: Record<string, { total: number; inSprint: number }> = {} - for (const pbiId of pbiIds) { - result[pbiId] = { total: 0, inSprint: inSprintByPbi.get(pbiId) ?? 0 } - } - for (const row of totals) { - result[row.pbi_id] = { - total: row._count._all, - inSprint: inSprintByPbi.get(row.pbi_id) ?? 0, - } - } - - return Response.json(result) -} diff --git a/app/api/products/[id]/sprints/route.ts b/app/api/products/[id]/sprints/route.ts deleted file mode 100644 index 50f8bb8..0000000 --- a/app/api/products/[id]/sprints/route.ts +++ /dev/null @@ -1,59 +0,0 @@ -// PBI-74 / Story 9 / T-882: GET /api/products/:id/sprints -// -// Levert een lijst sprints voor een product (sprint-workspace -// ensureProductSprintsLoaded). Auth + access-control consistent met andere -// product-routes. - -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' - -export const dynamic = 'force-dynamic' - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - - const product = await prisma.product.findFirst({ - where: { id, ...productAccessFilter(auth.userId) }, - select: { id: true }, - }) - if (!product) { - return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) - } - - const sprints = await prisma.sprint.findMany({ - where: { product_id: id }, - orderBy: [ - { status: 'asc' }, // OPEN < CLOSED alfabetisch — workspace-store her-sorteert - { start_date: 'desc' }, - { created_at: 'desc' }, - ], - select: { - id: true, - product_id: true, - code: true, - sprint_goal: true, - status: true, - start_date: true, - end_date: true, - created_at: true, - completed_at: true, - }, - }) - - return Response.json( - sprints.map((s) => ({ - ...s, - start_date: s.start_date ? s.start_date.toISOString().slice(0, 10) : null, - end_date: s.end_date ? s.end_date.toISOString().slice(0, 10) : null, - })), - ) -} diff --git a/app/api/profile/avatar/route.ts b/app/api/profile/avatar/route.ts index ba951c9..3bc4907 100644 --- a/app/api/profile/avatar/route.ts +++ b/app/api/profile/avatar/route.ts @@ -3,7 +3,6 @@ import { getIronSession } from 'iron-session' import sharp from 'sharp' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' -import { enforceUserRateLimit } from '@/lib/rate-limit' const MAX_BYTES = 12 * 1024 * 1024 const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']) @@ -21,9 +20,6 @@ export async function POST(request: Request) { return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) } - const limited = enforceUserRateLimit('upload-avatar', session.userId) - if (limited) return Response.json({ error: limited.error }, { status: 429 }) - const formData = await request.formData() const file = formData.get('avatar') as File | null if (!file || file.size === 0) { diff --git a/app/api/realtime/jobs/route.ts b/app/api/realtime/jobs/route.ts deleted file mode 100644 index 67edefd..0000000 --- a/app/api/realtime/jobs/route.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { NextRequest } from 'next/server' -import { Client } from 'pg' -import { getSession } from '@/lib/auth' -import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' -export const maxDuration = 300 - -const CHANNEL = 'scrum4me_changes' -const HEARTBEAT_MS = 25_000 -const HARD_CLOSE_MS = 240_000 - -type JobPayload = { - type: 'claude_job_enqueued' | 'claude_job_status' - job_id: string - task_id?: string | null - idea_id?: string | null - sprint_run_id?: string | null - kind?: string - user_id: string - status: string - branch?: string - pushed_at?: string - pr_url?: string - verify_result?: string - summary?: string - error?: string -} - -function shouldEmit(raw: unknown, userId: string): boolean { - if (!raw || typeof raw !== 'object') return false - const p = raw as Record<string, unknown> - return 'type' in p && typeof p.user_id === 'string' && p.user_id === userId -} - -export async function GET(request: NextRequest) { - const session = await getSession() - if (!session.userId) { - return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) - } - const userId = session.userId - - const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL - if (!directUrl) { - return Response.json({ error: 'DIRECT_URL/DATABASE_URL niet geconfigureerd' }, { status: 500 }) - } - - const encoder = new TextEncoder() - const pgClient = new Client({ connectionString: directUrl }) - - let heartbeatTimer: ReturnType<typeof setInterval> | null = null - let hardCloseTimer: ReturnType<typeof setTimeout> | null = null - let closed = false - - const stream = new ReadableStream({ - async start(controller) { - const enqueue = (chunk: string) => { - if (closed) return - try { - controller.enqueue(encoder.encode(chunk)) - } catch { - // Stream al gesloten - } - } - - const cleanup = async (reason: string) => { - if (closed) return - closed = true - if (heartbeatTimer) clearInterval(heartbeatTimer) - if (hardCloseTimer) clearTimeout(hardCloseTimer) - await closePgClientSafely(pgClient, 'realtime/jobs') - try { - controller.close() - } catch { - // already closed - } - if (process.env.NODE_ENV !== 'production') { - console.log(`[realtime/jobs] closed: ${reason}`) - } - } - - try { - await pgClient.connect() - await pgClient.query(`LISTEN ${CHANNEL}`) - } catch (err) { - console.error('[realtime/jobs] pg connect/listen failed:', err) - enqueue(`event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`) - await cleanup('pg connect failed') - return - } - - pgClient.on('notification', (msg) => { - if (!msg.payload) return - let payload: unknown - try { - payload = JSON.parse(msg.payload) - } catch { - return - } - if (!shouldEmit(payload, userId)) return - enqueue(`data: ${msg.payload}\n\n`) - }) - - pgClient.on('error', async (err) => { - console.error('[realtime/jobs] pg client error:', err) - await cleanup('pg error') - }) - - enqueue(`event: ready\ndata: ${JSON.stringify({ user_id: userId })}\n\n`) - - const activeJobs = await prisma_jobs_findActive(userId) - if (activeJobs.length > 0) { - enqueue(`event: jobs_initial\ndata: ${JSON.stringify(activeJobs)}\n\n`) - } - - heartbeatTimer = setInterval(() => { - enqueue(`: heartbeat\n\n`) - }, HEARTBEAT_MS) - - hardCloseTimer = setTimeout(() => { - cleanup('hard close 240s') - }, HARD_CLOSE_MS) - - request.signal.addEventListener('abort', () => { - cleanup('client aborted') - }) - }, - }) - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream; charset=utf-8', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }, - }) -} - -async function prisma_jobs_findActive(userId: string): Promise<JobPayload[]> { - const { prisma } = await import('@/lib/prisma') - const jobs = await prisma.claudeJob.findMany({ - where: { user_id: userId, status: { notIn: ['DONE'] } }, - select: { - id: true, - kind: true, - status: true, - task_id: true, - idea_id: true, - sprint_run_id: true, - branch: true, - error: true, - summary: true, - }, - }) - return jobs.map(j => ({ - type: 'claude_job_status' as const, - job_id: j.id, - kind: j.kind, - user_id: userId, - status: j.status, - task_id: j.task_id, - idea_id: j.idea_id, - sprint_run_id: j.sprint_run_id, - branch: j.branch ?? undefined, - error: j.error ?? undefined, - summary: j.summary ?? undefined, - })) -} diff --git a/app/api/realtime/notifications/route.ts b/app/api/realtime/notifications/route.ts index 4f42d05..907898a 100644 --- a/app/api/realtime/notifications/route.ts +++ b/app/api/realtime/notifications/route.ts @@ -26,66 +26,17 @@ const CHANNEL = 'scrum4me_changes' const HEARTBEAT_MS = 25_000 const HARD_CLOSE_MS = 240_000 -// Question-payloads: emitted by the notify_question_change trigger on -// claude_questions. story_id and idea_id are mutually exclusive (DB-level -// check-constraint added in M12). -interface QuestionPayload { +interface NotifyPayload { op: 'I' | 'U' - entity: 'question' + entity: 'task' | 'story' | 'question' id: string product_id: string - story_id?: string | null + story_id?: string task_id?: string | null - idea_id?: string | null assignee_id?: string | null status?: string } -// Idea-job-payloads: emitted by actions/ideas.ts (startGrillJobAction etc.) -// via prisma.$executeRaw pg_notify. Always carries user_id + idea_id + kind. -interface IdeaJobPayload { - type: 'claude_job_enqueued' | 'claude_job_status' - job_id: string - idea_id: string - user_id: string - product_id?: string | null - kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' - status: string -} - -// Story-log-payloads: emitted by notify_story_log_change trigger op story_logs -// (T-559). Carries product_id voor productAccessFilter en optioneel idea_id -// voor user-private idea-access (M12 keuze 2). log_type is informatief. -interface StoryLogPayload { - op: 'INSERT' - entity: 'story_log' - id: string - story_id: string - product_id: string | null - idea_id: string | null - log_type: 'IMPLEMENTATION_PLAN' | 'COMMIT' | 'TEST_RESULT' | string -} - -type NotifyPayload = QuestionPayload | IdeaJobPayload | StoryLogPayload - -function isQuestionPayload(p: NotifyPayload): p is QuestionPayload { - return 'entity' in p && p.entity === 'question' -} - -function isIdeaJobPayload(p: NotifyPayload): p is IdeaJobPayload { - return ( - 'type' in p && - (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') && - 'idea_id' in p && - 'kind' in p && - (p.kind === 'IDEA_GRILL' || p.kind === 'IDEA_MAKE_PLAN') - ) -} - -function isStoryLogPayload(p: NotifyPayload): p is StoryLogPayload { - return 'entity' in p && p.entity === 'story_log' -} - export async function GET(request: NextRequest) { const session = await getSession() if (!session.userId) { @@ -102,15 +53,6 @@ export async function GET(request: NextRequest) { }) const accessibleProductIds = new Set(products.map((p) => p.id)) - // M12: idea-questions zijn strikt user_id-only (geen productAccessFilter). - // We pre-fetchen de user's idea-ids zodat we snel kunnen filteren op het - // SSE-pad — geen DB-call per event. - const userIdeas = await prisma.idea.findMany({ - where: { user_id: userId }, - select: { id: true }, - }) - const accessibleIdeaIds = new Set(userIdeas.map((i) => i.id)) - const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL if (!directUrl) { return Response.json( @@ -173,39 +115,7 @@ export async function GET(request: NextRequest) { } catch { return } - - if (isIdeaJobPayload(payload)) { - // M12: idea-jobs zijn user-scoped, niet product-scoped. - if (payload.user_id !== userId) return - enqueue(`data: ${msg.payload}\n\n`) - return - } - - if (isStoryLogPayload(payload)) { - // Sync-tab (PBI-36 ST-1219): story_log-event moet door als óf de - // story bij een user-eigen idee hoort, óf de user productAccess - // heeft (voor non-Idea views). idea_id-pad heeft voorrang — - // sluit aan op M12 strikt user_id-only voor ideas. - if (payload.idea_id && accessibleIdeaIds.has(payload.idea_id)) { - enqueue(`data: ${msg.payload}\n\n`) - return - } - if (payload.product_id && accessibleProductIds.has(payload.product_id)) { - enqueue(`data: ${msg.payload}\n\n`) - } - return - } - - if (!isQuestionPayload(payload)) return - - // Idea-question: alleen voor de eigenaar van het idee. - if (payload.idea_id) { - if (!accessibleIdeaIds.has(payload.idea_id)) return - enqueue(`data: ${msg.payload}\n\n`) - return - } - - // Story-question: bestaande product-access-check. + if (payload.entity !== 'question') return if (!accessibleProductIds.has(payload.product_id)) return enqueue(`data: ${msg.payload}\n\n`) }) @@ -217,55 +127,30 @@ export async function GET(request: NextRequest) { // Initial state ná LISTEN actief — race-fix conform M10 ST-1004 / ST-1006. // Voorkomt dat een vraag die net vóór SSE-open landt verloren gaat. - // M12 hotfix: óók idea-questions (user-private), zodat de bel - // gehydrateerd blijft na elke close+reconnect-cycle. - const [storyOpen, ideaOpen] = await Promise.all([ - prisma.claudeQuestion.findMany({ - where: { - status: 'open', - expires_at: { gt: new Date() }, - product_id: { in: products.map((p) => p.id) }, - }, - orderBy: { created_at: 'desc' }, - take: 100, - select: { - id: true, - product_id: true, - story_id: true, - task_id: true, - question: true, - options: true, - created_at: true, - expires_at: true, - story: { select: { code: true, title: true, assignee_id: true } }, - }, - }), - prisma.claudeQuestion.findMany({ - where: { - status: 'open', - expires_at: { gt: new Date() }, - idea: { user_id: userId }, - }, - orderBy: { created_at: 'desc' }, - take: 100, - select: { - id: true, - product_id: true, - idea_id: true, - question: true, - options: true, - created_at: true, - expires_at: true, - idea: { select: { id: true, code: true, title: true } }, - }, - }), - ]) + const openQuestions = await prisma.claudeQuestion.findMany({ + where: { + status: 'open', + expires_at: { gt: new Date() }, + product_id: { in: products.map((p) => p.id) }, + }, + orderBy: { created_at: 'desc' }, + take: 100, + select: { + id: true, + product_id: true, + story_id: true, + task_id: true, + question: true, + options: true, + created_at: true, + expires_at: true, + story: { select: { code: true, title: true, assignee_id: true } }, + }, + }) - const stateQuestions = [ - ...storyOpen.flatMap((q) => { - if (!q.story || q.story_id === null) return [] - return [{ - kind: 'story' as const, + enqueue( + `event: state\ndata: ${JSON.stringify({ + questions: openQuestions.map((q) => ({ id: q.id, product_id: q.product_id, story_id: q.story_id, @@ -277,26 +162,9 @@ export async function GET(request: NextRequest) { options: q.options, created_at: q.created_at.toISOString(), expires_at: q.expires_at.toISOString(), - }] - }), - ...ideaOpen.flatMap((q) => { - if (!q.idea || q.idea_id === null) return [] - return [{ - kind: 'idea' as const, - id: q.id, - product_id: q.product_id, - idea_id: q.idea_id, - idea_code: q.idea.code, - idea_title: q.idea.title, - question: q.question, - options: q.options, - created_at: q.created_at.toISOString(), - expires_at: q.expires_at.toISOString(), - }] - }), - ].sort((a, b) => (a.created_at < b.created_at ? 1 : -1)) - - enqueue(`event: state\ndata: ${JSON.stringify({ questions: stateQuestions })}\n\n`) + })), + })}\n\n`, + ) heartbeatTimer = setInterval(() => { enqueue(`: heartbeat\n\n`) diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts index 40a0b01..0553cf6 100644 --- a/app/api/realtime/solo/route.ts +++ b/app/api/realtime/solo/route.ts @@ -41,11 +41,7 @@ type EntityPayload = { type JobPayload = { type: 'claude_job_enqueued' | 'claude_job_status' job_id: string - task_id?: string | null - // M12: idea-jobs zetten kind + idea_id ipv task_id. Solo filtert die weg - // (idea-jobs horen op /api/realtime/notifications, niet op het Solo Paneel). - idea_id?: string | null - kind?: 'TASK_IMPLEMENTATION' | 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' + task_id: string user_id: string product_id: string status: string @@ -64,17 +60,7 @@ type WorkerPayload = { product_id?: string } -// M13: per-iteration quota-rapport van de worker. Geen product-scope — -// elke heartbeat geldt voor alle producten waar deze user toegang toe heeft. -type WorkerHeartbeatPayload = { - type: 'worker_heartbeat' - user_id: string - token_id: string - last_quota_pct: number - last_quota_check_at: string -} - -type NotifyPayload = EntityPayload | JobPayload | WorkerPayload | WorkerHeartbeatPayload +type NotifyPayload = EntityPayload | JobPayload | WorkerPayload function isJobPayload(p: NotifyPayload): p is JobPayload { return 'type' in p && (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') @@ -84,10 +70,6 @@ function isWorkerPayload(p: NotifyPayload): p is WorkerPayload { return 'type' in p && (p.type === 'worker_connected' || p.type === 'worker_disconnected') } -function isWorkerHeartbeatPayload(p: NotifyPayload): p is WorkerHeartbeatPayload { - return 'type' in p && p.type === 'worker_heartbeat' -} - function shouldEmit( payload: NotifyPayload, productId: string, @@ -95,8 +77,6 @@ function shouldEmit( userId: string, ): boolean { if (isJobPayload(payload)) { - // M12: skip idea-jobs (kind=IDEA_*) — die horen op /api/realtime/notifications. - if (payload.kind === 'IDEA_GRILL' || payload.kind === 'IDEA_MAKE_PLAN') return false return payload.user_id === userId && payload.product_id === productId } @@ -104,10 +84,6 @@ function shouldEmit( return payload.user_id === userId } - if (isWorkerHeartbeatPayload(payload)) { - return payload.user_id === userId - } - // M11 (ST-1104): question-events horen op /api/realtime/notifications, niet hier. if (payload.entity === 'question') return false @@ -261,7 +237,7 @@ export async function GET(request: NextRequest) { async function prisma_sprint_findActive(productId: string): Promise<{ id: string } | null> { const { prisma } = await import('@/lib/prisma') return prisma.sprint.findFirst({ - where: { product_id: productId, status: 'OPEN' }, + where: { product_id: productId, status: 'ACTIVE' }, select: { id: true }, orderBy: { created_at: 'desc' }, }) diff --git a/app/api/realtime/sprint/route.ts b/app/api/realtime/sprint/route.ts deleted file mode 100644 index aaaf34c..0000000 --- a/app/api/realtime/sprint/route.ts +++ /dev/null @@ -1,141 +0,0 @@ -// SSE endpoint for the sprint workspace (sprint / story / task changes). -// Mirrors /api/realtime/backlog but with entity filter ∈ {sprint, story, task} -// scoped per product. PBI-74 / Story 9. -// -// Auth: iron-session cookie. Demo users may read. - -import { NextRequest } from 'next/server' -import { Client } from 'pg' -import { getSession } from '@/lib/auth' -import { getAccessibleProduct } from '@/lib/product-access' -import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' -export const maxDuration = 300 - -const CHANNEL = 'scrum4me_changes' -const HEARTBEAT_MS = 25_000 -const HARD_CLOSE_MS = 240_000 - -type NotifyPayload = Record<string, unknown> - -function shouldEmit(payload: NotifyPayload, productId: string): boolean { - if ('type' in payload) return false - const entity = payload.entity as string | undefined - if (!entity || !['sprint', 'story', 'task'].includes(entity)) return false - return payload.product_id === productId -} - -export async function GET(request: NextRequest) { - const session = await getSession() - if (!session.userId) { - return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) - } - - const productId = request.nextUrl.searchParams.get('product_id') - if (!productId) { - return Response.json({ error: 'product_id is verplicht' }, { status: 400 }) - } - - const product = await getAccessibleProduct(productId, session.userId) - if (!product) { - return Response.json({ error: 'Geen toegang tot dit product' }, { status: 403 }) - } - - const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL - if (!directUrl) { - return Response.json( - { error: 'DIRECT_URL/DATABASE_URL niet geconfigureerd' }, - { status: 500 }, - ) - } - - const encoder = new TextEncoder() - const pgClient = new Client({ connectionString: directUrl }) - - let heartbeatTimer: ReturnType<typeof setInterval> | null = null - let hardCloseTimer: ReturnType<typeof setTimeout> | null = null - let closed = false - - const stream = new ReadableStream({ - async start(controller) { - const enqueue = (chunk: string) => { - if (closed) return - try { - controller.enqueue(encoder.encode(chunk)) - } catch { - // stream already closed - } - } - - const cleanup = async (reason: string) => { - if (closed) return - closed = true - if (heartbeatTimer) clearInterval(heartbeatTimer) - if (hardCloseTimer) clearTimeout(hardCloseTimer) - await closePgClientSafely(pgClient, 'realtime/sprint') - try { - controller.close() - } catch { - // already closed - } - if (process.env.NODE_ENV !== 'production') { - console.log(`[realtime/sprint] closed: ${reason}`) - } - } - - try { - await pgClient.connect() - await pgClient.query(`LISTEN ${CHANNEL}`) - } catch (err) { - console.error('[realtime/sprint] pg connect/listen failed:', err) - enqueue( - `event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`, - ) - await cleanup('pg connect failed') - return - } - - pgClient.on('notification', (msg) => { - if (!msg.payload) return - let payload: NotifyPayload - try { - payload = JSON.parse(msg.payload) as NotifyPayload - } catch { - return - } - if (!shouldEmit(payload, productId)) return - enqueue(`data: ${msg.payload}\n\n`) - }) - - pgClient.on('error', async (err) => { - console.error('[realtime/sprint] pg client error:', err) - await cleanup('pg error') - }) - - enqueue(`event: ready\ndata: ${JSON.stringify({ product_id: productId })}\n\n`) - - heartbeatTimer = setInterval(() => { - enqueue(`: heartbeat\n\n`) - }, HEARTBEAT_MS) - - hardCloseTimer = setTimeout(() => { - cleanup('hard close 240s') - }, HARD_CLOSE_MS) - - request.signal.addEventListener('abort', () => { - cleanup('client aborted') - }) - }, - }) - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream; charset=utf-8', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }, - }) -} diff --git a/app/api/realtime/user-settings/route.ts b/app/api/realtime/user-settings/route.ts deleted file mode 100644 index 6c3261f..0000000 --- a/app/api/realtime/user-settings/route.ts +++ /dev/null @@ -1,146 +0,0 @@ -// PBI-76: User-scoped SSE stream voor user-settings cross-tab/cross-device sync. -// -// Wordt door <UserSettingsBridge /> in app/(app)/layout.tsx geopend zodra de -// gebruiker is ingelogd. Filtert pg_notify-payloads op -// `kind === 'user_settings' && userId === session.userId`. Settings worden -// via prop al gehydrateerd; deze route levert alleen incrementele patches. -// -// Auth: iron-session cookie. Demo-tokens openen geen subscription (bridge -// skipt voor isDemo). -// Output: text/event-stream — `data:` met de patch (Partial<UserSettings>). -// Sluit zelf na 240s als safety-net; client herconnect. - -import { NextRequest } from 'next/server' -import { Client } from 'pg' -import { getSession } from '@/lib/auth' -import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' -export const maxDuration = 300 - -const CHANNEL = 'scrum4me_changes' -const HEARTBEAT_MS = 25_000 -const HARD_CLOSE_MS = 240_000 - -interface UserSettingsPayload { - kind: 'user_settings' - userId: string - patch: Record<string, unknown> -} - -function isUserSettingsPayload(p: unknown): p is UserSettingsPayload { - if (typeof p !== 'object' || p === null) return false - const obj = p as Record<string, unknown> - return ( - obj.kind === 'user_settings' && - typeof obj.userId === 'string' && - typeof obj.patch === 'object' && - obj.patch !== null - ) -} - -export async function GET(request: NextRequest) { - const session = await getSession() - if (!session.userId) { - return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) - } - const userId = session.userId - - const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL - if (!directUrl) { - return Response.json( - { error: 'DIRECT_URL/DATABASE_URL niet geconfigureerd' }, - { status: 500 }, - ) - } - - const encoder = new TextEncoder() - const pgClient = new Client({ connectionString: directUrl }) - - let heartbeatTimer: ReturnType<typeof setInterval> | null = null - let hardCloseTimer: ReturnType<typeof setTimeout> | null = null - let closed = false - - const stream = new ReadableStream({ - async start(controller) { - const enqueue = (chunk: string) => { - if (closed) return - try { - controller.enqueue(encoder.encode(chunk)) - } catch { - // controller already closed - } - } - - const cleanup = async (reason: string) => { - if (closed) return - closed = true - if (heartbeatTimer) clearInterval(heartbeatTimer) - if (hardCloseTimer) clearTimeout(hardCloseTimer) - await closePgClientSafely(pgClient, 'realtime/user-settings') - try { - controller.close() - } catch { - // already closed - } - if (process.env.NODE_ENV !== 'production') { - console.log(`[realtime/user-settings] closed: ${reason}`) - } - } - - try { - await pgClient.connect() - await pgClient.query(`LISTEN ${CHANNEL}`) - } catch (err) { - console.error('[realtime/user-settings] pg connect/listen failed:', err) - enqueue( - `event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`, - ) - await cleanup('pg connect failed') - return - } - - pgClient.on('notification', (msg) => { - if (!msg.payload) return - let payload: unknown - try { - payload = JSON.parse(msg.payload) - } catch { - return - } - if (!isUserSettingsPayload(payload)) return - if (payload.userId !== userId) return - enqueue(`data: ${JSON.stringify(payload.patch)}\n\n`) - }) - - pgClient.on('error', (err) => { - console.error('[realtime/user-settings] pg client error:', err) - cleanup('pg error') - }) - - enqueue(`: connected\n\n`) - - heartbeatTimer = setInterval(() => { - enqueue(`: heartbeat\n\n`) - }, HEARTBEAT_MS) - - hardCloseTimer = setTimeout(() => { - cleanup('hard close 240s') - }, HARD_CLOSE_MS) - - request.signal.addEventListener('abort', () => { - cleanup('client aborted') - }) - }, - }) - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream; charset=utf-8', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }, - }) -} diff --git a/app/api/sprints/[id]/tasks/route.ts b/app/api/sprints/[id]/tasks/route.ts index 73f88c0..6d1e2d3 100644 --- a/app/api/sprints/[id]/tasks/route.ts +++ b/app/api/sprints/[id]/tasks/route.ts @@ -28,6 +28,7 @@ export async function GET( where: { sprint_id: id }, orderBy: [ { story: { sort_order: 'asc' } }, + { priority: 'asc' }, { sort_order: 'asc' }, ], take: limit, diff --git a/app/api/sprints/[id]/workspace/route.ts b/app/api/sprints/[id]/workspace/route.ts deleted file mode 100644 index 8d48c7d..0000000 --- a/app/api/sprints/[id]/workspace/route.ts +++ /dev/null @@ -1,110 +0,0 @@ -// PBI-74 / Story 9 / T-882: GET /api/sprints/:id/workspace -// -// Levert een SprintWorkspaceSnapshot (sprint + stories + tasksByStory) voor -// de sprint-workspace-store (ensureSprintLoaded). Auth + access-control via -// product-membership. - -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' -import { storyStatusToApi, taskStatusToApi } from '@/lib/task-status' - -export const dynamic = 'force-dynamic' - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - - const sprint = await prisma.sprint.findFirst({ - where: { id, product: productAccessFilter(auth.userId) }, - select: { - id: true, - product_id: true, - code: true, - sprint_goal: true, - status: true, - start_date: true, - end_date: true, - created_at: true, - completed_at: true, - product: { select: { id: true, name: true } }, - }, - }) - if (!sprint) { - return Response.json({ error: 'Sprint niet gevonden' }, { status: 404 }) - } - - const [stories, tasks] = await Promise.all([ - prisma.story.findMany({ - where: { sprint_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - include: { - tasks: { select: { id: true, status: true } }, - assignee: { select: { id: true, username: true } }, - }, - }), - prisma.task.findMany({ - where: { sprint_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - select: { - id: true, - code: true, - title: true, - description: true, - priority: true, - sort_order: true, - status: true, - story_id: true, - sprint_id: true, - created_at: true, - }, - }), - ]) - - const tasksByStory: Record<string, unknown[]> = {} - for (const task of tasks) { - const apiTask = { ...task, status: taskStatusToApi(task.status) } - if (!tasksByStory[task.story_id]) tasksByStory[task.story_id] = [] - tasksByStory[task.story_id].push(apiTask) - } - - return Response.json({ - product: sprint.product, - sprint: { - id: sprint.id, - product_id: sprint.product_id, - code: sprint.code, - sprint_goal: sprint.sprint_goal, - status: sprint.status, - start_date: sprint.start_date ? sprint.start_date.toISOString().slice(0, 10) : null, - end_date: sprint.end_date ? sprint.end_date.toISOString().slice(0, 10) : null, - created_at: sprint.created_at, - completed_at: sprint.completed_at, - }, - stories: stories.map((s) => ({ - id: s.id, - code: s.code, - title: s.title, - description: s.description, - acceptance_criteria: s.acceptance_criteria, - priority: s.priority, - sort_order: s.sort_order, - status: storyStatusToApi(s.status), - pbi_id: s.pbi_id, - sprint_id: s.sprint_id, - created_at: s.created_at, - taskCount: s.tasks.length, - doneCount: s.tasks.filter((t) => t.status === 'DONE').length, - assignee_id: s.assignee_id, - assignee_username: s.assignee?.username ?? null, - })), - tasksByStory, - }) -} diff --git a/app/api/stories/[id]/log/route.ts b/app/api/stories/[id]/log/route.ts index a6965e5..42eb60a 100644 --- a/app/api/stories/[id]/log/route.ts +++ b/app/api/stories/[id]/log/route.ts @@ -3,7 +3,6 @@ import { prisma } from '@/lib/prisma' import { productAccessFilter } from '@/lib/product-access' import { Prisma } from '@prisma/client' import { z } from 'zod' -import { enforceUserRateLimit } from '@/lib/rate-limit' const metadataField = z.record(z.string(), z.unknown()).optional() @@ -40,9 +39,6 @@ export async function POST( return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) } - const limited = enforceUserRateLimit('log-story', auth.userId) - if (limited) return Response.json({ error: limited.error }, { status: 429 }) - const { id: storyId } = await params const story = await prisma.story.findFirst({ diff --git a/app/api/stories/[id]/tasks/reorder/route.ts b/app/api/stories/[id]/tasks/reorder/route.ts new file mode 100644 index 0000000..53aeab5 --- /dev/null +++ b/app/api/stories/[id]/tasks/reorder/route.ts @@ -0,0 +1,56 @@ +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' +import { z } from 'zod' + +const bodySchema = z.object({ + task_ids: z.array(z.string()).min(1), +}) + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + if (auth.isDemo) { + return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) + } + + const { id: storyId } = await params + + let body: unknown + try { + body = await request.json() + } catch { + return Response.json({ error: 'Malformed JSON' }, { status: 400 }) + } + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + return Response.json({ error: parsed.error.flatten() }, { status: 422 }) + } + + const story = await prisma.story.findFirst({ + where: { id: storyId, product: productAccessFilter(auth.userId) }, + include: { tasks: { select: { id: true } } }, + }) + if (!story) { + return Response.json({ error: 'Story niet gevonden' }, { status: 404 }) + } + + const storyTaskIds = new Set(story.tasks.map(t => t.id)) + const invalidId = parsed.data.task_ids.find(id => !storyTaskIds.has(id)) + if (invalidId) { + return Response.json({ error: `Ongeldig task_id: ${invalidId}` }, { status: 422 }) + } + + await prisma.$transaction( + parsed.data.task_ids.map((id, i) => + prisma.task.update({ where: { id }, data: { sort_order: i + 1.0 } }) + ) + ) + + return Response.json({ success: true }) +} diff --git a/app/api/stories/[id]/tasks/route.ts b/app/api/stories/[id]/tasks/route.ts deleted file mode 100644 index 2584ff7..0000000 --- a/app/api/stories/[id]/tasks/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -// PBI-74 / T-870: GET /api/stories/:id/tasks -// -// Levert tasks binnen een story voor ensureStoryLoaded. Access-control via -// product-eigenaarschap van de bovenliggende story. -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' -import { taskStatusToApi } from '@/lib/task-status' - -export const dynamic = 'force-dynamic' - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - - const story = await prisma.story.findFirst({ - where: { id, product: productAccessFilter(auth.userId) }, - select: { id: true }, - }) - if (!story) { - return Response.json({ error: 'Story niet gevonden' }, { status: 404 }) - } - - const tasks = await prisma.task.findMany({ - where: { story_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - select: { - id: true, - code: true, - title: true, - description: true, - priority: true, - sort_order: true, - status: true, - story_id: true, - created_at: true, - }, - }) - - return Response.json( - tasks.map((t) => ({ ...t, status: taskStatusToApi(t.status) })), - ) -} diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts index 52cce01..b80a811 100644 --- a/app/api/tasks/[id]/route.ts +++ b/app/api/tasks/[id]/route.ts @@ -2,57 +2,7 @@ import { authenticateApiRequest } from '@/lib/api-auth' import { prisma } from '@/lib/prisma' import { z } from 'zod' import { TASK_STATUS_API_VALUES, taskStatusFromApi, taskStatusToApi } from '@/lib/task-status' -import { propagateStatusUpwards } from '@/lib/tasks-status-update' -import { productAccessFilter } from '@/lib/product-access' - -// PBI-74 / T-869: force-dynamic zodat Next geen response-cache hangt aan -// deze route — workspace-store leest hier verse data via ensureTaskLoaded. -export const dynamic = 'force-dynamic' - -// PBI-74 / T-870: GET-handler voor ensureTaskLoaded. Levert TaskDetail-shape -// (extends BacklogTask met implementation_plan etc.). Access-control via -// product van de parent-story. -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - - const task = await prisma.task.findFirst({ - where: { - id, - story: { product: productAccessFilter(auth.userId) }, - }, - select: { - id: true, - title: true, - description: true, - priority: true, - sort_order: true, - status: true, - story_id: true, - created_at: true, - implementation_plan: true, - requires_opus: true, - verify_only: true, - verify_required: true, - }, - }) - if (!task) { - return Response.json({ error: 'Task niet gevonden' }, { status: 404 }) - } - - return Response.json({ - ...task, - status: taskStatusToApi(task.status), - _detail: true, - }) -} +import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update' // `review` is a valid TaskStatus in the DB and the kanban-board UI, but the // sprint task list (components/sprint/task-list.tsx) does not yet render it. @@ -161,7 +111,7 @@ export async function PATCH( : null if (dbStatus !== undefined && dbStatus !== null) { - const result = await propagateStatusUpwards(id, dbStatus, tx) + const result = await updateTaskStatusWithStoryPromotion(id, dbStatus, tx) return { id: result.task.id, status: result.task.status, diff --git a/app/api/todos/route.ts b/app/api/todos/route.ts new file mode 100644 index 0000000..6a682e5 --- /dev/null +++ b/app/api/todos/route.ts @@ -0,0 +1,53 @@ +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' +import { z } from 'zod' + +const bodySchema = z.object({ + title: z.string().min(1, 'Titel is verplicht').max(500), + description: z.string().max(2000, 'Beschrijving mag maximaal 2000 tekens bevatten').optional(), + product_id: z.string().min(1, 'Product is verplicht'), +}) + +export async function POST(request: Request) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + if (auth.isDemo) { + return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) + } + + let body: unknown + try { + body = await request.json() + } catch { + return Response.json({ error: 'Malformed JSON' }, { status: 400 }) + } + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + return Response.json({ error: parsed.error.flatten() }, { status: 422 }) + } + + const product = await prisma.product.findFirst({ + where: { id: parsed.data.product_id, user_id: auth.userId, archived: false }, + }) + if (!product) { + return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) + } + + const description = parsed.data.description?.trim() || null + + const todo = await prisma.todo.create({ + data: { + user_id: auth.userId, + product_id: parsed.data.product_id, + title: parsed.data.title, + description, + }, + }) + + return Response.json( + { id: todo.id, title: todo.title, description: todo.description, created_at: todo.created_at }, + { status: 201 }, + ) +} diff --git a/app/debug-env/page.tsx b/app/debug-env/page.tsx index 3e653b2..e8d0c47 100644 --- a/app/debug-env/page.tsx +++ b/app/debug-env/page.tsx @@ -5,7 +5,6 @@ // VERWIJDEREN zodra env-config op Vercel bevestigd is. import { headers } from 'next/headers' -import { notFound } from 'next/navigation' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' @@ -46,9 +45,6 @@ function inspectSecret(name: string, raw: string | undefined): VarStatus { } export default async function DebugEnvPage() { - // Productie-guard: lekt env-var-metadata (hostnames, lengtes, pooled-flag). - if (process.env.NODE_ENV === 'production') notFound() - // Force dynamic so each visit reads runtime env (niet build-time gecached) await headers() diff --git a/app/debug-realtime/page.tsx b/app/debug-realtime/page.tsx index f28124e..4dc28f3 100644 --- a/app/debug-realtime/page.tsx +++ b/app/debug-realtime/page.tsx @@ -5,15 +5,11 @@ // // VERWIJDEREN VOOR M8 OUT-OF-DRAFT. -import { notFound } from 'next/navigation' import { DebugRealtimeClient } from './client' export const dynamic = 'force-dynamic' export default function DebugRealtimePage() { - // Productie-guard: deze pagina toont rauwe pg_notify-events zonder auth. - if (process.env.NODE_ENV === 'production') notFound() - return ( <div style={{ fontFamily: 'monospace', padding: 16 }}> <h1 style={{ fontSize: 18, fontWeight: 'bold' }}>Realtime debug — scrum4me_changes</h1> diff --git a/app/globals.css b/app/globals.css index e8d3b09..9e6d7af 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3,25 +3,3 @@ @plugin "@tailwindcss/typography"; @import "./styles/theme.css"; - -/* Debug-mode overlay (alleen actief wanneer body.debug-mode is gezet door dev-only toggle) */ -body.debug-mode [data-debug-id] { - outline: 2px dashed var(--info); - outline-offset: 1px; - position: relative; -} -body.debug-mode [data-debug-id]:hover::after { - content: attr(data-debug-id); - position: absolute; - top: 0; - left: 0; - background: var(--info-container); - color: var(--info-container-foreground); - font-size: 10px; - line-height: 1.2; - padding: 2px 4px; - white-space: nowrap; - border-radius: 2px; - z-index: 9999; - pointer-events: none; -} diff --git a/app/layout.tsx b/app/layout.tsx index 5cc59ad..78b08fe 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata, Viewport } from "next"; +import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Analytics } from "@vercel/analytics/next"; import { Toaster } from "sonner"; @@ -30,17 +30,6 @@ export const metadata: Metadata = { ], }, manifest: "/manifest.json", - appleWebApp: { - capable: true, - statusBarStyle: 'default', - }, - other: { - 'mobile-web-app-capable': 'yes', - }, -}; - -export const viewport: Viewport = { - themeColor: '#ffffff', }; export default function RootLayout({ diff --git a/app/page.tsx b/app/page.tsx index ccf42d1..c01924f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -45,6 +45,12 @@ export default async function LandingPage() { > Solo </Link> + <Link + href="/todos" + className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors" + > + Todo's + </Link> <Link href="/settings" className="px-3 py-1.5 rounded-md text-sm text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors" @@ -76,13 +82,11 @@ export default async function LandingPage() { <section className="bg-primary-container px-6 py-16 text-center"> <div className="max-w-2xl mx-auto space-y-4"> <h1 className="text-3xl font-semibold text-primary-container-foreground"> - Van idee tot pull request — op je eigen hardware. + Scrum planner voor solo developers en kleine teams </h1> <p className="text-base text-primary-container-foreground/80 leading-relaxed"> - Leg een idee vast, laat Claude het kritisch bevragen, accepteer het plan en zet het - door een lokale agent uit. Code, executie en agents draaien op je eigen machine; - alleen metadata loopt via Vercel + Neon. Idee na idee, automatisch omgezet in - commits en pull requests. + Houd meerdere projecten bij in één overzicht. Plan Product Backlogs, beheer Sprints + met drag-and-drop en laat Claude Code taken oppakken via een REST API. </p> <div className="flex gap-3 justify-center pt-2"> <Link @@ -91,12 +95,12 @@ export default async function LandingPage() { > Account aanmaken </Link> - <a - href="#architectuur" + <Link + href="/login" className="px-5 py-2.5 rounded-lg border border-primary text-primary bg-transparent text-sm font-medium hover:bg-primary/10 transition-colors" > - Hoe het werkt - </a> + Demo bekijken + </Link> </div> <p className="text-xs text-primary-container-foreground/60 pt-1"> Demo-login: gebruikersnaam <code className="font-mono bg-primary-container-foreground/10 px-1 rounded">demo</code> · wachtwoord <code className="font-mono bg-primary-container-foreground/10 px-1 rounded">demo1234</code> @@ -113,198 +117,38 @@ export default async function LandingPage() { </div> </section> - {/* ── Van idee tot PR ────────────────────────────────────────── */} - <section className="px-6 py-14 bg-background border-b border-border"> - <div className="max-w-6xl mx-auto"> - <h2 className="text-xl font-semibold mb-2">Van idee tot pull request</h2> - <p className="text-muted-foreground text-sm mb-10 max-w-2xl"> - Vier stappen, één queue. Een idee groeit uit tot gemergde code zonder dat jij ertussen - hoeft. - </p> - - <div className="grid grid-cols-1 md:grid-cols-[1fr_auto_1fr_auto_1fr_auto_1fr] gap-4 items-stretch"> - {[ - { - step: '1', - title: 'Idee', - chip: 'DRAFT', - chipClass: 'bg-tertiary-container text-tertiary-container-foreground', - desc: 'Leg een idee vast in twee zinnen. Status: DRAFT.', - }, - { - step: '2', - title: 'Grill', - chip: 'GRILLING', - chipClass: 'bg-warning-container text-warning-container-foreground', - desc: 'Claude stelt kritische vragen via het belicoon; je antwoorden vormen de grill_md.', - }, - { - step: '3', - title: 'Plan', - chip: 'PLAN_READY', - chipClass: 'bg-success-container text-success-container-foreground', - desc: 'Claude schrijft een YAML-plan. Materialiseer en je hebt PBI + stories + tasks.', - }, - { - step: '4', - title: 'Execute', - chip: 'DONE → PR', - chipClass: 'bg-primary-container text-primary-container-foreground', - desc: 'Lokale agent claimt de jobs, commit, pusht en opent automatisch een PR.', - }, - ].flatMap((s, i, arr) => { - const card = ( - <div - key={s.step} - className="bg-surface-container-low border border-border rounded-xl p-5 space-y-3 flex flex-col" - > - <div className="flex items-center gap-2"> - <div className="shrink-0 w-7 h-7 rounded-full bg-primary text-primary-foreground text-sm font-semibold flex items-center justify-center"> - {s.step} - </div> - <div className="text-sm font-medium text-primary">{s.title}</div> - <span className={`text-[10px] font-mono font-semibold px-1.5 py-0.5 rounded ${s.chipClass}`}> - {s.chip} - </span> - </div> - <p className="text-sm text-muted-foreground leading-relaxed">{s.desc}</p> - </div> - ) - if (i === arr.length - 1) return [card] - return [ - card, - <div - key={`${s.step}-arrow`} - className="hidden md:flex items-center justify-center text-muted-foreground text-2xl" - > - → - </div>, - ] - })} - </div> - - <p className="text-sm text-muted-foreground leading-relaxed max-w-3xl mt-8"> - State-machine: <code className="font-mono text-xs bg-surface-container px-1 rounded">DRAFT → GRILLING → GRILLED → PLANNING → PLAN_READY → PLANNED</code>. - Bij materialiseren ontstaat in één atomaire transactie precies één PBI met N stories en M taken - uit het YAML-plan. Op de laatste taak van de laatste story pusht de worker automatisch een - branch en opent of mergt een pull request — geen handwerk meer tussen plan en deploy. - </p> - </div> - </section> - - {/* ── Architectuur ───────────────────────────────────────────── */} - <section id="architectuur" className="px-6 py-14 bg-background border-b border-border"> - <div className="max-w-4xl mx-auto"> - <h2 className="text-xl font-semibold mb-2">Architectuur — hoe Scrum4Me draait</h2> - <p className="text-muted-foreground text-sm mb-10 max-w-2xl"> - Vier componenten in twee zones. De Scrum4Me-stack (Vercel + Neon) houdt alleen - metadata bij. Jouw kant (lokale worker + GitHub) houdt de code en de uitvoering. - Het enige verkeer over de zonegrens is de job-queue zelf — agents claimen werk en - rapporteren status terug. - </p> - - <div className="bg-surface-container-low border border-border rounded-xl p-6 mb-6 flex justify-center"> - <Image - src="/diagrams/architecture-light.svg" - alt="Scrum4Me-architectuur: Vercel en Neon Postgres in de Scrum4Me-stack; lokale worker en GitHub aan jouw kant; job-queue verbindt de twee." - width={900} - height={420} - className="dark:hidden w-full h-auto" - priority - /> - <Image - src="/diagrams/architecture-dark.svg" - alt="Scrum4Me-architectuur: Vercel en Neon Postgres in de Scrum4Me-stack; lokale worker en GitHub aan jouw kant; job-queue verbindt de twee." - width={900} - height={420} - className="hidden dark:block w-full h-auto" - priority - /> - </div> - - <h3 className="text-sm font-semibold text-foreground mb-4 uppercase tracking-wide">Wat draait waar?</h3> - <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> - {[ - { - title: 'Vercel', - desc: 'Alleen UI, Server Actions en cron-jobs. Geen sourcecode, geen build-artefacten van klanten — Vercel weet niet hoe jouw code eruit ziet.', - }, - { - title: 'Neon Postgres', - desc: 'Scrum-metadata: titels, statussen, plan-tekstvelden, logs en commit-hashes. Geen volledige diffs, geen broncodebestanden. Wat jij of de agent zelf in een plan of log schrijft, staat hier wel.', - }, - { - title: 'Lokale worker', - desc: 'Jouw machine — laptop, NAS of VM. Claude Code via stdio-MCP, claimt jobs atomisch (FOR UPDATE SKIP LOCKED), executeert lokaal, commit lokaal, push lokaal. Doet drie soorten jobs: bevragen van een idee (GRILL), plan-generatie (PLAN), taak-implementatie (IMPL) — allemaal op dezelfde machine. Meerdere workers parallel veilig.', - }, - { - title: 'GitHub', - desc: 'Jouw eigen repo. Scrum4Me kent alleen de repo_url-string en de commit-hashes uit het story-log. Code en historie blijven onder jouw account.', - }, - ].map(({ title, desc }) => ( - <div key={title} className="bg-surface-container-low border border-border rounded-xl p-5 space-y-2"> - <div className="text-sm font-medium text-primary">{title}</div> - <p className="text-sm text-muted-foreground leading-relaxed">{desc}</p> - </div> - ))} - </div> - </div> - </section> - {/* ── Tour (screenshots) ─────────────────────────────────────── */} <section className="px-6 py-14 bg-background border-b border-border"> <div className="max-w-6xl mx-auto"> <h2 className="text-xl font-semibold mb-2">Bekijk Scrum4Me in actie</h2> <p className="text-muted-foreground text-sm mb-10 max-w-2xl"> - Zes weergaven van Scrum4Me — van inkomende ideeën tot persoonlijk Kanban-bord en - voortgangs-inzichten. Elke weergave is desktop-first en gebouwd op MD3-tokens en - shadcn-componenten. + Drie schermen die de kern van Scrum4Me afdekken — van Product Backlog tot persoonlijk + Kanban-bord. Elke weergave is desktop-first en gebouwd op MD3-tokens en shadcn-componenten. </p> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> {[ { - src: '/screenshots/ideas-table.png', - alt: 'Ideas-dashboard met idee-kaarten in DRAFT/GRILLED/PLAN_READY-statussen', - title: 'Ideas-dashboard', - caption: - 'Persoonlijk overzicht van je ideeën met status (DRAFT → GRILLED → PLAN_READY → PLANNED). Klik "Grill me" of "Make plan" om een lokale agent te starten; bij materialiseren ontstaat exact één PBI met stories en taken.', - }, - { - src: '/screenshots/producten.png', - alt: 'Producten-dashboard met overzicht van actieve projecten', - title: 'Producten', - caption: - 'Eén overzicht van alle producten waar je toegang toe hebt — eigen producten plus die waar je als developer bent toegevoegd. Vanaf hier spring je naar Backlog, Sprint of Solo.', - }, - { - src: '/screenshots/product-backlog.png', - alt: 'Product Backlog met PBIs gegroepeerd op prioriteit en stories per PBI', - title: 'Product Backlog', - caption: - 'PBIs gegroepeerd op prioriteit (Kritiek → Laag) in het linkerpaneel. Klik op een PBI om de stories rechts te zien, gerangschikt per urgentie en versleepbaar.', - }, - { - src: '/screenshots/sprint.png', + src: '/screenshots/sprint-board.jpg', alt: 'Sprint Board met drie panelen: Product Backlog, Sprint Backlog en Taken', title: 'Sprint Board', caption: 'Drie panelen op één scherm: Product Backlog links, Sprint Backlog in het midden, taken van de geselecteerde story rechts. Stories slepen tussen panelen werkt via dnd-kit.', }, { - src: '/screenshots/solo.png', + src: '/screenshots/product-backlog.jpg', + alt: 'Product Backlog met PBIs gegroepeerd op prioriteit en stories per PBI', + title: 'Product Backlog', + caption: + 'PBIs gegroepeerd op prioriteit (Kritiek → Laag) in het linkerpaneel. Klik op een PBI om de stories rechts te zien, gerangschikt per urgentie en versleepbaar.', + }, + { + src: '/screenshots/solo-paneel.jpg', alt: 'Solo Paneel — persoonlijk Kanban-bord met drie statuskolommen', title: 'Solo Paneel', caption: 'Persoonlijk Kanban-bord per product. Toont alleen taken van stories die jij hebt geclaimd, in drie kolommen (To Do, Bezig, Klaar). Drag-and-drop tussen kolommen verandert de status.', }, - { - src: '/screenshots/insights.png', - alt: 'Insights-dashboard met voortgangsmetrics en agent-throughput', - title: 'Insights', - caption: - 'Voortgang per product: doorlooptijden, agent-throughput en sprintresultaten in één blik. Helpt patronen herkennen — welke stories liepen vast, welke gingen vlot.', - }, ].map((s) => ( <figure key={s.src} @@ -338,65 +182,93 @@ export default async function LandingPage() { de overhead van grote tools als Jira of Linear. Ontworpen voor developers die zelf de regie willen houden over hun planning. </p> - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> - {[ - { - title: 'Ideas — Grill & Plan', - desc: 'Leg een idee vast in twee zinnen. Claude grilt het met kritische vragen, schrijft een YAML-plan en zet ’t om in PBI + stories + tasks. Alles via een job-queue, asynchroon.', - }, - { - title: 'Sprint Board + Solo Paneel', - desc: 'Twee weergaven van dezelfde data: een team-bord (Product Backlog · Sprint Backlog · taken) en een persoonlijk Kanban met geclaimde stories.', - }, - { - title: 'Lokale Claude-agents', - desc: 'Een job-queue met "Voer uit"-knop. Lokale Claude Code agents claimen werk atomisch, draaien het op jouw hardware en rapporteren status terug. Na de laatste task pusht de worker automatisch en opent een pull request via SQUASH-merge. Meerdere workers (laptop + NAS) parallel veilig.', - }, - { - title: 'Realtime updates', - desc: 'SSE bovenop Postgres LISTEN/NOTIFY. Wijzigingen vanuit andere tabs of een lokale agent verschijnen binnen 1–2 seconden in je Solo Paneel — geen refresh. De Sync-tab toont per idee de live status van story → push → PR-merge.', - }, - { - title: 'Async vraagkanaal', - desc: 'Loopt een agent vast op een keuze? Hij plaatst een vraag via het bel-icoon. Jij beantwoordt hem wanneer het uitkomt; de agent pakt automatisch de draad weer op. Tijdens een Grill stelt Claude vragen via hetzelfde kanaal — antwoorden komen direct terug in de Idea-timeline.', - }, - ].map(({ title, desc }) => ( - <div key={title} className="bg-surface-container-low border border-border rounded-xl p-5 space-y-2"> - <div className="text-sm font-medium text-primary">{title}</div> - <p className="text-sm text-muted-foreground leading-relaxed">{desc}</p> - </div> - ))} + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> + <div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-2"> + <div className="text-sm font-medium text-primary">Hiërarchisch plannen</div> + <p className="text-sm text-muted-foreground leading-relaxed"> + Organiseer werk in producten, Product Backlog Items, stories en taken. + Alles op één plek, gegroepeerd op prioriteit. + </p> + </div> + <div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-2"> + <div className="text-sm font-medium text-primary">Sprint Board</div> + <p className="text-sm text-muted-foreground leading-relaxed"> + Drie-panelen layout: Product Backlog, Sprint Backlog en taken per story op + één scherm. Slepen, sorteren en statussen wisselen via dnd-kit. + </p> + </div> + <div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-2"> + <div className="text-sm font-medium text-primary">Solo Paneel</div> + <p className="text-sm text-muted-foreground leading-relaxed"> + Persoonlijk Kanban-bord per product. Claim stories vanuit de Sprint en werk + je taken af in drie kolommen — To Do, Bezig, Klaar. + </p> + </div> + <div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-2"> + <div className="text-sm font-medium text-primary">Claude Code-integratie</div> + <p className="text-sm text-muted-foreground leading-relaxed"> + Twee opties: native MCP-server (aanbevolen) of REST API met Bearer-token. + Beide laten Claude Code stories ophalen, taken bijwerken en resultaten vastleggen. + </p> + </div> + </div> + <div className="mt-4 bg-surface-container border border-border rounded-xl p-5 flex items-start gap-4"> + <div className="text-xs font-mono font-semibold text-primary shrink-0 mt-1 px-2 py-0.5 rounded bg-primary-container"> + LIVE + </div> + <div className="space-y-1"> + <div className="text-sm font-medium">Realtime Solo Paneel</div> + <p className="text-sm text-muted-foreground leading-relaxed"> + Wijzigingen vanuit andere tabs of Claude Code verschijnen binnen 1–2 seconden in je + Solo Paneel. Geen refresh nodig — gebouwd op Postgres LISTEN/NOTIFY en Server-Sent Events. + </p> + </div> </div> </div> </section> - {/* ── Quickstart ──────────────────────────────────────────── */} + {/* ── Twee manieren om Claude Code te koppelen ─────────────── */} <section className="px-6 py-14 bg-background border-t border-border"> <div className="max-w-4xl mx-auto"> - <h2 className="text-xl font-semibold mb-2">Quickstart — lokale agent in 3 stappen</h2> - <p className="text-muted-foreground text-sm mb-6 max-w-2xl"> - De aanbevolen route: installeer de MCP-server lokaal en laat Claude Code de - Scrum4Me-tools native gebruiken. - </p> - <pre className="bg-background border border-border rounded-lg p-4 text-xs font-mono overflow-x-auto mb-4 max-w-2xl"> - <code className="text-foreground">{`# 1. Clone en installeer de MCP-server -git clone https://github.com/madhura68/scrum4me-mcp -cd scrum4me-mcp && npm install - -# 2. Voeg toe aan Claude Code config (zie repo-README) -# 3. Start Claude Code en vraag: -# "pak de volgende job uit de Scrum4Me-queue"`}</code> - </pre> - <p className="text-sm text-muted-foreground leading-relaxed max-w-2xl mb-3"> - Liever in de UI beginnen? Open <code className="font-mono text-xs bg-surface-container px-1 rounded">/ideas</code>, - druk op "Nieuw idee" en klik "Grill me" — de eerste vraag verschijnt - binnen seconden in je belicoon. - </p> - <p className="text-sm text-muted-foreground leading-relaxed max-w-2xl"> - Liever zonder MCP? Gebruik de{' '} - <a href="#api" className="text-primary hover:underline">REST API met een Bearer-token</a> - {' '}— werkt ook vanuit Codex, eigen scripts of CI-pipelines. + <h2 className="text-xl font-semibold mb-2">Twee manieren om Claude Code te koppelen</h2> + <p className="text-muted-foreground text-sm mb-10 max-w-2xl"> + Kies de aansluiting die bij je workflow past. Beide werken naast elkaar. </p> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-3"> + <div className="flex items-center gap-2"> + <div className="text-sm font-medium text-primary">MCP-server</div> + <span className="text-xs px-2 py-0.5 rounded bg-primary-container text-primary-container-foreground font-medium"> + Aanbevolen + </span> + </div> + <p className="text-sm text-muted-foreground leading-relaxed"> + Native Model Context Protocol-tools voor Claude Code. Geen REST-configuratie — + Claude ziet producten, stories en taken als ingebouwde commando's. Eén prompt + orkestreert de hele implementatieflow: story ophalen, status updaten, plan loggen, + commit vastleggen. + </p> + <a + href="https://github.com/madhura68/scrum4me-mcp" + target="_blank" + rel="noopener noreferrer" + className="inline-flex items-center gap-1 text-xs text-primary hover:underline" + > + github.com/madhura68/scrum4me-mcp → + </a> + </div> + <div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-3"> + <div className="text-sm font-medium text-primary">REST API</div> + <p className="text-sm text-muted-foreground leading-relaxed"> + Universele HTTP-API met Bearer-token. Werkt vanuit Claude Code, Codex, eigen + scripts of CI-pipelines. Zelfde rechten als de MCP-server, maar je beheert + tokens en aanroepen zelf. + </p> + <a href="#api" className="inline-flex items-center gap-1 text-xs text-primary hover:underline"> + API-endpoints bekijken ↓ + </a> + </div> + </div> </div> </section> @@ -412,21 +284,6 @@ cd scrum4me-mcp && npm install {/* Hiërarchie */} <div className="mb-10"> <h3 className="text-sm font-semibold text-foreground mb-4 uppercase tracking-wide">Hiërarchie</h3> - - {/* Idee-laag */} - <div className="mb-3"> - <div className="bg-tertiary-container border border-tertiary/20 rounded-lg px-4 py-2.5 inline-block"> - <div className="text-sm font-medium text-tertiary-container-foreground">Idea</div> - <div className="text-xs text-tertiary-container-foreground/70 leading-tight mt-0.5"> - DRAFT → GRILLED → PLAN_READY - </div> - </div> - <div className="text-muted-foreground text-xs mt-1 ml-4"> - ↓ <span className="italic">materialiseert naar</span> - </div> - </div> - - {/* Scrum-laag */} <div className="flex flex-col sm:flex-row items-start sm:items-center gap-2"> {[ { label: 'Product', sub: 'Een softwareproject of codebase' }, @@ -452,8 +309,6 @@ cd scrum4me-mcp && npm install <h3 className="text-sm font-semibold text-foreground mb-4 uppercase tracking-wide">Terminologie</h3> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> {[ - { term: 'Idea', def: 'Een voorstel of richting voordat ’t een PBI is. Heeft een grill-fase (Claude bevraagt ’t kritisch) en een plan-fase (Claude schrijft een YAML-plan met stories en tasks). Na materialiseren ontstaat exact één PBI.' }, - { term: 'Grill / Plan', def: 'Twee asynchrone Claude-jobsoorten op een idea. Grill produceert grill_md (Q&A-transcript). Plan produceert plan_md (YAML met PBI/stories/tasks-templates) dat strikt geparseerd wordt.' }, { term: 'Product Backlog', def: 'Geordende lijst van alle PBI\'s per product, gesorteerd op prioriteit (kritiek → laag) en positie.' }, { term: 'Sprint', def: 'Actief tijdblok met een Sprint Goal. Per product is er maximaal één actieve Sprint tegelijk.' }, { term: 'Sprint Backlog', def: 'De stories die voor deze Sprint zijn geselecteerd. Stories worden vanuit de Product Backlog gesleept.' }, @@ -508,98 +363,55 @@ cd scrum4me-mcp && npm install { step: '1', title: 'Account aanmaken', - desc: 'Ga naar Registreren en kies een gebruikersnaam en wachtwoord. Na registratie word je direct doorgestuurd naar het dashboard. Liever passwordless? Paar je telefoon één keer en log voortaan in via QR. Of test eerst met de demo-account (alleen leesrechten).', - ideaRoute: false, + desc: 'Ga naar Registreren en kies een gebruikersnaam en wachtwoord. Na registratie word je direct doorgestuurd naar het dashboard. Wil je eerst rondkijken? Log in met de demo-account (alleen leesrechten).', }, { step: '2', title: 'Product aanmaken', desc: 'Klik op "Nieuw product" op het dashboard. Vul naam, optionele beschrijving, repo-URL en je Definition of Done in. Het product wordt zichtbaar op het dashboard.', - ideaRoute: false, }, { step: '3', - title: 'Een idee vastleggen', - desc: 'Open /ideas, klik "Nieuw idee", vul titel + één-alinea beschrijving in. Status: DRAFT.', - ideaRoute: true, + title: 'Product Backlog opbouwen', + desc: 'Open het product en voeg PBI\'s toe via het linkerpaneel. Geef elk PBI een prioriteit (Kritiek / Hoog / Gemiddeld / Laag). Klik op een PBI om in het rechterpaneel stories toe te voegen. Versleep PBI\'s en stories om de volgorde aan te passen.', }, { step: '4', - title: 'Laat Claude grillen', - desc: 'Klik "Grill me". Een lokale agent stelt kritische vragen via het belicoon. Beantwoord ze; Claude schrijft een gestructureerde grill_md. Status: GRILLED.', - ideaRoute: true, + title: 'Sprint starten', + desc: 'Klik op "Sprint starten" op de productpagina en voer een Sprint Goal in. Per product is er maximaal één actieve Sprint tegelijk. Het Sprint-scherm wordt zichtbaar via de navigatie.', }, { step: '5', - title: 'Maak het plan + materialiseer', - desc: 'Klik "Make plan". Claude genereert een YAML-plan (PBI + stories + tasks). Klik "Materialiseer" om ’t atomair om te zetten naar je product-backlog. Status: PLANNED.', - ideaRoute: true, + title: 'Sprint Board — stories slepen en taken aanmaken', + desc: 'Open het Sprint-scherm. Drie panelen verschijnen op één view: Product Backlog links, Sprint Backlog in het midden, taken rechts. Sleep stories vanuit links naar het midden om ze in de Sprint te plaatsen. Selecteer een story in het middenpaneel om de taken rechts te tonen en aan te maken.', }, { step: '6', - title: 'Product Backlog finetunen', - desc: 'Optioneel: herorden PBI\'s en stories handmatig via drag-and-drop. Het meeste werk heeft materialise al gedaan — dit is alleen voor bijsturen of toevoegen van werk dat niet uit een idee komt.', - ideaRoute: false, + title: 'Solo Paneel — claim stories en werk persoonlijk', + desc: 'Open Solo via de navigatie. Claim openstaande stories uit de actieve Sprint (knop "Toon openstaande stories") en werk je taken af in drie statuskolommen via drag-and-drop. Klik op een taak voor het detail-dialoog met implementatieplan.', }, { step: '7', - title: 'Sprint starten', - desc: 'Klik op "Sprint starten" op de productpagina en voer een Sprint Goal in. Per product is er maximaal één actieve Sprint tegelijk. Het Sprint-scherm wordt zichtbaar via de navigatie.', - ideaRoute: false, + title: 'API-token aanmaken voor Claude Code', + desc: 'Ga naar Instellingen → Tokens. Maak een nieuw token aan en kopieer de waarde direct — die is daarna niet meer zichtbaar. Gebruik het token als Bearer-token in Claude Code of je eigen scripts.', }, { step: '8', - title: 'Sprint Board — stories slepen en taken aanmaken', - desc: 'Open het Sprint-scherm. Drie panelen verschijnen op één view: Product Backlog links, Sprint Backlog in het midden, taken rechts. Sleep stories vanuit links naar het midden om ze in de Sprint te plaatsen. Selecteer een story in het middenpaneel om de taken rechts te tonen en aan te maken.', - ideaRoute: false, + title: 'Claude Code koppelen', + desc: 'Configureer Claude Code met je API-token. Claude haalt via GET /api/products/:id/next-story de hoogst geprioriteerde open story op, werkt taken bij via PATCH /api/tasks/:id en legt het implementatieplan, testresultaten en commits vast via POST /api/stories/:id/log.', }, { step: '9', - title: 'Solo Paneel — claim stories en werk persoonlijk', - desc: 'Open Solo via de navigatie. Claim openstaande stories uit de actieve Sprint (knop "Toon openstaande stories") en werk je taken af in drie statuskolommen via drag-and-drop. Klik op een taak voor het detail-dialoog met implementatieplan.', - ideaRoute: false, - }, - { - step: '10', - title: 'Claude Code koppelen', - desc: 'Maak een API-token aan in Instellingen → Tokens — hetzelfde token werkt voor MCP en REST. Aanbevolen: installeer de scrum4me-mcp-server (zie Quickstart hierboven) zodat Claude Code de tools native ziet. Alternatief: gebruik de REST API direct vanuit Codex, eigen scripts of CI-pipelines.', - ideaRoute: false, - }, - { - step: '11', - title: 'Voer uit + Sync-tab volgen', - desc: 'Klik op "Voer uit" bij een story in het Solo Paneel. De story komt in de job-queue. Een lokale agent claimt de jobs, werkt taken af, commit en — na de laatste task — pusht en mergt automatisch een PR via SQUASH. Volg de voortgang in de Sync-tab op het idea-detail.', - ideaRoute: false, - }, - { - step: '12', title: 'Sprint afronden', desc: 'Klik op "Sprint afronden" op het Sprint Board. Voor elke story kies je: markeer als Done of zet terug naar de Product Backlog. Daarna is een nieuwe Sprint aanmaakbaar.', - ideaRoute: false, }, - ].map(({ step, title, desc, ideaRoute }) => ( - <div - key={step} - className={`flex gap-4 bg-surface-container-low border rounded-xl p-5 ${ - ideaRoute ? 'border-l-4 border-l-tertiary border-y-border border-r-border' : 'border-border' - }`} - > - <div - className={`shrink-0 w-8 h-8 rounded-full text-sm font-semibold flex items-center justify-center ${ - ideaRoute ? 'bg-tertiary text-tertiary-foreground' : 'bg-primary text-primary-foreground' - }`} - > + ].map(({ step, title, desc }) => ( + <div key={step} className="flex gap-4 bg-surface-container-low border border-border rounded-xl p-5"> + <div className="shrink-0 w-8 h-8 rounded-full bg-primary text-primary-foreground text-sm font-semibold flex items-center justify-center"> {step} </div> <div className="space-y-1"> - <div className="text-sm font-medium flex items-center gap-2"> - {title} - {ideaRoute && ( - <span className="text-[10px] font-mono font-semibold px-1.5 py-0.5 rounded bg-tertiary-container text-tertiary-container-foreground"> - Idea-route - </span> - )} - </div> + <div className="text-sm font-medium">{title}</div> <div className="text-sm text-muted-foreground leading-relaxed">{desc}</div> </div> </div> @@ -629,6 +441,7 @@ curl -H "Authorization: Bearer $TOKEN" \\ { method: 'PATCH', path: '/api/stories/:id/tasks/reorder', desc: 'Taakvolgorde aanpassen (body: { task_ids: string[] })' }, { method: 'POST', path: '/api/stories/:id/log', desc: 'Activiteit vastleggen: implementatieplan, testresultaat of commit' }, { method: 'PATCH', path: '/api/tasks/:id', desc: 'Taakstatus of implementatieplan bijwerken' }, + { method: 'POST', path: '/api/todos', desc: 'Todo aanmaken (body: { title, product_id })' }, ].map(({ method, path, desc }) => ( <div key={path} className="flex items-start gap-3 bg-background border border-border rounded-lg px-4 py-3"> <span className={`shrink-0 text-xs font-mono font-semibold px-2 py-0.5 rounded ${ @@ -651,24 +464,14 @@ curl -H "Authorization: Bearer $TOKEN" \\ {/* ── Footer ─────────────────────────────────────────────────── */} <footer className="shrink-0 border-t border-border bg-surface-container-low px-6 py-4 flex items-center justify-between text-xs text-muted-foreground"> <span>© {new Date().getFullYear()} Scrum4Me</span> - <div className="flex items-center gap-4"> - <a - href="https://github.com/madhura68/Scrum4Me" - target="_blank" - rel="noopener noreferrer" - className="hover:text-foreground transition-colors" - > - App-repo - </a> - <a - href="https://github.com/madhura68/scrum4me-mcp" - target="_blank" - rel="noopener noreferrer" - className="hover:text-foreground transition-colors" - > - MCP-server - </a> - </div> + <a + href="https://github.com/madhura68/Scrum4Me" + target="_blank" + rel="noopener noreferrer" + className="hover:text-foreground transition-colors" + > + GitHub + </a> <span>v{version} · gebouwd op {buildDate}</span> </footer> diff --git a/app/styles/theme.css b/app/styles/theme.css index a01769b..071598a 100644 --- a/app/styles/theme.css +++ b/app/styles/theme.css @@ -85,7 +85,6 @@ --status-review: #7b5ea7; --status-done: #006e1c; --status-blocked: #ba1a1a; - --status-failed: #93000a; --priority-critical: #ba1a1a; --priority-high: #c75300; @@ -197,7 +196,6 @@ --status-review: #c9b6ef; --status-done: #77db77; --status-blocked: #ffb4ab; - --status-failed: #ff8a80; --priority-critical: #ffb4ab; --priority-high: #ffb68d; @@ -303,7 +301,6 @@ --color-status-review: var(--status-review); --color-status-done: var(--status-done); --color-status-blocked: var(--status-blocked); - --color-status-failed: var(--status-failed); --color-priority-critical: var(--priority-critical); --color-priority-high: var(--priority-high); diff --git a/components/admin/jobs-table.tsx b/components/admin/jobs-table.tsx deleted file mode 100644 index a236bed..0000000 --- a/components/admin/jobs-table.tsx +++ /dev/null @@ -1,227 +0,0 @@ -'use client' - -import { useState, useTransition } from 'react' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { cancelJobAction, deleteJobAction } from '@/actions/admin/jobs' -import { debugProps } from '@/lib/debug' - -type Job = { - id: string - kind: string - status: string - created_at: Date - user: { username: string } - product: { name: string } - branch: string | null - pr_url: string | null - error: string | null - model_id: string | null - actual_thinking_tokens: number | null - requested_model: string | null - requested_thinking_budget: number | null - requested_permission_mode: string | null - cost_usd: number | null -} - -const STATUS_CLASS: Record<string, string> = { - QUEUED: 'bg-secondary text-secondary-foreground', - CLAIMED: 'bg-status-in-progress text-white border-transparent', - RUNNING: 'bg-warning text-warning-foreground border-transparent', - DONE: 'bg-status-done text-white border-transparent', - FAILED: 'bg-priority-high text-white border-transparent', - CANCELLED: 'bg-muted text-muted-foreground', - SKIPPED: 'bg-muted/60 text-muted-foreground italic border-transparent', -} - -const KIND_LABEL: Record<string, string> = { - TASK_IMPLEMENTATION: 'Taak', - IDEA_GRILL: 'Idee Grill', - IDEA_MAKE_PLAN: 'Idee Plan', -} - -const ACTIVE_STATUSES = new Set(['QUEUED', 'CLAIMED', 'RUNNING']) - -function JobRow({ job }: { job: Job }) { - const [pending, startTransition] = useTransition() - - function handleCancel() { - startTransition(() => cancelJobAction(job.id)) - } - - function handleDelete() { - startTransition(() => deleteJobAction(job.id)) - } - - return ( - <TableRow> - <TableCell className="font-mono text-xs text-muted-foreground">{job.id.slice(0, 8)}</TableCell> - <TableCell className="text-sm">{job.user.username}</TableCell> - <TableCell className="text-sm">{job.product.name}</TableCell> - <TableCell className="text-xs">{KIND_LABEL[job.kind] ?? job.kind}</TableCell> - <TableCell> - <Badge className={STATUS_CLASS[job.status] ?? 'bg-secondary'}>{job.status}</Badge> - </TableCell> - <TableCell className="text-xs text-muted-foreground"> - {job.branch ?? '—'} - </TableCell> - <TableCell className="text-xs text-muted-foreground"> - {new Date(job.created_at).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })} - </TableCell> - <TableCell> - {job.error && ( - <span className="text-xs text-priority-high line-clamp-1 max-w-[200px]" title={job.error}> - {job.error} - </span> - )} - </TableCell> - <TableCell> - <div className="flex gap-2 justify-end"> - {ACTIVE_STATUSES.has(job.status) && ( - <Button variant="outline" size="sm" onClick={handleCancel} disabled={pending}> - Annuleer - </Button> - )} - <Button variant="destructive" size="sm" onClick={handleDelete} disabled={pending}> - Verwijder - </Button> - </div> - </TableCell> - </TableRow> - ) -} - -function StatusTable({ jobs }: { jobs: Job[] }) { - return ( - <Table data-debug-id="admin-jobs-table__table"> - <TableHeader> - <TableRow> - <TableHead>ID</TableHead> - <TableHead>Gebruiker</TableHead> - <TableHead>Product</TableHead> - <TableHead>Type</TableHead> - <TableHead>Status</TableHead> - <TableHead>Branch</TableHead> - <TableHead>Aangemaakt</TableHead> - <TableHead>Fout</TableHead> - <TableHead className="text-right">Acties</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {jobs.length === 0 && ( - <TableRow> - <TableCell colSpan={9} className="text-center text-muted-foreground py-8"> - Geen jobs gevonden - </TableCell> - </TableRow> - )} - {jobs.map(job => ( - <JobRow key={job.id} job={job} /> - ))} - </TableBody> - </Table> - ) -} - -function CostRow({ job }: { job: Job }) { - const [pending, startTransition] = useTransition() - function handleCancel() { startTransition(() => cancelJobAction(job.id)) } - function handleDelete() { startTransition(() => deleteJobAction(job.id)) } - const costLabel = job.cost_usd != null ? `$${job.cost_usd.toFixed(4)}` : '—' - const thinkingLabel = job.actual_thinking_tokens != null ? job.actual_thinking_tokens.toLocaleString('nl-NL') : '—' - const modelMismatch = job.requested_model != null && job.model_id != null && job.requested_model !== job.model_id - const modelTitle = job.requested_model - ? `Aangevraagd: ${job.requested_model}${modelMismatch ? ' (mismatch met actueel)' : ''}` - : undefined - return ( - <TableRow> - <TableCell className="font-mono text-xs text-muted-foreground">{job.id.slice(0, 8)}</TableCell> - <TableCell className="text-sm">{job.user.username}</TableCell> - <TableCell className="text-sm">{job.product.name}</TableCell> - <TableCell className="text-xs">{KIND_LABEL[job.kind] ?? job.kind}</TableCell> - <TableCell - className={`text-xs ${modelMismatch ? 'text-priority-high font-medium' : 'text-muted-foreground'}`} - title={modelTitle} - > - {job.model_id ?? '—'} - </TableCell> - <TableCell className="text-xs font-mono text-muted-foreground">{thinkingLabel}</TableCell> - <TableCell className="text-xs font-mono">{costLabel}</TableCell> - <TableCell className="text-xs text-muted-foreground"> - {new Date(job.created_at).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })} - </TableCell> - <TableCell> - <div className="flex gap-2 justify-end"> - {ACTIVE_STATUSES.has(job.status) && ( - <Button variant="outline" size="sm" onClick={handleCancel} disabled={pending}>Annuleer</Button> - )} - <Button variant="destructive" size="sm" onClick={handleDelete} disabled={pending}>Verwijder</Button> - </div> - </TableCell> - </TableRow> - ) -} - -function CostsTable({ jobs }: { jobs: Job[] }) { - return ( - <Table data-debug-id="admin-jobs-table__table"> - <TableHeader> - <TableRow> - <TableHead>ID</TableHead> - <TableHead>Gebruiker</TableHead> - <TableHead>Product</TableHead> - <TableHead>Type</TableHead> - <TableHead>Model</TableHead> - <TableHead>Thinking</TableHead> - <TableHead>Kosten (USD)</TableHead> - <TableHead>Aangemaakt</TableHead> - <TableHead className="text-right">Acties</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {jobs.length === 0 && ( - <TableRow> - <TableCell colSpan={9} className="text-center text-muted-foreground py-8"> - Geen jobs gevonden - </TableCell> - </TableRow> - )} - {jobs.map((job) => <CostRow key={job.id} job={job} />)} - </TableBody> - </Table> - ) -} - -export function JobsTable({ jobs }: { jobs: Job[] }) { - const [view, setView] = useState<'status' | 'costs'>('status') - - return ( - <div className="space-y-3" {...debugProps('admin-jobs-table', 'JobsTable', 'components/admin/jobs-table.tsx')}> - <div className="flex gap-1" data-debug-id="admin-jobs-table__header"> - <Button - size="sm" - variant={view === 'status' ? 'default' : 'outline'} - onClick={() => setView('status')} - > - Status - </Button> - <Button - size="sm" - variant={view === 'costs' ? 'default' : 'outline'} - onClick={() => setView('costs')} - > - Kosten - </Button> - </div> - {view === 'status' ? <StatusTable jobs={jobs} /> : <CostsTable jobs={jobs} />} - </div> - ) -} diff --git a/components/admin/products-table.tsx b/components/admin/products-table.tsx deleted file mode 100644 index afcdc63..0000000 --- a/components/admin/products-table.tsx +++ /dev/null @@ -1,129 +0,0 @@ -'use client' - -import { useTransition } from 'react' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, - DialogClose, -} from '@/components/ui/dialog' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { adminArchiveProductAction, adminDeleteProductAction } from '@/actions/admin/products' -import { debugProps } from '@/lib/debug' - -type Product = { - id: string - name: string - archived: boolean - created_at: Date - user: { username: string } - _count: { members: number; pbis: number } -} - -function ArchiveButton({ product }: { product: Product }) { - const [pending, startTransition] = useTransition() - - function handleToggle() { - startTransition(() => adminArchiveProductAction(product.id, !product.archived)) - } - - return ( - <Button variant="outline" size="sm" onClick={handleToggle} disabled={pending}> - {product.archived ? 'Herstel' : 'Archiveer'} - </Button> - ) -} - -function DeleteDialog({ product }: { product: Product }) { - const [pending, startTransition] = useTransition() - - function handleDelete() { - startTransition(() => adminDeleteProductAction(product.id)) - } - - return ( - <Dialog> - <DialogTrigger render={<Button variant="destructive" size="sm" />}> - Verwijder - </DialogTrigger> - <DialogContent> - <DialogHeader> - <DialogTitle>Product verwijderen</DialogTitle> - </DialogHeader> - <p className="text-sm text-muted-foreground"> - Weet je zeker dat je <strong>{product.name}</strong> wilt verwijderen? - Dit verwijdert ook alle PBI's, stories en taken. Dit kan niet ongedaan worden gemaakt. - </p> - <DialogFooter> - <DialogClose render={<Button variant="outline" />}>Annuleer</DialogClose> - <Button variant="destructive" onClick={handleDelete} disabled={pending}> - {pending ? 'Verwijderen…' : 'Verwijderen'} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -} - -export function ProductsTable({ products }: { products: Product[] }) { - return ( - <Table {...debugProps('admin-products-table', 'ProductsTable', 'components/admin/products-table.tsx')}> - <TableHeader data-debug-id="admin-products-table__header"> - <TableRow> - <TableHead>Naam</TableHead> - <TableHead>Eigenaar</TableHead> - <TableHead>Leden</TableHead> - <TableHead>PBI's</TableHead> - <TableHead>Status</TableHead> - <TableHead>Aangemaakt</TableHead> - <TableHead className="text-right">Acties</TableHead> - </TableRow> - </TableHeader> - <TableBody data-debug-id="admin-products-table__table"> - {products.length === 0 && ( - <TableRow> - <TableCell colSpan={7} className="text-center text-muted-foreground py-8"> - Geen producten gevonden - </TableCell> - </TableRow> - )} - {products.map(product => ( - <TableRow key={product.id}> - <TableCell className="font-medium">{product.name}</TableCell> - <TableCell className="text-muted-foreground">{product.user.username}</TableCell> - <TableCell className="text-sm">{product._count.members}</TableCell> - <TableCell className="text-sm">{product._count.pbis}</TableCell> - <TableCell> - {product.archived ? ( - <Badge className="bg-muted text-muted-foreground">Gearchiveerd</Badge> - ) : ( - <Badge className="bg-status-done text-white border-transparent">Actief</Badge> - )} - </TableCell> - <TableCell className="text-xs text-muted-foreground"> - {new Date(product.created_at).toLocaleDateString('nl-NL')} - </TableCell> - <TableCell> - <div className="flex gap-2 justify-end"> - <ArchiveButton product={product} /> - <DeleteDialog product={product} /> - </div> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - ) -} diff --git a/components/admin/users-table.tsx b/components/admin/users-table.tsx deleted file mode 100644 index 32161c5..0000000 --- a/components/admin/users-table.tsx +++ /dev/null @@ -1,240 +0,0 @@ -'use client' - -import { useState, useTransition } from 'react' -import { Role } from '@prisma/client' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, - DialogClose, -} from '@/components/ui/dialog' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { - deleteUserAction, - updateUserRolesAction, - setMustResetPasswordAction, -} from '@/actions/admin/users' -import { debugProps } from '@/lib/debug' - -type UserWithRoles = { - id: string - username: string - email: string | null - must_reset_password: boolean - created_at: Date - roles: { role: Role }[] -} - -const ALL_ROLES: Role[] = [Role.PRODUCT_OWNER, Role.SCRUM_MASTER, Role.DEVELOPER, Role.ADMIN] - -const ROLE_LABEL: Record<Role, string> = { - PRODUCT_OWNER: 'Product Owner', - SCRUM_MASTER: 'Scrum Master', - DEVELOPER: 'Developer', - ADMIN: 'Admin', -} - -function RoleBadge({ role }: { role: Role }) { - const cls = - role === Role.ADMIN - ? 'bg-status-done text-white border-transparent' - : role === Role.PRODUCT_OWNER - ? 'bg-status-in-progress text-white border-transparent' - : role === Role.SCRUM_MASTER - ? 'bg-priority-medium text-white border-transparent' - : 'bg-secondary text-secondary-foreground' - return <Badge className={cls}>{ROLE_LABEL[role]}</Badge> -} - -function RolesDialog({ user, currentUserId }: { user: UserWithRoles; currentUserId: string }) { - const [open, setOpen] = useState(false) - const [selected, setSelected] = useState<Set<Role>>(new Set(user.roles.map(r => r.role))) - const [pending, startTransition] = useTransition() - const isSelf = user.id === currentUserId - - function toggle(role: Role) { - setSelected(prev => { - const next = new Set(prev) - if (next.has(role)) next.delete(role) - else next.add(role) - return next - }) - } - - function handleSave() { - startTransition(async () => { - await updateUserRolesAction(user.id, Array.from(selected)) - setOpen(false) - }) - } - - return ( - <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger render={<Button variant="outline" size="sm" />}> - Rollen - </DialogTrigger> - <DialogContent> - <DialogHeader> - <DialogTitle>Rollen voor {user.username}</DialogTitle> - </DialogHeader> - <div className="flex flex-col gap-2 py-2"> - {ALL_ROLES.map(role => { - const isDisabled = isSelf && role === Role.ADMIN && selected.has(role) - return ( - <label key={role} className="flex items-center gap-2 cursor-pointer"> - <input - type="checkbox" - checked={selected.has(role)} - disabled={isDisabled} - onChange={() => toggle(role)} - className="rounded border-border" - /> - <span className="text-sm">{ROLE_LABEL[role]}</span> - {isDisabled && <span className="text-xs text-muted-foreground">(eigen rol)</span>} - </label> - ) - })} - </div> - <DialogFooter showCloseButton> - <Button onClick={handleSave} disabled={pending}> - {pending ? 'Opslaan…' : 'Opslaan'} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -} - -function DeleteDialog({ user, currentUserId }: { user: UserWithRoles; currentUserId: string }) { - const [open, setOpen] = useState(false) - const [pending, startTransition] = useTransition() - const isSelf = user.id === currentUserId - - function handleDelete() { - startTransition(async () => { - await deleteUserAction(user.id) - setOpen(false) - }) - } - - return ( - <Dialog open={open} onOpenChange={setOpen}> - <DialogTrigger - render={ - <Button - variant="destructive" - size="sm" - disabled={isSelf} - title={isSelf ? 'Zelfverwijdering niet toegestaan' : undefined} - /> - } - > - Verwijder - </DialogTrigger> - <DialogContent> - <DialogHeader> - <DialogTitle>Gebruiker verwijderen</DialogTitle> - </DialogHeader> - <p className="text-sm text-muted-foreground"> - Weet je zeker dat je <strong>{user.username}</strong> wilt verwijderen? Dit kan niet ongedaan worden gemaakt. - </p> - <DialogFooter> - <DialogClose render={<Button variant="outline" />}>Annuleer</DialogClose> - <Button variant="destructive" onClick={handleDelete} disabled={pending}> - {pending ? 'Verwijderen…' : 'Verwijderen'} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -} - -function ResetToggle({ user }: { user: UserWithRoles }) { - const [pending, startTransition] = useTransition() - - function handleToggle() { - startTransition(async () => { - await setMustResetPasswordAction(user.id, !user.must_reset_password) - }) - } - - return ( - <Button - variant={user.must_reset_password ? 'default' : 'outline'} - size="sm" - onClick={handleToggle} - disabled={pending} - title="Forceer wachtwoord-reset bij volgende login" - > - {user.must_reset_password ? 'Reset gepland' : 'Reset pw'} - </Button> - ) -} - -export function UsersTable({ - users, - currentUserId, -}: { - users: UserWithRoles[] - currentUserId: string -}) { - return ( - <Table {...debugProps('admin-users-table', 'UsersTable', 'components/admin/users-table.tsx')}> - <TableHeader data-debug-id="admin-users-table__header"> - <TableRow> - <TableHead>Gebruiker</TableHead> - <TableHead>Email</TableHead> - <TableHead>Rollen</TableHead> - <TableHead>Reset pw</TableHead> - <TableHead>Aangemaakt</TableHead> - <TableHead className="text-right">Acties</TableHead> - </TableRow> - </TableHeader> - <TableBody data-debug-id="admin-users-table__table"> - {users.map(user => ( - <TableRow key={user.id}> - <TableCell className="font-medium">{user.username}</TableCell> - <TableCell className="text-muted-foreground">{user.email ?? '—'}</TableCell> - <TableCell> - <div className="flex flex-wrap gap-1"> - {user.roles.map(r => ( - <RoleBadge key={r.role} role={r.role} /> - ))} - {user.roles.length === 0 && <span className="text-muted-foreground text-xs">Geen</span>} - </div> - </TableCell> - <TableCell> - {user.must_reset_password ? ( - <Badge className="bg-priority-high text-white border-transparent">Ja</Badge> - ) : ( - <span className="text-muted-foreground text-xs">—</span> - )} - </TableCell> - <TableCell className="text-muted-foreground text-xs"> - {new Date(user.created_at).toLocaleDateString('nl-NL')} - </TableCell> - <TableCell> - <div className="flex gap-2 justify-end"> - <ResetToggle user={user} /> - <RolesDialog user={user} currentUserId={currentUserId} /> - <DeleteDialog user={user} currentUserId={currentUserId} /> - </div> - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - ) -} diff --git a/components/auth/auth-form.tsx b/components/auth/auth-form.tsx index 4879c57..6ec179b 100644 --- a/components/auth/auth-form.tsx +++ b/components/auth/auth-form.tsx @@ -4,14 +4,13 @@ import { useActionState } from 'react' import { useFormStatus } from 'react-dom' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { debugProps } from '@/lib/debug' type ActionResult = { error: string | Record<string, string[]> } | undefined function SubmitButton({ label }: { label: string }) { const { pending } = useFormStatus() return ( - <Button type="submit" className="w-full" disabled={pending} data-debug-id="auth-form__submit"> + <Button type="submit" className="w-full" disabled={pending}> {pending ? 'Even wachten…' : label} </Button> ) @@ -35,7 +34,7 @@ export function AuthForm({ action, submitLabel }: AuthFormProps) { const errorMessage = getErrorMessage(state) return ( - <form action={formAction} className="space-y-4" {...debugProps('auth-form', 'AuthForm', 'components/auth/auth-form.tsx')}> + <form action={formAction} className="space-y-4"> <div className="space-y-2"> <label htmlFor="username" className="text-sm font-medium text-foreground"> Gebruikersnaam @@ -49,7 +48,6 @@ export function AuthForm({ action, submitLabel }: AuthFormProps) { minLength={3} placeholder="jouw-naam" className="bg-input-background border-border focus-visible:ring-primary" - data-debug-id="auth-form__username" /> </div> @@ -66,7 +64,6 @@ export function AuthForm({ action, submitLabel }: AuthFormProps) { minLength={8} placeholder="••••••••" className="bg-input-background border-border focus-visible:ring-primary" - data-debug-id="auth-form__password" /> </div> diff --git a/components/backlog/active-selection-hydrator.tsx b/components/backlog/active-selection-hydrator.tsx deleted file mode 100644 index 966b672..0000000 --- a/components/backlog/active-selection-hydrator.tsx +++ /dev/null @@ -1,53 +0,0 @@ -'use client' - -import { useEffect } from 'react' -import { useUserSettingsStore } from '@/stores/user-settings/store' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' - -interface ActiveSelectionHydratorProps { - productId: string -} - -/** - * PBI-79: hydrateert de workspace-store met de actieve PBI/story die in - * user-settings staan opgeslagen. Loopt na elke (re)hydratatie en bij - * mutaties van de user-settings (bv. na sprint-switch). Wint van de - * localStorage hint-restore — user-settings is de cross-device source of - * truth. - */ -export function ActiveSelectionHydrator({ productId }: ActiveSelectionHydratorProps) { - const hydrated = useUserSettingsStore((s) => s.context.hydrated) - const persistedPbiId = useUserSettingsStore( - (s) => s.entities.settings.layout?.activePbis?.[productId] ?? undefined, - ) - const persistedStoryId = useUserSettingsStore( - (s) => s.entities.settings.layout?.activeStories?.[productId] ?? undefined, - ) - - useEffect(() => { - if (!hydrated) return - const store = useProductWorkspaceStore.getState() - // Schrijf alleen wanneer user-settings expliciet iets gekozen heeft - // (key aanwezig met string-waarde). null-key betekent 'bewust leeg' → - // we wissen lokale state. undefined-key (geen voorkeur) → niets doen. - if (persistedPbiId === undefined && persistedStoryId === undefined) return - - if (persistedPbiId === null) { - store.setActivePbi(null) - return - } - if (persistedPbiId && store.context.activePbiId !== persistedPbiId) { - store.setActivePbi(persistedPbiId) - } - if (persistedStoryId && store.context.activeStoryId !== persistedStoryId) { - // setActivePbi triggert async cascade-restore die de oude hint kan - // herstellen; de daarop volgende setActiveStory bumpt activeRequestId - // en ongeldigt de cascade. - store.setActiveStory(persistedStoryId) - } else if (persistedStoryId === null) { - store.setActiveStory(null) - } - }, [hydrated, persistedPbiId, persistedStoryId]) - - return null -} diff --git a/components/backlog/backlog-card.tsx b/components/backlog/backlog-card.tsx index 26fab89..7a93910 100644 --- a/components/backlog/backlog-card.tsx +++ b/components/backlog/backlog-card.tsx @@ -3,7 +3,6 @@ import { forwardRef } from 'react' import { cn } from '@/lib/utils' import { CodeBadge } from '@/components/shared/code-badge' -import { debugProps } from '@/lib/debug' export const PRIORITY_BORDER: Record<number, string> = { 1: 'border-l-4 border-l-priority-critical', @@ -39,10 +38,9 @@ export const BacklogCard = forwardRef<HTMLDivElement, BacklogCardProps>(function className, )} {...rest} - {...debugProps('backlog-card', 'BacklogCard', 'components/backlog/backlog-card.tsx')} > <div className="flex items-start justify-between gap-2"> - <p className="text-sm leading-snug line-clamp-2 flex-1" {...debugProps('backlog-card__title')}>{title}</p> + <p className="text-sm leading-snug line-clamp-2 flex-1">{title}</p> {code && <CodeBadge code={code} className="shrink-0 mt-0.5" />} </div> {(badge || actions) && ( diff --git a/components/backlog/backlog-hydration-wrapper.tsx b/components/backlog/backlog-hydration-wrapper.tsx index 30124d3..4bc5731 100644 --- a/components/backlog/backlog-hydration-wrapper.tsx +++ b/components/backlog/backlog-hydration-wrapper.tsx @@ -1,15 +1,8 @@ 'use client' -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' +import { useBacklogStore, type BacklogPbi, type BacklogStory, type BacklogTask } from '@/stores/backlog-store' import { useBacklogRealtime } from '@/lib/realtime/use-backlog-realtime' -import { useWorkspaceResync } from '@/lib/realtime/use-workspace-resync' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import type { - BacklogPbi, - BacklogStory, - BacklogTask, - ProductBacklogSnapshot, -} from '@/stores/product-workspace/types' interface InitialData { pbis: BacklogPbi[] @@ -20,55 +13,18 @@ interface InitialData { interface BacklogHydrationWrapperProps { initialData: InitialData productId: string - productName?: string children: React.ReactNode } -function fingerprint(data: InitialData): string { - const pbiPart = data.pbis.map((p) => `${p.id}:${p.status}:${p.priority}`).join(',') - const storyPart = Object.entries(data.storiesByPbi) - .flatMap(([, list]) => list.map((s) => `${s.id}:${s.status}:${s.sprint_id ?? 'null'}`)) - .join(',') - const taskPart = Object.entries(data.tasksByStory) - .flatMap(([, list]) => list.map((t) => `${t.id}:${t.status}`)) - .join(',') - return `${pbiPart}|${storyPart}|${taskPart}` -} - -// PBI-74 / Story 8: workspace-store is nu enige bron — dual-dispatch weg. -function toWorkspaceSnapshot( - data: InitialData, - productId: string, - productName: string | undefined, -): ProductBacklogSnapshot { - return { - product: { id: productId, name: productName ?? '' }, - pbis: data.pbis, - storiesByPbi: data.storiesByPbi, - tasksByStory: data.tasksByStory, - } -} - -export function BacklogHydrationWrapper({ - initialData, - productId, - productName, - children, -}: BacklogHydrationWrapperProps) { - const lastFingerprint = useRef<string>('') +export function BacklogHydrationWrapper({ initialData, productId, children }: BacklogHydrationWrapperProps) { + const setInitialData = useBacklogStore((s) => s.setInitialData) useEffect(() => { - const fp = fingerprint(initialData) - if (fp !== lastFingerprint.current) { - lastFingerprint.current = fp - useProductWorkspaceStore - .getState() - .hydrateSnapshot(toWorkspaceSnapshot(initialData, productId, productName)) - } - }, [initialData, productId, productName]) + setInitialData(initialData) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) useBacklogRealtime(productId) - useWorkspaceResync() return <>{children}</> } diff --git a/components/backlog/backlog-split-pane.tsx b/components/backlog/backlog-split-pane.tsx index 8a82a95..882f13b 100644 --- a/components/backlog/backlog-split-pane.tsx +++ b/components/backlog/backlog-split-pane.tsx @@ -1,16 +1,13 @@ 'use client' import { useState } from 'react' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import { useSelectionStore } from '@/stores/selection-store' import { SplitPane, type SplitPaneProps } from '@/components/split-pane/split-pane' type Props = Omit<SplitPaneProps, 'activeTab' | 'onActiveTabChange'> -// PBI-74 / T-848: leest active PBI/story-ids uit workspace-store. Primitives, -// dus geen useShallow nodig. export function BacklogSplitPane(props: Props) { - const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId) - const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId) + const { selectedPbiId, selectedStoryId } = useSelectionStore() const [activeTab, setActiveTab] = useState(0) // React-recommended "derived state from props" pattern: update state during render diff --git a/components/backlog/empty-panel.tsx b/components/backlog/empty-panel.tsx index 6fd531b..e48f688 100644 --- a/components/backlog/empty-panel.tsx +++ b/components/backlog/empty-panel.tsx @@ -2,7 +2,6 @@ import { Button } from '@/components/ui/button' import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { debugProps } from '@/lib/debug' interface EmptyPanelProps { title?: string @@ -16,8 +15,8 @@ interface EmptyPanelProps { export function EmptyPanel({ title, message, action }: EmptyPanelProps) { return ( - <div className="p-8 text-center text-muted-foreground space-y-3" {...debugProps('empty-panel', 'EmptyPanel', 'components/backlog/empty-panel.tsx')}> - {title && <p className="text-sm font-medium text-foreground" {...debugProps('empty-panel__title')}>{title}</p>} + <div className="p-8 text-center text-muted-foreground space-y-3"> + {title && <p className="text-sm font-medium text-foreground">{title}</p>} <p className="text-sm">{message}</p> {action && ( <DemoTooltip show={action.disabled ?? false}> @@ -26,7 +25,6 @@ export function EmptyPanel({ title, message, action }: EmptyPanelProps) { variant="outline" disabled={action.disabled} onClick={action.disabled ? undefined : action.onClick} - {...debugProps('empty-panel__cta')} > {action.label} </Button> diff --git a/components/backlog/new-sprint-metadata-dialog.tsx b/components/backlog/new-sprint-metadata-dialog.tsx deleted file mode 100644 index cccf9a9..0000000 --- a/components/backlog/new-sprint-metadata-dialog.tsx +++ /dev/null @@ -1,203 +0,0 @@ -'use client' - -import { useRef, useState, useTransition } from 'react' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { Textarea } from '@/components/ui/textarea' -import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' -import { - useDirtyCloseGuard, - DirtyCloseGuardDialog, -} from '@/components/shared/use-dirty-close-guard' -import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut' -import { - entityDialogContentClasses, - entityDialogFooterClasses, - entityDialogHeaderClasses, -} from '@/components/shared/entity-dialog-layout' -import { useUserSettingsStore } from '@/stores/user-settings/store' -import { debugProps } from '@/lib/debug' - -interface NewSprintMetadataDialogProps { - open: boolean - productId: string - onOpenChange: (open: boolean) => void -} - -function todayLocalDate(): string { - return new Date().toLocaleDateString('en-CA') -} - -function plusWeeks(weeks: number): string { - const d = new Date() - d.setDate(d.getDate() + weeks * 7) - return d.toLocaleDateString('en-CA') -} - -export function NewSprintMetadataDialog({ - open, - productId, - onOpenChange, -}: NewSprintMetadataDialogProps) { - const [sprintGoal, setSprintGoal] = useState('') - const [startDate, setStartDate] = useState(todayLocalDate()) - const [endDate, setEndDate] = useState(plusWeeks(2)) - const [error, setError] = useState<string | null>(null) - const [dirty, setDirty] = useState(false) - const [isPending, startTransition] = useTransition() - const formRef = useRef<HTMLFormElement>(null) - const setPendingSprintDraft = useUserSettingsStore( - (s) => s.setPendingSprintDraft, - ) - - function reset() { - setSprintGoal('') - setStartDate(todayLocalDate()) - setEndDate(plusWeeks(2)) - setError(null) - setDirty(false) - } - - const closeGuard = useDirtyCloseGuard(dirty, () => { - onOpenChange(false) - reset() - }) - - function handleSubmit(e: React.FormEvent) { - e.preventDefault() - const goal = sprintGoal.trim() - if (!goal) return - setError(null) - startTransition(async () => { - try { - await setPendingSprintDraft(productId, { - goal, - startAt: startDate || undefined, - endAt: endDate || undefined, - pbiIntent: {}, - storyOverrides: {}, - }) - reset() - onOpenChange(false) - } catch (err) { - const message = - err instanceof Error ? err.message : 'Onbekende fout bij opslaan' - setError(message) - toast.error(message) - } - }) - } - - const handleKeyDown = useDialogSubmitShortcut(() => - formRef.current?.requestSubmit(), - ) - - return ( - <> - <Dialog - open={open} - onOpenChange={(o) => { - if (!o) closeGuard.attemptClose() - else onOpenChange(o) - }} - > - <DialogContent - showCloseButton={false} - onKeyDown={handleKeyDown} - className={entityDialogContentClasses} - {...debugProps( - 'new-sprint-metadata-dialog', - 'NewSprintMetadataDialog', - 'components/backlog/new-sprint-metadata-dialog.tsx', - )} - > - <div className={entityDialogHeaderClasses}> - <DialogTitle className="text-xl font-semibold"> - Nieuwe sprint - </DialogTitle> - <p className="text-xs text-muted-foreground mt-1"> - Geef het sprint-doel en periode op. Je selecteert daarna PBI's - en stories via vinkjes in de backlog. - </p> - </div> - - <form - ref={formRef} - id="new-sprint-metadata-form" - onSubmit={handleSubmit} - onChange={() => setDirty(true)} - className="flex-1 overflow-y-auto px-6 py-6 space-y-6" - > - <div className="space-y-1.5"> - <label className="text-sm font-medium text-foreground"> - Sprint Goal <span className="text-error">*</span> - </label> - <Textarea - value={sprintGoal} - onChange={(e) => setSprintGoal(e.target.value)} - required - rows={3} - placeholder="Wat wil je aan het einde van deze Sprint bereikt hebben?" - autoFocus - /> - </div> - - <div className="grid grid-cols-2 gap-3"> - <div className="space-y-1.5"> - <label className="text-sm font-medium text-foreground"> - Startdatum - </label> - <input - type="date" - value={startDate} - onChange={(e) => setStartDate(e.target.value)} - className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" - /> - </div> - <div className="space-y-1.5"> - <label className="text-sm font-medium text-foreground"> - Einddatum - </label> - <input - type="date" - value={endDate} - onChange={(e) => setEndDate(e.target.value)} - className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" - /> - </div> - </div> - - {error && ( - <div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error"> - {error} - </div> - )} - </form> - - <div className={entityDialogFooterClasses}> - <div className="flex justify-end gap-2"> - <Button - type="button" - variant="ghost" - onClick={closeGuard.attemptClose} - disabled={isPending} - > - Annuleren - </Button> - <Button - type="submit" - form="new-sprint-metadata-form" - disabled={isPending || !sprintGoal.trim()} - data-debug-id="new-sprint-metadata-dialog__submit" - > - {isPending ? 'Opslaan…' : 'Verder'} - </Button> - </div> - </div> - </DialogContent> - </Dialog> - - <DirtyCloseGuardDialog guard={closeGuard} /> - </> - ) -} diff --git a/components/backlog/new-sprint-trigger.tsx b/components/backlog/new-sprint-trigger.tsx deleted file mode 100644 index 787804e..0000000 --- a/components/backlog/new-sprint-trigger.tsx +++ /dev/null @@ -1,53 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Button } from '@/components/ui/button' -import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { useUserSettingsStore } from '@/stores/user-settings/store' -import { NewSprintMetadataDialog } from './new-sprint-metadata-dialog' - -interface NewSprintTriggerProps { - productId: string - isDemo: boolean - isActiveProduct: boolean -} - -/** - * PBI-79 / ST-1337: trigger-knop voor de nieuwe sprint-flow. - * Verbergt zichzelf wanneer er al een pendingSprintDraft loopt — dan - * staat de SprintDefinitionBanner zelf de afronding te regelen — en - * wanneer het product niet het actieve product is (ST-1369 / G6). - */ -export function NewSprintTrigger({ - productId, - isDemo, - isActiveProduct, -}: NewSprintTriggerProps) { - const [open, setOpen] = useState(false) - const hasDraft = useUserSettingsStore( - (s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId], - ) - - if (hasDraft) return null - if (!isActiveProduct) return null - - return ( - <> - <DemoTooltip show={isDemo}> - <Button - size="sm" - onClick={() => setOpen(true)} - disabled={isDemo} - data-debug-id="new-sprint-trigger" - > - Nieuwe sprint - </Button> - </DemoTooltip> - <NewSprintMetadataDialog - open={open} - productId={productId} - onOpenChange={setOpen} - /> - </> - ) -} diff --git a/components/backlog/pbi-dialog.tsx b/components/backlog/pbi-dialog.tsx index 64664dc..6af393a 100644 --- a/components/backlog/pbi-dialog.tsx +++ b/components/backlog/pbi-dialog.tsx @@ -2,32 +2,23 @@ import { useEffect, useRef, useState } from 'react' import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' import { toast } from 'sonner' import { Dialog, DialogContent, + DialogHeader, DialogTitle, + DialogFooter, + DialogClose, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { PrioritySelect } from '@/components/shared/priority-select' import { PbiStatusSelect } from '@/components/shared/pbi-status-select' -import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { - useDirtyCloseGuard, - DirtyCloseGuardDialog, -} from '@/components/shared/use-dirty-close-guard' -import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut' -import { - entityDialogBodyClasses, - entityDialogContentClasses, - entityDialogFooterClasses, - entityDialogHeaderClasses, -} from '@/components/shared/entity-dialog-layout' import { createPbiAction, updatePbiAction } from '@/actions/pbis' import type { PbiStatusApi } from '@/lib/task-status' -import { debugProps } from '@/lib/debug' export interface PbiDialogPbi { id: string @@ -45,18 +36,18 @@ export type PbiDialogState = CreateState | EditState interface PbiDialogProps { state: PbiDialogState | null onClose: () => void - isDemo?: boolean } -interface ActionResult { - success?: boolean - error?: string - code?: number - fieldErrors?: Record<string, string[]> - pbi?: unknown +function SubmitButton({ label }: { label: string }) { + const { pending } = useFormStatus() + return ( + <Button type="submit" disabled={pending}> + {pending ? '…' : label} + </Button> + ) } -export function PbiDialog({ state, onClose, isDemo = false }: PbiDialogProps) { +export function PbiDialog({ state, onClose }: PbiDialogProps) { const isEdit = state?.mode === 'edit' const pbi = isEdit ? state.pbi : null @@ -66,43 +57,43 @@ export function PbiDialog({ state, onClose, isDemo = false }: PbiDialogProps) { const initialStatus: PbiStatusApi = isEdit ? (pbi!.status ?? 'ready') : 'ready' const [status, setStatus] = useState<PbiStatusApi>(initialStatus) - const [dirty, setDirty] = useState(false) - const formRef = useRef<HTMLFormElement>(null) - - // Sync priority + status + reset dirty when dialog opens for a different PBI or switches mode + // Sync priority + status when dialog opens for a different PBI or switches create/edit mode useEffect(() => { if (state) { // eslint-disable-next-line react-hooks/set-state-in-effect setPriority(isEdit ? (state as EditState).pbi.priority : ((state as CreateState).defaultPriority ?? 2)) + setStatus(isEdit ? ((state as EditState).pbi.status ?? 'ready') : 'ready') - setDirty(false) } }, [state, isEdit]) - const [createState, createAction, createPending] = useActionState<ActionResult | undefined, FormData>( - async (_prev, fd) => { - const result = await createPbiAction(_prev, fd) as ActionResult + const [createState, createAction] = useActionState( + async (_prev: unknown, fd: FormData) => { + const result = await createPbiAction(_prev, fd) if (result?.success) { toast.success('PBI aangemaakt'); onClose() } - else if (result?.code !== 422 && result?.error) toast.error(result.error) + else if (typeof result?.error === 'string') toast.error(result.error) return result }, - undefined, + undefined ) - const [updateState, updateAction, updatePending] = useActionState<ActionResult | undefined, FormData>( - async (_prev, fd) => { - const result = await updatePbiAction(_prev, fd) as ActionResult + const [updateState, updateAction] = useActionState( + async (_prev: unknown, fd: FormData) => { + const result = await updatePbiAction(_prev, fd) if (result?.success) { toast.success('PBI opgeslagen'); onClose() } - else if (result?.code !== 422 && result?.error) toast.error(result.error) + else if (typeof result?.error === 'string') toast.error(result.error) return result }, - undefined, + undefined ) - const pending = isEdit ? updatePending : createPending - const activeState = isEdit ? updateState : createState - const fieldError = (field: string) => activeState?.fieldErrors?.[field]?.[0] + const error = typeof activeState?.error === 'string' ? activeState.error : null + const fieldError = (field: string) => { + const err = activeState?.error + if (!err || typeof err === 'string') return undefined + return (err as Record<string, string[]>)[field]?.[0] + } const titleRef = useRef<HTMLInputElement>(null) useEffect(() => { @@ -111,114 +102,84 @@ export function PbiDialog({ state, onClose, isDemo = false }: PbiDialogProps) { } }, [state]) - const closeGuard = useDirtyCloseGuard(dirty, onClose) - const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit()) - return ( - <> - <Dialog open={!!state} onOpenChange={(open) => { if (!open) closeGuard.attemptClose() }}> - <DialogContent - showCloseButton={false} - onKeyDown={handleKeyDown} - className={entityDialogContentClasses} - {...debugProps('pbi-dialog', 'PbiDialog', 'components/backlog/pbi-dialog.tsx')} - > - <div className={entityDialogHeaderClasses}> - <DialogTitle className="text-xl font-semibold"> - {isEdit ? 'PBI bewerken' : 'Nieuw PBI'} - </DialogTitle> - </div> + <Dialog open={!!state} onOpenChange={(open) => { if (!open) onClose() }}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>{isEdit ? 'PBI bewerken' : 'Nieuw PBI'}</DialogTitle> + </DialogHeader> - <form - ref={formRef} - id="pbi-form" - key={isEdit ? pbi!.id : 'create'} - action={isEdit ? updateAction : createAction} - onChange={() => setDirty(true)} - className={entityDialogBodyClasses} - > - {isEdit && <input type="hidden" name="id" value={pbi!.id} />} - {!isEdit && <input type="hidden" name="productId" value={(state as CreateState | null)?.productId ?? ''} />} - <input type="hidden" name="priority" value={priority} /> - <input type="hidden" name="status" value={status} /> - - <div className="grid grid-cols-[6rem_1fr] gap-3"> - <div className="grid gap-1.5"> - <label htmlFor="pbi-code" className="text-sm font-medium">Code</label> - <Input - id="pbi-code" - name="code" - defaultValue={pbi?.code ?? ''} - placeholder={isEdit ? '' : 'auto'} - maxLength={30} - disabled={isDemo} - aria-invalid={!!fieldError('code')} - className={fieldError('code') ? 'font-mono text-sm border-error' : 'font-mono text-sm'} - /> - {fieldError('code') && <p className="text-xs text-error">{fieldError('code')}</p>} - </div> - <div className="grid gap-1.5"> - <label htmlFor="pbi-title" className="text-sm font-medium">Titel <span className="text-error">*</span></label> - <Input - id="pbi-title" - ref={titleRef} - name="title" - defaultValue={pbi?.title ?? ''} - placeholder="PBI-titel…" - required - maxLength={200} - disabled={isDemo} - aria-invalid={!!fieldError('title')} - className={fieldError('title') ? 'border-error' : ''} - {...debugProps('pbi-dialog__title')} - /> - {fieldError('title') && <p className="text-xs text-error">{fieldError('title')}</p>} - </div> - </div> - - <div className="grid grid-cols-2 gap-3"> - <div className="grid gap-1.5"> - <label className="text-sm font-medium">Prioriteit</label> - <PrioritySelect value={priority} onChange={(v) => { setPriority(v); setDirty(true) }} /> - </div> - <div className="grid gap-1.5"> - <label className="text-sm font-medium">Status</label> - <PbiStatusSelect value={status} onChange={(v) => { setStatus(v); setDirty(true) }} /> - </div> - </div> + <form key={isEdit ? pbi!.id : 'create'} action={isEdit ? updateAction : createAction} className="grid gap-4"> + {isEdit && <input type="hidden" name="id" value={pbi!.id} />} + {!isEdit && <input type="hidden" name="productId" value={(state as CreateState | null)?.productId ?? ''} />} + <input type="hidden" name="priority" value={priority} /> + <input type="hidden" name="status" value={status} /> + <div className="grid grid-cols-[6rem_1fr] gap-3"> <div className="grid gap-1.5"> - <label htmlFor="pbi-description" className="text-sm font-medium"> - Beschrijving <span className="text-muted-foreground font-normal">(optioneel)</span> - </label> - <Textarea - id="pbi-description" - name="description" - defaultValue={pbi?.description ?? ''} - placeholder="Korte omschrijving van het PBI…" - rows={3} - maxLength={2000} - disabled={isDemo} - className="resize-none" + <label htmlFor="pbi-code" className="text-sm font-medium">Code</label> + <Input + id="pbi-code" + name="code" + defaultValue={pbi?.code ?? ''} + placeholder={isEdit ? '' : 'auto'} + maxLength={30} + className={fieldError('code') ? 'font-mono text-sm border-error' : 'font-mono text-sm'} /> + {fieldError('code') && <p className="text-xs text-error">{fieldError('code')}</p>} </div> - </form> - - <div className={entityDialogFooterClasses}> - <div className="flex items-center justify-end gap-2"> - <Button type="button" variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}> - Annuleren - </Button> - <DemoTooltip show={isDemo}> - <Button type="submit" form="pbi-form" disabled={pending || isDemo} {...debugProps('pbi-dialog__submit')}> - {pending ? '…' : isEdit ? 'Opslaan' : 'Aanmaken'} - </Button> - </DemoTooltip> + <div className="grid gap-1.5"> + <label htmlFor="pbi-title" className="text-sm font-medium">Titel</label> + <Input + id="pbi-title" + ref={titleRef} + name="title" + defaultValue={pbi?.title ?? ''} + placeholder="PBI-titel…" + required + maxLength={200} + className={fieldError('title') ? 'border-error' : ''} + /> + {fieldError('title') && <p className="text-xs text-error">{fieldError('title')}</p>} </div> </div> - </DialogContent> - </Dialog> - <DirtyCloseGuardDialog guard={closeGuard} /> - </> + + <div className="grid grid-cols-2 gap-3"> + <div className="grid gap-1.5"> + <label className="text-sm font-medium">Prioriteit</label> + <PrioritySelect value={priority} onChange={setPriority} /> + </div> + <div className="grid gap-1.5"> + <label className="text-sm font-medium">Status</label> + <PbiStatusSelect value={status} onChange={setStatus} /> + </div> + </div> + + <div className="grid gap-1.5"> + <label htmlFor="pbi-description" className="text-sm font-medium"> + Beschrijving <span className="text-muted-foreground font-normal">(optioneel)</span> + </label> + <Textarea + id="pbi-description" + name="description" + defaultValue={pbi?.description ?? ''} + placeholder="Korte omschrijving van het PBI…" + rows={3} + maxLength={2000} + className="resize-none" + /> + </div> + + {error && <p className="text-xs text-error">{error}</p>} + + <DialogFooter> + <DialogClose render={<Button type="button" variant="outline" />}> + Annuleren + </DialogClose> + <SubmitButton label={isEdit ? 'Opslaan' : 'Aanmaken'} /> + </DialogFooter> + </form> + </DialogContent> + </Dialog> ) } diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx index a749228..3995102 100644 --- a/components/backlog/pbi-list.tsx +++ b/components/backlog/pbi-list.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useTransition } from 'react' +import { useState, useTransition, useEffect } from 'react' import { DndContext, DragEndEvent, @@ -21,27 +21,15 @@ import { } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { toast } from 'sonner' -import { CheckSquare, MinusSquare, Square } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { - BacklogFilterPopover, - PRIORITY_LABELS, - type SortDir, -} from '@/components/shared/backlog-filter-popover' -import { useShallow } from 'zustand/react/shallow' -import { useUserSettingsStore } from '@/stores/user-settings/store' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import { - selectPbiTriState, - selectVisiblePbis, - type PbiTriState, -} from '@/stores/product-workspace/selectors' -import type { BacklogPbi as WorkspacePbi } from '@/stores/product-workspace/types' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { useSelectionStore } from '@/stores/selection-store' +import { usePlannerStore } from '@/stores/planner-store' +import { useBacklogStore } from '@/stores/backlog-store' import { deletePbiAction } from '@/actions/pbis' import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories' import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' import { PbiDialog, type PbiDialogState } from './pbi-dialog' import { BacklogCard } from './backlog-card' import { EmptyPanel } from './empty-panel' @@ -50,6 +38,14 @@ import { PRIORITY_COLORS } from '@/components/shared/priority-select' import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select' import type { PbiStatusApi } from '@/lib/task-status' +const PRIORITY_LABELS: Record<number, string> = { + 1: 'Kritiek', + 2: 'Hoog', + 3: 'Gemiddeld', + 4: 'Laag', +} + + type SortMode = 'priority' | 'code' | 'date' const SORT_OPTIONS: Array<{ value: SortMode; label: string }> = [ @@ -58,15 +54,56 @@ const SORT_OPTIONS: Array<{ value: SortMode; label: string }> = [ { value: 'date', label: 'Datum' }, ] -type PbiStatusFilter = PbiStatusApi | 'all' +const PRIORITY_OPTIONS: Array<{ value: number | 'all'; label: string }> = [ + { value: 'all', label: 'Alle' }, + { value: 1, label: 'Kritiek' }, + { value: 2, label: 'Hoog' }, + { value: 3, label: 'Gemiddeld' }, + { value: 4, label: 'Laag' }, +] -const STATUS_OPTIONS: Array<{ value: PbiStatusFilter; label: string }> = [ +const STATUS_OPTIONS: Array<{ value: PbiStatusApi | 'all'; label: string }> = [ { value: 'all', label: 'Alle' }, { value: 'ready', label: 'Klaar' }, { value: 'blocked', label: 'Geblokkeerd' }, { value: 'done', label: 'Afgerond' }, ] +function FilterPills<T extends string | number>({ + label, + options, + value, + onChange, +}: { + label: string + options: Array<{ value: T; label: string }> + value: T + onChange: (v: T) => void +}) { + return ( + <div className="space-y-1.5"> + <p className="text-xs font-medium text-muted-foreground">{label}</p> + <div className="flex flex-wrap gap-1.5"> + {options.map((opt) => ( + <button + key={String(opt.value)} + type="button" + onClick={() => onChange(opt.value)} + className={cn( + 'text-xs px-2.5 py-1 rounded-full border transition-colors', + value === opt.value + ? 'bg-primary text-primary-foreground border-primary' + : 'bg-transparent border-border hover:bg-surface-container' + )} + > + {opt.label} + </button> + ))} + </div> + </div> + ) +} + interface Pbi { id: string code: string | null @@ -80,42 +117,26 @@ interface Pbi { interface PbiListProps { productId: string isDemo: boolean - activeSprintId?: string | null } // --- Sortable PBI row --- -function TriStateIcon({ state }: { state: PbiTriState }) { - if (state === 'full') - return <CheckSquare size={18} className="text-primary" /> - if (state === 'partial') - return <MinusSquare size={18} className="text-primary" /> - return <Square size={18} /> -} - function SortablePbiRow({ pbi, isSelected, isDemo, - selectionMode, - triState, onSelect, - onToggleCheck, onEdit, onDelete, }: { pbi: Pbi isSelected: boolean isDemo: boolean - selectionMode: boolean - triState: PbiTriState onSelect: () => void - onToggleCheck: () => void onEdit: () => void onDelete: () => void }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: pbi.id, - disabled: selectionMode, }) const style = { @@ -123,52 +144,6 @@ function SortablePbiRow({ transition, } - if (selectionMode) { - return ( - <BacklogCard - ref={setNodeRef} - style={style} - title={pbi.title} - code={pbi.code} - priority={pbi.priority} - isSelected={isSelected} - role="button" - tabIndex={0} - aria-pressed={isSelected} - onClick={onSelect} - onKeyDown={(e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onSelect() - } - }} - badge={ - <Badge className={cn('text-xs font-normal', PBI_STATUS_COLORS[pbi.status])}> - {PBI_STATUS_LABELS[pbi.status]} - </Badge> - } - actions={ - <button - type="button" - onClick={(e) => { - e.stopPropagation() - onToggleCheck() - }} - aria-pressed={triState !== 'empty'} - aria-label={ - triState === 'full' - ? 'Stories uit sprint halen' - : 'Stories aan sprint toevoegen' - } - className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-foreground rounded transition-colors" - > - <TriStateIcon state={triState} /> - </button> - } - /> - ) - } - return ( <BacklogCard ref={setNodeRef} @@ -182,7 +157,7 @@ function SortablePbiRow({ isDragging={isDragging} role="button" tabIndex={0} - aria-pressed={isSelected} + aria-selected={isSelected} onClick={onSelect} onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect() } }} badge={ @@ -195,7 +170,7 @@ function SortablePbiRow({ <DemoTooltip show={isDemo}> <button onClick={(e) => { e.stopPropagation(); if (!isDemo) onEdit() }} - className="inline-flex items-center justify-center min-h-7 min-w-7 border border-border rounded text-xs text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors disabled:opacity-40 disabled:cursor-not-allowed" + className="border border-border rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground hover:bg-surface-container transition-colors disabled:opacity-40 disabled:cursor-not-allowed" aria-label="Bewerk PBI" disabled={isDemo} > @@ -205,7 +180,7 @@ function SortablePbiRow({ <DemoTooltip show={isDemo}> <button onClick={(e) => { e.stopPropagation(); if (!isDemo) onDelete() }} - className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-error text-base leading-none disabled:opacity-40 disabled:cursor-not-allowed" + className="text-muted-foreground hover:text-error text-xs disabled:opacity-40 disabled:cursor-not-allowed" aria-label="Verwijder PBI" disabled={isDemo} > @@ -219,79 +194,61 @@ function SortablePbiRow({ } // --- Main component --- -// PBI-74 / T-849: leest pbis + actieve selectie uit workspace-store via -// useShallow-selector. DnD-mutaties via applyOptimisticMutation/rollback/settle. -export function PbiList({ productId, isDemo, activeSprintId = null }: PbiListProps) { - // selectVisiblePbis is gesorteerd op priority/sort_order; useShallow - // voorkomt re-render op ongerelateerde store-mutaties (G2). - const pbis = useProductWorkspaceStore(useShallow(selectVisiblePbis)) as WorkspacePbi[] - const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId) - const prefs = useUserSettingsStore( - useShallow((s) => s.entities.settings.views?.pbiList ?? {}), - ) - const setPref = useUserSettingsStore((s) => s.setPref) - const filterPriority = prefs.filterPriority ?? 'all' - const filterStatus: PbiStatusFilter = prefs.filterStatus ?? 'all' - const sortMode: SortMode = prefs.sort ?? 'priority' - const sortDir: SortDir = prefs.sortDir ?? 'asc' - const setFilterPriority = (v: number | 'all') => - void setPref(['views', 'pbiList', 'filterPriority'], v) - const setFilterStatus = (v: PbiStatusFilter) => - void setPref(['views', 'pbiList', 'filterStatus'], v) - const setSortMode = (v: SortMode) => void setPref(['views', 'pbiList', 'sort'], v) - const setSortDir = (v: SortDir) => void setPref(['views', 'pbiList', 'sortDir'], v) - const [filterPopoverOpen, setFilterPopoverOpen] = useState(false) +export function PbiList({ productId, isDemo }: PbiListProps) { + const pbis = useBacklogStore((s) => s.pbis) + const { selectedPbiId, selectPbi } = useSelectionStore() + const { pbiOrder, pbiPriority, initPbis, reorderPbis, rollbackPbis, updatePbiPriority } = usePlannerStore() + // Defaults match SSR; persisted values applied post-mount in the loader effect below. + // This avoids hydration mismatch when localStorage holds non-default values. + const [filterPriority, setFilterPriority] = useState<number | 'all'>('all') + const [filterStatus, setFilterStatus] = useState<PbiStatusApi | 'all'>('all') + const [sortMode, setSortMode] = useState<SortMode>('priority') + const [prefsLoaded, setPrefsLoaded] = useState(false) const [dialogState, setDialogState] = useState<PbiDialogState | null>(null) const [activeDragId, setActiveDragId] = useState<string | null>(null) const [, startTransition] = useTransition() - // PBI-79 / ST-1337+ST-1338: selectionMode is afgeleid uit drie staten: - // A′ (pendingSprintDraft) → vinkjes muteren de draft via upsertPbiIntent. - // B (activeSprintId zonder draft) → vinkjes muteren de membership-buffer - // via toggleStorySprintMembership per child story (bulk). - // A (geen sprint, geen draft) → geen vinkjes. - const hasDraft = useUserSettingsStore( - (s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId], - ) - const upsertPbiIntent = useUserSettingsStore((s) => s.upsertPbiIntent) - const toggleStorySprintMembership = useProductWorkspaceStore( - (s) => s.toggleStorySprintMembership, - ) - const stateBMode = !hasDraft && !!activeSprintId - const selectionMode = hasDraft || stateBMode - - function togglePbiInDraft(id: string, currentState: PbiTriState) { - if (hasDraft) { - // A′: empty/partial → all; full → none. - const nextIntent = currentState === 'full' ? 'none' : 'all' - void upsertPbiIntent(productId, id, nextIntent) - return + // Load persisted preferences once after mount (client-only). + // setState calls here are intentional: hydrating from localStorage on first paint. + useEffect(() => { + const savedSort = localStorage.getItem('scrum4me:pbi_sort') + if (savedSort === 'priority' || savedSort === 'code' || savedSort === 'date') { + // eslint-disable-next-line react-hooks/set-state-in-effect + setSortMode(savedSort) } - if (stateBMode && activeSprintId) { - // State B: bulk-toggle alle child-stories naar/uit de pending buffer. - const store = useProductWorkspaceStore.getState() - const storyIds = store.relations.storyIdsByPbi[id] ?? [] - const goingFull = currentState !== 'full' - for (const storyId of storyIds) { - const story = store.entities.storiesById[storyId] - if (!story) continue - const blocked = store.sprintMembership.crossSprintBlocks[storyId] - if (blocked) continue - const inSprint = story.sprint_id === activeSprintId - if (goingFull && !inSprint) { - toggleStorySprintMembership(storyId, false) - } - if (!goingFull && inSprint) { - toggleStorySprintMembership(storyId, true) - } - } + const savedPriority = localStorage.getItem('scrum4me:pbi_filter_priority') + if (savedPriority && savedPriority !== 'all') { + const n = parseInt(savedPriority, 10) + if (Number.isInteger(n) && n >= 1 && n <= 4) setFilterPriority(n) } - } + const savedStatus = localStorage.getItem('scrum4me:pbi_filter_status') + if (savedStatus === 'ready' || savedStatus === 'blocked' || savedStatus === 'done') { + setFilterStatus(savedStatus) + } + setPrefsLoaded(true) + }, []) - // pbis komen al gesorteerd binnen via selectVisiblePbis (priority + sort_order). - // Geen aparte order/priority maps meer — workspace-store entities zijn de waarheid. + // Persist on change, but skip the initial render so we don't overwrite saved values with defaults. + useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_sort', sortMode) }, [sortMode, prefsLoaded]) + useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_filter_priority', String(filterPriority)) }, [filterPriority, prefsLoaded]) + useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_filter_status', filterStatus) }, [filterStatus, prefsLoaded]) + + // Sync server data into store — use stable string dep to avoid infinite loop + const pbiIdKey = pbis.map(p => p.id).join(',') + useEffect(() => { + initPbis(productId, pbiIdKey ? pbiIdKey.split(',') : []) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [productId, pbiIdKey]) + + // Build ordered PBI list from store (or fall back to server order) + const order = pbiOrder[productId] ?? pbis.map(p => p.id) const pbiMap = Object.fromEntries(pbis.map(p => [p.id, p])) - const orderedPbis = pbis + + // Apply priority overrides from store + const orderedPbis = order + .map(id => pbiMap[id]) + .filter(Boolean) + .map(p => ({ ...p, priority: pbiPriority[p.id] ?? p.priority })) const base = orderedPbis.filter(p => { if (filterPriority !== 'all' && p.priority !== filterPriority) return false @@ -302,19 +259,17 @@ export function PbiList({ productId, isDemo, activeSprintId = null }: PbiListPro const activeFilterCount = (filterPriority !== 'all' ? 1 : 0) + (filterStatus !== 'all' ? 1 : 0) + - (sortMode !== 'priority' ? 1 : 0) + - (sortDir !== 'asc' ? 1 : 0) + (sortMode !== 'priority' ? 1 : 0) const filtered = [...base].sort((a, b) => { - let cmp = 0 if (sortMode === 'code') { - cmp = (a.code ?? '').localeCompare(b.code ?? '', 'nl', { numeric: true }) - } else if (sortMode === 'date') { - cmp = new Date(b.created_at).getTime() - new Date(a.created_at).getTime() - } else { - cmp = a.priority !== b.priority ? a.priority - b.priority : 0 + return (a.code ?? '').localeCompare(b.code ?? '', 'nl', { numeric: true }) } - return sortDir === 'desc' ? -cmp : cmp + if (sortMode === 'date') { + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + } + // priority: sort by priority asc, then drag-and-drop sort_order within group + return a.priority !== b.priority ? a.priority - b.priority : 0 }) const sensors = useSensors( @@ -335,58 +290,30 @@ export function PbiList({ productId, isDemo, activeSprintId = null }: PbiListPro const overPbi = pbiMap[over.id as string] if (!activePbi || !overPbi) return - const store = useProductWorkspaceStore.getState() - const prevOrder = [...store.relations.pbiIds] - const oldIndex = prevOrder.indexOf(active.id as string) - const newIndex = prevOrder.indexOf(over.id as string) - if (oldIndex === -1 || newIndex === -1) return - const newOrder = arrayMove([...prevOrder], oldIndex, newIndex) + const prevOrder = [...order] + const oldIndex = order.indexOf(active.id as string) + const newIndex = order.indexOf(over.id as string) + const newOrder = arrayMove([...order], oldIndex, newIndex) - // Snapshot rollback-info en pas optimistisch toe. - const orderMutationId = store.applyOptimisticMutation({ - kind: 'pbi-order', - prevPbiIds: prevOrder, - }) - useProductWorkspaceStore.setState((s) => { - s.relations.pbiIds = newOrder - }) + // Optimistic update + reorderPbis(productId, newOrder) const priorityChanged = activePbi.priority !== overPbi.priority - let priorityMutationId: string | null = null - if (priorityChanged) { - priorityMutationId = store.applyOptimisticMutation({ - kind: 'entity-patch', - entity: 'pbi', - id: active.id as string, - prev: store.entities.pbisById[active.id as string], - }) - useProductWorkspaceStore.setState((s) => { - const pbi = s.entities.pbisById[active.id as string] - if (pbi) pbi.priority = overPbi.priority - }) - } startTransition(async () => { - const settle = () => { - const st = useProductWorkspaceStore.getState() - if (priorityMutationId) st.settleMutation(priorityMutationId) - st.settleMutation(orderMutationId) - } - const rollback = (msg: string) => { - const st = useProductWorkspaceStore.getState() - if (priorityMutationId) st.rollbackMutation(priorityMutationId) - st.rollbackMutation(orderMutationId) - toast.error(msg) - } - if (priorityChanged) { + updatePbiPriority(active.id as string, overPbi.priority) const result = await updatePbiPriorityAction(active.id as string, overPbi.priority, productId) - if (result.success) settle() - else rollback('Prioriteit opslaan mislukt') + if (!result.success) { + rollbackPbis(productId, prevOrder) + toast.error('Prioriteit opslaan mislukt') + } } else { const result = await reorderPbisAction(productId, newOrder) - if (result.success) settle() - else rollback('Volgorde opslaan mislukt') + if (!result.success) { + rollbackPbis(productId, prevOrder) + toast.error('Volgorde opslaan mislukt') + } } }) } @@ -394,17 +321,15 @@ export function PbiList({ productId, isDemo, activeSprintId = null }: PbiListPro function handleDelete(id: string) { startTransition(async () => { await deletePbiAction(id) - if (selectedPbiId === id) { - useProductWorkspaceStore.getState().setActivePbi(null) - } + if (selectedPbiId === id) selectPbi(null) }) } const activePbi = activeDragId ? pbiMap[activeDragId] : null return ( - <div className="flex flex-col h-full" {...debugProps('pbi-list', 'PbiList', 'components/backlog/pbi-list.tsx')}> - <div className="flex items-center justify-end gap-2 px-4 py-2 border-b border-border bg-surface-container-low shrink-0" {...debugProps('pbi-list__header')}> + <div className="flex flex-col h-full"> + <div className="flex items-center justify-end gap-2 px-4 py-2 border-b border-border bg-surface-container-low shrink-0"> {filterPriority !== 'all' && ( <button onClick={() => setFilterPriority('all')} @@ -429,33 +354,56 @@ export function PbiList({ productId, isDemo, activeSprintId = null }: PbiListPro <span>×</span> </button> )} - <BacklogFilterPopover - open={filterPopoverOpen} - onOpenChange={setFilterPopoverOpen} - filterPriority={filterPriority} - onFilterPriorityChange={setFilterPriority} - filterStatus={filterStatus} - onFilterStatusChange={setFilterStatus} - statusOptions={STATUS_OPTIONS} - sort={sortMode} - onSortChange={setSortMode} - sortDir={sortDir} - onSortDirChange={setSortDir} - sortOptions={SORT_OPTIONS} - activeFilterCount={activeFilterCount} - resetDisabled={activeFilterCount === 0} - onReset={() => { - setFilterPriority('all') - setFilterStatus('all') - setSortMode('priority') - setSortDir('asc') - }} - /> + <Popover> + <PopoverTrigger + render={ + <Button variant="outline" size="sm" className="h-7 text-xs"> + {`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`} + </Button> + } + /> + <PopoverContent align="end" className="w-72 space-y-4"> + <FilterPills + label="Sorteren op" + options={SORT_OPTIONS} + value={sortMode} + onChange={setSortMode} + /> + <FilterPills + label="Prioriteit" + options={PRIORITY_OPTIONS} + value={filterPriority} + onChange={setFilterPriority} + /> + <FilterPills + label="Status" + options={STATUS_OPTIONS} + value={filterStatus} + onChange={setFilterStatus} + /> + <div className="flex justify-end pt-1 border-t border-border"> + <Button + type="button" + variant="ghost" + size="sm" + className="h-7 text-xs" + disabled={activeFilterCount === 0} + onClick={() => { + setFilterPriority('all') + setFilterStatus('all') + setSortMode('priority') + }} + > + Wis filters + </Button> + </div> + </PopoverContent> + </Popover> <DemoTooltip show={isDemo}> <Button size="sm" className="h-7 text-xs" - disabled={isDemo || selectionMode} + disabled={isDemo} onClick={() => !isDemo && setDialogState({ mode: 'create', productId, defaultPriority: 2 })} > + PBI @@ -481,17 +429,14 @@ export function PbiList({ productId, isDemo, activeSprintId = null }: PbiListPro items={filtered.map(p => p.id)} strategy={verticalListSortingStrategy} > - <div className="p-3 flex flex-col gap-2" {...debugProps('pbi-list__items')}> + <div className="p-3 flex flex-col gap-2"> {filtered.map(pbi => ( - <SortablePbiRowWithTriState + <SortablePbiRow key={pbi.id} pbi={pbi} isSelected={selectedPbiId === pbi.id} isDemo={isDemo} - selectionMode={selectionMode} - productId={productId} - onSelect={() => useProductWorkspaceStore.getState().setActivePbi(pbi.id)} - onToggle={togglePbiInDraft} + onSelect={() => selectPbi(pbi.id)} onEdit={() => setDialogState({ mode: 'edit', productId, pbi })} onDelete={() => handleDelete(pbi.id)} /> @@ -515,69 +460,7 @@ export function PbiList({ productId, isDemo, activeSprintId = null }: PbiListPro <PbiDialog state={dialogState} onClose={() => setDialogState(null)} - isDemo={isDemo} /> </div> ) } - -// PBI-79 / ST-1337: wrapper rond SortablePbiRow die zijn tri-state uit de -// workspace-store leest. Subscribed per PBI zodat alleen de relevante rij -// re-rendert bij pbiIntent/storyOverrides-mutaties. -function SortablePbiRowWithTriState({ - pbi, - isSelected, - isDemo, - selectionMode, - productId, - onSelect, - onToggle, - onEdit, - onDelete, -}: { - pbi: Pbi - isSelected: boolean - isDemo: boolean - selectionMode: boolean - productId: string - onSelect: () => void - onToggle: (id: string, currentState: PbiTriState) => void - onEdit: () => void - onDelete: () => void -}) { - // Tri-state uit pendingSprintDraft (state A′) of pbiSummary (state B). - // Wanneer geen draft: leid af van pbiSummary; wanneer wel: uit pbiIntent. - const triState = useUserSettingsStore((s) => { - const draft = s.entities.settings.workflow?.pendingSprintDraft?.[productId] - if (draft) { - const intent = draft.pbiIntent[pbi.id] ?? 'none' - const override = draft.storyOverrides[pbi.id] - if (intent === 'all') { - if (override?.remove.length) return 'partial' - return 'full' - } - if (override?.add.length) return 'partial' - return 'empty' - } - return null - }) - const summaryTriState = useProductWorkspaceStore((s) => - selectPbiTriState(s, pbi.id), - ) - const effectiveTriState: PbiTriState = - triState ?? (selectionMode ? summaryTriState : 'empty') - - return ( - <SortablePbiRow - pbi={pbi} - isSelected={isSelected} - isDemo={isDemo} - selectionMode={selectionMode} - triState={effectiveTriState} - onSelect={onSelect} - onToggleCheck={() => onToggle(pbi.id, effectiveTriState)} - onEdit={onEdit} - onDelete={onDelete} - /> - ) -} diff --git a/components/backlog/save-sprint-button.tsx b/components/backlog/save-sprint-button.tsx deleted file mode 100644 index fe12538..0000000 --- a/components/backlog/save-sprint-button.tsx +++ /dev/null @@ -1,89 +0,0 @@ -'use client' - -import { useTransition } from 'react' -import { useRouter } from 'next/navigation' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import { - selectIsDirty, - selectPendingCount, -} from '@/stores/product-workspace/selectors' -import { commitSprintMembershipAction } from '@/actions/sprints' - -interface SaveSprintButtonProps { - activeSprintId: string -} - -/** - * PBI-79 / ST-1338 / T-940: 'Sprint opslaan'-knop voor state B. - * Altijd zichtbaar zolang er een actieve sprint is. Disabled bij clean, - * enabled met teller bij dirty. Commit gebeurt via - * commitSprintMembershipAction; client patcht gericht via - * applyMembershipCommitResult. Geen router.refresh. - */ -export function SaveSprintButton({ activeSprintId }: SaveSprintButtonProps) { - const router = useRouter() - const isDirty = useProductWorkspaceStore(selectIsDirty) - const count = useProductWorkspaceStore(selectPendingCount) - const adds = useProductWorkspaceStore((s) => s.sprintMembership.pending.adds) - const removes = useProductWorkspaceStore( - (s) => s.sprintMembership.pending.removes, - ) - const applyMembershipCommitResult = useProductWorkspaceStore( - (s) => s.applyMembershipCommitResult, - ) - const [isPending, startTransition] = useTransition() - - function handleSave() { - startTransition(async () => { - const result = await commitSprintMembershipAction({ - activeSprintId, - adds: [...adds], - removes: [...removes], - }) - if ('error' in result) { - toast.error(result.error) - return - } - applyMembershipCommitResult({ - activeSprintId, - addedStoryIds: adds.filter((id) => - result.affectedStoryIds.includes(id), - ), - removedStoryIds: removes.filter((id) => - result.affectedStoryIds.includes(id), - ), - }) - const skipped = - result.conflicts.notEligible.length + - result.conflicts.alreadyRemoved.length - if (skipped > 0) { - toast.warning( - `${skipped} wijziging${skipped === 1 ? '' : 'en'} overgeslagen — story al in andere sprint of inmiddels verwijderd.`, - ) - } else { - toast.success('Sprint opgeslagen') - } - // Gericht patchen voldoende voor lokale UI; refresh haalt server-side - // counts opnieuw op zodat tri-state in volgende renders klopt. - router.refresh() - }) - } - - return ( - <Button - type="button" - size="sm" - onClick={handleSave} - disabled={!isDirty || isPending} - data-debug-id="save-sprint-button" - > - {isPending - ? 'Opslaan…' - : isDirty - ? `Sprint opslaan (${count})` - : 'Sprint opslaan'} - </Button> - ) -} diff --git a/components/backlog/sprint-definition-banner.tsx b/components/backlog/sprint-definition-banner.tsx deleted file mode 100644 index df3fdf3..0000000 --- a/components/backlog/sprint-definition-banner.tsx +++ /dev/null @@ -1,202 +0,0 @@ -'use client' - -import { useMemo, useState, useTransition } from 'react' -import { useRouter } from 'next/navigation' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' -import { useUserSettingsStore } from '@/stores/user-settings/store' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import type { PendingSprintDraft } from '@/lib/user-settings' -import { createSprintWithSelectionAction } from '@/actions/sprints' -import { debugProps } from '@/lib/debug' - -interface SprintDefinitionBannerProps { - productId: string - draft: PendingSprintDraft -} - -type DraftCounts = { - pbiCount: number - storyCount: number - hasUnknownTotal: boolean -} - -function computeCounts( - draft: PendingSprintDraft, - pbiSummary: Record< - string, - { totalStoryCount: number; inActiveSprintStoryCount: number } - >, -): DraftCounts { - let pbiCount = 0 - let storyCount = 0 - let hasUnknownTotal = false - - const seenPbis = new Set<string>() - - for (const [pbiId, intent] of Object.entries(draft.pbiIntent)) { - if (intent === 'all') { - seenPbis.add(pbiId) - const summary = pbiSummary[pbiId] - const override = draft.storyOverrides[pbiId] - if (!summary) { - hasUnknownTotal = true - continue - } - const removed = override?.remove.length ?? 0 - storyCount += Math.max(0, summary.totalStoryCount - removed) - } - } - - for (const [pbiId, override] of Object.entries(draft.storyOverrides)) { - if (override.add.length === 0) continue - seenPbis.add(pbiId) - storyCount += override.add.length - } - - pbiCount = seenPbis.size - return { pbiCount, storyCount, hasUnknownTotal } -} - -export function SprintDefinitionBanner({ - productId, - draft, -}: SprintDefinitionBannerProps) { - const clearPendingSprintDraft = useUserSettingsStore( - (s) => s.clearPendingSprintDraft, - ) - const pbiSummary = useProductWorkspaceStore((s) => s.sprintMembership.pbiSummary) - const router = useRouter() - const [isPending, startTransition] = useTransition() - const [confirmCancel, setConfirmCancel] = useState(false) - - const counts = useMemo( - () => computeCounts(draft, pbiSummary), - [draft, pbiSummary], - ) - - function handleCancel() { - setConfirmCancel(true) - } - - function confirmCancelAction() { - setConfirmCancel(false) - startTransition(async () => { - try { - await clearPendingSprintDraft(productId) - } catch (err) { - const message = - err instanceof Error ? err.message : 'Annuleren mislukt' - toast.error(message) - } - }) - } - - function handleCreate() { - startTransition(async () => { - const result = await createSprintWithSelectionAction({ - productId, - metadata: { - goal: draft.goal, - startAt: draft.startAt, - endAt: draft.endAt, - }, - pbiIntent: draft.pbiIntent, - storyOverrides: draft.storyOverrides, - }) - if ('error' in result) { - toast.error(result.error) - return - } - const { conflicts } = result - if (conflicts.notEligible.length > 0) { - toast.warning( - `${conflicts.notEligible.length} stor${ - conflicts.notEligible.length === 1 ? 'y is' : 'ies zijn' - } overgeslagen (al in een andere sprint of afgerond).`, - ) - } else { - toast.success('Sprint aangemaakt') - } - router.refresh() - }) - } - - const storyLabel = counts.hasUnknownTotal - ? `${counts.storyCount}+` - : counts.storyCount - const pbiSuffix = counts.pbiCount === 1 ? '' : "'s" - - return ( - <div - className="sticky top-0 z-30 bg-tertiary-container text-tertiary-container-foreground border-b border-tertiary px-4 py-2.5 flex items-center gap-4" - {...debugProps('sprint-definition-banner')} - > - <div className="flex-1 min-w-0"> - <div className="flex items-baseline gap-2"> - <span className="text-sm font-medium shrink-0"> - Sprint definiëren — - </span> - <span className="text-sm truncate" title={draft.goal}> - {draft.goal} - </span> - </div> - <div className="text-xs opacity-80 mt-0.5"> - {counts.pbiCount} PBI{pbiSuffix} · {storyLabel} stor - {counts.storyCount === 1 ? 'y' : 'ies'} geselecteerd - </div> - </div> - <div className="flex items-center gap-2 shrink-0"> - <Button - type="button" - variant="ghost" - onClick={handleCancel} - disabled={isPending} - data-debug-id="sprint-definition-banner__cancel" - > - Annuleren - </Button> - <Button - type="button" - onClick={handleCreate} - disabled={isPending || counts.pbiCount === 0} - data-debug-id="sprint-definition-banner__create" - > - Sprint aanmaken - </Button> - </div> - <AlertDialog open={confirmCancel} onOpenChange={setConfirmCancel}> - <AlertDialogContent size="sm"> - <AlertDialogHeader> - <AlertDialogTitle>Sprint-definitie annuleren?</AlertDialogTitle> - <AlertDialogDescription> - Je conceptselectie gaat verloren. Het sprint-doel en de - gemarkeerde PBI/stories worden verwijderd. - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel onClick={() => setConfirmCancel(false)}> - Doorgaan - </AlertDialogCancel> - <AlertDialogAction - variant="destructive" - onClick={confirmCancelAction} - > - Ja, annuleren - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - </div> - ) -} diff --git a/components/backlog/sprint-draft-banner.tsx b/components/backlog/sprint-draft-banner.tsx deleted file mode 100644 index 3d3b9ff..0000000 --- a/components/backlog/sprint-draft-banner.tsx +++ /dev/null @@ -1,22 +0,0 @@ -'use client' - -import { useUserSettingsStore } from '@/stores/user-settings/store' -import { SprintDefinitionBanner } from './sprint-definition-banner' - -interface SprintDraftBannerProps { - productId: string -} - -/** - * PBI-79 / ST-1337: client-wrapper die de SprintDefinitionBanner alleen rendert - * als er een pendingSprintDraft voor dit product staat. Hydratatie loopt via - * UserSettingsBridge — dit component subscribt op die store en is daarmee - * automatisch reactief op draft-mutaties (set/clear). - */ -export function SprintDraftBanner({ productId }: SprintDraftBannerProps) { - const draft = useUserSettingsStore( - (s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId], - ) - if (!draft) return null - return <SprintDefinitionBanner productId={productId} draft={draft} /> -} diff --git a/components/backlog/sprint-draft-leave-guard.tsx b/components/backlog/sprint-draft-leave-guard.tsx deleted file mode 100644 index 5daf9db..0000000 --- a/components/backlog/sprint-draft-leave-guard.tsx +++ /dev/null @@ -1,37 +0,0 @@ -'use client' - -import { useEffect } from 'react' -import { useUserSettingsStore } from '@/stores/user-settings/store' - -interface SprintDraftLeaveGuardProps { - productId: string -} - -/** - * PBI-79: window.beforeunload-waarschuwing zolang er een pendingSprintDraft - * loopt voor dit product. De draft is session-only en gaat verloren bij - * refresh/close — deze guard zorgt dat de gebruiker dat eerst bevestigt. - * Voor in-app route-changes (klikken op een andere product) doet Next.js - * geen onbeforeunload; daar vangen we het op via de banner-Annuleren-flow. - */ -export function SprintDraftLeaveGuard({ - productId, -}: SprintDraftLeaveGuardProps) { - const hasDraft = useUserSettingsStore( - (s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId], - ) - - useEffect(() => { - if (!hasDraft) return - function handler(e: BeforeUnloadEvent) { - e.preventDefault() - // Moderne browsers tonen een eigen vertaalde tekst; returnValue is - // alleen nodig voor legacy compat. - e.returnValue = '' - } - window.addEventListener('beforeunload', handler) - return () => window.removeEventListener('beforeunload', handler) - }, [hasDraft]) - - return null -} diff --git a/components/backlog/sprint-edit-dialog.tsx b/components/backlog/sprint-edit-dialog.tsx deleted file mode 100644 index 4842a8a..0000000 --- a/components/backlog/sprint-edit-dialog.tsx +++ /dev/null @@ -1,217 +0,0 @@ -'use client' - -import { useRef, useState, useTransition } from 'react' -import { useRouter } from 'next/navigation' -import Link from 'next/link' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { Textarea } from '@/components/ui/textarea' -import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' -import { - useDirtyCloseGuard, - DirtyCloseGuardDialog, -} from '@/components/shared/use-dirty-close-guard' -import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut' -import { - entityDialogContentClasses, - entityDialogFooterClasses, - entityDialogHeaderClasses, -} from '@/components/shared/entity-dialog-layout' -import { updateSprintAction } from '@/actions/sprints' -import { debugProps } from '@/lib/debug' - -interface SprintEditDialogProps { - open: boolean - productId: string - sprint: { - id: string - code: string - sprint_goal: string - start_date?: string | null - end_date?: string | null - } - onOpenChange: (open: boolean) => void -} - -function toDateInput(value: string | null | undefined): string { - if (!value) return '' - // Accept ISO datetime or YYYY-MM-DD; output YYYY-MM-DD. - const d = new Date(value) - if (Number.isNaN(d.getTime())) return '' - return d.toLocaleDateString('en-CA') -} - -export function SprintEditDialog({ - open, - productId, - sprint, - onOpenChange, -}: SprintEditDialogProps) { - const [goal, setGoal] = useState(sprint.sprint_goal) - const [startDate, setStartDate] = useState(toDateInput(sprint.start_date)) - const [endDate, setEndDate] = useState(toDateInput(sprint.end_date)) - const [error, setError] = useState<string | null>(null) - const [dirty, setDirty] = useState(false) - const [isPending, startTransition] = useTransition() - const formRef = useRef<HTMLFormElement>(null) - const router = useRouter() - - function reset() { - setGoal(sprint.sprint_goal) - setStartDate(toDateInput(sprint.start_date)) - setEndDate(toDateInput(sprint.end_date)) - setError(null) - setDirty(false) - } - - const closeGuard = useDirtyCloseGuard(dirty, () => { - onOpenChange(false) - reset() - }) - - function handleSubmit(e: React.FormEvent) { - e.preventDefault() - const trimmed = goal.trim() - if (!trimmed) return - setError(null) - startTransition(async () => { - const result = await updateSprintAction({ - sprintId: sprint.id, - fields: { - goal: trimmed, - startAt: startDate || null, - endAt: endDate || null, - }, - }) - if ('error' in result) { - setError(result.error) - toast.error(result.error) - return - } - toast.success('Sprint bijgewerkt') - onOpenChange(false) - router.refresh() - }) - } - - const handleKeyDown = useDialogSubmitShortcut(() => - formRef.current?.requestSubmit(), - ) - - return ( - <> - <Dialog - open={open} - onOpenChange={(o) => { - if (!o) closeGuard.attemptClose() - else onOpenChange(o) - }} - > - <DialogContent - showCloseButton={false} - onKeyDown={handleKeyDown} - className={entityDialogContentClasses} - {...debugProps( - 'sprint-edit-dialog', - 'SprintEditDialog', - 'components/backlog/sprint-edit-dialog.tsx', - )} - > - <div className={entityDialogHeaderClasses}> - <DialogTitle className="text-xl font-semibold"> - Sprint {sprint.code} bewerken - </DialogTitle> - <p className="text-xs text-muted-foreground mt-1"> - Wijzig sprint-doel en datums. Voor afronding (per-story DONE/OPEN - beslissing) ga naar de sprint-pagina. - </p> - </div> - - <form - ref={formRef} - id="sprint-edit-form" - onSubmit={handleSubmit} - onChange={() => setDirty(true)} - className="flex-1 overflow-y-auto px-6 py-6 space-y-6" - > - <div className="space-y-1.5"> - <label className="text-sm font-medium text-foreground"> - Sprint Goal <span className="text-error">*</span> - </label> - <Textarea - value={goal} - onChange={(e) => setGoal(e.target.value)} - required - rows={3} - autoFocus - /> - </div> - - <div className="grid grid-cols-2 gap-3"> - <div className="space-y-1.5"> - <label className="text-sm font-medium text-foreground"> - Startdatum - </label> - <input - type="date" - value={startDate} - onChange={(e) => setStartDate(e.target.value)} - className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" - /> - </div> - <div className="space-y-1.5"> - <label className="text-sm font-medium text-foreground"> - Einddatum - </label> - <input - type="date" - value={endDate} - onChange={(e) => setEndDate(e.target.value)} - className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" - /> - </div> - </div> - - <div className="pt-2 border-t border-border"> - <Link - href={`/products/${productId}/sprint/${sprint.id}`} - className="text-sm text-primary hover:underline" - > - Sprint afronden… → - </Link> - </div> - - {error && ( - <div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error"> - {error} - </div> - )} - </form> - - <div className={entityDialogFooterClasses}> - <div className="flex justify-end gap-2"> - <Button - type="button" - variant="ghost" - onClick={closeGuard.attemptClose} - disabled={isPending} - > - Annuleren - </Button> - <Button - type="submit" - form="sprint-edit-form" - disabled={isPending || !goal.trim()} - data-debug-id="sprint-edit-dialog__submit" - > - {isPending ? 'Opslaan…' : 'Opslaan'} - </Button> - </div> - </div> - </DialogContent> - </Dialog> - - <DirtyCloseGuardDialog guard={closeGuard} /> - </> - ) -} diff --git a/components/backlog/story-dialog.tsx b/components/backlog/story-dialog.tsx index 5c416e1..724f430 100644 --- a/components/backlog/story-dialog.tsx +++ b/components/backlog/story-dialog.tsx @@ -1,24 +1,17 @@ 'use client' import { useEffect, useRef, useState, useTransition } from 'react' -import { useActionState } from 'react' import { Markdown } from '@/components/markdown' +import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' import { toast } from 'sonner' import { Dialog, DialogContent, + DialogHeader, DialogTitle, + DialogClose, } from '@/components/ui/dialog' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' @@ -26,19 +19,8 @@ import { Badge } from '@/components/ui/badge' import { PrioritySelect, PRIORITY_LABELS, PRIORITY_COLORS } from '@/components/shared/priority-select' import { StoryLog } from '@/components/shared/story-log' import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { - useDirtyCloseGuard, - DirtyCloseGuardDialog, -} from '@/components/shared/use-dirty-close-guard' -import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut' -import { - entityDialogContentClasses, - entityDialogFooterClasses, - entityDialogHeaderClasses, -} from '@/components/shared/entity-dialog-layout' import { createStoryAction, updateStoryAction, deleteStoryAction, getStoryLogsAction } from '@/actions/stories' import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' import type { Story } from './story-panel' export type StoryDialogState = @@ -51,14 +33,6 @@ interface StoryDialogProps { isDemo?: boolean } -interface ActionResult { - success?: boolean - error?: string - code?: number - fieldErrors?: Record<string, string[]> - story?: unknown -} - const STATUS_COLORS: Record<string, string> = { OPEN: 'bg-status-todo/15 text-status-todo border-status-todo/30', IN_SPRINT: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30', @@ -70,6 +44,15 @@ const STATUS_LABELS: Record<string, string> = { DONE: 'Klaar', } +function SubmitButton({ label, disabled }: { label: string; disabled?: boolean }) { + const { pending } = useFormStatus() + return ( + <Button type="submit" disabled={disabled || pending}> + {pending ? '…' : label} + </Button> + ) +} + export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps) { const isEdit = state?.mode === 'edit' const story = isEdit ? (state as Extract<StoryDialogState, { mode: 'edit' }>).story : null @@ -79,50 +62,52 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps const [confirmDelete, setConfirmDelete] = useState(false) const [isDeleting, startDeleteTransition] = useTransition() const [logs, setLogs] = useState<Awaited<ReturnType<typeof getStoryLogsAction>> | null>(null) - const [dirty, setDirty] = useState(false) - const formRef = useRef<HTMLFormElement>(null) useEffect(() => { if (!state) return // eslint-disable-next-line react-hooks/set-state-in-effect setConfirmDelete(false) - setDirty(false) if (state.mode === 'edit') { + setPriority(state.story.priority) + setLogs(null) getStoryLogsAction(state.story.id).then(setLogs) } else { + setPriority(state.defaultPriority ?? 2) } }, [state]) - const [createResult, createAction, createPending] = useActionState<ActionResult | undefined, FormData>( - async (_prev, fd) => { - const result = await createStoryAction(_prev, fd) as ActionResult + const [createResult, createAction] = useActionState( + async (_prev: unknown, fd: FormData) => { + const result = await createStoryAction(_prev, fd) if (result?.success) { toast.success('Story aangemaakt'); onClose() } - else if (result?.code !== 422 && result?.error) toast.error(result.error) + else if (typeof result?.error === 'string') toast.error(result.error) return result }, - undefined, + undefined ) - const [updateResult, updateAction, updatePending] = useActionState<ActionResult | undefined, FormData>( - async (_prev, fd) => { - const result = await updateStoryAction(_prev, fd) as ActionResult + const [updateResult, updateAction] = useActionState( + async (_prev: unknown, fd: FormData) => { + const result = await updateStoryAction(_prev, fd) if (result?.success) { toast.success('Story opgeslagen'); onClose() } - else if (result?.code !== 422 && result?.error) toast.error(result.error) + else if (typeof result?.error === 'string') toast.error(result.error) return result }, - undefined, + undefined ) - const pending = isEdit ? updatePending : createPending - const activeResult = isEdit ? updateResult : createResult - const fieldError = (field: string) => activeResult?.fieldErrors?.[field]?.[0] + const fieldError = (field: string) => { + const result = isEdit ? updateResult : createResult + const err = result?.error + if (!err || typeof err === 'string') return undefined + return (err as Record<string, string[]>)[field]?.[0] + } function handleDelete() { if (!story) return - setConfirmDelete(false) startDeleteTransition(async () => { const result = await deleteStoryAction(story.id) if (result && 'error' in result) toast.error(result.error ?? 'Verwijderen mislukt') @@ -136,134 +121,114 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps if (state) setTimeout(() => titleRef.current?.focus(), 50) }, [state]) - const closeGuard = useDirtyCloseGuard(dirty, onClose) - const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit()) - const showForm = !isDemo || !isEdit return ( - <> - <Dialog open={!!state} onOpenChange={(open) => { if (!open) closeGuard.attemptClose() }}> - <DialogContent - showCloseButton={false} - onKeyDown={handleKeyDown} - className={entityDialogContentClasses} - {...debugProps('story-dialog', 'StoryDialog', 'components/backlog/story-dialog.tsx')} - > - <div className={cn(entityDialogHeaderClasses, 'flex-col items-stretch gap-1')}> - <div className="flex items-start gap-2"> - <DialogTitle className="flex-1 text-xl font-semibold"> - {isEdit ? story!.title : 'Nieuwe story'} - </DialogTitle> - {isEdit && story!.code && ( - <span className="font-mono text-[11px] text-muted-foreground border border-border rounded-md bg-surface-container px-1.5 py-0.5 shrink-0 mt-0.5"> - {story!.code} - </span> - )} - </div> - {isEdit && ( - <div className="flex gap-2"> - <Badge className={cn('text-xs border', PRIORITY_COLORS[priority])}> - {PRIORITY_LABELS[priority]} - </Badge> - <Badge className={cn('text-xs border', STATUS_COLORS[story!.status])}> - {STATUS_LABELS[story!.status]} - </Badge> - </div> + <Dialog open={!!state} onOpenChange={(open) => { if (!open) onClose() }}> + <DialogContent className="sm:max-w-lg flex flex-col gap-0 p-0 max-h-[90vh] overflow-hidden"> + <DialogHeader className="px-5 pt-5 pb-4 border-b border-border shrink-0 pr-14"> + <div className="flex items-start gap-2"> + <DialogTitle className="flex-1">{isEdit ? story!.title : 'Nieuwe story'}</DialogTitle> + {isEdit && story!.code && ( + <span className="font-mono text-[11px] text-muted-foreground border border-border rounded-md bg-surface-container px-1.5 py-0.5 shrink-0 mt-0.5"> + {story!.code} + </span> )} </div> + {isEdit && ( + <div className="flex gap-2 mt-1"> + <Badge className={cn('text-xs border', PRIORITY_COLORS[priority])}> + {PRIORITY_LABELS[priority]} + </Badge> + <Badge className={cn('text-xs border', STATUS_COLORS[story!.status])}> + {STATUS_LABELS[story!.status]} + </Badge> + </div> + )} + </DialogHeader> - <form - ref={formRef} - id="story-form" - key={isEdit ? story!.id : 'create'} - action={isEdit ? updateAction : createAction} - onChange={() => setDirty(true)} - className="flex-1 overflow-y-auto" - > - {isEdit && <input type="hidden" name="id" value={story!.id} />} - {!isEdit && ( - <> - <input type="hidden" name="pbiId" value={createState_?.pbiId ?? ''} /> - <input type="hidden" name="productId" value={createState_?.productId ?? ''} /> - </> - )} - <input type="hidden" name="priority" value={priority} /> + <form + key={isEdit ? story!.id : 'create'} + action={isEdit ? updateAction : createAction} + className="flex flex-col min-h-0 flex-1" + > + {isEdit && <input type="hidden" name="id" value={story!.id} />} + {!isEdit && ( + <> + <input type="hidden" name="pbiId" value={createState_?.pbiId ?? ''} /> + <input type="hidden" name="productId" value={createState_?.productId ?? ''} /> + </> + )} + <input type="hidden" name="priority" value={priority} /> + <div className="flex-1 overflow-y-auto"> {showForm ? ( - <div className="px-6 py-6 space-y-6"> + <div className="p-5 space-y-4"> <div className="grid grid-cols-[6rem_1fr] gap-3"> <div className="space-y-1.5"> - <label htmlFor="story-code" className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Code</label> + <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Code</label> <Input - id="story-code" name="code" defaultValue={story?.code ?? ''} placeholder={isEdit ? '' : 'auto'} maxLength={30} - disabled={isDemo} - aria-invalid={!!fieldError('code')} className={cn('font-mono text-sm', fieldError('code') ? 'border-error' : '')} /> {fieldError('code') && <p className="text-xs text-error">{fieldError('code')}</p>} </div> <div className="space-y-1.5"> - <label htmlFor="story-title" className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> - Titel <span className="text-error">*</span> - </label> + <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Titel</label> <Input - id="story-title" ref={titleRef} name="title" defaultValue={story?.title ?? ''} required maxLength={200} - disabled={isDemo} - aria-invalid={!!fieldError('title')} className={fieldError('title') ? 'border-error' : ''} - {...debugProps('story-dialog__title')} /> {fieldError('title') && <p className="text-xs text-error">{fieldError('title')}</p>} </div> </div> <div className="space-y-1.5"> - <span id="story-priority-label" className="block text-xs font-medium text-muted-foreground uppercase tracking-wide">Prioriteit</span> - <PrioritySelect value={priority} onChange={(v) => { setPriority(v); setDirty(true) }} /> + <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">Prioriteit</label> + <PrioritySelect value={priority} onChange={setPriority} /> </div> <div className="space-y-1.5"> - <label htmlFor="story-description" className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> + <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> Omschrijving <span className="normal-case font-normal">(optioneel)</span> </label> <Textarea - id="story-description" name="description" rows={3} defaultValue={story?.description ?? ''} placeholder="Als… wil ik… zodat…" - disabled={isDemo} className="resize-none" /> </div> <div className="space-y-1.5"> - <label htmlFor="story-acceptance" className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> + <label className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> Acceptatiecriteria <span className="normal-case font-normal">(optioneel)</span> </label> <Textarea - id="story-acceptance" name="acceptance_criteria" rows={3} defaultValue={story?.acceptance_criteria ?? ''} placeholder="- Gegeven… Als… Dan…" - disabled={isDemo} className="resize-none" /> </div> + + {typeof (isEdit ? updateResult?.error : createResult?.error) === 'string' && ( + <p className="text-xs text-error"> + {String(isEdit ? updateResult?.error : createResult?.error)} + </p> + )} </div> ) : ( - <div className="px-6 py-6 space-y-6"> + <div className="p-5 space-y-4"> {story?.description && ( <div> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Omschrijving</p> @@ -280,7 +245,7 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps )} {isEdit && ( - <div className="px-6 py-4 border-t border-outline-variant"> + <div className="px-5 py-4 border-t border-border"> <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Activiteitenlog</p> {logs && 'logs' in logs && logs.logs ? ( <StoryLog @@ -297,59 +262,49 @@ export function StoryDialog({ state, onClose, isDemo = false }: StoryDialogProps )} </div> )} - </form> + </div> - <div className={entityDialogFooterClasses}> - <div className="flex items-center justify-between gap-2"> - {isEdit ? ( + {isEdit && ( + <div className="px-5 py-3 border-t border-border shrink-0"> + {!isDemo && confirmDelete ? ( + <div className="flex items-center gap-2"> + <span className="text-xs text-muted-foreground flex-1"> + Weet je het zeker? Taken worden ook verwijderd. + </span> + <Button type="button" variant="destructive" size="sm" disabled={isDeleting} onClick={handleDelete}> + {isDeleting ? 'Bezig…' : 'Verwijderen'} + </Button> + <Button type="button" variant="ghost" size="sm" onClick={() => setConfirmDelete(false)}> + Annuleren + </Button> + </div> + ) : ( <DemoTooltip show={isDemo}> <Button type="button" - variant="destructive" - disabled={isDemo || isDeleting || pending} - onClick={() => setConfirmDelete(true)} + variant="ghost" + size="sm" + className="text-error hover:bg-error/10" + disabled={isDemo} + onClick={() => !isDemo && setConfirmDelete(true)} > - Verwijderen + Story verwijderen </Button> </DemoTooltip> - ) : ( - <div /> )} - <div className="flex gap-2"> - <Button type="button" variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}> - Annuleren - </Button> - <DemoTooltip show={isDemo}> - <Button type="submit" form="story-form" disabled={pending || isDemo} {...debugProps('story-dialog__submit')}> - {pending ? '…' : isEdit ? 'Opslaan' : 'Aanmaken'} - </Button> - </DemoTooltip> - </div> </div> - </div> - </DialogContent> - </Dialog> + )} - <DirtyCloseGuardDialog guard={closeGuard} /> - - <AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}> - <AlertDialogContent size="sm"> - <AlertDialogHeader> - <AlertDialogTitle>Story verwijderen</AlertDialogTitle> - <AlertDialogDescription> - Weet je het zeker? Bijbehorende taken worden ook verwijderd. Dit kan niet ongedaan worden. - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel onClick={() => setConfirmDelete(false)}> + <div className="flex justify-end gap-2 px-5 py-4 border-t border-border shrink-0 rounded-b-xl bg-muted/50"> + <DialogClose render={<Button type="button" variant="outline" />}> Annuleren - </AlertDialogCancel> - <AlertDialogAction variant="destructive" disabled={isDeleting} onClick={handleDelete}> - {isDeleting ? 'Bezig…' : 'Verwijderen'} - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - </> + </DialogClose> + <DemoTooltip show={isDemo}> + <SubmitButton label={isEdit ? 'Opslaan' : 'Aanmaken'} disabled={isDemo} /> + </DemoTooltip> + </div> + </form> + </DialogContent> + </Dialog> ) } diff --git a/components/backlog/story-panel.tsx b/components/backlog/story-panel.tsx index 54e56db..c39b858 100644 --- a/components/backlog/story-panel.tsx +++ b/components/backlog/story-panel.tsx @@ -1,27 +1,35 @@ 'use client' -import { useState } from 'react' -import { CheckSquare, Square } from 'lucide-react' +import { useState, useTransition, useEffect } from 'react' import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip' + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + closestCenter, +} from '@dnd-kit/core' +import { + SortableContext, + useSortable, + rectSortingStrategy, + arrayMove, + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { PanelNavBar } from '@/components/shared/panel-nav-bar' -import { useShallow } from 'zustand/react/shallow' -import { useUserSettingsStore } from '@/stores/user-settings/store' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import { - selectStoriesForActivePbi, - selectStoryIsBlocked, -} from '@/stores/product-workspace/selectors' -import type { BacklogStory as WorkspaceStory } from '@/stores/product-workspace/types' +import { useSelectionStore } from '@/stores/selection-store' +import { usePlannerStore } from '@/stores/planner-store' +import { useBacklogStore } from '@/stores/backlog-store' +import { reorderStoriesAction } from '@/actions/stories' import { StoryDialog, type StoryDialogState } from './story-dialog' -import { debugProps } from '@/lib/debug' import { BacklogCard } from './backlog-card' import { EmptyPanel } from './empty-panel' import { DemoTooltip } from '@/components/shared/demo-tooltip' @@ -46,41 +54,48 @@ export interface Story { description: string | null acceptance_criteria: string | null priority: number - sort_order: number status: string pbi_id: string - sprint_id: string | null created_at: Date } interface StoryPanelProps { productId: string isDemo: boolean - activeSprintId?: string | null } -function StoryBlock({ +// --- Sortable story block --- +function SortableStoryBlock({ story, isSelected, - cherrypick, onSelect, onEdit, }: { story: Story isSelected: boolean - cherrypick: { - checked: boolean - blocked: { sprintName: string } | null - onToggle: () => void - } | null onSelect: () => void onEdit: () => void }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: story.id, + }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.4 : 1, + } + return ( <BacklogCard + ref={setNodeRef} + style={style} + {...attributes} + {...listeners} title={story.title} code={story.code} priority={story.priority} + isDragging={isDragging} isSelected={isSelected} onClick={onSelect} badge={ @@ -89,90 +104,54 @@ function StoryBlock({ </Badge> } actions={ - <div className="flex items-center gap-1"> - {cherrypick && <StoryCherrypickButton {...cherrypick} />} - <button - onClick={(e) => { e.stopPropagation(); onEdit() }} - className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-foreground rounded transition-colors" - aria-label="Story bewerken" - > - <svg className="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> - <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /> - <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /> - </svg> - </button> - </div> + <button + onClick={(e) => { e.stopPropagation(); onEdit() }} + className="text-muted-foreground hover:text-foreground p-0.5 rounded transition-colors" + aria-label="Story bewerken" + > + <svg className="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> + <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /> + <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /> + </svg> + </button> } /> ) } -function StoryCherrypickButton({ - checked, - blocked, - onToggle, -}: { - checked: boolean - blocked: { sprintName: string } | null - onToggle: () => void -}) { - const icon = checked ? ( - <CheckSquare size={16} className="text-primary" /> - ) : ( - <Square size={16} /> - ) - if (blocked) { - return ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger - data-disabled="true" - aria-disabled="true" - onClick={(e) => e.stopPropagation()} - className="inline-flex items-center justify-center min-h-7 min-w-7 rounded opacity-40 cursor-not-allowed text-muted-foreground" - > - {icon} - </TooltipTrigger> - <TooltipContent>Zit in sprint {blocked.sprintName}</TooltipContent> - </Tooltip> - </TooltipProvider> - ) - } - return ( - <button - onClick={(e) => { - e.stopPropagation() - onToggle() - }} - aria-pressed={checked} - aria-label={ - checked ? 'Story uit sprint halen' : 'Story aan sprint toevoegen' - } - className={cn( - 'inline-flex items-center justify-center min-h-7 min-w-7 rounded transition-colors', - 'text-muted-foreground hover:text-foreground', - )} - > - {icon} - </button> - ) -} - // --- Main component --- -export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPanelProps) { - const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId) - const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId) - const rawStories = useProductWorkspaceStore(useShallow(selectStoriesForActivePbi)) as WorkspaceStory[] +export function StoryPanel({ productId, isDemo }: StoryPanelProps) { + const { selectedPbiId, selectedStoryId, selectStory } = useSelectionStore() + const storiesByPbi = useBacklogStore((s) => s.storiesByPbi) + const { storyOrder, initStories, reorderStories, rollbackStories } = usePlannerStore() const [filterStatus, setFilterStatus] = useState<string | null>(null) const [filterPriority, setFilterPriority] = useState<number | null>(null) - const sortMode: SortMode = useUserSettingsStore( - (s) => s.entities.settings.views?.storyPanel?.sort ?? 'priority', - ) - const setPref = useUserSettingsStore((s) => s.setPref) - const setSortMode = (v: SortMode) => void setPref(['views', 'storyPanel', 'sort'], v) + const [sortMode, setSortMode] = useState<SortMode>(() => { + const saved = typeof window !== 'undefined' ? localStorage.getItem('scrum4me:story_sort') : null + return (saved === 'priority' || saved === 'code' || saved === 'date') ? saved : 'priority' + }) const [storyDialogState, setStoryDialogState] = useState<StoryDialogState | null>(null) + const [activeDragId, setActiveDragId] = useState<string | null>(null) + const [, startTransition] = useTransition() - const base = rawStories + useEffect(() => { localStorage.setItem('scrum4me:story_sort', sortMode) }, [sortMode]) + + const rawStories = selectedPbiId ? (storiesByPbi[selectedPbiId] ?? []) : [] + + // Sync into store — use stable string dep to avoid infinite loop + const storyIdKey = rawStories.map(s => s.id).join(',') + useEffect(() => { + if (selectedPbiId) { + initStories(selectedPbiId, storyIdKey ? storyIdKey.split(',') : []) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedPbiId, storyIdKey]) + + const storyMap = Object.fromEntries(rawStories.map(s => [s.id, s])) + const order = (selectedPbiId ? storyOrder[selectedPbiId] : null) ?? rawStories.map(s => s.id) + const orderedStories = order.map(id => storyMap[id]).filter(Boolean) + + const base = orderedStories .filter(s => !filterStatus || s.status === filterStatus) .filter(s => !filterPriority || s.priority === filterPriority) @@ -186,10 +165,51 @@ export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPa return a.priority !== b.priority ? a.priority - b.priority : 0 }) + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ) + + function handleDragStart(event: DragStartEvent) { + setActiveDragId(event.active.id as string) + } + + function handleDragEnd(event: DragEndEvent) { + setActiveDragId(null) + const { active, over } = event + if (!over || active.id === over.id || !selectedPbiId) return + + const activeStory = storyMap[active.id as string] + const overStory = storyMap[over.id as string] + if (!activeStory || !overStory) return + + const prevOrder = [...order] + const oldIndex = order.indexOf(active.id as string) + const newIndex = order.indexOf(over.id as string) + const newOrder = arrayMove([...order], oldIndex, newIndex) + + reorderStories(selectedPbiId, newOrder) + + const priorityChanged = activeStory.priority !== overStory.priority + + startTransition(async () => { + const result = await reorderStoriesAction( + selectedPbiId, + productId, + newOrder, + priorityChanged ? overStory.priority : undefined + ) + if (!result.success) { + rollbackStories(selectedPbiId, prevOrder) + toast.error('Volgorde opslaan mislukt') + } + }) + } + const hasActiveFilters = filterStatus !== null || filterPriority !== null return ( - <div className="flex flex-col h-full" {...debugProps('story-panel', 'StoryPanel', 'components/backlog/story-panel.tsx')}> + <div className="flex flex-col h-full"> <PanelNavBar title="Stories" actions={ @@ -239,7 +259,7 @@ export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPa } /> - <div className="flex-1 overflow-y-auto p-4" {...debugProps('story-panel__tasks')}> + <div className="flex-1 overflow-y-auto p-4"> {selectedPbiId === null ? ( <EmptyPanel message="Selecteer een PBI om de stories te bekijken." /> ) : rawStories.length === 0 ? ( @@ -248,19 +268,37 @@ export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPa action={{ label: 'Maak je eerste story aan', onClick: () => setStoryDialogState({ mode: 'create', pbiId: selectedPbiId, productId, defaultPriority: 2 }), disabled: isDemo }} /> ) : ( - <div className="grid grid-cols-3 gap-2"> - {filtered.map(story => ( - <StoryBlockWithCherrypick - key={story.id} - story={story} - productId={productId} - activeSprintId={activeSprintId} - isSelected={selectedStoryId === story.id} - onSelect={() => useProductWorkspaceStore.getState().setActiveStory(story.id)} - onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })} - /> - ))} - </div> + <DndContext + id="story-panel" + sensors={sensors} + collisionDetection={closestCenter} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + > + <SortableContext items={filtered.map(s => s.id)} strategy={rectSortingStrategy}> + <div className="grid grid-cols-3 gap-2"> + {filtered.map(story => ( + <SortableStoryBlock + key={story.id} + story={story} + isSelected={selectedStoryId === story.id} + onSelect={() => selectStory(story.id)} + onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })} + /> + ))} + </div> + </SortableContext> + + <DragOverlay> + {activeDragId && storyMap[activeDragId] && ( + <BacklogCard + title={storyMap[activeDragId].title} + priority={storyMap[activeDragId].priority} + className="border-primary shadow-xl opacity-90" + /> + )} + </DragOverlay> + </DndContext> )} </div> @@ -272,92 +310,3 @@ export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPa </div> ) } - -// PBI-79 / ST-1337: wrapper rond StoryBlock met cherrypick-handling. -function StoryBlockWithCherrypick({ - story, - productId, - activeSprintId, - isSelected, - onSelect, - onEdit, -}: { - story: Story - productId: string - activeSprintId: string | null - isSelected: boolean - onSelect: () => void - onEdit: () => void -}) { - const draft = useUserSettingsStore( - (s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId], - ) - const upsertStoryOverride = useUserSettingsStore((s) => s.upsertStoryOverride) - const toggleStorySprintMembership = useProductWorkspaceStore( - (s) => s.toggleStorySprintMembership, - ) - const pending = useProductWorkspaceStore((s) => s.sprintMembership.pending) - const blocked = useProductWorkspaceStore((s) => - selectStoryIsBlocked(s, story.id), - ) - - let cherrypick: { - checked: boolean - blocked: { sprintName: string } | null - onToggle: () => void - } | null = null - - if (draft) { - const intent = draft.pbiIntent[story.pbi_id] ?? 'none' - const override = draft.storyOverrides[story.pbi_id] ?? { - add: [], - remove: [], - } - const checked = - (intent === 'all' && !override.remove.includes(story.id)) || - override.add.includes(story.id) - cherrypick = { - checked, - blocked: blocked ? { sprintName: blocked.sprintName } : null, - onToggle: () => { - if (intent === 'all') { - void upsertStoryOverride( - productId, - story.pbi_id, - story.id, - checked ? 'remove' : 'clear', - ) - } else { - void upsertStoryOverride( - productId, - story.pbi_id, - story.id, - checked ? 'clear' : 'add', - ) - } - }, - } - } else if (activeSprintId) { - const inSprintDb = story.sprint_id === activeSprintId - const inAdds = pending.adds.includes(story.id) - const inRemoves = pending.removes.includes(story.id) - const checked = inAdds || (inSprintDb && !inRemoves) - cherrypick = { - checked, - blocked: blocked ? { sprintName: blocked.sprintName } : null, - onToggle: () => { - toggleStorySprintMembership(story.id, inSprintDb) - }, - } - } - - return ( - <StoryBlock - story={story} - isSelected={isSelected} - cherrypick={cherrypick} - onSelect={onSelect} - onEdit={onEdit} - /> - ) -} diff --git a/components/backlog/task-panel.tsx b/components/backlog/task-panel.tsx index 6905dd6..c3d7526 100644 --- a/components/backlog/task-panel.tsx +++ b/components/backlog/task-panel.tsx @@ -1,19 +1,35 @@ 'use client' +import { useState, useTransition } from 'react' import { useRouter } from 'next/navigation' +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + closestCenter, +} from '@dnd-kit/core' +import { + SortableContext, + useSortable, + rectSortingStrategy, + arrayMove, + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { toast } from 'sonner' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { useShallow } from 'zustand/react/shallow' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import { selectTasksForActiveStory } from '@/stores/product-workspace/selectors' -import type { - BacklogTask, - TaskDetail, -} from '@/stores/product-workspace/types' +import { useSelectionStore } from '@/stores/selection-store' +import { useBacklogStore, type BacklogTask } from '@/stores/backlog-store' +import { reorderTasksAction } from '@/actions/tasks' import { BacklogCard } from './backlog-card' -import { debugProps } from '@/lib/debug' import { EmptyPanel } from './empty-panel' import { cn } from '@/lib/utils' @@ -30,18 +46,32 @@ const STATUS_LABELS: Record<string, string> = { DONE: 'Klaar', } -function TaskCard({ +function SortableTaskCard({ task, + isDemo, onClick, }: { - task: BacklogTask | TaskDetail + task: BacklogTask + isDemo: boolean onClick: () => void }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: task.id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + } + return ( <BacklogCard + ref={setNodeRef} + style={style} + {...attributes} + {...(isDemo ? {} : listeners)} title={task.title} priority={task.priority} - code={task.code} + isDragging={isDragging} onClick={onClick} badge={ <Badge @@ -63,16 +93,54 @@ interface TaskPanelProps { closePath: string } -// PBI-74 / T-851: leest tasks voor active story via selectTasksForActiveStory. export function TaskPanel({ isDemo, closePath }: TaskPanelProps) { const router = useRouter() - const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId) - const rawTasks = useProductWorkspaceStore(useShallow(selectTasksForActiveStory)) as - | (BacklogTask | TaskDetail)[] + const [, startTransition] = useTransition() + const selectedStoryId = useSelectionStore((s) => s.selectedStoryId) + const tasksByStory = useBacklogStore((s) => s.tasksByStory) + const [activeDragId, setActiveDragId] = useState<string | null>(null) + const [localOrder, setLocalOrder] = useState<string[] | null>(null) - const tasks: (BacklogTask | TaskDetail)[] | null = selectedStoryId - ? rawTasks - : null + const rawTasks = selectedStoryId ? (tasksByStory[selectedStoryId] ?? []) : null + + // Merge local order with rawTasks for optimistic reorder + const tasks: BacklogTask[] | null = rawTasks === null + ? null + : localOrder + ? localOrder.map((id) => rawTasks.find((t) => t.id === id)).filter(Boolean) as BacklogTask[] + : rawTasks + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ) + + function handleDragStart(event: DragStartEvent) { + setActiveDragId(event.active.id as string) + } + + function handleDragEnd(event: DragEndEvent) { + setActiveDragId(null) + if (!selectedStoryId || !tasks) return + const { active, over } = event + if (!over || active.id === over.id) return + + const ids = tasks.map((t) => t.id) + const oldIndex = ids.indexOf(active.id as string) + const newIndex = ids.indexOf(over.id as string) + if (oldIndex === -1 || newIndex === -1) return + + const newOrder = arrayMove(ids, oldIndex, newIndex) + setLocalOrder(newOrder) + + startTransition(async () => { + const result = await reorderTasksAction(selectedStoryId, newOrder) + if (result?.error) { + setLocalOrder(null) + toast.error(result.error) + } + }) + } const navActions = ( <DemoTooltip show={isDemo}> @@ -84,18 +152,15 @@ export function TaskPanel({ isDemo, closePath }: TaskPanelProps) { if (!selectedStoryId) return router.push(`${closePath}?newTask=1&storyId=${selectedStoryId}`) }} - {...debugProps('task-panel__actions')} > + Nieuwe taak </Button> </DemoTooltip> ) - const dp = debugProps('task-panel', 'TaskPanel', 'components/backlog/task-panel.tsx') - if (tasks === null) { return ( - <div className="flex flex-col h-full" {...dp}> + <div className="flex flex-col h-full"> <PanelNavBar title="Taken" actions={navActions} /> <EmptyPanel message="Selecteer een story om de taken te bekijken." /> </div> @@ -104,7 +169,7 @@ export function TaskPanel({ isDemo, closePath }: TaskPanelProps) { if (tasks.length === 0) { return ( - <div className="flex flex-col h-full" {...dp}> + <div className="flex flex-col h-full"> <PanelNavBar title="Taken" actions={navActions} /> <EmptyPanel message="Nog geen taken voor deze story." @@ -118,19 +183,42 @@ export function TaskPanel({ isDemo, closePath }: TaskPanelProps) { ) } + const activeTask = activeDragId ? tasks.find((t) => t.id === activeDragId) : null + return ( - <div className="flex flex-col h-full" {...dp}> + <div className="flex flex-col h-full"> <PanelNavBar title="Taken" actions={navActions} /> <div className="flex-1 overflow-y-auto p-3"> - <div className="grid grid-cols-2 gap-2"> - {tasks.map((task) => ( - <TaskCard - key={task.id} - task={task} - onClick={() => router.push(`${closePath}?editTask=${task.id}`)} - /> - ))} - </div> + <DndContext + id="task-panel" + sensors={sensors} + collisionDetection={closestCenter} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + > + <SortableContext items={tasks.map((t) => t.id)} strategy={rectSortingStrategy}> + <div className="grid grid-cols-2 gap-2"> + {tasks.map((task) => ( + <SortableTaskCard + key={task.id} + task={task} + isDemo={isDemo} + onClick={() => router.push(`${closePath}?editTask=${task.id}`)} + /> + ))} + </div> + </SortableContext> + + <DragOverlay> + {activeTask && ( + <BacklogCard + title={activeTask.title} + priority={activeTask.priority} + className="border-primary shadow-xl opacity-90" + /> + )} + </DragOverlay> + </DndContext> </div> </div> ) diff --git a/components/backlog/url-task-sync.tsx b/components/backlog/url-task-sync.tsx deleted file mode 100644 index 70e4c76..0000000 --- a/components/backlog/url-task-sync.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'use client' - -// PBI-74 / T-859: URL-prioriteit boven restore-hint. -// -// Als de route `?editTask=<id>` draagt, wint dat boven de localStorage-hint -// die de restore-flow normaal zou toepassen. We schrijven de URL-id direct -// naar de task-hint en roepen setActiveTask aan; de restore-flow leest de -// task-hint pas na drie ensure*Loaded-awaits, dus onze schrijfactie wint -// in de praktijk altijd. - -import { useEffect } from 'react' -import { useSearchParams } from 'next/navigation' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import { writeTaskHint } from '@/stores/product-workspace/restore' - -export function UrlTaskSync() { - const searchParams = useSearchParams() - const editTask = searchParams.get('editTask') - - useEffect(() => { - if (!editTask) return - const productId = useProductWorkspaceStore.getState().context.activeProduct?.id - if (productId) { - // Hint overschrijven zodat restore-flow's setActiveTask op deze id eindigt - // (mocht hij na onze directe call komen). - writeTaskHint(productId, editTask) - } - useProductWorkspaceStore.getState().setActiveTask(editTask) - }, [editTask]) - - return null -} diff --git a/components/dashboard/new-product-button.tsx b/components/dashboard/new-product-button.tsx deleted file mode 100644 index 86230ff..0000000 --- a/components/dashboard/new-product-button.tsx +++ /dev/null @@ -1,26 +0,0 @@ -'use client' - -import { useState } from 'react' -import { useRouter } from 'next/navigation' -import { Button } from '@/components/ui/button' -import { ProductDialog } from '@/components/dialogs/product-dialog' -import { debugProps } from '@/lib/debug' - -export function NewProductButton() { - const [open, setOpen] = useState(false) - const router = useRouter() - - return ( - <> - <div {...debugProps('new-product-button', 'NewProductButton', 'components/dashboard/new-product-button.tsx')}> - <Button onClick={() => setOpen(true)} data-debug-id="new-product-button__trigger">+ Nieuw product</Button> - </div> - <ProductDialog - mode="create" - open={open} - onOpenChange={setOpen} - onSaved={(id) => router.push(`/products/${id}`)} - /> - </> - ) -} diff --git a/components/dashboard/product-list.tsx b/components/dashboard/product-list.tsx index 8968144..d01f8ce 100644 --- a/components/dashboard/product-list.tsx +++ b/components/dashboard/product-list.tsx @@ -2,8 +2,7 @@ import Link from 'next/link' import { useRouter } from 'next/navigation' -import { useState, useTransition } from 'react' -import { Pencil } from 'lucide-react' +import { useTransition } from 'react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' @@ -11,8 +10,6 @@ import { CodeBadge } from '@/components/shared/code-badge' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { restoreProductAction } from '@/actions/products' import { setActiveProductAction } from '@/actions/active-product' -import { ProductDialog, type ProductDialogProduct } from '@/components/dialogs/product-dialog' -import { debugProps } from '@/lib/debug' interface Product { id: string @@ -20,8 +17,6 @@ interface Product { code: string | null description: string | null repo_url: string | null - definition_of_done: string | null - auto_pr: boolean } interface ProductListProps { @@ -34,7 +29,6 @@ interface ProductListProps { export function ProductList({ products, isDemo, showArchived = false, activeProductId }: ProductListProps) { const router = useRouter() const [, startTransition] = useTransition() - const [editingProduct, setEditingProduct] = useState<ProductDialogProduct | null>(null) function handleRestore(id: string) { startTransition(async () => { @@ -54,7 +48,7 @@ export function ProductList({ products, isDemo, showArchived = false, activeProd if (products.length === 0) { return ( - <div className="bg-surface-container-low rounded-xl border border-border p-12 text-center space-y-3" {...debugProps('product-list', 'ProductList', 'components/dashboard/product-list.tsx')}> + <div className="bg-surface-container-low rounded-xl border border-border p-12 text-center space-y-3"> <p className="text-muted-foreground"> {showArchived ? 'Geen gearchiveerde producten.' @@ -70,7 +64,7 @@ export function ProductList({ products, isDemo, showArchived = false, activeProd } return ( - <div className="grid gap-3" {...debugProps('product-list', 'ProductList', 'components/dashboard/product-list.tsx')}> + <div className="grid gap-3"> {products.map(product => ( <div key={product.id} @@ -78,9 +72,8 @@ export function ProductList({ products, isDemo, showArchived = false, activeProd className={`group bg-surface-container-low border border-border rounded-xl p-4 transition-colors ${ showArchived ? 'opacity-60' : 'cursor-pointer hover:border-primary' }`} - data-debug-id="product-list__items" > - <div className="flex items-start justify-between gap-4" data-debug-id="product-list__header"> + <div className="flex items-start justify-between gap-4"> <div className="min-w-0"> <div className="flex items-center gap-2"> {product.code && <CodeBadge code={product.code} />} @@ -107,32 +100,19 @@ export function ProductList({ products, isDemo, showArchived = false, activeProd </a> )} {!showArchived && ( - <> - <DemoTooltip show={isDemo}> - <button - onClick={(e) => { e.stopPropagation(); if (!isDemo) setEditingProduct(product) }} - className="opacity-0 group-hover:opacity-100 inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-foreground rounded disabled:opacity-40 disabled:cursor-not-allowed" - aria-label="Bewerk product" - disabled={isDemo} - > - <Pencil size={14} /> - </button> - </DemoTooltip> - {product.id === activeProductId - ? <Badge className="bg-primary-container text-primary-container-foreground text-xs px-2 py-0">Actief</Badge> - : ( - <DemoTooltip show={isDemo}> - <button - onClick={(e) => { e.stopPropagation(); if (!isDemo) handleActivate(product.id) }} - className="text-xs text-primary hover:underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline" - disabled={isDemo} - > - Activeer - </button> - </DemoTooltip> - ) - } - </> + product.id === activeProductId + ? <Badge className="bg-primary-container text-primary-container-foreground text-xs px-2 py-0">Actief</Badge> + : ( + <DemoTooltip show={isDemo}> + <button + onClick={(e) => { e.stopPropagation(); if (!isDemo) handleActivate(product.id) }} + className="text-xs text-primary hover:underline disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline" + disabled={isDemo} + > + Activeer + </button> + </DemoTooltip> + ) )} {showArchived && ( <DemoTooltip show={isDemo}> @@ -149,15 +129,6 @@ export function ProductList({ products, isDemo, showArchived = false, activeProd </div> </div> ))} - {editingProduct && ( - <ProductDialog - mode="edit" - open={!!editingProduct} - onOpenChange={(v) => { if (!v) setEditingProduct(null) }} - product={editingProduct} - isDemo={isDemo} - /> - )} </div> ) } diff --git a/components/dialogs/product-dialog.tsx b/components/dialogs/product-dialog.tsx deleted file mode 100644 index 20eeaea..0000000 --- a/components/dialogs/product-dialog.tsx +++ /dev/null @@ -1,311 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { useForm, useWatch } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { toast } from 'sonner' -import { cn } from '@/lib/utils' -import { - Dialog, - DialogContent, - DialogTitle, -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Textarea } from '@/components/ui/textarea' -import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { - useDirtyCloseGuard, - DirtyCloseGuardDialog, -} from '@/components/shared/use-dirty-close-guard' -import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut' -import { - entityDialogBodyClasses, - entityDialogContentClasses, - entityDialogFooterClasses, - entityDialogHeaderClasses, -} from '@/components/shared/entity-dialog-layout' -import { productSchema, type ProductInput } from '@/lib/schemas/product' -import { createProductAction, updateProductAction } from '@/actions/products' -import { useProductsStore } from '@/stores/products-store' -import { debugProps } from '@/lib/debug' - -export interface ProductDialogProduct { - id: string - name: string - code?: string | null - description?: string | null - repo_url?: string | null - definition_of_done?: string | null - auto_pr?: boolean -} - -type Props = - | { mode: 'create'; open: boolean; onOpenChange: (v: boolean) => void; onSaved?: (id: string) => void; isDemo?: boolean } - | { mode: 'edit'; open: boolean; onOpenChange: (v: boolean) => void; product: ProductDialogProduct; onSaved?: (id: string) => void; isDemo?: boolean } - -export function ProductDialog(props: Props) { - const { mode, open, onOpenChange, isDemo = false } = props - const product = mode === 'edit' ? props.product : null - const addProduct = useProductsStore((s) => s.addProduct) - const updateProduct = useProductsStore((s) => s.updateProduct) - - const [isPending, setIsPending] = useState(false) - - const form = useForm<ProductInput>({ - resolver: zodResolver(productSchema), - mode: 'onTouched', - defaultValues: { - name: product?.name ?? '', - code: product?.code ?? '', - description: product?.description ?? '', - repo_url: product?.repo_url ?? '', - definition_of_done: product?.definition_of_done ?? '', - auto_pr: product?.auto_pr ?? false, - }, - }) - - // Reset when opening or switching product - useEffect(() => { - if (open) { - form.reset({ - name: product?.name ?? '', - code: product?.code ?? '', - description: product?.description ?? '', - repo_url: product?.repo_url ?? '', - definition_of_done: product?.definition_of_done ?? '', - auto_pr: product?.auto_pr ?? false, - }) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, product?.id]) - - const closeGuard = useDirtyCloseGuard(form.formState.isDirty, () => onOpenChange(false)) - const handleKeyDown = useDialogSubmitShortcut(() => form.handleSubmit(onSubmit)()) - - async function onSubmit(values: ProductInput) { - setIsPending(true) - try { - const payload: ProductInput = { - name: values.name, - code: values.code || undefined, - description: values.description || undefined, - repo_url: values.repo_url || null, - definition_of_done: values.definition_of_done || undefined, - auto_pr: values.auto_pr, - } - - function applyError(result: { error: string; code?: number; fieldErrors?: Partial<Record<keyof ProductInput, string[]>> }) { - if (result.code === 422 && result.fieldErrors) { - for (const [field, errors] of Object.entries(result.fieldErrors)) { - if (errors && errors.length > 0) { - form.setError(field as keyof ProductInput, { message: errors[0] }) - } - } - const firstError = Object.keys(result.fieldErrors)[0] as keyof ProductInput | undefined - if (firstError) form.setFocus(firstError) - return - } - toast.error(result.error) - } - - if (mode === 'create') { - const result = await createProductAction(payload) - if ('error' in result) { - applyError(result) - return - } - const productId = result.productId - addProduct({ - id: productId, - name: values.name, - code: values.code ?? null, - description: values.description ?? null, - repo_url: values.repo_url ?? null, - definition_of_done: values.definition_of_done ?? '', - auto_pr: values.auto_pr, - }) - toast.success('Product aangemaakt') - onOpenChange(false) - props.onSaved?.(productId) - } else { - const result = await updateProductAction(product!.id, payload) - if ('error' in result) { - applyError(result) - return - } - updateProduct(product!.id, { - name: values.name, - code: values.code ?? null, - description: values.description ?? null, - repo_url: values.repo_url ?? null, - definition_of_done: values.definition_of_done ?? '', - auto_pr: values.auto_pr, - }) - toast.success('Product opgeslagen') - onOpenChange(false) - props.onSaved?.(product!.id) - } - } finally { - setIsPending(false) - } - } - - const autoPr = useWatch({ control: form.control, name: 'auto_pr' }) - - return ( - <> - <Dialog open={open} onOpenChange={(v) => { if (!v) closeGuard.attemptClose(); else onOpenChange(v) }}> - <DialogContent - showCloseButton={false} - onKeyDown={handleKeyDown} - className={entityDialogContentClasses} - {...debugProps('product-dialog', 'ProductDialog', 'components/dialogs/product-dialog.tsx')} - > - <div className={entityDialogHeaderClasses}> - <DialogTitle className="text-xl font-semibold"> - {mode === 'edit' ? 'Product bewerken' : 'Nieuw product'} - </DialogTitle> - </div> - - <form - id="product-form" - onSubmit={form.handleSubmit(onSubmit)} - className={entityDialogBodyClasses} - data-debug-id="product-dialog__content" - > - <div className="grid gap-1.5"> - <label htmlFor="product-name" className="text-sm font-medium"> - Naam <span className="text-error">*</span> - </label> - <Input - id="product-name" - autoFocus={mode === 'create'} - disabled={isDemo} - maxLength={200} - aria-invalid={!!form.formState.errors.name} - {...form.register('name')} - className={form.formState.errors.name ? 'border-error' : ''} - /> - {form.formState.errors.name && ( - <p className="text-xs text-error">{form.formState.errors.name.message}</p> - )} - </div> - - <div className="grid gap-1.5"> - <label htmlFor="product-code" className="text-sm font-medium"> - Code <span className="text-muted-foreground font-normal">(optioneel)</span> - </label> - <Input - id="product-code" - disabled={isDemo} - maxLength={20} - placeholder="korte slug, bv. SCRUM4ME" - aria-invalid={!!form.formState.errors.code} - className={cn('font-mono text-sm', form.formState.errors.code && 'border-error')} - {...form.register('code')} - /> - {form.formState.errors.code && ( - <p className="text-xs text-error">{form.formState.errors.code.message}</p> - )} - </div> - - <div className="grid gap-1.5"> - <label htmlFor="product-description" className="text-sm font-medium"> - Beschrijving <span className="text-muted-foreground font-normal">(optioneel)</span> - </label> - <Textarea - id="product-description" - disabled={isDemo} - rows={3} - maxLength={4000} - className="resize-none" - {...form.register('description')} - /> - </div> - - <div className="grid gap-1.5"> - <label htmlFor="product-repo-url" className="text-sm font-medium"> - Repository URL <span className="text-muted-foreground font-normal">(optioneel)</span> - </label> - <Input - id="product-repo-url" - disabled={isDemo} - placeholder="https://github.com/owner/repo" - aria-invalid={!!form.formState.errors.repo_url} - {...form.register('repo_url')} - className={form.formState.errors.repo_url ? 'border-error' : ''} - /> - {form.formState.errors.repo_url && ( - <p className="text-xs text-error">{form.formState.errors.repo_url.message}</p> - )} - </div> - - <div className="grid gap-1.5"> - <label htmlFor="product-dod" className="text-sm font-medium"> - Definition of Done <span className="text-muted-foreground font-normal">(optioneel)</span> - </label> - <Textarea - id="product-dod" - disabled={isDemo} - rows={4} - maxLength={4000} - className="resize-none" - {...form.register('definition_of_done')} - /> - </div> - - <div className="flex items-start gap-3"> - <button - type="button" - role="switch" - aria-checked={autoPr} - disabled={isDemo} - onClick={() => form.setValue('auto_pr', !autoPr, { shouldDirty: true })} - className={cn( - 'relative mt-0.5 inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent', - 'transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', - 'disabled:cursor-not-allowed disabled:opacity-50', - autoPr ? 'bg-primary' : 'bg-input', - )} - > - <span - className={cn( - 'pointer-events-none inline-block h-4 w-4 rounded-full bg-background shadow-sm', - 'transition-transform duration-200', - autoPr ? 'translate-x-4' : 'translate-x-0', - )} - /> - </button> - <div className="grid gap-0.5"> - <span className="text-sm font-medium">Automatisch PR aanmaken na voltooide story</span> - <span className="text-xs text-muted-foreground"> - Bij elke voltooide story automatisch een PR aanmaken in <code>repo_url</code> - </span> - </div> - </div> - </form> - - <div className={entityDialogFooterClasses}> - <div className="flex items-center justify-end gap-2"> - <Button - type="button" - variant="ghost" - onClick={closeGuard.attemptClose} - disabled={isPending} - > - Annuleren - </Button> - <DemoTooltip show={isDemo}> - <Button type="submit" form="product-form" disabled={isPending || isDemo} data-debug-id="product-dialog__submit"> - {isPending ? '…' : mode === 'edit' ? 'Opslaan' : 'Aanmaken'} - </Button> - </DemoTooltip> - </div> - </div> - </DialogContent> - </Dialog> - <DirtyCloseGuardDialog guard={closeGuard} /> - </> - ) -} diff --git a/components/entity-dialog/dirty-close-guard.tsx b/components/entity-dialog/dirty-close-guard.tsx index 6828191..eedd362 100644 --- a/components/entity-dialog/dirty-close-guard.tsx +++ b/components/entity-dialog/dirty-close-guard.tsx @@ -1,7 +1,6 @@ 'use client' import { useState } from 'react' -import { debugProps } from '@/lib/debug' import { AlertDialog, AlertDialogContent, @@ -34,7 +33,7 @@ export function DirtyCloseGuard({ isDirty, onConfirm, children }: DirtyCloseGuar <> {children(attemptClose)} <AlertDialog open={open} onOpenChange={setOpen}> - <AlertDialogContent size="sm" {...debugProps('dirty-close-guard', 'DirtyCloseGuard', 'components/entity-dialog/dirty-close-guard.tsx')}> + <AlertDialogContent size="sm"> <AlertDialogHeader> <AlertDialogTitle>Wijzigingen niet opgeslagen</AlertDialogTitle> <AlertDialogDescription> @@ -42,12 +41,11 @@ export function DirtyCloseGuard({ isDirty, onConfirm, children }: DirtyCloseGuar </AlertDialogDescription> </AlertDialogHeader> <AlertDialogFooter> - <AlertDialogCancel data-debug-id="dirty-close-guard__cancel" onClick={() => setOpen(false)}> + <AlertDialogCancel onClick={() => setOpen(false)}> Blijven </AlertDialogCancel> <AlertDialogAction variant="destructive" - data-debug-id="dirty-close-guard__confirm" onClick={() => { setOpen(false); onConfirm() }} > Weggooien diff --git a/components/ideas/download-md-button.tsx b/components/ideas/download-md-button.tsx deleted file mode 100644 index 8280f25..0000000 --- a/components/ideas/download-md-button.tsx +++ /dev/null @@ -1,57 +0,0 @@ -'use client' - -// DownloadMdButton — download grill_md of plan_md als .md-bestand. -// Demo MAG downloaden (read-only). Server-action returnt md-string; client -// bouwt een Blob + anchor + click(). - -import { useTransition } from 'react' -import { Download } from 'lucide-react' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { debugProps } from '@/lib/debug' -import { downloadIdeaMdAction } from '@/actions/ideas' - -interface Props { - ideaId: string - kind: 'grill' | 'plan' - hasContent: boolean -} - -export function DownloadMdButton({ ideaId, kind, hasContent }: Props) { - const [pending, startTransition] = useTransition() - - function handleClick() { - startTransition(async () => { - const r = await downloadIdeaMdAction(ideaId, kind) - if ('error' in r) { - toast.error(r.error) - return - } - if (!r.data) return - const blob = new Blob([r.data.markdown], { type: 'text/markdown;charset=utf-8' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = r.data.filename - document.body.appendChild(a) - a.click() - a.remove() - URL.revokeObjectURL(url) - }) - } - - return ( - <Button - size="sm" - variant="ghost" - onClick={handleClick} - disabled={pending || !hasContent} - title={hasContent ? `Download ${kind}_md` : 'Geen content'} - {...debugProps('download-md-button', 'DownloadMdButton', 'components/ideas/download-md-button.tsx')} - > - <Download className="size-3.5 mr-1" /> - .md - </Button> - ) -} diff --git a/components/ideas/idea-detail-layout.tsx b/components/ideas/idea-detail-layout.tsx deleted file mode 100644 index 2ef0ab0..0000000 --- a/components/ideas/idea-detail-layout.tsx +++ /dev/null @@ -1,504 +0,0 @@ -'use client' - -// IdeaDetailLayout — top-level container voor /ideas/[id]. -// Bevat: header (titel + status-badge + row-actions), tab-switcher -// (Idee/Grill/Plan/Timeline), en per-tab content. -// -// URL-based tabs (?tab=grill) — bookmarkable + refresh-safe. -// Md-editor (T-511), timeline (T-512), pbi-link-card (T-512) komen later. - -import { useState, useTransition } from 'react' -import { useRouter, useSearchParams } from 'next/navigation' -import Link from 'next/link' -import { ArrowLeft, ExternalLink } from 'lucide-react' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Textarea } from '@/components/ui/textarea' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { getIdeaStatusBadge } from '@/lib/idea-status-colors' -import type { IdeaStatusApi } from '@/lib/idea-status' -import { isIdeaEditable } from '@/lib/idea-status' -import type { IdeaDto } from '@/lib/idea-dto' -import { debugProps } from '@/lib/debug' -import { updateIdeaAction, archiveIdeaAction, updateSecondaryProductsAction } from '@/actions/ideas' -import { IdeaRowActions } from '@/components/ideas/idea-row-actions' -import { IdeaMdEditor } from '@/components/ideas/idea-md-editor' -import { IdeaPbiLinkCard } from '@/components/ideas/idea-pbi-link-card' -import { IdeaTimeline } from '@/components/ideas/idea-timeline' -import { IdeaSyncTab } from '@/components/ideas/idea-sync-tab' -import { DownloadMdButton } from '@/components/ideas/download-md-button' -import { ReviewLogViewer, type ReviewLog } from '@/components/ideas/review-log-viewer' -import type { IdeaSyncData } from '@/app/(app)/ideas/[id]/sync-tab-server' - -const API_TO_DB: Record<IdeaStatusApi, Parameters<typeof getIdeaStatusBadge>[0]> = { - draft: 'DRAFT', - grilling: 'GRILLING', - grill_failed: 'GRILL_FAILED', - grilled: 'GRILLED', - planning: 'PLANNING', - plan_failed: 'PLAN_FAILED', - plan_ready: 'PLAN_READY', - reviewing_plan: 'REVIEWING_PLAN', - plan_review_failed: 'PLAN_REVIEW_FAILED', - plan_reviewed: 'PLAN_REVIEWED', - planned: 'PLANNED', -} - -type TabKey = 'idee' | 'grill' | 'plan' | 'timeline' | 'sync' - -interface IdeaLog { - id: string - type: string - content: string - metadata: unknown - created_at: string -} - -interface IdeaQuestion { - id: string - question: string - options: string[] | null - status: 'open' | 'answered' | 'cancelled' | 'expired' - answer: string | null - created_at: string - expires_at: string -} - -interface ProductOption { - id: string - name: string - repo_url: string | null -} - -export interface IdeaUserQuestionDto { - id: string - question: string - answer: string | null - status: 'pending' | 'answered' - created_at: string -} - -interface Props { - idea: IdeaDto - grill_md: string | null - plan_md: string | null - plan_review_log: ReviewLog | null // From DB JSON field, null if no review has been performed - products: ProductOption[] - logs: IdeaLog[] - questions: IdeaQuestion[] - userQuestions: IdeaUserQuestionDto[] - isDemo: boolean - initialTab: string - syncData: IdeaSyncData | null -} - -export function IdeaDetailLayout({ - idea, - grill_md, - plan_md, - plan_review_log, - products, - logs, - questions, - userQuestions, - isDemo, - initialTab, - syncData, -}: Props) { - const router = useRouter() - const searchParams = useSearchParams() - const [pending, startTransition] = useTransition() - - const showSync = syncData !== null && idea.status === 'planned' - const TAB_KEYS: TabKey[] = showSync - ? ['idee', 'grill', 'plan', 'timeline', 'sync'] - : ['idee', 'grill', 'plan', 'timeline'] - const tab = (TAB_KEYS.includes(initialTab as TabKey) ? initialTab : 'idee') as TabKey - - function setTab(key: TabKey) { - const params = new URLSearchParams(searchParams.toString()) - params.set('tab', key) - router.replace(`/ideas/${idea.id}?${params.toString()}`, { scroll: false }) - } - - function handleArchive() { - if (isDemo) return - if (!confirm('Idee archiveren?')) return - startTransition(async () => { - const r = await archiveIdeaAction(idea.id) - if ('error' in r) { - toast.error(r.error) - return - } - toast.success('Idee gearchiveerd') - router.push('/ideas') - }) - } - - const badge = getIdeaStatusBadge(API_TO_DB[idea.status]) - - return ( - <div className="p-6 max-w-5xl mx-auto w-full space-y-6" {...debugProps('idea-detail-layout', 'IdeaDetailLayout', 'components/ideas/idea-detail-layout.tsx')}> - {/* Breadcrumb / back-link */} - <Link - href="/ideas" - className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground" - > - <ArrowLeft className="size-4" /> - Alle ideas - </Link> - - {/* Header */} - <header className="flex flex-wrap items-start justify-between gap-4" data-debug-id="idea-detail-layout__header"> - <div className="space-y-1"> - <p className="font-mono text-xs text-muted-foreground">{idea.code}</p> - <h1 className="text-2xl font-medium text-foreground">{idea.title}</h1> - <div className="flex items-center gap-2"> - <span className={badge.classes + (badge.pulse ? ' animate-pulse' : '')}> - {badge.label} - </span> - {idea.product ? ( - <Link - href={`/products/${idea.product.id}`} - className="text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1" - > - {idea.product.name} - <ExternalLink className="size-3" /> - </Link> - ) : ( - <span className="text-sm italic text-muted-foreground">geen product</span> - )} - </div> - {idea.secondary_products.length > 0 && ( - <div className="flex flex-wrap gap-1 mt-1"> - {idea.secondary_products.map((sp) => ( - <span - key={sp.id} - className="text-xs bg-muted px-2 py-0.5 rounded-full text-muted-foreground" - > - {sp.product.name} - </span> - ))} - </div> - )} - </div> - <IdeaRowActions idea={idea} isDemo={isDemo} onArchive={handleArchive} /> - </header> - - {/* PBI-link card / Re-link banner bij PLANNED */} - <IdeaPbiLinkCard idea={idea} isDemo={isDemo} /> - - {/* Tab-switcher */} - <nav className="border-b border-input flex gap-1" data-debug-id="idea-detail-layout__main"> - {([ - { key: 'idee' as TabKey, label: 'Idee', disabled: false, hasContent: true }, - { key: 'grill' as TabKey, label: 'Grill', disabled: !grill_md, hasContent: !!grill_md }, - { key: 'plan' as TabKey, label: 'Plan', disabled: !plan_md, hasContent: !!plan_md }, - { key: 'timeline' as TabKey, label: 'Timeline', disabled: false, hasContent: true }, - ...(showSync - ? [{ key: 'sync' as TabKey, label: 'Sync', disabled: false, hasContent: true }] - : []), - ] as const).map((t) => ( - <button - key={t.key} - type="button" - onClick={() => !t.disabled && setTab(t.key)} - disabled={t.disabled} - className={`px-4 py-2 text-sm border-b-2 transition-colors ${ - t.disabled - ? 'border-transparent text-muted-foreground/40 cursor-not-allowed' - : tab === t.key - ? 'border-primary text-foreground' - : 'border-transparent text-muted-foreground hover:text-foreground' - }`} - > - {t.label} - {t.hasContent && !t.disabled && t.key !== 'idee' && t.key !== 'timeline' && ( - <span className="ml-1 text-[10px] text-status-done">●</span> - )} - {t.key === 'timeline' && - (logs.length > 0 || questions.length > 0 || userQuestions.length > 0) ? ( - <span className="ml-1.5 text-xs text-muted-foreground"> - ({logs.length + questions.length + userQuestions.length}) - </span> - ) : null} - </button> - ))} - </nav> - - {/* Tab content */} - {tab === 'idee' && ( - <IdeaFormSection - idea={idea} - products={products} - isDemo={isDemo} - pending={pending} - secondaryProducts={idea.secondary_products} - /> - )} - {tab === 'grill' && ( - <MdSection - kind="grill" - markdown={grill_md} - // M12 grill-keuze 12: grill_md editable in GRILLED + PLAN_READY. - editable={ - !isDemo && (idea.status === 'grilled' || idea.status === 'plan_ready') - } - ideaId={idea.id} - /> - )} - {tab === 'plan' && ( - <div className="space-y-6"> - <MdSection - kind="plan" - markdown={plan_md} - // M12 grill-keuze 12: plan_md editable alleen in PLAN_READY. - editable={!isDemo && idea.status === 'plan_ready'} - ideaId={idea.id} - /> - {plan_review_log && <ReviewLogViewer reviewLog={plan_review_log} />} - </div> - )} - {tab === 'timeline' && ( - <IdeaTimeline - logs={logs} - questions={questions} - userQuestions={userQuestions} - planMd={plan_md} - ideaId={idea.id} - isDemo={isDemo} - /> - )} - {tab === 'sync' && showSync && syncData && <IdeaSyncTab data={syncData} />} - </div> - ) -} - -// --------------------------------------------------------------------------- -// Idee-tab: inline form (geen modal — de detailpagina IS de form). - -interface FormProps { - idea: IdeaDto - products: ProductOption[] - isDemo: boolean - pending: boolean - secondaryProducts: IdeaDto['secondary_products'] -} - -function IdeaFormSection({ idea, products, isDemo, pending, secondaryProducts }: FormProps) { - const router = useRouter() - const editable = - !isDemo && - isIdeaEditable(API_TO_DB[idea.status]) - const [title, setTitle] = useState(idea.title) - const [description, setDescription] = useState(idea.description ?? '') - const [productId, setProductId] = useState(idea.product_id ?? '') - const [selectedSecondary, setSelectedSecondary] = useState<string[]>( - secondaryProducts.map((sp) => sp.product_id), - ) - const [submitting, startSubmit] = useTransition() - - const secondaryDirty = - JSON.stringify([...selectedSecondary].sort()) !== - JSON.stringify(secondaryProducts.map((sp) => sp.product_id).sort()) - - const dirty = - title !== idea.title || - description !== (idea.description ?? '') || - productId !== (idea.product_id ?? '') || - secondaryDirty - - function save() { - startSubmit(async () => { - const r = await updateIdeaAction(idea.id, { - title, - description: description || null, - product_id: productId || null, - }) - if ('error' in r) { - toast.error(r.error) - return - } - if (secondaryDirty) { - const r2 = await updateSecondaryProductsAction(idea.id, selectedSecondary) - if ('error' in r2) { - toast.error(r2.error) - return - } - } - toast.success('Opgeslagen') - router.refresh() - }) - } - - return ( - <div className="space-y-4"> - <div className="space-y-1"> - <label className="text-xs font-medium text-muted-foreground">Titel</label> - <Input - value={title} - onChange={(e) => setTitle(e.target.value)} - disabled={!editable || pending || submitting} - /> - </div> - <div className="space-y-1"> - <label className="text-xs font-medium text-muted-foreground">Beschrijving</label> - <Textarea - value={description} - onChange={(e) => setDescription(e.target.value)} - rows={5} - disabled={!editable || pending || submitting} - placeholder="Korte beschrijving — wordt door Grill Me als startpunt gebruikt." - /> - </div> - <div className="space-y-1"> - <label className="text-xs font-medium text-muted-foreground">Product</label> - <select - value={productId} - onChange={(e) => setProductId(e.target.value)} - disabled={!editable || pending || submitting} - className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" - > - <option value="">Geen product</option> - {products.map((p) => ( - <option key={p.id} value={p.id}> - {p.name} - {p.repo_url ? '' : ' (geen repo — vereist voor Grill/Make Plan)'} - </option> - ))} - </select> - </div> - {products.filter((p) => p.id !== productId).length > 0 && ( - <div className="space-y-1"> - <label className="text-xs font-medium text-muted-foreground">Extra producten</label> - <Popover> - <PopoverTrigger - render={ - <Button - type="button" - variant="outline" - size="sm" - disabled={!editable || pending || submitting} - > - {selectedSecondary.length > 0 - ? `Extra producten (${selectedSecondary.length})` - : 'Extra producten'} - </Button> - } - /> - <PopoverContent align="start" className="w-64 space-y-1"> - {products - .filter((p) => p.id !== productId) - .map((p) => ( - <label key={p.id} className="flex items-center gap-2 text-sm"> - <input - type="checkbox" - checked={selectedSecondary.includes(p.id)} - onChange={(e) => - setSelectedSecondary((prev) => - e.target.checked - ? [...prev, p.id] - : prev.filter((id) => id !== p.id), - ) - } - disabled={!editable || pending || submitting} - /> - {p.name} - </label> - ))} - </PopoverContent> - </Popover> - </div> - )} - - {!editable && ( - <p className="text-xs text-muted-foreground italic"> - Idee is niet bewerkbaar in status {idea.status.toUpperCase()}. - </p> - )} - - {editable && ( - <div className="flex justify-end gap-2 pt-2"> - <Button - variant="outline" - size="sm" - disabled={!dirty || submitting} - onClick={() => { - setTitle(idea.title) - setDescription(idea.description ?? '') - setProductId(idea.product_id ?? '') - setSelectedSecondary(secondaryProducts.map((sp) => sp.product_id)) - }} - > - Reset - </Button> - <Button size="sm" disabled={!dirty || submitting} onClick={save}> - Opslaan - </Button> - </div> - )} - </div> - ) -} - -// --------------------------------------------------------------------------- -// Grill / Plan tab — read-only render. T-511 voegt edit-mode toe. - -interface MdProps { - kind: 'grill' | 'plan' - markdown: string | null - editable: boolean - ideaId: string -} - -function MdSection({ kind, markdown, editable, ideaId }: MdProps) { - const [editing, setEditing] = useState(false) - - if (editing) { - return ( - <IdeaMdEditor - ideaId={ideaId} - kind={kind} - initialValue={markdown ?? ''} - onCancel={() => setEditing(false)} - /> - ) - } - - if (!markdown) { - return ( - <div className="space-y-3 py-6"> - <p className="text-sm text-muted-foreground text-center italic"> - {kind === 'grill' - ? 'Nog geen grill-resultaat. Klik "Grill" in de header om te starten.' - : 'Nog geen plan. Voltooi eerst de grill-fase en klik dan "Plan".'} - </p> - {editable && ( - <div className="flex justify-center"> - <Button size="sm" variant="outline" onClick={() => setEditing(true)}> - Schrijf zelf - </Button> - </div> - )} - </div> - ) - } - - return ( - <div className="space-y-3"> - <div className="flex justify-end gap-2"> - <DownloadMdButton ideaId={ideaId} kind={kind} hasContent={markdown !== null} /> - {editable && ( - <Button size="sm" variant="outline" onClick={() => setEditing(true)}> - Bewerk - </Button> - )} - </div> - <pre className="rounded-md border border-input bg-surface-container p-4 text-sm whitespace-pre-wrap font-mono leading-relaxed overflow-x-auto"> - {markdown} - </pre> - </div> - ) -} diff --git a/components/ideas/idea-list.tsx b/components/ideas/idea-list.tsx deleted file mode 100644 index 546e54d..0000000 --- a/components/ideas/idea-list.tsx +++ /dev/null @@ -1,460 +0,0 @@ -'use client' - -// IdeaList — top-level lijstpagina voor /ideas. -// - Strikt user_id-only data (server haalt al; client filtert binnen die set). -// - Filters: zoeken op titel, product-dropdown, status-multiselect. -// - Klik op rij navigeert naar /ideas/[id]. Acties (Grill / Make Plan / -// Materialiseer) staan in components/ideas/idea-row-actions.tsx (T-508). -// - DemoTooltip rondom muteer-acties; bulk-archive blijft achter feature-flag -// in T-508 en latere stories. - -import { useMemo, useState, useTransition } from 'react' -import { useRouter } from 'next/navigation' -import { Plus, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react' -import { toast } from 'sonner' -import { useShallow } from 'zustand/react/shallow' - -import { useUserSettingsStore } from '@/stores/user-settings/store' -import { IdeasFilterPopover } from '@/components/ideas/ideas-filter-popover' - -import { cn } from '@/lib/utils' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Textarea } from '@/components/ui/textarea' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { getIdeaStatusBadge } from '@/lib/idea-status-colors' -import type { IdeaStatusApi } from '@/lib/idea-status' -import type { IdeaDto } from '@/lib/idea-dto' -import { debugProps } from '@/lib/debug' -import { createIdeaAction, archiveIdeaAction } from '@/actions/ideas' -import { IdeaRowActions } from '@/components/ideas/idea-row-actions' - -// Reverse mapping voor het renderen van de status-badge — DTO bevat lowercase -// API-strings, het badge-helper verwacht DB-enum. -const API_TO_DB: Record<IdeaStatusApi, Parameters<typeof getIdeaStatusBadge>[0]> = { - draft: 'DRAFT', - grilling: 'GRILLING', - grill_failed: 'GRILL_FAILED', - grilled: 'GRILLED', - planning: 'PLANNING', - plan_failed: 'PLAN_FAILED', - plan_ready: 'PLAN_READY', - reviewing_plan: 'REVIEWING_PLAN', - plan_review_failed: 'PLAN_REVIEW_FAILED', - plan_reviewed: 'PLAN_REVIEWED', - planned: 'PLANNED', -} - -interface ProductOption { - id: string - name: string - repo_url: string | null -} - -type SortKey = 'code' | 'title' | 'product' | 'status' - -interface IdeaListProps { - ideas: IdeaDto[] - products: ProductOption[] - isDemo: boolean - activeProductId: string | null -} - -const STATUS_FILTERS: { value: IdeaStatusApi; label: string }[] = [ - { value: 'draft', label: 'Concept' }, - { value: 'grilling', label: 'Grillen' }, - { value: 'grilled', label: 'Gegrilld' }, - { value: 'planning', label: 'Plannen' }, - { value: 'plan_ready', label: 'Plan klaar' }, - { value: 'reviewing_plan', label: 'Plan beoordelen' }, - { value: 'planned', label: 'Gepland' }, - { value: 'grill_failed', label: 'Grill mislukt' }, - { value: 'plan_failed', label: 'Plan mislukt' }, - { value: 'plan_review_failed', label: 'Beoordeling mislukt' }, - { value: 'plan_reviewed', label: 'Plan beoordeeld' }, -] - -const STATUS_SORT_ORDER: Record<IdeaStatusApi, number> = { - draft: 0, grilling: 1, grilled: 2, planning: 3, - plan_ready: 4, reviewing_plan: 5, plan_reviewed: 6, - planned: 7, grill_failed: 8, plan_failed: 9, plan_review_failed: 10, -} - -function SortHeader({ - col, - label, - sortKey, - sortDir, - onSort, -}: { - col: SortKey - label: string - sortKey: SortKey - sortDir: 'asc' | 'desc' - onSort: (col: SortKey) => void -}) { - const active = sortKey === col - const Icon = active - ? sortDir === 'asc' ? ArrowUp : ArrowDown - : ArrowUpDown - return ( - <button - type="button" - onClick={() => onSort(col)} - className={cn( - 'flex items-center gap-1 text-xs font-medium hover:text-foreground transition-colors', - active ? 'text-foreground' : 'text-muted-foreground' - )} - > - {label} - <Icon className="size-3" /> - </button> - ) -} - -export function IdeaList({ ideas, products, isDemo, activeProductId }: IdeaListProps) { - const router = useRouter() - const [isPending, startTransition] = useTransition() - - // Filter state - const [search, setSearch] = useState('') - const [productFilter, setProductFilter] = useState<string>('all') - const filterStatuses = useUserSettingsStore(useShallow( - (s) => s.entities.settings.views?.ideasList?.filterStatuses ?? [])) - const setPref = useUserSettingsStore((s) => s.setPref) - const statusFilter = useMemo(() => new Set(filterStatuses), [filterStatuses]) - const [filterPopoverOpen, setFilterPopoverOpen] = useState(false) - - // Sort state - const [sortKey, setSortKey] = useState<SortKey>('code') - const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc') - - // Create-form state - const [showCreate, setShowCreate] = useState(false) - const [newTitle, setNewTitle] = useState('') - const [newDescription, setNewDescription] = useState('') - const [newProductId, setNewProductId] = useState<string>(activeProductId ?? '') - - // Quick-idea form state - const [showQuick, setShowQuick] = useState(false) - const [quickTitle, setQuickTitle] = useState('') - const [quickDescription, setQuickDescription] = useState('') - - const filtered = useMemo(() => { - const q = search.trim().toLowerCase() - const result = ideas.filter((idea) => { - if (q && !idea.title.toLowerCase().includes(q)) return false - if (productFilter !== 'all') { - if (productFilter === 'none') { - if (idea.product_id !== null) return false - } else { - const matchesPrimary = idea.product_id === productFilter - const matchesSecondary = - idea.secondary_products?.some((sp) => sp.product_id === productFilter) ?? false - if (!matchesPrimary && !matchesSecondary) return false - } - } - if (statusFilter.size > 0 && !statusFilter.has(idea.status)) return false - return true - }) - const dir = sortDir === 'asc' ? 1 : -1 - return [...result].sort((a, b) => { - switch (sortKey) { - case 'code': return dir * a.code.localeCompare(b.code) - case 'title': return dir * a.title.localeCompare(b.title) - case 'product': return dir * (a.product?.name ?? '').localeCompare(b.product?.name ?? '') - case 'status': return dir * a.status.localeCompare(b.status) - } - }) - }, [ideas, search, productFilter, statusFilter, sortKey, sortDir]) - - const sorted = useMemo(() => { - return [...filtered].sort((a, b) => { - let cmp = 0 - if (sortKey === 'code') { - cmp = (a.code ?? '').localeCompare(b.code ?? '', 'nl', { numeric: true }) - } else if (sortKey === 'title') { - cmp = a.title.localeCompare(b.title, 'nl') - } else if (sortKey === 'product') { - const aN = a.product?.name ?? '' - const bN = b.product?.name ?? '' - if (!aN && bN) return sortDir === 'asc' ? 1 : -1 - if (aN && !bN) return sortDir === 'asc' ? -1 : 1 - cmp = aN.localeCompare(bN, 'nl') - } else { - cmp = (STATUS_SORT_ORDER[a.status] ?? 99) - (STATUS_SORT_ORDER[b.status] ?? 99) - } - return sortDir === 'asc' ? cmp : -cmp - }) - }, [filtered, sortKey, sortDir]) - - function handleSort(col: SortKey) { - if (sortKey === col) { - setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')) - } else { - setSortKey(col) - setSortDir('asc') - } - } - - function toggleStatus(s: IdeaStatusApi) { - const next = filterStatuses.includes(s) - ? filterStatuses.filter((v) => v !== s) - : [...filterStatuses, s] - void setPref(['views', 'ideasList', 'filterStatuses'], next) - } - - function clearStatusFilter() { - void setPref(['views', 'ideasList', 'filterStatuses'], []) - } - - function handleCreate() { - if (isDemo) return - const title = newTitle.trim() - if (!title) { - toast.error('Titel is verplicht') - return - } - startTransition(async () => { - const r = await createIdeaAction({ - title, - description: newDescription.trim() || null, - product_id: newProductId || null, - }) - if ('error' in r) { - toast.error(r.error) - return - } - toast.success(`Idee aangemaakt (${r.data?.code})`) - setNewTitle('') - setNewDescription('') - setNewProductId(activeProductId ?? '') - setShowCreate(false) - router.refresh() - }) - } - - function handleQuickCreate() { - if (isDemo) return - const title = quickTitle.trim() - if (!title) { - toast.error('Titel is verplicht') - return - } - startTransition(async () => { - const r = await createIdeaAction({ title, description: quickDescription.trim() || null, product_id: activeProductId }) - if ('error' in r) { - toast.error(r.error) - return - } - toast.success(`Idee aangemaakt (${r.data?.code})`) - setQuickTitle('') - setQuickDescription('') - setShowQuick(false) - router.refresh() - }) - } - - function handleArchive(id: string) { - if (isDemo) return - startTransition(async () => { - const r = await archiveIdeaAction(id) - if ('error' in r) { - toast.error(r.error) - return - } - toast.success('Idee gearchiveerd') - router.refresh() - }) - } - - return ( - <div className="space-y-4" {...debugProps('idea-list', 'IdeaList', 'components/ideas/idea-list.tsx')}> - {/* Top-bar: search + nieuw-knop */} - <div className="flex flex-wrap items-center gap-3" data-debug-id="idea-list__toolbar"> - <Input - value={search} - onChange={(e) => setSearch(e.target.value)} - placeholder="Zoek op titel..." - className="max-w-sm" - /> - <select - value={productFilter} - onChange={(e) => setProductFilter(e.target.value)} - className="h-9 rounded-md border border-input bg-background px-3 text-sm" - > - <option value="all">Alle producten</option> - <option value="none">Geen product</option> - {products.map((p) => ( - <option key={p.id} value={p.id}> - {p.name} - </option> - ))} - </select> - <div className="ml-auto flex items-center gap-2"> - <IdeasFilterPopover - open={filterPopoverOpen} - onOpenChange={setFilterPopoverOpen} - statusOptions={STATUS_FILTERS} - selected={statusFilter} - onToggle={toggleStatus} - onClear={clearStatusFilter} - activeFilterCount={statusFilter.size} - /> - <DemoTooltip show={isDemo}> - <Button - size="sm" - variant="outline" - onClick={() => setShowQuick((v) => !v)} - disabled={isDemo || isPending} - > - <Plus className="size-4 mr-1" /> - Snel idee - </Button> - </DemoTooltip> - <DemoTooltip show={isDemo}> - <Button - size="sm" - onClick={() => setShowCreate((v) => !v)} - disabled={isDemo || isPending} - > - <Plus className="size-4 mr-1" /> - Nieuw idee - </Button> - </DemoTooltip> - </div> - </div> - - {/* Snel idee form — geen product-dropdown */} - {showQuick && ( - <div className="rounded-md border border-input bg-surface-container p-4 space-y-3"> - <Input - placeholder="Titel *" - value={quickTitle} - onChange={(e) => setQuickTitle(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleQuickCreate()} - /> - <Textarea - placeholder="Beschrijving (optioneel)" - value={quickDescription} - onChange={(e) => setQuickDescription(e.target.value)} - rows={2} - /> - <div className="flex gap-2 justify-end"> - <Button size="sm" variant="ghost" onClick={() => setShowQuick(false)}>Annuleer</Button> - <Button size="sm" onClick={handleQuickCreate} disabled={isPending || !quickTitle.trim()}>Opslaan</Button> - </div> - </div> - )} - - {/* Inline create form */} - {showCreate && ( - <div className="rounded-md border border-input bg-surface-container p-4 space-y-3"> - <Input - value={newTitle} - onChange={(e) => setNewTitle(e.target.value)} - placeholder="Titel van het idee..." - disabled={isPending} - /> - <Textarea - value={newDescription} - onChange={(e) => setNewDescription(e.target.value)} - placeholder="Korte beschrijving (optioneel)..." - rows={3} - disabled={isPending} - /> - <select - value={newProductId} - onChange={(e) => setNewProductId(e.target.value)} - className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" - disabled={isPending} - > - <option value="">Geen product (kan later worden gekoppeld)</option> - {products.map((p) => ( - <option key={p.id} value={p.id}> - {p.name} - {p.repo_url ? '' : ' (geen repo)'} - </option> - ))} - </select> - <div className="flex justify-end gap-2"> - <Button - variant="outline" - size="sm" - onClick={() => setShowCreate(false)} - disabled={isPending} - > - Annuleer - </Button> - <Button size="sm" onClick={handleCreate} disabled={isPending || !newTitle.trim()}> - Aanmaken - </Button> - </div> - </div> - )} - - {/* Tabel */} - {sorted.length === 0 ? ( - <p className="text-sm text-muted-foreground py-8 text-center"> - {ideas.length === 0 - ? 'Nog geen ideeën — start hierboven met "Nieuw idee".' - : 'Geen ideeën die aan de filters voldoen.'} - </p> - ) : ( - <Table data-debug-id="idea-list__items"> - <TableHeader> - <TableRow> - <TableHead className="w-24"><SortHeader col="code" label="Code" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} /></TableHead> - <TableHead><SortHeader col="title" label="Titel" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} /></TableHead> - <TableHead className="w-40"><SortHeader col="product" label="Product" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} /></TableHead> - <TableHead className="w-32"><SortHeader col="status" label="Status" sortKey={sortKey} sortDir={sortDir} onSort={handleSort} /></TableHead> - <TableHead className="w-72">Acties</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {sorted.map((idea) => { - const badge = getIdeaStatusBadge(API_TO_DB[idea.status]) - return ( - <TableRow - key={idea.id} - className="cursor-pointer hover:bg-muted/50" - onClick={() => router.push(`/ideas/${idea.id}`)} - > - <TableCell className="font-mono text-xs text-muted-foreground"> - {idea.code} - </TableCell> - <TableCell className="font-medium">{idea.title}</TableCell> - <TableCell className="text-sm text-muted-foreground"> - {idea.product?.name ?? <span className="italic">geen</span>} - </TableCell> - <TableCell> - <span - className={badge.classes + (badge.pulse ? ' animate-pulse' : '')} - > - {badge.label} - </span> - </TableCell> - <TableCell onClick={(e) => e.stopPropagation()}> - <IdeaRowActions - idea={idea} - isDemo={isDemo} - onArchive={() => handleArchive(idea.id)} - /> - </TableCell> - </TableRow> - ) - })} - </TableBody> - </Table> - )} - </div> - ) -} diff --git a/components/ideas/idea-md-editor.tsx b/components/ideas/idea-md-editor.tsx deleted file mode 100644 index 44a6368..0000000 --- a/components/ideas/idea-md-editor.tsx +++ /dev/null @@ -1,173 +0,0 @@ -'use client' - -// IdeaMdEditor — bewerk grill_md of plan_md. -// -// - kind='grill': geen yaml-validatie (vrije markdown). -// - kind='plan' : preflight via parsePlanMd (server-side action herhaalt -// validation, dit is alleen UX om eerder te falen). -// -// Save → updateGrillMdAction / updatePlanMdAction. Cmd/Ctrl+S triggert save. -// LocalStorage-backed draft per idea+kind, restore bij heropening. - -import { useEffect, useMemo, useState, useTransition } from 'react' -import { useRouter } from 'next/navigation' -import { Save, X } from 'lucide-react' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { Textarea } from '@/components/ui/textarea' -import { debugProps } from '@/lib/debug' -import { parsePlanMd, type PlanParseError } from '@/lib/idea-plan-parser' -import { updateGrillMdAction, updatePlanMdAction } from '@/actions/ideas' - -type Kind = 'grill' | 'plan' - -interface Props { - ideaId: string - kind: Kind - initialValue: string - onCancel: () => void -} - -// Lazily compute the seed: read draft from localStorage on first render, fall -// back to initialValue. Avoids setState-in-useEffect for hydration. -function readSeed(draftKey: string, initialValue: string): { - value: string - restored: boolean -} { - if (typeof window === 'undefined') return { value: initialValue, restored: false } - const draft = window.localStorage.getItem(draftKey) - if (draft && draft !== initialValue) return { value: draft, restored: true } - return { value: initialValue, restored: false } -} - -export function IdeaMdEditor({ ideaId, kind, initialValue, onCancel }: Props) { - const router = useRouter() - const draftKey = `idea-md-draft-${ideaId}-${kind}` - const [seed] = useState(() => readSeed(draftKey, initialValue)) - const [value, setValue] = useState(seed.value) - const [submitErrors, setSubmitErrors] = useState<PlanParseError[]>([]) - const [submitting, startSubmit] = useTransition() - - // Eenmalige toast voor restore — de seed is al toegepast bij mount. - useEffect(() => { - if (seed.restored) { - toast.info('Niet-opgeslagen wijziging hersteld uit lokale draft.') - } - }, [seed.restored]) - - // Auto-save naar localStorage on change. - useEffect(() => { - if (typeof window === 'undefined') return - if (value === initialValue) { - window.localStorage.removeItem(draftKey) - } else { - window.localStorage.setItem(draftKey, value) - } - }, [value, initialValue, draftKey]) - - // Live yaml-validatie als afgeleide state — geen useEffect nodig. - const validationErrors = useMemo<PlanParseError[]>(() => { - if (kind !== 'plan') return [] - if (value === '' || value === initialValue) return [] - const r = parsePlanMd(value) - return r.ok ? [] : r.errors - }, [value, initialValue, kind]) - - // Combine: validation errors voor live feedback, submitErrors voor server-side details. - const errors = submitErrors.length > 0 ? submitErrors : validationErrors - - function save() { - if (errors.length > 0 && kind === 'plan') { - toast.error('Frontmatter heeft fouten — fix die eerst.') - return - } - setSubmitErrors([]) - startSubmit(async () => { - const r = - kind === 'grill' - ? await updateGrillMdAction(ideaId, value) - : await updatePlanMdAction(ideaId, value) - if ('error' in r) { - toast.error(r.error) - if ('details' in r && Array.isArray(r.details)) { - setSubmitErrors(r.details as PlanParseError[]) - } - return - } - toast.success('Opgeslagen') - window.localStorage.removeItem(draftKey) - router.refresh() - onCancel() - }) - } - - // Cmd/Ctrl+S → save - function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) { - if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { - e.preventDefault() - save() - } - } - - const dirty = value !== initialValue - - return ( - <div className="space-y-3" {...debugProps('idea-md-editor', 'IdeaMdEditor', 'components/ideas/idea-md-editor.tsx')}> - {errors.length > 0 && ( - <div className="rounded-md border border-status-blocked/30 bg-status-blocked/10 p-3 space-y-1"> - <p className="text-xs font-medium text-status-blocked"> - {kind === 'plan' ? 'YAML-frontmatter fouten' : 'Validatiefouten'} - </p> - <ul className="text-xs text-status-blocked space-y-0.5"> - {errors.map((err, i) => ( - <li key={i}> - {err.line ? `Regel ${err.line}: ` : ''} - {err.message} - {err.hint && ( - <div className="mt-1 text-foreground/80">Tip: {err.hint}</div> - )} - </li> - ))} - </ul> - </div> - )} - - <Textarea - value={value} - onChange={(e) => setValue(e.target.value)} - onKeyDown={onKeyDown} - rows={24} - className="font-mono text-sm leading-relaxed" - data-debug-id="idea-md-editor__textarea" - placeholder={ - kind === 'grill' - ? '# Idee — ...\n## Scope\n...' - : '---\npbi:\n title: ...\n priority: 2\nstories:\n - title: ...\n---\n\n# Overwegingen\n...' - } - disabled={submitting} - /> - - <div className="flex items-center justify-between"> - <p className="text-xs text-muted-foreground"> - {dirty ? 'Niet-opgeslagen wijzigingen — Cmd/Ctrl+S om op te slaan' : 'Geen wijzigingen'} - </p> - <div className="flex gap-2"> - <Button variant="outline" size="sm" onClick={onCancel} disabled={submitting}> - <X className="size-3.5 mr-1" /> - Annuleer - </Button> - <Button - size="sm" - onClick={save} - disabled={!dirty || submitting || (errors.length > 0 && kind === 'plan')} - data-debug-id="idea-md-editor__save" - > - <Save className="size-3.5 mr-1" /> - Opslaan - </Button> - </div> - </div> - </div> - ) -} diff --git a/components/ideas/idea-pbi-link-card.tsx b/components/ideas/idea-pbi-link-card.tsx deleted file mode 100644 index 53a1490..0000000 --- a/components/ideas/idea-pbi-link-card.tsx +++ /dev/null @@ -1,87 +0,0 @@ -'use client' - -// IdeaPbiLinkCard — toont de gekoppelde PBI bij PLANNED. Bij "stale link" -// (status===PLANNED maar pbi_id===null, want PBI elders verwijderd via -// de SetNull FK) tonen we de Re-link-banner. - -import { useTransition } from 'react' -import Link from 'next/link' -import { useRouter } from 'next/navigation' -import { ExternalLink, Link2Off } from 'lucide-react' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { debugProps } from '@/lib/debug' -import { relinkIdeaPlanAction } from '@/actions/ideas' -import type { IdeaDto } from '@/lib/idea-dto' - -interface Props { - idea: IdeaDto - isDemo: boolean -} - -export function IdeaPbiLinkCard({ idea, isDemo }: Props) { - const router = useRouter() - const [pending, startTransition] = useTransition() - - if (idea.status !== 'planned') return null - - if (idea.pbi && idea.product_id) { - return ( - <div className="rounded-md border border-status-done/30 bg-status-done/10 p-4 flex items-center gap-3" {...debugProps('idea-pbi-link-card', 'IdeaPbiLinkCard', 'components/ideas/idea-pbi-link-card.tsx')}> - <div className="flex-1"> - <p className="text-xs uppercase tracking-wide text-status-done font-medium" data-debug-id="idea-pbi-link-card__title"> - Gepland - </p> - <p className="text-sm"> - Gematerialiseerd als{' '} - <Link - href={`/products/${idea.product_id}`} - className="font-medium text-status-done hover:underline inline-flex items-center gap-1" - data-debug-id="idea-pbi-link-card__link" - > - {idea.pbi.code} — {idea.pbi.title} - <ExternalLink className="size-3" /> - </Link> - </p> - </div> - </div> - ) - } - - // Stale link — pbi_id === null maar status nog PLANNED. - function handleRelink() { - if (isDemo) return - startTransition(async () => { - const r = await relinkIdeaPlanAction(idea.id) - if ('error' in r) { - toast.error(r.error) - return - } - toast.success('Idee terug naar PLAN_READY — open de Plan-tab.') - router.refresh() - }) - } - - return ( - <div className="rounded-md border border-status-blocked/30 bg-status-blocked/10 p-4 space-y-2" {...debugProps('idea-pbi-link-card', 'IdeaPbiLinkCard', 'components/ideas/idea-pbi-link-card.tsx')}> - <div className="flex items-center gap-2"> - <Link2Off className="size-4 text-status-blocked" /> - <p className="text-sm font-medium text-status-blocked"> - De gekoppelde PBI bestaat niet meer - </p> - </div> - <p className="text-sm text-muted-foreground"> - Klik om dit idee terug naar PLAN_READY te zetten en opnieuw te materialiseren. - </p> - <Button - size="sm" - variant="outline" - onClick={handleRelink} - disabled={isDemo || pending} - > - Plan opnieuw beschikbaar maken - </Button> - </div> - ) -} diff --git a/components/ideas/idea-row-actions.tsx b/components/ideas/idea-row-actions.tsx deleted file mode 100644 index 37b8226..0000000 --- a/components/ideas/idea-row-actions.tsx +++ /dev/null @@ -1,351 +0,0 @@ -'use client' - -// IdeaRowActions — Grill Me / Make Plan / Materialiseer / Archive / Open. -// Disabled-rules per M12 T-508: -// -// Grill Me: niet in GRILLING|PLANNING; vereist product-met-repo + -// connectedWorkers > 0 -// Make Plan: alleen in GRILLED|PLAN_FAILED|PLAN_READY (re-plan); idem -// voorwaarden -// Materialiseer: alleen in PLAN_READY (geen worker nodig — synchrone parser) -// PLANNED: alle drie disabled, "Bekijk PBI" link -// *_FAILED: "Probeer opnieuw" knop (= start-job opnieuw) -// -// Demo-tooltip om elke muteer-knop. connectedWorkers wordt gelezen uit -// useSoloStore (M12 grill-keuze 16 — geen lift voor v1). - -import { useRef, useTransition } from 'react' -import { useRouter } from 'next/navigation' -import { - Archive, - ArrowRight, - ExternalLink, - Flame, - Layers, - RotateCw, - Sparkles, - Upload, -} from 'lucide-react' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '@/components/ui/tooltip' -import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { useSoloStore } from '@/stores/solo-store' -import { debugProps } from '@/lib/debug' -import { - startGrillJobAction, - startMakePlanJobAction, - materializeIdeaPlanAction, - uploadPlanMdAction, -} from '@/actions/ideas' -import type { IdeaDto } from '@/lib/idea-dto' - -interface IdeaRowActionsProps { - idea: IdeaDto - isDemo: boolean - onArchive: () => void -} - -export function IdeaRowActions({ idea, isDemo, onArchive }: IdeaRowActionsProps) { - const router = useRouter() - const connectedWorkers = useSoloStore((s) => s.connectedWorkers) - const [pending, startTransition] = useTransition() - - const hasProductWithRepo = idea.product != null && idea.product.repo_url !== null - const workerOk = connectedWorkers > 0 - const status = idea.status - - // ---- Grill Me ---- - const grillBlockedReason = (() => { - if (status === 'grilling' || status === 'planning') return 'Job loopt al' - if (!hasProductWithRepo) return 'Idee heeft een product met repo nodig' - if (!workerOk) return 'Geen Claude-worker actief' - return null - })() - const grillEnabled = !grillBlockedReason && !isDemo && !pending - - // ---- Make Plan ---- - const makePlanAllowedStates = ['grilled', 'plan_failed', 'plan_ready'] - const makePlanBlockedReason = (() => { - if (!makePlanAllowedStates.includes(status)) { - if (status === 'draft' || status === 'grill_failed') return 'Eerst grillen' - if (status === 'grilling' || status === 'planning') return 'Job loopt al' - if (status === 'planned') return 'Idee is gepland — open de PBI' - return null - } - if (!hasProductWithRepo) return 'Idee heeft een product met repo nodig' - if (!workerOk) return 'Geen Claude-worker actief' - return null - })() - const makePlanEnabled = !makePlanBlockedReason && !isDemo && !pending - - // ---- Materialiseer ---- - const materializeBlockedReason = (() => { - if (status !== 'plan_ready') return 'Plan is niet klaar' - return null - })() - const materializeEnabled = !materializeBlockedReason && !isDemo && !pending - - // ---- Upload plan ---- - // Synchrone server-action (parse + DB), geen worker nodig. Mag vanuit - // DRAFT (skip-grill), GRILLED, PLAN_FAILED of PLAN_READY (overschrijft het - // bestaande plan zonder confirmation — consistent met updatePlanMdAction). - const uploadPlanAllowedStates = ['draft', 'grilled', 'plan_failed', 'plan_ready'] - const uploadPlanBlockedReason = (() => { - if (uploadPlanAllowedStates.includes(status)) return null - if (status === 'grilling' || status === 'planning') return 'Job loopt al' - if (status === 'planned') return 'Idee is al gepland' - return null - })() - const uploadPlanEnabled = !uploadPlanBlockedReason && !isDemo && !pending - const fileInputRef = useRef<HTMLInputElement>(null) - - function handleUploadPlanClick() { - fileInputRef.current?.click() - } - - function handlePlanFileChange(e: React.ChangeEvent<HTMLInputElement>) { - const file = e.target.files?.[0] - // Reset zodat dezelfde file na een fout opnieuw gekozen kan worden. - e.target.value = '' - if (!file) return - - startTransition(async () => { - let text: string - try { - text = await file.text() - } catch { - toast.error('Kon bestand niet lezen') - return - } - const r = await uploadPlanMdAction(idea.id, text) - if ('error' in r) { - toast.error(r.error) - return - } - toast.success('Plan geüpload — idee staat nu op PLAN_READY') - router.refresh() - }) - } - - // ---- Failed-states tonen "Probeer opnieuw" ---- - const isFailedState = status === 'grill_failed' || status === 'plan_failed' - - function runStart(action: typeof startGrillJobAction | typeof startMakePlanJobAction) { - startTransition(async () => { - const r = await action(idea.id) - if ('error' in r) { - toast.error(r.error) - return - } - toast.success('Job in de wachtrij — een worker pakt hem op.') - router.refresh() - }) - } - - function handleMaterialize() { - if (!confirm('Plan materialiseren? Dit maakt PBI + stories + taken aan.')) return - startTransition(async () => { - let r = await materializeIdeaPlanAction(idea.id) - - if ('error' in r && r.code === 409 && r.error.startsWith('PBI_HAS_ACTIVE_TASKS:')) { - const pbiCode = r.error.split(':')[1] - const alongside = confirm( - `De bestaande PBI (${pbiCode}) heeft uitgevoerde taken.\n` + - `OK = nieuwe PBI naast bestaande aanmaken.\n` + - `Annuleren = stoppen.` - ) - if (!alongside) return - r = await materializeIdeaPlanAction(idea.id, { allowAlongside: true }) - } - - if ('error' in r) { - toast.error(r.error) - return - } - toast.success(`Gematerialiseerd als ${r.data?.pbi_code}`) - if (idea.product_id) { - router.push(`/products/${idea.product_id}`) - } else { - router.refresh() - } - }) - } - - return ( - <div className="flex items-center gap-1" {...debugProps('idea-row-actions', 'IdeaRowActions', 'components/ideas/idea-row-actions.tsx')}> - {/* Bekijk PBI — alleen zichtbaar in PLANNED */} - {status === 'planned' && idea.pbi && idea.product_id && ( - <Button - variant="outline" - size="sm" - onClick={() => router.push(`/products/${idea.product_id!}`)} - > - Bekijk {idea.pbi.code} - <ExternalLink className="ml-1 size-3.5" /> - </Button> - )} - - {/* Grill Me */} - <span data-debug-id="idea-row-actions__grill"> - <ActionButton - label="Grill" - icon={<Flame className="size-3.5" />} - enabled={grillEnabled} - blockedReason={grillBlockedReason} - isDemo={isDemo} - onClick={() => runStart(startGrillJobAction)} - /> - </span> - - {/* Make Plan */} - <span data-debug-id="idea-row-actions__plan"> - <ActionButton - label="Plan" - icon={<Sparkles className="size-3.5" />} - enabled={makePlanEnabled} - blockedReason={makePlanBlockedReason} - isDemo={isDemo} - onClick={() => runStart(startMakePlanJobAction)} - /> - </span> - - {/* Upload plan — synchrone short-circuit van de Make-Plan AI-flow */} - <span data-debug-id="idea-row-actions__upload-plan"> - <ActionButton - label="Upload plan" - icon={<Upload className="size-3.5" />} - enabled={uploadPlanEnabled} - blockedReason={uploadPlanBlockedReason} - isDemo={isDemo} - onClick={handleUploadPlanClick} - /> - <input - ref={fileInputRef} - type="file" - accept=".md,.markdown,text/markdown,text/plain" - className="hidden" - onChange={handlePlanFileChange} - aria-label="Plan-markdown bestand kiezen" - /> - </span> - - {/* Materialiseer */} - <ActionButton - label="Maak PBI" - icon={<Layers className="size-3.5" />} - enabled={materializeEnabled} - blockedReason={materializeBlockedReason} - isDemo={isDemo} - onClick={handleMaterialize} - variant="default" - /> - - {/* Failed-states: kleine retry-shortcut */} - {isFailedState && ( - <DemoTooltip show={isDemo}> - <Button - size="sm" - variant="outline" - disabled={isDemo || pending || !workerOk || !hasProductWithRepo} - onClick={() => - runStart( - status === 'grill_failed' ? startGrillJobAction : startMakePlanJobAction, - ) - } - title="Probeer opnieuw" - > - <RotateCw className="size-3.5" /> - </Button> - </DemoTooltip> - )} - - {/* Open detail */} - <Button - size="sm" - variant="ghost" - onClick={() => router.push(`/ideas/${idea.id}`)} - aria-label="Open idee" - title="Open idee" - > - <ArrowRight className="size-4" /> - </Button> - - {/* Archive */} - <DemoTooltip show={isDemo}> - <Button - size="sm" - variant="ghost" - onClick={onArchive} - disabled={isDemo || pending} - aria-label="Archiveer idee" - title="Archiveer" - data-debug-id="idea-row-actions__delete" - > - <Archive className="size-4" /> - </Button> - </DemoTooltip> - </div> - ) -} - -interface ActionButtonProps { - label: string - icon: React.ReactNode - enabled: boolean - blockedReason: string | null - isDemo: boolean - onClick: () => void - variant?: 'default' | 'outline' -} - -function ActionButton({ - label, - icon, - enabled, - blockedReason, - isDemo, - onClick, - variant = 'outline', -}: ActionButtonProps) { - // Bij demo: DemoTooltip toont reden. Bij niet-demo + reden: gewone tooltip. - if (isDemo) { - return ( - <DemoTooltip show> - <Button size="sm" variant={variant} disabled> - {icon} - <span className="ml-1">{label}</span> - </Button> - </DemoTooltip> - ) - } - - if (!enabled && blockedReason) { - return ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger render={<span className="inline-flex" />}> - <Button size="sm" variant={variant} disabled> - {icon} - <span className="ml-1">{label}</span> - </Button> - </TooltipTrigger> - <TooltipContent>{blockedReason}</TooltipContent> - </Tooltip> - </TooltipProvider> - ) - } - - return ( - <Button size="sm" variant={variant} onClick={onClick}> - {icon} - <span className="ml-1">{label}</span> - </Button> - ) -} diff --git a/components/ideas/idea-sync-tab.tsx b/components/ideas/idea-sync-tab.tsx deleted file mode 100644 index a5dc0e0..0000000 --- a/components/ideas/idea-sync-tab.tsx +++ /dev/null @@ -1,236 +0,0 @@ -'use client' - -// Sync-tab op /ideas/[id] (PBI-36 ST-1219). Toont per Story onder de -// gekoppelde PBI: status, job-rij (ClaudeJobs incl. branch/pushed_at/pr_url), -// en de bestaande activity-log via <StoryLog>. Realtime refresh via -// notifications-SSE: bij elk story_log of relevant claude_job-event triggeren -// we router.refresh() (server-render verzorgt nieuwe data). - -import { useEffect } from 'react' -import { useRouter } from 'next/navigation' -import { Badge } from '@/components/ui/badge' -import { StoryLog } from '@/components/shared/story-log' -import { JOB_STATUS_LABELS, JOB_STATUS_COLORS } from '@/components/shared/job-status' -import type { ClaudeJobStatusApi } from '@/lib/job-status' -import { debugProps } from '@/lib/debug' -import type { IdeaSyncData } from '@/app/(app)/ideas/[id]/sync-tab-server' - -interface Props { - data: IdeaSyncData -} - -const TASK_STATUS_LABEL: Record<string, string> = { - TO_DO: 'TO-DO', - IN_PROGRESS: 'Bezig', - REVIEW: 'Review', - DONE: 'Klaar', -} - -const TASK_STATUS_COLOR: Record<string, string> = { - TO_DO: 'bg-status-todo/15 text-status-todo border-status-todo/30', - IN_PROGRESS: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30', - REVIEW: 'bg-warning/15 text-warning border-warning/30', - DONE: 'bg-status-done/15 text-status-done border-status-done/30', -} - -function formatRelative(iso: string | Date | null): string { - if (!iso) return '—' - const d = typeof iso === 'string' ? new Date(iso) : iso - const diffMs = Date.now() - d.getTime() - const min = Math.round(diffMs / 60_000) - if (min < 1) return 'zojuist' - if (min < 60) return `${min} min geleden` - const h = Math.round(min / 60) - if (h < 24) return `${h} u geleden` - return d.toLocaleDateString('nl-NL', { day: 'numeric', month: 'short' }) -} - -function jobStatusKey(dbStatus: string): ClaudeJobStatusApi { - return dbStatus.toLowerCase() as ClaudeJobStatusApi -} - -export function IdeaSyncTab({ data }: Props) { - const router = useRouter() - const pbi = data.pbi - const storyIdsKey = pbi ? pbi.stories.map((s) => s.id).join(',') : '' - - // Realtime refresh op story_log inserts en idea-job updates. - // Listen op de bestaande user-scoped notifications stream (SSE-route filtert - // al op accessibleIdeaIds + accessibleProductIds). - useEffect(() => { - if (!storyIdsKey) return - const storyIds = new Set(storyIdsKey.split(',')) - const es = new EventSource('/api/realtime/notifications') - - es.addEventListener('message', (ev) => { - try { - const payload = JSON.parse(ev.data) - if (payload.entity === 'story_log' && storyIds.has(payload.story_id)) { - router.refresh() - return - } - if (payload.type === 'claude_job_status' && payload.idea_id === data.id) { - router.refresh() - } - } catch { - // niet-JSON of niet-relevant — negeren - } - }) - - es.addEventListener('error', () => { - // EventSource probeert zelf opnieuw te verbinden; geen actie nodig. - }) - - return () => { - es.close() - } - }, [data.id, storyIdsKey, router]) - - if (!pbi) return null - - return ( - <div className="space-y-4" {...debugProps('idea-sync-tab', 'IdeaSyncTab', 'components/ideas/idea-sync-tab.tsx')}> - {/* Header: PBI-link + PR-status */} - <div className="flex flex-wrap items-center gap-3 rounded-md border border-border bg-surface-container p-3" data-debug-id="idea-sync-tab__header"> - <a - href={`/backlog/${pbi.id}`} - className="font-mono text-sm text-primary hover:underline" - > - {pbi.code} - </a> - <span className="text-sm font-medium">{pbi.title}</span> - <div className="ml-auto flex items-center gap-2"> - {pbi.pr_url && ( - <a - href={pbi.pr_url} - target="_blank" - rel="noreferrer" - className="text-xs text-primary underline" - > - PR open - </a> - )} - {pbi.pr_merged_at && ( - <Badge className="bg-status-done/15 text-status-done border-status-done/30"> - Gemerged {formatRelative(pbi.pr_merged_at)} - </Badge> - )} - </div> - </div> - - {/* Stories */} - <div data-debug-id="idea-sync-tab__items"> - {pbi.stories.length === 0 && ( - <p className="text-sm text-muted-foreground italic"> - Deze PBI heeft nog geen stories. - </p> - )} - {pbi.stories.map((story) => ( - <details - key={story.id} - open - className="rounded-md border border-border bg-card" - > - <summary className="flex cursor-pointer flex-wrap items-center gap-2 px-3 py-2"> - <span className="font-mono text-xs text-muted-foreground"> - {story.code} - </span> - <span className="text-sm font-medium">{story.title}</span> - <Badge - className={`ml-auto ${TASK_STATUS_COLOR[story.status] ?? 'bg-muted'}`} - > - {TASK_STATUS_LABEL[story.status] ?? story.status} - </Badge> - </summary> - - <div className="space-y-3 border-t border-border px-3 py-3"> - {/* Tasks + jobs */} - {story.tasks.length === 0 ? ( - <p className="text-xs text-muted-foreground">Geen taken.</p> - ) : ( - <ul className="space-y-2"> - {story.tasks.map((task) => { - const latestJob = task.claude_jobs[0] - return ( - <li - key={task.id} - className="flex flex-wrap items-center gap-2 rounded border border-border/60 bg-surface-container/40 px-2 py-1.5 text-xs" - > - <span className="font-mono text-muted-foreground"> - {task.code} - </span> - <span className="flex-1 truncate">{task.title}</span> - <Badge - className={`${TASK_STATUS_COLOR[task.status] ?? 'bg-muted'}`} - > - {TASK_STATUS_LABEL[task.status] ?? task.status} - </Badge> - {latestJob ? ( - <Badge - className={ - JOB_STATUS_COLORS[jobStatusKey(latestJob.status)] ?? - 'bg-muted' - } - > - {JOB_STATUS_LABELS[jobStatusKey(latestJob.status)] ?? - latestJob.status} - </Badge> - ) : ( - <Badge className="bg-muted/60 text-muted-foreground italic"> - Geen job - </Badge> - )} - {latestJob?.branch && ( - <span className="font-mono text-muted-foreground"> - {latestJob.branch} - </span> - )} - {latestJob?.pushed_at && ( - <span className="text-muted-foreground"> - gepusht {formatRelative(latestJob.pushed_at)} - </span> - )} - {latestJob?.pr_url && ( - <a - href={latestJob.pr_url} - target="_blank" - rel="noreferrer" - className="text-primary underline" - > - PR - </a> - )} - </li> - ) - })} - </ul> - )} - - {/* Activity log (StoryLog hergebruik) */} - <div> - <h4 className="mb-1 text-xs font-medium text-muted-foreground"> - Activiteit - </h4> - <StoryLog - logs={story.logs.map((l) => ({ - id: l.id, - type: l.type, - content: l.content, - status: l.status, - commit_hash: l.commit_hash, - commit_message: l.commit_message, - created_at: - typeof l.created_at === 'string' - ? l.created_at - : l.created_at.toISOString(), - }))} - repoUrl={data.product?.repo_url ?? null} - /> - </div> - </div> - </details> - ))} - </div> - </div> - ) -} diff --git a/components/ideas/idea-timeline.tsx b/components/ideas/idea-timeline.tsx deleted file mode 100644 index b81eb42..0000000 --- a/components/ideas/idea-timeline.tsx +++ /dev/null @@ -1,347 +0,0 @@ -'use client' - -// IdeaTimeline — chronologische merge van IdeaLog + ClaudeQuestion + UserQuestion entries. -// Server-component zou ook kunnen, maar we mounten dit binnen de client-side -// detail-layout dus client is simpler (geen rsc-boundary doorbreken). -// -// Iconen + kleur per log-type voor snelle herkenning. -// Open ClaudeQuestions krijgen een inline answer-form (M11). -// PBI-33: UserQuestions tonen vraag + (indien beantwoord) Claude's antwoord. -// Onderaan: UserChatInput om nieuwe vraag te stellen (alleen als plan_md aanwezig is). - -import { useState, useTransition } from 'react' -import { useRouter } from 'next/navigation' -import { - CheckCircle2, - ClipboardList, - FileText, - HelpCircle, - Lightbulb, - MessageCircle, - RefreshCw, - StickyNote, - Wrench, -} from 'lucide-react' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { Textarea } from '@/components/ui/textarea' -import { debugProps } from '@/lib/debug' -import { answerQuestion } from '@/actions/questions' -import { UserChatInput } from '@/components/ideas/user-chat-input' - -import type { IdeaLogType } from '@prisma/client' - -export interface TimelineLog { - id: string - type: string - content: string - metadata: unknown - created_at: string -} - -export interface TimelineQuestion { - id: string - question: string - options: string[] | null - status: 'open' | 'answered' | 'cancelled' | 'expired' - answer: string | null - created_at: string - expires_at: string -} - -export interface TimelineUserQuestion { - id: string - question: string - answer: string | null - status: 'pending' | 'answered' - created_at: string -} - -interface Props { - logs: TimelineLog[] - questions: TimelineQuestion[] - userQuestions: TimelineUserQuestion[] - planMd: string | null - ideaId: string - isDemo?: boolean -} - -const LOG_ICON: Record<IdeaLogType, React.ReactNode> = { - DECISION: <Lightbulb className="size-4" />, - NOTE: <StickyNote className="size-4" />, - GRILL_RESULT: <FileText className="size-4" />, - PLAN_RESULT: <ClipboardList className="size-4" />, - PLAN_REVIEW_RESULT: <CheckCircle2 className="size-4" />, - STATUS_CHANGE: <RefreshCw className="size-4" />, - JOB_EVENT: <Wrench className="size-4" />, -} - -const LOG_LABEL: Record<IdeaLogType, string> = { - DECISION: 'Beslissing', - NOTE: 'Notitie', - GRILL_RESULT: 'Grill-resultaat', - PLAN_RESULT: 'Plan-resultaat', - PLAN_REVIEW_RESULT: 'Plan-beoordeeling', - STATUS_CHANGE: 'Status', - JOB_EVENT: 'Job-event', -} - -const QUESTION_STATUS_LABEL: Record<TimelineQuestion['status'], string> = { - open: 'Open', - answered: 'Beantwoord', - cancelled: 'Geannuleerd', - expired: 'Verlopen', -} - -const USER_QUESTION_STATUS_LABEL: Record<TimelineUserQuestion['status'], string> = { - pending: 'In behandeling', - answered: 'Beantwoord', -} - -export function IdeaTimeline({ - logs, - questions, - userQuestions, - planMd, - ideaId, - isDemo = false, -}: Props) { - const merged = [ - ...logs.map((l) => ({ - kind: 'log' as const, - created_at: l.created_at, - data: l, - })), - ...questions.map((q) => ({ - kind: 'question' as const, - created_at: q.created_at, - data: q, - })), - ...userQuestions.map((uq) => ({ - kind: 'user_question' as const, - created_at: uq.created_at, - data: uq, - })), - ].sort((a, b) => (a.created_at < b.created_at ? 1 : -1)) - - const showChatInput = planMd !== null - - return ( - <div className="space-y-4" {...debugProps('idea-timeline', 'IdeaTimeline', 'components/ideas/idea-timeline.tsx')}> - {merged.length === 0 ? ( - <p className="text-sm text-muted-foreground py-8 text-center italic"> - Nog geen activiteit op dit idee. - </p> - ) : ( - <ol className="border-l-2 border-input pl-4 space-y-3 ml-2" data-debug-id="idea-timeline__items"> - {merged.map((entry, i) => { - // Expliciete locale + format om SSR/CSR hydration-mismatch te voorkomen - // (server-locale verschilde van browser-locale). - const time = new Date(entry.created_at).toLocaleString('nl-NL', { - dateStyle: 'short', - timeStyle: 'short', - }) - - if (entry.kind === 'log') { - const type = entry.data.type as IdeaLogType - return ( - <li key={`l-${entry.data.id}`} className="relative"> - <span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-muted-foreground"> - {LOG_ICON[type] ?? <StickyNote className="size-4" />} - </span> - <div className="rounded-md border border-input bg-surface-container p-3 space-y-1"> - <div className="flex items-center gap-2 text-xs text-muted-foreground"> - <span className="font-medium uppercase tracking-wide"> - {LOG_LABEL[type] ?? type} - </span> - <span>·</span> - <time>{time}</time> - </div> - <p className="text-sm whitespace-pre-wrap">{entry.data.content}</p> - {entry.data.metadata != null && - typeof entry.data.metadata === 'object' && - Object.keys(entry.data.metadata as object).length > 0 ? ( - <details className="text-xs text-muted-foreground"> - <summary className="cursor-pointer">metadata</summary> - <pre className="mt-1 whitespace-pre-wrap font-mono text-[10px]"> - {JSON.stringify(entry.data.metadata, null, 2)} - </pre> - </details> - ) : null} - </div> - </li> - ) - } - - if (entry.kind === 'question') { - const q = entry.data - return ( - <li key={`q-${q.id}-${i}`} className="relative"> - <span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-status-review"> - <HelpCircle className="size-4" /> - </span> - <div className="rounded-md border border-input bg-surface-container p-3 space-y-2"> - <div className="flex items-center gap-2 text-xs text-muted-foreground"> - <span className="font-medium uppercase tracking-wide">Vraag</span> - <span>·</span> - <span>{QUESTION_STATUS_LABEL[q.status]}</span> - <span>·</span> - <time>{time}</time> - </div> - <p className="text-sm">{q.question}</p> - {q.status === 'open' ? ( - <AnswerForm questionId={q.id} options={q.options} /> - ) : ( - <> - {q.options && q.options.length > 0 ? ( - <ul className="text-xs text-muted-foreground list-disc list-inside"> - {q.options.map((o, ii) => ( - <li key={ii}>{o}</li> - ))} - </ul> - ) : null} - {q.answer ? ( - <p className="text-sm border-l-2 border-primary pl-2 text-foreground"> - <span className="text-xs font-medium uppercase tracking-wide text-primary mr-2"> - Antwoord - </span> - {q.answer} - </p> - ) : null} - </> - )} - </div> - </li> - ) - } - - // user_question — gebruiker stelt vraag aan Claude (PBI-33 PLAN_CHAT) - const uq = entry.data - return ( - <li key={`uq-${uq.id}`} className="relative"> - <span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-primary"> - <MessageCircle className="size-4" /> - </span> - <div className="rounded-md border border-input bg-surface-container p-3 space-y-2"> - <div className="flex items-center gap-2 text-xs text-muted-foreground"> - <span className="font-medium uppercase tracking-wide">Jouw vraag</span> - <span>·</span> - <span>{USER_QUESTION_STATUS_LABEL[uq.status]}</span> - <span>·</span> - <time>{time}</time> - </div> - <p className="text-sm whitespace-pre-wrap">{uq.question}</p> - {uq.status === 'pending' ? ( - <p className="text-xs text-muted-foreground italic"> - Claude denkt na… - </p> - ) : uq.answer ? ( - <div className="border-l-2 border-primary pl-2"> - <span className="text-xs font-medium uppercase tracking-wide text-primary"> - Antwoord van Claude - </span> - <p className="text-sm whitespace-pre-wrap text-foreground mt-1"> - {uq.answer} - </p> - </div> - ) : null} - </div> - </li> - ) - })} - </ol> - )} - - {showChatInput && <UserChatInput ideaId={ideaId} isDemo={isDemo} />} - </div> - ) -} - -// --------------------------------------------------------------------------- -// AnswerForm — inline antwoord op een open Claude-vraag. -// Voor multi-choice (options): knoppen die direct submitten met de gekozen -// optie. Anders: textarea + Submit knop. - -function AnswerForm({ - questionId, - options, -}: { - questionId: string - options: string[] | null -}) { - const router = useRouter() - const [text, setText] = useState('') - const [pending, startTransition] = useTransition() - - function submit(answer: string) { - const trimmed = answer.trim() - if (!trimmed) { - toast.error('Antwoord mag niet leeg zijn') - return - } - startTransition(async () => { - const r = await answerQuestion(questionId, trimmed) - if (!r.ok) { - toast.error(r.error) - return - } - toast.success('Antwoord verzonden — agent gaat door.') - setText('') - router.refresh() - }) - } - - if (options && options.length > 0) { - return ( - <div className="flex flex-col gap-1.5 pt-1"> - {options.map((opt, i) => ( - <Button - key={i} - size="sm" - variant="outline" - className="justify-start text-left h-auto py-2 whitespace-normal" - disabled={pending} - onClick={() => submit(opt)} - > - {opt} - </Button> - ))} - <details className="text-xs text-muted-foreground pt-1"> - <summary className="cursor-pointer">Of typ een eigen antwoord</summary> - <div className="space-y-2 pt-2"> - <Textarea - value={text} - onChange={(e) => setText(e.target.value)} - rows={3} - placeholder="Eigen antwoord…" - disabled={pending} - /> - <div className="flex justify-end"> - <Button size="sm" disabled={pending || !text.trim()} onClick={() => submit(text)}> - Verzend - </Button> - </div> - </div> - </details> - </div> - ) - } - - return ( - <div className="space-y-2 pt-1"> - <Textarea - value={text} - onChange={(e) => setText(e.target.value)} - rows={3} - placeholder="Antwoord…" - disabled={pending} - /> - <div className="flex justify-end"> - <Button size="sm" disabled={pending || !text.trim()} onClick={() => submit(text)}> - Verzend - </Button> - </div> - </div> - ) -} diff --git a/components/ideas/ideas-filter-popover.tsx b/components/ideas/ideas-filter-popover.tsx deleted file mode 100644 index f3d0c78..0000000 --- a/components/ideas/ideas-filter-popover.tsx +++ /dev/null @@ -1,69 +0,0 @@ -'use client' - -import { Button } from '@/components/ui/button' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { MultiFilterPills } from '@/components/shared/backlog-filter-popover' -import { debugProps } from '@/lib/debug' -import type { IdeaStatusApi } from '@/lib/idea-status' - -interface IdeasFilterPopoverProps { - open: boolean - onOpenChange: (o: boolean) => void - statusOptions: Array<{ value: IdeaStatusApi; label: string }> - selected: Set<IdeaStatusApi> - onToggle: (v: IdeaStatusApi) => void - onClear: () => void - activeFilterCount: number -} - -export function IdeasFilterPopover({ - open, - onOpenChange, - statusOptions, - selected, - onToggle, - onClear, - activeFilterCount, -}: IdeasFilterPopoverProps) { - return ( - <Popover open={open} onOpenChange={onOpenChange}> - <PopoverTrigger - render={ - <Button - variant="outline" - size="sm" - className="h-7 text-xs" - {...debugProps('ideas-filter-popover__trigger')} - > - {`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`} - </Button> - } - /> - <PopoverContent - align="end" - className="w-72 space-y-4" - {...debugProps('ideas-filter-popover', 'IdeasFilterPopover', 'components/ideas/ideas-filter-popover.tsx')} - > - <MultiFilterPills - label="Status" - options={statusOptions} - selected={selected} - onToggle={onToggle} - onClear={onClear} - /> - <div className="flex justify-end pt-1 border-t border-border"> - <Button - type="button" - variant="ghost" - size="sm" - className="h-7 text-xs" - disabled={activeFilterCount === 0} - onClick={onClear} - > - Wis filters - </Button> - </div> - </PopoverContent> - </Popover> - ) -} diff --git a/components/ideas/review-log-viewer.tsx b/components/ideas/review-log-viewer.tsx deleted file mode 100644 index 3bddcad..0000000 --- a/components/ideas/review-log-viewer.tsx +++ /dev/null @@ -1,241 +0,0 @@ -'use client' - -import { CheckCircle2, AlertCircle, Info, BarChart3 } from 'lucide-react' -import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' - -export interface IssueItem { - category: 'structure' | 'logic' | 'risk' | 'pattern' - severity: 'error' | 'warning' | 'info' - suggestion: string -} - -export interface ReviewRound { - round: number - model: string - role: string - focus: string - issues: IssueItem[] - score: number - plan_diff_lines: number - converged: boolean - timestamp: string -} - -export interface ReviewLog { - plan_file: string - created_at: string - rounds: ReviewRound[] - convergence?: { - stable_at_round: number - final_diff_pct: number - convergence_metric: string - } - approval: { - status: 'pending' | 'approved' | 'rejected' - timestamp?: string - } - summary: string -} - -interface ReviewLogViewerProps { - reviewLog: ReviewLog -} - -const SEVERITY_COLORS: Record<IssueItem['severity'], string> = { - error: 'text-status-blocked bg-status-blocked/10 border-status-blocked/30', - warning: 'text-status-in-progress bg-status-in-progress/10 border-status-in-progress/30', - info: 'text-status-review bg-status-review/10 border-status-review/30', -} - -const CATEGORY_LABELS: Record<IssueItem['category'], string> = { - structure: 'Structuur', - logic: 'Logica', - risk: 'Risico', - pattern: 'Patroon', -} - -const APPROVAL_COLORS: Record<ReviewLog['approval']['status'], string> = { - pending: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30', - approved: 'bg-status-done/15 text-status-done border-status-done/30', - rejected: 'bg-status-blocked/15 text-status-blocked border-status-blocked/30', -} - -const APPROVAL_LABELS: Record<ReviewLog['approval']['status'], string> = { - pending: 'In behandeling', - approved: 'Goedgekeurd', - rejected: 'Afgewezen', -} - -function IssueIcon({ severity }: { severity: IssueItem['severity'] }) { - switch (severity) { - case 'error': - return <AlertCircle className="size-4" /> - case 'warning': - return <AlertCircle className="size-4" /> - case 'info': - return <Info className="size-4" /> - } -} - -function RoundHeader({ round }: { round: ReviewRound }) { - const date = new Date(round.timestamp).toLocaleString('nl-NL', { - dateStyle: 'short', - timeStyle: 'short', - }) - - return ( - <div className="flex items-center justify-between gap-4 mb-3"> - <div className="flex items-center gap-3"> - <div className="flex items-center gap-1.5"> - <span className="text-xs font-mono px-2 py-0.5 rounded bg-muted text-muted-foreground"> - Ronde {round.round + 1} - </span> - <span className="text-xs font-mono px-2 py-0.5 rounded border border-border bg-surface-container text-muted-foreground"> - {round.model.split('-').pop()?.toUpperCase()} - </span> - </div> - <div className="flex items-center gap-2"> - <span className="text-sm font-medium">{round.role}</span> - {round.converged && ( - <span className="text-xs px-1.5 py-0.5 rounded-full bg-status-done/20 text-status-done border border-status-done/30"> - Converged - </span> - )} - </div> - </div> - <span className="text-xs text-muted-foreground">{date}</span> - </div> - ) -} - -function RoundStats({ round }: { round: ReviewRound }) { - return ( - <div className="grid grid-cols-2 gap-2 mb-3 text-xs"> - <div className="flex items-center gap-2 p-2 rounded bg-surface-container"> - <BarChart3 className="size-4 text-muted-foreground" /> - <div> - <div className="text-muted-foreground">Score</div> - <div className="font-medium">{round.score}/100</div> - </div> - </div> - <div className="flex items-center gap-2 p-2 rounded bg-surface-container"> - <AlertCircle className="size-4 text-muted-foreground" /> - <div> - <div className="text-muted-foreground">Wijzigingen</div> - <div className="font-medium">{round.plan_diff_lines} regels</div> - </div> - </div> - </div> - ) -} - -function IssueBadge({ issue, index }: { issue: IssueItem; index: number }) { - return ( - <div key={index} className={cn('rounded border p-2 text-xs', SEVERITY_COLORS[issue.severity])}> - <div className="flex items-start gap-2"> - <IssueIcon severity={issue.severity} /> - <div className="flex-1"> - <div className="font-medium">{CATEGORY_LABELS[issue.category]}</div> - <p className="text-xs mt-1 opacity-90">{issue.suggestion}</p> - </div> - </div> - </div> - ) -} - -export function ReviewLogViewer({ reviewLog }: ReviewLogViewerProps) { - const approvalDate = reviewLog.approval.timestamp - ? new Date(reviewLog.approval.timestamp).toLocaleString('nl-NL', { - dateStyle: 'short', - timeStyle: 'short', - }) - : null - - return ( - <div - className="space-y-4" - {...debugProps('review-log-viewer', 'ReviewLogViewer', 'components/ideas/review-log-viewer.tsx')} - > - {/* Summary */} - <div className="rounded-lg border border-input bg-surface-container p-4 space-y-2"> - <div className="flex items-center justify-between"> - <h3 className="font-semibold text-sm">Plan-beoordeling</h3> - <span - className={cn( - 'text-xs px-2.5 py-1 rounded-full border font-medium', - APPROVAL_COLORS[reviewLog.approval.status], - )} - > - {APPROVAL_LABELS[reviewLog.approval.status]} - </span> - </div> - <p className="text-sm text-foreground">{reviewLog.summary}</p> - {approvalDate && ( - <p className="text-xs text-muted-foreground">Goedgekeurd op {approvalDate}</p> - )} - </div> - - {/* Convergence Metrics */} - {reviewLog.convergence && ( - <div className="rounded-lg border border-input bg-surface-container p-4 space-y-3"> - <h3 className="font-semibold text-sm flex items-center gap-2"> - <CheckCircle2 className="size-4 text-status-done" /> - Convergentie - </h3> - <div className="grid grid-cols-2 gap-3"> - <div className="p-2 rounded bg-surface-container-low"> - <p className="text-xs text-muted-foreground">Stabiel na ronde</p> - <p className="font-semibold text-lg">{reviewLog.convergence.stable_at_round + 1}</p> - </div> - <div className="p-2 rounded bg-surface-container-low"> - <p className="text-xs text-muted-foreground">Eindwijziging</p> - <p className="font-semibold text-lg">{reviewLog.convergence.final_diff_pct.toFixed(1)}%</p> - </div> - </div> - </div> - )} - - {/* Review Rounds */} - <div className="space-y-4"> - <h3 className="font-semibold text-sm px-2">Review-rondes</h3> - {reviewLog.rounds.map((round) => ( - <div key={round.round} className="rounded-lg border border-input bg-surface-container p-4 space-y-3"> - <RoundHeader round={round} /> - <RoundStats round={round} /> - - {/* Issues */} - {round.issues.length > 0 ? ( - <div className="space-y-2"> - <h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> - Bevindingen ({round.issues.length}) - </h4> - <div className="space-y-2"> - {round.issues.map((issue, idx) => ( - <IssueBadge key={idx} issue={issue} index={idx} /> - ))} - </div> - </div> - ) : ( - <p className="text-xs text-muted-foreground italic">Geen bevindingen in deze ronde.</p> - )} - </div> - ))} - </div> - - {/* Metadata */} - <div className="text-xs text-muted-foreground p-2 rounded bg-surface-container-low space-y-1"> - <p> - <span className="font-medium">Bestand:</span> {reviewLog.plan_file} - </p> - <p> - <span className="font-medium">Gemaakt:</span>{' '} - {new Date(reviewLog.created_at).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })} - </p> - <p> - <span className="font-medium">Rondes:</span> {reviewLog.rounds.length} - </p> - </div> - </div> - ) -} diff --git a/components/ideas/user-chat-input.tsx b/components/ideas/user-chat-input.tsx deleted file mode 100644 index 3ed7c7c..0000000 --- a/components/ideas/user-chat-input.tsx +++ /dev/null @@ -1,77 +0,0 @@ -'use client' - -import { useState, useTransition } from 'react' -import { useRouter } from 'next/navigation' -import { Send } from 'lucide-react' -import { toast } from 'sonner' - -import { Button } from '@/components/ui/button' -import { Textarea } from '@/components/ui/textarea' -import { debugProps } from '@/lib/debug' -import { createUserQuestionAction } from '@/actions/user-questions' - -interface Props { - ideaId: string - isDemo?: boolean -} - -export function UserChatInput({ ideaId, isDemo = false }: Props) { - const router = useRouter() - const [text, setText] = useState('') - const [pending, startTransition] = useTransition() - - function submit() { - const trimmed = text.trim() - if (!trimmed) { - toast.error('Vraag mag niet leeg zijn') - return - } - startTransition(async () => { - const r = await createUserQuestionAction(ideaId, trimmed) - if ('error' in r) { - toast.error(r.error) - return - } - toast.success('Vraag verzonden — Claude gaat ermee aan de slag.') - setText('') - router.refresh() - }) - } - - if (isDemo) { - return ( - <div className="rounded-md border border-input bg-surface-container p-3" {...debugProps('user-chat-input', 'UserChatInput', 'components/ideas/user-chat-input.tsx')}> - <p className="text-xs text-muted-foreground italic"> - Demo-modus: vragen stellen is niet beschikbaar. - </p> - </div> - ) - } - - return ( - <div className="space-y-2 rounded-md border border-input bg-surface-container p-3" {...debugProps('user-chat-input', 'UserChatInput', 'components/ideas/user-chat-input.tsx')}> - <label className="text-xs font-medium text-muted-foreground"> - Stel een vraag over dit plan - </label> - <Textarea - value={text} - onChange={(e) => setText(e.target.value)} - rows={3} - placeholder="Bijv. Waarom is gekozen voor X in plaats van Y?" - disabled={pending} - data-debug-id="user-chat-input__textarea" - /> - <div className="flex justify-end"> - <Button - size="sm" - disabled={pending || !text.trim()} - onClick={submit} - data-debug-id="user-chat-input__submit" - > - <Send className="size-4" /> - {pending ? 'Bezig…' : 'Verzend'} - </Button> - </div> - </div> - ) -} diff --git a/components/jobs/job-card.tsx b/components/jobs/job-card.tsx deleted file mode 100644 index ab5184f..0000000 --- a/components/jobs/job-card.tsx +++ /dev/null @@ -1,108 +0,0 @@ -'use client' - -import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' -import { JOB_STATUS_LABELS, JOB_STATUS_COLORS } from '@/components/shared/job-status' -import { jobStatusToApi } from '@/lib/job-status' -import type { ClaudeJobKind, ClaudeJobStatus } from '@prisma/client' - -interface JobCardProps { - id: string - kind: ClaudeJobKind - status: ClaudeJobStatus - taskCode?: string | null - taskTitle?: string | null - ideaCode?: string | null - ideaTitle?: string | null - sprintGoal?: string | null - sprintCode?: string | null - productName: string - productCode?: string | null - pbiCode?: string | null - storyCode?: string | null - branch?: string | null - error?: string | null - summary?: string | null - createdAt: Date | string - startedAt?: Date | string | null - finishedAt?: Date | string | null - isSelected?: boolean - onClick?: () => void -} - -const KIND_LABELS: Record<ClaudeJobKind, string> = { - TASK_IMPLEMENTATION: 'TAAK', - SPRINT_IMPLEMENTATION: 'SPRINT', - IDEA_GRILL: 'GRILL', - IDEA_MAKE_PLAN: 'PLAN', - IDEA_REVIEW_PLAN: 'REVIEW', - PLAN_CHAT: 'CHAT', -} - -export default function JobCard({ - kind, status, taskCode, taskTitle, ideaCode, ideaTitle, - sprintGoal, sprintCode, productName, productCode, pbiCode, storyCode, - branch, error, createdAt, startedAt, finishedAt, isSelected, onClick, -}: JobCardProps) { - let titleText: string - if (kind === 'TASK_IMPLEMENTATION') { - titleText = taskCode && taskTitle ? `${taskCode} ${taskTitle}` : taskTitle || 'Taak' - } else if (kind === 'SPRINT_IMPLEMENTATION') { - if (sprintCode && sprintGoal) titleText = `${sprintCode} ${sprintGoal}` - else titleText = sprintGoal || sprintCode || 'Sprint' - } else if (kind === 'IDEA_GRILL' || kind === 'IDEA_MAKE_PLAN') { - titleText = ideaCode && ideaTitle ? `${ideaCode} ${ideaTitle}` : ideaTitle || 'Idee' - } else if (kind === 'PLAN_CHAT') { - titleText = ideaCode ? `Chat ${ideaCode}` : 'Chat' - } else { - titleText = 'Job' - } - - let breadcrumb: string - if (kind === 'TASK_IMPLEMENTATION') { - breadcrumb = [productCode ?? productName, pbiCode, storyCode].filter(Boolean).join(' ') - } else if (kind === 'IDEA_GRILL' || kind === 'IDEA_MAKE_PLAN' || kind === 'IDEA_REVIEW_PLAN' || kind === 'PLAN_CHAT') { - breadcrumb = [productCode ?? productName, ideaCode].filter(Boolean).join(' ') - } else if (kind === 'SPRINT_IMPLEMENTATION') { - breadcrumb = [productCode ?? productName, sprintCode].filter(Boolean).join(' ') - } else { - breadcrumb = productCode ?? productName - } - - const detailText = branch || (error ? error.slice(0, 80) : null) || productName - const displayDate = finishedAt ?? startedAt ?? createdAt - - const apiStatus = jobStatusToApi(status) - - return ( - <div - onClick={onClick} - className={cn( - 'border rounded-lg p-3 cursor-pointer hover:bg-surface-container transition-colors text-sm', - isSelected && 'ring-2 ring-primary', - )} - {...debugProps('job-card', 'JobCard', 'components/jobs/job-card.tsx')} - > - <div className="flex items-center gap-2" data-debug-id="job-card__status"> - <span className="text-[10px] px-1.5 py-0.5 rounded border bg-muted text-muted-foreground font-mono shrink-0"> - {KIND_LABELS[kind]} - </span> - {breadcrumb && ( - <span className="truncate font-mono text-[10px] text-muted-foreground flex-1 min-w-0"> - {breadcrumb} - </span> - )} - <span className={cn('text-xs px-2 py-0.5 rounded-full border font-medium shrink-0 ml-auto', JOB_STATUS_COLORS[apiStatus])}> - {JOB_STATUS_LABELS[apiStatus]} - </span> - </div> - <p className="font-medium truncate mt-1" data-debug-id="job-card__title">{titleText}</p> - <div className="flex items-end justify-between gap-2 mt-0.5" data-debug-id="job-card__actions"> - <p className="text-xs text-muted-foreground truncate">{detailText}</p> - <span className="text-[10px] text-muted-foreground shrink-0 tabular-nums"> - {new Date(displayDate).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })} - </span> - </div> - </div> - ) -} diff --git a/components/jobs/job-detail-pane.tsx b/components/jobs/job-detail-pane.tsx deleted file mode 100644 index a5ee13a..0000000 --- a/components/jobs/job-detail-pane.tsx +++ /dev/null @@ -1,149 +0,0 @@ -'use client' - -import { useTransition } from 'react' -import { toast } from 'sonner' -import { cn } from '@/lib/utils' -import { JOB_STATUS_LABELS, JOB_STATUS_COLORS } from '@/components/shared/job-status' -import { jobStatusToApi } from '@/lib/job-status' -import type { JobWithRelations } from '@/actions/jobs-page' -import { Button } from '@/components/ui/button' -import { debugProps } from '@/lib/debug' -import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { restartClaudeJobAction } from '@/actions/claude-jobs' - -const RESTARTABLE_API_STATUSES = new Set(['failed', 'cancelled', 'skipped']) - -interface FieldRowProps { - label: string - children: React.ReactNode -} - -function FieldRow({ label, children }: FieldRowProps) { - return ( - <div className="flex gap-2 py-1.5 border-b border-border/50 text-sm"> - <span className="w-28 shrink-0 text-muted-foreground">{label}</span> - <span className="flex-1 min-w-0">{children}</span> - </div> - ) -} - -function subjectLabel(job: JobWithRelations): { label: string; value: string } | null { - switch (job.kind) { - case 'TASK_IMPLEMENTATION': - if (!job.taskTitle) return null - return { label: 'Taak', value: job.taskCode ? `${job.taskCode} ${job.taskTitle}` : job.taskTitle } - case 'SPRINT_IMPLEMENTATION': - if (!job.sprintGoal && !job.sprintCode) return null - return { - label: 'Sprint', - value: job.sprintCode && job.sprintGoal ? `${job.sprintCode} ${job.sprintGoal}` : (job.sprintGoal ?? job.sprintCode ?? ''), - } - case 'IDEA_GRILL': - case 'IDEA_MAKE_PLAN': - case 'PLAN_CHAT': - if (!job.ideaTitle) return null - return { label: 'Idee', value: job.ideaCode ? `${job.ideaCode} ${job.ideaTitle}` : job.ideaTitle } - default: - return null - } -} - -interface JobDetailPaneProps { - job: JobWithRelations | null - isDemo: boolean -} - -export default function JobDetailPane({ job, isDemo }: JobDetailPaneProps) { - const [isPending, startTransition] = useTransition() - - if (!job) { - return ( - <div className="flex items-center justify-center h-full text-sm text-muted-foreground" {...debugProps('job-detail-pane', 'JobDetailPane', 'components/jobs/job-detail-pane.tsx')}> - Selecteer een job om details te zien - </div> - ) - } - - const apiStatus = jobStatusToApi(job.status) - const subject = subjectLabel(job) - const canRestart = RESTARTABLE_API_STATUSES.has(apiStatus) - - function handleRestart() { - startTransition(async () => { - const result = await restartClaudeJobAction(job!.id) - if ('error' in result) toast.error(result.error) - }) - } - - return ( - <div className="overflow-y-auto h-full p-4" {...debugProps('job-detail-pane', 'JobDetailPane', 'components/jobs/job-detail-pane.tsx')}> - <div data-debug-id="job-detail-pane__header"> - <FieldRow label="Status"> - <span className={cn('text-xs px-2 py-0.5 rounded-full border font-medium', JOB_STATUS_COLORS[apiStatus])}> - {JOB_STATUS_LABELS[apiStatus]} - </span> - </FieldRow> - <FieldRow label="Kind">{job.kind}</FieldRow> - <FieldRow label="Product">{job.productName}</FieldRow> - {subject && <FieldRow label={subject.label}>{subject.value}</FieldRow>} - <FieldRow label="Branch"> - <span className="font-mono text-xs break-all">{job.branch || '—'}</span> - </FieldRow> - <FieldRow label="PR"> - {job.prUrl ? ( - <a href={job.prUrl} target="_blank" rel="noreferrer" className="text-primary underline text-xs break-all"> - PR openen ↗ - </a> - ) : '—'} - </FieldRow> - <FieldRow label="Verify">{job.verifyResult ?? '—'}</FieldRow> - <FieldRow label="Aangemaakt"> - {new Date(job.createdAt).toLocaleString('nl-NL')} - </FieldRow> - <FieldRow label="Gestart"> - {job.startedAt ? new Date(job.startedAt).toLocaleString('nl-NL') : '—'} - </FieldRow> - <FieldRow label="Klaar"> - {job.finishedAt ? new Date(job.finishedAt).toLocaleString('nl-NL') : '—'} - </FieldRow> - <FieldRow label="Fout"> - {job.error ? ( - <pre className="text-xs text-status-blocked whitespace-pre-wrap break-all max-h-40 overflow-auto bg-status-blocked/5 rounded p-2"> - {job.error} - </pre> - ) : '—'} - </FieldRow> - <FieldRow label="Samenvatting"> - {job.summary ? ( - <pre className="text-xs whitespace-pre-wrap break-words max-h-40 overflow-auto bg-muted/40 rounded p-2"> - {job.summary} - </pre> - ) : '—'} - </FieldRow> - </div> - <div className="pt-3 mt-3 border-t border-border/50" data-debug-id="job-detail-pane__body"> - <p className="text-xs text-muted-foreground mb-1.5">Beschrijving</p> - {job.description ? ( - <pre className="text-xs whitespace-pre-wrap break-words bg-muted/40 rounded p-3 font-sans"> - {job.description} - </pre> - ) : ( - <p className="text-xs text-muted-foreground italic">Geen beschrijving.</p> - )} - </div> - {canRestart && ( - <div className="pt-3 mt-3 border-t border-border/50"> - <DemoTooltip show={isDemo}> - <Button - size="sm" - onClick={handleRestart} - disabled={isPending || isDemo} - > - {isPending ? 'Opnieuw starten…' : 'Opnieuw starten'} - </Button> - </DemoTooltip> - </div> - )} - </div> - ) -} diff --git a/components/jobs/job-usage-pane.tsx b/components/jobs/job-usage-pane.tsx deleted file mode 100644 index 98158a1..0000000 --- a/components/jobs/job-usage-pane.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client' - -import { debugProps } from '@/lib/debug' -import type { JobWithRelations } from '@/actions/jobs-page' - -interface FieldRowProps { - label: string - children: React.ReactNode -} - -function FieldRow({ label, children }: FieldRowProps) { - return ( - <div className="flex gap-2 py-1.5 border-b border-border/50 text-sm"> - <span className="w-32 shrink-0 text-muted-foreground">{label}</span> - <span className="flex-1 min-w-0 font-mono text-xs">{children}</span> - </div> - ) -} - -function formatNumber(n: number | null | undefined): string { - return n != null ? n.toLocaleString('nl-NL') : '—' -} - -function formatDuration(start: Date | null, end: Date | null): string { - if (!start) return '—' - const endTime = end ? new Date(end).getTime() : Date.now() - const ms = endTime - new Date(start).getTime() - if (ms < 0) return '—' - const sec = Math.floor(ms / 1000) - if (sec < 60) return `${sec}s` - const min = Math.floor(sec / 60) - const remSec = sec % 60 - if (min < 60) return `${min}m ${remSec}s` - const hr = Math.floor(min / 60) - const remMin = min % 60 - return `${hr}u ${remMin}m` -} - -interface JobUsagePaneProps { - job: JobWithRelations | null -} - -export default function JobUsagePane({ job }: JobUsagePaneProps) { - if (!job) { - return ( - <div className="flex items-center justify-center h-full text-sm text-muted-foreground" {...debugProps('job-usage-pane', 'JobUsagePane', 'components/jobs/job-usage-pane.tsx')}> - Selecteer een job om gebruik te zien - </div> - ) - } - - const totalTokens = - (job.inputTokens ?? 0) + - (job.outputTokens ?? 0) + - (job.cacheReadTokens ?? 0) + - (job.cacheWriteTokens ?? 0) - - const costLabel = job.costUsd != null ? `$${job.costUsd.toFixed(4)}` : '—' - - return ( - <div className="overflow-y-auto h-full p-4" {...debugProps('job-usage-pane', 'JobUsagePane', 'components/jobs/job-usage-pane.tsx')}> - <div data-debug-id="job-usage-pane__header"> - <FieldRow label="Model">{job.modelId ?? '—'}</FieldRow> - </div> - <div data-debug-id="job-usage-pane__table"> - <FieldRow label="Tokens invoer">{formatNumber(job.inputTokens)}</FieldRow> - <FieldRow label="Tokens uitvoer">{formatNumber(job.outputTokens)}</FieldRow> - <FieldRow label="Cache read">{formatNumber(job.cacheReadTokens)}</FieldRow> - <FieldRow label="Cache write">{formatNumber(job.cacheWriteTokens)}</FieldRow> - <FieldRow label="Tokens totaal">{formatNumber(totalTokens || null)}</FieldRow> - <FieldRow label="Kosten (USD)">{costLabel}</FieldRow> - <FieldRow label="Duur">{formatDuration(job.startedAt, job.finishedAt)}</FieldRow> - </div> - </div> - ) -} diff --git a/components/jobs/jobs-board.tsx b/components/jobs/jobs-board.tsx deleted file mode 100644 index cbd5456..0000000 --- a/components/jobs/jobs-board.tsx +++ /dev/null @@ -1,111 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { Button } from '@/components/ui/button' -import { SplitPane } from '@/components/split-pane/split-pane' -import JobsColumn from './jobs-column' -import JobDetailPane from './job-detail-pane' -import JobUsagePane from './job-usage-pane' -import SprintSubTasksPane from './sprint-sub-tasks-pane' -import { debugProps } from '@/lib/debug' -import { useJobsStore } from '@/stores/jobs-store' -import useJobsRealtime from '@/hooks/use-jobs-realtime' -import type { ClaudeJobStatusApi } from '@/lib/job-status' -import type { JobWithRelations } from '@/actions/jobs-page' - -interface JobsBoardProps { - initialActiveJobs: JobWithRelations[] - initialDoneJobs: JobWithRelations[] - isDemo: boolean -} - -type View = 'detail' | 'usage' - -const ACTIVE_STATUS_OPTIONS: Array<{ value: ClaudeJobStatusApi; label: string }> = [ - { value: 'queued', label: 'Wacht…' }, - { value: 'claimed', label: 'Geclaimd…' }, - { value: 'running', label: 'Bezig…' }, -] - -const DONE_STATUS_OPTIONS: Array<{ value: ClaudeJobStatusApi; label: string }> = [ - { value: 'done', label: 'Klaar' }, - { value: 'failed', label: 'Mislukt' }, - { value: 'cancelled', label: 'Geannuleerd' }, - { value: 'skipped', label: 'Overgeslagen' }, -] - -export default function JobsBoard({ initialActiveJobs, initialDoneJobs, isDemo }: JobsBoardProps) { - const { activeJobs, doneJobs, selectedJobId, initJobs, setSelectedJobId } = useJobsStore() - const [view, setView] = useState<View>('detail') - useJobsRealtime() - - // eslint-disable-next-line react-hooks/exhaustive-deps - useEffect(() => { initJobs(initialActiveJobs, initialDoneJobs) }, []) - - const selectedJob = [...activeJobs, ...doneJobs].find(j => j.id === selectedJobId) ?? null - - const leftPane = ( - <JobsColumn - title="Actief" - jobs={activeJobs} - selectedJobId={selectedJobId} - onSelect={setSelectedJobId} - storageKeyPrefix="scrum4me:jobs_active" - statusOptions={ACTIVE_STATUS_OPTIONS} - emptyText="Geen actieve jobs" - /> - ) - - const middlePane = ( - <div className="flex flex-col h-full overflow-hidden"> - <SprintSubTasksPane - jobId={selectedJobId} - isSprintJob={selectedJob?.kind === 'SPRINT_IMPLEMENTATION'} - /> - <div className="flex gap-1 px-3 pt-3 pb-2 border-b shrink-0" data-debug-id="jobs-board__toolbar"> - <Button - size="sm" - variant={view === 'detail' ? 'default' : 'outline'} - onClick={() => setView('detail')} - > - Detail - </Button> - <Button - size="sm" - variant={view === 'usage' ? 'default' : 'outline'} - onClick={() => setView('usage')} - > - Usage - </Button> - </div> - <div className="flex-1 overflow-y-auto"> - {view === 'detail' ? <JobDetailPane job={selectedJob} isDemo={isDemo} /> : <JobUsagePane job={selectedJob} />} - </div> - </div> - ) - - const rightPane = ( - <JobsColumn - title="Klaar" - jobs={doneJobs} - selectedJobId={selectedJobId} - onSelect={setSelectedJobId} - storageKeyPrefix="scrum4me:jobs_done" - statusOptions={DONE_STATUS_OPTIONS} - emptyText="Nog geen afgeronde jobs" - /> - ) - - return ( - <div className="h-full" {...debugProps('jobs-board', 'JobsBoard', 'components/jobs/jobs-board.tsx')}> - <div className="h-full" data-debug-id="jobs-board__columns"> - <SplitPane - panes={[leftPane, middlePane, rightPane]} - defaultSplit={[25, 50, 25]} - cookieKey="jobs" - tabLabels={['Actief', 'Details', 'Klaar']} - /> - </div> - </div> - ) -} diff --git a/components/jobs/jobs-column.tsx b/components/jobs/jobs-column.tsx deleted file mode 100644 index b4b53d7..0000000 --- a/components/jobs/jobs-column.tsx +++ /dev/null @@ -1,218 +0,0 @@ -'use client' - -import { useMemo } from 'react' -import { useShallow } from 'zustand/react/shallow' -import { Button } from '@/components/ui/button' -import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' -import JobCard from './job-card' -import { JOB_STATUS_LABELS } from '@/components/shared/job-status' -import { MultiFilterPills } from '@/components/shared/backlog-filter-popover' -import { jobStatusToApi, type ClaudeJobStatusApi } from '@/lib/job-status' -import { useUserSettingsStore } from '@/stores/user-settings/store' -import { isWithinTimeWindow, DEFAULT_JOBS_TIME_FILTER } from '@/lib/jobs-time-filter' -import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' -import type { JobWithRelations } from '@/actions/jobs-page' -import type { ClaudeJobKind } from '@prisma/client' - -const KIND_LABELS: Record<ClaudeJobKind, string> = { - TASK_IMPLEMENTATION: 'TAAK', - SPRINT_IMPLEMENTATION: 'SPRINT', - IDEA_GRILL: 'GRILL', - IDEA_MAKE_PLAN: 'PLAN', - IDEA_REVIEW_PLAN: 'REVIEW', - PLAN_CHAT: 'CHAT', -} - -const KIND_OPTIONS: Array<{ value: ClaudeJobKind; label: string }> = [ - { value: 'TASK_IMPLEMENTATION', label: 'TAAK' }, - { value: 'SPRINT_IMPLEMENTATION', label: 'SPRINT' }, - { value: 'IDEA_GRILL', label: 'GRILL' }, - { value: 'IDEA_MAKE_PLAN', label: 'PLAN' }, - { value: 'IDEA_REVIEW_PLAN', label: 'REVIEW' }, - { value: 'PLAN_CHAT', label: 'CHAT' }, -] - -const KIND_VALUES = new Set<ClaudeJobKind>(KIND_OPTIONS.map((o) => o.value)) - -interface JobsColumnProps { - title: string - jobs: JobWithRelations[] - selectedJobId: string | null - onSelect: (id: string) => void - storageKeyPrefix: string - statusOptions: Array<{ value: ClaudeJobStatusApi; label: string }> - emptyText: string -} - -export default function JobsColumn({ - title, - jobs, - selectedJobId, - onSelect, - storageKeyPrefix, - statusOptions, - emptyText, -}: JobsColumnProps) { - const allowedStatuses = useMemo( - () => new Set<ClaudeJobStatusApi>(statusOptions.map((o) => o.value)), - [statusOptions], - ) - const colPrefs = useUserSettingsStore( - useShallow((s) => s.entities.settings.views?.jobsColumns?.[storageKeyPrefix]), - ) - const timeFilter = useUserSettingsStore( - useShallow((s) => s.entities.settings.views?.jobs?.timeFilter), - ) ?? DEFAULT_JOBS_TIME_FILTER - const setPref = useUserSettingsStore((s) => s.setPref) - - const filterKinds = useMemo<Set<ClaudeJobKind>>(() => { - const out = new Set<ClaudeJobKind>() - for (const v of colPrefs?.kinds ?? []) { - if (KIND_VALUES.has(v as ClaudeJobKind)) out.add(v as ClaudeJobKind) - } - return out - }, [colPrefs?.kinds]) - - const filterStatuses = useMemo<Set<ClaudeJobStatusApi>>(() => { - const out = new Set<ClaudeJobStatusApi>() - for (const v of colPrefs?.statuses ?? []) { - if (allowedStatuses.has(v as ClaudeJobStatusApi)) out.add(v as ClaudeJobStatusApi) - } - return out - }, [colPrefs?.statuses, allowedStatuses]) - - function persist(kinds: Set<ClaudeJobKind>, statuses: Set<ClaudeJobStatusApi>) { - void setPref(['views', 'jobsColumns', storageKeyPrefix], { - kinds: Array.from(kinds), - statuses: Array.from(statuses), - }) - } - - function toggleKind(v: ClaudeJobKind) { - const next = new Set(filterKinds) - if (next.has(v)) next.delete(v) - else next.add(v) - persist(next, filterStatuses) - } - - function toggleStatus(v: ClaudeJobStatusApi) { - const next = new Set(filterStatuses) - if (next.has(v)) next.delete(v) - else next.add(v) - persist(filterKinds, next) - } - - const filtered = jobs.filter((j) => { - if (!isWithinTimeWindow(j.createdAt, timeFilter)) return false - if (filterKinds.size > 0 && !filterKinds.has(j.kind)) return false - if (filterStatuses.size > 0 && !filterStatuses.has(jobStatusToApi(j.status))) return false - return true - }) - - const activeFilterCount = filterKinds.size + filterStatuses.size - - return ( - <div className="flex flex-col h-full" {...debugProps('jobs-column', 'JobsColumn', 'components/jobs/jobs-column.tsx')}> - <div className="flex items-center justify-between gap-2 px-2 py-1.5 border-b border-border bg-surface-container-low shrink-0" data-debug-id="jobs-column__header"> - <span className="text-xs font-medium text-muted-foreground px-1">{title}</span> - <div className="flex items-center gap-1.5 flex-wrap justify-end"> - {Array.from(filterKinds).map((k) => ( - <button - key={`k-${k}`} - type="button" - onClick={() => toggleKind(k)} - className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded border bg-muted text-muted-foreground hover:bg-surface-container font-mono" - aria-label={`Wis filter ${KIND_LABELS[k]}`} - > - <span>{KIND_LABELS[k]}</span> - <span aria-hidden>×</span> - </button> - ))} - {Array.from(filterStatuses).map((s) => ( - <button - key={`s-${s}`} - type="button" - onClick={() => toggleStatus(s)} - className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full border bg-muted text-muted-foreground hover:bg-surface-container" - aria-label={`Wis filter ${JOB_STATUS_LABELS[s]}`} - > - <span>{JOB_STATUS_LABELS[s]}</span> - <span aria-hidden>×</span> - </button> - ))} - <Popover> - <PopoverTrigger - render={ - <Button variant="outline" size="sm" className="h-7 text-xs"> - {`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`} - </Button> - } - /> - <PopoverContent align="end" className="w-72 space-y-4"> - <MultiFilterPills - label="Soort" - options={KIND_OPTIONS} - selected={filterKinds} - onToggle={toggleKind} - onClear={() => persist(new Set(), filterStatuses)} - /> - <MultiFilterPills - label="Status" - options={statusOptions} - selected={filterStatuses} - onToggle={toggleStatus} - onClear={() => persist(filterKinds, new Set())} - /> - <div className="flex justify-end pt-1 border-t border-border"> - <Button - type="button" - variant="ghost" - size="sm" - className="h-7 text-xs" - disabled={activeFilterCount === 0} - onClick={() => persist(new Set(), new Set())} - > - Wis filters - </Button> - </div> - </PopoverContent> - </Popover> - </div> - </div> - <div className="overflow-y-auto flex-1 p-2 space-y-2" data-debug-id="jobs-column__items"> - {filtered.map((j) => ( - <JobCard - key={j.id} - id={j.id} - kind={j.kind} - status={j.status} - taskCode={j.taskCode} - taskTitle={j.taskTitle} - ideaCode={j.ideaCode} - ideaTitle={j.ideaTitle} - sprintGoal={j.sprintGoal} - sprintCode={j.sprintCode} - productName={j.productName} - productCode={j.productCode} - pbiCode={j.pbiCode} - storyCode={j.storyCode} - branch={j.branch} - error={j.error} - summary={j.summary} - createdAt={j.createdAt} - startedAt={j.startedAt} - finishedAt={j.finishedAt} - isSelected={j.id === selectedJobId} - onClick={() => onSelect(j.id)} - /> - ))} - {filtered.length === 0 && ( - <p className="text-sm text-muted-foreground text-center py-8"> - {jobs.length === 0 ? emptyText : 'Geen jobs voldoen aan filter'} - </p> - )} - </div> - </div> - ) -} diff --git a/components/jobs/jobs-time-filter.tsx b/components/jobs/jobs-time-filter.tsx deleted file mode 100644 index 9615003..0000000 --- a/components/jobs/jobs-time-filter.tsx +++ /dev/null @@ -1,48 +0,0 @@ -'use client' - -import { useShallow } from 'zustand/react/shallow' -import { useUserSettingsStore } from '@/stores/user-settings/store' -import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' -import { - JOBS_TIME_FILTER_VALUES, - DEFAULT_JOBS_TIME_FILTER, - type JobsTimeFilter, -} from '@/lib/jobs-time-filter' - -const LABELS: Record<JobsTimeFilter, string> = { - '1h': '1 uur', - '24h': '24 uur', - all: 'Alles', -} - -export default function JobsTimeFilterControl() { - const current = - useUserSettingsStore( - useShallow((s) => s.entities.settings.views?.jobs?.timeFilter), - ) ?? DEFAULT_JOBS_TIME_FILTER - const setPref = useUserSettingsStore((s) => s.setPref) - - return ( - <div - className="flex items-center gap-1.5" - {...debugProps('jobs-time-filter', 'JobsTimeFilter', 'components/jobs/jobs-time-filter.tsx')} - > - {JOBS_TIME_FILTER_VALUES.map((v) => ( - <button - key={v} - type="button" - onClick={() => void setPref(['views', 'jobs', 'timeFilter'], v)} - className={cn( - 'text-xs px-2.5 py-1 rounded-full border transition-colors', - current === v - ? 'bg-primary text-primary-foreground border-primary' - : 'bg-transparent border-border hover:bg-surface-container', - )} - > - {LABELS[v]} - </button> - ))} - </div> - ) -} diff --git a/components/jobs/sprint-sub-tasks-pane.tsx b/components/jobs/sprint-sub-tasks-pane.tsx deleted file mode 100644 index b59df9a..0000000 --- a/components/jobs/sprint-sub-tasks-pane.tsx +++ /dev/null @@ -1,72 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' -import { JOB_STATUS_LABELS, JOB_STATUS_COLORS } from '@/components/shared/job-status' -import type { ClaudeJobStatusApi } from '@/lib/job-status' - -type SubTask = { - id: string - taskCode: string | null - taskTitle: string - status: string -} - -interface SprintSubTasksPaneProps { - jobId: string | null - isSprintJob: boolean -} - -function SubTaskList({ jobId }: { jobId: string }) { - const [subTasks, setSubTasks] = useState<SubTask[]>([]) - const [loading, setLoading] = useState(true) - - useEffect(() => { - const controller = new AbortController() - - fetch(`/api/jobs/${jobId}/sub-tasks`, { signal: controller.signal }) - .then(res => res.json()) - .then((data: SubTask[]) => { - setSubTasks(data) - setLoading(false) - }) - .catch(() => { - setLoading(false) - }) - - return () => controller.abort() - }, [jobId]) - - if (loading) { - return <div className="text-xs text-muted-foreground p-3">Laden…</div> - } - - if (subTasks.length === 0) return null - - return ( - <div className="border-b p-2 space-y-1 max-h-44 overflow-y-auto shrink-0" data-debug-id="sprint-sub-tasks-pane__items"> - {subTasks.map(t => { - const apiStatus = t.status.toLowerCase() as ClaudeJobStatusApi - return ( - <div key={t.id} className="flex items-center gap-2 py-1 px-2 rounded hover:bg-surface-container text-sm"> - <span className="text-xs font-mono text-muted-foreground w-16 shrink-0 truncate">{t.taskCode}</span> - <span className="flex-1 truncate">{t.taskTitle}</span> - <span className={cn('text-xs px-1.5 py-0.5 rounded-full border', JOB_STATUS_COLORS[apiStatus])}> - {JOB_STATUS_LABELS[apiStatus] ?? t.status} - </span> - </div> - ) - })} - </div> - ) -} - -export default function SprintSubTasksPane({ jobId, isSprintJob }: SprintSubTasksPaneProps) { - if (!isSprintJob || !jobId) return null - return ( - <div {...debugProps('sprint-sub-tasks-pane', 'SprintSubTasksPane', 'components/jobs/sprint-sub-tasks-pane.tsx')}> - <SubTaskList key={jobId} jobId={jobId} /> - </div> - ) -} diff --git a/components/loading/backlog-page-skeleton.tsx b/components/loading/backlog-page-skeleton.tsx deleted file mode 100644 index 79666b6..0000000 --- a/components/loading/backlog-page-skeleton.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { Skeleton } from '@/components/ui/skeleton' - -export default function BacklogPageSkeleton() { - return ( - <div className="flex flex-col h-full"> - <div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-between"> - <div className="space-y-1.5"> - <Skeleton className="h-4 w-32" /> - <Skeleton className="h-3 w-48" /> - </div> - <Skeleton className="h-7 w-24" /> - </div> - - <div className="flex-1 flex overflow-hidden"> - <div className="w-2/5 border-r border-border p-4 space-y-3"> - <Skeleton className="h-4 w-24" /> - {[1, 2, 3, 4, 5].map((i) => ( - <Skeleton key={i} className="h-8 w-full" /> - ))} - </div> - <div className="flex-1 p-4 space-y-3"> - <Skeleton className="h-4 w-16" /> - <div className="flex gap-2 flex-wrap"> - {[1, 2, 3].map((i) => ( - <Skeleton key={i} className="w-28 h-24 rounded-lg" /> - ))} - </div> - </div> - </div> - </div> - ) -} diff --git a/components/markdown.tsx b/components/markdown.tsx index 1541d7e..2b07fa8 100644 --- a/components/markdown.tsx +++ b/components/markdown.tsx @@ -1,7 +1,6 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' interface MarkdownProps { children: string @@ -10,7 +9,7 @@ interface MarkdownProps { export function Markdown({ children, className }: MarkdownProps) { return ( - <div className={cn('prose prose-sm dark:prose-invert max-w-none', className)} {...debugProps('markdown', 'Markdown', 'components/markdown.tsx')}> + <div className={cn('prose prose-sm dark:prose-invert max-w-none', className)}> <ReactMarkdown remarkPlugins={[remarkGfm]} disallowedElements={['script', 'iframe']} diff --git a/components/mobile/landscape-guard.tsx b/components/mobile/landscape-guard.tsx deleted file mode 100644 index c344d92..0000000 --- a/components/mobile/landscape-guard.tsx +++ /dev/null @@ -1,34 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { RotateCw } from 'lucide-react' -import { debugProps } from '@/lib/debug' - -export function LandscapeGuard({ children }: { children: React.ReactNode }) { - const [isPortrait, setIsPortrait] = useState(false) - - useEffect(() => { - const mq = window.matchMedia('(orientation: portrait)') - const update = () => setIsPortrait(mq.matches) - update() - mq.addEventListener('change', update) - return () => mq.removeEventListener('change', update) - }, []) - - return ( - <> - {children} - {isPortrait && ( - <div - role="alert" - aria-live="assertive" - className="fixed inset-0 z-50 flex flex-col items-center justify-center gap-4 bg-background text-foreground p-6" - {...debugProps('landscape-guard', 'LandscapeGuard', 'components/mobile/landscape-guard.tsx')} - > - <RotateCw className="size-12 text-primary" /> - <p className="text-base font-medium text-center" data-debug-id="landscape-guard__title">Draai je telefoon naar landscape</p> - </div> - )} - </> - ) -} diff --git a/components/mobile/logout-button.tsx b/components/mobile/logout-button.tsx deleted file mode 100644 index 394cc04..0000000 --- a/components/mobile/logout-button.tsx +++ /dev/null @@ -1,58 +0,0 @@ -'use client' - -import { useState, useTransition } from 'react' -import { LogOut } from 'lucide-react' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' -import { Button } from '@/components/ui/button' -import { logoutAction } from '@/actions/auth' -import { debugProps } from '@/lib/debug' - -export function LogoutButton() { - const [open, setOpen] = useState(false) - const [pending, startTransition] = useTransition() - - function confirm() { - startTransition(async () => { - await logoutAction() - }) - } - - return ( - <> - <Button - variant="outline" - onClick={() => setOpen(true)} - className="w-full justify-center gap-2" - {...debugProps('logout-button', 'LogoutButton', 'components/mobile/logout-button.tsx')} - > - <LogOut className="size-4" aria-hidden="true" /> - Uitloggen - </Button> - <AlertDialog open={open} onOpenChange={setOpen}> - <AlertDialogContent size="sm"> - <AlertDialogHeader> - <AlertDialogTitle>Uitloggen?</AlertDialogTitle> - <AlertDialogDescription> - Weet je zeker dat je wilt uitloggen? - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel onClick={() => setOpen(false)}>Annuleren</AlertDialogCancel> - <AlertDialogAction disabled={pending} onClick={confirm}> - {pending ? 'Bezig…' : 'Uitloggen'} - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - </> - ) -} diff --git a/components/mobile/mobile-tab-bar.tsx b/components/mobile/mobile-tab-bar.tsx deleted file mode 100644 index b29fb36..0000000 --- a/components/mobile/mobile-tab-bar.tsx +++ /dev/null @@ -1,71 +0,0 @@ -'use client' - -import Link from 'next/link' -import { usePathname } from 'next/navigation' -import { ListTree, Activity, Settings } from 'lucide-react' -import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' - -interface MobileTabBarProps { - activeProductId: string | null -} - -export function MobileTabBar({ activeProductId }: MobileTabBarProps) { - const pathname = usePathname() - - const tabs: Array<{ href: string; icon: typeof ListTree; label: string; match: (p: string) => boolean }> = [] - - if (activeProductId) { - tabs.push( - { - href: `/m/products/${activeProductId}`, - icon: ListTree, - label: 'Backlog', - match: (p) => p === `/m/products/${activeProductId}`, - }, - { - href: `/m/products/${activeProductId}/solo`, - icon: Activity, - label: 'Solo', - match: (p) => p.startsWith(`/m/products/${activeProductId}/solo`), - }, - ) - } - - tabs.push({ - href: '/m/settings', - icon: Settings, - label: 'Settings', - match: (p) => p.startsWith('/m/settings'), - }) - - return ( - <nav - aria-label="Hoofdnavigatie" - className="fixed bottom-0 left-0 right-0 z-40 flex border-t border-border bg-surface-container-low" - {...debugProps('mobile-tab-bar', 'MobileTabBar', 'components/mobile/mobile-tab-bar.tsx')} - > - {tabs.map((tab) => { - const Icon = tab.icon - const active = tab.match(pathname) - return ( - <Link - key={tab.href} - href={tab.href} - aria-label={tab.label} - aria-current={active ? 'page' : undefined} - data-debug-id={`mobile-tab-bar__tab-${tab.label.toLowerCase()}`} - className={cn( - 'flex-1 h-14 flex items-center justify-center transition-colors', - active - ? 'bg-primary-container text-primary-container-foreground' - : 'text-muted-foreground hover:text-foreground', - )} - > - <Icon className="size-5" aria-hidden="true" /> - </Link> - ) - })} - </nav> - ) -} diff --git a/components/notifications/answer-modal.tsx b/components/notifications/answer-modal.tsx index 8a8d05b..cbe574d 100644 --- a/components/notifications/answer-modal.tsx +++ b/components/notifications/answer-modal.tsx @@ -2,11 +2,11 @@ // ST-1105: Modal waar de gebruiker een Claude-vraag beantwoordt (M11). // -// Free-text Textarea (max ANSWER_MAX_CHARS) of multiple-choice via knoppen -// wanneer de vraag `options` heeft. Submit roept answerQuestion-Server-Action -// aan via useTransition; bij succes wordt de vraag uit de store verwijderd +// Free-text Textarea (max 4000) of multiple-choice via knoppen wanneer de +// vraag `options` heeft. Submit roept answerQuestion-Server-Action aan via +// useTransition; bij succes wordt de vraag uit de store verwijderd // (optimistisch) en sluit de modal. Demo-modus: textarea readOnly + submit -// disabled met DemoTooltip. +// disabled met tooltip. import { useState, useTransition } from 'react' import Link from 'next/link' @@ -16,25 +16,17 @@ import { Dialog, DialogContent, DialogDescription, + DialogHeader, DialogTitle, + DialogFooter, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' -import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { - useDirtyCloseGuard, - DirtyCloseGuardDialog, -} from '@/components/shared/use-dirty-close-guard' -import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut' -import { - entityDialogContentClasses, - entityDialogFooterClasses, - entityDialogHeaderClasses, -} from '@/components/shared/entity-dialog-layout' -import { ANSWER_MAX_CHARS } from '@/lib/schemas/question-answer' +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { answerQuestion } from '@/actions/questions' import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store' -import { debugProps } from '@/lib/debug' + +const MAX_ANSWER_CHARS = 4000 interface AnswerModalProps { question: NotificationQuestion | null @@ -46,23 +38,26 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) { const [answer, setAnswer] = useState('') const [pending, startTransition] = useTransition() - const closeGuard = useDirtyCloseGuard(answer.trim().length > 0, () => { - setAnswer('') - onClose() - }) + if (!question) return null - const charsLeft = ANSWER_MAX_CHARS - answer.length + const charsLeft = MAX_ANSWER_CHARS - answer.length const tooLong = charsLeft < 0 const submitDisabled = isDemo || pending || answer.trim().length === 0 || tooLong function submit(text: string) { if (!question) return + if (isDemo) { + toast.error('Niet beschikbaar in demo-modus') + return + } startTransition(async () => { const res = await answerQuestion(question.id, text) if (!res.ok) { toast.error(res.error) return } + // Optimistisch verwijderen — SSE-event komt anders later met dezelfde + // remove en kost een extra render useNotificationsStore.getState().remove(question.id) toast.success('Antwoord verstuurd') setAnswer('') @@ -70,115 +65,93 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) { }) } - const handleKeyDown = useDialogSubmitShortcut(() => { - if (!submitDisabled) submit(answer) - }) - - if (!question) return null - return ( - <> - <Dialog open={!!question} onOpenChange={(open) => { if (!open) closeGuard.attemptClose() }}> - <DialogContent - showCloseButton={false} - onKeyDown={handleKeyDown} - className={entityDialogContentClasses} - {...debugProps('answer-modal', 'AnswerModal', 'components/notifications/answer-modal.tsx')} + <Dialog open={!!question} onOpenChange={(open) => !open && onClose()}> + <DialogContent className="sm:max-w-lg"> + <DialogHeader> + <DialogTitle>Beantwoord Claude</DialogTitle> + <DialogDescription> + <span className="font-mono">{question.story_code ?? 'story'}</span> + {' — '} + {question.story_title} + </DialogDescription> + </DialogHeader> + <Link + href={`/products/${question.product_id}/sprint`} + className="text-primary inline-flex items-center gap-1 self-start text-xs hover:underline" > - <div className={entityDialogHeaderClasses}> - <div className="flex flex-col gap-1"> - <DialogTitle className="text-xl font-semibold">Beantwoord Claude</DialogTitle> - <DialogDescription> - <span className="font-mono"> - {question.kind === 'idea' ? question.idea_code : (question.story_code ?? 'story')} - </span> - {' — '} - {question.kind === 'idea' ? question.idea_title : question.story_title} - </DialogDescription> - </div> - </div> + <ExternalLink className="h-3.5 w-3.5" /> + <span>Open in Sprint</span> + </Link> - <div className="flex-1 overflow-y-auto px-6 py-6 space-y-6" data-debug-id="answer-modal__content"> - <Link - href={ - question.kind === 'idea' - ? `/ideas/${question.idea_id}?tab=timeline` - : `/products/${question.product_id}/sprint` - } - className="text-primary inline-flex items-center gap-1 self-start text-xs hover:underline" - > - <ExternalLink className="h-3.5 w-3.5" /> - <span>{question.kind === 'idea' ? 'Open idee' : 'Open in Sprint'}</span> - </Link> + <div className="bg-surface-container-low rounded-md border p-3 text-sm whitespace-pre-wrap"> + {question.question} + </div> - <div className="bg-surface-container-low rounded-md border p-3 text-sm whitespace-pre-wrap"> - {question.question} - </div> - - {question.options?.length ? ( - <div className="space-y-2"> - <p className="text-muted-foreground text-xs">Kies een van de opties:</p> - <div className="flex flex-col gap-2"> - {question.options.map((opt) => ( - <DemoTooltip key={opt} show={isDemo}> - <Button - type="button" - variant="outline" - className="justify-start" - disabled={isDemo || pending} - onClick={() => submit(opt)} - > - {opt} - </Button> - </DemoTooltip> - ))} - </div> - </div> - ) : null} - - <div className={question.options?.length ? 'space-y-1 border-t pt-4' : 'space-y-1'}> - {question.options?.length ? ( - <p className="text-muted-foreground text-xs">Of typ een eigen antwoord</p> - ) : null} - <Textarea - value={answer} - onChange={(e) => setAnswer(e.target.value)} - placeholder="Typ je antwoord…" - rows={5} - maxLength={ANSWER_MAX_CHARS} - disabled={isDemo} - aria-label="Antwoord op Claude's vraag" - /> - <div - className={ - tooLong - ? 'text-error text-right text-xs' - : 'text-muted-foreground text-right text-xs' - } - > - {charsLeft} tekens over - </div> - </div> - </div> - - <div className={entityDialogFooterClasses} data-debug-id="answer-modal__submit"> - <div className="flex justify-end gap-2"> - <Button variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}> - Annuleren - </Button> - <DemoTooltip show={isDemo}> + {question.options && question.options.length > 0 ? ( + <div className="space-y-2"> + <p className="text-muted-foreground text-xs">Kies een van de opties:</p> + <div className="flex flex-col gap-2"> + {question.options.map((opt) => ( <Button - onClick={() => submit(answer)} - disabled={submitDisabled} + key={opt} + type="button" + variant="outline" + className="justify-start" + disabled={isDemo || pending} + onClick={() => submit(opt)} > - {pending ? 'Bezig…' : 'Verstuur'} + {opt} </Button> - </DemoTooltip> + ))} </div> </div> - </DialogContent> - </Dialog> - <DirtyCloseGuardDialog guard={closeGuard} /> - </> + ) : ( + <div className="space-y-1"> + <Textarea + value={answer} + onChange={(e) => setAnswer(e.target.value)} + placeholder="Typ je antwoord…" + rows={5} + maxLength={MAX_ANSWER_CHARS} + readOnly={isDemo} + aria-label="Antwoord op Claude's vraag" + /> + <div + className={ + tooLong + ? 'text-error text-right text-xs' + : 'text-muted-foreground text-right text-xs' + } + > + {charsLeft} tekens over + </div> + </div> + )} + + <DialogFooter> + <Button variant="ghost" onClick={onClose} disabled={pending}> + Annuleren + </Button> + {(!question.options || question.options.length === 0) && ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger render={<span className="inline-flex" />}> + <Button + onClick={() => submit(answer)} + disabled={submitDisabled} + > + {pending ? 'Bezig…' : 'Verstuur'} + </Button> + </TooltipTrigger> + {isDemo && ( + <TooltipContent>Niet beschikbaar in demo-modus</TooltipContent> + )} + </Tooltip> + </TooltipProvider> + )} + </DialogFooter> + </DialogContent> + </Dialog> ) } diff --git a/components/notifications/notifications-bridge.tsx b/components/notifications/notifications-bridge.tsx index 604e1ed..11128b2 100644 --- a/components/notifications/notifications-bridge.tsx +++ b/components/notifications/notifications-bridge.tsx @@ -28,10 +28,6 @@ export async function NotificationsBridge({ userId }: NotificationsBridgeProps) status: 'open', expires_at: { gt: new Date() }, product_id: { in: productIds }, - // Skip idea-questions (story_id NULL): they have a separate - // realtime channel and aren't shown in this product-scoped bell. - // Narrowing happens in the flatMap below — Prisma 7 rejects - // `story_id: { not: null }` at runtime. }, orderBy: { created_at: 'desc' }, take: 100, @@ -48,64 +44,19 @@ export async function NotificationsBridge({ userId }: NotificationsBridgeProps) }, }) - const storyQuestions: NotificationQuestion[] = openQuestions.flatMap((q) => { - if (!q.story || q.story_id === null) return [] - return [{ - kind: 'story' as const, - id: q.id, - product_id: q.product_id, - story_id: q.story_id, - task_id: q.task_id, - story_code: q.story.code, - story_title: q.story.title, - assignee_id: q.story.assignee_id, - question: q.question, - options: Array.isArray(q.options) ? (q.options as string[]) : null, - created_at: q.created_at.toISOString(), - expires_at: q.expires_at.toISOString(), - }] - }) + const initial: NotificationQuestion[] = openQuestions.map((q) => ({ + id: q.id, + product_id: q.product_id, + story_id: q.story_id, + task_id: q.task_id, + story_code: q.story.code, + story_title: q.story.title, + assignee_id: q.story.assignee_id, + question: q.question, + options: Array.isArray(q.options) ? (q.options as string[]) : null, + created_at: q.created_at.toISOString(), + expires_at: q.expires_at.toISOString(), + })) - // M12 hotfix: idea-questions ook in de bel. user_id-only scope (idee is privé). - const ideaOpenQuestions = await prisma.claudeQuestion.findMany({ - where: { - status: 'open', - expires_at: { gt: new Date() }, - idea: { user_id: userId }, - }, - orderBy: { created_at: 'desc' }, - take: 100, - select: { - id: true, - product_id: true, - idea_id: true, - question: true, - options: true, - created_at: true, - expires_at: true, - idea: { select: { id: true, code: true, title: true } }, - }, - }) - - const ideaQuestions: NotificationQuestion[] = ideaOpenQuestions.flatMap((q) => { - if (!q.idea || q.idea_id === null) return [] - return [{ - kind: 'idea' as const, - id: q.id, - product_id: q.product_id, - idea_id: q.idea_id, - idea_code: q.idea.code, - idea_title: q.idea.title, - question: q.question, - options: Array.isArray(q.options) ? (q.options as string[]) : null, - created_at: q.created_at.toISOString(), - expires_at: q.expires_at.toISOString(), - }] - }) - - const initial: NotificationQuestion[] = [...storyQuestions, ...ideaQuestions] - .sort((a, b) => (a.created_at < b.created_at ? 1 : -1)) - - // debug-id: skip — bridge component (niet-renderende wrapper, zie docs/patterns/debug-id.md) return <NotificationsRealtimeMount initial={initial} /> } diff --git a/components/notifications/notifications-realtime-mount.tsx b/components/notifications/notifications-realtime-mount.tsx index ade82dc..fa75550 100644 --- a/components/notifications/notifications-realtime-mount.tsx +++ b/components/notifications/notifications-realtime-mount.tsx @@ -19,6 +19,5 @@ export function NotificationsRealtimeMount({ initial }: Props) { }, [initial]) useNotificationsRealtime() - // debug-id: skip — render-loos mount (returns null, zie docs/patterns/debug-id.md) return null } diff --git a/components/notifications/notifications-sheet.tsx b/components/notifications/notifications-sheet.tsx index bb1247b..cd617da 100644 --- a/components/notifications/notifications-sheet.tsx +++ b/components/notifications/notifications-sheet.tsx @@ -17,10 +17,8 @@ import { } from '@/components/ui/sheet' import { useNotificationsStore } from '@/stores/notifications-store' import { AnswerModal } from './answer-modal' -import { PushToggle } from './push-toggle' import { cn } from '@/lib/utils' import type { NotificationQuestion } from '@/stores/notifications-store' -import { debugProps } from '@/lib/debug' interface NotificationsSheetProps { trigger: React.ReactNode @@ -41,8 +39,8 @@ export function NotificationsSheet({ <> <Sheet open={open} onOpenChange={setOpen}> <SheetTrigger render={trigger as React.ReactElement} /> - <SheetContent side="right" className="w-full sm:max-w-md" {...debugProps('notifications-sheet', 'NotificationsSheet', 'components/notifications/notifications-sheet.tsx')}> - <SheetHeader data-debug-id="notifications-sheet__header"> + <SheetContent side="right" className="w-full sm:max-w-md"> + <SheetHeader> <SheetTitle>Vragen van Claude ({questions.length})</SheetTitle> <SheetDescription> Beantwoord open vragen om Claude verder te laten werken. @@ -54,14 +52,9 @@ export function NotificationsSheet({ Geen openstaande vragen. Lekker bezig! </div> ) : ( - <ul className="mt-4 flex flex-col gap-2 px-4 pb-4" data-debug-id="notifications-sheet__items"> + <ul className="mt-4 flex flex-col gap-2 px-4 pb-4"> {questions.map((q) => { - // story-questions: forYou wanneer assignee = ingelogd; idee-vragen - // zijn altijd "voor jou" (idee is strikt user_id-only). - const forYou = - q.kind === 'idea' || q.assignee_id === currentUserId - const code = q.kind === 'idea' ? q.idea_code : (q.story_code ?? '—') - const title = q.kind === 'idea' ? q.idea_title : q.story_title + const forYou = q.assignee_id === currentUserId return ( <li key={q.id}> <button @@ -74,8 +67,12 @@ export function NotificationsSheet({ )} > <div className="flex items-baseline gap-2"> - <span className="font-mono text-xs opacity-80">{code}</span> - <span className="line-clamp-1 text-sm font-medium">{title}</span> + <span className="font-mono text-xs opacity-80"> + {q.story_code ?? '—'} + </span> + <span className="line-clamp-1 text-sm font-medium"> + {q.story_title} + </span> </div> <p className={cn( @@ -96,12 +93,6 @@ export function NotificationsSheet({ })} </ul> )} - <div className="border-border mt-4 border-t px-4 pt-4 pb-2"> - <p className="text-muted-foreground mb-2 text-xs font-medium uppercase tracking-wide"> - Notificatie-instellingen - </p> - <PushToggle vapidPublicKey={process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY} /> - </div> </SheetContent> </Sheet> diff --git a/components/notifications/push-toggle.tsx b/components/notifications/push-toggle.tsx deleted file mode 100644 index 92a497a..0000000 --- a/components/notifications/push-toggle.tsx +++ /dev/null @@ -1,121 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { - isPushSupported, - isIOSSafari, - isStandalonePWA, - subscribeToPush, - unsubscribeFromPush, -} from '@/lib/push-client' -import { debugProps } from '@/lib/debug' - -type PushStatus = - | 'loading' - | 'unsupported' - | 'ios-needs-install' - | 'denied' - | 'subscribed' - | 'unsubscribed' - -interface PushToggleProps { - vapidPublicKey?: string -} - -export function PushToggle({ vapidPublicKey }: PushToggleProps) { - const [status, setStatus] = useState<PushStatus>('loading') - - useEffect(() => { - async function detectStatus() { - if (!isPushSupported()) { - if (isIOSSafari() && !isStandalonePWA()) { - setStatus('ios-needs-install') - } else { - setStatus('unsupported') - } - return - } - - if (Notification.permission === 'denied') { - setStatus('denied') - return - } - - try { - const reg = await navigator.serviceWorker.getRegistration() - const sub = await reg?.pushManager.getSubscription() - setStatus(sub ? 'subscribed' : 'unsubscribed') - } catch { - setStatus('unsubscribed') - } - } - - detectStatus() - }, []) - - async function handleSubscribe() { - if (!vapidPublicKey) { - toast.error('Push niet beschikbaar — VAPID-sleutel ontbreekt') - return - } - try { - await subscribeToPush(vapidPublicKey) - setStatus('subscribed') - toast.success('Push-notificaties geactiveerd') - } catch { - if (Notification.permission === 'denied') { - setStatus('denied') - } - toast.error('Kon push niet activeren. Controleer je browserinstellingen.') - } - } - - async function handleUnsubscribe() { - try { - await unsubscribeFromPush() - setStatus('unsubscribed') - toast.success('Push-notificaties uitgeschakeld') - } catch { - toast.error('Kon push niet uitschakelen') - } - } - - if (status === 'loading' || status === 'unsupported') return null - - if (status === 'ios-needs-install') { - return ( - <div className="rounded-lg bg-surface-variant p-3 text-sm text-on-surface-variant" {...debugProps('push-toggle', 'PushToggle', 'components/notifications/push-toggle.tsx')}> - Op iPhone/iPad: tik op het delen-icoon en kies{' '} - <strong>Zet op beginscherm</strong>. Daarna kun je notificaties activeren. - </div> - ) - } - - if (status === 'denied') { - return ( - <p className="text-sm text-on-surface-variant" {...debugProps('push-toggle', 'PushToggle', 'components/notifications/push-toggle.tsx')}> - Notificaties zijn geblokkeerd. Schakel ze in via je browser-instellingen. - </p> - ) - } - - if (status === 'unsubscribed') { - return ( - <div {...debugProps('push-toggle', 'PushToggle', 'components/notifications/push-toggle.tsx')}> - <Button variant="default" size="sm" onClick={handleSubscribe} data-debug-id="push-toggle__switch"> - <span data-debug-id="push-toggle__label">Activeer push</span> - </Button> - </div> - ) - } - - return ( - <div {...debugProps('push-toggle', 'PushToggle', 'components/notifications/push-toggle.tsx')}> - <Button variant="outline" size="sm" onClick={handleUnsubscribe} data-debug-id="push-toggle__switch"> - <span data-debug-id="push-toggle__label">Push uitzetten</span> - </Button> - </div> - ) -} diff --git a/components/products/archive-product-button.tsx b/components/products/archive-product-button.tsx index 6a2244e..a083e77 100644 --- a/components/products/archive-product-button.tsx +++ b/components/products/archive-product-button.tsx @@ -3,7 +3,6 @@ import { useState, useTransition } from 'react' import { Button } from '@/components/ui/button' import { archiveProductAction } from '@/actions/products' -import { debugProps } from '@/lib/debug' interface ArchiveProductButtonProps { productId: string @@ -21,7 +20,7 @@ export function ArchiveProductButton({ productId }: ArchiveProductButtonProps) { if (confirming) { return ( - <div className="flex gap-2 shrink-0" {...debugProps('archive-product-button', 'ArchiveProductButton', 'components/products/archive-product-button.tsx')}> + <div className="flex gap-2 shrink-0"> <Button variant="destructive" size="sm" @@ -48,7 +47,6 @@ export function ArchiveProductButton({ productId }: ArchiveProductButtonProps) { size="sm" className="shrink-0 border-error/40 text-error hover:bg-error/10" onClick={() => setConfirming(true)} - {...debugProps('archive-product-button', 'ArchiveProductButton', 'components/products/archive-product-button.tsx')} > Archiveren </Button> diff --git a/components/products/auto-pr-toggle.tsx b/components/products/auto-pr-toggle.tsx index 114f426..0158627 100644 --- a/components/products/auto-pr-toggle.tsx +++ b/components/products/auto-pr-toggle.tsx @@ -4,7 +4,6 @@ import { useState, useTransition } from 'react' import { cn } from '@/lib/utils' import { updateAutoPrAction } from '@/actions/products' import { toast } from 'sonner' -import { debugProps } from '@/lib/debug' interface AutoPrToggleProps { productId: string @@ -28,14 +27,13 @@ export function AutoPrToggle({ productId, initialValue }: AutoPrToggleProps) { } return ( - <div className="flex items-center gap-3" {...debugProps('auto-pr-toggle', 'AutoPrToggle', 'components/products/auto-pr-toggle.tsx')}> + <div className="flex items-center gap-3"> <button type="button" role="switch" aria-checked={enabled} onClick={handleToggle} disabled={isPending} - data-debug-id="auto-pr-toggle__switch" className={cn( 'relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent', 'transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', @@ -51,7 +49,7 @@ export function AutoPrToggle({ productId, initialValue }: AutoPrToggleProps) { )} /> </button> - <span className="text-sm text-foreground" data-debug-id="auto-pr-toggle__label">Automatisch PR aanmaken na succesvolle agent-job</span> + <span className="text-sm text-foreground">Automatisch PR aanmaken na succesvolle agent-job</span> </div> ) } diff --git a/components/products/edit-product-button.tsx b/components/products/edit-product-button.tsx deleted file mode 100644 index 0805a23..0000000 --- a/components/products/edit-product-button.tsx +++ /dev/null @@ -1,40 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Button } from '@/components/ui/button' -import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { ProductDialog, type ProductDialogProduct } from '@/components/dialogs/product-dialog' -import { debugProps } from '@/lib/debug' - -interface Props { - product: ProductDialogProduct - isDemo?: boolean - size?: 'sm' | 'default' - variant?: 'outline' | 'ghost' -} - -export function EditProductButton({ product, isDemo = false, size = 'sm', variant = 'outline' }: Props) { - const [open, setOpen] = useState(false) - - return ( - <span {...debugProps('edit-product-button', 'EditProductButton', 'components/products/edit-product-button.tsx')}> - <DemoTooltip show={isDemo}> - <Button - variant={variant} - size={size} - onClick={(e) => { e.stopPropagation(); if (!isDemo) setOpen(true) }} - disabled={isDemo} - > - Bewerken - </Button> - </DemoTooltip> - <ProductDialog - mode="edit" - open={open} - onOpenChange={setOpen} - product={product} - isDemo={isDemo} - /> - </span> - ) -} diff --git a/components/products/pr-strategy-select.tsx b/components/products/pr-strategy-select.tsx deleted file mode 100644 index de3dd90..0000000 --- a/components/products/pr-strategy-select.tsx +++ /dev/null @@ -1,63 +0,0 @@ -'use client' - -import { useState, useTransition } from 'react' -import { toast } from 'sonner' -import type { PrStrategy } from '@prisma/client' -import { updatePrStrategyAction } from '@/actions/products' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, -} from '@/components/ui/select' -import { debugProps } from '@/lib/debug' - -interface PrStrategySelectProps { - productId: string - initialValue: PrStrategy -} - -const STRATEGY_LABELS: Record<PrStrategy, string> = { - SPRINT: - 'Per sprint — één PR voor de hele sprint, klaar voor review aan eind. Per task een eigen claude-sessie.', - STORY: - 'Per story — auto-merge na CI groen, één PR per story. Per task een eigen claude-sessie.', - SPRINT_BATCH: - 'Sprint batch — één claude-sessie voor de hele sprint, sneller en behoudt context. Vereist alle tasks in de product-hoofdrepo.', -} - -const VALID_VALUES: ReadonlyArray<PrStrategy> = ['SPRINT', 'STORY', 'SPRINT_BATCH'] - -export function PrStrategySelect({ productId, initialValue }: PrStrategySelectProps) { - const [value, setValue] = useState<PrStrategy>(initialValue) - const [isPending, startTransition] = useTransition() - - function handleChange(next: string | null) { - if (!next || !VALID_VALUES.includes(next as PrStrategy)) return - if (next === value) return - const previous = value - setValue(next as PrStrategy) - startTransition(async () => { - const result = await updatePrStrategyAction(productId, next as PrStrategy) - if ('error' in result && result.error) { - setValue(previous) - toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt') - } - }) - } - - return ( - <div className="flex flex-col gap-2" {...debugProps('pr-strategy-select', 'PrStrategySelect', 'components/products/pr-strategy-select.tsx')}> - <Select value={value} onValueChange={handleChange} disabled={isPending}> - <SelectTrigger className="w-full max-w-xl" data-debug-id="pr-strategy-select__trigger"> - {STRATEGY_LABELS[value]} - </SelectTrigger> - <SelectContent> - <SelectItem value="SPRINT">{STRATEGY_LABELS.SPRINT}</SelectItem> - <SelectItem value="STORY">{STRATEGY_LABELS.STORY}</SelectItem> - <SelectItem value="SPRINT_BATCH">{STRATEGY_LABELS.SPRINT_BATCH}</SelectItem> - </SelectContent> - </Select> - </div> - ) -} diff --git a/components/products/product-form.tsx b/components/products/product-form.tsx index d3fd2a2..aa1d280 100644 --- a/components/products/product-form.tsx +++ b/components/products/product-form.tsx @@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' type FieldErrors = Record<string, string[]> type ActionResult = { error?: string | FieldErrors; success?: boolean } | undefined @@ -50,7 +49,7 @@ export function ProductForm({ action, submitLabel, defaultValues }: ProductFormP const globalError = getGlobalError(state?.error) return ( - <form action={formAction} className="space-y-5" {...debugProps('product-form', 'ProductForm', 'components/products/product-form.tsx')}> + <form action={formAction} className="space-y-5"> {defaultValues?.id && ( <input type="hidden" name="id" value={defaultValues.id} /> )} @@ -83,7 +82,6 @@ export function ProductForm({ action, submitLabel, defaultValues }: ProductFormP defaultValue={defaultValues?.name} placeholder="bijv. DevPlanner" className={fieldError('name') ? 'border-error' : ''} - data-debug-id="product-form__name" /> {fieldError('name') && ( <p className="text-xs text-error">{fieldError('name')}</p> @@ -119,7 +117,6 @@ export function ProductForm({ action, submitLabel, defaultValues }: ProductFormP defaultValue={defaultValues?.repo_url ?? ''} placeholder="https://github.com/..." className={fieldError('repo_url') ? 'border-error' : ''} - data-debug-id="product-form__repo" /> {fieldError('repo_url') && ( <p className="text-xs text-error">{fieldError('repo_url')}</p> @@ -150,7 +147,7 @@ export function ProductForm({ action, submitLabel, defaultValues }: ProductFormP </div> )} - <div className="flex gap-3 pt-1" data-debug-id="product-form__submit"> + <div className="flex gap-3 pt-1"> <SubmitButton label={submitLabel} /> </div> </form> diff --git a/components/products/team-manager.tsx b/components/products/team-manager.tsx index 5d13a68..d5f4760 100644 --- a/components/products/team-manager.tsx +++ b/components/products/team-manager.tsx @@ -4,7 +4,6 @@ import { useActionState, useState, useTransition } from 'react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { addProductMemberAction, removeProductMemberAction } from '@/actions/products' -import { debugProps } from '@/lib/debug' interface Member { id: string @@ -30,11 +29,11 @@ export function TeamManager({ productId, members }: TeamManagerProps) { } return ( - <div className="space-y-4" {...debugProps('team-manager', 'TeamManager', 'components/products/team-manager.tsx')}> + <div className="space-y-4"> {members.length === 0 ? ( <p className="text-sm text-muted-foreground">Nog geen teamleden toegevoegd.</p> ) : ( - <ul className="space-y-2" data-debug-id="team-manager__members"> + <ul className="space-y-2"> {members.map(m => ( <li key={m.id} className="flex items-center justify-between gap-3 rounded-lg bg-surface-container px-3 py-2"> <span className="text-sm font-medium text-foreground">{m.username}</span> @@ -52,7 +51,7 @@ export function TeamManager({ productId, members }: TeamManagerProps) { </ul> )} - <form action={formAction} className="flex gap-2" data-debug-id="team-manager__invite"> + <form action={formAction} className="flex gap-2"> <input type="hidden" name="productId" value={productId} /> <Input name="username" diff --git a/components/settings/leave-product-button.tsx b/components/settings/leave-product-button.tsx index 25b30b9..976e480 100644 --- a/components/settings/leave-product-button.tsx +++ b/components/settings/leave-product-button.tsx @@ -4,7 +4,6 @@ import { useState, useTransition } from 'react' import { Button } from '@/components/ui/button' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { leaveProductAction } from '@/actions/products' -import { debugProps } from '@/lib/debug' interface LeaveProductButtonProps { productId: string @@ -23,7 +22,7 @@ export function LeaveProductButton({ productId, isDemo = false }: LeaveProductBu if (confirming) { return ( - <div className="flex gap-2 shrink-0" {...debugProps('leave-product-button', 'LeaveProductButton', 'components/settings/leave-product-button.tsx')}> + <div className="flex gap-2 shrink-0"> <Button variant="destructive" size="sm" disabled={isPending} onClick={handleLeave}> {isPending ? 'Bezig…' : 'Ja, verlaten'} </Button> @@ -35,18 +34,16 @@ export function LeaveProductButton({ productId, isDemo = false }: LeaveProductBu } return ( - <span {...debugProps('leave-product-button', 'LeaveProductButton', 'components/settings/leave-product-button.tsx')}> - <DemoTooltip show={isDemo}> - <Button - variant="outline" - size="sm" - className="shrink-0 border-error/40 text-error hover:bg-error/10" - disabled={isDemo} - onClick={() => !isDemo && setConfirming(true)} - > - Verlaten - </Button> - </DemoTooltip> - </span> + <DemoTooltip show={isDemo}> + <Button + variant="outline" + size="sm" + className="shrink-0 border-error/40 text-error hover:bg-error/10" + disabled={isDemo} + onClick={() => !isDemo && setConfirming(true)} + > + Verlaten + </Button> + </DemoTooltip> ) } diff --git a/components/settings/min-quota-editor.tsx b/components/settings/min-quota-editor.tsx deleted file mode 100644 index f1e8147..0000000 --- a/components/settings/min-quota-editor.tsx +++ /dev/null @@ -1,61 +0,0 @@ -'use client' - -import { useState, useTransition } from 'react' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { updateMinQuotaPctAction } from '@/actions/settings' -import { debugProps } from '@/lib/debug' - -interface MinQuotaEditorProps { - currentValue: number - isDemo: boolean -} - -export function MinQuotaEditor({ currentValue, isDemo }: MinQuotaEditorProps) { - const [value, setValue] = useState(currentValue) - const [isPending, startTransition] = useTransition() - - function handleSave() { - startTransition(async () => { - const result = await updateMinQuotaPctAction(value) - if ('error' in result) { - toast.error(result.error as string) - } else { - toast.success('Instelling opgeslagen') - } - }) - } - - return ( - <div className="space-y-3" {...debugProps('min-quota-editor', 'MinQuotaEditor', 'components/settings/min-quota-editor.tsx')}> - <div> - <label htmlFor="min-quota-pct" className="text-sm font-medium text-foreground"> - Minimaal beschikbaar Claude-quota voordat de worker een job oppakt (%) - </label> - <p className="text-xs text-muted-foreground mt-0.5"> - Worker slaapt tot quota gereset is wanneer onder deze drempel. - </p> - </div> - <div className="flex items-center gap-3"> - <input - id="min-quota-pct" - type="number" - min={1} - max={100} - value={value} - onChange={e => setValue(Number(e.target.value))} - disabled={isDemo || isPending} - className="w-24 rounded border border-border bg-surface-container px-3 py-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50" - data-debug-id="min-quota-editor__input" - /> - <span className="text-sm text-muted-foreground">%</span> - <DemoTooltip show={isDemo}> - <Button onClick={handleSave} disabled={isDemo || isPending} size="sm" data-debug-id="min-quota-editor__save"> - Opslaan - </Button> - </DemoTooltip> - </div> - </div> - ) -} diff --git a/components/settings/profile-editor.tsx b/components/settings/profile-editor.tsx index c3eab4e..8eedb51 100644 --- a/components/settings/profile-editor.tsx +++ b/components/settings/profile-editor.tsx @@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { updateProfileAction } from '@/actions/profile' -import { debugProps } from '@/lib/debug' interface ProfileEditorProps { email: string | null @@ -64,7 +63,7 @@ export function ProfileEditor({ email, bio, bioDetail, hasAvatar, avatarVersion } return ( - <div className="space-y-5" {...debugProps('profile-editor', 'ProfileEditor', 'components/settings/profile-editor.tsx')}> + <div className="space-y-5"> <div className="flex items-center gap-5"> <button type="button" @@ -138,7 +137,6 @@ export function ProfileEditor({ email, bio, bioDetail, hasAvatar, avatarVersion placeholder="Bijv. Full-stack developer bij Acme" maxLength={160} disabled={isPending} - data-debug-id="profile-editor__username" /> <p className="text-xs text-muted-foreground">Max. 160 tekens</p> </div> @@ -160,7 +158,7 @@ export function ProfileEditor({ email, bio, bioDetail, hasAvatar, avatarVersion </div> <div className="flex items-center gap-3"> - <Button type="submit" size="sm" disabled={isPending} data-debug-id="profile-editor__save"> + <Button type="submit" size="sm" disabled={isPending}> {isPending ? 'Opslaan…' : 'Opslaan'} </Button> {state && 'success' in state && ( diff --git a/components/settings/role-manager.tsx b/components/settings/role-manager.tsx index 04d56e1..23fd59b 100644 --- a/components/settings/role-manager.tsx +++ b/components/settings/role-manager.tsx @@ -4,8 +4,7 @@ import { useState, useTransition } from 'react' import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { updateRolesAction } from '@/actions/settings' -import { debugProps } from '@/lib/debug' +import { updateRolesAction } from '@/actions/todos' const ALL_ROLES = [ { value: 'PRODUCT_OWNER', label: 'Product Owner' }, @@ -47,9 +46,9 @@ export function RoleManager({ currentRoles, isDemo }: RoleManagerProps) { } return ( - <div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-4" {...debugProps('role-manager', 'RoleManager', 'components/settings/role-manager.tsx')}> + <div className="bg-surface-container-low border border-border rounded-xl p-5 space-y-4"> <h2 className="text-sm font-medium text-foreground">Mijn rollen</h2> - <div className="flex flex-wrap gap-3" data-debug-id="role-manager__roles"> + <div className="flex flex-wrap gap-3"> {ALL_ROLES.map(role => ( <label key={role.value} className="flex items-center gap-2 cursor-pointer"> <input @@ -66,7 +65,7 @@ export function RoleManager({ currentRoles, isDemo }: RoleManagerProps) { {error && <p className="text-xs text-error">{error}</p>} {saved && <p className="text-xs text-success">Rollen opgeslagen.</p>} <DemoTooltip show={isDemo}> - <Button size="sm" onClick={handleSave} disabled={isDemo} data-debug-id="role-manager__add">Opslaan</Button> + <Button size="sm" onClick={handleSave} disabled={isDemo}>Opslaan</Button> </DemoTooltip> </div> ) diff --git a/components/settings/token-manager.tsx b/components/settings/token-manager.tsx index 117c27b..ba1f705 100644 --- a/components/settings/token-manager.tsx +++ b/components/settings/token-manager.tsx @@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { createApiTokenAction, revokeApiTokenAction } from '@/actions/api-tokens' -import { debugProps } from '@/lib/debug' interface Token { id: string @@ -24,7 +23,7 @@ function CreateSubmitButton({ isDemo }: { isDemo: boolean }) { const { pending } = useFormStatus() return ( <DemoTooltip show={isDemo}> - <Button type="submit" disabled={isDemo || pending} data-debug-id="token-manager__generate"> + <Button type="submit" disabled={isDemo || pending}> {pending ? 'Aanmaken…' : 'Token aanmaken'} </Button> </DemoTooltip> @@ -39,7 +38,7 @@ export function TokenManager({ tokens, isDemo }: TokenManagerProps) { const [state, formAction] = useActionState( async (_prev: unknown, fd: FormData) => { const result = await createApiTokenAction(_prev, fd) - if ('success' in result && result.success && result.token) { + if (result.success && result.token) { setNewToken(result.token) } return result @@ -64,7 +63,7 @@ export function TokenManager({ tokens, isDemo }: TokenManagerProps) { const revokedTokens = tokens.filter(t => t.revoked_at) return ( - <div className="space-y-6" {...debugProps('token-manager', 'TokenManager', 'components/settings/token-manager.tsx')}> + <div className="space-y-6"> {/* New token revealed */} {newToken && ( <div className="bg-success-container border border-success/30 rounded-xl p-4 space-y-3"> @@ -104,7 +103,7 @@ export function TokenManager({ tokens, isDemo }: TokenManagerProps) { {activeTokens.length === 0 ? ( <p className="text-sm text-muted-foreground">Geen actieve tokens.</p> ) : ( - <div className="bg-surface-container-low border border-border rounded-xl divide-y divide-border" data-debug-id="token-manager__tokens"> + <div className="bg-surface-container-low border border-border rounded-xl divide-y divide-border"> {activeTokens.map(token => ( <div key={token.id} className="flex items-center justify-between px-4 py-3 gap-3"> <div> diff --git a/components/shared/activate-product-button.tsx b/components/shared/activate-product-button.tsx index 76e59d7..90c19c4 100644 --- a/components/shared/activate-product-button.tsx +++ b/components/shared/activate-product-button.tsx @@ -5,7 +5,6 @@ import { useTransition } from 'react' import { toast } from 'sonner' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { setActiveProductAction } from '@/actions/active-product' -import { debugProps } from '@/lib/debug' interface Props { productId: string @@ -29,7 +28,6 @@ export function ActivateProductButton({ productId, isDemo, redirectTo, label = ' } return ( - <span {...debugProps('activate-product-button')}> <DemoTooltip show={isDemo}> <button onClick={() => !isDemo && handleActivate()} @@ -39,6 +37,5 @@ export function ActivateProductButton({ productId, isDemo, redirectTo, label = ' {label} </button> </DemoTooltip> - </span> ) } diff --git a/components/shared/alert-toast.tsx b/components/shared/alert-toast.tsx index 7d666d1..630892d 100644 --- a/components/shared/alert-toast.tsx +++ b/components/shared/alert-toast.tsx @@ -3,7 +3,6 @@ import { useEffect } from 'react' import { useSearchParams, useRouter, usePathname } from 'next/navigation' import { toast } from 'sonner' -import { debugProps } from '@/lib/debug' const ALERT_MESSAGES: Record<string, string> = { product_unavailable: 'Je actieve product is niet meer beschikbaar', @@ -25,5 +24,5 @@ export function AlertToast() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [alert]) - return <span {...debugProps('alert-toast')} hidden /> + return null } diff --git a/components/shared/app-icon.tsx b/components/shared/app-icon.tsx index a37c290..dabc49b 100644 --- a/components/shared/app-icon.tsx +++ b/components/shared/app-icon.tsx @@ -1,5 +1,3 @@ -import { debugProps } from '@/lib/debug' - interface AppIconProps { size?: number className?: string @@ -15,7 +13,6 @@ export function AppIcon({ size = 32, className }: AppIconProps) { xmlns="http://www.w3.org/2000/svg" className={className} aria-label="Scrum4Me" - {...debugProps('app-icon')} > <defs> <linearGradient id="s4m-bg" x1="0" y1="0" x2="512" y2="512" gradientUnits="userSpaceOnUse"> diff --git a/components/shared/backlog-filter-popover.tsx b/components/shared/backlog-filter-popover.tsx deleted file mode 100644 index 8658a16..0000000 --- a/components/shared/backlog-filter-popover.tsx +++ /dev/null @@ -1,239 +0,0 @@ -'use client' - -import { ArrowDown, ArrowUp } from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' - -export const PRIORITY_LABELS: Record<number, string> = { - 1: 'Kritiek', - 2: 'Hoog', - 3: 'Gemiddeld', - 4: 'Laag', -} - -export const PRIORITY_OPTIONS: Array<{ value: number | 'all'; label: string }> = [ - { value: 'all', label: 'Alle' }, - { value: 1, label: 'Kritiek' }, - { value: 2, label: 'Hoog' }, - { value: 3, label: 'Gemiddeld' }, - { value: 4, label: 'Laag' }, -] - -export type SortDir = 'asc' | 'desc' - -export function FilterPills<T extends string | number>({ - label, - options, - value, - onChange, -}: { - label: string - options: Array<{ value: T; label: string }> - value: T - onChange: (v: T) => void -}) { - return ( - <div className="space-y-1.5"> - <p className="text-xs font-medium text-muted-foreground">{label}</p> - <div className="flex flex-wrap gap-1.5"> - {options.map((opt) => ( - <button - key={String(opt.value)} - type="button" - onClick={() => onChange(opt.value)} - className={cn( - 'text-xs px-2.5 py-1 rounded-full border transition-colors', - value === opt.value - ? 'bg-primary text-primary-foreground border-primary' - : 'bg-transparent border-border hover:bg-surface-container' - )} - > - {opt.label} - </button> - ))} - </div> - </div> - ) -} - -export function MultiFilterPills<T extends string>({ - label, - options, - selected, - onToggle, - onClear, -}: { - label: string - options: Array<{ value: T; label: string }> - selected: Set<T> - onToggle: (v: T) => void - onClear: () => void -}) { - const allActive = selected.size === 0 - return ( - <div className="space-y-1.5"> - <p className="text-xs font-medium text-muted-foreground">{label}</p> - <div className="flex flex-wrap gap-1.5"> - <button - type="button" - onClick={onClear} - className={cn( - 'text-xs px-2.5 py-1 rounded-full border transition-colors', - allActive - ? 'bg-primary text-primary-foreground border-primary' - : 'bg-transparent border-border hover:bg-surface-container' - )} - > - Alle - </button> - {options.map((opt) => { - const active = selected.has(opt.value) - return ( - <button - key={opt.value} - type="button" - onClick={() => onToggle(opt.value)} - className={cn( - 'text-xs px-2.5 py-1 rounded-full border transition-colors', - active - ? 'bg-primary text-primary-foreground border-primary' - : 'bg-transparent border-border hover:bg-surface-container' - )} - > - {opt.label} - </button> - ) - })} - </div> - </div> - ) -} - -interface BacklogFilterPopoverProps<S extends string, So extends string> { - open: boolean - onOpenChange: (open: boolean) => void - - filterPriority: number | 'all' - onFilterPriorityChange: (v: number | 'all') => void - - filterStatus: S - onFilterStatusChange: (v: S) => void - statusOptions: Array<{ value: S; label: string }> - - sort: So - onSortChange: (v: So) => void - sortDir: SortDir - onSortDirChange: (v: SortDir) => void - sortOptions: Array<{ value: So; label: string }> - - activeFilterCount: number - onReset: () => void - resetDisabled: boolean -} - -export function BacklogFilterPopover<S extends string, So extends string>({ - open, - onOpenChange, - filterPriority, - onFilterPriorityChange, - filterStatus, - onFilterStatusChange, - statusOptions, - sort, - onSortChange, - sortDir, - onSortDirChange, - sortOptions, - activeFilterCount, - onReset, - resetDisabled, -}: BacklogFilterPopoverProps<S, So>) { - return ( - <Popover open={open} onOpenChange={onOpenChange}> - <PopoverTrigger - render={ - <Button - variant="outline" - size="sm" - className="h-7 text-xs" - {...debugProps('backlog-filter-popover__trigger')} - > - {`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`} - </Button> - } - /> - <PopoverContent - align="end" - className="w-72 space-y-4" - {...debugProps('backlog-filter-popover', 'BacklogFilterPopover', 'components/shared/backlog-filter-popover.tsx')} - > - <FilterPills - label="Prioriteit" - options={PRIORITY_OPTIONS} - value={filterPriority} - onChange={onFilterPriorityChange} - /> - <FilterPills - label="Status" - options={statusOptions} - value={filterStatus} - onChange={onFilterStatusChange} - /> - <div className="space-y-1.5"> - <p className="text-xs font-medium text-muted-foreground">Sorteren op</p> - <div className="flex flex-wrap gap-1.5"> - {sortOptions.map((opt) => { - const active = sort === opt.value - return ( - <button - key={opt.value} - type="button" - onClick={() => { - if (active) { - onSortDirChange(sortDir === 'asc' ? 'desc' : 'asc') - } else { - onSortChange(opt.value) - onSortDirChange('asc') - } - }} - aria-label={ - active - ? `Sorteren op ${opt.label} ${sortDir === 'asc' ? 'oplopend' : 'aflopend'} — klik om te wisselen` - : `Sorteren op ${opt.label}` - } - className={cn( - 'inline-flex items-center gap-1 text-xs px-2.5 py-1 rounded-full border transition-colors', - active - ? 'bg-primary text-primary-foreground border-primary' - : 'bg-transparent border-border hover:bg-surface-container' - )} - > - <span>{opt.label}</span> - {active && ( - sortDir === 'asc' - ? <ArrowDown size={12} aria-hidden /> - : <ArrowUp size={12} aria-hidden /> - )} - </button> - ) - })} - </div> - </div> - <div className="flex justify-end pt-1 border-t border-border"> - <Button - type="button" - variant="ghost" - size="sm" - className="h-7 text-xs" - disabled={resetDisabled} - onClick={onReset} - > - Wis filters - </Button> - </div> - </PopoverContent> - </Popover> - ) -} diff --git a/components/shared/code-badge.tsx b/components/shared/code-badge.tsx index 7c319dd..126dbeb 100644 --- a/components/shared/code-badge.tsx +++ b/components/shared/code-badge.tsx @@ -1,5 +1,4 @@ import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' interface CodeBadgeProps { code: string | null | undefined @@ -10,7 +9,6 @@ export function CodeBadge({ code, className }: CodeBadgeProps) { if (!code) return null return ( <span - {...debugProps('code-badge')} className={cn( 'inline-flex items-center rounded-md border border-border bg-surface-container px-1.5 py-0.5 font-mono text-[11px] leading-none text-muted-foreground', className, diff --git a/components/shared/demo-tooltip.tsx b/components/shared/demo-tooltip.tsx index c1ae3ef..bba0e8e 100644 --- a/components/shared/demo-tooltip.tsx +++ b/components/shared/demo-tooltip.tsx @@ -1,7 +1,6 @@ 'use client' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { debugProps } from '@/lib/debug' interface DemoTooltipProps { show: boolean @@ -11,18 +10,16 @@ interface DemoTooltipProps { // Wraps children with a "Niet beschikbaar in demo-modus" tooltip when show=true. // Uses a span trigger so tooltip works on disabled elements. export function DemoTooltip({ show, children }: DemoTooltipProps) { - if (!show) return <span {...debugProps('demo-tooltip')}>{children}</span> + if (!show) return <>{children}</> return ( - <span {...debugProps('demo-tooltip')}> - <TooltipProvider> - <Tooltip> - <TooltipTrigger render={<span className="inline-flex" />}> - {children} - </TooltipTrigger> - <TooltipContent>Niet beschikbaar in demo-modus</TooltipContent> - </Tooltip> - </TooltipProvider> - </span> + <TooltipProvider> + <Tooltip> + <TooltipTrigger render={<span className="inline-flex" />}> + {children} + </TooltipTrigger> + <TooltipContent>Niet beschikbaar in demo-modus</TooltipContent> + </Tooltip> + </TooltipProvider> ) } diff --git a/components/shared/entity-dialog-layout.ts b/components/shared/entity-dialog-layout.ts deleted file mode 100644 index e70e24e..0000000 --- a/components/shared/entity-dialog-layout.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { cn } from '@/lib/utils' - -export const entityDialogContentClasses = cn( - 'flex flex-col p-0 gap-0', - 'max-h-[90vh] w-full max-w-[calc(100%-2rem)]', - 'max-sm:w-screen max-sm:h-screen max-sm:max-h-screen max-sm:max-w-none max-sm:rounded-none', - 'sm:max-w-[90vw] sm:max-h-[85vh]', - 'lg:max-w-[50vw] lg:min-w-[480px]', -) - -export const entityDialogHeaderClasses = - 'flex items-center justify-between px-6 pt-5 pb-4 border-b border-outline-variant shrink-0' - -export const entityDialogBodyClasses = 'flex-1 overflow-y-auto px-6 py-6 space-y-6' - -export const entityDialogFooterClasses = - 'border-t border-outline-variant px-6 py-4 shrink-0' diff --git a/components/shared/job-status.ts b/components/shared/job-status.ts index 065c60f..06b8ecf 100644 --- a/components/shared/job-status.ts +++ b/components/shared/job-status.ts @@ -7,7 +7,6 @@ export const JOB_STATUS_LABELS: Record<ClaudeJobStatusApi, string> = { done: 'Klaar', failed: 'Mislukt', cancelled: 'Geannuleerd', - skipped: 'Overgeslagen', } export const JOB_STATUS_COLORS: Record<ClaudeJobStatusApi, string> = { @@ -17,7 +16,6 @@ export const JOB_STATUS_COLORS: Record<ClaudeJobStatusApi, string> = { done: 'bg-status-done/15 text-status-done border-status-done/30', failed: 'bg-status-blocked/15 text-status-blocked border-status-blocked/30', cancelled: 'bg-muted text-muted-foreground border-border', - skipped: 'bg-muted/50 text-muted-foreground border-border italic', } export const JOB_STATUS_ACTIVE = new Set<ClaudeJobStatusApi>(['queued', 'claimed', 'running']) diff --git a/components/shared/min-width-banner.tsx b/components/shared/min-width-banner.tsx index 4083d4f..6453bc2 100644 --- a/components/shared/min-width-banner.tsx +++ b/components/shared/min-width-banner.tsx @@ -1,14 +1,9 @@ 'use client' -import { debugProps } from '@/lib/debug' - // Shows a warning banner on screens narrower than 1024px. export function MinWidthBanner() { return ( - <div - {...debugProps('min-width-banner')} - className="lg:hidden bg-warning/10 border-b border-warning/30 px-4 py-2 text-center text-xs text-warning" - > + <div className="lg:hidden bg-warning/10 border-b border-warning/30 px-4 py-2 text-center text-xs text-warning"> Scrum4Me is ontworpen voor schermen van minimaal 1024px breed. Sommige functies zijn mogelijk niet goed bruikbaar op dit scherm. </div> ) diff --git a/components/shared/nav-bar.tsx b/components/shared/nav-bar.tsx index 88e5064..2a7e97e 100644 --- a/components/shared/nav-bar.tsx +++ b/components/shared/nav-bar.tsx @@ -20,8 +20,6 @@ import { NotificationsBell } from '@/components/shared/notifications-bell' import { SoloNavStatusIndicators } from '@/components/solo/nav-status-indicators' import { cn } from '@/lib/utils' import { setActiveProductAction } from '@/actions/active-product' -import { resolveProductSwitchTarget } from '@/lib/product-switch-path' -import { debugProps } from '@/lib/debug' interface NavBarProps { isDemo: boolean @@ -32,7 +30,6 @@ interface NavBarProps { activeProduct: { id: string; name: string } | null products: { id: string; name: string }[] hasActiveSprint: boolean - minQuotaPct: number } export function NavBar({ @@ -44,38 +41,24 @@ export function NavBar({ activeProduct, products, hasActiveSprint, - minQuotaPct, }: NavBarProps) { const pathname = usePathname() const router = useRouter() const [isPending, startTransition] = useTransition() - const urlProductId = pathname.match(/^\/products\/([^/]+)/)?.[1] ?? null - const displayActive = - isDemo && urlProductId - ? products.find(p => p.id === urlProductId) ?? activeProduct - : activeProduct - const activeId = displayActive?.id ?? null - function handleSwitchProduct(productId: string) { - if (productId === displayActive?.id) return - if (isDemo) { - router.push(`/products/${productId}`) - return - } startTransition(async () => { const result = await setActiveProductAction(productId) if (result?.error) { toast.error(typeof result.error === 'string' ? result.error : 'Wisselen mislukt') - return + } else { + router.push(`/products/${productId}`) } - const next = products.find(p => p.id === productId) - toast.success(`Actief product: ${next?.name ?? 'gewijzigd'}`) - const target = resolveProductSwitchTarget(pathname, productId) - if (target) router.push(target); else router.refresh() }) } + const activeId = activeProduct?.id ?? null + // Nav link helpers const disabledSpan = (label: string) => ( <span @@ -103,6 +86,7 @@ export function NavBar({ const sprintNode = () => { if (!activeId) return disabledSpan('Sprint') + const href = `/products/${activeId}/sprint` const isActive = pathname.includes('/sprint') if (!hasActiveSprint) { return ( @@ -119,18 +103,14 @@ export function NavBar({ </TooltipProvider> ) } - const href = `/products/${activeId}/sprint` return navLink(href, 'Sprint', isActive) } return ( - <header - {...debugProps('nav-bar')} - className="bg-surface-container-low border-b border-border h-14 flex items-center px-4 shrink-0" - > + <header className="bg-surface-container-low border-b border-border h-14 flex items-center px-4 shrink-0"> {/* Links: logo + nav */} <div className="flex items-center gap-4 flex-1"> - <Link href="/" data-debug-id="nav-bar__app-icon" className="flex items-center gap-2 font-medium text-foreground"> + <Link href="/" className="flex items-center gap-2 font-medium text-foreground"> <AppIcon size={24} /> <span className="text-primary font-semibold">Scrum4Me</span> {isDemo && ( @@ -158,24 +138,22 @@ export function NavBar({ ) : disabledSpan('Solo')} {navLink('/insights', 'Insights', pathname.startsWith('/insights'))} - {navLink('/ideas', 'Ideas', pathname.startsWith('/ideas'))} - {navLink('/jobs', 'Jobs', pathname.startsWith('/jobs'))} + {navLink('/todos', "Todo's", pathname.startsWith('/todos'))} </nav> </div> - {/* Midden: actief product */} + {/* Midden: actief product dropdown */} <div className="flex items-center justify-center"> - {displayActive ? ( + {activeProduct ? ( <DropdownMenu> <DropdownMenuTrigger disabled={isPending} - data-debug-id="nav-bar__product-switcher" className="flex items-center gap-1 text-sm font-medium text-foreground hover:text-primary transition-colors px-2 rounded-md hover:bg-surface-container focus:outline-none" > <span className="truncate max-w-[180px]"> - {displayActive.name.length > 22 - ? displayActive.name.slice(0, 22) + '…' - : displayActive.name} + {activeProduct.name.length > 22 + ? activeProduct.name.slice(0, 22) + '…' + : activeProduct.name} </span> <ChevronDown className="w-3.5 h-3.5 shrink-0 text-muted-foreground" /> </DropdownMenuTrigger> @@ -183,9 +161,9 @@ export function NavBar({ {products.map(p => ( <DropdownMenuItem key={p.id} - onClick={() => p.id !== displayActive.id && handleSwitchProduct(p.id)} + onSelect={() => p.id !== activeProduct.id && handleSwitchProduct(p.id)} className={cn( - p.id === displayActive.id && 'bg-primary-container text-primary-container-foreground font-medium' + p.id === activeProduct.id && 'bg-primary-container text-primary-container-foreground font-medium' )} > <span className="truncate">{p.name}</span> @@ -206,11 +184,9 @@ export function NavBar({ {/* Rechts: solo-status + notifications + account-menu */} <div className="flex items-center gap-2 flex-1 justify-end"> - <SoloNavStatusIndicators hasActiveProduct={!!activeProduct} minQuotaPct={minQuotaPct} /> + <SoloNavStatusIndicators hasActiveProduct={!!activeProduct} /> <NotificationsBell currentUserId={userId} isDemo={isDemo} /> - <span data-debug-id="nav-bar__user-menu"> - <UserMenu userId={userId} username={username} email={email} roles={roles} /> - </span> + <UserMenu userId={userId} username={username} email={email} roles={roles} /> </div> </header> ) diff --git a/components/shared/notifications-bell.tsx b/components/shared/notifications-bell.tsx index 75405ea..8120844 100644 --- a/components/shared/notifications-bell.tsx +++ b/components/shared/notifications-bell.tsx @@ -11,7 +11,6 @@ import { Bell } from 'lucide-react' import { useNotificationsStore } from '@/stores/notifications-store' import { NotificationsSheet } from '@/components/notifications/notifications-sheet' import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' interface NotificationsBellProps { currentUserId: string @@ -21,14 +20,10 @@ interface NotificationsBellProps { export function NotificationsBell({ currentUserId, isDemo }: NotificationsBellProps) { const total = useNotificationsStore((s) => s.questions.length) const forYou = useNotificationsStore((s) => - s.questions.filter((q) => - // story-question: assignee match; idea-question: altijd voor jou (privé) - q.kind === 'idea' ? true : q.assignee_id === currentUserId, - ).length, + s.questions.filter((q) => q.assignee_id === currentUserId).length, ) return ( - <span {...debugProps('notifications-bell')}> <NotificationsSheet currentUserId={currentUserId} isDemo={isDemo} @@ -55,6 +50,5 @@ export function NotificationsBell({ currentUserId, isDemo }: NotificationsBellPr </button> } /> - </span> ) } diff --git a/components/shared/panel-nav-bar.tsx b/components/shared/panel-nav-bar.tsx index 83b842f..41999f4 100644 --- a/components/shared/panel-nav-bar.tsx +++ b/components/shared/panel-nav-bar.tsx @@ -1,5 +1,4 @@ import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' interface PanelNavBarProps { title: string @@ -9,12 +8,9 @@ interface PanelNavBarProps { export function PanelNavBar({ title, actions, className }: PanelNavBarProps) { return ( - <div - {...debugProps('panel-nav-bar')} - className={cn('flex items-center justify-between px-4 py-2 border-b border-border bg-surface-container-low shrink-0', className)} - > - <span data-debug-id="panel-nav-bar__title" className="text-sm font-medium text-foreground">{title}</span> - {actions && <div data-debug-id="panel-nav-bar__actions" className="flex items-center gap-2">{actions}</div>} + <div className={cn('flex items-center justify-between px-4 py-2 border-b border-border bg-surface-container-low shrink-0', className)}> + <span className="text-sm font-medium text-foreground">{title}</span> + {actions && <div className="flex items-center gap-2">{actions}</div>} </div> ) } diff --git a/components/shared/pbi-status-select.tsx b/components/shared/pbi-status-select.tsx index 92daa21..ae93522 100644 --- a/components/shared/pbi-status-select.tsx +++ b/components/shared/pbi-status-select.tsx @@ -3,19 +3,16 @@ import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select' import { cn } from '@/lib/utils' import type { PbiStatusApi } from '@/lib/task-status' -import { debugProps } from '@/lib/debug' export const PBI_STATUS_LABELS: Record<PbiStatusApi, string> = { ready: 'Klaar voor sprint', blocked: 'Geblokkeerd', - failed: 'Gefaald', done: 'Afgerond', } export const PBI_STATUS_COLORS: Record<PbiStatusApi, string> = { ready: 'bg-status-todo/15 text-status-todo border-status-todo/30', blocked: 'bg-status-blocked/15 text-status-blocked border-status-blocked/30', - failed: 'bg-status-failed/15 text-status-failed border-status-failed/30', done: 'bg-status-done/15 text-status-done border-status-done/30', } @@ -27,7 +24,6 @@ interface PbiStatusSelectProps { export function PbiStatusSelect({ value, onChange, className }: PbiStatusSelectProps) { return ( - <span {...debugProps('pbi-status-select')}> <Select value={value} onValueChange={(v) => { if (v) onChange(v as PbiStatusApi) }} @@ -41,6 +37,5 @@ export function PbiStatusSelect({ value, onChange, className }: PbiStatusSelectP <SelectItem value="done">Afgerond</SelectItem> </SelectContent> </Select> - </span> ) } diff --git a/components/shared/priority-select.tsx b/components/shared/priority-select.tsx index 13efd87..a66e66c 100644 --- a/components/shared/priority-select.tsx +++ b/components/shared/priority-select.tsx @@ -2,7 +2,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select' import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' export const PRIORITY_LABELS: Record<number, string> = { 1: 'Kritiek', @@ -26,7 +25,6 @@ interface PrioritySelectProps { export function PrioritySelect({ value, onChange, className }: PrioritySelectProps) { return ( - <span {...debugProps('priority-select')}> <Select value={String(value)} onValueChange={(v) => { if (v) onChange(parseInt(v)) }} @@ -41,6 +39,5 @@ export function PrioritySelect({ value, onChange, className }: PrioritySelectPro <SelectItem value="4">Laag</SelectItem> </SelectContent> </Select> - </span> ) } diff --git a/components/shared/set-current-product.tsx b/components/shared/set-current-product.tsx index 94d71aa..c535eb6 100644 --- a/components/shared/set-current-product.tsx +++ b/components/shared/set-current-product.tsx @@ -1,20 +1,15 @@ 'use client' import { useEffect } from 'react' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import { debugProps } from '@/lib/debug' +import { useProductStore } from '@/stores/product-store' -// PBI-74 / T-853: workspace-store is nu enige bron voor active product. -// De voorganger (stores/product-store.ts) wordt in Story 8 (T-876) verwijderd. export function SetCurrentProduct({ id, name }: { id: string; name: string }) { - useEffect(() => { - useProductWorkspaceStore - .getState() - .setActiveProduct({ id, name }, { load: false, preserveSelection: true }) - return () => { - useProductWorkspaceStore.getState().setActiveProduct(null, { load: false }) - } - }, [id, name]) + const { setCurrentProduct, clearCurrentProduct } = useProductStore() - return <span {...debugProps('set-current-product')} hidden /> + useEffect(() => { + setCurrentProduct(id, name) + return () => clearCurrentProduct() + }, [id, name, setCurrentProduct, clearCurrentProduct]) + + return null } diff --git a/components/shared/sprint-switcher.tsx b/components/shared/sprint-switcher.tsx deleted file mode 100644 index e718a42..0000000 --- a/components/shared/sprint-switcher.tsx +++ /dev/null @@ -1,256 +0,0 @@ -'use client' - -import { usePathname, useRouter } from 'next/navigation' -import { useState, useTransition } from 'react' -import { Check, ChevronDown } from 'lucide-react' -import { toast } from 'sonner' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { cn } from '@/lib/utils' -import { - clearActiveSprintAction, - switchActiveSprintAction, -} from '@/actions/active-sprint' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import { deriveScreenState } from '@/stores/product-workspace/screen-state' -import { useUserSettingsStore } from '@/stores/user-settings/store' -import type { SprintStatusApi } from '@/lib/task-status' -import { debugProps } from '@/lib/debug' - -type SprintItem = { id: string; code: string; sprint_goal: string; status: SprintStatusApi } - -interface SprintSwitcherProps { - productId: string - sprints: SprintItem[] - activeSprint: SprintItem | null - buildingSprintIds: string[] -} - -const SPRINT_STATUS_LABEL: Record<SprintStatusApi, string> = { - open: 'Open', - closed: 'Gesloten', - archived: 'Gearchiveerd', - failed: 'Mislukt', -} - -export function SprintSwitcher({ - productId, - sprints, - activeSprint, - buildingSprintIds, -}: SprintSwitcherProps) { - const pathname = usePathname() - const router = useRouter() - const [isPending, startTransition] = useTransition() - const [showClosed, setShowClosed] = useState(false) - const buildingSet = new Set(buildingSprintIds) - const isDemo = useUserSettingsStore(s => s.context.isDemo) - - // PBI-79: zolang er een sprint-draft loopt tonen we 'Concept — [goal]' - // bovenaan de dropdown. De draft staat alleen in deze session-store; bij - // page-refresh/leave is hij weg. - const draftGoal = useUserSettingsStore( - (s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId]?.goal ?? null, - ) - const pendingAdds = useProductWorkspaceStore( - (s) => s.sprintMembership.pending.adds, - ) - const pendingRemoves = useProductWorkspaceStore( - (s) => s.sprintMembership.pending.removes, - ) - - const screenState = deriveScreenState({ - activeSprintItem: activeSprint, - buildingSprintIds, - hasPendingDraft: draftGoal !== null, - pendingAdds, - pendingRemoves, - }) - - const visibleSprints = sprints.filter(s => { - if (showClosed) return true - if (s.id === activeSprint?.id) return true - return s.status === 'open' - }) - - function handleSwitchSprint(sprintId: string) { - if (sprintId === activeSprint?.id) return - if (isDemo) { - router.push(`/products/${productId}/sprint/${sprintId}`) - return - } - startTransition(async () => { - const result = await switchActiveSprintAction(productId, sprintId) - if ('error' in result) { - toast.error( - typeof result.error === 'string' ? result.error : 'Wisselen mislukt', - ) - return - } - // Synchroniseer de client-side workspace-store met de auto-select die - // server-side is bepaald — voorkomt korte flash van vorige selectie - // voordat router.refresh de SSR-render binnenhaalt. - const store = useProductWorkspaceStore.getState() - if (result.pbiId) { - store.setActivePbi(result.pbiId) - if (result.storyId) { - store.setActiveStory(result.storyId) - } - } else { - store.setActivePbi(null) - } - if (pathname.includes('/sprint')) { - router.push(`/products/${productId}/sprint/${sprintId}`) - } else { - router.refresh() - } - }) - } - - function handleClearActiveSprint() { - if (!activeSprint) return - startTransition(async () => { - const result = await clearActiveSprintAction(productId) - if (result?.error) { - toast.error(typeof result.error === 'string' ? result.error : 'Wisselen mislukt') - return - } - if (pathname.includes('/sprint')) { - router.push(`/products/${productId}`) - } else { - router.refresh() - } - }) - } - - if (sprints.length === 0) { - return ( - <span {...debugProps('sprint-switcher')}> - <TooltipProvider> - <Tooltip> - <TooltipTrigger - className="text-sm text-muted-foreground/50 px-2 cursor-not-allowed select-none" - aria-disabled="true" - > - Geen sprints - </TooltipTrigger> - <TooltipContent>Maak een sprint aan vanuit de Product Backlog</TooltipContent> - </Tooltip> - </TooltipProvider> - </span> - ) - } - - return ( - <span {...debugProps('sprint-switcher')}> - <DropdownMenu> - <DropdownMenuTrigger - disabled={isPending} - className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded-md hover:bg-surface-container focus:outline-none" - > - <span - className={cn( - 'truncate max-w-[160px]', - screenState.kind === 'DRAFT' && 'italic text-tertiary', - )} - > - {screenState.kind === 'DRAFT' - ? `⚙ Concept — ${draftGoal}` - : activeSprint - ? activeSprint.code - : 'Selecteer sprint'} - </span> - {screenState.kind !== 'DRAFT' && activeSprint && ( - <span - className={cn( - 'text-sm', - buildingSet.has(activeSprint.id) ? 'text-warning' : 'text-muted-foreground', - )} - > - {buildingSet.has(activeSprint.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[activeSprint.status]} - </span> - )} - <ChevronDown className="w-3 h-3 shrink-0 text-muted-foreground" /> - </DropdownMenuTrigger> - <DropdownMenuContent align="center" className="w-80"> - <button - type="button" - onClick={(e) => { - e.preventDefault() - setShowClosed(v => !v) - }} - className="flex items-center gap-2 w-full px-2 py-1.5 text-sm text-muted-foreground hover:bg-surface-container rounded-md" - > - <span - className={cn( - 'inline-flex items-center justify-center w-3.5 h-3.5 rounded border', - showClosed ? 'bg-primary border-primary text-primary-foreground' : 'border-border', - )} - > - {showClosed && <Check className="w-3 h-3" />} - </span> - Toon afgeronde sprints - </button> - <DropdownMenuSeparator /> - {draftGoal && ( - <> - <DropdownMenuItem - disabled - className="italic text-tertiary opacity-90 cursor-default" - data-debug-id="sprint-switcher__concept" - > - <span className="shrink-0">⚙ Concept —</span> - <span className="truncate">{draftGoal}</span> - </DropdownMenuItem> - <DropdownMenuSeparator /> - </> - )} - <DropdownMenuItem - onClick={handleClearActiveSprint} - disabled={!activeSprint || isPending} - className={cn( - 'italic text-muted-foreground', - !activeSprint && 'opacity-50 cursor-not-allowed', - )} - > - — Geen actieve sprint — - </DropdownMenuItem> - <DropdownMenuSeparator /> - {visibleSprints.length === 0 ? ( - <div className="px-2 py-2 text-sm text-muted-foreground/70 italic"> - Geen open sprints - </div> - ) : ( - visibleSprints.map(s => ( - <DropdownMenuItem - key={s.id} - onClick={() => handleSwitchSprint(s.id)} - className={cn( - 'flex items-center gap-2', - s.id === activeSprint?.id && 'bg-primary-container text-primary-container-foreground font-medium', - )} - > - <span className="text-sm font-medium shrink-0">{s.code}</span> - <span className="text-sm truncate flex-1">{s.sprint_goal}</span> - <span - className={cn( - 'text-sm shrink-0', - buildingSet.has(s.id) ? 'text-warning' : 'text-muted-foreground', - )} - > - {buildingSet.has(s.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[s.status]} - </span> - </DropdownMenuItem> - )) - )} - </DropdownMenuContent> - </DropdownMenu> - </span> - ) -} diff --git a/components/shared/status-bar-debug-toggle.tsx b/components/shared/status-bar-debug-toggle.tsx deleted file mode 100644 index f2be375..0000000 --- a/components/shared/status-bar-debug-toggle.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client' - -import { useEffect } from 'react' -import { useUserSettingsStore } from '@/stores/user-settings/store' - -export function DebugToggle() { - const debugMode = useUserSettingsStore( - (s) => s.entities.settings.devTools?.debugMode ?? false, - ) - const hydrated = useUserSettingsStore((s) => s.context.hydrated) - const setPref = useUserSettingsStore((s) => s.setPref) - - useEffect(() => { - if (!hydrated) return - document.body.classList.toggle('debug-mode', debugMode) - }, [debugMode, hydrated]) - - return ( - <button - type="button" - onClick={() => void setPref(['devTools', 'debugMode'], !debugMode)} - aria-label="Debug-modus togglen" - aria-pressed={debugMode} - data-active={debugMode} - className="ml-2 cursor-pointer rounded px-1 text-xs opacity-40 transition-opacity hover:opacity-100 data-[active=true]:text-info" - > - {'{ }'} - </button> - ) -} diff --git a/components/shared/status-bar.tsx b/components/shared/status-bar.tsx index 4750a0c..96320a3 100644 --- a/components/shared/status-bar.tsx +++ b/components/shared/status-bar.tsx @@ -1,8 +1,3 @@ -'use client' - -import { DebugToggle } from './status-bar-debug-toggle' -import { debugProps } from '@/lib/debug' - const buildDate = process.env.NEXT_PUBLIC_BUILD_DATE ? new Date(process.env.NEXT_PUBLIC_BUILD_DATE).toLocaleDateString('nl-NL', { day: 'numeric', @@ -12,16 +7,12 @@ const buildDate = process.env.NEXT_PUBLIC_BUILD_DATE : '—' const version = process.env.NEXT_PUBLIC_APP_VERSION ?? '0.0.0' -const isDev = process.env.NODE_ENV !== 'production' export function StatusBar() { return ( - <footer - className="shrink-0 border-t border-border bg-surface-container-low h-14 px-4 flex items-center justify-between text-sm text-muted-foreground select-none" - {...debugProps('status-bar')} - > - <span data-debug-id="status-bar__copyright">© {new Date().getFullYear()} Scrum4Me</span> - <span data-debug-id="status-bar__build-info">v{version} · gebouwd op {buildDate}{isDev && <DebugToggle />}</span> + <footer className="shrink-0 border-t border-border bg-surface-container-low h-14 px-4 flex items-center justify-between text-sm text-muted-foreground select-none"> + <span>© {new Date().getFullYear()} Scrum4Me</span> + <span>v{version} · gebouwd op {buildDate}</span> </footer> ) } diff --git a/components/shared/story-log.tsx b/components/shared/story-log.tsx index e368954..ae50663 100644 --- a/components/shared/story-log.tsx +++ b/components/shared/story-log.tsx @@ -1,5 +1,3 @@ -import { debugProps } from '@/lib/debug' - interface StoryLogEntry { id: string type: string @@ -36,20 +34,14 @@ const TYPE_STYLES: Record<string, { bg: string; label: string; labelColor: strin export function StoryLog({ logs, repoUrl }: StoryLogProps) { if (logs.length === 0) { return ( - <p - {...debugProps('story-log')} - className="text-sm text-muted-foreground text-center py-4" - > + <p className="text-sm text-muted-foreground text-center py-4"> Nog geen activiteit. Gebruik de REST API om logs toe te voegen. </p> ) } return ( - <div - {...debugProps('story-log')} - className="space-y-3" - > + <div className="space-y-3"> {logs.map(log => { const style = TYPE_STYLES[log.type] ?? TYPE_STYLES.IMPLEMENTATION_PLAN const isTestResult = log.type === 'TEST_RESULT' diff --git a/components/shared/use-dialog-submit-shortcut.ts b/components/shared/use-dialog-submit-shortcut.ts deleted file mode 100644 index 669eea6..0000000 --- a/components/shared/use-dialog-submit-shortcut.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { KeyboardEvent } from 'react' - -export function useDialogSubmitShortcut(submit: () => void) { - return (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { - e.preventDefault() - submit() - } - } -} diff --git a/components/shared/use-dirty-close-guard.tsx b/components/shared/use-dirty-close-guard.tsx deleted file mode 100644 index 2d8005a..0000000 --- a/components/shared/use-dirty-close-guard.tsx +++ /dev/null @@ -1,66 +0,0 @@ -'use client' - -import { useState } from 'react' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' - -export interface DirtyCloseGuard { - confirmOpen: boolean - setConfirmOpen: (v: boolean) => void - attemptClose: () => void - confirmDiscard: () => void -} - -export function useDirtyCloseGuard( - isDirty: boolean, - onClose: () => void, -): DirtyCloseGuard { - const [confirmOpen, setConfirmOpen] = useState(false) - - function attemptClose() { - if (isDirty) setConfirmOpen(true) - else onClose() - } - - function confirmDiscard() { - setConfirmOpen(false) - onClose() - } - - return { confirmOpen, setConfirmOpen, attemptClose, confirmDiscard } -} - -export function DirtyCloseGuardDialog({ - guard, -}: { - guard: DirtyCloseGuard -}) { - return ( - <AlertDialog open={guard.confirmOpen} onOpenChange={guard.setConfirmOpen}> - <AlertDialogContent size="sm"> - <AlertDialogHeader> - <AlertDialogTitle>Wijzigingen niet opgeslagen</AlertDialogTitle> - <AlertDialogDescription> - Wil je de wijzigingen weggooien? - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel onClick={() => guard.setConfirmOpen(false)}> - Terug - </AlertDialogCancel> - <AlertDialogAction variant="destructive" onClick={guard.confirmDiscard}> - Weggooien - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> - ) -} diff --git a/components/shared/user-avatar.tsx b/components/shared/user-avatar.tsx index 05e242b..d7ca849 100644 --- a/components/shared/user-avatar.tsx +++ b/components/shared/user-avatar.tsx @@ -2,7 +2,6 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' @@ -24,13 +23,11 @@ export function UserAvatar({ userId, username, size = 'md', className }: UserAva const initials = username.slice(0, 2).toUpperCase() return ( - <span {...debugProps('user-avatar')}> - <Avatar className={cn(SIZE_CLASSES[size], className)}> - <AvatarImage src={`/api/users/${userId}/avatar`} alt={username} /> - <AvatarFallback className="bg-primary-container text-primary-container-foreground font-medium"> - {initials} - </AvatarFallback> - </Avatar> - </span> + <Avatar className={cn(SIZE_CLASSES[size], className)}> + <AvatarImage src={`/api/users/${userId}/avatar`} alt={username} /> + <AvatarFallback className="bg-primary-container text-primary-container-foreground font-medium"> + {initials} + </AvatarFallback> + </Avatar> ) } diff --git a/components/shared/user-menu.tsx b/components/shared/user-menu.tsx index a834318..b628476 100644 --- a/components/shared/user-menu.tsx +++ b/components/shared/user-menu.tsx @@ -2,7 +2,7 @@ import { useTransition } from 'react' import Link from 'next/link' -import { Settings, Sun, Globe, LogOut, BookOpen, Shield } from 'lucide-react' +import { Settings, Sun, Globe, LogOut } from 'lucide-react' import { logoutAction } from '@/actions/auth' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Badge } from '@/components/ui/badge' @@ -15,13 +15,11 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' -import { debugProps } from '@/lib/debug' const ROLE_LABELS: Record<string, string> = { PRODUCT_OWNER: 'Product Owner', SCRUM_MASTER: 'Scrum Master', DEVELOPER: 'Developer', - ADMIN: 'Admin', } interface UserMenuProps { @@ -47,7 +45,6 @@ export function UserMenu({ userId, username, email, roles }: UserMenuProps) { } return ( - <span {...debugProps('user-menu')}> <DropdownMenu> <DropdownMenuTrigger className="rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background" @@ -114,20 +111,6 @@ export function UserMenu({ userId, username, email, roles }: UserMenuProps) { <DropdownMenuSeparator /> - <DropdownMenuItem render={<Link href="/manual" />}> - <BookOpen className="mr-2 h-4 w-4" /> - <span>Manual</span> - </DropdownMenuItem> - - {roles.includes('ADMIN') && ( - <DropdownMenuItem render={<Link href="/admin" />}> - <Shield className="mr-2 h-4 w-4" /> - <span>Admin</span> - </DropdownMenuItem> - )} - - <DropdownMenuSeparator /> - <DropdownMenuItem onClick={handleLogout} onSelect={handleLogout} @@ -139,6 +122,5 @@ export function UserMenu({ userId, username, email, roles }: UserMenuProps) { </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> - </span> ) } diff --git a/components/shared/user-settings-bridge.tsx b/components/shared/user-settings-bridge.tsx deleted file mode 100644 index 581c832..0000000 --- a/components/shared/user-settings-bridge.tsx +++ /dev/null @@ -1,69 +0,0 @@ -'use client' - -import { useEffect } from 'react' -import { updateUserSettingsAction } from '@/actions/user-settings' -import { useUserSettingsStore } from '@/stores/user-settings/store' -import type { UserSettings } from '@/lib/user-settings' -import { - buildMigrationPatch, - clearLegacyStorage, -} from '@/lib/user-settings-migration' - -interface Props { - initial: UserSettings - isDemo: boolean -} - -/** - * PBI-76: hydrates the user-settings Zustand store with server-rendered prefs - * and opens an SSE subscription so other tabs/devices of the same user - * immediately see changes. Demo accounts skip the SSE subscription — their - * settings live only in-memory. - */ -export function UserSettingsBridge({ initial, isDemo }: Props) { - const hydrate = useUserSettingsStore((s) => s.hydrate) - const applyServerPatch = useUserSettingsStore((s) => s.applyServerPatch) - - useEffect(() => { - hydrate(initial, isDemo) - }, [hydrate, initial, isDemo]) - - // One-shot migration: read legacy localStorage prefs, push to server, clear. - // Idempotent via marker; demo accounts skip (no server-write). - useEffect(() => { - if (isDemo) return - const result = buildMigrationPatch() - if (!result.hasData) { - clearLegacyStorage([], []) - return - } - let cancelled = false - void (async () => { - const res = await updateUserSettingsAction(result.patch) - if (cancelled) return - if ('success' in res && res.success) { - applyServerPatch(result.patch) - clearLegacyStorage(result.legacyKeys, result.legacyCookies) - } - })() - return () => { - cancelled = true - } - }, [isDemo, applyServerPatch]) - - useEffect(() => { - if (isDemo) return - const es = new EventSource('/api/realtime/user-settings') - es.onmessage = (e) => { - try { - const patch = JSON.parse(e.data) as Partial<UserSettings> - applyServerPatch(patch) - } catch { - // ignore malformed event - } - } - return () => es.close() - }, [applyServerPatch, isDemo]) - - return null -} diff --git a/components/solo/batch-enqueue-blocker-dialog.tsx b/components/solo/batch-enqueue-blocker-dialog.tsx deleted file mode 100644 index b710748..0000000 --- a/components/solo/batch-enqueue-blocker-dialog.tsx +++ /dev/null @@ -1,96 +0,0 @@ -'use client' - -import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { - entityDialogContentClasses, - entityDialogFooterClasses, - entityDialogHeaderClasses, -} from '@/components/shared/entity-dialog-layout' -import { debugProps } from '@/lib/debug' - -interface BatchEnqueueBlockerDialogProps { - open: boolean - onOpenChange: (v: boolean) => void - prefixCount: number - blockerReason: 'task-review' | 'pbi-blocked' - blockerLabel: string - onConfirm: () => void - onCancel: () => void -} - -const BLOCKER_REASON_LABELS: Record<BatchEnqueueBlockerDialogProps['blockerReason'], string> = { - 'task-review': "Een taak staat op 'review'", - 'pbi-blocked': 'De PBI is geblokkeerd', -} - -export function BatchEnqueueBlockerDialog({ - open, - onOpenChange, - prefixCount, - blockerReason, - blockerLabel, - onConfirm, - onCancel, -}: BatchEnqueueBlockerDialogProps) { - const noTasksBeforeBlocker = prefixCount === 0 - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent showCloseButton={false} className={entityDialogContentClasses} {...debugProps('batch-enqueue-blocker-dialog', 'BatchEnqueueBlockerDialog', 'components/solo/batch-enqueue-blocker-dialog.tsx')}> - <div className={entityDialogHeaderClasses}> - <DialogTitle className="text-xl font-semibold">Blokkade gedetecteerd</DialogTitle> - </div> - - <div className="flex-1 overflow-y-auto px-6 py-6 space-y-6 text-sm text-foreground" data-debug-id="batch-enqueue-blocker-dialog__content"> - <p> - {BLOCKER_REASON_LABELS[blockerReason]}:{' '} - <span className="font-medium">{blockerLabel}</span>. - </p> - {noTasksBeforeBlocker ? ( - <p className="text-muted-foreground">Er zijn geen taken vóór de blokkade om in te plannen.</p> - ) : ( - <p> - {prefixCount === 1 - ? `Er is ${prefixCount} taak vóór de blokkade.` - : `Er zijn ${prefixCount} taken vóór de blokkade.`} - </p> - )} - </div> - - <div className={entityDialogFooterClasses}> - <div className="flex justify-end gap-2"> - <Button variant="ghost" onClick={onCancel} data-debug-id="batch-enqueue-blocker-dialog__cancel"> - Annuleer - </Button> - <TooltipProvider> - <Tooltip> - <TooltipTrigger - render={ - <span> - <Button - onClick={onConfirm} - disabled={noTasksBeforeBlocker} - data-debug-id="batch-enqueue-blocker-dialog__confirm" - > - {prefixCount === 1 - ? `Stuur ${prefixCount} taak tot aan blokkade` - : `Stuur ${prefixCount} taken tot aan blokkade`} - </Button> - </span> - } - /> - {noTasksBeforeBlocker && ( - <TooltipContent side="top" className="text-xs"> - Geen taken vóór blokkade - </TooltipContent> - )} - </Tooltip> - </TooltipProvider> - </div> - </div> - </DialogContent> - </Dialog> - ) -} diff --git a/components/solo/nav-status-indicators.tsx b/components/solo/nav-status-indicators.tsx index 994997c..e370540 100644 --- a/components/solo/nav-status-indicators.tsx +++ b/components/solo/nav-status-indicators.tsx @@ -4,7 +4,6 @@ import { useSoloStore } from '@/stores/solo-store' import type { RealtimeStatus } from '@/stores/solo-store' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' function RealtimeIndicator({ status, @@ -41,61 +40,25 @@ function RealtimeIndicator({ ) } -export function SoloNavStatusIndicators({ - hasActiveProduct, - minQuotaPct, -}: { - hasActiveProduct: boolean - minQuotaPct: number -}) { +export function SoloNavStatusIndicators({ hasActiveProduct }: { hasActiveProduct: boolean }) { const realtimeStatus = useSoloStore((s) => s.realtimeStatus) const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator) const connectedWorkers = useSoloStore((s) => s.connectedWorkers) - const workerQuotaPct = useSoloStore((s) => s.workerQuotaPct) if (!hasActiveProduct) return null - // M13: stand-by als alle workers low quota hebben (workerQuotaPct geldt - // voor de laatste-rapporterende worker; bij N>1 workers is dit een - // benadering — server-side aggregate is een v2-verbetering). - const isStandby = - connectedWorkers > 0 && - workerQuotaPct !== null && - workerQuotaPct < minQuotaPct - return ( - <div className="flex items-center gap-3 px-2" {...debugProps('solo-nav-status-indicators', 'SoloNavStatusIndicators', 'components/solo/nav-status-indicators.tsx')}> - <span data-debug-id="nav-status-indicators__queue"> - <RealtimeIndicator - status={realtimeStatus} - showConnectingIndicator={showConnectingIndicator} - /> - </span> - <div className="flex items-center gap-1 text-xs text-muted-foreground" data-debug-id="nav-status-indicators__running"> + <div className="flex items-center gap-3 px-2"> + <RealtimeIndicator + status={realtimeStatus} + showConnectingIndicator={showConnectingIndicator} + /> + <div className="flex items-center gap-1 text-xs text-muted-foreground"> <span className={cn( 'size-2 rounded-full', - isStandby - ? 'bg-warning' - : connectedWorkers > 0 - ? 'bg-status-done' - : 'bg-muted-foreground/40' + connectedWorkers > 0 ? 'bg-status-done' : 'bg-muted-foreground/40' )} /> - {isStandby ? ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger - render={<span>Stand-by ({workerQuotaPct}%)</span>} - /> - <TooltipContent> - Worker wacht tot Anthropic-quota stijgt boven {minQuotaPct}% - </TooltipContent> - </Tooltip> - </TooltipProvider> - ) : connectedWorkers > 0 ? ( - 'Agent verbonden' - ) : ( - 'Geen agent' - )} + {connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'} </div> </div> ) diff --git a/components/solo/no-active-sprint.tsx b/components/solo/no-active-sprint.tsx index cdad62f..12fab60 100644 --- a/components/solo/no-active-sprint.tsx +++ b/components/solo/no-active-sprint.tsx @@ -1,5 +1,4 @@ import Link from 'next/link' -import { debugProps } from '@/lib/debug' interface NoActiveSprintProps { productId: string @@ -8,9 +7,9 @@ interface NoActiveSprintProps { export function NoActiveSprint({ productId, productName }: NoActiveSprintProps) { return ( - <div className="flex flex-col items-center justify-center h-full gap-4 text-center px-6" {...debugProps('no-active-sprint', 'NoActiveSprint', 'components/solo/no-active-sprint.tsx')}> + <div className="flex flex-col items-center justify-center h-full gap-4 text-center px-6"> <div className="text-4xl text-muted-foreground">🏃</div> - <h2 className="text-lg font-medium text-foreground" data-debug-id="no-active-sprint__title">Geen actieve sprint</h2> + <h2 className="text-lg font-medium text-foreground">Geen actieve sprint</h2> <p className="text-sm text-muted-foreground max-w-sm"> Er is nog geen actieve sprint voor <span className="font-medium text-foreground">{productName}</span>. Start een sprint in het Sprint Board om hier je taken te zien. @@ -18,7 +17,6 @@ export function NoActiveSprint({ productId, productName }: NoActiveSprintProps) <Link href={`/products/${productId}/sprint`} className="text-sm text-primary hover:underline" - data-debug-id="no-active-sprint__cta" > Naar Sprint Board → </Link> diff --git a/components/solo/product-picker.tsx b/components/solo/product-picker.tsx index 1397d8c..513675c 100644 --- a/components/solo/product-picker.tsx +++ b/components/solo/product-picker.tsx @@ -1,5 +1,4 @@ import Link from 'next/link' -import { debugProps } from '@/lib/debug' interface Product { id: string @@ -13,7 +12,7 @@ interface ProductPickerProps { export function ProductPicker({ products }: ProductPickerProps) { return ( - <div className="p-6 max-w-2xl mx-auto w-full" {...debugProps('product-picker', 'ProductPicker', 'components/solo/product-picker.tsx')}> + <div className="p-6 max-w-2xl mx-auto w-full"> <h1 className="text-xl font-medium text-foreground mb-2">Solo bord</h1> <p className="text-sm text-muted-foreground mb-6"> Kies een product om je persoonlijke Kanban-bord te openen. @@ -27,7 +26,7 @@ export function ProductPicker({ products }: ProductPickerProps) { </Link> </div> ) : ( - <div className="grid gap-2" data-debug-id="product-picker__items"> + <div className="grid gap-2"> {products.map(product => ( <Link key={product.id} diff --git a/components/solo/realtime-bridge.tsx b/components/solo/realtime-bridge.tsx index b8bb4b3..37cc14b 100644 --- a/components/solo/realtime-bridge.tsx +++ b/components/solo/realtime-bridge.tsx @@ -11,7 +11,6 @@ import { useSoloRealtime } from '@/lib/realtime/use-solo-realtime' -// render-loos — geen JSX-root, data-debug-id niet van toepassing export function SoloRealtimeBridge({ productId }: { productId: string | null }) { useSoloRealtime(productId) return null diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx index c7a554e..54ee48e 100644 --- a/components/solo/solo-board.tsx +++ b/components/solo/solo-board.tsx @@ -1,40 +1,45 @@ 'use client' import { useEffect, useState, useTransition } from 'react' -import { useShallow } from 'zustand/react/shallow' import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, PointerSensor, useSensor, useSensors, closestCorners, } from '@dnd-kit/core' import { toast } from 'sonner' import { useSoloStore } from '@/stores/solo-store' -import { - selectSoloTaskById, - selectSoloTasksForColumn, - selectSoloUnassignedStories, -} from '@/stores/solo-workspace/selectors' -import type { SoloTask, SoloUnassignedStory, SoloWorkspaceSnapshot } from '@/stores/solo-workspace/types' import { taskStatusToApi } from '@/lib/task-status' -import { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs' -import { BatchEnqueueBlockerDialog } from './batch-enqueue-blocker-dialog' -import { debugProps } from '@/lib/debug' +import { enqueueAllTodoJobsAction } from '@/actions/claude-jobs' import { Button } from '@/components/ui/button' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { SplitPane } from '@/components/split-pane/split-pane' import { SoloColumn, type ColumnStatus } from './solo-column' import { SoloTaskCardOverlay } from './solo-task-card' import { TaskDetailDialog } from './task-detail-dialog' -import { UnassignedStoriesSheet } from './unassigned-stories-sheet' +import { UnassignedStoriesSheet, type UnassignedStory } from './unassigned-stories-sheet' -export type { SoloTask } from '@/stores/solo-workspace/types' +export interface SoloTask { + id: string + title: string + description: string | null + implementation_plan: string | null + priority: number + sort_order: number + status: 'TO_DO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE' + verify_only: boolean + verify_required: 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY' + story_id: string + story_code: string | null + story_title: string + task_code: string | null +} export interface SoloBoardProps { productId: string sprintGoal: string - tasks?: SoloTask[] - unassignedStories?: SoloUnassignedStory[] + tasks: SoloTask[] + unassignedStories: UnassignedStory[] isDemo: boolean - currentUserId?: string + currentUserId: string repoUrl?: string | null } @@ -46,55 +51,32 @@ function getColumnStatus(status: SoloTask['status']): ColumnStatus { } export function SoloBoard({ - productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, repoUrl, currentUserId, + productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, repoUrl, }: SoloBoardProps) { - const { - tasks, - hydrateSnapshot, - optimisticMove, - rollback, - markPending, - clearPending, - removeUnassignedStory, - } = useSoloStore() + const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore() const claudeJobsByTaskId = useSoloStore((s) => s.claudeJobsByTaskId) const [activeDragId, setActiveDragId] = useState<string | null>(null) - const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null) - const selectedTask = useSoloStore(selectSoloTaskById(selectedTaskId)) + const [selectedTask, setSelectedTask] = useState<SoloTask | null>(null) const [sheetOpen, setSheetOpen] = useState(false) + const [unassignedStories, setUnassignedStories] = useState(initialUnassigned) const [, startTransition] = useTransition() const [batchPending, startBatchTransition] = useTransition() - const [confirmPending, startConfirmTransition] = useTransition() - - type BlockerDialogState = { - prefixCount: number - blockerReason: 'task-review' | 'pbi-blocked' - blockerLabel: string - prefixIds: string[] - } - const [blockerDialog, setBlockerDialog] = useState<BlockerDialogState | null>(null) + const taskKey = initialTasks.map(t => t.id).join(',') useEffect(() => { - if (!initialTasks || !initialUnassigned || !currentUserId) return - const snapshot: SoloWorkspaceSnapshot = { - product: { id: productId, name: '' }, - sprint: { id: `compat:${productId}`, sprint_goal: sprintGoal }, - activeUserId: currentUserId, - tasks: initialTasks, - unassignedStories: initialUnassigned, - } - hydrateSnapshot(snapshot) - }, [currentUserId, hydrateSnapshot, initialTasks, initialUnassigned, productId, sprintGoal]) + initTasks(initialTasks) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [taskKey]) const pointerSensor = useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) const sensors = useSensors(...(isDemo ? [] : [pointerSensor])) + const taskList = Object.values(tasks) const columnTasks: Record<ColumnStatus, SoloTask[]> = { - TO_DO: useSoloStore(useShallow(selectSoloTasksForColumn('TO_DO'))), - IN_PROGRESS: useSoloStore(useShallow(selectSoloTasksForColumn('IN_PROGRESS'))), - DONE: useSoloStore(useShallow(selectSoloTasksForColumn('DONE'))), + TO_DO: taskList.filter(t => getColumnStatus(t.status) === 'TO_DO'), + IN_PROGRESS: taskList.filter(t => getColumnStatus(t.status) === 'IN_PROGRESS'), + DONE: taskList.filter(t => getColumnStatus(t.status) === 'DONE'), } - const unassignedStories = useSoloStore(useShallow(selectSoloUnassignedStories)) function handleDragStart(event: DragStartEvent) { setActiveDragId(event.active.id as string) @@ -158,42 +140,7 @@ export function SoloBoard({ function handleStartAll() { if (queueableCount === 0) return startBatchTransition(async () => { - const preview = await previewEnqueueAllAction(productId) - if ('error' in preview) { - toast.error(preview.error) - return - } - if (preview.blockerIndex === null) { - const todoIds = preview.tasks.filter(t => t.status === 'TO_DO').map(t => t.id) - const result = await enqueueClaudeJobsBatchAction(productId, todoIds) - if ('error' in result) { - toast.error(result.error) - } else if (result.count === 0) { - toast.info('Geen taken om te starten') - } else { - toast.success(`${result.count} ${result.count === 1 ? 'agent' : 'agents'} ingeschakeld`) - } - } else { - const blockerTask = preview.tasks[preview.blockerIndex] - const blockerLabel = preview.blockerReason === 'task-review' - ? `${blockerTask.story_title} — ${blockerTask.title}` - : blockerTask.story_title - setBlockerDialog({ - prefixCount: preview.blockerIndex, - blockerReason: preview.blockerReason!, - blockerLabel, - prefixIds: preview.tasks.slice(0, preview.blockerIndex).map(t => t.id), - }) - } - }) - } - - function handleBlockerConfirm() { - if (!blockerDialog) return - const { prefixIds } = blockerDialog - setBlockerDialog(null) - startConfirmTransition(async () => { - const result = await enqueueClaudeJobsBatchAction(productId, prefixIds) + const result = await enqueueAllTodoJobsAction(productId) if ('error' in result) { toast.error(result.error) } else if (result.count === 0) { @@ -205,16 +152,16 @@ export function SoloBoard({ } return ( - <div className="flex flex-col h-full p-4 gap-4 min-h-0" {...debugProps('solo-board', 'SoloBoard', 'components/solo/solo-board.tsx')}> - <div className="flex items-start justify-between gap-4 shrink-0" data-debug-id="solo-board__header"> + <div className="flex flex-col h-full p-4 gap-4 min-h-0"> + <div className="flex items-start justify-between gap-4 shrink-0"> <div className="min-w-0 flex items-center gap-3"> <DemoTooltip show={isDemo}> <Button size="sm" onClick={handleStartAll} - disabled={isDemo || batchPending || confirmPending || queueableCount === 0} + disabled={isDemo || batchPending || queueableCount === 0} > - {batchPending || confirmPending ? 'Starten…' : `Start agents (${queueableCount})`} + {batchPending ? 'Starten…' : `Start agents (${queueableCount})`} </Button> </DemoTooltip> {sprintGoal && ( @@ -236,7 +183,7 @@ export function SoloBoard({ onDragStart={handleDragStart} onDragEnd={handleDragEnd} > - <div className="flex-1 min-h-0" data-debug-id="solo-board__columns"> + <div className="flex-1 min-h-0"> <SplitPane cookieKey={`solo-${productId}`} defaultSplit={[33, 33, 34]} @@ -247,21 +194,21 @@ export function SoloBoard({ status="TO_DO" tasks={columnTasks.TO_DO} isDemo={isDemo} - onTaskClick={(t) => setSelectedTaskId(t.id)} + onTaskClick={(t) => setSelectedTask(t)} />, <SoloColumn key="IN_PROGRESS" status="IN_PROGRESS" tasks={columnTasks.IN_PROGRESS} isDemo={isDemo} - onTaskClick={(t) => setSelectedTaskId(t.id)} + onTaskClick={(t) => setSelectedTask(t)} />, <SoloColumn key="DONE" status="DONE" tasks={columnTasks.DONE} isDemo={isDemo} - onTaskClick={(t) => setSelectedTaskId(t.id)} + onTaskClick={(t) => setSelectedTask(t)} />, ]} /> @@ -276,7 +223,7 @@ export function SoloBoard({ productId={productId} isDemo={isDemo} repoUrl={repoUrl} - onClose={() => setSelectedTaskId(null)} + onClose={() => setSelectedTask(null)} /> <UnassignedStoriesSheet @@ -285,20 +232,8 @@ export function SoloBoard({ isDemo={isDemo} open={sheetOpen} onOpenChange={setSheetOpen} - onClaim={removeUnassignedStory} + onClaim={(id) => setUnassignedStories(prev => prev.filter(s => s.id !== id))} /> - - {blockerDialog && ( - <BatchEnqueueBlockerDialog - open - onOpenChange={(v) => { if (!v) setBlockerDialog(null) }} - prefixCount={blockerDialog.prefixCount} - blockerReason={blockerDialog.blockerReason} - blockerLabel={blockerDialog.blockerLabel} - onConfirm={handleBlockerConfirm} - onCancel={() => setBlockerDialog(null)} - /> - )} </div> ) } diff --git a/components/solo/solo-column.tsx b/components/solo/solo-column.tsx index 0956b67..be0e7fa 100644 --- a/components/solo/solo-column.tsx +++ b/components/solo/solo-column.tsx @@ -3,7 +3,6 @@ import { useDroppable } from '@dnd-kit/core' import { cn } from '@/lib/utils' import { SoloTaskCard } from './solo-task-card' -import { debugProps } from '@/lib/debug' import type { SoloTask } from './solo-board' export const COLUMN_CONFIG = { @@ -41,14 +40,13 @@ export function SoloColumn({ status, tasks, isDemo, onTaskClick }: SoloColumnPro 'flex flex-col h-full rounded-lg border border-border overflow-hidden', isOver && 'ring-2 ring-primary ring-inset', )} - {...debugProps('solo-column', 'SoloColumn', 'components/solo/solo-column.tsx')} > - <div className={cn('flex items-center gap-2 px-3 py-2', config.headerClass)} data-debug-id="solo-column__header"> + <div className={cn('flex items-center gap-2 px-3 py-2', config.headerClass)}> <span className="text-sm font-medium">{config.label}</span> <span className="text-xs opacity-60 ml-auto">{tasks.length}</span> </div> - <div className="flex-1 flex flex-col gap-2 p-2 overflow-y-auto min-h-[140px]" data-debug-id="solo-column__tasks"> + <div className="flex-1 flex flex-col gap-2 p-2 overflow-y-auto min-h-[140px]"> {tasks.map(task => ( <SoloTaskCard key={task.id} diff --git a/components/solo/solo-hydration-wrapper.tsx b/components/solo/solo-hydration-wrapper.tsx deleted file mode 100644 index a260060..0000000 --- a/components/solo/solo-hydration-wrapper.tsx +++ /dev/null @@ -1,55 +0,0 @@ -'use client' - -import { useEffect, useRef } from 'react' -import { useSoloStore } from '@/stores/solo-store' -import type { SoloWorkspaceSnapshot } from '@/stores/solo-workspace/types' - -interface SoloHydrationWrapperProps { - initialData: SoloWorkspaceSnapshot - children: React.ReactNode -} - -function fingerprint(data: SoloWorkspaceSnapshot): string { - const taskPart = data.tasks - .map((task) => [ - task.id, - task.status, - task.sort_order, - task.title, - task.implementation_plan ?? '', - task.verify_only ? '1' : '0', - task.verify_required, - task.story_id, - task.story_title, - task.story_code ?? '', - ].join(':')) - .join(',') - const unassignedPart = data.unassignedStories - .map((story) => [ - story.id, - story.title, - story.code ?? '', - story.tasks.map((task) => `${task.id}:${task.status}:${task.title}`).join('|'), - ].join(':')) - .join(',') - return [ - data.product.id, - data.sprint.id, - data.activeUserId, - taskPart, - unassignedPart, - ].join('||') -} - -export function SoloHydrationWrapper({ initialData, children }: SoloHydrationWrapperProps) { - const lastFingerprint = useRef<string>('') - - useEffect(() => { - const fp = fingerprint(initialData) - if (fp === lastFingerprint.current) return - lastFingerprint.current = fp - useSoloStore.getState().hydrateSnapshot(initialData) - }, [initialData]) - - return <>{children}</> -} diff --git a/components/solo/solo-task-card.tsx b/components/solo/solo-task-card.tsx index 31b85a1..6289d57 100644 --- a/components/solo/solo-task-card.tsx +++ b/components/solo/solo-task-card.tsx @@ -8,9 +8,7 @@ import { cn } from '@/lib/utils' import { CodeBadge } from '@/components/shared/code-badge' import { JOB_STATUS_LABELS, JOB_STATUS_COLORS, JOB_STATUS_ACTIVE } from '@/components/shared/job-status' import { useSoloStore } from '@/stores/solo-store' -import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip' import type { SoloTask } from './solo-board' -import { debugProps } from '@/lib/debug' const PRIORITY_BORDER: Record<number, string> = { 1: 'border-l-4 border-l-priority-critical', @@ -32,6 +30,10 @@ export function SoloTaskCard({ task, isDemo, onClick }: SoloTaskCardProps) { disabled: isDemo, }) + // view-transition-name laat de browser deze card snapshotten zodat hij + // soepel van kolom naar kolom animeert wanneer de status realtime wijzigt + // (ST-805 animatie A). Tijdens drag uit zetten — dnd-kit beheert de + // transform dan zelf en dubbele transitions willen we niet. const style: React.CSSProperties | undefined = transform ? { transform: CSS.Translate.toString(transform) } : { viewTransitionName: `solo-task-${task.id}` } @@ -48,87 +50,24 @@ export function SoloTaskCard({ task, isDemo, onClick }: SoloTaskCardProps) { isDemo ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing', )} {...(!isDemo ? { ...attributes, ...listeners } : {})} - {...debugProps('solo-task-card', 'SoloTaskCard', 'components/solo/solo-task-card.tsx')} > - {/* Regel 1: taaknaam + task_code */} - <div className="flex items-start justify-between gap-2" data-debug-id="solo-task-card__title"> + <div className="flex items-start justify-between gap-2"> <p className="text-sm text-foreground leading-snug flex-1">{task.title}</p> - {task.task_code && ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger render={<span className="shrink-0 mt-0.5" />}> - <CodeBadge code={task.task_code} /> - </TooltipTrigger> - <TooltipContent side="left"> - <p className="font-semibold">{task.title}</p> - {task.description && ( - <p className="text-muted-foreground italic">{task.description.slice(0, 100)}</p> - )} - </TooltipContent> - </Tooltip> - </TooltipProvider> - )} + {task.task_code && <CodeBadge code={task.task_code} className="shrink-0 mt-0.5" />} </div> - - {/* Regels 2–3: beschrijving + pbi_code */} - <div className="flex items-start justify-between gap-2 mt-0.5"> - {task.description ? ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger render={ - <p className="text-xs text-muted-foreground line-clamp-2 flex-1" /> - }> - {task.description} - </TooltipTrigger> - {task.description.length > 80 && ( - <TooltipContent side="bottom"> - {task.description} - </TooltipContent> - )} - </Tooltip> - </TooltipProvider> - ) : ( - <div className="flex-1" /> - )} - {task.pbi_code && ( - <TooltipProvider> - <Tooltip> - <TooltipTrigger render={<span className="shrink-0" />}> - <CodeBadge code={task.pbi_code} /> - </TooltipTrigger> - <TooltipContent side="left"> - <p className="font-semibold">{task.pbi_title}</p> - {task.pbi_description && ( - <p className="text-muted-foreground italic">{task.pbi_description.slice(0, 100)}</p> - )} - </TooltipContent> - </Tooltip> - </TooltipProvider> - )} - </div> - - {/* Regel 4: story-info + job-badge */} - <div className="flex items-center justify-between gap-2 mt-0.5" data-debug-id="solo-task-card__status"> - <p className="text-xs text-muted-foreground truncate flex-1"> + <div className="flex items-center justify-between gap-2 mt-0.5"> + <p className="text-xs text-muted-foreground truncate"> {task.story_code && <span className="font-mono mr-1">{task.story_code}</span>} {task.story_title} </p> {job && ( <span className={cn( - 'text-[10px] px-1.5 py-0 rounded border flex items-center gap-1 shrink-0 cursor-pointer', + 'text-[10px] px-1.5 py-0 rounded border flex items-center gap-1 shrink-0', JOB_STATUS_COLORS[job.status], )} onClick={(e) => { e.stopPropagation(); onClick() }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - e.stopPropagation() - onClick() - } - }} role="button" - tabIndex={0} aria-label={`Agent-status: ${JOB_STATUS_LABELS[job.status]}`} > {JOB_STATUS_ACTIVE.has(job.status) && <Loader2 className="animate-spin" size={8} />} @@ -147,23 +86,11 @@ export function SoloTaskCardOverlay({ task }: { task: SoloTask }) { 'bg-surface-container rounded border border-primary px-3 py-2 shadow-xl opacity-90', PRIORITY_BORDER[task.priority], )} - {...debugProps('solo-task-card-overlay', 'SoloTaskCardOverlay', 'components/solo/solo-task-card.tsx')} > - {/* Regel 1 */} <div className="flex items-start justify-between gap-2"> <p className="text-sm text-foreground leading-snug flex-1">{task.title}</p> {task.task_code && <CodeBadge code={task.task_code} className="shrink-0 mt-0.5" />} </div> - {/* Regels 2–3 */} - <div className="flex items-start justify-between gap-2 mt-0.5"> - {task.description ? ( - <p className="text-xs text-muted-foreground line-clamp-2 flex-1">{task.description}</p> - ) : ( - <div className="flex-1" /> - )} - {task.pbi_code && <CodeBadge code={task.pbi_code} className="shrink-0" />} - </div> - {/* Regel 4 */} <p className="text-xs text-muted-foreground mt-0.5 truncate"> {task.story_code && <span className="font-mono mr-1">{task.story_code}</span>} {task.story_title} diff --git a/components/solo/task-detail-dialog.tsx b/components/solo/task-detail-dialog.tsx index 121bab7..a7f3147 100644 --- a/components/solo/task-detail-dialog.tsx +++ b/components/solo/task-detail-dialog.tsx @@ -4,12 +4,7 @@ import { useRef, useState, useTransition } from 'react' import Link from 'next/link' import { toast } from 'sonner' import { Markdown } from '@/components/markdown' -import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' -import { - entityDialogBodyClasses, - entityDialogContentClasses, - entityDialogFooterClasses, -} from '@/components/shared/entity-dialog-layout' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' @@ -19,7 +14,6 @@ import { useSoloStore } from '@/stores/solo-store' import { enqueueClaudeJobAction, cancelClaudeJobAction } from '@/actions/claude-jobs' import { cn } from '@/lib/utils' import { getBranchUrl } from '@/lib/job-status-url' -import { debugProps } from '@/lib/debug' import type { SoloTask } from './solo-board' const STATUS_COLORS: Record<string, string> = { @@ -187,8 +181,8 @@ function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDe return ( <> - <div className="flex flex-col gap-1 px-6 pt-5 pb-4 border-b border-outline-variant shrink-0"> - <div className="flex items-start gap-3"> + <DialogHeader> + <div className="flex items-start gap-3 pr-8"> <DialogTitle className="text-sm font-medium leading-snug flex-1"> {task.title} </DialogTitle> @@ -205,80 +199,78 @@ function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDe {task.story_code && <span className="font-mono mr-1">{task.story_code}</span>} {task.story_title} </p> - </div> - - <div className={entityDialogBodyClasses} data-debug-id="task-detail-dialog__content"> - {task.description && ( - <div> - <p className="text-xs font-medium text-muted-foreground mb-1.5">Beschrijving</p> - <Markdown className="text-foreground">{task.description}</Markdown> - </div> - )} + </DialogHeader> + {task.description && ( <div> - <p className="text-xs font-medium text-muted-foreground mb-1.5">Implementatieplan</p> - <DemoTooltip show={isDemo}> - <Textarea - value={localPlan} - onChange={(e) => setLocalPlan(e.target.value)} - onBlur={handleBlur} - placeholder="Voeg een implementatieplan toe…" - className="resize-none text-sm min-h-[120px] max-h-[40vh]" - readOnly={isDemo} - /> - </DemoTooltip> - <div className="flex justify-end mt-1 h-4"> - {saveState === 'saving' && ( - <span className="text-xs text-muted-foreground">Bezig met opslaan…</span> - )} - {saveState === 'saved' && ( - <span className="text-xs text-status-done">Opgeslagen</span> - )} - </div> + <p className="text-xs font-medium text-muted-foreground mb-1.5">Beschrijving</p> + <Markdown className="text-foreground">{task.description}</Markdown> </div> + )} - <div className="flex items-center gap-2"> - <DemoTooltip show={isDemo}> - <button - type="button" - role="checkbox" - aria-checked={localVerifyOnly} - onClick={handleVerifyOnlyToggle} - disabled={isDemo || verifyOnlyPending} - className={cn( - 'h-4 w-4 rounded border border-border flex items-center justify-center shrink-0', - 'disabled:cursor-not-allowed disabled:opacity-50', - localVerifyOnly && 'bg-primary border-primary', - )} - > - {localVerifyOnly && ( - <svg className="h-3 w-3 text-primary-foreground" viewBox="0 0 12 12" fill="none"> - <path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> - </svg> - )} - </button> - </DemoTooltip> - <span className="text-xs text-muted-foreground">Alleen verifiëren (niet implementeren)</span> - </div> - - <div className="flex items-center gap-2"> - <span className="text-xs text-muted-foreground shrink-0">Verify-gate:</span> - <DemoTooltip show={isDemo}> - <select - value={localVerifyRequired} - onChange={handleVerifyRequiredChange} - disabled={isDemo || verifyRequiredPending} - className="text-xs rounded-md border border-border bg-surface-container px-2 py-1 text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50" - > - {(['ALIGNED', 'ALIGNED_OR_PARTIAL', 'ANY'] as const).map(v => ( - <option key={v} value={v}>{VERIFY_REQUIRED_LABELS[v]}</option> - ))} - </select> - </DemoTooltip> + <div> + <p className="text-xs font-medium text-muted-foreground mb-1.5">Implementatieplan</p> + <DemoTooltip show={isDemo}> + <Textarea + value={localPlan} + onChange={(e) => setLocalPlan(e.target.value)} + onBlur={handleBlur} + placeholder="Voeg een implementatieplan toe…" + className="resize-none text-sm min-h-[120px]" + readOnly={isDemo} + /> + </DemoTooltip> + <div className="flex justify-end mt-1 h-4"> + {saveState === 'saving' && ( + <span className="text-xs text-muted-foreground">Bezig met opslaan…</span> + )} + {saveState === 'saved' && ( + <span className="text-xs text-status-done">Opgeslagen</span> + )} </div> </div> - <div className={cn(entityDialogFooterClasses, 'flex flex-wrap items-center gap-2')}> + <div className="flex items-center gap-2"> + <DemoTooltip show={isDemo}> + <button + type="button" + role="checkbox" + aria-checked={localVerifyOnly} + onClick={handleVerifyOnlyToggle} + disabled={isDemo || verifyOnlyPending} + className={cn( + 'h-4 w-4 rounded border border-border flex items-center justify-center shrink-0', + 'disabled:cursor-not-allowed disabled:opacity-50', + localVerifyOnly && 'bg-primary border-primary', + )} + > + {localVerifyOnly && ( + <svg className="h-3 w-3 text-primary-foreground" viewBox="0 0 12 12" fill="none"> + <path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> + </svg> + )} + </button> + </DemoTooltip> + <span className="text-xs text-muted-foreground">Alleen verifiëren (niet implementeren)</span> + </div> + + <div className="flex items-center gap-2"> + <span className="text-xs text-muted-foreground shrink-0">Verify-gate:</span> + <DemoTooltip show={isDemo}> + <select + value={localVerifyRequired} + onChange={handleVerifyRequiredChange} + disabled={isDemo || verifyRequiredPending} + className="text-xs rounded-md border border-border bg-surface-container px-2 py-1 text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-50" + > + {(['ALIGNED', 'ALIGNED_OR_PARTIAL', 'ANY'] as const).map(v => ( + <option key={v} value={v}>{VERIFY_REQUIRED_LABELS[v]}</option> + ))} + </select> + </DemoTooltip> + </div> + + <div className="-mx-4 -mb-4 flex flex-wrap items-center gap-2 border-t bg-muted/50 px-4 py-3 rounded-b-xl"> <Link href={`/products/${productId}/sprint/planning`} className="text-xs text-primary hover:underline mr-auto" @@ -381,7 +373,7 @@ function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDe export function TaskDetailDialog({ task, productId, isDemo, repoUrl, onClose }: TaskDetailDialogProps) { return ( <Dialog open={!!task} onOpenChange={(open) => { if (!open) onClose() }}> - <DialogContent showCloseButton={false} className={entityDialogContentClasses} {...debugProps('task-detail-dialog', 'TaskDetailDialog', 'components/solo/task-detail-dialog.tsx')}> + <DialogContent className="sm:max-w-lg"> {task && ( <TaskDetailContent key={task.id} diff --git a/components/solo/unassigned-stories-sheet.tsx b/components/solo/unassigned-stories-sheet.tsx index a7ff475..6d84892 100644 --- a/components/solo/unassigned-stories-sheet.tsx +++ b/components/solo/unassigned-stories-sheet.tsx @@ -10,7 +10,6 @@ import { DemoTooltip } from '@/components/shared/demo-tooltip' import { CodeBadge } from '@/components/shared/code-badge' import { claimStoryAction } from '@/actions/stories' import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' export interface UnassignedStoryTask { id: string @@ -157,12 +156,12 @@ export function UnassignedStoriesSheet({ return ( <Sheet open={open} onOpenChange={onOpenChange}> - <SheetContent side="right" {...debugProps('unassigned-stories-sheet', 'UnassignedStoriesSheet', 'components/solo/unassigned-stories-sheet.tsx')}> + <SheetContent side="right"> <SheetHeader> <SheetTitle>Openstaande stories</SheetTitle> </SheetHeader> - <div className="flex-1 overflow-y-auto" data-debug-id="unassigned-stories-sheet__content"> + <div className="flex-1 overflow-y-auto"> {stories.length === 0 ? ( <div className="flex items-center justify-center h-32"> <p className="text-sm text-muted-foreground text-center"> @@ -170,7 +169,7 @@ export function UnassignedStoriesSheet({ </p> </div> ) : ( - <div className="flex flex-col gap-2" data-debug-id="unassigned-stories-sheet__items"> + <div className="flex flex-col gap-2"> {stories.map(story => ( <ClaimStoryRow key={story.id} diff --git a/components/split-pane/split-pane.tsx b/components/split-pane/split-pane.tsx index 1e169c7..13dac6f 100644 --- a/components/split-pane/split-pane.tsx +++ b/components/split-pane/split-pane.tsx @@ -2,16 +2,34 @@ import { Fragment, useRef, useState, useEffect, useCallback } from 'react' import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' -import { useUserSettingsStore } from '@/stores/user-settings/store' -function isValidPositions(value: unknown, n: number): value is number[] { - return ( - Array.isArray(value) && - value.length === n && - value.every((v) => typeof v === 'number') && - Math.abs((value as number[]).reduce((a, b) => a + b, 0) - 100) <= 1 +const COOKIE_PREFIX = 'sp:' +const COOKIE_MAX_AGE = 60 * 60 * 24 * 365 + +function readSplits(cookieKey: string, n: number): number[] | null { + if (typeof document === 'undefined') return null + const match = document.cookie.match( + new RegExp(`(?:^|; )${COOKIE_PREFIX}${cookieKey}=([^;]+)`) ) + if (!match) return null + try { + const parsed: unknown = JSON.parse(decodeURIComponent(match[1])) + if ( + !Array.isArray(parsed) || + parsed.length !== n || + parsed.some((v) => typeof v !== 'number') || + Math.abs((parsed as number[]).reduce((a, b) => a + b, 0) - 100) > 1 + ) return null + return parsed as number[] + } catch { + return null + } +} + +function writeSplits(cookieKey: string, splits: number[]) { + document.cookie = `${COOKIE_PREFIX}${cookieKey}=${encodeURIComponent( + JSON.stringify(splits) + )}; max-age=${COOKIE_MAX_AGE}; path=/; samesite=lax` } export interface SplitPaneProps { @@ -40,16 +58,9 @@ export function SplitPane({ const containerRef = useRef<HTMLDivElement>(null) const splitsRef = useRef<number[]>(defaultSplit) - const persisted = useUserSettingsStore( - (s) => s.entities.settings.layout?.splitPanePositions?.[cookieKey], - ) - const setPref = useUserSettingsStore((s) => s.setPref) - - // While dragging we keep splits in local state to avoid round-tripping every - // mousemove through the store. Outside of a drag, the store is the source of - // truth so cross-tab updates flow in automatically. - const [dragSplits, setDragSplits] = useState<number[] | null>(null) - const splits = dragSplits ?? (isValidPositions(persisted, n) ? persisted : defaultSplit) + const [splits, setSplits] = useState<number[]>(() => { + return readSplits(cookieKey, n) ?? defaultSplit + }) const [dragging, setDragging] = useState<number | null>(null) // divider index (0..n-2) const [isMobile, setIsMobile] = useState(false) const [internalTab, setInternalTab] = useState(0) @@ -84,20 +95,20 @@ export function SplitPane({ const newLeft = Math.min(Math.max(cursorPct - leftEdge, minPct), combinedWidth - minPct) const newRight = combinedWidth - newLeft - const base = splitsRef.current - const next = [...base] - next[dragging] = newLeft - next[dragging + 1] = newRight - setDragSplits(next) + setSplits((prev) => { + const next = [...prev] + next[dragging] = newLeft + next[dragging + 1] = newRight + return next + }) }, [dragging, minSize]) const onMouseUp = useCallback(() => { if (dragging !== null) { - void setPref(['layout', 'splitPanePositions', cookieKey], splitsRef.current) - setDragSplits(null) + writeSplits(cookieKey, splitsRef.current) setDragging(null) } - }, [dragging, cookieKey, setPref]) + }, [dragging, cookieKey]) useEffect(() => { if (dragging !== null) { @@ -112,7 +123,7 @@ export function SplitPane({ if (isMobile) { return ( - <div className="flex flex-col h-full" {...debugProps('split-pane', 'SplitPane', 'components/split-pane/split-pane.tsx')}> + <div className="flex flex-col h-full"> <div className="flex items-center border-b border-border shrink-0"> {activeTab > 0 && ( <button @@ -146,12 +157,11 @@ export function SplitPane({ } return ( - <div ref={containerRef} className="flex h-full overflow-hidden select-none" {...debugProps('split-pane', 'SplitPane', 'components/split-pane/split-pane.tsx')}> + <div ref={containerRef} className="flex h-full overflow-hidden select-none"> {panes.map((pane, i) => ( <Fragment key={i}> {i > 0 && ( <div - data-debug-id="split-pane__divider" onMouseDown={() => setDragging(i - 1)} className={cn( 'w-1 shrink-0 bg-border hover:bg-primary transition-colors cursor-col-resize', @@ -162,7 +172,6 @@ export function SplitPane({ <div className="flex flex-col overflow-hidden" style={i === n - 1 ? { flex: 1 } : { width: `${splits[i]}%` }} - data-debug-id={i === 0 ? 'split-pane__left' : i === n - 1 ? 'split-pane__right' : undefined} > {pane} </div> diff --git a/components/sprint/new-sprint-dialog.tsx b/components/sprint/new-sprint-dialog.tsx deleted file mode 100644 index 03593b5..0000000 --- a/components/sprint/new-sprint-dialog.tsx +++ /dev/null @@ -1,190 +0,0 @@ -'use client' - -import { useState, useTransition, useRef } from 'react' -import { useRouter } from 'next/navigation' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { Textarea } from '@/components/ui/textarea' -import { - Dialog, - DialogContent, - DialogTitle, -} from '@/components/ui/dialog' -import { - useDirtyCloseGuard, - DirtyCloseGuardDialog, -} from '@/components/shared/use-dirty-close-guard' -import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut' -import { - entityDialogContentClasses, - entityDialogFooterClasses, - entityDialogHeaderClasses, -} from '@/components/shared/entity-dialog-layout' -import { createSprintWithPbisAction } from '@/actions/sprints' -import { debugProps } from '@/lib/debug' - -interface NewSprintDialogProps { - open: boolean - productId: string - pbiIds: string[] - onOpenChange: (open: boolean) => void - onCreated?: (sprintId: string) => void -} - -function todayLocalDate() { - return new Date().toLocaleDateString('en-CA') -} - -export function NewSprintDialog({ - open, - productId, - pbiIds, - onOpenChange, - onCreated, -}: NewSprintDialogProps) { - const [sprintGoal, setSprintGoal] = useState('') - const [startDate, setStartDate] = useState(todayLocalDate()) - const [endDate, setEndDate] = useState(todayLocalDate()) - const [error, setError] = useState<string | null>(null) - const [dirty, setDirty] = useState(false) - const [isPending, startTransition] = useTransition() - const formRef = useRef<HTMLFormElement>(null) - const router = useRouter() - - function reset() { - setSprintGoal('') - setStartDate(todayLocalDate()) - setEndDate(todayLocalDate()) - setError(null) - setDirty(false) - } - - const closeGuard = useDirtyCloseGuard(dirty, () => { - onOpenChange(false) - reset() - }) - - function handleSubmit(e: React.FormEvent) { - e.preventDefault() - if (!sprintGoal.trim() || pbiIds.length === 0) return - setError(null) - startTransition(async () => { - const result = await createSprintWithPbisAction({ - productId, - sprint_goal: sprintGoal.trim(), - start_date: startDate || null, - end_date: endDate || null, - pbi_ids: pbiIds, - }) - if ('error' in result) { - setError(result.error) - toast.error(result.error) - return - } - toast.success('Nieuwe sprint aangemaakt') - reset() - onCreated?.(result.sprintId) - router.push(`/products/${productId}/sprint/${result.sprintId}`) - }) - } - - const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit()) - - return ( - <> - <Dialog - open={open} - onOpenChange={(o) => { - if (!o) closeGuard.attemptClose() - else onOpenChange(o) - }} - > - <DialogContent - showCloseButton={false} - onKeyDown={handleKeyDown} - className={entityDialogContentClasses} - {...debugProps('new-sprint-dialog', 'NewSprintDialog', 'components/sprint/new-sprint-dialog.tsx')} - > - <div className={entityDialogHeaderClasses}> - <DialogTitle className="text-xl font-semibold">Nieuwe sprint</DialogTitle> - <p className="text-xs text-muted-foreground mt-1"> - {pbiIds.length} PBI{pbiIds.length === 1 ? '' : "'s"} worden in deze sprint geplaatst - </p> - </div> - - <form - ref={formRef} - id="new-sprint-form" - onSubmit={handleSubmit} - onChange={() => setDirty(true)} - className="flex-1 overflow-y-auto px-6 py-6 space-y-6" - > - <div className="space-y-1.5"> - <label className="text-sm font-medium text-foreground"> - Sprint Goal <span className="text-error">*</span> - </label> - <Textarea - value={sprintGoal} - onChange={(e) => setSprintGoal(e.target.value)} - required - rows={3} - placeholder="Wat wil je aan het einde van deze Sprint bereikt hebben?" - autoFocus - /> - </div> - - <div className="grid grid-cols-2 gap-3"> - <div className="space-y-1.5"> - <label className="text-sm font-medium text-foreground">Startdatum</label> - <input - type="date" - value={startDate} - onChange={(e) => setStartDate(e.target.value)} - className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" - /> - </div> - <div className="space-y-1.5"> - <label className="text-sm font-medium text-foreground">Einddatum</label> - <input - type="date" - value={endDate} - onChange={(e) => setEndDate(e.target.value)} - className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" - /> - </div> - </div> - - {error && ( - <div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error"> - {error} - </div> - )} - </form> - - <div className={entityDialogFooterClasses}> - <div className="flex justify-end gap-2"> - <Button - type="button" - variant="ghost" - onClick={closeGuard.attemptClose} - disabled={isPending} - > - Annuleren - </Button> - <Button - type="submit" - form="new-sprint-form" - disabled={isPending || !sprintGoal.trim() || pbiIds.length === 0} - data-debug-id="new-sprint-dialog__submit" - > - {isPending ? 'Aanmaken…' : 'Sprint aanmaken'} - </Button> - </div> - </div> - </DialogContent> - </Dialog> - - <DirtyCloseGuardDialog guard={closeGuard} /> - </> - ) -} diff --git a/components/sprint/sprint-backlog.tsx b/components/sprint/sprint-backlog.tsx index a41671f..3555912 100644 --- a/components/sprint/sprint-backlog.tsx +++ b/components/sprint/sprint-backlog.tsx @@ -1,18 +1,13 @@ 'use client' -import { useMemo, useState, useTransition } from 'react' -import { Trash2, MoreHorizontal, ChevronsUp, ChevronsDown, Pencil } from 'lucide-react' +import { useState, useTransition } from 'react' +import { Trash2, MoreHorizontal, ChevronsUp, ChevronsDown, ListFilter } from 'lucide-react' import { useDroppable, useDraggable } from '@dnd-kit/core' +import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' import { toast } from 'sonner' -import { useShallow } from 'zustand/react/shallow' import { Badge } from '@/components/ui/badge' import { CodeBadge } from '@/components/shared/code-badge' -import { useUserSettingsStore } from '@/stores/user-settings/store' -import { - BacklogFilterPopover, - PRIORITY_LABELS as SHARED_PRIORITY_LABELS, - type SortDir, -} from '@/components/shared/backlog-filter-popover' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, @@ -22,15 +17,9 @@ import { UserAvatar } from '@/components/shared/user-avatar' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { PRIORITY_BORDER } from '@/components/backlog/backlog-card' -import { PRIORITY_COLORS } from '@/components/shared/priority-select' -import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' -import { selectStoriesForActiveSprint } from '@/stores/sprint-workspace/selectors' +import { useSprintStore } from '@/stores/sprint-store' import { claimStoryAction, unclaimStoryAction, reassignStoryAction, claimAllUnassignedInActiveSprintAction } from '@/actions/stories' -import { PbiDialog, type PbiDialogState } from '@/components/backlog/pbi-dialog' -import { StoryDialog, type StoryDialogState } from '@/components/backlog/story-dialog' -import type { PbiStatusApi } from '@/lib/task-status' import { cn } from '@/lib/utils' -import { debugProps } from '@/lib/debug' const STATUS_COLORS: Record<string, string> = { OPEN: 'bg-status-todo/15 text-status-todo border-status-todo/30', @@ -43,13 +32,7 @@ export interface SprintStory { id: string code: string | null title: string - description: string | null - acceptance_criteria: string | null - pbi_id: string - sprint_id: string | null - created_at: Date priority: number - sort_order: number status: string taskCount: number doneCount: number @@ -66,31 +49,27 @@ export interface PbiWithStories { id: string code: string | null title: string - priority: number - status: PbiStatusApi - description: string | null stories: SprintStory[] } // --- Left panel: Sprint Backlog --- -function SprintRow({ - story, isDemo, onRemove, onSelect, onEdit, isSelected, +function SortableSprintRow({ + story, isDemo, onRemove, onSelect, isSelected, currentUserId, productId, members, onAssigneeChange, }: { story: SprintStory isDemo: boolean onRemove: () => void onSelect: () => void - onEdit: () => void isSelected: boolean currentUserId: string productId: string members: ProductMember[] onAssigneeChange: (storyId: string, id: string | null, username: string | null) => void }) { - const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: story.id }) - const style = { opacity: isDragging ? 0.4 : 1 } + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: story.id }) + const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 } const [, startTransition] = useTransition() function handleClaim(e: React.MouseEvent) { @@ -210,16 +189,6 @@ function SprintRow({ </DropdownMenuContent> </DropdownMenu> </DemoTooltip> - <DemoTooltip show={isDemo}> - <button - onClick={e => { e.stopPropagation(); if (!isDemo) onEdit() }} - className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed" - aria-label="Bewerk story" - disabled={isDemo} - > - <Pencil size={14} /> - </button> - </DemoTooltip> <DemoTooltip show={isDemo}> <button onClick={e => { e.stopPropagation(); if (!isDemo) onRemove() }} @@ -240,6 +209,7 @@ function SprintRow({ interface SprintBacklogLeftProps { sprintId: string + stories: SprintStory[] isDemo: boolean onRemove: (storyId: string) => void onSelect: (storyId: string) => void @@ -251,21 +221,18 @@ interface SprintBacklogLeftProps { } export function SprintBacklogLeft({ - sprintId: _sprintId, isDemo, onRemove, onSelect, selectedStoryId, + sprintId, stories, isDemo, onRemove, onSelect, selectedStoryId, currentUserId, productId, members, onAssigneeChange, }: SprintBacklogLeftProps) { - const orderedStories = useSprintWorkspaceStore( - useShallow((s) => selectStoriesForActiveSprint(s) as SprintStory[]), - ) + const { sprintStoryOrder } = useSprintStore() const { setNodeRef, isOver } = useDroppable({ id: 'sprint-zone' }) const [isPending, startTransition] = useTransition() - const [storyDialogState, setStoryDialogState] = useState<StoryDialogState | null>(null) - const unassignedCount = orderedStories.filter(s => (s.assignee_id ?? null) === null).length + const unassignedCount = stories.filter(s => s.assignee_id === null).length const currentUserUsername = members.find(m => m.userId === currentUserId)?.username ?? null function handleClaimAll() { - const unassigned = orderedStories.filter(s => (s.assignee_id ?? null) === null) + const unassigned = stories.filter(s => s.assignee_id === null) unassigned.forEach(s => onAssigneeChange(s.id, currentUserId, currentUserUsername)) startTransition(async () => { const result = await claimAllUnassignedInActiveSprintAction(productId) @@ -278,8 +245,12 @@ export function SprintBacklogLeft({ }) } + const storyMap = Object.fromEntries(stories.map(s => [s.id, s])) + const order = sprintStoryOrder[sprintId] ?? stories.map(s => s.id) + const orderedStories = order.map(id => storyMap[id]).filter(Boolean) + return ( - <div className="flex flex-col h-full" {...debugProps('sprint-backlog-left', 'SprintBacklogLeft', 'components/sprint/sprint-backlog.tsx')}> + <div className="flex flex-col h-full"> <PanelNavBar title="Sprint Backlog" actions={ @@ -296,7 +267,6 @@ export function SprintBacklogLeft({ /> <div ref={setNodeRef} - data-debug-id="sprint-backlog-left__list" className={cn( 'flex-1 overflow-y-auto transition-colors', isOver && 'bg-primary/5 ring-2 ring-inset ring-primary/20 rounded' @@ -310,15 +280,14 @@ export function SprintBacklogLeft({ {isOver ? 'Loslaten om toe te voegen aan Sprint' : 'Geen stories in de Sprint. Sleep stories vanuit het linkerpaneel.'} </p> ) : ( - <> + <SortableContext items={orderedStories.map(s => s.id)} strategy={verticalListSortingStrategy}> {orderedStories.map(story => ( - <SprintRow + <SortableSprintRow key={story.id} story={story} isDemo={isDemo} onRemove={() => onRemove(story.id)} onSelect={() => onSelect(story.id)} - onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })} isSelected={selectedStoryId === story.id} currentUserId={currentUserId} productId={productId} @@ -326,59 +295,15 @@ export function SprintBacklogLeft({ onAssigneeChange={onAssigneeChange} /> ))} - </> + </SortableContext> )} </div> - <StoryDialog - state={storyDialogState} - onClose={() => setStoryDialogState(null)} - isDemo={isDemo} - /> </div> ) } // --- Right panel: Product Backlog grouped by PBI --- -type StoryStatusFilter = 'OPEN' | 'IN_SPRINT' | 'DONE' | 'all' - -const STATUS_OPTIONS_SPRINT: Array<{ value: StoryStatusFilter; label: string }> = [ - { value: 'all', label: 'Alle' }, - { value: 'OPEN', label: 'Open' }, - { value: 'IN_SPRINT', label: 'In Sprint' }, - { value: 'DONE', label: 'Klaar' }, -] - -type PbiSort = 'code' | 'priority' | 'status' - -const SORT_OPTIONS_SPRINT: Array<{ value: PbiSort; label: string }> = [ - { value: 'code', label: 'Code' }, - { value: 'priority', label: 'Prioriteit' }, - { value: 'status', label: 'Status' }, -] - -const PBI_STATUS_ORDER: Record<PbiStatusApi, number> = { - ready: 0, - blocked: 1, - failed: 2, - done: 3, -} - -function comparePbis(a: PbiWithStories, b: PbiWithStories, sort: PbiSort): number { - const codeCmp = (a.code ?? '').localeCompare(b.code ?? '', undefined, { numeric: true }) - if (sort === 'priority') { - if (a.priority !== b.priority) return a.priority - b.priority - return codeCmp - } - if (sort === 'status') { - const sa = PBI_STATUS_ORDER[a.status] ?? 99 - const sb = PBI_STATUS_ORDER[b.status] ?? 99 - if (sa !== sb) return sa - sb - return codeCmp - } - return codeCmp -} - function DraggablePbiStoryRow({ story, isDemo, @@ -446,162 +371,85 @@ interface SprintBacklogRightProps { pbisWithStories: PbiWithStories[] sprintStoryIds: Set<string> isDemo: boolean - productId: string onAdd: (storyId: string) => void } -export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, productId, onAdd }: SprintBacklogRightProps) { - const prefs = useUserSettingsStore( - useShallow((s) => s.entities.settings.views?.sprintBacklog ?? {}), - ) - const setPref = useUserSettingsStore((s) => s.setPref) - - const filterPriority = prefs.filterPriority ?? 'all' - const filterStatus: StoryStatusFilter = prefs.filterStatus ?? 'OPEN' - const sort: PbiSort = prefs.sort ?? 'code' - const sortDir: SortDir = prefs.sortDir ?? 'asc' - const filterPopoverOpen = prefs.filterPopoverOpen ?? false - - const collapsed = useMemo<Set<string>>(() => { - if (prefs.collapsedPbis !== undefined) return new Set(prefs.collapsedPbis) - // Default: auto-collapse PBIs whose stories are all DONE. +export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, onAdd }: SprintBacklogRightProps) { + const [collapsed, setCollapsed] = useState<Set<string>>(() => { const auto = new Set<string>() for (const pbi of pbisWithStories) { - if (pbi.stories.length > 0 && pbi.stories.every((s) => s.status === 'DONE')) { + if (pbi.stories.length > 0 && pbi.stories.every(s => s.status === 'DONE')) { auto.add(pbi.id) } } return auto - }, [prefs.collapsedPbis, pbisWithStories]) - - const setFilterPriority = (v: number | 'all') => - void setPref(['views', 'sprintBacklog', 'filterPriority'], v) - const setFilterStatus = (v: StoryStatusFilter) => - void setPref(['views', 'sprintBacklog', 'filterStatus'], v) - const setSort = (v: PbiSort) => void setPref(['views', 'sprintBacklog', 'sort'], v) - const setSortDir = (v: SortDir) => void setPref(['views', 'sprintBacklog', 'sortDir'], v) - const setFilterPopoverOpen = (v: boolean) => - void setPref(['views', 'sprintBacklog', 'filterPopoverOpen'], v) - const setCollapsedArray = (next: Set<string>) => - void setPref(['views', 'sprintBacklog', 'collapsedPbis'], Array.from(next)) - - const [pbiDialogState, setPbiDialogState] = useState<PbiDialogState | null>(null) + }) const { setNodeRef, isOver } = useDroppable({ id: 'backlog-zone' }) - const filteredPbis = pbisWithStories - .map(pbi => ({ - ...pbi, - stories: pbi.stories.filter(s => - (filterPriority === 'all' || s.priority === filterPriority) && - (filterStatus === 'all' || s.status === filterStatus) - ), - })) - .filter(pbi => pbi.stories.length > 0) - .sort((a, b) => (sortDir === 'desc' ? -1 : 1) * comparePbis(a, b, sort)) - - const activeFilterCount = - (filterPriority !== 'all' ? 1 : 0) + - (filterStatus !== 'OPEN' ? 1 : 0) - function toggle(pbiId: string) { - const next = new Set(collapsed) - if (next.has(pbiId)) next.delete(pbiId) - else next.add(pbiId) - setCollapsedArray(next) + setCollapsed(prev => { + const next = new Set(prev) + if (next.has(pbiId)) { next.delete(pbiId) } else { next.add(pbiId) } + return next + }) } function collapseAll() { - setCollapsedArray(new Set(filteredPbis.map((p) => p.id))) + setCollapsed(new Set(pbisWithStories.map(p => p.id))) } function expandAll() { - setCollapsedArray(new Set()) + setCollapsed(new Set()) } - const headerActions = ( - <> - {filterPriority !== 'all' && ( - <button - onClick={() => setFilterPriority('all')} - className="flex items-center gap-1 text-xs text-primary hover:underline" - aria-label="Wis prioriteitsfilter" - > - <Badge className={cn('text-xs', PRIORITY_COLORS[filterPriority])}> - {SHARED_PRIORITY_LABELS[filterPriority]} - </Badge> - <span>×</span> - </button> - )} - {filterStatus !== 'OPEN' && ( - <button - onClick={() => setFilterStatus('OPEN')} - className="flex items-center gap-1 text-xs text-primary hover:underline" - aria-label="Wis statusfilter" - > - <Badge className={cn('text-[10px] px-1.5 py-0 border', filterStatus === 'all' ? '' : STATUS_COLORS[filterStatus])}> - {filterStatus === 'all' ? 'Alle' : STATUS_LABELS[filterStatus]} - </Badge> - <span>×</span> - </button> - )} - <BacklogFilterPopover - open={filterPopoverOpen} - onOpenChange={setFilterPopoverOpen} - filterPriority={filterPriority} - onFilterPriorityChange={setFilterPriority} - filterStatus={filterStatus} - onFilterStatusChange={setFilterStatus} - statusOptions={STATUS_OPTIONS_SPRINT} - sort={sort} - onSortChange={setSort} - sortDir={sortDir} - onSortDirChange={setSortDir} - sortOptions={SORT_OPTIONS_SPRINT} - activeFilterCount={activeFilterCount} - resetDisabled={filterPriority === 'all' && filterStatus === 'OPEN' && sort === 'code' && sortDir === 'asc'} - onReset={() => { - setFilterPriority('all') - setFilterStatus('OPEN') - setSort('code') - setSortDir('asc') - }} - /> - <TooltipProvider> - <Tooltip> - <TooltipTrigger onClick={collapseAll} className="text-muted-foreground hover:text-foreground p-0.5 rounded" aria-label="Alles inklappen"> - <ChevronsUp size={14} /> - </TooltipTrigger> - <TooltipContent>Alles inklappen</TooltipContent> - </Tooltip> - <Tooltip> - <TooltipTrigger onClick={expandAll} className="text-muted-foreground hover:text-foreground p-0.5 rounded" aria-label="Alles uitklappen"> - <ChevronsDown size={14} /> - </TooltipTrigger> - <TooltipContent>Alles uitklappen</TooltipContent> - </Tooltip> - </TooltipProvider> - </> + function onlyNotDone() { + const auto = new Set<string>() + for (const pbi of pbisWithStories) { + if (pbi.stories.length > 0 && pbi.stories.every(s => s.status === 'DONE')) { + auto.add(pbi.id) + } + } + setCollapsed(auto) + } + + const collapseActions = ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger onClick={collapseAll} className="text-muted-foreground hover:text-foreground p-0.5 rounded" aria-label="Alles inklappen"> + <ChevronsUp size={14} /> + </TooltipTrigger> + <TooltipContent>Alles inklappen</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger onClick={expandAll} className="text-muted-foreground hover:text-foreground p-0.5 rounded" aria-label="Alles uitklappen"> + <ChevronsDown size={14} /> + </TooltipTrigger> + <TooltipContent>Alles uitklappen</TooltipContent> + </Tooltip> + <Tooltip> + <TooltipTrigger onClick={onlyNotDone} className="text-muted-foreground hover:text-foreground p-0.5 rounded" aria-label="Alleen niet klaar"> + <ListFilter size={14} /> + </TooltipTrigger> + <TooltipContent>Alleen niet klaar</TooltipContent> + </Tooltip> + </TooltipProvider> ) return ( - <div className="flex flex-col h-full" {...debugProps('sprint-backlog-right', 'SprintBacklogRight', 'components/sprint/sprint-backlog.tsx')}> - <PanelNavBar title="Product Backlog" actions={headerActions} /> + <div className="flex flex-col h-full"> + <PanelNavBar title="Product Backlog" actions={collapseActions} /> <div ref={setNodeRef} - data-debug-id="sprint-backlog-right__list" className={cn( 'flex-1 overflow-y-auto py-2 transition-colors', isOver && 'bg-error/5 ring-2 ring-inset ring-error/20 rounded' )} > - {filteredPbis.map(pbi => ( + {pbisWithStories.map(pbi => ( <div key={pbi.id}> - <div - role="button" - tabIndex={0} + <button onClick={() => toggle(pbi.id)} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(pbi.id) } }} - className="group w-full flex items-center gap-2 px-4 py-1.5 hover:bg-surface-container transition-colors text-left select-none cursor-pointer" + className="w-full flex items-center gap-2 px-4 py-1.5 hover:bg-surface-container transition-colors text-left select-none" > <span className="text-xs">{collapsed.has(pbi.id) ? '▶' : '▼'}</span> <span className="text-sm font-medium truncate flex-1">{pbi.title}</span> @@ -609,20 +457,7 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr <span className="text-xs text-muted-foreground"> {pbi.stories.filter(s => s.status === 'DONE').length}/{pbi.stories.length} klaar </span> - <DemoTooltip show={isDemo}> - <button - onClick={(e) => { - e.stopPropagation() - if (!isDemo) setPbiDialogState({ mode: 'edit', productId, pbi: { id: pbi.id, title: pbi.title, code: pbi.code, priority: pbi.priority, status: pbi.status, description: pbi.description } }) - }} - className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground p-0.5 rounded disabled:opacity-40 disabled:cursor-not-allowed" - aria-label="Bewerk PBI" - disabled={isDemo} - > - <Pencil size={14} /> - </button> - </DemoTooltip> - </div> + </button> {!collapsed.has(pbi.id) && pbi.stories.map(story => { const inSprint = sprintStoryIds.has(story.id) @@ -655,11 +490,6 @@ export function SprintBacklogRight({ pbisWithStories, sprintStoryIds, isDemo, pr </div> ))} </div> - <PbiDialog - state={pbiDialogState} - onClose={() => setPbiDialogState(null)} - isDemo={isDemo} - /> </div> ) } diff --git a/components/sprint/sprint-board-client.tsx b/components/sprint/sprint-board-client.tsx index 6f98b10..d499767 100644 --- a/components/sprint/sprint-board-client.tsx +++ b/components/sprint/sprint-board-client.tsx @@ -1,71 +1,66 @@ 'use client' -import { useState, useTransition } from 'react' +import { useState, useEffect, useTransition } from 'react' import { DndContext, DragEndEvent, DragStartEvent, DragOverlay, KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, } from '@dnd-kit/core' -import { sortableKeyboardCoordinates } from '@dnd-kit/sortable' +import { sortableKeyboardCoordinates, arrayMove } from '@dnd-kit/sortable' import { toast } from 'sonner' -import { useShallow } from 'zustand/react/shallow' import { SplitPane } from '@/components/split-pane/split-pane' import { SprintBacklogLeft, SprintBacklogRight } from './sprint-backlog' import type { SprintStory, PbiWithStories, ProductMember } from './sprint-backlog' import { TaskList } from './task-list' -import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' -import { selectStoriesForActiveSprint } from '@/stores/sprint-workspace/selectors' -import type { SprintWorkspaceStory } from '@/stores/sprint-workspace/types' +import type { Task } from './task-list' +import { useSprintStore } from '@/stores/sprint-store' import { addStoryToSprintAction, removeStoryFromSprintAction, + reorderSprintStoriesAction, } from '@/actions/sprints' -import { debugProps } from '@/lib/debug' interface SprintBoardClientProps { productId: string sprintId: string + stories: SprintStory[] pbisWithStories: PbiWithStories[] + sprintStoryIdList: string[] + tasksByStory: Record<string, Task[]> isDemo: boolean currentUserId: string members: ProductMember[] } -function toWorkspaceStory(story: SprintStory, sprintId: string): SprintWorkspaceStory { - return { - id: story.id, - code: story.code, - title: story.title, - description: story.description, - acceptance_criteria: story.acceptance_criteria, - priority: story.priority, - sort_order: story.sort_order, - status: story.status, - pbi_id: story.pbi_id, - sprint_id: sprintId, - created_at: story.created_at, - taskCount: story.taskCount, - doneCount: story.doneCount, - assignee_id: story.assignee_id, - assignee_username: story.assignee_username, - } -} - export function SprintBoardClient({ productId, sprintId, + stories, pbisWithStories, + sprintStoryIdList, + tasksByStory, isDemo, currentUserId, members, }: SprintBoardClientProps) { - const sprintStories = useSprintWorkspaceStore( - useShallow((s) => selectStoriesForActiveSprint(s) as SprintStory[]), - ) - const selectedStoryId = useSprintWorkspaceStore((s) => s.context.activeStoryId) - const sprintStoryIds = new Set(sprintStories.map(s => s.id)) + const [sprintStories, setSprintStories] = useState<SprintStory[]>(stories) + const [sprintStoryIds, setSprintStoryIds] = useState<Set<string>>(() => new Set(sprintStoryIdList)) + const [selectedStoryId, setSelectedStoryId] = useState<string | null>(null) + const { + sprintStoryOrder, + initSprint, + addStoryToSprint, + removeStoryFromSprint, + reorderSprintStories, + rollbackSprint, + } = useSprintStore() const [activeDragStory, setActiveDragStory] = useState<SprintStory | null>(null) const [, startTransition] = useTransition() + useEffect(() => { + initSprint(sprintId, stories.map(s => s.id)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sprintId]) + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) @@ -86,8 +81,9 @@ export function SprintBoardClient({ const activeId = active.id.toString() const overId = over.id.toString() + const order = sprintStoryOrder[sprintId] ?? sprintStories.map(s => s.id) - // Drag from product backlog (left) → add to sprint (middle) + // Drag from left (product backlog) → add to sprint (middle) if (activeId.startsWith('pb:')) { const storyId = activeId.slice(3) const droppingOnSprint = @@ -95,90 +91,108 @@ export function SprintBoardClient({ (!overId.startsWith('pb:') && overId !== 'backlog-zone') if (droppingOnSprint && !sprintStoryIds.has(storyId)) { const storyData = pbisWithStories.flatMap(p => p.stories).find(s => s.id === storyId) - if (storyData) handleAdd(storyId, storyData) + if (!storyData) return + setSprintStoryIds(prev => new Set([...prev, storyId])) + setSprintStories(prev => [...prev, storyData]) + addStoryToSprint(sprintId, storyId) + startTransition(async () => { + const result = await addStoryToSprintAction(sprintId, storyId) + if (!result.success) { + setSprintStoryIds(prev => { const n = new Set(prev); n.delete(storyId); return n }) + setSprintStories(prev => prev.filter(s => s.id !== storyId)) + removeStoryFromSprint(sprintId, storyId) + toast.error(result.error ?? 'Toevoegen mislukt') + } + }) } return } - // Drag from sprint (middle) → product backlog (left) → remove + // Drag from middle (sprint backlog) → left (product backlog) → remove if (overId === 'backlog-zone') { - handleRemove(activeId) + const storyData = sprintStories.find(s => s.id === activeId) + setSprintStoryIds(prev => { const n = new Set(prev); n.delete(activeId); return n }) + setSprintStories(prev => prev.filter(s => s.id !== activeId)) + removeStoryFromSprint(sprintId, activeId) + if (selectedStoryId === activeId) setSelectedStoryId(null) + startTransition(async () => { + const result = await removeStoryFromSprintAction(activeId) + if (!result.success) { + if (storyData) { + setSprintStoryIds(prev => new Set([...prev, activeId])) + setSprintStories(prev => [...prev, storyData]) + } + addStoryToSprint(sprintId, activeId) + toast.error('Verwijderen mislukt') + } + }) return } + + // Reorder within sprint (middle panel) + if (activeId !== overId && !activeId.startsWith('pb:')) { + const prevOrder = [...order] + const newOrder = order.includes(overId) + ? arrayMove([...order], order.indexOf(activeId), order.indexOf(overId)) + : [...order.filter(id => id !== activeId), activeId] + + reorderSprintStories(sprintId, newOrder) + startTransition(async () => { + const result = await reorderSprintStoriesAction(sprintId, newOrder) + if (!result.success) { + rollbackSprint(sprintId, prevOrder) + toast.error('Volgorde opslaan mislukt') + } + }) + } } - function handleAdd(storyId: string, storyData: SprintStory) { + function handleAdd(storyId: string) { if (sprintStoryIds.has(storyId)) return - - const store = useSprintWorkspaceStore.getState() - const prevStory = store.entities.storiesById[storyId] - const prevSprintStoryIds = [...(store.relations.storyIdsBySprint[sprintId] ?? [])] - - useSprintWorkspaceStore.setState((s) => { - s.entities.storiesById[storyId] = toWorkspaceStory(storyData, sprintId) - const list = s.relations.storyIdsBySprint[sprintId] ?? [] - if (!list.includes(storyId)) list.push(storyId) - s.relations.storyIdsBySprint[sprintId] = list - }) - + const storyData = pbisWithStories.flatMap(p => p.stories).find(s => s.id === storyId) + if (!storyData) return + setSprintStoryIds(prev => new Set([...prev, storyId])) + setSprintStories(prev => [...prev, storyData]) + addStoryToSprint(sprintId, storyId) startTransition(async () => { const result = await addStoryToSprintAction(sprintId, storyId) if (!result.success) { - useSprintWorkspaceStore.setState((s) => { - if (prevStory === undefined) { - delete s.entities.storiesById[storyId] - } else { - s.entities.storiesById[storyId] = prevStory - } - s.relations.storyIdsBySprint[sprintId] = prevSprintStoryIds - }) + setSprintStoryIds(prev => { const n = new Set(prev); n.delete(storyId); return n }) + setSprintStories(prev => prev.filter(s => s.id !== storyId)) + removeStoryFromSprint(sprintId, storyId) toast.error(result.error ?? 'Toevoegen mislukt') } }) } + function handleAssigneeChange(storyId: string, assigneeId: string | null, assigneeUsername: string | null) { + setSprintStories(prev => + prev.map(s => s.id === storyId ? { ...s, assignee_id: assigneeId, assignee_username: assigneeUsername } : s) + ) + } + function handleRemove(storyId: string) { - const store = useSprintWorkspaceStore.getState() - const prevStory = store.entities.storiesById[storyId] - const prevSprintStoryIds = [...(store.relations.storyIdsBySprint[sprintId] ?? [])] - - useSprintWorkspaceStore.setState((s) => { - const list = s.relations.storyIdsBySprint[sprintId] - if (list) { - s.relations.storyIdsBySprint[sprintId] = list.filter((id) => id !== storyId) - } - const story = s.entities.storiesById[storyId] - if (story) story.sprint_id = null - }) - - if (selectedStoryId === storyId) { - useSprintWorkspaceStore.getState().setActiveStory(null) - } - + const storyData = sprintStories.find(s => s.id === storyId) + setSprintStoryIds(prev => { const n = new Set(prev); n.delete(storyId); return n }) + setSprintStories(prev => prev.filter(s => s.id !== storyId)) + removeStoryFromSprint(sprintId, storyId) + if (selectedStoryId === storyId) setSelectedStoryId(null) startTransition(async () => { const result = await removeStoryFromSprintAction(storyId) if (!result.success) { - useSprintWorkspaceStore.setState((s) => { - if (prevStory) s.entities.storiesById[storyId] = prevStory - s.relations.storyIdsBySprint[sprintId] = prevSprintStoryIds - }) + if (storyData) { + setSprintStoryIds(prev => new Set([...prev, storyId])) + setSprintStories(prev => [...prev, storyData]) + } + addStoryToSprint(sprintId, storyId) toast.error('Verwijderen mislukt') } }) } - function handleAssigneeChange(storyId: string, assigneeId: string | null, assigneeUsername: string | null) { - useSprintWorkspaceStore.setState((s) => { - const story = s.entities.storiesById[storyId] - if (story) { - story.assignee_id = assigneeId - story.assignee_username = assigneeUsername - } - }) - } + const selectedTasks = selectedStoryId ? (tasksByStory[selectedStoryId] ?? []) : [] return ( - <div {...debugProps('sprint-board-client')} className="contents"> <DndContext id="sprint-board" sensors={sensors} @@ -196,18 +210,15 @@ export function SprintBoardClient({ pbisWithStories={pbisWithStories} sprintStoryIds={sprintStoryIds} isDemo={isDemo} - productId={productId} - onAdd={(storyId) => { - const storyData = pbisWithStories.flatMap(p => p.stories).find(s => s.id === storyId) - if (storyData) handleAdd(storyId, storyData) - }} + onAdd={handleAdd} />, <SprintBacklogLeft key="sprint" sprintId={sprintId} + stories={sprintStories} isDemo={isDemo} onRemove={handleRemove} - onSelect={(storyId) => useSprintWorkspaceStore.getState().setActiveStory(storyId)} + onSelect={setSelectedStoryId} selectedStoryId={selectedStoryId} currentUserId={currentUserId} productId={productId} @@ -217,8 +228,11 @@ export function SprintBoardClient({ selectedStoryId ? ( <TaskList key="tasks" + storyId={selectedStoryId} + storyCode={stories.find(s => s.id === selectedStoryId)?.code ?? null} sprintId={sprintId} productId={productId} + tasks={selectedTasks} isDemo={isDemo} /> ) : ( @@ -230,13 +244,12 @@ export function SprintBoardClient({ /> <DragOverlay> {activeDragStory && ( - <div className="flex items-center gap-3 px-6 py-2 bg-popover border border-primary rounded shadow-lg text-sm opacity-95 w-72" data-debug-id="sprint-board-client__drag-overlay"> + <div className="flex items-center gap-3 px-6 py-2 bg-popover border border-primary rounded shadow-lg text-sm opacity-95 w-72"> <span className="text-muted-foreground select-none">⠿</span> <span className="truncate flex-1">{activeDragStory.title}</span> </div> )} </DragOverlay> </DndContext> - </div> ) } diff --git a/components/sprint/sprint-header.tsx b/components/sprint/sprint-header.tsx index 304dd88..b47a567 100644 --- a/components/sprint/sprint-header.tsx +++ b/components/sprint/sprint-header.tsx @@ -1,44 +1,22 @@ 'use client' -import { useState, useTransition, useActionState, useRef } from 'react' +import { useState, useTransition, useActionState } from 'react' +import { useFormStatus } from 'react-dom' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' import { Dialog, DialogContent, + DialogHeader, DialogTitle, } from '@/components/ui/dialog' -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' import { toast } from 'sonner' import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { - useDirtyCloseGuard, - DirtyCloseGuardDialog, -} from '@/components/shared/use-dirty-close-guard' -import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut' -import { - entityDialogContentClasses, - entityDialogFooterClasses, - entityDialogHeaderClasses, -} from '@/components/shared/entity-dialog-layout' -import { updateSprintGoalAction, updateSprintDatesAction, completeSprintAction, setAllSprintTasksDoneAction } from '@/actions/sprints' +import { updateSprintGoalAction, updateSprintDatesAction, completeSprintAction } from '@/actions/sprints' import type { SprintStory } from './sprint-backlog' -import { debugProps } from '@/lib/debug' -import { SprintSwitcher } from '@/components/shared/sprint-switcher' -import type { SprintSwitcherItem } from '@/lib/sprint-switcher-data' interface Sprint { id: string - code: string sprint_goal: string status: string start_date: Date | null @@ -51,16 +29,11 @@ interface SprintHeaderProps { sprint: Sprint isDemo: boolean sprintStories: SprintStory[] - switcherSprints: SprintSwitcherItem[] - switcherActiveSprint: SprintSwitcherItem | null - switcherBuildingSprintIds: string[] } -interface ActionResult { - success?: boolean - error?: string - code?: number - fieldErrors?: Record<string, string[]> +function SaveGoalButton() { + const { pending } = useFormStatus() + return <Button type="submit" size="sm" disabled={pending}>{pending ? 'Opslaan…' : 'Opslaan'}</Button> } function toDateInputValue(d: Date | null) { @@ -68,47 +41,39 @@ function toDateInputValue(d: Date | null) { return d.toISOString().slice(0, 10) } -export function SprintHeader({ productId, productName, sprint, isDemo, sprintStories, switcherSprints, switcherActiveSprint, switcherBuildingSprintIds }: SprintHeaderProps) { +export function SprintHeader({ productId: _productId, productName, sprint, isDemo, sprintStories }: SprintHeaderProps) { const [editingGoal, setEditingGoal] = useState(false) const [editingDates, setEditingDates] = useState(false) const [completeOpen, setCompleteOpen] = useState(false) const [decisions, setDecisions] = useState<Record<string, 'DONE' | 'OPEN'>>({}) const [isCompleting, startCompleting] = useTransition() - const [showAllDoneConfirm, setShowAllDoneConfirm] = useState(false) - const [isSettingAllDone, startSettingAllDone] = useTransition() - const [datesDirty, setDatesDirty] = useState(false) - const datesFormRef = useRef<HTMLFormElement>(null) - const [, goalFormAction, goalPending] = useActionState<ActionResult | undefined, FormData>( - async (_prev, fd) => { - const result = await updateSprintGoalAction(_prev, fd) as ActionResult + const [, goalFormAction] = useActionState( + async (_prev: unknown, fd: FormData) => { + const result = await updateSprintGoalAction(_prev, fd) if (result?.success) { setEditingGoal(false); toast.success('Sprint goal opgeslagen') } - else if (result?.error) toast.error(result.error) + else if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt') return result }, - undefined, + undefined ) - const [datesState, datesFormAction, datesPending] = useActionState<ActionResult | undefined, FormData>( - async (_prev, fd) => { - const result = await updateSprintDatesAction(_prev, fd) as ActionResult - if (result?.success) { setEditingDates(false); setDatesDirty(false); toast.success('Sprint datums opgeslagen') } - else if (result?.code !== 422 && result?.error) toast.error(result.error) + const [datesState, datesFormAction] = useActionState( + async (_prev: unknown, fd: FormData) => { + const result = await updateSprintDatesAction(_prev, fd) + if (result?.success) { setEditingDates(false); toast.success('Sprint datums opgeslagen') } + else if (result?.error) toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt') return result }, - undefined, + undefined ) - const datesFieldError = (field: string) => datesState?.fieldErrors?.[field]?.[0] - - const datesCloseGuard = useDirtyCloseGuard(datesDirty, () => setEditingDates(false)) - const datesKeyDown = useDialogSubmitShortcut(() => datesFormRef.current?.requestSubmit()) - function setDecision(storyId: string, value: 'DONE' | 'OPEN') { setDecisions(prev => ({ ...prev, [storyId]: value })) } function handleComplete() { + // Default: stories without explicit decision → OPEN const finalDecisions: Record<string, 'DONE' | 'OPEN'> = {} sprintStories.forEach(s => { finalDecisions[s.id] = decisions[s.id] ?? 'OPEN' @@ -121,30 +86,14 @@ export function SprintHeader({ productId, productName, sprint, isDemo, sprintSto }) } - function handleAllDone() { - startSettingAllDone(async () => { - const result = await setAllSprintTasksDoneAction(sprint.id) - if (!result.ok) { - toast.error(result.error ?? 'Alles op done mislukt') - } else { - const allDone: Record<string, 'DONE' | 'OPEN'> = {} - sprintStories.forEach(s => { allDone[s.id] = 'DONE' }) - setDecisions(allDone) - } - setShowAllDoneConfirm(false) - }) - } - return ( - <div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0" {...debugProps('sprint-header', 'SprintHeader', 'components/sprint/sprint-header.tsx')}> - <div className="flex items-center gap-4"> + <div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0"> + <div className="flex items-center justify-between gap-4"> <div className="min-w-0 flex-1"> <div className="flex items-center gap-2"> <span className="text-xs text-muted-foreground">{productName}</span> <span className="text-muted-foreground">›</span> <span className="text-xs font-medium text-primary">Sprint actief</span> - <span className="text-muted-foreground">·</span> - <span className="text-xs font-mono text-muted-foreground">{sprint.code}</span> </div> {editingGoal ? ( @@ -152,14 +101,12 @@ export function SprintHeader({ productId, productName, sprint, isDemo, sprintSto <input type="hidden" name="id" value={sprint.id} /> <Textarea name="sprint_goal" defaultValue={sprint.sprint_goal} rows={2} className="text-sm flex-1" autoFocus /> <div className="flex flex-col gap-1"> - <Button type="submit" size="sm" disabled={goalPending}> - {goalPending ? 'Opslaan…' : 'Opslaan'} - </Button> + <SaveGoalButton /> <Button type="button" size="sm" variant="ghost" aria-label="Annuleer bewerken" onClick={() => setEditingGoal(false)}>×</Button> </div> </form> ) : ( - <button onClick={() => !isDemo && setEditingGoal(true)} className="text-left mt-0.5 group" data-debug-id="sprint-header__title"> + <button onClick={() => !isDemo && setEditingGoal(true)} className="text-left mt-0.5 group"> <p className="text-sm font-medium text-foreground group-hover:text-primary transition-colors"> {sprint.sprint_goal} </p> @@ -167,18 +114,9 @@ export function SprintHeader({ productId, productName, sprint, isDemo, sprintSto )} </div> - <div className="shrink-0"> - <SprintSwitcher - productId={productId} - sprints={switcherSprints} - activeSprint={switcherActiveSprint} - buildingSprintIds={switcherBuildingSprintIds} - /> - </div> - - <div className="flex items-center justify-end gap-2 flex-1 shrink-0" data-debug-id="sprint-header__actions"> + <div className="flex items-center gap-2 shrink-0"> <DemoTooltip show={isDemo}> - <Button size="sm" variant="ghost" disabled={isDemo} className="text-muted-foreground" data-debug-id="sprint-header__dates" onClick={() => !isDemo && setEditingDates(true)}> + <Button size="sm" variant="ghost" disabled={isDemo} className="text-muted-foreground" onClick={() => !isDemo && setEditingDates(true)}> {sprint.start_date && sprint.end_date ? `${toDateInputValue(sprint.start_date)} → ${toDateInputValue(sprint.end_date)}` : 'Datums instellen'} @@ -193,78 +131,51 @@ export function SprintHeader({ productId, productName, sprint, isDemo, sprintSto </div> {/* Dates edit dialog */} - <Dialog open={editingDates} onOpenChange={(o) => { if (!o) datesCloseGuard.attemptClose(); else setEditingDates(o) }}> - <DialogContent - showCloseButton={false} - onKeyDown={datesKeyDown} - className={entityDialogContentClasses} - > - <div className={entityDialogHeaderClasses}> - <DialogTitle className="text-xl font-semibold">Sprint datums instellen</DialogTitle> - </div> - <form - ref={datesFormRef} - id="sprint-dates-form" - action={datesFormAction} - onChange={() => setDatesDirty(true)} - className="flex-1 overflow-y-auto px-6 py-6 space-y-6" - > + <Dialog open={editingDates} onOpenChange={setEditingDates}> + <DialogContent className="sm:max-w-sm"> + <DialogHeader> + <DialogTitle>Sprint datums instellen</DialogTitle> + </DialogHeader> + <form action={datesFormAction} className="space-y-4 p-1"> <input type="hidden" name="id" value={sprint.id} /> <div className="grid grid-cols-2 gap-3"> <div className="space-y-1.5"> <label className="text-sm font-medium text-foreground">Startdatum</label> <input type="date" name="start_date" defaultValue={toDateInputValue(sprint.start_date)} className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" /> - {datesFieldError('start_date') && ( - <p className="text-xs text-error">{datesFieldError('start_date')}</p> + {typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).start_date && ( + <p className="text-xs text-error">{(datesState.error as Record<string, string[]>).start_date[0]}</p> )} </div> <div className="space-y-1.5"> <label className="text-sm font-medium text-foreground">Einddatum</label> <input type="date" name="end_date" defaultValue={toDateInputValue(sprint.end_date)} className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" /> - {datesFieldError('end_date') && ( - <p className="text-xs text-error">{datesFieldError('end_date')}</p> + {typeof datesState?.error === 'object' && (datesState.error as Record<string, string[]>).end_date && ( + <p className="text-xs text-error">{(datesState.error as Record<string, string[]>).end_date[0]}</p> )} </div> </div> - </form> - <div className={entityDialogFooterClasses}> + {typeof datesState?.error === 'string' && ( + <p className="text-xs text-error">{datesState.error}</p> + )} <div className="flex gap-2 justify-end"> - <Button type="button" variant="ghost" onClick={datesCloseGuard.attemptClose} disabled={datesPending}> - Annuleren - </Button> - <Button type="submit" form="sprint-dates-form" disabled={datesPending}> - {datesPending ? '…' : 'Opslaan'} - </Button> + <Button type="button" variant="ghost" onClick={() => setEditingDates(false)}>Annuleren</Button> + <Button type="submit">Opslaan</Button> </div> - </div> + </form> </DialogContent> </Dialog> - <DirtyCloseGuardDialog guard={datesCloseGuard} /> - {/* Complete sprint dialog */} <Dialog open={completeOpen} onOpenChange={setCompleteOpen}> - <DialogContent showCloseButton={false} className={entityDialogContentClasses}> - <div className={entityDialogHeaderClasses}> - <DialogTitle className="text-xl font-semibold">Sprint afronden</DialogTitle> - </div> - <div className="flex-1 overflow-y-auto px-6 py-6 space-y-6"> + <DialogContent className="sm:max-w-2xl"> + <DialogHeader> + <DialogTitle>Sprint afronden</DialogTitle> + </DialogHeader> + <div className="space-y-4 p-1"> <p className="text-sm text-muted-foreground"> Geef per story aan wat er mee moet gebeuren: </p> - <div className="flex justify-end"> - <Button - type="button" - size="sm" - variant="outline" - className="border-status-done/40 text-status-done hover:bg-status-done/10" - disabled={isSettingAllDone || isCompleting} - onClick={() => setShowAllDoneConfirm(true)} - > - {isSettingAllDone ? 'Bezig…' : 'Alles op done'} - </Button> - </div> - <div className="space-y-2"> + <div className="space-y-2 max-h-64 overflow-y-auto"> {sprintStories.map(story => ( <div key={story.id} className="flex items-center justify-between gap-3 p-2 bg-surface-container-low rounded-lg"> {story.code && <span className="font-mono text-[11px] text-muted-foreground shrink-0">{story.code}</span>} @@ -286,41 +197,15 @@ export function SprintHeader({ productId, productName, sprint, isDemo, sprintSto </div> ))} </div> - </div> - <div className={entityDialogFooterClasses}> <div className="flex gap-2 justify-end"> - <Button variant="ghost" onClick={() => setCompleteOpen(false)} disabled={isCompleting}> - Annuleren + <Button variant="ghost" onClick={() => setCompleteOpen(false)}>Annuleren</Button> + <Button disabled={isCompleting} onClick={handleComplete}> + {isCompleting ? 'Bezig…' : 'Sprint afronden'} </Button> - <DemoTooltip show={isDemo}> - <Button disabled={isCompleting || isDemo} onClick={handleComplete}> - {isCompleting ? 'Bezig…' : 'Sprint afronden'} - </Button> - </DemoTooltip> </div> </div> </DialogContent> </Dialog> - - <AlertDialog open={showAllDoneConfirm} onOpenChange={setShowAllDoneConfirm}> - <AlertDialogContent> - <AlertDialogHeader> - <AlertDialogTitle>Alles op done zetten?</AlertDialogTitle> - <AlertDialogDescription> - Alle taken én stories in de sprint — inclusief taken met status - REVIEW — worden op DONE gezet. De per-story toggles hieronder - worden daarna bijgewerkt. Je kunt daarna nog per story aanpassen - vóór je de sprint afrondt. - </AlertDialogDescription> - </AlertDialogHeader> - <AlertDialogFooter> - <AlertDialogCancel disabled={isSettingAllDone}>Annuleren</AlertDialogCancel> - <AlertDialogAction onClick={handleAllDone} disabled={isSettingAllDone}> - {isSettingAllDone ? 'Bezig…' : 'Alles op done'} - </AlertDialogAction> - </AlertDialogFooter> - </AlertDialogContent> - </AlertDialog> </div> ) } diff --git a/components/sprint/sprint-hydration-wrapper.tsx b/components/sprint/sprint-hydration-wrapper.tsx deleted file mode 100644 index 47bebb0..0000000 --- a/components/sprint/sprint-hydration-wrapper.tsx +++ /dev/null @@ -1,84 +0,0 @@ -'use client' - -// PBI-74 / Story 9: Sprint workspace hydration wrapper. -// -// Server-component (sprint page) fetcht initial sprint snapshot; deze wrapper -// hydreert useSprintWorkspaceStore op client-mount, mount de SSE-hook en de -// resync-laag. - -import { useEffect, useRef } from 'react' -import { useSprintRealtime } from '@/lib/realtime/use-sprint-realtime' -import { useSprintWorkspaceResync } from '@/lib/realtime/use-sprint-workspace-resync' -import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' -import type { - SprintWorkspaceSnapshot, - SprintWorkspaceSprint, - SprintWorkspaceStory, - SprintWorkspaceTask, -} from '@/stores/sprint-workspace/types' - -export interface SprintHydrationData { - sprint: SprintWorkspaceSprint - stories: SprintWorkspaceStory[] - tasksByStory: Record<string, SprintWorkspaceTask[]> -} - -interface SprintHydrationWrapperProps { - initialData: SprintHydrationData - productId: string - productName?: string - children: React.ReactNode -} - -function fingerprint(data: SprintHydrationData): string { - const sprintPart = `${data.sprint.id}:${data.sprint.status}` - const storyPart = data.stories - .map((s) => `${s.id}:${s.status}:${s.sprint_id ?? 'null'}:${s.sort_order}`) - .join(',') - const taskPart = Object.entries(data.tasksByStory) - .flatMap(([, list]) => list.map((t) => `${t.id}:${t.status}:${t.sort_order}`)) - .join(',') - return `${sprintPart}|${storyPart}|${taskPart}` -} - -function toWorkspaceSnapshot( - data: SprintHydrationData, - productId: string, - productName: string | undefined, -): SprintWorkspaceSnapshot { - return { - product: { id: productId, name: productName ?? '' }, - sprint: data.sprint, - stories: data.stories, - tasksByStory: data.tasksByStory, - } -} - -export function SprintHydrationWrapper({ - initialData, - productId, - productName, - children, -}: SprintHydrationWrapperProps) { - const lastFingerprint = useRef<string>('') - - useEffect(() => { - const fp = fingerprint(initialData) - if (fp !== lastFingerprint.current) { - lastFingerprint.current = fp - useSprintWorkspaceStore - .getState() - .hydrateSnapshot(toWorkspaceSnapshot(initialData, productId, productName)) - // T-880 schaduw-fase: zet activeSprintId zodat selectors meteen werken - useSprintWorkspaceStore.setState((s) => { - s.context.activeSprintId = initialData.sprint.id - s.context.activeProduct = { id: productId, name: productName ?? '' } - }) - } - }, [initialData, productId, productName]) - - useSprintRealtime(productId) - useSprintWorkspaceResync() - - return <>{children}</> -} diff --git a/components/sprint/sprint-run-controls.tsx b/components/sprint/sprint-run-controls.tsx deleted file mode 100644 index 780abd5..0000000 --- a/components/sprint/sprint-run-controls.tsx +++ /dev/null @@ -1,253 +0,0 @@ -'use client' - -import { useState, useTransition } from 'react' -import { toast } from 'sonner' -import { - startSprintRunAction, - resumeSprintAction, - resumePausedSprintRunAction, - cancelSprintRunAction, - type PreFlightBlocker, -} from '@/actions/sprint-runs' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog' -import { type PauseContext, pauseReasonLabel } from '@/lib/pause-context' - -type SprintStatusValue = 'OPEN' | 'CLOSED' | 'ARCHIVED' | 'FAILED' -type SprintRunStatusValue = - | 'QUEUED' - | 'RUNNING' - | 'PAUSED' - | 'DONE' - | 'FAILED' - | 'CANCELLED' - | null - -interface Props { - sprintId: string - productId: string - sprintStatus: SprintStatusValue - activeSprintRunId: string | null - activeSprintRunStatus: SprintRunStatusValue - pauseContext: PauseContext | null - isDemo: boolean -} - -const BLOCKER_LABELS: Record<PreFlightBlocker['type'], string> = { - task_no_plan: 'Task zonder implementation plan', - open_question: 'Openstaande vraag aan jou', - pbi_blocked: 'PBI is geblokkeerd of gefaald', - task_cross_repo: 'Task met afwijkende repo (niet toegestaan in SPRINT_BATCH)', -} - -function blockerHref(productId: string, blocker: PreFlightBlocker): string { - switch (blocker.type) { - case 'task_no_plan': - return `/products/${productId}/sprint?editTask=${blocker.id}` - case 'open_question': - return `/products/${productId}/sprint` - case 'pbi_blocked': - return `/products/${productId}` - case 'task_cross_repo': - return `/products/${productId}/sprint?editTask=${blocker.id}` - } -} - -export function SprintRunControls({ - sprintId, - productId, - sprintStatus, - activeSprintRunId, - activeSprintRunStatus, - pauseContext, - isDemo, -}: Props) { - const [pending, startTransition] = useTransition() - const [blockers, setBlockers] = useState<PreFlightBlocker[] | null>(null) - - const hasActiveRun = - activeSprintRunId !== null && - (activeSprintRunStatus === 'QUEUED' || - activeSprintRunStatus === 'RUNNING' || - activeSprintRunStatus === 'PAUSED') - - const canStart = sprintStatus === 'OPEN' && !hasActiveRun - const canResume = sprintStatus === 'FAILED' - const canResumePaused = - activeSprintRunStatus === 'PAUSED' && pauseContext !== null - const canCancel = hasActiveRun - - function handleStart() { - startTransition(async () => { - const result = await startSprintRunAction({ sprint_id: sprintId }) - if (result.ok) { - toast.success(`Sprint gestart (${result.jobs_count} taak(s) klaar)`) - } else if (result.error === 'PRE_FLIGHT_BLOCKED' && 'blockers' in result) { - setBlockers(result.blockers) - } else { - toast.error(result.error) - } - }) - } - - function handleResume() { - startTransition(async () => { - const result = await resumeSprintAction({ sprint_id: sprintId }) - if (result.ok) { - toast.success(`Sprint hervat (${result.jobs_count} taak(s) klaar)`) - } else if (result.error === 'PRE_FLIGHT_BLOCKED' && 'blockers' in result) { - setBlockers(result.blockers) - } else { - toast.error(result.error) - } - }) - } - - function handleResumePaused() { - if (!activeSprintRunId || !pauseContext) return - if ( - !confirm( - `Sprint hervatten? Bevestig dat het ${pauseReasonLabel( - pauseContext.pause_reason, - ).toLowerCase()} is opgelost.`, - ) - ) - return - startTransition(async () => { - const result = await resumePausedSprintRunAction({ - sprint_run_id: activeSprintRunId, - }) - if (result.ok) toast.success('Sprint hervat') - else toast.error(result.error) - }) - } - - function handleCancel() { - if (!activeSprintRunId) return - if (!confirm('Sprint annuleren? Openstaande taken blijven TO_DO.')) return - startTransition(async () => { - const result = await cancelSprintRunAction({ sprint_run_id: activeSprintRunId }) - if (result.ok) toast.success('Sprint geannuleerd') - else toast.error(result.error) - }) - } - - return ( - <> - {canResumePaused && pauseContext && ( - <div className="rounded-md border border-warning/40 bg-warning-container/20 p-3 mb-2"> - <div className="text-xs uppercase tracking-wide text-on-warning-container"> - Gepauzeerd: {pauseReasonLabel(pauseContext.pause_reason)} - </div> - <a - href={pauseContext.pr_url} - target="_blank" - rel="noreferrer" - className="text-sm text-primary hover:underline break-all" - > - {pauseContext.pr_url} - </a> - {pauseContext.conflict_files.length > 0 && ( - <ul className="mt-1 text-xs text-muted-foreground"> - {pauseContext.conflict_files.slice(0, 5).map((f) => ( - <li key={f}>· {f}</li> - ))} - {pauseContext.conflict_files.length > 5 && ( - <li>· + {pauseContext.conflict_files.length - 5} meer</li> - )} - </ul> - )} - <Button - size="sm" - onClick={handleResumePaused} - disabled={pending || isDemo} - className="text-xs mt-2" - > - Hervat gepauzeerde sprint - </Button> - </div> - )} - <div className="flex items-center gap-2" data-debug-id="sprint-run-controls"> - {canStart && ( - <Button - size="sm" - onClick={handleStart} - disabled={pending || isDemo} - className="text-xs" - data-debug-id="sprint-run-controls__start" - > - Start Sprint - </Button> - )} - {canResume && ( - <Button - size="sm" - onClick={handleResume} - disabled={pending || isDemo} - variant="default" - className="text-xs" - data-debug-id="sprint-run-controls__start" - > - Hervat sprint - </Button> - )} - {canCancel && ( - <Button - size="sm" - onClick={handleCancel} - disabled={pending || isDemo} - variant="outline" - className="text-xs" - data-debug-id="sprint-run-controls__cancel" - > - Annuleer sprint-run - </Button> - )} - </div> - - <Dialog open={blockers !== null} onOpenChange={(open) => { if (!open) setBlockers(null) }}> - <DialogContent className="max-w-lg" data-debug-id="sprint-run-controls__blockers-dialog"> - <DialogHeader> - <DialogTitle>Sprint kan nog niet starten</DialogTitle> - <DialogDescription> - Los eerst onderstaande punten op. Klik op een item om er direct naar - te navigeren. - </DialogDescription> - </DialogHeader> - - <ul className="flex flex-col gap-2 max-h-80 overflow-y-auto"> - {blockers?.map((b, i) => ( - <li - key={`${b.type}-${b.id}-${i}`} - className="rounded-md border border-border bg-surface-container-low px-3 py-2" - > - <div className="text-xs uppercase tracking-wide text-muted-foreground"> - {BLOCKER_LABELS[b.type]} - </div> - <a - href={blockerHref(productId, b)} - className="text-sm text-primary hover:underline break-words" - > - {b.label} - </a> - </li> - ))} - </ul> - - <DialogFooter> - <Button variant="outline" onClick={() => setBlockers(null)}> - Sluit - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - </> - ) -} diff --git a/components/sprint/sprint-task-dialog-mount.tsx b/components/sprint/sprint-task-dialog-mount.tsx deleted file mode 100644 index 1ca0f45..0000000 --- a/components/sprint/sprint-task-dialog-mount.tsx +++ /dev/null @@ -1,41 +0,0 @@ -'use client' - -import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' -import { selectActiveTask } from '@/stores/sprint-workspace/selectors' -import { isDetail } from '@/stores/sprint-workspace/types' -import { TaskDialog } from '@/app/_components/tasks/task-dialog' -import { taskStatusFromApi } from '@/lib/task-status' -import type { TaskStatus } from '@prisma/client' - -interface Props { - productId: string - isDemo: boolean -} - -export function SprintTaskDialogMount({ productId, isDemo }: Props) { - const task = useSprintWorkspaceStore(selectActiveTask) - const setActiveTask = useSprintWorkspaceStore((s) => s.setActiveTask) - - if (!task || !isDetail(task)) return null - - const status = (taskStatusFromApi(String(task.status)) ?? 'TO_DO') as TaskStatus - const createdAt = task.created_at instanceof Date ? task.created_at : new Date(task.created_at) - - return ( - <TaskDialog - task={{ - id: task.id, - code: task.code, - title: task.title, - description: task.description, - implementation_plan: task.implementation_plan ?? null, - priority: task.priority, - status, - created_at: createdAt, - }} - productId={productId} - onClose={() => setActiveTask(null)} - isDemo={isDemo} - /> - ) -} diff --git a/components/sprint/sprint-url-task-sync.tsx b/components/sprint/sprint-url-task-sync.tsx deleted file mode 100644 index 893b0af..0000000 --- a/components/sprint/sprint-url-task-sync.tsx +++ /dev/null @@ -1,29 +0,0 @@ -'use client' - -// PBI-75: URL-deeplink → store sync voor sprint task-edit. -// -// Patroon spiegelt components/backlog/url-task-sync.tsx: zodra de route -// `?editTask=<id>` draagt, schrijven we de taak-hint en roepen we -// setActiveTask aan op de sprint-workspace-store. De dialog wordt -// vervolgens client-side gemount door SprintTaskDialogMount. - -import { useEffect } from 'react' -import { useSearchParams } from 'next/navigation' -import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' -import { writeTaskHint } from '@/stores/sprint-workspace/restore' - -export function SprintUrlTaskSync() { - const searchParams = useSearchParams() - const editTask = searchParams.get('editTask') - - useEffect(() => { - if (!editTask) return - const sprintId = useSprintWorkspaceStore.getState().context.activeSprintId - if (sprintId) { - writeTaskHint(sprintId, editTask) - } - useSprintWorkspaceStore.getState().setActiveTask(editTask) - }, [editTask]) - - return null -} diff --git a/components/sprint/start-sprint-button.tsx b/components/sprint/start-sprint-button.tsx index bc79fee..f9c18d6 100644 --- a/components/sprint/start-sprint-button.tsx +++ b/components/sprint/start-sprint-button.tsx @@ -1,130 +1,63 @@ 'use client' -import { useState, useActionState, useRef } from 'react' +import { useState, useActionState } from 'react' +import { useFormStatus } from 'react-dom' import { useRouter } from 'next/navigation' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' import { Dialog, DialogContent, + DialogHeader, DialogTitle, } from '@/components/ui/dialog' -import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { - useDirtyCloseGuard, - DirtyCloseGuardDialog, -} from '@/components/shared/use-dirty-close-guard' -import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut' -import { - entityDialogContentClasses, - entityDialogFooterClasses, - entityDialogHeaderClasses, -} from '@/components/shared/entity-dialog-layout' import { createSprintAction } from '@/actions/sprints' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import { - selectActivePbi, - selectStoriesForActivePbi, -} from '@/stores/product-workspace/selectors' -import { useShallow } from 'zustand/react/shallow' interface StartSprintButtonProps { productId: string - isDemo?: boolean } -interface ActionResult { - success?: boolean - error?: string - code?: number - fieldErrors?: Record<string, string[]> - sprintId?: string +function SubmitButton() { + const { pending } = useFormStatus() + return ( + <Button type="submit" disabled={pending}> + {pending ? 'Aanmaken…' : 'Sprint starten'} + </Button> + ) } -function todayLocalDate() { - return new Date().toLocaleDateString('en-CA') -} - -export function StartSprintButton({ productId, isDemo = false }: StartSprintButtonProps) { +export function StartSprintButton({ productId }: StartSprintButtonProps) { const [open, setOpen] = useState(false) - const [dirty, setDirty] = useState(false) - const formRef = useRef<HTMLFormElement>(null) const router = useRouter() - // PBI-74 / T-852: actief PBI + free-story count via workspace-store selectors. - const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId) - const selectedPbi = useProductWorkspaceStore(selectActivePbi) - const stories = useProductWorkspaceStore(useShallow(selectStoriesForActivePbi)) - const freeStoryCount = stories.filter((story) => story.sprint_id === null).length - const [state, formAction, pending] = useActionState<ActionResult | undefined, FormData>( - async (_prev, fd) => { - const result = await createSprintAction(_prev, fd) as ActionResult - if (result?.success) { + const [state, formAction] = useActionState( + async (_prev: unknown, fd: FormData) => { + const result = await createSprintAction(_prev, fd) + if (result.success) { setOpen(false) - setDirty(false) - router.refresh() - } else if (result?.code !== 422 && result?.error) { - // Toast handled by caller; here we just keep the form open + router.push(`/products/${productId}/sprint`) } return result }, - undefined, + undefined ) - const fieldError = (field: string) => state?.fieldErrors?.[field]?.[0] - const globalError = state?.code !== 422 ? state?.error : undefined - - const closeGuard = useDirtyCloseGuard(dirty, () => setOpen(false)) - const handleKeyDown = useDialogSubmitShortcut(() => formRef.current?.requestSubmit()) + const globalError = typeof state?.error === 'string' ? state.error : undefined return ( <> - <DemoTooltip show={isDemo}> - <Button size="sm" onClick={() => setOpen(true)} disabled={isDemo} data-debug-id="start-sprint-button"> - Sprint starten - </Button> - </DemoTooltip> + <Button size="sm" onClick={() => setOpen(true)}> + Sprint starten + </Button> - <Dialog open={open} onOpenChange={(o) => { if (!o) closeGuard.attemptClose(); else setOpen(o) }}> - <DialogContent - showCloseButton={false} - onKeyDown={handleKeyDown} - className={entityDialogContentClasses} - data-debug-id="start-sprint-button__dialog" - > - <div className={entityDialogHeaderClasses}> - <DialogTitle className="text-xl font-semibold">Nieuwe Sprint starten</DialogTitle> - </div> + <Dialog open={open} onOpenChange={setOpen}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>Nieuwe Sprint starten</DialogTitle> + </DialogHeader> - <form - ref={formRef} - id="start-sprint-form" - action={formAction} - onChange={() => setDirty(true)} - className="flex-1 overflow-y-auto px-6 py-6 space-y-6" - > + <form action={formAction} className="space-y-4 p-1"> <input type="hidden" name="productId" value={productId} /> - {selectedPbiId && <input type="hidden" name="pbi_id" value={selectedPbiId} />} - - {!selectedPbi ? ( - <div className="bg-warning-container text-warning-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-warning"> - Geen PBI geselecteerd — de sprint wordt leeg aangemaakt. Je kunt later stories - toevoegen via slepen. - </div> - ) : freeStoryCount === 0 ? ( - <div className="bg-warning-container text-warning-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-warning"> - PBI <strong>{selectedPbi.code ?? selectedPbi.id.slice(0, 8)}</strong> heeft geen - vrije stories (alle stories zitten al in een andere sprint of zijn afgerond) — de - sprint wordt leeg aangemaakt. - </div> - ) : ( - <div className="bg-primary-container text-primary-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-primary"> - <strong>{freeStoryCount}</strong> {freeStoryCount === 1 ? 'story' : 'stories'} van - PBI <strong>{selectedPbi.code ?? selectedPbi.id.slice(0, 8)}</strong> - {selectedPbi.title ? ` (${selectedPbi.title})` : ''} worden toegevoegd aan deze - sprint. - </div> - )} <div className="space-y-1.5"> <label className="text-sm font-medium text-foreground"> @@ -136,27 +69,25 @@ export function StartSprintButton({ productId, isDemo = false }: StartSprintButt rows={3} placeholder="Wat wil je aan het einde van deze Sprint bereikt hebben?" autoFocus - aria-invalid={!!fieldError('sprint_goal')} - className={fieldError('sprint_goal') ? 'border-error' : ''} /> - {fieldError('sprint_goal') && ( - <p className="text-xs text-error">{fieldError('sprint_goal')}</p> + {typeof state?.error === 'object' && (state.error as Record<string, string[]>).sprint_goal && ( + <p className="text-xs text-error">{(state.error as Record<string, string[]>).sprint_goal[0]}</p> )} </div> <div className="grid grid-cols-2 gap-3"> <div className="space-y-1.5"> <label className="text-sm font-medium text-foreground">Startdatum</label> - <input type="date" name="start_date" defaultValue={todayLocalDate()} className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" /> - {fieldError('start_date') && ( - <p className="text-xs text-error">{fieldError('start_date')}</p> + <input type="date" name="start_date" className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" /> + {typeof state?.error === 'object' && (state.error as Record<string, string[]>).start_date && ( + <p className="text-xs text-error">{(state.error as Record<string, string[]>).start_date[0]}</p> )} </div> <div className="space-y-1.5"> <label className="text-sm font-medium text-foreground">Einddatum</label> - <input type="date" name="end_date" defaultValue={todayLocalDate()} className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" /> - {fieldError('end_date') && ( - <p className="text-xs text-error">{fieldError('end_date')}</p> + <input type="date" name="end_date" className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary" /> + {typeof state?.error === 'object' && (state.error as Record<string, string[]>).end_date && ( + <p className="text-xs text-error">{(state.error as Record<string, string[]>).end_date[0]}</p> )} </div> </div> @@ -166,22 +97,16 @@ export function StartSprintButton({ productId, isDemo = false }: StartSprintButt {globalError} </div> )} - </form> - <div className={entityDialogFooterClasses}> - <div className="flex justify-end gap-2"> - <Button type="button" variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}> + <div className="flex gap-2 justify-end"> + <Button type="button" variant="ghost" onClick={() => setOpen(false)}> Annuleren </Button> - <Button type="submit" form="start-sprint-form" disabled={pending} data-debug-id="start-sprint-button__submit"> - {pending ? 'Aanmaken…' : 'Sprint starten'} - </Button> + <SubmitButton /> </div> - </div> + </form> </DialogContent> </Dialog> - - <DirtyCloseGuardDialog guard={closeGuard} /> </> ) } diff --git a/components/sprint/sync-active-sprint-cookie.tsx b/components/sprint/sync-active-sprint-cookie.tsx deleted file mode 100644 index 23d1cb8..0000000 --- a/components/sprint/sync-active-sprint-cookie.tsx +++ /dev/null @@ -1,17 +0,0 @@ -'use client' - -import { useEffect } from 'react' -import { syncActiveSprintCookieAction } from '@/actions/active-sprint' - -interface Props { - productId: string - sprintId: string -} - -export function SyncActiveSprintCookie({ productId, sprintId }: Props) { - useEffect(() => { - syncActiveSprintCookieAction(productId, sprintId) - }, [productId, sprintId]) - // No data-debug-id: this component renders null (side-effect only). - return null -} diff --git a/components/sprint/task-list.tsx b/components/sprint/task-list.tsx index 5e88d19..028e449 100644 --- a/components/sprint/task-list.tsx +++ b/components/sprint/task-list.tsx @@ -1,53 +1,43 @@ 'use client' -import { useTransition } from 'react' +import { useState, useTransition, useEffect } from 'react' import { useRouter, usePathname } from 'next/navigation' -import { Pencil } from 'lucide-react' -import { useShallow } from 'zustand/react/shallow' +import { + DndContext, DragEndEvent, DragOverlay, + KeyboardSensor, PointerSensor, useSensor, useSensors, closestCenter, +} from '@dnd-kit/core' +import { + SortableContext, useSortable, verticalListSortingStrategy, arrayMove, + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { CodeBadge } from '@/components/shared/code-badge' import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { PRIORITY_BORDER } from '@/components/backlog/backlog-card' -import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' -import { selectTasksForActiveStory } from '@/stores/sprint-workspace/selectors' -import type { - SprintWorkspaceTask, - SprintWorkspaceTaskDetail, -} from '@/stores/sprint-workspace/types' -import { updateTaskStatusAction } from '@/actions/tasks' +import { deriveTaskCode } from '@/lib/code' +import { useSprintStore } from '@/stores/sprint-store' +import { updateTaskStatusAction, reorderTasksAction } from '@/actions/tasks' import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { debugProps } from '@/lib/debug' import { cn } from '@/lib/utils' const STATUS_CYCLE: Record<string, 'TO_DO' | 'IN_PROGRESS' | 'DONE'> = { TO_DO: 'IN_PROGRESS', IN_PROGRESS: 'DONE', DONE: 'TO_DO', - EXCLUDED: 'TO_DO', } const STATUS_COLORS: Record<string, string> = { TO_DO: 'bg-status-todo/15 text-status-todo border-status-todo/30', IN_PROGRESS: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30', DONE: 'bg-status-done/15 text-status-done border-status-done/30', - EXCLUDED: 'bg-surface-container-low text-muted-foreground border-border', - FAILED: 'bg-status-failed/15 text-status-failed border-status-failed/30', - REVIEW: 'bg-status-review/15 text-status-review border-status-review/30', -} -const STATUS_LABELS: Record<string, string> = { - TO_DO: 'To Do', - IN_PROGRESS: 'Bezig', - REVIEW: 'Review', - DONE: 'Klaar', - FAILED: 'Mislukt', - EXCLUDED: 'Uitgesloten', } +const STATUS_LABELS: Record<string, string> = { TO_DO: 'To Do', IN_PROGRESS: 'Bezig', DONE: 'Klaar' } + -// Behouden voor type-compat met SprintBoardClient props (verdwijnt zodra -// SprintBoardClient ook geen tasks-prop meer doorgeeft — T-883). export interface Task { id: string - code: string | null title: string description: string | null priority: number @@ -56,25 +46,29 @@ export interface Task { sprint_id: string | null } -type WorkspaceTask = SprintWorkspaceTask | SprintWorkspaceTaskDetail - interface TaskListProps { + storyId: string + storyCode: string | null sprintId: string productId: string + tasks: Task[] isDemo: boolean } -function TaskRow({ +function SortableTaskRow({ task, code, isDemo, onStatusToggle, onEdit, }: { - task: WorkspaceTask + task: Task code: string | null isDemo: boolean onStatusToggle: () => void onEdit: () => void }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id }) + const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.4 : 1 } + return ( - <div className="group px-2 py-1"> + <div ref={setNodeRef} style={style} className="group px-2 py-1"> <div className={cn( 'flex items-start gap-2 rounded border border-border px-3 py-2 transition-colors bg-surface-container hover:bg-surface-container-high cursor-pointer', @@ -91,12 +85,22 @@ function TaskRow({ } }} > + {!isDemo && ( + <span + {...attributes} + {...listeners} + onClick={(e) => e.stopPropagation()} + className="text-muted-foreground cursor-grab active:cursor-grabbing shrink-0 text-sm select-none mt-0.5" + aria-hidden="true" + > + ⠿ + </span> + )} <div className="flex-1 min-w-0"> <div className="flex items-start justify-between gap-2"> <p className={cn( 'text-sm leading-snug line-clamp-2 flex-1', task.status === 'DONE' && 'line-through text-muted-foreground', - task.status === 'EXCLUDED' && 'text-muted-foreground/70 italic', )}> {task.title} </p> @@ -117,49 +121,64 @@ function TaskRow({ </button> </div> </div> - <DemoTooltip show={isDemo}> - <button - onClick={(e) => { e.stopPropagation(); if (!isDemo) onEdit() }} - className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground p-0.5 rounded shrink-0 mt-0.5 disabled:opacity-40 disabled:cursor-not-allowed" - aria-label="Bewerk taak" - disabled={isDemo} - > - <Pencil size={14} /> - </button> - </DemoTooltip> </div> </div> ) } -export function TaskList({ sprintId: _sprintId, productId: _productId, isDemo }: TaskListProps) { - const storyId = useSprintWorkspaceStore((s) => s.context.activeStoryId) - const orderedTasks = useSprintWorkspaceStore( - useShallow(selectTasksForActiveStory), - ) +export function TaskList({ storyId, storyCode, sprintId: _sprintId, productId: _productId, tasks, isDemo }: TaskListProps) { + const { taskOrder, initTasks, reorderTasks, rollbackTasks } = useSprintStore() + const [activeDragId, setActiveDragId] = useState<string | null>(null) const [, startTransition] = useTransition() const router = useRouter() const pathname = usePathname() + const idKey = tasks.map(t => t.id).join(',') + useEffect(() => { + initTasks(storyId, idKey ? idKey.split(',') : []) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [storyId, idKey]) + + const taskMap = Object.fromEntries(tasks.map(t => [t.id, t])) + const order = taskOrder[storyId] ?? tasks.map(t => t.id) + const orderedTasks = order.map(id => taskMap[id]).filter(Boolean) + const doneCount = orderedTasks.filter(t => t.status === 'DONE').length - function handleStatusToggle(task: WorkspaceTask) { + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ) + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event + if (!over || active.id === over.id) return + const prevOrder = [...order] + const newOrder = arrayMove([...order], order.indexOf(active.id as string), order.indexOf(over.id as string)) + reorderTasks(storyId, newOrder) + setActiveDragId(null) + startTransition(async () => { + const result = await reorderTasksAction(storyId, newOrder) + if (!result.success) { rollbackTasks(storyId, prevOrder); toast.error('Volgorde opslaan mislukt') } + }) + } + + function handleStatusToggle(task: Task) { startTransition(async () => { await updateTaskStatusAction(task.id, STATUS_CYCLE[task.status] ?? 'TO_DO') }) } function openCreateDialog() { - if (!storyId) return router.push(`${pathname}?newTask=1&storyId=${storyId}`) } function openEditDialog(taskId: string) { - useSprintWorkspaceStore.getState().setActiveTask(taskId) + router.push(`${pathname}?editTask=${taskId}`) } return ( - <div className="flex flex-col h-full" {...debugProps('task-list', 'TaskList', 'components/sprint/task-list.tsx')}> + <div className="flex flex-col h-full"> <PanelNavBar title="Taken" actions={ @@ -179,9 +198,9 @@ export function TaskList({ sprintId: _sprintId, productId: _productId, isDemo }: } /> - <div className="flex-1 overflow-y-auto" data-debug-id="task-list__items"> + <div className="flex-1 overflow-y-auto"> {orderedTasks.length === 0 ? ( - <div className="text-center mt-8 space-y-3" data-debug-id="task-list__empty"> + <div className="text-center mt-8 space-y-3"> <p className="text-sm text-muted-foreground">Geen taken voor deze story.</p> <DemoTooltip show={isDemo}> <Button @@ -195,18 +214,36 @@ export function TaskList({ sprintId: _sprintId, productId: _productId, isDemo }: </DemoTooltip> </div> ) : ( - <> - {orderedTasks.map((task) => ( - <TaskRow - key={task.id} - task={task} - code={task.code} - isDemo={isDemo} - onStatusToggle={() => handleStatusToggle(task)} - onEdit={() => openEditDialog(task.id)} - /> - ))} - </> + <DndContext + id="task-list" + sensors={sensors} + collisionDetection={closestCenter} + onDragStart={e => setActiveDragId(e.active.id as string)} + onDragEnd={handleDragEnd} + > + <SortableContext items={orderedTasks.map(t => t.id)} strategy={verticalListSortingStrategy}> + {orderedTasks.map((task, idx) => ( + <SortableTaskRow + key={task.id} + task={task} + code={deriveTaskCode(storyCode, idx + 1)} + isDemo={isDemo} + onStatusToggle={() => handleStatusToggle(task)} + onEdit={() => openEditDialog(task.id)} + /> + ))} + </SortableContext> + <DragOverlay> + {activeDragId && taskMap[activeDragId] && ( + <div className={cn( + 'rounded border border-primary px-3 py-2 bg-surface-container shadow-lg opacity-90 text-sm', + PRIORITY_BORDER[taskMap[activeDragId].priority], + )}> + {taskMap[activeDragId].title} + </div> + )} + </DragOverlay> + </DndContext> )} </div> </div> diff --git a/components/todos/todo-list.tsx b/components/todos/todo-list.tsx new file mode 100644 index 0000000..40f2af3 --- /dev/null +++ b/components/todos/todo-list.tsx @@ -0,0 +1,619 @@ +'use client' + +import { useState, useTransition, useMemo, useEffect, useRef, useCallback } from 'react' +import { useActionState } from 'react' +import { useFormStatus } from 'react-dom' +import { + useReactTable, + getCoreRowModel, + getPaginationRowModel, + flexRender, + type ColumnDef, + type RowSelectionState, + type PaginationState, +} from '@tanstack/react-table' +import { toast } from 'sonner' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { DemoTooltip } from '@/components/shared/demo-tooltip' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' +import { + createTodoAction, + updateTodoAction, + archiveSelectedTodosAction, + promoteTodoToPbiAction, + promoteTodoToStoryAction, +} from '@/actions/todos' + +interface Todo { + id: string + title: string + description: string | null + done: boolean + created_at: string + product_id: string | null + product_name: string | null +} + +interface Pbi { + id: string + title: string +} + +interface Product { + id: string + name: string + pbis: Pbi[] +} + +interface TodoListProps { + todos: Todo[] + products: Product[] + isDemo: boolean +} + +// Checkbox with indeterminate support for TanStack row selection +function IndeterminateCheckbox({ + indeterminate, + className, + ...props +}: React.InputHTMLAttributes<HTMLInputElement> & { indeterminate?: boolean }) { + const ref = useRef<HTMLInputElement>(null) + useEffect(() => { + if (ref.current) ref.current.indeterminate = indeterminate ?? false + }, [indeterminate]) + return ( + <input + ref={ref} + type="checkbox" + className={cn('size-4 cursor-pointer accent-primary', className)} + {...props} + /> + ) +} + +function SaveButton() { + const { pending } = useFormStatus() + return ( + <Button type="submit" size="sm" disabled={pending}> + {pending ? '…' : 'Opslaan'} + </Button> + ) +} + +// --- Promote to PBI dialog --- +function PromotePbiDialog({ + todo, + products, + onClose, +}: { todo: Todo; products: Product[]; onClose: () => void }) { + const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose]) + useEffect(() => { + document.addEventListener('keydown', handleKey) + return () => document.removeEventListener('keydown', handleKey) + }, [handleKey]) + + const [state, formAction] = useActionState( + async (_prev: unknown, fd: FormData) => { + const result = await promoteTodoToPbiAction(_prev, fd) + if (result?.success) { toast.success('Todo gepromoveerd naar PBI'); onClose() } + return result + }, + undefined + ) + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> + <div className="bg-popover border border-border rounded-xl p-6 w-full max-w-md shadow-xl space-y-4"> + <h2 className="font-medium text-foreground">Promoveer naar PBI</h2> + <p className="text-xs text-warning">Let op: dit kan niet ongedaan worden gemaakt.</p> + <form action={formAction} className="space-y-3"> + <input type="hidden" name="todoId" value={todo.id} /> + <div className="space-y-1.5"> + <label className="text-sm font-medium">Titel</label> + <Input name="title" defaultValue={todo.title} required /> + </div> + <div className="space-y-1.5"> + <label className="text-sm font-medium">Product</label> + {products.length === 0 ? ( + <p className="text-sm text-muted-foreground">Maak eerst een product aan.</p> + ) : ( + <select + name="productId" + required + defaultValue={todo.product_id ?? products[0]?.id} + className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background" + > + {products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)} + </select> + )} + </div> + <div className="space-y-1.5"> + <label className="text-sm font-medium">Prioriteit</label> + <select name="priority" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background"> + <option value="1">Kritiek</option> + <option value="2">Hoog</option> + <option value="3">Gemiddeld</option> + <option value="4">Laag</option> + </select> + </div> + {typeof state?.error === 'string' && <p className="text-xs text-error">{state.error}</p>} + <div className="flex gap-2 justify-end"> + <Button type="button" variant="ghost" onClick={onClose}>Annuleren</Button> + <Button type="submit" disabled={products.length === 0}>Promoveren</Button> + </div> + </form> + </div> + </div> + ) +} + +// --- Promote to Story dialog --- +function PromoteStoryDialog({ + todo, + products, + onClose, +}: { todo: Todo; products: Product[]; onClose: () => void }) { + const handleKey = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }, [onClose]) + useEffect(() => { + document.addEventListener('keydown', handleKey) + return () => document.removeEventListener('keydown', handleKey) + }, [handleKey]) + + const [selectedProductId, setSelectedProductId] = useState(todo.product_id ?? products[0]?.id ?? '') + const selectedProduct = products.find(p => p.id === selectedProductId) + + const [state, formAction] = useActionState( + async (_prev: unknown, fd: FormData) => { + const result = await promoteTodoToStoryAction(_prev, fd) + if (result?.success) { toast.success('Todo gepromoveerd naar Story'); onClose() } + return result + }, + undefined + ) + + return ( + <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"> + <div className="bg-popover border border-border rounded-xl p-6 w-full max-w-md shadow-xl space-y-4"> + <h2 className="font-medium text-foreground">Promoveer naar Story</h2> + <p className="text-xs text-warning">Let op: dit kan niet ongedaan worden gemaakt.</p> + <form action={formAction} className="space-y-3"> + <input type="hidden" name="todoId" value={todo.id} /> + <input type="hidden" name="productId" value={selectedProductId} /> + <div className="space-y-1.5"> + <label className="text-sm font-medium">Titel</label> + <Input name="title" defaultValue={todo.title} required /> + </div> + <div className="space-y-1.5"> + <label className="text-sm font-medium">Product</label> + {products.length === 0 ? ( + <p className="text-sm text-muted-foreground">Maak eerst een product aan.</p> + ) : ( + <select + value={selectedProductId} + onChange={e => setSelectedProductId(e.target.value)} + className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background" + > + {products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)} + </select> + )} + </div> + <div className="space-y-1.5"> + <label className="text-sm font-medium">PBI</label> + {!selectedProduct?.pbis.length ? ( + <p className="text-sm text-muted-foreground">Maak eerst een PBI aan in dit product.</p> + ) : ( + <select name="pbiId" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background"> + {selectedProduct.pbis.map(p => <option key={p.id} value={p.id}>{p.title}</option>)} + </select> + )} + </div> + <div className="space-y-1.5"> + <label className="text-sm font-medium">Prioriteit</label> + <select name="priority" required className="w-full border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background"> + <option value="1">Kritiek</option> + <option value="2">Hoog</option> + <option value="3">Gemiddeld</option> + <option value="4">Laag</option> + </select> + </div> + {typeof state?.error === 'string' && <p className="text-xs text-error">{state.error}</p>} + <div className="flex gap-2 justify-end"> + <Button type="button" variant="ghost" onClick={onClose}>Annuleren</Button> + <Button type="submit" disabled={!selectedProduct?.pbis.length}>Promoveren</Button> + </div> + </form> + </div> + </div> + ) +} + +// --- Detail card --- +function TodoCard({ + mode, + activeTodo, + products, + isDemo, + defaultProductId, + onSuccess, + onPromotePbi, + onPromoteStory, +}: { + mode: 'idle' | 'create' | 'edit' + activeTodo: Todo | null + products: Product[] + isDemo: boolean + defaultProductId: string + onSuccess: () => void + onPromotePbi: (todo: Todo) => void + onPromoteStory: (todo: Todo) => void +}) { + const [createState, createFormAction] = useActionState(createTodoAction, undefined) + const [editState, editFormAction] = useActionState(updateTodoAction, undefined) + + useEffect(() => { + if (createState?.success) onSuccess() + }, [createState, onSuccess]) + + useEffect(() => { + if (editState?.success) onSuccess() + }, [editState, onSuccess]) + + if (mode === 'idle') { + return ( + <div className="rounded-xl border border-border bg-surface-container-low p-5 min-h-[88px] flex items-center justify-center"> + <p className="text-sm text-muted-foreground">Selecteer een rij of klik op + om te beginnen.</p> + </div> + ) + } + + if (mode === 'create') { + return ( + <div className="rounded-xl border border-border bg-surface-container-low p-5"> + <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Nieuwe todo</p> + <form action={createFormAction} className="space-y-3"> + <div className="flex gap-3"> + <select + name="productId" + defaultValue={defaultProductId} + disabled={isDemo} + className="border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background shrink-0" + > + <option value="">Geen product</option> + {products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)} + </select> + <Input + name="title" + placeholder="Titel…" + disabled={isDemo} + autoFocus + className="flex-1" + autoComplete="off" + /> + </div> + <Textarea + name="description" + placeholder="Beschrijving (optioneel, max 2000 tekens)…" + disabled={isDemo} + maxLength={2000} + rows={4} + /> + {typeof createState?.error === 'string' && ( + <p className="text-xs text-error">{createState.error}</p> + )} + <div className="flex gap-2 justify-end"> + <Button type="button" variant="ghost" size="sm" onClick={onSuccess}>Annuleren</Button> + <SaveButton /> + </div> + </form> + </div> + ) + } + + // Edit mode + if (!activeTodo) return null + + return ( + <div className="rounded-xl border border-border bg-surface-container-low p-5"> + <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-3">Todo bewerken</p> + <form action={editFormAction} className="space-y-3"> + <input type="hidden" name="id" value={activeTodo.id} /> + <div className="flex gap-3"> + <select + name="productId" + defaultValue={activeTodo.product_id ?? ''} + disabled={isDemo} + className="border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background shrink-0" + > + <option value="">Geen product</option> + {products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)} + </select> + <Input + name="title" + defaultValue={activeTodo.title} + disabled={isDemo} + autoFocus + className="flex-1" + autoComplete="off" + /> + </div> + <Textarea + name="description" + defaultValue={activeTodo.description ?? ''} + placeholder="Beschrijving (optioneel, max 2000 tekens)…" + disabled={isDemo} + maxLength={2000} + rows={4} + /> + <label className="flex items-center gap-2 text-sm cursor-pointer w-fit select-none"> + <input + type="checkbox" + name="done" + defaultChecked={activeTodo.done} + disabled={isDemo} + className="size-4 accent-primary cursor-pointer" + /> + Afgerond + </label> + {typeof editState?.error === 'string' && ( + <p className="text-xs text-error">{editState.error}</p> + )} + <div className="flex items-center gap-2"> + {!isDemo && ( + <> + <Button type="button" variant="outline" size="sm" onClick={() => onPromotePbi(activeTodo)}> + → PBI + </Button> + <Button type="button" variant="outline" size="sm" onClick={() => onPromoteStory(activeTodo)}> + → Story + </Button> + </> + )} + <div className="flex-1" /> + <Button type="button" variant="ghost" size="sm" onClick={onSuccess}>Annuleren</Button> + <SaveButton /> + </div> + </form> + </div> + ) +} + +// --- Main component --- +export function TodoList({ todos, products, isDemo }: TodoListProps) { + const [isPending, startTransition] = useTransition() + const [selectedProductId, setSelectedProductId] = useState('all') + const [rowSelection, setRowSelection] = useState<RowSelectionState>({}) + const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 }) + const [activeRowId, setActiveRowId] = useState<string | null>(null) + const [mode, setMode] = useState<'idle' | 'create'>('idle') + const [promotePbi, setPromotePbi] = useState<Todo | null>(null) + const [promoteStory, setPromoteStory] = useState<Todo | null>(null) + + const filtered = useMemo(() => { + if (selectedProductId === 'all') return todos + if (selectedProductId === '') return todos.filter(t => t.product_id === null) + return todos.filter(t => t.product_id === selectedProductId) + }, [todos, selectedProductId]) + + useEffect(() => { + setPagination(p => ({ ...p, pageIndex: 0 })) + setRowSelection({}) + }, [selectedProductId]) + + const columns = useMemo<ColumnDef<Todo>[]>(() => [ + { + id: 'select', + header: ({ table }) => ( + <IndeterminateCheckbox + checked={table.getIsAllPageRowsSelected()} + indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()} + onChange={e => table.toggleAllPageRowsSelected(e.target.checked)} + /> + ), + cell: ({ row }) => ( + <IndeterminateCheckbox + checked={row.getIsSelected()} + disabled={!row.getCanSelect()} + onChange={e => row.toggleSelected(e.target.checked)} + onClick={e => e.stopPropagation()} + /> + ), + }, + { + accessorKey: 'title', + header: 'Titel', + cell: ({ row }) => ( + <p className={cn( + 'line-clamp-2 text-sm leading-snug', + row.original.done && 'line-through text-muted-foreground' + )}> + {row.original.title} + </p> + ), + }, + { + accessorKey: 'product_name', + header: 'Product', + cell: ({ row }) => row.original.product_name ? ( + <Badge variant="outline" className="text-xs font-normal">{row.original.product_name}</Badge> + ) : ( + <span className="text-xs text-muted-foreground">—</span> + ), + }, + { + accessorKey: 'created_at', + header: 'Datum', + cell: ({ row }) => ( + <span className="text-xs text-muted-foreground whitespace-nowrap"> + {new Date(row.original.created_at).toLocaleDateString('nl-NL')} + </span> + ), + }, + ], []) + + const table = useReactTable({ + data: filtered, + columns, + state: { rowSelection, pagination }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }) + + const selectedRows = table.getSelectedRowModel().rows + const selectedCount = selectedRows.length + const { pageIndex, pageSize } = table.getState().pagination + const totalRows = filtered.length + const start = totalRows === 0 ? 0 : pageIndex * pageSize + 1 + const end = Math.min((pageIndex + 1) * pageSize, totalRows) + + const activeTodo = todos.find(t => t.id === activeRowId) ?? null + const cardMode = mode === 'create' ? 'create' : activeTodo ? 'edit' : 'idle' + const defaultProductId = selectedProductId !== 'all' ? selectedProductId : '' + + const handleCancel = useCallback(() => { + setActiveRowId(null) + setMode('idle') + }, []) + + function handleRowClick(todo: Todo) { + setActiveRowId(prev => prev === todo.id ? null : todo.id) + setMode('idle') + } + + function handleNew() { + setActiveRowId(null) + setRowSelection({}) + setMode('create') + } + + function handleBulkArchive() { + const ids = selectedRows.map(r => r.original.id) + startTransition(async () => { + const result = await archiveSelectedTodosAction(ids) + if (result?.error) { + toast.error(typeof result.error === 'string' ? result.error : 'Archiveren mislukt') + } else { + const n = ids.length + toast.success(`${n} todo${n === 1 ? '' : "'s"} gearchiveerd`) + setRowSelection({}) + setActiveRowId(null) + setMode('idle') + } + }) + } + + return ( + <div className="space-y-4"> + {/* Toolbar */} + <div className="flex items-center gap-3 flex-wrap"> + <select + value={selectedProductId} + onChange={e => setSelectedProductId(e.target.value)} + className="border border-border rounded-lg px-3 py-1.5 text-sm bg-input-background" + > + <option value="all">Alles</option> + <option value="">Geen product</option> + {products.map(p => <option key={p.id} value={p.id}>{p.name}</option>)} + </select> + + <div className="flex-1" /> + + {selectedCount > 0 && !isDemo && ( + <Button variant="outline" size="sm" onClick={handleBulkArchive} disabled={isPending}> + Archiveer geselecteerde ({selectedCount}) + </Button> + )} + + <DemoTooltip show={isDemo}> + <Button size="sm" onClick={handleNew} disabled={isDemo}>+</Button> + </DemoTooltip> + </div> + + {/* Table */} + <div className="rounded-xl border border-border overflow-hidden"> + <Table> + <TableHeader> + {table.getHeaderGroups().map(hg => ( + <TableRow key={hg.id} className="hover:bg-transparent"> + {hg.headers.map(header => ( + <TableHead key={header.id}> + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + </TableHead> + ))} + </TableRow> + ))} + </TableHeader> + <TableBody> + {table.getRowModel().rows.length > 0 ? ( + table.getRowModel().rows.map(row => ( + <TableRow + key={row.id} + className={cn( + 'cursor-pointer hover:bg-surface-container-low', + activeRowId === row.original.id + ? 'bg-primary/5' + : row.getIsSelected() + ? 'bg-primary/10' + : '' + )} + onClick={() => handleRowClick(row.original)} + > + {row.getVisibleCells().map(cell => ( + <TableCell key={cell.id}> + {flexRender(cell.column.columnDef.cell, cell.getContext())} + </TableCell> + ))} + </TableRow> + )) + ) : ( + <TableRow> + <TableCell colSpan={4} className="h-32 text-center text-sm text-muted-foreground"> + {todos.length === 0 + ? "Nog geen todo's. Gebruik + om er een aan te maken." + : "Geen todo's voor deze selectie."} + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + + {/* Pagination */} + {totalRows > pageSize && ( + <div className="flex items-center justify-between"> + <span className="text-xs text-muted-foreground">{start}–{end} van {totalRows}</span> + <div className="flex gap-1"> + <Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>‹</Button> + <Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>›</Button> + </div> + </div> + )} + + {/* Detail card */} + <TodoCard + key={activeRowId ?? (mode === 'create' ? 'create' : 'idle')} + mode={cardMode} + activeTodo={activeTodo} + products={products} + isDemo={isDemo} + defaultProductId={defaultProductId} + onSuccess={handleCancel} + onPromotePbi={setPromotePbi} + onPromoteStory={setPromoteStory} + /> + + {promotePbi && ( + <PromotePbiDialog todo={promotePbi} products={products} onClose={() => setPromotePbi(null)} /> + )} + {promoteStory && ( + <PromoteStoryDialog todo={promoteStory} products={products} onClose={() => setPromoteStory(null)} /> + )} + </div> + ) +} diff --git a/components/ui/input.tsx b/components/ui/input.tsx index 71d1fb8..7d21bab 100644 --- a/components/ui/input.tsx +++ b/components/ui/input.tsx @@ -9,7 +9,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { type={type} data-slot="input" className={cn( - "h-8 w-full min-w-0 rounded-lg border border-border bg-input-background px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", + "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", className )} {...props} diff --git a/components/ui/select.tsx b/components/ui/select.tsx index 64ce971..e8021f5 100644 --- a/components/ui/select.tsx +++ b/components/ui/select.tsx @@ -41,7 +41,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-border bg-input-background py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx index 48a4e75..04d27f7 100644 --- a/components/ui/textarea.tsx +++ b/components/ui/textarea.tsx @@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { <textarea data-slot="textarea" className={cn( - "flex field-sizing-content min-h-16 w-full rounded-lg border border-border bg-input-background px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", + "flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", className )} {...props} diff --git a/docs/INDEX.md b/docs/INDEX.md index ffcbce3..9e21dc9 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -2,7 +2,7 @@ # Documentation Index -Auto-generated on 2026-05-15 from front-matter and headings. +Auto-generated on 2026-05-03 from front-matter and headings. ## Architecture Decision Records @@ -17,70 +17,55 @@ Auto-generated on 2026-05-15 from front-matter and headings. | 0006 | [ADR-0006: Demo-user write protection enforced in three layers](./adr/0006-demo-user-three-layer-policy.md) | accepted | | 0007 | [ADR-0007: Agent ↔ user question channel via persistent table + LISTEN/NOTIFY](./adr/0007-claude-question-channel-design.md) | accepted | | 0008 | [ADR-0008: Agent instructions in CLAUDE.md + topical runbooks](./adr/0008-agent-instructions-in-claude-md.md) | accepted | -| 0010 | [ADR-0010: Eén product = één repo; cross-product planning vereist (later) een Initiative-laag](./adr/0010-product-per-repo-cross-product-planning.md) | accepted | -| 0011 | [ADR-0011: code is de bindende volgordesleutel voor stories en taken; priority is label](./adr/0011-code-volgordesleutel-stories-taken.md) | accepted | ## Specifications | Title | Status | Updated | |---|---|---| -| [AnswerModal Profiel](./specs/dialogs/answer-modal.md) | active | 2026-05-15 | -| [BatchEnqueueBlockerDialog Profiel](./specs/dialogs/batch-enqueue-blocker.md) | active | 2026-05-04 | -| [IdeaDialog Profiel](./specs/dialogs/idea.md) | active | 2026-05-04 | -| [PbiDialog Profiel](./specs/dialogs/pbi.md) | active | 2026-05-04 | -| [ProductDialog Profiel](./specs/dialogs/product.md) | active | 2026-05-04 | -| [Sprint Dialogs Profiel](./specs/dialogs/sprint.md) | active | 2026-05-04 | -| [StoryDialog Profiel](./specs/dialogs/story.md) | active | 2026-05-04 | -| [TaskDetailDialog Profiel](./specs/dialogs/task-detail.md) | active | 2026-05-04 | +| [PbiDialog Profiel](./specs/dialogs/pbi.md) | active | 2026-05-03 | +| [StoryDialog Profiel](./specs/dialogs/story.md) | active | 2026-05-03 | | [TaskDialog Profiel](./specs/dialogs/task.md) | active | 2026-05-03 | -| [Scrum4Me — Functionele Specificatie](./specs/functional.md) | active | 2026-05-08 | +| [Scrum4Me — Functionele Specificatie](./specs/functional.md) | active | 2026-05-03 | | [DevPlanner — User Personas](./specs/personas.md) | active | 2026-05-03 | ## Plans | Title | Status | Updated | |---|---|---| +| [Docs-restructuur — geoptimaliseerd voor AI-lookup](./plans/docs-restructure-ai-lookup.md) | proposal | 2026-05-02 | | [PBI Bulk-Create Spec — Docs-Restructure for AI-Optimized Lookup](./plans/docs-restructure-pbi-spec.md) | done | 2026-05-03 | -| [Plan: model + mode-selectie per ClaudeJob-kind](./plans/job-model-selection.md) | — | — | -| [Verbeterplan load/render Product Backlog, Sprint en Solo](./plans/load-render-improvement-plan-2026-05-10.md) | draft | 2026-05-10 | -| [M12 — Idea entity + Grill/Plan Claude jobs](./plans/M12-ideas.md) | planned | — | -| [Bootstrap-wizard voor nieuwe Product-repo](./plans/M8-bootstrap-wizard-upload.md) | — | — | -| [Plan v3.5 — Bootstrap-wizard voor nieuwe Product-repo (Scrum4Me feature)](./plans/M8-bootstrap-wizard.md) | reviewed | — | -| [PBI-80 — Demo-gebruiker mag eigen UI-voorkeuren wijzigen](./plans/PBI-80-demo-prefs.md) | — | — | -| [Plan — `code` wordt bindende volgorde voor stories & taken; drag-and-drop eruit](./plans/PBI-84-code-binding-order.md) | — | — | -| [Plan — Expliciete schermstaat + draft-zichtbaarheid op de Product Backlog page](./plans/PBI-91-pb-screen-state.md) | — | — | -| [Queue-loop verplaatsen van Claude naar runner](./plans/queue-loop-extraction.md) | — | — | -| [Sprint MCP-tools — create_sprint & update_sprint](./plans/sprint-mcp-tools.md) | draft | 2026-05-11 | -| [Advies - SprintRun, PR en worktree lifecycle als state machines](./plans/sprint-pr-worktree-state-machines.md) | draft | 2026-05-06 | +| [M10 — Password-loze inlog via QR-pairing](./plans/M10-qr-pairing-login.md) | active | 2026-05-03 | +| [M11 — Claude vraagt, gebruiker antwoordt](./plans/M11-claude-questions.md) | active | 2026-05-03 | +| [M9 — Actief Product Backlog](./plans/M9-active-product-backlog.md) | active | 2026-05-03 | | [ST-1109 — PBI krijgt een status (Ready / Blocked / Done)](./plans/ST-1109-pbi-status.md) | active | 2026-05-03 | | [ST-1110 — Demo gebruiker read-only](./plans/ST-1110-demo-readonly.md) | active | 2026-05-03 | | [ST-1111 — Voer uit-knop met Claude Code job queue](./plans/ST-1111-claude-job-trigger.md) | active | 2026-05-03 | -| [Plan: wekelijkse sync van `model_prices` (PBI-66 / ST-1296)](./plans/sync-model-prices.md) | — | — | +| [ST-1114 — Copilot reviews op dashboard](./plans/ST-1114-copilot-reviews.md) | active | 2026-05-03 | | [Tweede Claude Agent — Planning Agent](./plans/tweede-claude-agent-planning.md) | proposal | 2026-05-03 | -| [Zustand store rearchitecture - active context, realtime en resync](./plans/zustand-store-rearchitecture.md) | ready-to-execute | 2026-05-09 | -| [Zustand workspace-store implementatieplan (PBI-74)](./plans/zustand-workspace-store-implementation.md) | in-progress | 2026-05-10 | + +### Archive + +| Title | Updated | +|---|---| +| [CLAUDE.md workflow-update na M7 + ST-509/511/512/513](./plans/archive/2026-04-27-claude-md-workflow-update.md) | 2026-05-03 | +| [Herbruikbaar scripts/insert-milestone.ts](./plans/archive/2026-04-27-insert-milestone-tool.md) | 2026-05-03 | +| [Realtime updates voor Solo Paneel (M8)](./plans/archive/2026-04-27-m8-realtime-solo.md) | 2026-05-03 | ## Patterns | Title | Status | Updated | |---|---|---| | [Bidirectionele async-comms MCP-agent ↔ user](./patterns/claude-question-channel.md) | active | 2026-05-03 | -| [Debug-id op component-root](./patterns/debug-id.md) | active | 2026-05-09 | -| [Debug-labels: BEM data-debug-id patroon](./patterns/debug-labels.md) | active | 2026-05-09 | -| [Demo client-state (UI-prefs zonder DB)](./patterns/demo-client-state.md) | active | 2026-05-12 | -| [Entity Dialog](./patterns/dialog.md) | active | 2026-05-08 | +| [Entity Dialog](./patterns/dialog.md) | active | 2026-05-03 | | [iron-session](./patterns/iron-session.md) | active | 2026-05-03 | | [Prisma Client singleton](./patterns/prisma-client.md) | active | 2026-05-03 | -| [Proxy (route protection)](./patterns/proxy.md) | active | 2026-05-08 | +| [Proxy (route protection)](./patterns/proxy.md) | active | 2026-05-03 | | [QR-pairing via unauth-SSE + pre-auth cookie](./patterns/qr-login.md) | active | 2026-05-03 | -| [Realtime NOTIFY payload — veldnaam-contract](./patterns/realtime-notify-payload.md) | active | 2026-05-03 | -| [Route Handler (REST API)](./patterns/route-handler.md) | active | 2026-05-08 | -| [Server Action](./patterns/server-action.md) | active | 2026-05-08 | -| [sort_order — PBI drag-and-drop vs. code-bindende volgorde voor stories/taken](./patterns/sort-order.md) | active | 2026-05-14 | +| [Route Handler (REST API)](./patterns/route-handler.md) | active | 2026-05-03 | +| [Server Action](./patterns/server-action.md) | active | 2026-05-03 | +| [Float sort_order (drag-and-drop volgorde)](./patterns/sort-order.md) | active | 2026-05-03 | | [Story met UI-component](./patterns/story-with-ui-component.md) | active | 2026-05-03 | -| [Web Push](./patterns/web-push.md) | active | 2026-05-07 | -| [Workspace-store + realtime — bounded-context patroon](./patterns/workspace-store.md) | active | 2026-05-10 | -| [Zustand optimistische update + rollback](./patterns/zustand-optimistic.md) | active | 2026-05-10 | +| [Zustand optimistische update + rollback](./patterns/zustand-optimistic.md) | active | 2026-05-03 | ## Other Docs @@ -91,53 +76,29 @@ Auto-generated on 2026-05-15 from front-matter and headings. | [Scrum4Me — Technische Architectuur (breadcrumb)](./architecture.md) | `architecture.md` | active | 2026-05-03 | | [Authentication, Sessions & Demo Policy](./architecture/auth-and-sessions.md) | `architecture/auth-and-sessions.md` | active | 2026-05-03 | | [Claude ↔ User Question Channel](./architecture/claude-question-channel.md) | `architecture/claude-question-channel.md` | active | 2026-05-03 | -| [Data Model & Prisma Schema](./architecture/data-model.md) | `architecture/data-model.md` | active | 2026-05-08 | -| [Scrum4Me — Architecture Overview](./architecture/overview.md) | `architecture/overview.md` | active | 2026-05-08 | -| [Product Backlog page — workflow & states](./architecture/product-backlog-workflow.md) | `architecture/product-backlog-workflow.md` | active | 2026-05-14 | -| [Project Structure, Stores, Realtime & Job Queue](./architecture/project-structure.md) | `architecture/project-structure.md` | active | 2026-05-08 | +| [Data Model & Prisma Schema](./architecture/data-model.md) | `architecture/data-model.md` | active | 2026-05-03 | +| [Scrum4Me — Architecture Overview](./architecture/overview.md) | `architecture/overview.md` | active | 2026-05-03 | +| [Project Structure, Stores, Realtime & Job Queue](./architecture/project-structure.md) | `architecture/project-structure.md` | active | 2026-05-03 | | [QR-pairing Login Flow](./architecture/qr-pairing.md) | `architecture/qr-pairing.md` | active | 2026-05-03 | -| [Sprint execution modes — PER_TASK vs SPRINT_BATCH](./architecture/sprint-execution-modes.md) | `architecture/sprint-execution-modes.md` | active | 2026-05-07 | +| [Scrum4Me — Implementatie Backlog](./backlog.md) | `backlog.md` | active | 2026-05-03 | +| [Scrum4Me — Implementatie Backlog](./backlog/index.md) | `backlog/index.md` | active | 2026-05-03 | +| [DevPlanner — Product Backlog](./backlog/product-historical.md) | `backlog/product-historical.md` | active | 2026-05-03 | | [Agent Instruction Audit](./decisions/agent-instructions-history.md) | `decisions/agent-instructions-history.md` | active | 2026-05-03 | | [Scrum4Me — Styling & Design System](./design/styling.md) | `design/styling.md` | active | 2026-05-03 | | [Docker smoke test — task 1](./docker-smoke/2-mei-task-1.md) | `docker-smoke/2-mei-task-1.md` | done | 2026-05-03 | | [Docker smoke test — task 2](./docker-smoke/2-mei-task-2.md) | `docker-smoke/2-mei-task-2.md` | done | 2026-05-03 | -| [Scrum4Me — Glossary](./glossary.md) | `glossary.md` | active | 2026-05-08 | -| [Onderzoek — AI-gedreven programmeren en Scrum planning](./Ideas/ai-driven-scrum-planning-research.md) | `Ideas/ai-driven-scrum-planning-research.md` | draft | 2026-05-11 | -| [Installatieplan — Beelink Ubuntu Scrum4Me server en worker-aanpassingen](./Ideas/beelink-scrum4me-server-install-and-worker-plan.md) | `Ideas/beelink-scrum4me-server-install-and-worker-plan.md` | draft | 2026-05-10 | -| [Advies — Product Backlog en Sprint-pagina workflow](./Ideas/sprint-page-backlog-relationship-research.md) | `Ideas/sprint-page-backlog-relationship-research.md` | draft | 2026-05-11 | -| [ST-1114 — Copilot reviews op dashboard](./Ideas/ST-1114-copilot-reviews.md) | `Ideas/ST-1114-copilot-reviews.md` | active | 2026-05-03 | -| [IDEA_REVIEW_PLAN Implementation Summary](./implementation-complete/IDEA_REVIEW_PLAN-implementation-summary.md) | `implementation-complete/IDEA_REVIEW_PLAN-implementation-summary.md` | — | — | -| [IDEA_REVIEW_PLAN Implementation — COMPLETE ✅](./implementation-complete/IMPLEMENTATION-COMPLETE.md) | `implementation-complete/IMPLEMENTATION-COMPLETE.md` | — | — | -| [Phase 6: End-to-End Testing & Rollout Plan](./implementation-complete/PHASE6-END-TO-END-TEST-PLAN.md) | `implementation-complete/PHASE6-END-TO-END-TEST-PLAN.md` | — | — | -| [Overview](./manual/01-overview.md) | `manual/01-overview.md` | active | 2026-05-07 | -| [Statuses & Transitions](./manual/02-statuses-and-transitions.md) | `manual/02-statuses-and-transitions.md` | active | 2026-05-07 | -| [Git Workflow](./manual/03-git-workflow.md) | `manual/03-git-workflow.md` | active | 2026-05-07 | -| [MCP Integration](./manual/04-mcp-integration.md) | `manual/04-mcp-integration.md` | active | 2026-05-07 | -| [Docker](./manual/05-docker.md) | `manual/05-docker.md` | active | 2026-05-07 | -| [Troubleshooting](./manual/06-troubleshooting.md) | `manual/06-troubleshooting.md` | active | 2026-05-07 | -| [Scrum4Me Developer Manual](./manual/index.md) | `manual/index.md` | active | 2026-05-07 | +| [Scrum4Me — Functionele Specificatie](./functional.md) | `functional.md` | active | 2026-05-03 | +| [Scrum4Me — Glossary](./glossary.md) | `glossary.md` | active | 2026-05-03 | | [Scrum4Me — Styling & Design System](./md3-color-scheme.md) | `md3-color-scheme.md` | active | 2026-05-03 | | [Obsidian as Personal Authoring Layer](./obsidian-authoring.md) | `obsidian-authoring.md` | active | 2026-05-02 | +| [PbiDialog Profiel](./pbi-dialog.md) | `pbi-dialog.md` | active | 2026-05-03 | | [DevPlanner — User Personas](./personas.md) | `personas.md` | active | 2026-05-03 | +| [DevPlanner — Product Backlog](./product-backlog.md) | `product-backlog.md` | active | 2026-05-03 | | [Scrum4Me — API Test Plan](./qa/api-test-plan.md) | `qa/api-test-plan.md` | active | 2026-05-03 | | [Realtime smoke-checklist — PBI / Story / Task](./realtime-smoke.md) | `realtime-smoke.md` | active | 2026-05-03 | -| [Caveman plan — Beelink naar Ubuntu Scrum4Me server](./recommendations/beelink-ubuntu-scrum4me-server-caveman-plan.md) | `recommendations/beelink-ubuntu-scrum4me-server-caveman-plan.md` | draft | 2026-05-09 | -| [Review - Bootstrap-wizard plan](./recommendations/bootstrap-wizard-plan-review-2026-05-13.md) | `recommendations/bootstrap-wizard-plan-review-2026-05-13.md` | draft | 2026-05-13 | -| [Review - Bootstrap-wizard plan v2 met webresearch](./recommendations/bootstrap-wizard-plan-v2-web-research-review-2026-05-13.md) | `recommendations/bootstrap-wizard-plan-v2-web-research-review-2026-05-13.md` | draft | 2026-05-13 | -| [Review - Bootstrap-wizard plan v3.2](./recommendations/bootstrap-wizard-plan-v3-2-review-2026-05-14.md) | `recommendations/bootstrap-wizard-plan-v3-2-review-2026-05-14.md` | draft | 2026-05-14 | -| [Review - Bootstrap-wizard plan v3.3](./recommendations/bootstrap-wizard-plan-v3-3-review-2026-05-14.md) | `recommendations/bootstrap-wizard-plan-v3-3-review-2026-05-14.md` | draft | 2026-05-14 | -| [Review — M8 bootstrap-wizard plan v3.4](./recommendations/bootstrap-wizard-plan-v3-4-review-2026-05-14.md) | `recommendations/bootstrap-wizard-plan-v3-4-review-2026-05-14.md` | — | — | -| [Aanbeveling — Claude VM jobflow en gitstrategie](./recommendations/claude-vm-job-flow-git-strategy.md) | `recommendations/claude-vm-job-flow-git-strategy.md` | draft | 2026-05-09 | -| [Load/render implementatie review](./recommendations/load-render-implementation-review-2026-05-10.md) | `recommendations/load-render-implementation-review-2026-05-10.md` | review | 2026-05-10 | -| [Agent-flow: open issues & decision log](./runbooks/agent-flow-pitfalls.md) | `runbooks/agent-flow-pitfalls.md` | active | 2026-05-03 | -| [Auto-PR flow: van story-DONE naar gemergde PR](./runbooks/auto-pr-flow.md) | `runbooks/auto-pr-flow.md` | active | 2026-05-06 | | [Branch, PR & Commit Strategy](./runbooks/branch-and-commit.md) | `runbooks/branch-and-commit.md` | active | 2026-05-03 | -| [Deploy-controle: triggers, labels, path-filter](./runbooks/deploy-control.md) | `runbooks/deploy-control.md` | active | 2026-05-07 | | [Vercel Deployment](./runbooks/deploy-vercel.md) | `runbooks/deploy-vercel.md` | active | 2026-05-03 | -| [Job-model-selectie per ClaudeJob-kind](./runbooks/job-model-selection.md) | `runbooks/job-model-selection.md` | active | 2026-05-09 (idea-kinds + PLAN_CHAT permission_mode → acceptEdits) | -| [MCP Integration — Scrum4Me Tools](./runbooks/mcp-integration.md) | `runbooks/mcp-integration.md` | active | 2026-05-08 | -| [Plan → Sprint/PBI/Story/Task workflow](./runbooks/plan-to-pbi-flow.md) | `runbooks/plan-to-pbi-flow.md` | active | 2026-05-11 | -| [Review-Plan Job Orchestration](./runbooks/review-plan-job.md) | `runbooks/review-plan-job.md` | — | — | -| [v1.0 Smoke Test Checklist](./runbooks/v1-smoke-test.md) | `runbooks/v1-smoke-test.md` | active | 2026-05-04 | -| [Worker idempotency & job-status protocol](./runbooks/worker-idempotency.md) | `runbooks/worker-idempotency.md` | active | 2026-05-09 | +| [MCP Integration — Scrum4Me Tools](./runbooks/mcp-integration.md) | `runbooks/mcp-integration.md` | active | 2026-05-03 | +| [StoryDialog Profiel](./story-dialog.md) | `story-dialog.md` | active | 2026-05-03 | +| [TaskDialog Profiel](./task-dialog.md) | `task-dialog.md` | active | 2026-05-03 | | [Scrum4Me — API Test Plan](./test-plan.md) | `test-plan.md` | active | 2026-05-03 | diff --git a/docs/Ideas/ai-driven-scrum-planning-research.md b/docs/Ideas/ai-driven-scrum-planning-research.md deleted file mode 100644 index c76ef6d..0000000 --- a/docs/Ideas/ai-driven-scrum-planning-research.md +++ /dev/null @@ -1,306 +0,0 @@ ---- -title: "Onderzoek — AI-gedreven programmeren en Scrum planning" -status: draft -audience: [product, ai-agent] -language: nl -last_updated: 2026-05-11 ---- - -# Onderzoek — AI-gedreven programmeren en Scrum planning - -## Vraag - -Wat is het effect van AI-assisted / AI-driven programming op de manier waarop we Scrum toepassen, als werk niet meer in 1-2 weekse sprints hoeft te worden gepland maar in meerdere uitvoercycli per dag kan worden gerealiseerd? Wat gebeurt er met burndown, velocity en planning als tokengebruik, verificatie en agent-efficiency belangrijker worden? - -## Korte conclusie - -AI maakt Scrum niet overbodig, maar verandert waar Scrum op moet sturen. - -Traditionele sprintplanning gebruikt vaak velocity/story points als proxy voor menselijke uitvoercapaciteit. In AI-gedreven ontwikkeling wordt menselijke typ-/bouwtijd veel minder voorspelbaar als bottleneck. De nieuwe bottlenecks zijn: - -- helderheid van productbeslissingen; -- kwaliteit van backlog-items en acceptatiecriteria; -- beschikbare context voor agents; -- verificatiecapaciteit: tests, review, security, deploy; -- tokenkosten en modelkeuze; -- rework door foutieve of half-passende AI-output. - -Daarom moet planning verschuiven van "hoeveel story points kunnen we in twee weken doen?" naar "welke waardevolle hypothese kunnen we nu veilig laten uitvoeren, verifieren en leren, binnen een token- en reviewbudget?" - -## Wat de bronnen laten zien - -### 1. Het empirische bewijs is gemengd - -Onderzoek naar Copilot liet in een gecontroleerde programmeertaak zien dat deelnemers met Copilot de taak 55,8% sneller voltooiden. Een latere Microsoft-studie met drie field experiments bij 4.867 developers vond gemiddeld 26,08% meer voltooide taken bij developers met AI-code-completion. - -Tegelijk vond METR in 2025 in een realistische RCT met ervaren open-source developers dat AI-tools taken juist 19% langzamer maakten. De taken waren echte issues in grote codebases die developers goed kenden. METR waarschuwt zelf tegen te brede generalisatie, maar het resultaat is belangrijk: AI-snelheid hangt sterk af van taaktype, codebase-context, kwaliteitslat en meetmethode. In 2026 gaf METR bovendien aan dat het meten van AI-uplift lastiger wordt doordat developers liever niet meer zonder AI werken en doordat sommige developers meerdere agents tegelijk gebruiken. - -Implicatie: Scrum4Me moet geen vaste productiviteitsfactor aannemen. Meet per product, per agent-run en per taaktype. - -### 2. DORA: AI is een versterker, geen oplossing - -DORA 2025 concludeert dat AI vooral een amplifier is: het versterkt bestaande sterke en zwakke punten. Google rapporteert bijna universele adoptie, veel ervaren individuele productiviteitswinst, maar ook een vertrouwensparadox en complexere effecten op organisatorische performance. - -DORA's AI Capabilities Model noemt zeven randvoorwaarden die AI-effecten versterken: - -- duidelijke AI-stance/policy; -- gezonde data-ecosystemen; -- AI-toegankelijke interne data; -- sterke version-control-praktijken; -- kleine batches; -- user-centric focus; -- kwalitatieve interne platforms. - -Voor Scrum betekent dit: de sprint moet niet groter worden omdat agents sneller code schrijven. De batch moet kleiner worden, met scherpere feedback. - -### 3. Agile verschuift naar outcome- en governance-gedreven planning - -Digital.ai's 18th State of Agile beschrijft AI als een verschuiving van ondersteunende tool naar orchestrator van de delivery lifecycle. Tegelijk noemt het rapport hogere ROI-druk, governance-lag en de noodzaak om Agile-investeringen aan meetbare business outcomes te koppelen. - -Implicatie: velocity en burndown zijn onvoldoende als hoofdmetrics. Ze laten activiteit zien, geen waarde, geen kosten en geen risico. - -### 4. Tokengebruik wordt economisch relevant, maar is geen doel op zichzelf - -Stanford Digital Economy Lab vond in 2026 dat agentic coding tasks veel token-intensiever zijn dan code chat/reasoning, dat inputtokens de kosten domineren, dat runs op dezelfde taak tot 30x kunnen verschillen in tokengebruik, en dat meer tokens niet automatisch meer accuracy opleveren. - -Jellyfish analyseerde 12.000 developers bij 200 bedrijven en vond dat meer tokengebruik wel met meer output correleert, maar disproportioneel duurder wordt. De topgebruikers haalden ongeveer twee keer zoveel PR-throughput, maar met ongeveer tien keer zoveel tokens per PR. - -Implicatie: tokenusage is een cost/efficiency-signaal, geen prestatiebadge. "Tokenmaxxing" is net zo gevaarlijk als velocity maximaliseren. - -## Wat verandert aan Scrum? - -### Sprint - -De Sprint blijft nuttig als container voor focus, inspectie en adaptatie. Maar bij AI-gedreven werk is een sprint minder een capaciteitsmandje voor menselijke arbeid en meer een beslis- en leerhorizon. - -Advies: - -- Gebruik "Sprint" voor productfocus en Sprint Goal. -- Gebruik "AI runs" of "execution cycles" binnen de sprint voor 1-4 uitvoercycli per dag. -- Noem 4 cycli per dag liever geen 4 Scrum-sprints, tenzij je ook echt 4 keer Sprint Planning, Review en Retrospective wilt doen. Dat levert ceremonie-overhead op. - -### Sprint Planning - -Planning verschuift van effort forecast naar control loop. - -Oude vraag: - -- Hoeveel werk kunnen we deze sprint doen? - -Nieuwe vraag: - -- Welke waardevolle slice is klaar voor agent-uitvoering? -- Welke context en tests maken het veilig? -- Welk model/mode/budget past bij risico en complexiteit? -- Hoe weten we binnen 30-120 minuten of dit goed genoeg is? -- Wat is de maximale token- en reviewspend voor deze poging? - -### Daily Scrum - -Daily Scrum wordt minder statusmeeting en meer flow-control: - -- Welke agent-runs zijn afgerond, geblokkeerd of failed? -- Waar zit de verificatiequeue? -- Welke Product Owner-beslissing ontbreekt? -- Welke context ontbreekt waardoor tokens of rework oplopen? -- Moeten we scope heronderhandelen zonder het Sprint Goal te beschadigen? - -Bij 4 runs per dag kan een korte "run review" na elke run de Daily Scrum deels vervangen. - -### Sprint Review - -Review wordt frequenter en meer outcome-gericht: - -- Wat is echt geaccepteerd en bruikbaar? -- Welke hypothese is gevalideerd? -- Wat is alleen code-output maar nog geen waarde? -- Welke user feedback of runtime data hebben we? - -### Retrospective - -Retrospective moet expliciet AI-systemen verbeteren: - -- Welke prompts, contextbestanden of specs verminderden rework? -- Welke modelkeuzes waren te duur of te zwak? -- Waar faalden tests/reviews te laat? -- Welke taken waren slecht voorbereid voor agents? -- Waar waren menselijke beslissingen de bottleneck? - -## Nieuwe planningshiërarchie - -Een praktisch model voor Scrum4Me: - -| Laag | Cadans | Doel | Output | -|---|---:|---|---| -| Product Goal / roadmap | weken-maanden | richting en waarde | product outcomes, prioriteiten | -| Sprint | 1 dag tot 1 week | focus en leerdoel | Sprint Goal, selected PBIs/stories, budget | -| AI execution run | 1-3 uur | concrete slice bouwen/verifieren | PR/diff, testresultaat, token/cost telemetry | -| Agent job | minuten-uren | taak uitvoeren | logs, patch, status, vragen | - -Voor solo/kleine teams met Scrum4Me is een dag-sprint of week-sprint met meerdere AI runs realistischer dan 4 volledige Scrum-sprints per dag. - -## Nieuwe metrics - -### Behoud, maar herinterpreteer - -- Lead time: idee/story -> geaccepteerde productie-wijziging. -- Cycle time: taak/run start -> done. -- Deployment frequency. -- Change failure rate. -- MTTR. -- Escaped defects. - -Deze blijven belangrijker dan velocity. - -### Vervang velocity als hoofdmetric - -Velocity/story points kunnen nog gespreksmateriaal zijn voor complexiteit en onzekerheid, maar niet meer als centrale capaciteitsmetric. - -Betere hoofdmetrics: - -- accepted increments per dag/week; -- validated outcomes per week; -- lead time per PBI/story; -- verification queue time; -- change failure rate na AI-runs; -- rework rate na review; -- human intervention rate; -- agent first-pass success rate. - -### Voeg token-economie toe - -Nuttige tokenmetrics: - -- tokens per accepted task; -- tokens per merged PR; -- tokens per validated outcome; -- tokens per failed/abandoned run; -- input/output/cache-token mix; -- cost per accepted task; -- cost per defect fixed; -- review minutes per 1M tokens; -- token spend by model/mode/job-kind; -- wasted tokens: output niet gebruikt, failed loops, repeated context discovery. - -Belangrijke waarschuwing: tokens zijn een input-cost, geen output-value. Gebruik ze als budget en efficiency-signaal, niet als score. - -### Nieuwe samengestelde metric - -Voor Scrum4Me zou een nuttige metric kunnen zijn: - -```text -AI Delivery Efficiency = - accepted value units - / (token cost + human review time + elapsed time + rework penalty) -``` - -In de praktijk kan dit simpeler: - -```text -accepted_tasks_per_euro -accepted_tasks_per_1M_tokens -merged_PRs_per_review_hour -validated_outcomes_per_day -``` - -## Definition of Ready voor AI - -Een story/task is AI-ready als: - -- het gewenste gedrag concreet is; -- acceptatiecriteria testbaar zijn; -- relevante files, patronen en docs bekend of vindbaar zijn; -- non-goals en scopegrenzen expliciet zijn; -- risico duidelijk is: laag/middel/hoog; -- vereiste verificatie bekend is; -- tokenbudget/model/mode is gekozen; -- open productvragen zijn beantwoord of expliciet buiten scope gezet. - -## Definition of Done voor AI - -Done betekent niet "agent heeft code geschreven". Done betekent: - -- diff/PR is geaccepteerd; -- tests/lint/typecheck/build passend bij risico zijn groen; -- security/privacy/demo-mode checks zijn gedaan waar relevant; -- menselijke review is gedaan voor risicovolle of user-facing wijzigingen; -- tokenusage/cost is gelogd; -- rework/lessons zijn teruggekoppeld naar prompt, docs of backlog; -- productwaarde is zichtbaar of meetbaar. - -## Voorstel voor Scrum4Me planning - -### 1. Sprint als focuscontainer - -Maak een sprint niet langer primair een verzameling werk voor 1-2 weken, maar een focuscontainer: - -- Sprint Goal; -- geselecteerde PBI's/stories; -- AI-run budget; -- verificatie-WIP-limiet; -- risico-policy. - -### 2. AI Runs binnen de sprint - -Voeg of gebruik een concept als `SprintRun`: - -- `PLANNED -> RUNNING -> VERIFYING -> ACCEPTED | REWORK | FAILED | ABANDONED` -- gekoppelde `ClaudeJob`s / agent jobs; -- model/mode snapshot; -- tokenbudget en werkelijk tokengebruik; -- affected stories/tasks; -- testresultaat; -- reviewbeslissing. - -### 3. Planningproces per run - -1. Selecteer een kleine slice uit de Sprint Backlog. -2. Controleer AI-ready criteria. -3. Kies model/mode/tokenbudget. -4. Start agent jobs. -5. Verzamel patch, logs, testresultaten en tokenusage. -6. Verifieer. -7. Accepteer, stuur terug voor rework, of stop de run. -8. Update backlog en metrics. - -### 4. Dashboard-shift - -Vervang klassieke burndown als primaire grafiek door: - -- token burnup vs accepted outcomes; -- verification queue; -- accepted/rework/failed runs; -- lead time distribution; -- cost per accepted task; -- change failure / rollback rate; -- remaining uncertainty per Sprint Goal. - -Burndown kan blijven als "remaining selected stories/tasks", maar niet als performance-meter. - -## Productimplicaties voor Scrum4Me - -1. Voeg token/cost telemetry toe aan `ClaudeJob` en `SprintRun`. -2. Maak AI-run planning zichtbaar op de Sprint-pagina. -3. Voeg `AI Ready` checks toe aan story/task dialogs of een planning pane. -4. Maak verification WIP expliciet: niet meer agents starten dan je kunt verifieren. -5. Voeg budgetrails toe: per sprint, per run, per task, per model. -6. Rapporteer tokenusage altijd naast outcome: token-only dashboards sturen verkeerd gedrag. -7. Maak retrospectives data-driven: prompt/context/model/test-strategie verbeteren. - -## Bronnen - -- Scrum Guide 2020 — Sprint Planning, Sprint Backlog, Sprint Goal: https://scrumguides.org/scrum-guide.html -- Microsoft Research — GitHub Copilot controlled experiment: https://www.microsoft.com/en-us/research/publication/the-impact-of-ai-on-developer-productivity-evidence-from-github-copilot/ -- Microsoft Research — three field experiments, 4.867 developers: https://www.microsoft.com/en-us/research/publication/the-effects-of-generative-ai-on-high-skilled-work-evidence-from-three-field-experiments-with-software-developers/ -- METR 2025 — experienced open-source developer RCT: https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/ -- METR 2026 — measurement redesign and concurrent-agent measurement issues: https://metr.org/blog/2026-02-24-uplift-update/ -- DORA 2025 — State of AI-assisted Software Development: https://dora.dev/research/2025/dora-report/ -- Google Research publication page for DORA 2025: https://research.google/pubs/dora-2025-state-of-ai-assisted-software-development-report/ -- Google Cloud — DORA AI Capabilities Model: https://cloud.google.com/blog/products/ai-machine-learning/introducing-doras-inaugural-ai-capabilities-model/ -- Google blog summary of DORA 2025: https://blog.google/innovation-and-ai/technology/developers-tools/dora-report-2025/ -- Digital.ai — 18th State of Agile press release: https://digital.ai/press-releases/digital-ais-18th-state-of-agile-report-marks-the-start-of-the-fourth-wave-of-software-delivery/ -- Stanford Digital Economy Lab — token consumption in agentic coding tasks: https://digitaleconomy.stanford.edu/publication/how-do-ai-agents-spend-your-money-analyzing-and-predicting-token-consumption-in-agentic-coding-tasks/ -- GitHub Docs — Copilot usage metrics: https://docs.github.com/en/enterprise-cloud@latest/copilot/reference/copilot-usage-metrics/copilot-usage-metrics -- Jellyfish — tokenmaxxing and token ROI analysis: https://jellyfish.co/blog/is-tokenmaxxing-cost-effective-new-data-from-jellyfish-explains/ -- Microsoft Research — LLM metric framework and token utilization: https://www.microsoft.com/en-us/research/articles/how-to-evaluate-llms-a-complete-metric-framework/ - diff --git a/docs/Ideas/beelink-scrum4me-server-install-and-worker-plan.md b/docs/Ideas/beelink-scrum4me-server-install-and-worker-plan.md deleted file mode 100644 index fd22de7..0000000 --- a/docs/Ideas/beelink-scrum4me-server-install-and-worker-plan.md +++ /dev/null @@ -1,605 +0,0 @@ ---- -title: "Installatieplan — Beelink Ubuntu Scrum4Me server en worker-aanpassingen" -status: draft -audience: [maintainer, operator, ai-agent] -language: nl -last_updated: 2026-05-10 ---- - -# Installatieplan — Beelink Ubuntu Scrum4Me server en worker-aanpassingen - -## Doel - -Deze notitie beschrijft de huidige Beelink-installatie en het vervolgplan om de Scrum4Me workers geschikt te maken voor drie rollen: - -```text -worker-idea -worker-implementation -worker-orchestrator -``` - -De server draait nu als LAN-host voor Scrum4Me. Productie-internettoegang met domein en HTTPS is nog een latere stap. - -## Hardware - -| Onderdeel | Waarde | -|---|---| -| Machine | Beelink mini-PC | -| CPU | Intel Core i5-12450H | -| RAM zichtbaar in Ubuntu | 16 GB | -| Max RAM volgens hardware | 32 GB | -| Disk | 468 GB bruikbaar na Ubuntu-installatie | -| IP | `192.168.0.154` | - -Opmerking: Ubuntu ziet momenteel ongeveer 16 GB RAM. De hardware meldt een maximum van 32 GB, maar dat betekent niet dat 32 GB bruikbaar/geplaatst is. - -## Huidige Installatie - -### Ubuntu - -Ubuntu Server is geïnstalleerd op de hele disk. - -Belangrijke keuzes: - -- Ubuntu Server 24.04 LTS. -- Geen Ubuntu Desktop. -- Geen LVM. -- Geen aparte GPU-drivers. -- Geen Windows dual boot meer. -- Hostname: `scrum4me-server`. -- Sleep/hibernate uitgeschakeld. -- Swapfile vergroot naar 16 GB. - -Controle: - -```bash -hostnamectl -free -h -swapon --show -df -h -``` - -### Directorystructuur - -Alle service-data staat onder: - -```text -/srv/scrum4me -``` - -Structuur: - -```text -/srv/scrum4me/postgres database data -/srv/scrum4me/repos GitHub clones -/srv/scrum4me/worker-cache worker caches -/srv/scrum4me/worker-logs worker logs -/srv/scrum4me/worker-state worker state -/srv/scrum4me/backups Postgres backups -/srv/scrum4me/compose Docker Compose files -/srv/scrum4me/caddy Caddy config/data -``` - -### Docker - -Docker Engine draait native op Ubuntu. - -Controle: - -```bash -docker run hello-world -docker compose version -``` - -### Postgres - -Postgres draait als Docker container: - -```text -container: scrum4me-postgres -image: postgres:17 -``` - -Host mapping: - -```text -127.0.0.1:5432 -> postgres:5432 -``` - -Host-app gebruikt: - -```env -DATABASE_URL="postgresql://scrum4me:<password>@127.0.0.1:5432/scrum4me" -DIRECT_URL="postgresql://scrum4me:<password>@127.0.0.1:5432/scrum4me" -``` - -Containers gebruiken: - -```env -DATABASE_URL=postgresql://scrum4me:<password>@postgres:5432/scrum4me -DIRECT_URL=postgresql://scrum4me:<password>@postgres:5432/scrum4me -``` - -DB-test: - -```bash -docker exec -e PGPASSWORD="$DBPASS" scrum4me-postgres \ - psql -h 127.0.0.1 -U scrum4me -d scrum4me \ - -c "select current_user, current_database();" -``` - -### Scrum4Me Web - -Repo: - -```text -/srv/scrum4me/repos/Scrum4Me -``` - -Build: - -```bash -cd /srv/scrum4me/repos/Scrum4Me -rm -rf .next -npm run build -``` - -Runtime: - -```text -systemd service: scrum4me-web -``` - -Service startcommand: - -```bash -npm run start -- -H 0.0.0.0 -``` - -Controle: - -```bash -systemctl status scrum4me-web --no-pager -curl -I http://127.0.0.1:3000/login -``` - -### Caddy - -Caddy draait als Docker container: - -```text -container: scrum4me-caddy -``` - -Caddy reverse proxyt: - -```text -http://192.168.0.154 -> Caddy -> 172.18.0.1:3000 -> Scrum4Me web -``` - -Caddyfile: - -```caddyfile -:80 { - reverse_proxy 172.18.0.1:3000 -} -``` - -Controle: - -```bash -curl -I http://192.168.0.154/login -docker logs --tail=50 scrum4me-caddy -``` - -### LAN Session Config - -Omdat de server nu via HTTP op LAN draait, is secure session cookie tijdelijk uitgezet. - -Env: - -```env -SESSION_COOKIE_SECURE="false" -``` - -Code-aanpassing: - -```ts -secure: process.env.SESSION_COOKIE_SECURE === 'true', -``` - -Later, bij domein + HTTPS: - -```env -SESSION_COOKIE_SECURE="true" -``` - -Daarna: - -```bash -rm -rf .next -npm run build -sudo systemctl restart scrum4me-web -``` - -### Migrations - -De database is gemigreerd. - -Belangrijke migration-notitie: - -`20260506101436_restore_todos_table` kan op een bestaande DB falen met: - -```text -relation "todos" already exists -``` - -Voor deze server is de juiste aanpak: - -```bash -npx prisma migrate resolve --applied 20260506101436_restore_todos_table -npx prisma migrate deploy -``` - -Controle: - -```bash -npx prisma migrate status -docker exec -it scrum4me-postgres psql -U scrum4me -d scrum4me -c "\dt public.users" -``` - -### Admin en Product - -Admin user is aangemaakt via: - -```bash -npx tsx scripts/create-admin.ts janpeter '<password>' -``` - -Login werkt. - -Product aanmaken werkt. - -### Backups - -Backup-script: - -```text -/srv/scrum4me/backup-postgres.sh -``` - -Script: - -```bash -#!/usr/bin/env bash -set -euo pipefail - -BACKUP_DIR="/srv/scrum4me/backups" -STAMP="$(date +%Y%m%d-%H%M%S)" -FILE="$BACKUP_DIR/scrum4me-$STAMP.sql.gz" - -mkdir -p "$BACKUP_DIR" - -docker exec scrum4me-postgres pg_dump -U scrum4me scrum4me | gzip > "$FILE" - -find "$BACKUP_DIR" -type f -name 'scrum4me-*.sql.gz' -mtime +14 -delete - -echo "backup written: $FILE" -``` - -Test: - -```bash -/srv/scrum4me/backup-postgres.sh -ls -lh /srv/scrum4me/backups -``` - -Cron: - -```cron -15 3 * * * /srv/scrum4me/backup-postgres.sh >> /srv/scrum4me/backups/backup.log 2>&1 -``` - -## Worker-Idea Installatie - -Worker compose-service: - -```text -worker-idea -container: scrum4me-worker-idea -health: http://127.0.0.1:18081/health -``` - -Belangrijke env-waarden: - -```env -SCRUM4ME_BASE_URL=http://caddy -SCRUM4ME_TOKEN=<raw Scrum4Me API token> - -DATABASE_URL=postgresql://scrum4me:<password>@postgres:5432/scrum4me -DIRECT_URL=postgresql://scrum4me:<password>@postgres:5432/scrum4me - -GH_TOKEN=<GitHub token> -GH_PRECLONE_REPOS=madhura68/Scrum4Me,madhura68/scrum4me-mcp,madhura68/scrum4me-docker - -CLAUDE_CODE_OAUTH_TOKEN=<Claude Code OAuth token> -``` - -Token-validatie: - -```bash -read -s -p "Scrum4Me token: " TOKEN; echo -curl -i -H "Authorization: Bearer $TOKEN" http://127.0.0.1:3000/api/products -unset TOKEN -``` - -Verwacht: - -```text -HTTP/1.1 200 OK -``` - -Worker health: - -```bash -curl http://127.0.0.1:18081/health -``` - -Gezonde idle-output bevat: - -```json -{ - "status": "idle", - "heartbeatAgeSeconds": 1, - "consecutiveFailures": 0 -} -``` - -## Huidige Worker-Beperking - -De Docker worker is gezond, maar Scrum4Me UI toont mogelijk nog: - -```text -geen Claude worker actief -``` - -Oorzaak: - -- De Docker health-server draait altijd. -- De daemon-loop draait altijd. -- Maar de DB-tabel `claude_workers` wordt nu alleen bijgewerkt door de MCP stdio-server. -- Die MCP stdio-server start pas binnen een echte Claude/MCP job-run. -- Bij een lege queue is de Docker worker dus idle en gezond, maar verschijnt hij niet als actieve worker in de UI. - -Controle: - -```bash -docker exec -it scrum4me-postgres psql -U scrum4me -d scrum4me \ - -c "select t.id, t.label, w.id as worker_id, w.last_seen_at from api_tokens t left join claude_workers w on w.token_id=t.id order by t.created_at desc;" -``` - -Gezonde Docker-worker maar lege presence: - -```text -label | worker_id | last_seen_at -worker-idea | | -``` - -## Worker Aanpassingsplan - -### Doelrollen - -```text -worker-idea - IDEA_GRILL - IDEA_MAKE_PLAN - PLAN_CHAT - -worker-implementation - TASK_IMPLEMENTATION - SPRINT_IMPLEMENTATION - later STORY_IMPLEMENTATION - -worker-orchestrator - PR_REVIEW - CI_TRIAGE - MERGE_CONFLICT_RESOLUTION - REPAIR_FAILED_JOB - CONTEXT_SUMMARY -``` - -## Fase 1 — Presence Fix - -### Probleem - -Worker-health is nu container-lokaal, maar UI-presence is DB-gebaseerd. - -Nu: - -```text -curl :18081/health -> online -claude_workers -> leeg -UI -> offline -``` - -### Gewenst gedrag - -Zolang de Docker daemon-loop draait, moet `claude_workers.last_seen_at` vers blijven, ook als de queue leeg is. - -### Aanpassing - -Verplaats worker-presence naar `scrum4me-docker/bin/run-one-job.ts` of naar een kleine runner-level heartbeat naast `run-agent.sh`. - -Aanbevolen: in `run-one-job.ts`, direct na `getAuth()`: - -```ts -const { userId, tokenId } = await getAuth() -await registerWorker({ userId, tokenId }) -const heartbeat = startHeartbeat({ userId, tokenId, intervalMs: 10_000 }) -``` - -In `finally`: - -```ts -heartbeat.stop() -``` - -Niet unregisteren bij normale idle-exit. Anders gaat de UI-indicator flikkeren tussen iteraties. - -### Acceptatie - -Bij lege queue: - -```bash -curl http://127.0.0.1:18081/health -``` - -toont: - -```text -status idle -``` - -En: - -```sql -select token_id, last_seen_at, now() - last_seen_at from claude_workers; -``` - -toont een recente `last_seen_at`. - -## Fase 2 — Role-Aware Workers - -### Probleem - -De huidige worker claimt elke job die beschikbaar is. Daardoor kan `worker-idea` ook implementation jobs claimen. - -### Nieuwe env - -```env -SCRUM4ME_WORKER_ROLE=idea -``` - -Toegestane waarden: - -```text -idea -implementation -orchestrator -``` - -### Claimfilter - -`tryClaimJob` krijgt een role/capability-filter. - -Mapping: - -```text -idea: - IDEA_GRILL - IDEA_MAKE_PLAN - PLAN_CHAT - -implementation: - TASK_IMPLEMENTATION - SPRINT_IMPLEMENTATION - -orchestrator: - PR_REVIEW - CI_TRIAGE - MERGE_CONFLICT_RESOLUTION - REPAIR_FAILED_JOB - CONTEXT_SUMMARY -``` - -### Acceptatie - -Test: - -- Queue bevat één `IDEA_GRILL` en één `TASK_IMPLEMENTATION`. -- Alleen `worker-idea` actief: claimt alleen `IDEA_GRILL`. -- Alleen `worker-implementation` actief: claimt alleen `TASK_IMPLEMENTATION`. -- Beide actief: ieder claimt eigen jobtype. - -## Fase 3 — DB/UI Uitbreiding - -Breid `claude_workers` uit met: - -```text -role -worker_name -container_name -last_status -last_job_id -last_error -``` - -UI toont dan: - -```text -Idea worker online / idle -Implementation worker offline -Orchestrator online / idle -``` - -## Fase 4 — Orchestrator Jobs - -Nieuwe job kinds: - -```text -PR_REVIEW -CI_TRIAGE -MERGE_CONFLICT_RESOLUTION -REPAIR_FAILED_JOB -CONTEXT_SUMMARY -``` - -Orchestrator mag: - -- PR's inspecteren. -- CI-fouten samenvatten. -- Merge conflicts analyseren. -- Repair jobs aanmaken. -- Context capsules schrijven. -- Human escalation vragen. -- Draft PR naar ready begeleiden. - -Orchestrator mag niet: - -- Vrij featurewerk implementeren. -- Dezelfde branch tegelijk wijzigen als implementation-worker. -- Auto-mergen zonder checks. -- Secrets of tokens loggen. - -## Fase 5 — Deployment - -Na code-aanpassing: - -```bash -cd /srv/scrum4me/repos/scrum4me-docker -git pull -cd /srv/scrum4me/compose -docker compose build worker-idea -docker compose up -d --force-recreate worker-idea -``` - -Checks: - -```bash -curl http://127.0.0.1:18081/health -docker logs -f scrum4me-worker-idea -docker exec -it scrum4me-postgres psql -U scrum4me -d scrum4me \ - -c "select token_id, last_seen_at from claude_workers;" -``` - -## Aanbevolen Volgorde Vanaf Nu - -1. Test één `IDEA_GRILL` job met de huidige worker. -2. Implementeer Fase 1: runner-level presence. -3. Rebuild `worker-idea`. -4. Verifieer UI online/idle bij lege queue. -5. Implementeer Fase 2: role-aware claiming. -6. Voeg `worker-implementation` toe. -7. Voeg pas daarna `worker-orchestrator` toe. - -Niet meteen drie workers starten zonder role-aware claimfilter. diff --git a/docs/Ideas/sprint-page-backlog-relationship-research.md b/docs/Ideas/sprint-page-backlog-relationship-research.md deleted file mode 100644 index 985c0fb..0000000 --- a/docs/Ideas/sprint-page-backlog-relationship-research.md +++ /dev/null @@ -1,135 +0,0 @@ ---- -title: "Advies — Product Backlog en Sprint-pagina workflow" -status: draft -audience: [product, ai-agent] -language: nl -last_updated: 2026-05-11 ---- - -# Advies — Product Backlog en Sprint-pagina workflow - -## Aanleiding - -Het bestaande plan `dit-verhaal-gaat-over-dazzling-mccarthy.md` beschrijft een nieuwe Product Backlog-workflow waarin sprint-membership via vinkjes wordt beheerd. De vraag is hoe de Sprint-pagina daarop moet aansluiten, met als doel om de huidige sprint verder samen te stellen. - -## Korte conclusie - -Maak de Product Backlog-pagina de brede plek voor backlog-refinement en sprint-scope selectie, en maak de Sprint-pagina de werkomgeving voor de huidige sprint: scope bijstellen, volgorde bepalen, taken uitwerken, assignees zetten, capaciteit bewaken en afronden. - -De twee pagina's mogen dezelfde onderliggende membership-acties gebruiken, maar ze moeten niet dezelfde primaire UI dupliceren. De Product Backlog is de product-brede selectie- en overzichtslaag. De Sprint-pagina is de sprint-specifieke uitwerkingslaag. - -## Verhouding tussen de pagina's - -| Pagina | Primaire vraag | Scope | Hoofdhandeling | -|---|---|---|---| -| Product Backlog `/products/[id]` | Wat is waardevol, wat is klaar, wat hoort bij welke sprint? | Alle PBI's/stories van het product | Refinen, ordenen, nieuwe sprintdraft maken, sprint-membership bulk selecteren | -| Sprint-pagina `/products/[id]/sprint/[sprintId]` | Hoe maken we deze sprint uitvoerbaar en af? | Een gekozen sprint | Sprint Backlog ordenen, stories aanvullen/verwijderen, taken maken, eigenaarschap/capaciteit/voortgang beheren | - -## Aanbevolen workflow - -1. Product Backlog zonder actieve sprint: klassieke refinement-view zonder vinkjes. -2. Product Backlog met nieuwe sprintdraft: metadata invullen, PBI's/stories selecteren via vinkjes, sprint aanmaken. -3. Product Backlog met actieve sprint: product-breed zien welke PBI's/stories in de sprint zitten en membership in batches aanpassen. -4. Sprint-pagina: huidige sprint verder samenstellen en uitvoeren. Het middenpaneel is de waarheid voor de huidige Sprint Backlog. Het backlogpaneel is alleen toevoer/context. - -## Consequenties voor de Sprint-pagina - -Aanbevolen aanpassing: - -- Header toont Sprint Goal, dates, status, switcher, scope-dirty teller en afronden-flow. -- Linkerpaneel hernoemen van `Product Backlog` naar iets als `Aanvullen uit backlog`; standaard filter op eligible stories: `OPEN`, niet `DONE`, geen andere `OPEN` sprint. -- Middenpaneel blijft `Sprint Backlog`: geselecteerde stories, sortering, assignee, task-progress, remove. -- Rechterpaneel blijft `Taken`: taakdecompositie, taakvolgorde, status, implementatieplan. -- Membership-mutaties op de Sprint-pagina gebruiken dezelfde serveractie als de Product Backlog: `commitSprintMembershipAction(activeSprintId, adds, removes)`. -- Scopewijzigingen zijn pending/dirty tot `Scope opslaan (N)`. Story/task-field edits blijven direct opslaan. -- Cross-sprint conflicts tonen als disabled story met tooltip. Server blijft autoritatief. -- Na start van een sprint markeer je add/remove visueel als scopewijziging, omdat dat gevolgen heeft voor burndown/rapportage. - -Wat je juist niet moet doen: - -- Geen tweede volledige PBI-tri-state bulkselectie bouwen op de Sprint-pagina. Dat hoort op de Product Backlog. -- Geen aparte sprint-membership semantiek naast het plan. `story.sprint_id` blijft unit-of-truth. -- Geen nieuwe afrondactie. De bestaande `completeSprintAction` blijft de sprint-completion-flow. - -## Andere methoden uit websearch - -### 1. Scrum Guide: why / what / how - -De Scrum Guide beschrijft Sprint Planning als drie onderwerpen: waarom is deze sprint waardevol, wat kan deze sprint gedaan worden, en hoe wordt het gekozen werk gedaan. De Sprint Backlog bestaat uit Sprint Goal, geselecteerde PBIs en een uitvoerbaar plan. - -Impliceert voor Scrum4Me: - -- Product Backlog-pagina: vooral `what`. -- Sprint-pagina: vooral `how`, plus gecontroleerde bijstelling van `what`. - -Bron: https://scrumguides.org/scrum-guide.html - -### 2. Jira-methode: sprints plannen vanuit backlog, board voor active sprint - -Jira plant sprints op het Backlog-scherm. De backlog toont werk gegroepeerd in backlog en sprints; items kunnen naar sprints worden gesleept. Na start verschijnt de sprint op het board. Jira waarschuwt ook dat add/remove in een actieve sprint scope change is. - -Impliceert voor Scrum4Me: - -- De richting van het plan is marktconform: sprint-samenstelling vanuit de backlog. -- De Sprint-pagina mag scope aanpassen, maar moet dat als scopewijziging behandelen. - -Bronnen: - -- https://support.atlassian.com/jira-software-cloud/docs/use-your-scrum-backlog/ -- https://support.atlassian.com/jira-software-cloud/docs/enable-sprints/ -- https://support.atlassian.com/jira-software-cloud/docs/plan-a-sprint/ - -### 3. Azure Boards-methode: eerst items toewijzen, daarna capaciteit checken - -Azure Boards beschrijft sprintplanning in twee delen: eerst backlog items selecteren, daarna bepalen hoe het team ontwikkelt/test, taken definiëren en capaciteit controleren. Azure toont geplande effort en capaciteit om onder- of overbelasting zichtbaar te maken. - -Impliceert voor Scrum4Me: - -- Voeg op de Sprint-pagina een lichte capacity/forecast-strip toe zodra story points, effort of taakminuten beschikbaar zijn. -- Laat de Sprint-pagina na selectie vooral helpen met taakdecompositie en load balancing. - -Bronnen: - -- https://learn.microsoft.com/en-us/azure/devops/boards/sprints/assign-work-sprint -- https://learn.microsoft.com/en-us/azure/devops/boards/sprints/adjust-work - -### 4. Backlog refinement als aparte continue praktijk - -Atlassian beschrijft refinement als doorlopend reviewen, rangschikken en verduidelijken van de Product Backlog zodat Sprint Planning soepeler loopt. - -Impliceert voor Scrum4Me: - -- Houd refinement-controls op de Product Backlog: PBI/story details, status, priority, split/merge later. -- Maak de Sprint-pagina niet de primaire plek voor product-brede refinement. - -Bron: https://www.atlassian.com/agile/scrum/backlog-refinement - -### 5. Linear cycles / Scrumban-achtige methode - -Linear cycles zijn time-boxed perioden met een vooraf bepaalde set werk, inclusief automatiseringen zoals rollover en auto-add van actieve issues. Dit is minder strikt Scrum, maar nuttig voor solo/kleine teams. - -Impliceert voor Scrum4Me: - -- Eventueel later: optionele automation "carry over unfinished stories to next sprint". -- Niet als basis voor de huidige Scrum4Me-flow, omdat Scrum4Me al Sprint Goal, Sprint Backlog en completion-semantiek heeft. - -Bron: https://linear.app/docs/use-cycles - -## Aanbevolen ontwerpkeuze - -Kies voor een hybride die dicht bij Scrum/Jira/Azure ligt: - -- Product Backlog = refinement + sprint-scope bulkselectie. -- Sprint-pagina = huidige sprint afmaken: ordenen, decomponeren, capaciteit en uitvoering. -- Eén gedeelde membership-laag in code: dezelfde conflictregels, task cascade, statusmutaties en affected-id returns. - -Dit houdt het mentale model simpel: je kunt overal zien wat in de sprint zit, maar elke pagina heeft een eigen reden om te bestaan. - -## Implementatie-notities - -1. Hergebruik `commitSprintMembershipAction` op de Sprint-pagina voor add/remove. -2. Vervang directe `addStoryToSprintAction` / `removeStoryFromSprintAction` in `SprintBoardClient` geleidelijk door een pending scope-buffer, of laat ze intern dezelfde transactiesemantiek gebruiken als tijdelijke tussenstap. -3. Fix bij die refactor ook task-cascade consistentie: remove moet `task.sprint_id = null` zetten voor taken onder verwijderde stories. -4. Gebruik de nieuwe `cross-sprint-blocks` en `sprint-membership-summary` endpoints ook op de Sprint-pagina, maar gescoped op zichtbare PBI's. -5. Voeg later capacity toe als aparte story, niet als voorwaarde voor de eerste workflow-migratie. - diff --git a/docs/adr/0000-record-architecture-decisions.md b/docs/adr/0000-record-architecture-decisions.md index 25750fb..12e6dd7 100644 --- a/docs/adr/0000-record-architecture-decisions.md +++ b/docs/adr/0000-record-architecture-decisions.md @@ -61,6 +61,6 @@ that supersedes the old one rather than editing the original. not enforced through review. - Backfilling existing decisions requires writing 5–8 retrospective ADRs for choices that were never recorded (planned in fase 6 of - [`../old/plans/docs-restructure-ai-lookup.md`](../old/plans/docs-restructure-ai-lookup.md)). + [`../plans/docs-restructure-ai-lookup.md`](../plans/docs-restructure-ai-lookup.md)). - Two templates means a per-decision choice about which to use. Mitigated by making Nygard the explicit default in `README.md`. diff --git a/docs/adr/0006-demo-user-three-layer-policy.md b/docs/adr/0006-demo-user-three-layer-policy.md index 032df22..cbcbc85 100644 --- a/docs/adr/0006-demo-user-three-layer-policy.md +++ b/docs/adr/0006-demo-user-three-layer-policy.md @@ -28,24 +28,3 @@ Write protection for the demo user is enforced at **three independent layers**: - Three enforcement sites for every new write operation — easy to miss one when adding a new feature. - Mitigation: the `DemoTooltip` pattern is documented in `docs/patterns/` and enforced in code review. - -## Updated 2026-05-12 — Exception for client-side UI preferences - -PBI-80 relaxes the policy *for client-side UI preferences only*: - -- **Allowed for demo:** product-switch and sprint-switch via URL navigation, - filters/sort, layout state (split-panes, collapsed PBIs, selections) — - routed through the in-memory `useUserSettingsStore`. -- **Why this is safe:** none of these touch the database. The demo user is a - single shared row, but each visitor's browser holds its own Zustand store - and URL state. A refresh resets to seed defaults; visitors never see each - other's choices. -- **Unchanged — three-layer enforcement still applies to:** all data mutations - (PBI/story/task/sprint create/update/delete/reorder), account fields - (username, password, email), role assignment, QR-pairing, web-push, and any - cron/webhook secrets. -- **Pattern for new demo-friendly features:** if it is UI state, route it - through `useUserSettingsStore.setPref` (which already has a demo-fork at - [stores/user-settings/store.ts:80](../../stores/user-settings/store.ts)) or - pure URL navigation via `router.push`. Never call a server action for demo. - See [docs/patterns/demo-client-state.md](../patterns/demo-client-state.md). diff --git a/docs/adr/0010-product-per-repo-cross-product-planning.md b/docs/adr/0010-product-per-repo-cross-product-planning.md deleted file mode 100644 index 533ab18..0000000 --- a/docs/adr/0010-product-per-repo-cross-product-planning.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -status: accepted -date: 2026-05-03 -decision-makers: Janpeter Visser -consulted: Claude (planning agent) -informed: alle Scrum4Me-gebruikers die met meerdere repos werken ---- - -# ADR-0010: Eén product = één repo; cross-product planning vereist (later) een Initiative-laag - -## Context and Problem Statement - -Een feature kan meerdere repositories raken — bv. de "agent merge-policy"-feature -heeft tegelijk werk nodig in `Scrum4Me` (schema, server actions, UI) én in -`scrum4me-mcp` (twee nieuwe MCP-tools). Het Scrum4Me-datamodel kent op dit -moment één `repo_url` per `Product`, en stories hangen aan één PBI dat aan één -product hangt. Hoe modelleren we werk dat structureel meerdere repos raakt? - -## Decision Drivers - -- Per-repo PR-volgorde en deploy-pipeline blijft simpel (1 PR = 1 repo). -- Auditbaarheid: de link tussen "wat is gemerged" en "in welke repo" moet - triviaal te leggen zijn. -- Het datamodel moet niet de complexiteit van een feature dragen die in 80% van - de gevallen toch single-repo is. -- Plannen wel kunnen overspannen: een idee kan zonder fricted tegelijk taken - in twee repo's beschrijven. - -## Considered Options - -- **A. Eén product = één repo, plan-overspanning via duplicate-PBI per product.** - Een feature die twee repos raakt wordt als twee PBIs vastgelegd (één in elk - product), met handmatige verwijzing tussen de twee in de description. -- **B. Multi-repo per product (`Product.repo_urls: String[]`).** - Eén product representeert een logisch "systeem" en heeft N repo's; PBIs en - stories blijven onder dat product. -- **C. Initiative-laag boven PBI**, product-onafhankelijk; een Initiative bundelt - PBIs uit verschillende producten onder één plan. - -## Decision Outcome - -**Gekozen: optie A — voor nu**, met optie C als geïdentificeerde toekomstige -uitbreiding zodra cross-repo werk regelmatig genoeg voorkomt. - -Rationale: optie B vermengt het datamodel rond een conceptueel troebele eenheid -("product = systeem" vs. "product = repo") en breekt de 1:1-aanname in -batch-enqueue, PR-gating en CI-flow. Optie C is structureel het juiste antwoord, -maar verdient een eigen feature-traject (datamodel, UI, gating-semantiek per -initiative) en wordt nu niet bevroren in een ad-hoc implementatie. - -### Consequences - -- Goed, omdat: per-repo flow simpel blijft (1 product = 1 repo = 1 PR-stack); - bestaande gating-logica (`pr_url`/`pr_merged_at` op PBI, sequentiële PBI's) - werkt zonder aanpassing. -- Goed, omdat: cross-repo werk wordt expliciet zichtbaar als gespiegelde PBIs - in beide producten — geen verborgen koppelingen. -- Slecht, omdat: voor cross-repo features moet een mens nu zelf twee PBIs - aanmaken en de afhankelijkheidsvolgorde tussen ze (bv. "MCP eerst, Scrum4Me - daarna") in de descriptions documenteren. Plan-drift tussen de twee PBIs is - een reëel risico. -- Slecht, omdat: er komt geen "één klik = één feature klaar" flow voor - cross-repo werk; de PB-owner moet over twee producten heen schedulen en - mergen. - -### Confirmation - -Bevestiging dat deze ADR effectief is: - -1. Producten in Scrum4Me hebben elk precies één `repo_url`. Geen - schema-wijziging die `repo_urls` introduceert. -2. PBIs voor cross-repo werk verwijzen in hun description expliciet naar de - spiegel-PBI in het andere product (id-link). -3. Er staat een open backlog-item `Cross-product planning via Initiative-laag` - in product Scrum4Me als trigger om optie C te realiseren wanneer de pijn - van duplicatie te groot wordt. - -## Pros and Cons of the Options - -### A. Eén product = één repo, duplicate-PBI - -- Goed: minimaal datamodel-veranderingen. -- Goed: PR-flow, batch-gating, deploy-pijplijn blijven 1:1 met repo. -- Goed: per-product permissies en token-scoping blijven simpel. -- Neutraal: cross-product werk vereist twee PBIs (zichtbaar werk, niet verborgen). -- Slecht: dubbel onderhoud aan plan-tekst; risico op divergentie. - -### B. Multi-repo per product - -- Goed: één plek voor alles wat bij een "systeem" hoort. -- Slecht: PR-gating per PBI moet plotseling N repos tegelijk modelleren. -- Slecht: deploy-volgorde (eerst MCP, dan Scrum4Me) zit niet in het datamodel - → ad-hoc encoded in description of CI. -- Slecht: vervaagt wat een "product" is — repo-technisch of business-technisch. - -### C. Initiative-laag boven PBI - -- Goed: structureel correct — werk dat de scope van één product overstijgt - krijgt zijn eigen niveau. -- Goed: per-product gating en flow blijven simpel; alleen de Initiative - orkestreert. -- Slecht: feature op zich (UI, datamodel, gating-semantiek per initiative, - velocity-rapportage) — significante investering vóór de eerste praktische - payoff. -- Slecht: introduceert een vierde hiërarchielaag (Initiative → PBI → Story → - Task), met UI-druk om die zichtbaar te maken. - -## More Information - -- Verwante PBIs: - - Scrum4Me product, PBI `Agent merge-policy: geen auto-merge, sequentieel per - PBI` (id `cmoppwpwu000evt17ev2c4oo4`) — gebruikt deze ADR als grondvest voor - de gating per single-repo PR. - - scrum4me-mcp product, PBI `MCP: set_pbi_pr & mark_pbi_pr_merged voor - sequentiële PBI-gating` (id `cmoprewcf000qvt17pf42t0ig`) — concrete - spiegel-PBI van bovenstaande, in het MCP-repo. -- Wanneer optie C overwegen: zodra er tegelijk drie of meer "spiegel-PBIs" open - staan op verschillende producten, of wanneer de afhankelijkheidsvolgorde - tussen ze leidt tot meer dan één gemiste merge per maand. -- Re-visit: in de retro na de eerste vier cross-repo features. diff --git a/docs/adr/0011-code-volgordesleutel-stories-taken.md b/docs/adr/0011-code-volgordesleutel-stories-taken.md deleted file mode 100644 index b3d72b1..0000000 --- a/docs/adr/0011-code-volgordesleutel-stories-taken.md +++ /dev/null @@ -1,58 +0,0 @@ -# ADR-0011: code is de bindende volgordesleutel voor stories en taken; priority is label - -## Status - -accepted - -## Context - -Vóór dit besluit werden stories en taken geordend op `[priority ASC, sort_order ASC]`. -Gebruikers konden de volgorde via drag-and-drop aanpassen (float-insertion in `sort_order`). -Dit leidde tot meerdere problemen: - -1. **Onvoorspelbare uitvoervolgorde voor de AI-agent**: een agent die taken aanmaakt via - `create_task` (in call-volgorde) zag die volgorde ongedaan gemaakt zodra een andere - agent of gebruiker de `priority` aanpaste. -2. **Divergentie tussen `code` en `sort_order`**: `code` reflecteerde de aanmaak-volgorde - (`T-1`, `T-2`, …), maar `sort_order` kon na herordening compleet anders zijn. -3. **DnD-reorder voor stories/taken was overbodig**: de enige betekenisvolle volgorde voor - een AI-gedreven sprint is de creatie-volgorde van taken — handmatige herordening voegde - verwarring toe zonder toegevoegde waarde. - -## Beslissing - -- **`code` is de bindende volgordesleutel** voor stories en taken. -- **`sort_order` is een numerieke spiegel van `code`**, berekend via `parseCodeNumber(code)` - uit `lib/code.ts`. Hierbij extraheert `parseCodeNumber` het trailertal van de code-string - (bijv. `"ST-042"` → `42`, `"T-7"` → `7`). -- **`sort_order` wordt automatisch gezet** bij `create` (server berekent de waarde) en bij - code-edit (PATCH met nieuw `code`). Sprint-membership-acties laten `sort_order` ongemoeid. -- **Drag-and-drop herordening van stories en taken is verwijderd.** Enkel PBI-ordering - gebruikt nog float-insertion (zie ADR-0002). -- **`priority` is een puur label** (urgentie-aanduiding voor de gebruiker). Geen enkele - `orderBy` op stories of taken gebruikt nog `priority`. - -## Gevolgen - -### Positief - -- De uitvoervolgorde van taken is deterministisch en gelijk aan de aanroep-volgorde van - `create_task` — agents kunnen dit betrouwbaar afleiden zonder extra queries. -- `code` en `sort_order` zijn altijd in sync — geen divergentie meer na herordening. -- Minder complexiteit: geen reorder-route, geen reorder-server-actions, geen DnD-context - in backlog-story-panel en task-panel. - -### Negatief - -- Gebruikers kunnen de volgorde van stories en taken niet handmatig herordenen via slepen. - Volgorde aanpassen vereist nu het wijzigen van de `code` (of het aanmaken in de gewenste - volgorde). Dit is een bewuste trade-off ten gunste van voorspelbaarheid voor de agent. -- `parseCodeNumber` retourneert `0` voor codes zonder numeriek suffix (bijv. `"CUSTOM-FOO"`). - Zulke codes clusteren bij positie 0 — vermijdbaar door codes met een numeriek suffix te - gebruiken. - -## Zie ook - -- [ADR-0002: float sort_order voor drag-and-drop (PBI-ordering)](./0002-float-sort-order.md) -- [docs/patterns/sort-order.md](../patterns/sort-order.md) — implementatiepatroon -- `lib/code.ts` — `parseCodeNumber`-helper diff --git a/docs/api.md b/docs/api.md index c8796e4..4065a47 100644 --- a/docs/api.md +++ b/docs/api.md @@ -96,7 +96,7 @@ curl -H "Authorization: Bearer $TOKEN" https://scrum4me.app/api/products ### `GET /api/products/:id/claude-context` -Bundled context voor Claude Code: product, actieve sprint, volgende story (met tasks) en open ideas van de tokengebruiker — in één call. +Bundled context voor Claude Code: product, actieve sprint, volgende story (met tasks) en open todos van de tokengebruiker — in één call. **Response (200):** ```json @@ -111,13 +111,13 @@ Bundled context voor Claude Code: product, actieve sprint, volgende story (met t "priority", "sort_order", "status" } ] } | null, - "open_ideas": [ - { "id", "code", "title", "description", "status", "created_at" } + "open_todos": [ + { "id", "title", "description", "created_at" } ] } ``` -`open_ideas` bevat ideeën van de gebruiker voor dit product die niet gearchiveerd zijn en nog niet de status `PLANNED` hebben (= nog niet als PBI gepromoveerd). Gelimiteerd op 50 items, gesorteerd op `created_at` asc. Demo-tokens kunnen dit endpoint lezen. +`open_todos` is gelimiteerd op 50 items, gesorteerd op `created_at` asc. Demo-tokens kunnen dit endpoint lezen. ```bash curl -H "Authorization: Bearer $TOKEN" \ diff --git a/docs/api/rest-contract.md b/docs/api/rest-contract.md index b4f5871..4065a47 100644 --- a/docs/api/rest-contract.md +++ b/docs/api/rest-contract.md @@ -29,15 +29,6 @@ De API gebruikt **lowercase** statussen. De database gebruikt UPPER_SNAKE; de ve | Task status | `todo`, `in_progress`, `review`, `done` | | Story status | `open`, `in_sprint`, `done` | -## Entity codes - -PBI's, stories en tasks hebben elk een verplichte `code` (max 30 chars, regex `^[A-Za-z0-9._-]+$`) die als stabiele identifier dient binnen het product: - -- **Auto-generatie** wanneer niet meegegeven: `PBI-N`, `ST-N` (3-digit padded), `T-N` — eigen sequence per product. -- **Uniek per `(product_id, code)`** voor alle drie entiteiten. -- **Stabiel bij re-parenting**: een task die naar een andere story wordt verplaatst behoudt zijn `code` (Jira-stijl). -- POST-body `code` is **optioneel** (server vult bij ontbreken); response bevat `code` altijd. - ## Foutcodes | Code | Betekenis | @@ -105,7 +96,7 @@ curl -H "Authorization: Bearer $TOKEN" https://scrum4me.app/api/products ### `GET /api/products/:id/claude-context` -Bundled context voor Claude Code: product, actieve sprint, volgende story (met tasks) en open ideas van de tokengebruiker — in één call. +Bundled context voor Claude Code: product, actieve sprint, volgende story (met tasks) en open todos van de tokengebruiker — in één call. **Response (200):** ```json @@ -120,13 +111,13 @@ Bundled context voor Claude Code: product, actieve sprint, volgende story (met t "priority", "sort_order", "status" } ] } | null, - "open_ideas": [ - { "id", "code", "title", "description", "status", "created_at" } + "open_todos": [ + { "id", "title", "description", "created_at" } ] } ``` -`open_ideas` bevat ideeën van de gebruiker voor dit product die niet gearchiveerd zijn en nog niet de status `PLANNED` hebben (= nog niet als PBI gepromoveerd). Gelimiteerd op 50 items, gesorteerd op `created_at` asc. Demo-tokens kunnen dit endpoint lezen. +`open_todos` is gelimiteerd op 50 items, gesorteerd op `created_at` asc. Demo-tokens kunnen dit endpoint lezen. ```bash curl -H "Authorization: Bearer $TOKEN" \ @@ -151,7 +142,7 @@ Hoogst geprioriteerde open story in de actieve sprint. "tasks": [ { "id": "...", - "code": "T-42", + "code": "ST-356.1", "title": "Store stores/solo-store.ts", "description": "...", "implementation_plan": null, @@ -169,7 +160,7 @@ Hoogst geprioriteerde open story in de actieve sprint. ### `GET /api/sprints/:id/tasks` -Lijst taken van de sprint, geordend op `(story.sort_order, task.sort_order)` — code-volgorde, geen priority. +Lijst taken van de sprint, geordend op `(story.sort_order, task.priority, task.sort_order)`. **Query params:** `?limit=N` (default 10, max 50) @@ -193,6 +184,19 @@ Lijst taken van de sprint, geordend op `(story.sort_order, task.sort_order)` — --- +### `PATCH /api/stories/:id/tasks/reorder` + +Volgorde van taken binnen een story aanpassen. + +**Body:** +```json +{ "task_ids": ["task-id-a", "task-id-b", "task-id-c"] } +``` + +Alle IDs moeten bij de story horen. **Foutcodes:** `422` bij Zod-fouten of als een task_id niet tot de story behoort. + +--- + ### `PATCH /api/tasks/:id` Status of implementation_plan bijwerken. Minstens één van beide is verplicht. @@ -514,38 +518,6 @@ curl -X POST -H "Authorization: Bearer $CRON_SECRET" \ --- -## Workspace store endpoint audit (PBI-74) - -`product-workspace-store` heeft vier `ensure*Loaded`-loaders. Deze tabel -documenteert welke routes al bestaan en welke in Story 7 (T-870) toegevoegd -worden. Tot dan retourneert de stub-default in vitest een lege response. - -| Loader | URL | Status | Op te leveren in | -|---|---|---|---| -| `ensureProductLoaded(productId)` | `GET /api/products/:id/backlog` | **ontbreekt** | T-870 (Story 7) | -| `ensurePbiLoaded(pbiId)` | `GET /api/pbis/:id/stories` | **ontbreekt** (en `/api/pbis` route-folder bestaat nog niet) | T-870 (Story 7) | -| `ensureStoryLoaded(storyId)` | `GET /api/stories/:id/tasks` | **ontbreekt** | T-870 (Story 7) | -| `ensureTaskLoaded(taskId)` | `GET /api/tasks/:id` | **ontbreekt** (alleen `PATCH` bestaat) | T-870 (Story 7) | - -Vereisten voor de toe te voegen routes: - -- Auth via `authenticateApiRequest` (Bearer-token), conform bestaande patroon. -- Access-control via `getAccessibleProduct(productId, userId)` uit - `lib/product-access.ts` waar de route product-context heeft. -- `export const dynamic = 'force-dynamic'` zodat Next geen response-cache - introduceert (T-869 in Story 7). -- Response-shape: - - `GET /api/products/:id/backlog` → `ProductBacklogSnapshot` (`{ product?, pbis[], storiesByPbi, tasksByStory }`). - - `GET /api/pbis/:id/stories` → `BacklogStory[]`. - - `GET /api/stories/:id/tasks` → `BacklogTask[]`. - - `GET /api/tasks/:id` → `TaskDetail` (extends `BacklogTask` met `_detail: true` plus extra velden zoals `implementation_plan`, `acceptance_criteria`, `requires_opus`, `estimated_minutes`). -- Type-bron: `stores/product-workspace/types.ts`. - -Auth/access-control wijzigt niet — de rearchitecture raakt alleen -client-state, niet serverlaag-security. - ---- - ## Voorbeeldworkflow voor Claude Code 1. **Probe:** `GET /api/health?db=1` — bevestig dat de service en DB bereikbaar zijn. diff --git a/docs/architecture.md b/docs/architecture.md index 5387082..8ada2a6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -18,5 +18,3 @@ last_updated: 2026-05-03 | QR-pairing login flow | [architecture/qr-pairing.md](./architecture/qr-pairing.md) | | Claude ↔ User vraag-kanaal | [architecture/claude-question-channel.md](./architecture/claude-question-channel.md) | | Projectstructuur, Stores, Realtime, Job queue | [architecture/project-structure.md](./architecture/project-structure.md) | -| Sprint execution modes (PER_TASK vs SPRINT_BATCH) | [architecture/sprint-execution-modes.md](./architecture/sprint-execution-modes.md) | -| Product Backlog page — workflow & states | [architecture/product-backlog-workflow.md](./architecture/product-backlog-workflow.md) | diff --git a/docs/architecture/auth-and-sessions.md b/docs/architecture/auth-and-sessions.md index af4f069..4d633c3 100644 --- a/docs/architecture/auth-and-sessions.md +++ b/docs/architecture/auth-and-sessions.md @@ -187,7 +187,7 @@ NODE_ENV="development" **CI/CD:** GitHub Actions → lint + typecheck + `prisma validate` op elke PR; Vercel deploy automatisch bij merge naar `main` **Database (cloud):** Neon — migraties via `prisma migrate deploy` in de Vercel build-stap **Database (lokaal):** Neon (gratis tier) — `npx prisma db push` synchroniseert schema -**Prisma generatie:** `prisma generate` (single client generator) +**Prisma generatie:** CI/deployment gebruikt `prisma generate --generator client`; `npm run db:erd` is alleen lokaal en bouwt ook `docs/assets/erd.svg` **Seeding:** `npx prisma db seed` laadt de testdata uit het Product Backlog document ### Deployment checklist (pre-launch) diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index befa797..7051a38 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -3,34 +3,27 @@ title: "Data Model & Prisma Schema" status: active audience: [maintainer, contributor] language: nl -last_updated: 2026-05-08 +last_updated: 2026-05-03 related: [auth-and-sessions.md](./auth-and-sessions.md) --- ## Datamodel -> Bron van waarheid is [`prisma/schema.prisma`](../../prisma/schema.prisma); dit document samenvat de tabellen en sleutelinvarianten. Bij twijfel wint het schema. - ### `users` | Kolom | Type | Constraints | Noten | |---|---|---|---| -| id | String (cuid) | PK | | +| id | String (cuid) | PK | Gegenereerd door Prisma | | username | String | unique, not null, min 3 | Inlognaam | -| email | String? | unique | Optioneel; gebruikt voor wachtwoord-reset-flows | | password_hash | String | not null | bcrypt hash (cost factor 12) | | is_demo | Boolean | default false | Demo-gebruiker heeft read-only rechten | -| bio | String? | max 160 | Korte profielomschrijving | -| bio_detail | String? | max 2000 | Uitgebreide profielbeschrijving | -| must_reset_password | Boolean | default false | Forceert wachtwoord-reset bij volgende login | -| avatar_data | Bytes? | | Profielfoto als WebP bytea (max 700×700) | -| active_product_id | String? | FK → products (SetNull) | Persistente actieve PB-keuze (M9) | -| idea_code_counter | Int | default 0 | Sequentiële teller voor user-scoped idea-codes | -| min_quota_pct | Int | default 20 | Worker stand-by-drempel (M13 quota-check) | +| bio | String | nullable, max 160 | Korte profielomschrijving | +| bio_detail | String | nullable, max 2000 | Uitgebreide profielbeschrijving | +| avatar_data | Bytes | nullable | Profielfoto als WebP bytea (max 700×700) | | created_at | DateTime | default now() | | -| updated_at | DateTime | auto-update | Cache-buster voor avatar-URL | +| updated_at | DateTime | auto-update | Gebruikt als cache-buster voor avatar-URL | -**Indexes:** `username` (unique), `email` (unique), `active_product_id` +**Indexes:** `username` (unique lookup bij inloggen) --- @@ -39,9 +32,10 @@ related: [auth-and-sessions.md](./auth-and-sessions.md) | Kolom | Type | Constraints | Noten | |---|---|---|---| | id | String (cuid) | PK | | -| user_id | String | FK → users (Cascade) | | -| role | Enum | `PRODUCT_OWNER \| SCRUM_MASTER \| DEVELOPER \| ADMIN` | | +| user_id | String | FK → users, not null | | +| role | Enum | PRODUCT_OWNER \| SCRUM_MASTER \| DEVELOPER | | +**Indexes:** `(user_id)` — meerdere rollen per gebruiker **Constraint:** unique `(user_id, role)` --- @@ -51,13 +45,13 @@ related: [auth-and-sessions.md](./auth-and-sessions.md) | Kolom | Type | Constraints | Noten | |---|---|---|---| | id | String (cuid) | PK | | -| user_id | String | FK → users (Cascade) | | -| token_hash | String | unique, not null | SHA-256 hash van het token | -| label | String? | | Bijv. "Claude Code — laptop" | +| user_id | String | FK → users, not null | | +| token_hash | String | not null | SHA-256 hash van het token | +| label | String | nullable | Bijv. "Claude Code — laptop" | | created_at | DateTime | default now() | | -| revoked_at | DateTime? | | Null = actief | +| revoked_at | DateTime | nullable | Null = actief | -**Indexes:** `token_hash` (unique). Eén token kan max. één `claude_workers`-record hebben. +**Indexes:** `token_hash` (lookup bij elke API-aanroep — moet snel zijn) --- @@ -66,20 +60,17 @@ related: [auth-and-sessions.md](./auth-and-sessions.md) | Kolom | Type | Constraints | Noten | |---|---|---|---| | id | String (cuid) | PK | | -| user_id | String | FK → users (Cascade) | Eigenaar | -| name | String | not null | Uniek per gebruiker | -| code | String? | max 30 | Optionele afkorting; uniek per gebruiker als gezet | -| description | String? | | | -| repo_url | String? | | Gevalideerde URL | -| definition_of_done | String | not null | Vaste instelling per product | -| auto_pr | Boolean | default false | Automatische PR creëren na sprint-completion | -| pr_strategy | Enum | `SPRINT \| STORY \| SPRINT_BATCH`, default `SPRINT` | Granulariteit voor auto-PR | +| user_id | String | FK → users, not null | | +| name | String | not null, max 200 | Uniek per gebruiker | +| description | String | nullable, max 1000 | | +| repo_url | String | nullable | Gevalideerde URL | +| definition_of_done | String | not null, max 500 | Vaste instelling per product | | archived | Boolean | default false | | | created_at | DateTime | default now() | | | updated_at | DateTime | auto-update | | **Indexes:** `(user_id, archived)` — standaard query filtert op actieve producten -**Constraints:** unique `(user_id, name)`, unique `(user_id, code)` +**Constraint:** unique `(user_id, name)` --- @@ -88,22 +79,19 @@ related: [auth-and-sessions.md](./auth-and-sessions.md) | Kolom | Type | Constraints | Noten | |---|---|---|---| | id | String (cuid) | PK | | -| product_id | String | FK → products (Cascade) | | -| code | String | max 30, not null | Verplicht; auto-gegenereerd of handmatig | -| title | String | not null | | -| description | String? | | | -| priority | Int | 1–4 | 1 = Kritiek, 4 = Laag | +| product_id | String | FK → products (cascade delete) | | +| code | String | nullable, max 30 | Auto-gegenereerd of handmatig | +| title | String | not null, max 200 | | +| description | String | nullable, max 2000 | | +| priority | Int | 1–4, not null | 1 = Kritiek, 4 = Laag | | sort_order | Float | not null | Float voor volgorde tussen items zonder renummering | -| status | Enum | `READY \| BLOCKED \| FAILED \| DONE`, default `READY` | Auto-promotie naar DONE bij sprint-close | -| pr_url | String? | | URL van de PR die deze PBI dekt (PBI-strategie) | -| pr_merged_at | DateTime? | | Gezet wanneer de PR daadwerkelijk gemerged is | +| status | Enum | READY \| BLOCKED \| DONE, default READY | Auto-promotie naar DONE bij sprint-close (zie hieronder) | | created_at | DateTime | default now() | | | updated_at | DateTime | auto-update | | -**Indexes:** `(product_id, priority, sort_order)`, `(product_id, status)` -**Constraint:** unique `(product_id, code)` +**Indexes:** `(product_id, priority, sort_order)` — standaard query voor het gesplitste scherm; `(product_id, status)` — voor het statusfilter op de Product Backlog -**Cascade-regel (sprint-close):** wanneer een Sprint wordt afgerond via `completeSprintAction` en alle stories van een PBI eindigen op DONE, zet diezelfde transactie de PBI-status op DONE. Promotie alléén — een DONE-PBI wordt nooit automatisch teruggezet. Stories die niet in deze Sprint zaten worden meegerekend op hun huidige DB-status. Een PBI zonder stories blijft READY. +**Cascade-regel (sprint-close):** wanneer een Sprint wordt afgerond via `completeSprintAction` en alle stories van een PBI eindigen op DONE (na toepassing van de afsluitbeslissingen), zet diezelfde transactie de PBI-status op DONE. Promotie alléén — een PBI op DONE wordt nooit automatisch teruggezet. Stories die niet in deze Sprint zaten worden meegerekend op hun huidige DB-status. Een PBI zonder stories blijft READY. --- @@ -112,24 +100,21 @@ related: [auth-and-sessions.md](./auth-and-sessions.md) | Kolom | Type | Constraints | Noten | |---|---|---|---| | id | String (cuid) | PK | | -| pbi_id | String | FK → pbis (Cascade) | | +| pbi_id | String | FK → pbis (cascade delete) | | | product_id | String | FK → products | Denormalisatie voor snellere queries | -| sprint_id | String? | FK → sprints | Null = in Product Backlog | -| assignee_id | String? | FK → users (SetNull) | Story-claim op het Solo-bord | -| code | String | max 30, not null | Auto-gegenereerd of handmatig | -| title | String | not null | | -| description | String? | | | -| acceptance_criteria | String? | | | -| priority | Int | 1–4 | | +| sprint_id | String | FK → sprints, nullable | Null = in Product Backlog | +| title | String | not null, max 200 | | +| description | String | nullable, max 2000 | | +| acceptance_criteria | String | nullable, max 2000 | | +| priority | Int | 1–4, not null | | | sort_order | Float | not null | | -| status | Enum | `OPEN \| IN_SPRINT \| DONE \| FAILED`, default `OPEN` | | +| status | Enum | OPEN \| IN_SPRINT \| DONE | | | created_at | DateTime | default now() | | | updated_at | DateTime | auto-update | | -**Indexes:** `(pbi_id, priority, sort_order)`, `(sprint_id, sort_order)`, `(product_id, status)`, `(sprint_id, assignee_id)` -**Constraint:** unique `(product_id, code)` +**Indexes:** `(pbi_id, priority, sort_order)`, `(sprint_id, sort_order)`, `(product_id, status)` -**Auto-promotie/demotie via task-status:** zodra alle tasks van een story op `DONE` staan en de story-status nog niet `DONE` is, promoot dezelfde transactie de story naar `DONE`. Wordt een task van een `DONE`-story heropend, dan demoot de story terug naar `IN_SPRINT` — niet naar `OPEN` (dat zou "terug in productbacklog" betekenen, een sprint-management-actie). Logica zit in [lib/tasks-status-update.ts](../../lib/tasks-status-update.ts) en wordt aangeroepen door alle drie de task-status-write-paden (`updateTaskStatusAction`, `saveTask` edit-mode, REST `PATCH /api/tasks/[id]`). +**Auto-promotie/demotie via task-status:** zodra alle tasks van een story op `DONE` staan en de huidige story-status nog niet `DONE` is, promoot dezelfde transactie de story naar `DONE`. Wordt een task van een `DONE`-story uit `DONE` getrokken (heropening), dan demoot de story terug naar `IN_SPRINT` — niet naar `OPEN`, want `OPEN` betekent "terug in productbacklog" en is een sprint-management-actie. De logica zit in [lib/tasks-status-update.ts](../../lib/tasks-status-update.ts) en wordt aangeroepen door alle drie de task-status-write-paden (`updateTaskStatusAction`, `saveTask` edit-mode, REST `PATCH /api/tasks/[id]`). --- @@ -138,16 +123,15 @@ related: [auth-and-sessions.md](./auth-and-sessions.md) | Kolom | Type | Constraints | Noten | |---|---|---|---| | id | String (cuid) | PK | | -| story_id | String | FK → stories (Cascade) | | -| type | Enum | `IMPLEMENTATION_PLAN \| TEST_RESULT \| COMMIT` | | -| content | String | not null | | -| status | Enum | `PASSED \| FAILED`? | Alleen bij type `TEST_RESULT` | -| commit_hash | String? | | Alleen bij type `COMMIT` | -| commit_message | String? | | Alleen bij type `COMMIT` | -| metadata | Json? | | Vrije bag voor bron-info (bv. agent, model_id) | +| story_id | String | FK → stories (cascade delete) | | +| type | Enum | IMPLEMENTATION_PLAN \| TEST_RESULT \| COMMIT | | +| content | String | not null | Tekst van plan of testuitvoer | +| status | Enum | PASSED \| FAILED, nullable | Alleen bij type TEST_RESULT | +| commit_hash | String | nullable | Alleen bij type COMMIT | +| commit_message | String | nullable | Alleen bij type COMMIT | | created_at | DateTime | default now() | | -**Indexes:** `(story_id, created_at)` +**Indexes:** `(story_id, created_at)` — chronologische weergave in de UI --- @@ -156,43 +140,14 @@ related: [auth-and-sessions.md](./auth-and-sessions.md) | Kolom | Type | Constraints | Noten | |---|---|---|---| | id | String (cuid) | PK | | -| product_id | String | FK → products (Cascade) | | -| code | String | max 30, not null | Auto-gegenereerd `SP-N` per product (PBI-59) | -| sprint_goal | String | not null | | -| status | Enum | `OPEN \| CLOSED \| ARCHIVED \| FAILED`, default `OPEN` | | -| start_date | Date? | | Optionele planningsmetadata | -| end_date | Date? | | Optionele planningsmetadata | +| product_id | String | FK → products (cascade delete) | | +| sprint_goal | String | not null, max 500 | | +| status | Enum | ACTIVE \| COMPLETED | | | created_at | DateTime | default now() | | -| completed_at | DateTime? | | Wordt gezet bij overgang naar CLOSED | +| completed_at | DateTime | nullable | | -**Indexes:** `(product_id, status)` -**Constraint:** unique `(product_id, code)` - -**Eén product, meerdere sprints (PBI-63):** een product kan tegelijk meer dan één sprint hebben. `OPEN` is geen exclusieve status; de sprint-switcher in de product-header laat de gebruiker tussen sprints kiezen. Stories in `IN_SPRINT` linken via `sprint_id` naar één specifieke sprint. - ---- - -### `sprint_runs` - -Eén `sprint_runs`-record per uitvoering van de SPRINT_IMPLEMENTATION-flow (PBI-46/47/50). Houdt status, branch en chained retries bij wanneer een run is gepauzeerd of mislukt. - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| sprint_id | String | FK → sprints (Cascade) | | -| started_by_id | String | FK → users | Wie de run startte | -| status | Enum | `QUEUED \| RUNNING \| PAUSED \| DONE \| FAILED \| CANCELLED` | | -| pr_strategy | Enum | `SPRINT \| STORY \| SPRINT_BATCH` | Snapshot van de strategie bij start | -| branch | String? | | Werkbranch voor de run | -| pr_url | String? | | | -| started_at / finished_at | DateTime? | | | -| failure_reason | String? | | Vrij tekstveld bij FAILED/CANCELLED | -| failed_task_id | String? | FK → tasks (SetNull) | Eerste task die de run brak (cascade-FAIL) | -| pause_context | Json? | | Gevalideerd door Zod (`lib/sprint-run/pause-context.ts`) | -| previous_run_id | String? | unique, FK → sprint_runs (SetNull) | Chain naar een eerdere run | -| created_at / updated_at | DateTime | | | - -**Indexes:** `(sprint_id, status)`, `(started_by_id, status)` +**Indexes:** `(product_id, status)` — query voor actieve Sprint per product +**Constraint:** Max. 1 actieve Sprint per product (gehandhaafd in applicatielaag) --- @@ -201,269 +156,51 @@ Eén `sprint_runs`-record per uitvoering van de SPRINT_IMPLEMENTATION-flow (PBI- | Kolom | Type | Constraints | Noten | |---|---|---|---| | id | String (cuid) | PK | | -| story_id | String | FK → stories (Cascade) | | -| product_id | String | FK → products (Cascade) | Denormalisatie voor product-scoped queries | -| sprint_id | String? | FK → sprints | Denormalisatie; geërfd van story bij sprint-toevoeging | -| code | String | max 30, not null | Auto-gegenereerd `T-N` per product | -| title | String | not null | | -| description | String? | | | -| implementation_plan | String? | | Opgeslagen via Server Action of `PATCH /api/tasks/:id` | -| priority | Int | 1–4 | | +| story_id | String | FK → stories (cascade delete) | | +| sprint_id | String | FK → sprints, nullable | Denormalisatie voor snellere queries | +| title | String | not null, max 200 | | +| description | String | nullable, max 1000 | | +| implementation_plan | String | nullable | Opgeslagen door Claude Code MCP via `PATCH /api/tasks/:id` | +| priority | Int | 1–4, not null | | | sort_order | Float | not null | | -| status | Enum | `TO_DO \| IN_PROGRESS \| REVIEW \| DONE \| FAILED \| EXCLUDED`, default `TO_DO` | `EXCLUDED` slaat verify-skip op tijdens een sprint-run | -| verify_only | Boolean | default false | Run-mode: alleen verifiëren, niet implementeren | -| verify_required | Enum | `ALIGNED \| ALIGNED_OR_PARTIAL \| ANY`, default `ALIGNED_OR_PARTIAL` | Drempel waarop een job's verify_result als acceptabel telt | -| repo_url | String? | | Optionele override van `product.repo_url` voor tasks die in een andere repo wonen | +| status | Enum | TO_DO \| IN_PROGRESS \| REVIEW \| DONE | | | created_at | DateTime | default now() | | | updated_at | DateTime | auto-update | | -**Indexes:** `(story_id, priority, sort_order)`, `(sprint_id, status)`, `(product_id)` -**Constraint:** unique `(product_id, code)` — `code` blijft stabiel bij re-parenting (Jira-stijl) +**Indexes:** `(story_id, priority, sort_order)`, `(sprint_id, status)` --- -### `claude_jobs` - -Job-queue waarop `wait_for_job` (MCP) atomisch claimt via `FOR UPDATE SKIP LOCKED`. Eén rij per task-implementatie, idea-grill, idea-make-plan, plan-chat of sprint-run. +### `todos` | Kolom | Type | Constraints | Noten | |---|---|---|---| | id | String (cuid) | PK | | -| user_id | String | FK → users (Cascade) | | -| product_id | String | FK → products (Cascade) | | -| task_id | String? | FK → tasks (Cascade) | Bij `TASK_IMPLEMENTATION` of `SPRINT_IMPLEMENTATION` | -| idea_id | String? | FK → ideas (Cascade) | Bij `IDEA_GRILL` / `IDEA_MAKE_PLAN` | -| sprint_run_id | String? | FK → sprint_runs (SetNull) | Koppel naar de bovenliggende run | -| kind | Enum | `TASK_IMPLEMENTATION \| IDEA_GRILL \| IDEA_MAKE_PLAN \| PLAN_CHAT \| SPRINT_IMPLEMENTATION` | | -| status | Enum | `QUEUED \| CLAIMED \| RUNNING \| DONE \| FAILED \| CANCELLED \| SKIPPED` | | -| claimed_by_token_id | String? | FK → api_tokens (SetNull) | Auth-koppel voor `update_job_status` | -| claimed_at / started_at / finished_at / pushed_at | DateTime? | | Lifecycle-stempels | -| verify_result | Enum? | `ALIGNED \| PARTIAL \| EMPTY \| DIVERGENT` | Alleen voor task-/sprint-jobs | -| model_id | String? | | Anthropic model dat de agent rapporteerde | -| input_tokens / output_tokens / cache_read_tokens / cache_write_tokens | Int? | | Token-usage voor billing-overzicht | -| plan_snapshot | String? | | Bevroren plan op claim-moment | -| base_sha / head_sha / branch / pr_url | String? | | Git-context | -| summary | String? | | Vrije agent-samenvatting bij DONE | -| error | String? | | Reden bij FAILED | -| retry_count | Int | default 0 | | -| lease_until | DateTime? | | Stale-CLAIMED → terug naar QUEUED na 30 min | -| created_at / updated_at | DateTime | | | +| user_id | String | FK → users, not null | | +| product_id | String? | FK → products, nullable | Optioneel in UI; SetNull bij verwijderen product | +| title | String | not null | | +| done | Boolean | default false | | +| archived | Boolean | default false | | +| created_at | DateTime | default now() | | +| updated_at | DateTime | auto-update | | -**Indexes:** `(user_id, status)`, `(task_id, status)`, `(idea_id, status)`, `(sprint_run_id, status)`, `(status, claimed_at)`, `(status, finished_at)`, `(status, lease_until)` - ---- - -### `sprint_task_executions` - -Bevroren scope-snapshot per `SPRINT_IMPLEMENTATION`-claim (PBI-50). Bij claim wordt voor elke `TO_DO`-task in scope één `PENDING`-record gemaakt met `implementation_plan` + `verify_required` gesnapshot. Worker en gate werken uitsluitend op deze rows; latere wijzigingen aan Task hebben geen invloed op de lopende batch. - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| sprint_job_id | String | FK → claude_jobs (Cascade) | De parent SPRINT_IMPLEMENTATION-job | -| task_id | String | FK → tasks (Cascade) | | -| order | Int | not null | Volgorde binnen de batch | -| plan_snapshot | String (Text) | not null | Het bevroren implementation_plan | -| verify_required_snapshot | Enum | `ALIGNED \| ALIGNED_OR_PARTIAL \| ANY` | | -| verify_only_snapshot | Boolean | default false | | -| base_sha / head_sha | String? | | | -| status | Enum | `PENDING \| RUNNING \| DONE \| FAILED \| SKIPPED` | | -| verify_result | Enum? | `ALIGNED \| PARTIAL \| EMPTY \| DIVERGENT` | | -| verify_summary / skip_reason | String? (Text) | | | -| started_at / finished_at | DateTime? | | | -| created_at / updated_at | DateTime | | | - -**Constraint:** unique `(sprint_job_id, task_id)` -**Indexes:** `(sprint_job_id, order)` - ---- - -### `model_prices` - -Prijslookup voor Anthropic-modellen, gebruikt door de jobs-pagina om kosten te berekenen. - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| model_id | String | unique | Anthropic model-id (bv. `claude-opus-4-7`) | -| input_price_per_1m | Decimal(12,6) | | USD per 1M tokens (default) | -| output_price_per_1m | Decimal(12,6) | | | -| cache_read_price_per_1m | Decimal(12,6) | | | -| cache_write_price_per_1m | Decimal(12,6) | | | -| currency | String | default `USD` | | -| created_at / updated_at | DateTime | | | - ---- - -### `claude_workers` - -Live-presence-record per actieve agent worker. Ingevoegd bij MCP-startup, geüpdatet via `worker_heartbeat` (5s), opgeruimd bij SIGTERM. NavBar telt actieve workers op `last_seen_at < now() - 15s`. - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| user_id | String | FK → users (Cascade) | | -| token_id | String | unique, FK → api_tokens (Cascade) | Eén worker per token | -| product_id | String? | | Optioneel; gerapporteerd door de agent | -| started_at / last_seen_at | DateTime | default now() | | -| last_quota_pct | Int? | | M13 pre-flight quota-check | -| last_quota_check_at | DateTime? | | | - -**Indexes:** `(user_id, last_seen_at)` +**Indexes:** `(user_id, done, archived)` — standaard weergave filtert op actieve todo's; `(user_id, product_id)` — filteren per product --- ### `product_members` -Koppelt Developer-gebruikers aan een product backlog. De eigenaar (`products.user_id`) heeft altijd volledige toegang; via `product_members` kunnen aanvullende Developers leesrechten en schrijfrechten op stories, taken en sprints van dat product krijgen. Rollen worden niet hier opgeslagen — dat doet `user_roles`. Een gebruiker kan alleen worden toegevoegd als hij/zij de rol `DEVELOPER` heeft. - | Kolom | Type | Constraints | Noten | |---|---|---|---| | id | String (cuid) | PK | | -| product_id | String | FK → products (Cascade) | | -| user_id | String | FK → users (Cascade) | | +| product_id | String | FK → products (cascade delete) | | +| user_id | String | FK → users (cascade delete) | | | created_at | DateTime | default now() | | -**Constraint:** unique `(product_id, user_id)` -**Indexes:** `(user_id)` +**Indexes:** `(user_id)` — opzoeken van producten waarbij een gebruiker lid is +**Constraint:** unique `(product_id, user_id)` — één lidmaatschap per gebruiker per product ---- - -### `ideas` - -Idea-entity (M12) tussen losse notitie en PBI. Een idea wordt eerst _gegrilled_ (interactieve Q&A → `grill_md`), daarna gemateriaaliseerd tot een plan (`plan_md`) dat deterministisch geparseerd wordt naar PBI + stories + tasks. Vervangt de oude `todos`-tabel volledig (atomische migratie ST-1239 — todos zijn gedropt). - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| user_id | String | FK → users (Cascade) | | -| product_id | String? | FK → products (SetNull) | Primaire scope; null = unscoped capture | -| code | String | max 30, not null | Sequentieel per gebruiker via `idea_code_counter` | -| title | String | not null | | -| description | String? | max 4000 | Initiële tekst | -| grill_md | String? (Text) | | Output van IDEA_GRILL | -| plan_md | String? (Text) | | Output van IDEA_MAKE_PLAN | -| pbi_id | String? | unique, FK → pbis (SetNull) | Wordt gevuld na materialisatie | -| status | Enum | `DRAFT \| GRILLING \| GRILL_FAILED \| GRILLED \| PLANNING \| PLAN_FAILED \| PLAN_READY \| PLANNED`, default `DRAFT` | | -| archived | Boolean | default false | | -| created_at / updated_at | DateTime | | | - -**Constraint:** unique `(user_id, code)` -**Indexes:** `(user_id, archived, status)`, `(user_id, product_id)` - ---- - -### `idea_products` - -Optionele secundaire producten waar een idea ook impact heeft. - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| idea_id | String | FK → ideas (Cascade) | | -| product_id | String | FK → products (Cascade) | | -| created_at | DateTime | default now() | | - -**Constraint:** unique `(idea_id, product_id)` -**Indexes:** `(product_id)` - ---- - -### `idea_logs` - -Activiteitenlog per idea — soortgelijke rol als `story_logs` voor stories. - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| idea_id | String | FK → ideas (Cascade) | | -| type | Enum | `DECISION \| NOTE \| GRILL_RESULT \| PLAN_RESULT \| STATUS_CHANGE \| JOB_EVENT` | | -| content | String (Text) | not null | | -| metadata | Json? | | | -| created_at | DateTime | default now() | | - -**Indexes:** `(idea_id, created_at)` - ---- - -### `user_questions` - -Vrije vragen die een gebruiker aan zichzelf stelt op een idea (M12). Wordt door de Idea-detail-UI ingelezen om te helpen bij het grillen. - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| idea_id | String | FK → ideas (Cascade) | | -| user_id | String | not null | Eigenaar | -| question | String (Text) | not null | | -| answer | String? (Text) | | | -| status | Enum (lowercase) | `pending \| answered`, default `pending` | | -| created_at / updated_at | DateTime | | | - -**Indexes:** `(idea_id, status)`, `(user_id)` - ---- - -### `claude_questions` - -Persistent vraag-antwoord-kanaal van een agent (via `mcp__scrum4me__ask_user_question`) richting de actieve gebruiker (M11). LISTEN/NOTIFY pusht het antwoord terug naar de wachtende agent. Zie [architecture/claude-question-channel.md](./claude-question-channel.md). - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| story_id | String? | FK → stories (Cascade) | Eén van story/task/idea is verplicht | -| task_id | String? | FK → tasks (SetNull) | | -| idea_id | String? | FK → ideas (Cascade) | | -| product_id | String | FK → products (Cascade) | Gedenormaliseerd voor het SSE-filter | -| asked_by | String | FK → users | Token-houder = Claude | -| question | String (Text) | not null | | -| options | Json? | | `string[]` voor multi-choice; null voor free-text | -| status | String | not null | `open \| answered \| cancelled \| expired` | -| answer | String? (Text) | | | -| answered_by | String? | FK → users | | -| answered_at | DateTime? | | | -| created_at | DateTime | default now() | | -| expires_at | DateTime | not null | Default `now() + 24h`, ingesteld door MCP-tool | - -**Indexes:** `(story_id, status)`, `(idea_id, status)`, `(product_id, status)`, `(status, expires_at)` - ---- - -### `login_pairings` - -QR-pairing-flow (M10). Desktop start een pairing, telefoon scant en bevestigt; daarna kan de desktop ruilen voor een sessie. - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| secret_hash | String | not null | Hash van het pairing-secret | -| desktop_token_hash | String | not null | Hash van het pre-auth desktop-token | -| status | String | not null | `pending \| approved \| consumed \| expired` | -| user_id | String? | FK → users (SetNull) | Gezet bij approval | -| desktop_ua | String? | max 255 | UA-string van de desktop-aanvraag | -| desktop_ip | String? | max 45 | IP voor audit | -| created_at | DateTime | default now() | | -| expires_at | DateTime | not null | TTL voor afhandeling | -| approved_at / consumed_at | DateTime? | | | - -**Indexes:** `(expires_at)`, `(status, expires_at)` - ---- - -### `push_subscriptions` - -Web Push subscriptions per gebruiker, voor notificaties. - -| Kolom | Type | Constraints | Noten | -|---|---|---|---| -| id | String (cuid) | PK | | -| user_id | String | FK → users (Cascade) | | -| endpoint | String | unique, not null | Push-service endpoint | -| p256dh / auth | String | not null | VAPID keys | -| user_agent | String? | | UA bij subscribe | -| created_at / last_used_at | DateTime | default now() | | - -**Indexes:** `(user_id)` +Koppelt Developer-gebruikers aan een product backlog. De eigenaar (`products.user_id`) heeft altijd volledige toegang; via `product_members` kunnen aanvullende Developers leesrechten en schrijfrechten op stories, taken en sprints van dat product krijgen. Rollen worden niet opgeslagen in deze tabel — dat doet `user_roles`. Een gebruiker kan alleen worden toegevoegd als hij/zij de rol `DEVELOPER` heeft. --- @@ -474,7 +211,7 @@ Producttoegang is centraal gedefinieerd als: - eigenaar: `products.user_id === gebruiker.id` - teamlid: `product_members` bevat `(product_id, user_id)` -Code gebruikt hiervoor `productAccessFilter(userId)` uit `lib/product-access.ts`. Route Handlers en Server Actions mogen geen eigenaar-only filter (`user_id`) gebruiken voor product-scoped resources tenzij het expliciet om eigenaarsbeheer gaat (archiveren, teamleden beheren). +Code gebruikt hiervoor `productAccessFilter(userId)` uit `lib/product-access.ts`. Route Handlers en Server Actions mogen geen eigenaar-only filter (`user_id`) gebruiken voor product-scoped resources tenzij het expliciet om eigenaarsbeheer gaat, zoals archiveren of teamleden beheren. Schrijfoperaties volgen deze invarianten: @@ -488,32 +225,236 @@ Schrijfoperaties volgen deze invarianten: --- -## Enums (overzicht) +## Prisma Schema (excerpt) -| Enum | Waarden | -|---|---| -| `Role` | `PRODUCT_OWNER`, `SCRUM_MASTER`, `DEVELOPER`, `ADMIN` | -| `PbiStatus` | `READY`, `BLOCKED`, `FAILED`, `DONE` | -| `StoryStatus` | `OPEN`, `IN_SPRINT`, `DONE`, `FAILED` | -| `TaskStatus` | `TO_DO`, `IN_PROGRESS`, `REVIEW`, `DONE`, `FAILED`, `EXCLUDED` | -| `SprintStatus` | `OPEN`, `CLOSED`, `ARCHIVED`, `FAILED` | -| `SprintRunStatus` | `QUEUED`, `RUNNING`, `PAUSED`, `DONE`, `FAILED`, `CANCELLED` | -| `PrStrategy` | `SPRINT`, `STORY`, `SPRINT_BATCH` | -| `LogType` | `IMPLEMENTATION_PLAN`, `TEST_RESULT`, `COMMIT` | -| `TestStatus` | `PASSED`, `FAILED` | -| `ClaudeJobStatus` | `QUEUED`, `CLAIMED`, `RUNNING`, `DONE`, `FAILED`, `CANCELLED`, `SKIPPED` | -| `ClaudeJobKind` | `TASK_IMPLEMENTATION`, `IDEA_GRILL`, `IDEA_MAKE_PLAN`, `PLAN_CHAT`, `SPRINT_IMPLEMENTATION` | -| `VerifyResult` | `ALIGNED`, `PARTIAL`, `EMPTY`, `DIVERGENT` | -| `VerifyRequired` | `ALIGNED`, `ALIGNED_OR_PARTIAL`, `ANY` | -| `SprintTaskExecutionStatus` | `PENDING`, `RUNNING`, `DONE`, `FAILED`, `SKIPPED` | -| `IdeaStatus` | `DRAFT`, `GRILLING`, `GRILL_FAILED`, `GRILLED`, `PLANNING`, `PLAN_FAILED`, `PLAN_READY`, `PLANNED` | -| `IdeaLogType` | `DECISION`, `NOTE`, `GRILL_RESULT`, `PLAN_RESULT`, `STATUS_CHANGE`, `JOB_EVENT` | -| `UserQuestionStatus` | `pending`, `answered` (lowercase, niet UPPER_SNAKE) | +```prisma +// prisma/schema.prisma -> API-grens: `TaskStatus` en `StoryStatus` worden tussen DB (UPPER_SNAKE) en API (lowercase) vertaald via `lib/task-status.ts` (zie [ADR-0004](../adr/0004-status-enum-mapping.md)). +generator client { + provider = "prisma-client-js" +} + +// Database wordt bepaald via prisma.config.ts — niet hier + +enum Role { + PRODUCT_OWNER + SCRUM_MASTER + DEVELOPER +} + +enum StoryStatus { + OPEN + IN_SPRINT + DONE +} + +enum PbiStatus { + READY + BLOCKED + DONE +} + +enum TaskStatus { + TO_DO + IN_PROGRESS + REVIEW + DONE +} + +enum LogType { + IMPLEMENTATION_PLAN + TEST_RESULT + COMMIT +} + +enum TestStatus { + PASSED + FAILED +} + +enum SprintStatus { + ACTIVE + COMPLETED +} + +model User { + id String @id @default(cuid()) + username String @unique + password_hash String + is_demo Boolean @default(false) + bio String? @db.VarChar(160) + bio_detail String? @db.VarChar(2000) + avatar_data Bytes? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + roles UserRole[] + api_tokens ApiToken[] + products Product[] + todos Todo[] + product_members ProductMember[] +} + +model UserRole { + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + role Role + + @@unique([user_id, role]) +} + +model ApiToken { + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + token_hash String @unique + label String? + created_at DateTime @default(now()) + revoked_at DateTime? + + @@index([token_hash]) +} + +model Product { + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + name String + description String? + repo_url String? + definition_of_done String + archived Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + pbis Pbi[] + sprints Sprint[] + stories Story[] + todos Todo[] + members ProductMember[] + + @@unique([user_id, name]) + @@index([user_id, archived]) +} + +model Pbi { + id String @id @default(cuid()) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + code String? @db.VarChar(30) + title String + description String? + priority Int + sort_order Float + status PbiStatus @default(READY) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + stories Story[] + + @@unique([product_id, code]) + @@index([product_id, priority, sort_order]) + @@index([product_id, status]) +} + +model Story { + id String @id @default(cuid()) + pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade) + pbi_id String + product Product @relation(fields: [product_id], references: [id]) + product_id String + sprint Sprint? @relation(fields: [sprint_id], references: [id]) + sprint_id String? + title String + description String? + acceptance_criteria String? + priority Int + sort_order Float + status StoryStatus @default(OPEN) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + logs StoryLog[] + tasks Task[] + + @@index([pbi_id, priority, sort_order]) + @@index([sprint_id, sort_order]) + @@index([product_id, status]) +} + +model StoryLog { + id String @id @default(cuid()) + story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) + story_id String + type LogType + content String + status TestStatus? + commit_hash String? + commit_message String? + created_at DateTime @default(now()) + + @@index([story_id, created_at]) +} + +model Sprint { + id String @id @default(cuid()) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + sprint_goal String + status SprintStatus @default(ACTIVE) + created_at DateTime @default(now()) + completed_at DateTime? + stories Story[] + tasks Task[] + + @@index([product_id, status]) +} + +model Task { + id String @id @default(cuid()) + story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) + story_id String + sprint Sprint? @relation(fields: [sprint_id], references: [id]) + sprint_id String? + title String + description String? + implementation_plan String? + priority Int + sort_order Float + status TaskStatus @default(TO_DO) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([story_id, priority, sort_order]) + @@index([sprint_id, status]) +} + +model Todo { + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) + product_id String? + title String + done Boolean @default(false) + archived Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([user_id, done, archived]) + @@index([user_id, product_id]) +} + +model ProductMember { + id String @id @default(cuid()) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + created_at DateTime @default(now()) + + @@unique([product_id, user_id]) + @@index([user_id]) + @@map("product_members") +} +``` --- -## Prisma Schema - -De volledige, levende definitie staat in [`prisma/schema.prisma`](../../prisma/schema.prisma). diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index d213ea1..7a4fa5b 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -3,18 +3,18 @@ title: "Scrum4Me — Architecture Overview" status: active audience: [maintainer, contributor] language: nl -last_updated: 2026-05-08 +last_updated: 2026-05-03 related: [data-model.md](./data-model.md), [project-structure.md](./project-structure.md) --- -**Versie:** 0.2 — mei 2026 +**Versie:** 0.1 — april 2026 **Volgt op:** Functionele Specificatie v0.2 --- ## Architectuursamenvatting -Scrum4Me is een desktop-first Next.js 16 webapplicatie die server-side wordt gerenderd en gedeployed op Vercel. De database is PostgreSQL via Neon, aangestuurd door Prisma v7. Authenticatie is custom username/password via iron-session — geen externe auth-provider, geen verplichte e-mail. De REST API voor Claude Code-integratie loopt via Next.js Route Handlers, beveiligd met API-tokens. Drag-and-drop in de planningsschermen wordt afgehandeld door dnd-kit. Realtime-updates voor Solo-bord en Backlog gaan via Postgres `LISTEN/NOTIFY` + Server-Sent Events; geen externe broker. Een eigen MCP-server (`scrum4me-mcp`) biedt agents een job-queue (`claude_jobs`) bovenop dezelfde database. Vercel Analytics meet pageviews via de root layout; profielfoto's worden server-side verwerkt met Sharp. +Scrum4Me is een desktop-first Next.js 16 webapplicatie die server-side wordt gerenderd en gedeployed op Vercel. De database is PostgreSQL via Neon, aangestuurd via Prisma v7. Authenticatie is custom username/password via iron-session — geen externe auth-provider, geen e-mail. De REST API voor Claude Code-integratie loopt via Next.js Route Handlers, beveiligd met API-tokens. Drag-and-drop in de planningsschermen wordt afgehandeld door dnd-kit. Vercel Analytics meet pageviews via de root layout; profielfoto's worden server-side verwerkt met Sharp. --- @@ -49,9 +49,9 @@ Scrum4Me is een desktop-first Next.js 16 webapplicatie die server-side wordt ger | Jotai / Recoil | Atom-gebaseerd model is te granulaar voor de gecorreleerde state in de gesplitste schermen; Zustand stores zijn explicieter en beter uitbreidbaar | | React Query / SWR | Server Components + Server Actions dekken de datalaag; client-side server-state caching introduceert een sync-probleem dat we bewust vermijden | | Context API (React) | Veroorzaakt onnodige re-renders bij drag-and-drop updates; Zustand's selector-gebaseerde subscriptions zijn granulairder | -| WebSockets / externe realtime-broker (Pusher, Ably, Supabase Realtime) | Postgres `LISTEN/NOTIFY` + SSE dekt de realtime-behoefte zonder een tweede auth-laag of extra infrastructuur — zie `architecture/project-structure.md` | -| Redis | Geen caching- of queuerequirements; de `claude_jobs`-tabel met `FOR UPDATE SKIP LOCKED` doet het werk dat een queue-broker anders zou doen | -| Docker voor lokale dev | Voor lokale ontwikkeling volstaat Neon. Er is wél een opt-in Docker-deploy-flow (`scrum4me-docker`, native arm64 op Mac, NAS-flow opt-in) — die is voor deploy, niet voor dev | +| WebSockets / real-time | Geen real-time vereisten in v1; polling of page-refresh volstaat | +| Redis | Geen caching- of queuerequirements op deze schaal | +| Docker (lokale dev) | Neon gratis tier volstaat voor lokale ontwikkeling; Docker voegt geen waarde toe | | Supabase (als database) | Neon geeft directe PostgreSQL-toegang zonder Supabase-specifieke abstractielagen; past beter bij Prisma-first aanpak | | tRPC | REST API is vereist voor Claude Code-integratie; tRPC werkt alleen vanuit TypeScript-clients | diff --git a/docs/architecture/product-backlog-workflow.md b/docs/architecture/product-backlog-workflow.md deleted file mode 100644 index 8c28784..0000000 --- a/docs/architecture/product-backlog-workflow.md +++ /dev/null @@ -1,284 +0,0 @@ ---- -title: "Product Backlog page — workflow & states" -status: active -audience: [maintainer, contributor] -language: nl -last_updated: 2026-05-14 -related: [project-structure.md](./project-structure.md), [sprint-execution-modes.md](./sprint-execution-modes.md) ---- - -# Product Backlog page — workflow & states (PBI-88) - -> Eén autoritatieve beschrijving van het Product Backlog-scherm: de architectuur-lagen, de impliciete workflow-states, en — als doelbeeld — een expliciete state machine met gap-analyse. - -## Context & scope - -De **Product Backlog page** (`app/(app)/products/[id]/page.tsx`) is het scherm waar een gebruiker de PBI's, stories en taken van één product beheert en — sinds PBI-79 — een nieuwe sprint samenstelt zónder het scherm te verlaten. De layout zelf (3-pane split: PBI's · Stories · Taken) staat in [functional.md](../specs/functional.md) (F-04..F-06); dit doc beschrijft het *gedrag* eromheen. - -Waarom dit doc bestaat: het scherm kent vandaag een handvol **impliciete** workflow-states die ad-hoc worden afgeleid uit losse SSR-flags en store-flags. Een samenhangende beschrijving ontbrak — kennis zat verspreid over `functional.md` (alleen layout), de patterns-docs en [project-structure.md](./project-structure.md). Dit doc bundelt die kennis en zet er een doelbeeld naast. - -**Grens met de sprint-execution-page.** Dit scherm gaat over *backlog-beheer + sprint-samenstelling*. Zodra een sprint draait, verschuift het werk naar de sprint-execution-flow — zie [sprint-execution-modes.md](./sprint-execution-modes.md). De `CLOSED`/`ARCHIVED`-levenscyclus van een sprint valt buiten dit scherm. - -## Architectuur-lagen (as-is) - -Het scherm volgt het lagenmodel **PostgreSQL-triggers → SSE → Zustand → React**. Belangrijk: dit is geen toekomstplan — het draait al. De lagen, van onder naar boven: - -### 1. PostgreSQL NOTIFY-triggers - -Row-level `AFTER INSERT/UPDATE/DELETE`-triggers emitteren een JSON-payload op het hardcoded kanaal **`scrum4me_changes`**: - -| Tabel | Trigger / functie | Migratie | -|---|---|---| -| `tasks` | `tasks_notify_change` → `notify_task_change()` | `20260426230316_add_solo_realtime_triggers` | -| `stories` | `stories_notify_change` → `notify_story_change()` | `20260426230316_add_solo_realtime_triggers` | -| `pbis` | NOTIFY-trigger op `pbis` | `20260502190200_add_pbi_notify_trigger` | - -De payload bevat minimaal `{ op: 'I'|'U'|'D', entity, id, product_id, … }` en bij `UPDATE` een `changed_fields`-array. Latere migraties breiden de payload uit (`20260427000216_extend_realtime_payload`) en voegen `story_logs`-notificaties toe (`20260506001700_story_logs_notify`). Volledig payload-contract: [realtime-notify-payload.md](../patterns/realtime-notify-payload.md). - -### 2. SSE-route - -[app/api/realtime/backlog/route.ts](../../app/api/realtime/backlog/route.ts) opent een `pg.Client` op `DIRECT_URL` (pooler-bypass), doet `LISTEN scrum4me_changes` en streamt via een `ReadableStream`: - -- **Filter** (`shouldEmit`): events met een `type`-veld (job/worker) worden genegeerd; alleen `entity ∈ {pbi, story, task}` met matchend `product_id` gaat door. -- **Heartbeat** elke 25s (`: heartbeat`), **hard-close** na 240s (Next-`maxDuration` is 300s) — de client reconnect. -- Auth via iron-session; demo-users mogen meelezen. - -De sprint-board heeft een eigen route met dezelfde opzet: [app/api/realtime/sprint/route.ts](../../app/api/realtime/sprint/route.ts). - -### 3. Zustand-store - -De client-hook `useBacklogRealtime` ([lib/realtime/use-backlog-realtime.ts](../../lib/realtime/use-backlog-realtime.ts)), gemount in `BacklogHydrationWrapper`, opent een `EventSource` naar de SSE-route en: - -- dispatcht elk event naar `useProductWorkspaceStore.applyRealtimeEvent()`; -- beheert **exponential backoff** (1s → 30s) bij `onerror`, en reconnect alleen als de tab `visible` is; -- triggert bij een *latere* `ready` (post-reconnect) `resyncActiveScopes('reconnect')`, zodat events die tijdens een disconnect gemist zijn alsnog binnenkomen. - -`applyRealtimeEvent` ([stores/product-workspace/store.ts](../../stores/product-workspace/store.ts)) filtert op `product_id`, stuurt onbekende entities naar `resyncActiveScopes('unknown-event')` en dispatcht bekende events naar `applyPbiEvent` / `applyStoryEvent` / `applyTaskEvent`: idempotente upsert + sort, parent-move bij gewijzigd `pbi_id`/`story_id`, cleanup bij `DELETE`. - -### 4. React - -Componenten lezen via selectors uit de store en re-renderen op mutaties. De SSR-render (`page.tsx`) levert de *initiële* snapshot + sprint-switcher-data; daarna is de store leidend en haalt `router.refresh()` na een server-action de verse SSR-render op. - -### Kernconclusie - -Het lagenmodel uit het architectuurvoorstel **draait al**: triggers, SSE, stores met `applyRealtimeEvent`, en backoff/reconnect/resync zijn aanwezig. SSE fungeert hier puur als **sync-mechanisme**, niet als UI-bestuurder — de stream zegt alleen "er is iets veranderd aan X", de store bepaalt de betekenis. Wat ontbreekt zit niet in deze lagen, maar in de **state-modellering** erbovenop; dat is het onderwerp van de volgende secties. - -## Stores (as-is) - -Drie Zustand-stores voeden dit scherm. De opdeling volgt het **bounded-context-patroon** uit PBI-74 (één store per coherente workflow — niet per pagina, geen megastore), vastgelegd in [workspace-store.md](../patterns/workspace-store.md). Dit doc bouwt op die opdeling voort; het herstructureert die niet. - -### `product-workspace` — [stores/product-workspace/store.ts](../../stores/product-workspace/store.ts) - -De hoofdstore van dit scherm: PBI's, stories en taken van de backlog. Slices: - -| Slice | Inhoud | -|---|---| -| `context` | `activeProduct`, `activePbiId`, `activeStoryId`, `activeTaskId` — de cascade-selectie | -| `entities` | `pbisById`, `storiesById`, `tasksById` — genormaliseerde entity-maps | -| `relations` | `pbiIds`, `storyIdsByPbi`, `taskIdsByStory` — gesorteerde id-lijsten | -| `loading` | `loaded*Ids`, `activeRequestId` — race-safe markers voor `ensure*Loaded` | -| `sync` | `realtimeStatus`, `lastEventAt`, `lastResyncAt`, `resyncReason` — SSE-connectiestatus | -| `pendingMutations` | rollback-snapshots voor optimistische DnD/patch-mutaties | -| `sprintMembership` | `pbiSummary`, `crossSprintBlocks`, `pending: { adds, removes }`, `loadedSummaryForSprintId` — de sprint-samenstel-laag (PBI-79) | - -De `sprintMembership`-slice is uniek voor dit scherm: hij houdt bij welke stories de gebruiker in/uit de **actieve sprint** cherry-pickt (`toggleStorySprintMembership` → `pending`), met `applyMembershipCommitResult` als gericht patch-pad ná de server-action-commit. - -### `sprint-workspace` — [stores/sprint-workspace/store.ts](../../stores/sprint-workspace/store.ts) - -Spiegelbeeld voor de sprint-board: stories/taken *binnen* één sprint. Zelfde slice-vorm, maar `context` heeft `activeSprintId` i.p.v. `activePbiId`, en `relations` is per-sprint georganiseerd (`storyIdsBySprint`). Op de Product Backlog page niet direct gebruikt, maar relevant voor de grens met de sprint-flow. - -### `user-settings` — [stores/user-settings/store.ts](../../stores/user-settings/store.ts) - -Gebruikersvoorkeuren + cross-tab sync. Voor dit scherm cruciaal: **`workflow.pendingSprintDraft[productId]`** — de concept-sprint die ontstaat bij "Nieuwe sprint". Sinds PBI-79 is die draft **session-only**: `setPendingSprintDraft` / `clearPendingSprintDraft` schrijven alleen lokaal (geen server-roundtrip), en `hydrate()` stript legacy DB-entries weg zodat de draft niet "spookt". `upsertPbiIntent` / `upsertStoryOverride` muteren de draft-selectie. - -Slice-details van het workspace-patroon (`ensure*Loaded`, selectors, optimistic mutations, gotchas) staan in [workspace-store.md](../patterns/workspace-store.md) — hier niet gedupliceerd. - -## Workflow-states (as-is) - -Het scherm kent vandaag **geen expliciete `screenState`**. De zichtbare toestand wordt per render ad-hoc afgeleid uit SSR-flags in `app/(app)/products/[id]/page.tsx` (`isActiveProduct`, `hasOpenSprint`, `isDemo`, plus `sprintItems` / `activeSprintItem` / `buildingSprintIds` uit `getSprintSwitcherData`) en store-flags (`pendingSprintDraft` uit `user-settings`, `sprintMembership.pending` uit `product-workspace`). Daaruit zijn **zeven** herkenbare states te destilleren. - -### 1. `PRODUCT_NOT_ACTIVE` -**Preconditie:** `isActiveProduct === false` (`user.active_product_id !== id`). -**UI:** `SprintSwitcher` wordt niet gerenderd (`{isActiveProduct && …}`); `ActivateProductButton` zichtbaar. De PBI/Story/Task-panes werken normaal. `NewSprintTrigger` is wél zichtbaar (alleen demo-gated, geen `isActiveProduct`-gate); `SaveSprintButton` niet. - -### 2. `PRODUCT_ACTIVE_NO_SPRINTS` -**Preconditie:** `isActiveProduct === true` && `sprintItems.length === 0`. -**UI:** `SprintSwitcher` rendert maar valt terug op de "Geen sprints"-tooltip (early return in `sprint-switcher.tsx`). `NewSprintTrigger` enabled. Geen `SaveSprintButton`, geen "Sprint actief →"-link (`hasOpenSprint === false`). - -### 3. `SPRINT_DRAFT_PENDING` -**Preconditie:** `user-settings.workflow.pendingSprintDraft[productId]` is truthy (session-only). -**UI:** sticky `SprintDefinitionBanner` onder de header (`sprint-draft-banner.tsx`); `NewSprintTrigger` verbergt zichzelf (`if (hasDraft) return null`); `SprintSwitcher` toont een *disabled* "⚙ Concept — [goal]"-item; `SprintDraftLeaveGuard` registreert een `beforeunload`-waarschuwing. De cherrypick-checkboxes in de PBI/Story-panes schakelen naar draft-selectie-modus. - -### 4. `ACTIVE_SPRINT_CLEAN` -**Preconditie:** `isActiveProduct` && `activeSprintItem !== null` && `selectIsDirty === false`. -**UI:** `SprintSwitcher` toont de actieve sprint-code + status; `SaveSprintButton` zichtbaar maar **disabled** (`disabled={!isDirty || …}`), label "Sprint opslaan"; "Sprint actief →"-link zichtbaar zolang er een open sprint is. - -### 5. `ACTIVE_SPRINT_DIRTY` -**Preconditie:** als 4, maar `selectIsDirty === true` — `sprintMembership.pending.adds`/`removes` is niet leeg. -**UI:** als 4, maar `SaveSprintButton` **enabled** met teller — label `Sprint opslaan (N)`. - -### 6. `ACTIVE_SPRINT_BUILDING` -**Preconditie:** `activeSprintItem` && `buildingSprintIds.includes(activeSprintItem.id)` — er loopt een `SprintRun` met status `QUEUED`/`RUNNING` (`getSprintSwitcherData`). -**UI:** als 4/5, maar `SprintSwitcher` toont een gele **"BUILDING"**-badge i.p.v. de sprint-status. Cosmetisch — er worden geen knoppen geblokkeerd. - -### 7. `DEMO_MODE` -**Preconditie:** `session.isDemo === true`. -**Strikt genomen geen state** maar een **cross-cutting constraint** over alle bovenstaande states: schrijf-knoppen (`ActivateProductButton`, `SaveSprintButton`, `NewSprintTrigger`, `EditProductButton`) zijn verborgen of `disabled`; de `SprintSwitcher` navigeert i.p.v. een server-action aan te roepen; en de server-actions zelf returnen vroeg met `{ error: 'Niet beschikbaar in demo-modus' }`. - -## Transitions (as-is) - -Er is geen centrale overgangs-tabel; elke transitie is een server-action of store-mutatie gevolgd door een re-render: - -| Van → naar | Trigger | -|---|---| -| `PRODUCT_NOT_ACTIVE` → `PRODUCT_ACTIVE_*` | `setActiveProductAction` (`actions/active-product.ts`) → `revalidatePath('/', 'layout')` | -| `PRODUCT_ACTIVE_NO_SPRINTS` / `ACTIVE_SPRINT_*` → `SPRINT_DRAFT_PENDING` | `NewSprintTrigger` → `NewSprintMetadataDialog` → `setPendingSprintDraft` (`user-settings` store, session-only) | -| `SPRINT_DRAFT_PENDING` → vorige state | `SprintDefinitionBanner` "Annuleren" → `clearPendingSprintDraft` | -| `SPRINT_DRAFT_PENDING` → `ACTIVE_SPRINT_CLEAN` | `SprintDefinitionBanner` "Sprint aanmaken" → `createSprintWithSelectionAction` (`actions/sprints.ts`): maakt de sprint, koppelt de geselecteerde stories, en de nieuwe sprint wordt de actieve sprint | -| `ACTIVE_SPRINT_CLEAN` ↔ `ACTIVE_SPRINT_DIRTY` | `toggleStorySprintMembership` (`product-workspace` store) maakt dirty; `commitSprintMembershipAction` via `SaveSprintButton` → `applyMembershipCommitResult` maakt weer clean | -| `ACTIVE_SPRINT_*` → andere `ACTIVE_SPRINT_*` | `SprintSwitcher` dropdown → `switchActiveSprintAction` (`actions/active-sprint.ts`) | -| `ACTIVE_SPRINT_*` → `PRODUCT_ACTIVE_NO_SPRINTS` (geen actieve sprint) | `SprintSwitcher` "— Geen actieve sprint —" → `clearActiveSprintAction` | -| `ACTIVE_SPRINT_CLEAN/DIRTY` ↔ `ACTIVE_SPRINT_BUILDING` | extern: een `SprintRun` gaat naar `QUEUED`/`RUNNING` resp. rondt af — zichtbaar bij de volgende SSR-render | - -Server-actions roepen `revalidatePath` aan; de client doet daarnaast `router.refresh()` (bv. na sprint-creatie). De realtime-laag (SSE → `applyRealtimeEvent`) dekt *externe* wijzigingen — zie de sectie Architectuur-lagen hierboven. - -## State-diagram - -```mermaid -stateDiagram-v2 - [*] --> PRODUCT_NOT_ACTIVE - PRODUCT_NOT_ACTIVE --> PRODUCT_ACTIVE_NO_SPRINTS: setActiveProductAction - - PRODUCT_ACTIVE_NO_SPRINTS --> SPRINT_DRAFT_PENDING: Nieuwe sprint / setPendingSprintDraft - SPRINT_DRAFT_PENDING --> PRODUCT_ACTIVE_NO_SPRINTS: Annuleren / clearPendingSprintDraft - SPRINT_DRAFT_PENDING --> ACTIVE_SPRINT: Sprint aanmaken / createSprintWithSelectionAction - - state ACTIVE_SPRINT { - [*] --> CLEAN - CLEAN --> DIRTY: toggleStorySprintMembership - DIRTY --> CLEAN: commitSprintMembershipAction - CLEAN --> BUILDING: SprintRun QUEUED/RUNNING - BUILDING --> CLEAN: SprintRun klaar - } - - ACTIVE_SPRINT --> SPRINT_DRAFT_PENDING: Nieuwe sprint / setPendingSprintDraft - ACTIVE_SPRINT --> ACTIVE_SPRINT: switchActiveSprintAction - ACTIVE_SPRINT --> PRODUCT_ACTIVE_NO_SPRINTS: clearActiveSprintAction -``` - -`DEMO_MODE` staat bewust niet in het diagram: het is geen knoop in de graaf maar een read-only constraint die over álle states heen ligt. - -## To-be: expliciete state machine - -> **Doelbeeld — nog niet geïmplementeerd.** Deze sectie beschrijft hoe de impliciete states een expliciet, testbaar model kunnen worden, conform het architectuurvoorstel. Het is een ontwerp, geen weerslag van bestaande code. - -### Canonieke state-set - -Het voorstel noemde zeven states (`NO_SPRINT`, `DRAFT`, `EDITING`, `READY_TO_START`, `ACTIVE`, `CLOSED`, `ERROR`). Tegen de werkelijkheid van dit scherm afgezet blijven er **vier** canonieke states over, plus twee cross-cutting gates: - -| Voorstel-state | Canoniek hier | Mapping op de as-is werkelijkheid | -|---|---|---| -| `NO_SPRINT` | **`NO_SPRINT`** | `PRODUCT_ACTIVE_NO_SPRINTS` + de "geen actieve sprint"-situatie na `clearActiveSprintAction` | -| `DRAFT` | **`DRAFT`** | `SPRINT_DRAFT_PENDING` — 1-op-1 | -| `EDITING` | **`EDITING`** | `ACTIVE_SPRINT_DIRTY` — 1-op-1 (ongecommitte membership-wijzigingen) | -| `ACTIVE` | **`ACTIVE`** (+ `building`-flag) | `ACTIVE_SPRINT_CLEAN` + `ACTIVE_SPRINT_BUILDING` | -| `READY_TO_START` | — *(vervalt)* | Bestaat niet: `createSprintWithSelectionAction` maakt de sprint `status: 'OPEN'` én roept meteen `setActiveSprintInSettings` aan. Er is geen tussenstap "sprint klaar, nog niet gestart" | -| `CLOSED` | — *(buiten scope)* | `completeSprintAction` zet `status: 'CLOSED'` vanaf de sprint-execution-page. De switcher kán closed/archived sprints tónen ("Toon afgeronde sprints"), maar muteert ze hier niet | -| `ERROR` | — *(niet gemodelleerd)* | Server-actions returnen `{ error, code }`; de client toont een `toast.error` en blijft in de huidige state. Er is geen expliciete ERROR-schermtoestand | - -`building` is een **orthogonale flag** op `ACTIVE`/`EDITING` (een `SprintRun` is `QUEUED`/`RUNNING`), geen aparte state — de UI blokkeert tijdens building niets, het is puur een badge. - -**Cross-cutting gates** (geen knopen in de machine, wel bepalend voor wat zichtbaar is): - -- `PRODUCT_NOT_ACTIVE` — `isActiveProduct === false`: de sprint-workflow is nog niet begonnen; alleen `ActivateProductButton` brengt je verder. -- `DEMO_MODE` — `session.isDemo`: read-only over alle states. - -### Transitietabel - -`currentState + event + context → nextState`: - -| currentState | event | context / guard | nextState | -|---|---|---|---| -| `NO_SPRINT` | `OPEN_DRAFT` | — | `DRAFT` | -| `DRAFT` | `CANCEL_DRAFT` | — | vorige state (`NO_SPRINT` of `ACTIVE`) | -| `DRAFT` | `CREATE_SPRINT` | ≥1 eligible story | `ACTIVE` | -| `ACTIVE` | `OPEN_DRAFT` | — | `DRAFT` | -| `ACTIVE` | `TOGGLE_MEMBERSHIP` | — | `EDITING` | -| `EDITING` | `TOGGLE_MEMBERSHIP` | `pending` wordt leeg | `ACTIVE` | -| `EDITING` | `TOGGLE_MEMBERSHIP` | `pending` blijft gevuld | `EDITING` | -| `EDITING` | `COMMIT_MEMBERSHIP` | — | `ACTIVE` | -| `ACTIVE` / `EDITING` | `SWITCH_SPRINT` | — | `ACTIVE` (andere sprint) | -| `ACTIVE` / `EDITING` | `CLEAR_ACTIVE_SPRINT` | — | `NO_SPRINT` | -| `ACTIVE` / `EDITING` | `SPRINT_RUN_STARTED` / `_FINISHED` | — | idem state, `building` flag om | - -`CANCEL_DRAFT` is context-afhankelijk: open je `DRAFT` vanuit `ACTIVE`, dan keert annuleren terug naar `ACTIVE`; vanuit `NO_SPRINT` naar `NO_SPRINT`. - -### Dunne afleidingslaag — `deriveScreenState()` - -Conform de met de gebruiker afgestemde keuze blijven de **PBI-74 bounded-context stores leidend**. Er komt dus **geen `workflowStore`** en geen herstructurering — alleen een dunne, pure afleidingslaag bovenop wat er al is: één functie die de verspreide flags consolideert tot één `ScreenState`. - -```ts -// stores/product-workspace/screen-state.ts — ONTWERP, nog niet geïmplementeerd -export type ScreenState = - | { kind: 'NO_SPRINT' } - | { kind: 'DRAFT' } - | { kind: 'ACTIVE'; building: boolean } - | { kind: 'EDITING'; building: boolean } - -export interface ScreenStateInput { - // SSR-props uit page.tsx - activeSprintItem: { id: string } | null - buildingSprintIds: string[] - // store-slices (geen nieuwe state — alleen lezen) - hasPendingDraft: boolean // user-settings: pendingSprintDraft[productId] - pendingAdds: string[] // product-workspace: sprintMembership.pending.adds - pendingRemoves: string[] // product-workspace: sprintMembership.pending.removes -} - -export function deriveScreenState(i: ScreenStateInput): ScreenState { - if (i.hasPendingDraft) return { kind: 'DRAFT' } // draft wint van alles - if (i.activeSprintItem) { - const building = i.buildingSprintIds.includes(i.activeSprintItem.id) - const dirty = i.pendingAdds.length > 0 || i.pendingRemoves.length > 0 - return dirty ? { kind: 'EDITING', building } : { kind: 'ACTIVE', building } - } - return { kind: 'NO_SPRINT' } -} -``` - -Ontwerp-uitgangspunten: - -- **Pure functie, geen nieuwe store.** Leest uitsluitend uit de bestaande `product-workspace`- en `user-settings`-stores plus de SSR-props. Geen gedupliceerde state. -- **Eén plek voor "in welke state zit ik".** Vandaag zit die afleiding verspreid over `page.tsx`, `sprint-switcher.tsx`, `new-sprint-trigger.tsx` en `save-sprint-button.tsx`; componenten schakelen straks over op één `switch (screenState.kind)`. -- **Leeft als selector/util** naast `stores/product-workspace/selectors.ts` — testbaar in isolatie (pure input → output). -- `PRODUCT_NOT_ACTIVE` en `DEMO_MODE` blijven **buiten** `ScreenState`: het zijn gates die de UI al apart als booleans heeft, geen knopen in de machine. - -## Gap-analyse - -Wat verschilt tussen de as-is werkelijkheid en het doelbeeld: - -| # | Gap | Huidige situatie | Doelbeeld / divergentie | -|---|---|---|---| -| G1 | Geen expliciete schermstaat | De state wordt per render ad-hoc afgeleid; die logica zit verspreid over `page.tsx`, `sprint-switcher.tsx`, `new-sprint-trigger.tsx` en `save-sprint-button.tsx` | Eén `deriveScreenState()` als single source of truth | -| G2 | Geen `READY_TO_START` | `createSprintWithSelectionAction` maakt de sprint én activeert 'm in één stap | Voorstel-state vervalt bewust — vastleggen, niet "fixen" | -| G3 | `ERROR` niet gemodelleerd | Server-action-fout → `toast.error`, scherm blijft in de huidige state | Geen expliciete ERROR-state; afweging of dat wenselijk is bij falende commit / SSE-verlies | -| G4 | `DEMO_MODE` / `PRODUCT_NOT_ACTIVE` zijn geen states | Cross-cutting booleans, los van de state-afleiding | Bewust buiten `ScreenState` — gates, geen knopen | -| G5 | Draft onzichtbaar op de switcher-trigger | De `SprintSwitcher`-knop toont alleen `activeSprint.code` of "Selecteer sprint"; de "⚙ Concept"-regel zit alleen in de (disabled) dropdown | In `DRAFT` zou de trigger de concept-status moeten tonen — dit was de **oorspronkelijke "FOUT"-melding** die tot dit doc leidde | -| G6 | Draft te starten op niet-actief product | `NewSprintTrigger` is alleen demo-gated, niet `isActiveProduct`-gated | Een draft op een niet-actief product is verwarrend; overweeg de trigger ook achter `isActiveProduct` te zetten | - -## Aanbevelingen - -Geprioriteerd en **niet-bindend** — input voor latere PBI's, geen scope van dit doc: - -1. **`deriveScreenState()` introduceren** (G1) — grootste hefboom: maakt de UI testbaar en de overige gaps adresseerbaar vanuit één plek. -2. **Draft-indicatie op de switcher-trigger** (G5) — kleine, zichtbare UX-fix die de oorspronkelijke melding direct oplost. -3. **`NewSprintTrigger` achter `isActiveProduct`** (G6) — kleine guard-toevoeging. -4. **`ERROR`-state overwegen** (G3) — grotere afweging; alleen oppakken als falende commits of SSE-verlies een echt UX-probleem blijken. - -## Verwante docs - -- [functional.md](../specs/functional.md) — F-04..F-06: layout van de 3-pane Product Backlog page -- [workspace-store.md](../patterns/workspace-store.md) — het bounded-context store-patroon (PBI-74) -- [realtime-notify-payload.md](../patterns/realtime-notify-payload.md) — payload-contract van de NOTIFY-triggers -- [sprint-execution-modes.md](./sprint-execution-modes.md) — de sprint-flow ná dit scherm -- [project-structure.md](./project-structure.md) — stores, realtime en projectopbouw in het groot diff --git a/docs/architecture/project-structure.md b/docs/architecture/project-structure.md index 5dd4620..bce5d7a 100644 --- a/docs/architecture/project-structure.md +++ b/docs/architecture/project-structure.md @@ -3,7 +3,7 @@ title: "Project Structure, Stores, Realtime & Job Queue" status: active audience: [maintainer, contributor] language: nl -last_updated: 2026-05-08 +last_updated: 2026-05-03 related: [data-model.md](./data-model.md) --- @@ -15,56 +15,38 @@ scrum4me/ │ ├── (auth)/ │ │ ├── login/page.tsx │ │ └── register/page.tsx -│ ├── (app)/ # Beschermde routes (desktop + tablets) -│ │ ├── layout.tsx # Auth-check (requireSession) + navigatie +│ ├── (app)/ # Beschermde routes +│ │ ├── layout.tsx # Auth-check + navigatie │ │ ├── dashboard/page.tsx # Productenlijst │ │ ├── products/ │ │ │ ├── new/page.tsx │ │ │ └── [id]/ │ │ │ ├── layout.tsx # Zet actief product in Zustand store │ │ │ ├── page.tsx # Product Backlog (gesplitst scherm) -│ │ │ └── sprint/ -│ │ │ ├── page.tsx # Sprint Backlog (drie-paneel scherm) -│ │ │ └── planning/page.tsx # Redirect → /sprint -│ │ ├── solo/page.tsx # Solo board (top-level; Kanban per ingelogde gebruiker) -│ │ ├── ideas/ # Idea-laag (M12) — vervangt vroegere /todos -│ │ │ ├── page.tsx -│ │ │ └── [id]/page.tsx -│ │ ├── jobs/page.tsx # Job-queue inzicht (PBI-59) -│ │ ├── insights/page.tsx # Tokenkosten + run-statistieken -│ │ ├── manual/ # In-app developer manual (PBI-58) -│ │ │ ├── layout.tsx -│ │ │ ├── _components/ -│ │ │ └── [[...slug]]/page.tsx # Catch-all route voor alle manual-secties -│ │ ├── admin/ # Admin-only schermen +│ │ │ ├── solo/page.tsx # Solo board (Kanban per ingelogde gebruiker) +│ │ │ ├── sprint/ +│ │ │ │ ├── page.tsx # Sprint Backlog (drie-paneel scherm) +│ │ │ │ └── planning/page.tsx # Redirect → /sprint +│ │ ├── todos/page.tsx │ │ └── settings/ │ │ ├── page.tsx # Profiel, account, PB-overzicht, rollen, tokens │ │ └── tokens/page.tsx -│ ├── (mobile)/ # Mobile-shell route group (telefoon-UA) -│ │ ├── layout.tsx # Auth via gedeelde requireSession; geen NavBar/StatusBar -│ │ └── m/ -│ │ ├── settings/page.tsx # Account + product-selector + QR-instructie + logout -│ │ ├── pair/ # QR-pairing (verhuisd uit (app)/ — URL ongewijzigd) -│ │ │ ├── page.tsx -│ │ │ └── pair-confirmation.tsx -│ │ └── products/[id]/ -│ │ ├── page.tsx # Mobile Product Backlog (tab-mode op <1024px) -│ │ └── solo/page.tsx # Mobile Solo (3-koloms-kanban) -│ ├── api/ # REST API voor Claude Code en interne SSE -│ │ ├── auth/pair/ # QR-pairing endpoints (start/claim/stream) -│ │ ├── cron/ # cleanup-agent-artifacts, expire-questions -│ │ ├── debug/ # emit-test-notify, realtime-stream (dev-only) -│ │ ├── health/route.ts # Liveness + ?db=1 ping -│ │ ├── ideas/ # Idea CRUD + grill/plan trigger -│ │ ├── internal/push/ # Web-push send + test-send (INTERNAL_PUSH_SECRET) -│ │ ├── jobs/[id]/sub-tasks/ # SprintTaskExecution-rows uitlezen -│ │ ├── products/[id]/ # next-story, claude-context -│ │ ├── profile/avatar/route.ts # POST upload + GET serve profielfoto -│ │ ├── realtime/ # SSE-streams: backlog, jobs, notifications, solo -│ │ ├── sprints/[id]/tasks/ # GET sprint-taken -│ │ ├── stories/[id]/ # log + tasks/reorder -│ │ ├── tasks/[id]/route.ts # PATCH status + implementation_plan -│ │ └── users/[id]/avatar/ # GET avatar van een specifieke user +│ ├── api/ # REST API voor Claude Code +│ │ ├── products/ +│ │ │ └── [id]/ +│ │ │ └── next-story/route.ts +│ │ ├── profile/ +│ │ │ └── avatar/route.ts # POST upload + GET serve profielfoto +│ │ ├── sprints/ +│ │ │ └── [id]/ +│ │ │ └── tasks/route.ts +│ │ ├── stories/ +│ │ │ └── [id]/ +│ │ │ ├── log/route.ts +│ │ │ └── tasks/reorder/route.ts +│ │ ├── tasks/ +│ │ │ └── [id]/route.ts +│ │ └── todos/route.ts ├── components/ │ ├── ui/ # shadcn/ui primitieven │ ├── split-pane/ # Gesplitst scherm component @@ -72,10 +54,7 @@ scrum4me/ │ ├── sprint/ # Sprint-componenten │ ├── products/ # ProductForm, TeamManager, ArchiveProductButton │ ├── settings/ # RoleManager, ProfileEditor, LeaveProductButton -│ ├── mobile/ # LandscapeGuard, MobileTabBar, LogoutButton -│ ├── ideas/ # Idea-detail, grill-chat, plan-preview -│ ├── jobs/ # Job-card, filter-popover, view-switch -│ └── dnd/ # dnd-kit wrappers +│ └── dnd/ # dnd-kit wrappers ├── lib/ │ ├── prisma.ts # Prisma Client singleton │ ├── session.ts # iron-session configuratie @@ -96,10 +75,6 @@ scrum4me/ │ └── seed.ts # Testdata uit Product Backlog document ├── proxy.ts # Next.js 16 proxy voor route protection ├── prisma.config.ts # Prisma v7 config (DATABASE_URL) -├── instrumentation.ts # Next.js hook → koppelt Sentry-config aan runtime -├── instrumentation-client.ts # Sentry client-init + router-transitions -├── sentry.server.config.ts # Sentry node-runtime init (no-op zonder DSN) -├── sentry.edge.config.ts # Sentry edge-runtime init (proxy.ts) └── .env.example ``` @@ -132,31 +107,6 @@ scrum4me/ **Rationale:** De gesplitste schermen met dnd-kit vereisen client-side staat die twee panelen tegelijk aanstuurt. `useState` per component leidt tot prop drilling; Context API veroorzaakt onnodige re-renders bij 60fps drag-events. Zustand's selector-gebaseerde subscriptions updaten alleen de componenten die de gewijzigde slice observeren. De gouden regel: Zustand beheert uitsluitend ephemere UI-staat — nooit server-data. Server-data blijft in Server Components en wordt opgehaald via Prisma. **Trade-off:** Extra abstractielaag die geïnitialiseerd moet worden vanuit server-data. Opgelost via een patroon waarbij het Server Component de initiële ids doorgeeft aan een Client Component dat de store hydrateert. -### Beslissing: Eigen route group `(mobile)` voor mobile-shell (PBI-11) -**Keuze:** Telefoon-routes leven onder `app/(mobile)/m/*` met eigen `layout.tsx`, niet als nested directory in `(app)/m/*`. -**Rationale:** Next.js layouts erven naar binnen — een nested layout in `(app)/m/` zou de NavBar/StatusBar/MinWidthBanner/SoloRealtimeBridge/NotificationsBridge erven van `(app)/layout.tsx` zonder die te kunnen onderdrukken. De mobile-shell heeft die chrome niet nodig (alleen bottom-tab-bar). Een eigen route group geeft een schone parent-layout. De auth-check is geëxtraheerd naar `lib/auth-guard.ts` `requireSession()` zodat `(app)/layout.tsx` en `(mobile)/layout.tsx` dezelfde guard delen. -**Trade-off:** Twee layouts om te onderhouden, maar elk met een duidelijk afgebakende verantwoordelijkheid. Content-componenten (PbiList, StoryPanel, TaskPanel, SoloBoard, alle entity-dialogen) blijven volledig gedeeld — geen dubbele implementatie. - -### Beslissing: UA-redirect via `Mobi`-substring (PBI-11) -**Keuze:** `lib/user-agent.ts` `isPhoneUA()` test op `Mobi` in de UA-string. `loginAction` (`actions/auth.ts`) leest de header na `session.save()`; phone-UA → `/m/products/[active]/solo` (zonder actief product → `/m/settings`); tablet-UA en desktop → `/dashboard`. -**Rationale:** `Mobi` is de standaard-heuristiek — aanwezig in iPhone Safari Mobile en Android Chrome op telefoons, afwezig op iPad en Android-tablet. Exact wat we willen: alleen telefoons krijgen de mobile-shell, tablets behouden de desktop-flow. -**Trade-off:** Heuristieken zijn nooit 100%; wie via een mobile-emulatie (DevTools) wil testen kan UA spoofen. - -### Beslissing: Gedeelde `entityDialogContentClasses` voor mobile-fullscreen (PBI-11) -**Keuze:** Eén Tailwind-class-string in `components/shared/entity-dialog-layout.ts` met `max-sm:w-screen max-sm:h-screen max-sm:max-w-none max-sm:rounded-none` dekt alle entity-dialogen (PbiDialog, StoryDialog, TaskDialog, TaskDetailDialog). -**Rationale:** Dialog-fullscreen op mobile op vier plekken bewaken zou drift introduceren. De gedeelde constant geeft één bron van waarheid. Het regressie-vangnet (`__tests__/components/shared/entity-dialog-layout.test.ts`) verifieert dat elke dialog deze constant blijft gebruiken. -**Trade-off:** Eén dialog kan niet afwijken zonder de constant te verlaten — bewuste keuze voor consistentie. - -### Beslissing: Sentry voor error-monitoring (v1-readiness item 2) -**Keuze:** `@sentry/nextjs` met vier config-files in repo-root: `instrumentation.ts`, `instrumentation-client.ts`, `sentry.server.config.ts`, `sentry.edge.config.ts`. DSN via `NEXT_PUBLIC_SENTRY_DSN`. Zonder DSN draait de SDK als no-op — geen overhead in dev of bij ontbrekende creds in CI. -**Rationale:** Een echte v1-launch zonder runtime-monitoring is een blinde vlek; build-fouten vangen we in CI maar productie-fouten zien we anders pas via een gebruiker. Vercel + Sentry koppelen via env-vars (geen native marketplace-integratie nodig). Source-maps uploaden alleen als `SENTRY_AUTH_TOKEN` aanwezig is — anders skip-build voor lokale dev. Tunnel-route `/monitoring` omzeilt ad-blockers die `*.sentry.io` blokkeren. -**Trade-off:** Extra dependency (~150 KB client-bundle additioneel). Sample-rates conservatief (10% performance in productie, 100% errors). Geen Replay-integratie — vereist eigen privacy-review en is overkill voor MVP. - -### Beslissing: Gescheiden SplitPane cookie-key voor mobile (PBI-11) -**Keuze:** `BacklogSplitPane` op `app/(mobile)/m/products/[id]/page.tsx` gebruikt `cookieKey={\`backlog-${id}-mobile\`}` (versus desktop `backlog-${id}`). -**Rationale:** Op mobile rendert de `SplitPane` in tab-mode (`<1024px`), waar split-percentages niet aangepast worden. Zonder gescheiden key zou dezelfde cookie hergebruikt worden — telefoon-rotaties of orientatie-wisselingen hadden anders ongewenste interactie met de desktop-split-state. -**Trade-off:** Gebruikers die zowel mobile als desktop gebruiken hebben twee onafhankelijke split-instellingen, wat juist gewenst is. - --- ## Zustand stores diff --git a/docs/architecture/sprint-execution-modes.md b/docs/architecture/sprint-execution-modes.md deleted file mode 100644 index dc0fb4e..0000000 --- a/docs/architecture/sprint-execution-modes.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -title: "Sprint execution modes — PER_TASK vs SPRINT_BATCH" -status: active -audience: [maintainer, contributor] -language: nl -last_updated: 2026-05-07 -related: [project-structure.md](./project-structure.md), [data-model.md](./data-model.md) ---- - -# Sprint execution modes (PBI-50) - -`Product.pr_strategy` bepaalt hoe een SprintRun wordt uitgevoerd. Drie waarden: - -| Waarde | Branch + PR | Worker-sessies | Wanneer | -|---|---|---|---| -| `STORY` | branch + PR per story | één claude-sessie per task | klassieke flow; iedere story landt onafhankelijk | -| `SPRINT` | één draft-PR voor de hele sprint, mark-ready aan eind | één claude-sessie **per task** | werk wordt verzameld in één PR maar tasks blijven losse jobs | -| `SPRINT_BATCH` | één draft-PR voor de hele sprint, mark-ready aan eind | **één claude-sessie voor de hele sprint** | snelste pad; vereist alle tasks in dezelfde repo | - -`STORY` en `SPRINT` triggeren beide het PER_TASK-pad: per TO_DO-task één -`ClaudeJob` met `kind=TASK_IMPLEMENTATION`. Het verschil zit alleen in de -PR-strategie van [`maybeCreateAutoPr`](https://github.com/madhura68/scrum4me-mcp/blob/main/src/tools/update-job-status.ts). - -`SPRINT_BATCH` triggert het PER_SPRINT-pad: één `ClaudeJob` met -`kind=SPRINT_IMPLEMENTATION` die alle TO_DO-tasks van de sprint sequentieel -afhandelt in één claude-sessie. - ---- - -## Waarom SPRINT_BATCH - -Iedere task-claim onder PER_TASK is een verse `claude -p`-sessie. Setup-overhead -per task: laden van Claude Code + MCP-tools, project-CLAUDE.md inlezen, -codebase-oriëntatie. Bij een sprint van 12 tasks van elk 2-4 minuten effectief -werk levert dat ~10-20% pure setup-overhead op én geen continuïteit tussen -tasks. - -`SPRINT_BATCH` ruilt deze overhead in voor één lange sessie: - -``` -PER_TASK (STORY/SPRINT): SPRINT_BATCH: - task1 → setup → werk → done setup → task1 → task2 → ... → done - task2 → setup → werk → done [één heartbeat-loop, één branch, - task3 → setup → werk → done één PR, één finalisering] - ... -``` - ---- - -## Trade-offs - -| Aspect | PER_TASK | SPRINT_BATCH | -|---|---|---| -| Setup-overhead per task | hoog | éénmalig | -| Cross-repo task | toegestaan via `task.repo_url`-override | hard-fail in pre-flight (`task_cross_repo`-blocker) | -| Mid-sprint task-plan-edit | wordt direct opgepikt door volgende claim | snapshot-frozen in `SprintTaskExecution.plan_snapshot` op claim-tijd | -| Cancel/pause vanuit UI | werkt op task-niveau (volgende claim respecteert PAUSED-status) | werkt op SprintRun-niveau via `job_heartbeat`-respons (worker breekt task-loop bij `sprint_run_status !== 'RUNNING'`) | -| Failure-mode | task → cancelPbiOnFailure cascade | cascade-stop op eerste fail; sprint → FAILED, branch wordt gepusht maar PR niet ready | -| Quota-pause | niet gedefinieerd | per-task probe via `worker_heartbeat`; bij low → `QUOTA_PAUSE:`-prefix → SprintRun PAUSED met resume-instructions; resume creëert nieuwe SprintRun met `previous_run_id` + branch-hergebruik | - ---- - -## Datamodel - -`SPRINT_BATCH` introduceert: - -- `ClaudeJobKind.SPRINT_IMPLEMENTATION` — één job per SprintRun. -- `SprintTaskExecution` — frozen scope-snapshot per claim: - `{ plan_snapshot, verify_required_snapshot, verify_only_snapshot, base_sha, - head_sha, status, verify_result, verify_summary }`. Worker en gate werken - uitsluitend op deze rows; latere wijzigingen aan Task-records hebben geen - invloed op de lopende batch. -- `ClaudeJob.lease_until` — heartbeat-driven anti-stale-reset (60s interval, - 5min lease). -- `SprintRun.previous_run_id` (self-relation `SprintRunChain`) — link naar de - voorgaande run bij resume; worker hergebruikt `previous.branch` in plaats - van `feat/sprint-<new_id>`. - ---- - -## MCP-tools per modus - -| Tool | PER_TASK | SPRINT_BATCH | -|---|---|---| -| `wait_for_job` | ✓ | ✓ (claim-filter discrimineert via `cj.kind`) | -| `update_task_plan` | ✓ | n/a (frozen in snapshot) | -| `verify_task_against_plan` | ✓ | n/a | -| `verify_sprint_task` | n/a | ✓ (per execution, snapshot-aware) | -| `update_task_status` | ✓ | ✓ (vereist `sprint_run_id` voor token-coupling) | -| `update_task_execution` | n/a | ✓ | -| `job_heartbeat` | ✓ (lease-extend, no sprint-fields) | ✓ (lease-extend + `sprint_run_status` poll) | -| `update_job_status` | ✓ (per-task) | ✓ (aggregate `checkSprintVerifyGate` + `finalizeSprintRunOnDone`) | - -Volledige tool-catalogus: [scrum4me-mcp README](https://github.com/madhura68/scrum4me-mcp#readme). -Worker-loop pseudocode: [scrum4me-docker CLAUDE.md](https://github.com/madhura68/scrum4me-docker/blob/master/CLAUDE.md). diff --git a/docs/old/backlog.md b/docs/backlog.md similarity index 100% rename from docs/old/backlog.md rename to docs/backlog.md diff --git a/docs/old/backlog/.gitkeep b/docs/backlog/.gitkeep similarity index 100% rename from docs/old/backlog/.gitkeep rename to docs/backlog/.gitkeep diff --git a/docs/old/backlog/index.md b/docs/backlog/index.md similarity index 96% rename from docs/old/backlog/index.md rename to docs/backlog/index.md index fb72de7..3891334 100644 --- a/docs/old/backlog/index.md +++ b/docs/backlog/index.md @@ -36,7 +36,6 @@ De MVP is klaar wanneer Lars — de primaire persona — de volledige cyclus kan | M9: Actief Product Backlog | Persistente actieve PB-keuze, gesplitste navigatie, disabled-states | ST-901 – ST-907 | | M10: Password-loze inlog via QR-pairing | Mobiel als bevestigingskanaal voor desktop-login zonder wachtwoord | ST-1001 – ST-1008 | | M11: Claude vraagt, gebruiker antwoordt | Persistent vraag-antwoord-kanaal tussen Claude (MCP) en de actieve gebruiker | ST-1101 – ST-1108 | -| M12: Ideeën & Grill/Plan jobs | Idee-entity tussen Todo en PBI; interactief grillen + deterministisch materialiseren | ST-1192 – ST-1201 | --- ## Backlog @@ -756,49 +755,6 @@ Persistent vraag-antwoord-kanaal tussen Claude Code (via MCP) en de actieve Scru --- -### M12: Ideeën & Grill/Plan jobs - -**Implementatieplan:** [docs/plans/M12-ideas.md](../plans/M12-ideas.md) -**Dialog-profiel:** [docs/specs/dialogs/idea.md](../specs/dialogs/idea.md) - -Idee is een nieuw concept tussen Todo en PBI. Strikt user_id-only (privé), met -twee Claude-jobs: **Grill Me** (interactief vragen-stellen via MCP) en **Make -Plan** (single-pass yaml-frontmatter genereren). De **Materialiseer**-knop -parseert het plan deterministisch en creëert PBI + stories + taken. - -- [x] **ST-1192** — DB-schema & migratie voor Idea (T-491, T-492, T-489) - - Idea-model + IdeaLog-model + 3 enums; ClaudeJob.task_id nullable + idea_id + - kind; ClaudeQuestion.story_id nullable + idea_id; check-constraints + - pg_notify-trigger update -- [x] **ST-1193** — Lib + schemas + embedded prompts (T-493, T-494, T-495) - - zod-schemas, status-mapper + transition-guard, atomic code-generator, - yaml-frontmatter parser, embedded grill+make-plan prompts -- [x] **ST-1194** — Server actions + Todo→Idea promotie (T-496..T-499) - - CRUD, md-edit, job-triggers, materialize, relink, promoteTodoToIdeaAction -- [x] **ST-1195** — REST API + proxy demo-laag (T-500, T-501) - - /api/ideas + /api/ideas/[id]; demo-403 via proxy.ts catch-all -- [x] **ST-1196** — Realtime SSE + idea-store (T-502, T-503) - - SSE-routing voor idea-events; Zustand idea-store; extension van bestaande - notifications-realtime hook -- [ ] **ST-1197** — MCP-server tools (extern: madhura68/scrum4me-mcp) - - get_idea_context, update_idea_grill_md, update_idea_plan_md, log_idea_decision; - uitbreiding ask_user_question/wait_for_job/update_job_status; Docker rebuild -- [x] **ST-1198** — UI lijstpagina + row-actions (T-507, T-508, T-509) - - /ideas pagina, IdeaList tabel met filters, IdeaRowActions met - disabled-rules per status, idea-status-badge helper -- [x] **ST-1199** — UI detail + dialog + tabs (T-510..T-513) - - /ideas/[id] met 4 tabs (Idee/Grill/Plan/Timeline); md-editor met - yaml-validate; timeline met UNION view; pbi-link-card; dialog-profiel doc -- [x] **ST-1200** — Promote-from-Todo + sidebar (T-514, T-515) - - "→ Idee" knop in TodoCard, PromoteIdeaDialog, "Ideeën" nav-entry -- [ ] **ST-1201** — End-to-end smoke + docs-update (T-516, T-517) - - Volledige flow doorlopen volgens M12-ideas.md verificatie-script; - docs/runbooks/mcp-integration.md uitbreiden voor IDEA_*-job-kinds - - Done when: docs/INDEX.md opnieuw gegenereerd, alle stories ✓, MCP-server - PR met passende versie-bump gedeployed - ---- - ## v2 Backlog (na MVP) - [ ] Uitnodigingsflow voor teams — e-mailuitnodiging of link-gebaseerd; nu kunnen alleen admins met toegang tot het systeem Developers toevoegen via gebruikersnaam diff --git a/docs/old/backlog/product-historical.md b/docs/backlog/product-historical.md similarity index 100% rename from docs/old/backlog/product-historical.md rename to docs/backlog/product-historical.md diff --git a/docs/diagrams/architecture.mmd b/docs/diagrams/architecture.mmd deleted file mode 100644 index aa08a0b..0000000 --- a/docs/diagrams/architecture.mmd +++ /dev/null @@ -1,24 +0,0 @@ ---- -title: Scrum4Me — architectuur (lokaal & veilig) ---- -flowchart LR - User([Jij in je browser]):::user - - subgraph Scrum["Scrum4Me-stack (managed)"] - direction TB - Vercel["Vercel<br/>UI · Server Actions · cron"] - Neon[("Neon Postgres<br/>metadata · jobs · logs")] - Vercel <-->|Prisma + SSE| Neon - end - - subgraph Yours["Jouw kant (lokaal)"] - direction TB - Worker["Lokale worker<br/>laptop / NAS / VM<br/>Claude Code + MCP<br/>jobs: GRILL · PLAN · IMPL"] - GitHub[("GitHub<br/>jouw repo")] - Worker -->|git push| GitHub - end - - User -->|HTTPS| Vercel - Neon <-.->|job claim + LISTEN/NOTIFY| Worker - - classDef user fill:transparent,stroke-dasharray:3 3 diff --git a/docs/old/functional.md b/docs/functional.md similarity index 100% rename from docs/old/functional.md rename to docs/functional.md diff --git a/docs/glossary.md b/docs/glossary.md index e81cca1..bd613ba 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -3,7 +3,7 @@ title: "Scrum4Me — Glossary" status: active audience: [ai-agent, contributor] language: en -last_updated: 2026-05-08 +last_updated: 2026-05-03 when_to_read: "When you encounter a domain term and need its canonical definition and the doc where it is specified." --- @@ -15,10 +15,6 @@ Domain terms used across Scrum4Me docs, code, and MCP tooling. A Claude Code session that has registered itself as a `ClaudeWorker` record and polls the job queue via `mcp__scrum4me__wait_for_job`. The NavBar counts active workers by `last_seen_at < now() - 15s`. See [MCP integration runbook](./runbooks/mcp-integration.md). -## ClaudeJob - -A row in the `claude_jobs` table — the queue from which agents claim work atomically (`FOR UPDATE SKIP LOCKED`). Distinguished by `kind` (`TASK_IMPLEMENTATION`, `IDEA_GRILL`, `IDEA_MAKE_PLAN`, `PLAN_CHAT`, `SPRINT_IMPLEMENTATION`) and tracked through `status` (`QUEUED → CLAIMED → RUNNING → DONE/FAILED/CANCELLED/SKIPPED`). See [data-model](./architecture/data-model.md) and [MCP integration runbook](./runbooks/mcp-integration.md). - ## claude-question A pending question posted by an agent (via `mcp__scrum4me__ask_user_question`) to a human user, stored in the `claude_questions` table. The user answers in the UI; a PostgreSQL `LISTEN/NOTIFY` trigger pushes the answer back to the waiting agent. See [architecture: claude-question-channel](./architecture/claude-question-channel.md) and [ADR-0007](./adr/0007-claude-question-channel-design.md). @@ -31,17 +27,13 @@ An API token whose owning user has `isDemo = true`. All write operations are blo A preconfigured read-only account used for public showcase. Shares product data with the main account but cannot create, update, or delete anything. See [architecture: auth and sessions](./architecture/auth-and-sessions.md). -## Idea - -A user-scoped idea captured before it becomes a PBI. An idea is first _grilled_ via an interactive Q&A loop (`IDEA_GRILL` job → `grill_md`), then materialized through a deterministic plan (`IDEA_MAKE_PLAN` job → `plan_md`) into a PBI with stories and tasks. Replaced the legacy `todos` table in M12. Status enum: `DRAFT | GRILLING | GRILL_FAILED | GRILLED | PLANNING | PLAN_FAILED | PLAN_READY | PLANNED`. See [data-model](./architecture/data-model.md) and the [M12 plan](./plans/M12-ideas.md). - ## MCP-job -Synonym for **ClaudeJob** — used in agent-facing docs because Claude Code consumes the queue through MCP tools. An agent claims a job atomically via `mcp__scrum4me__wait_for_job` and reports completion via `mcp__scrum4me__update_job_status`. See [MCP integration runbook](./runbooks/mcp-integration.md). +A `Task` record that has been queued for autonomous agent execution. An agent claims a job atomically via `mcp__scrum4me__wait_for_job` and reports completion via `mcp__scrum4me__update_job_status`. See [MCP integration runbook](./runbooks/mcp-integration.md). ## PBI (Product Backlog Item) -The second level of the work hierarchy: `Product → PBI → Story → Task`. A PBI groups related stories under a single theme or capability. Status enum: `READY | BLOCKED | FAILED | DONE`. Has a stable `code` (`PBI-N`) per product. Do not use "Epic", "Feature", or "Issue" as synonyms. +The second level of the work hierarchy: `Product → PBI → Story → Task`. A PBI groups related stories under a single theme or capability. Do not use "Epic", "Feature", or "Issue" as synonyms. See [backlog index](./backlog/index.md). ## Solo Panel @@ -49,20 +41,16 @@ The single-user planning screen that shows all PBIs and stories for one product ## Sprint -A time-boxed iteration with a `sprint_goal` and a stable `code` (`SP-N`) per product. A product can hold multiple sprints simultaneously (PBI-63) — `OPEN` is not exclusive; the sprint-switcher in the product header lets the user pick which sprint to plan against. Status enum: `OPEN | CLOSED | ARCHIVED | FAILED`. Stories enter `IN_SPRINT` when added to a sprint via `sprint_id`. See [data-model](./architecture/data-model.md). - -## SprintRun - -A single execution of the SPRINT_IMPLEMENTATION flow against one Sprint. Tracked in `sprint_runs` with status `QUEUED | RUNNING | PAUSED | DONE | FAILED | CANCELLED`, optional `pause_context` for resume, and a chain via `previous_run_id` for retries. The frozen scope-snapshot per run lives in `sprint_task_executions`. See [sprint execution modes](./architecture/sprint-execution-modes.md). +A time-boxed iteration with a `sprint_goal`. Stories move from `OPEN` to `IN_SPRINT` when added to the active sprint. Only one sprint per product can be `ACTIVE` at a time. See [backlog index](./backlog/index.md). ## Story -The third level of the work hierarchy: `Product → PBI → Story → Task`. A Story has acceptance criteria, an optional `assignee_id` (Solo-bord claim), a stable `code` (`ST-N`) per product, and a status (`OPEN | IN_SPRINT | DONE | FAILED`). See [functional spec](./specs/functional.md). +The third level of the work hierarchy: `Product → PBI → Story → Task`. A Story has acceptance criteria and a status (`OPEN | IN_SPRINT | DONE`). See [functional spec](./specs/functional.md). ## Task -The leaf level of the work hierarchy: `Product → PBI → Story → Task`. A Task has an `implementation_plan`, a stable `code` (`T-N`) per product, a `status` (`TO_DO | IN_PROGRESS | REVIEW | DONE | FAILED | EXCLUDED`), `verify_only` and `verify_required` flags, and an optional `repo_url` override. API exposes status as lowercase (`todo | in_progress | review | done | failed | excluded`). See [data-model](./architecture/data-model.md) and [ADR-0004](./adr/0004-status-enum-mapping.md). +The leaf level of the work hierarchy: `Product → PBI → Story → Task`. A Task has an `implementation_plan`, a `status` (`TO_DO | IN_PROGRESS | REVIEW | DONE`), and an optional `sort_order`. API exposes status as lowercase (`todo | in_progress | review | done`). See [architecture: data model](./architecture/data-model.md) and [ADR-0004](./adr/0004-status-enum-mapping.md). -## verify_result +## Todo -The agent's outcome of a verification pass (`ALIGNED | PARTIAL | EMPTY | DIVERGENT`). Combined with the task's `verify_required` threshold (`ALIGNED | ALIGNED_OR_PARTIAL | ANY`) it determines whether a job's claim of "done" is accepted by the gate. See [agent-flow-pitfalls runbook](./runbooks/agent-flow-pitfalls.md). +A lightweight freeform note scoped to a product (or unscoped). Not part of the sprint hierarchy — used for quick capture. Created via `mcp__scrum4me__create_todo`. See [MCP integration runbook](./runbooks/mcp-integration.md). diff --git a/docs/implementation-complete/IDEA_REVIEW_PLAN-implementation-summary.md b/docs/implementation-complete/IDEA_REVIEW_PLAN-implementation-summary.md deleted file mode 100644 index 7d9709b..0000000 --- a/docs/implementation-complete/IDEA_REVIEW_PLAN-implementation-summary.md +++ /dev/null @@ -1,228 +0,0 @@ -# IDEA_REVIEW_PLAN Implementation Summary - -**Date:** May 14, 2026 -**Phase:** Completed (Phases 1-5) | Ready for Testing (Phase 6) -**Status:** ✅ All core implementation complete - ---- - -## Overview - -The IDEA_REVIEW_PLAN job kind has been fully implemented as a multi-model iterative plan review orchestrator. This feature enables automated review of implementation plans (YAML + markdown documents) with convergence detection and approval gates. - ---- - -## Implementation Checklist - -### Phase 1: Database & Config ✅ -- [x] Added `plan_review_log` (Json) and `reviewed_at` (DateTime) fields to Idea model -- [x] Added `REVIEWING_PLAN`, `PLAN_REVIEW_FAILED`, `PLAN_REVIEWED` to IdeaStatus enum -- [x] Added `IDEA_REVIEW_PLAN` to ClaudeJobKind enum -- [x] Added `PLAN_REVIEW_RESULT` to IdeaLogType enum -- [x] Created migration `20260514000000_add_review_plan_support` -- [x] Synchronized both Prisma schemas (main repo + scrum4me-mcp) -- [x] Configured job-config.ts with: - - Model: `claude-opus-4-7` - - Thinking budget: 6000 tokens - - Allowed tools: Read, Write, Grep, Glob, MCP tools - -### Phase 2: MCP Tool Implementation ✅ -- [x] Created `update_idea_plan_reviewed` MCP tool -- [x] Implemented transaction-safe database updates -- [x] Added error handling and access control -- [x] Registered tool in MCP server index -- [x] Type-safe Zod input validation - -### Phase 3: Server Actions & UI Components ✅ -- [x] Created `startReviewPlanJobAction()` server action -- [x] Updated `cancelIdeaJobAction()` for IDEA_REVIEW_PLAN -- [x] Updated status transition rules in `lib/idea-status.ts` -- [x] Added status colors and labels for new statuses -- [x] Updated job-card and jobs-column to display IDEA_REVIEW_PLAN -- [x] Updated idea-timeline to display PLAN_REVIEW_RESULT log entries - -### Phase 4: Grill Prompt Implementation ✅ -- [x] Created `lib/idea-prompts/review-plan-job.md` prompt -- [x] Copied prompt to MCP server at `src/prompts/idea/review-plan.md` -- [x] Updated `kind-prompts.ts` to register the new prompt -- [x] Updated `getIdeaPromptText()` to include IDEA_REVIEW_PLAN -- [x] Updated `wait-for-job.ts` to handle IDEA_REVIEW_PLAN -- [x] Updated branch suggestion logic for review jobs -- [x] Created comprehensive documentation in `docs/runbooks/review-plan-job.md` -- [x] Created test suite for review-log schema validation (`__tests__/review-plan-job.test.ts`) -- [x] All tests passing (13/13 review-plan-job tests, 862 total tests) - -### Phase 5: ReviewLogViewer UI Component ✅ -- [x] Created `components/ideas/review-log-viewer.tsx` component -- [x] Integrated component into idea page -- [x] Display review-log in plan tab with convergence metrics -- [x] Show round-by-round issues and scores -- [x] Approval status display with proper styling -- [x] Updated idea page to load and pass `plan_review_log` -- [x] TypeScript compilation successful - -### Phase 6: Integration & Rollout 🔄 (In Progress) -- [x] ✅ Wire wait-for-job discriminator (IDEA_REVIEW_PLAN already in condition at line 511) -- [ ] 📋 End-to-end testing with live job execution -- [ ] 📋 Verify IdeaLog entries and review-log persistence -- [ ] 📋 Feature flag management (if applicable) -- [ ] 📋 Rollout to staging (24h test) -- [ ] 📋 Gradual rollout: 10% → 50% → 100% (if using feature flags) - ---- - -## Files Modified/Created - -### Database & Schema -- `prisma/schema.prisma` - Added fields and enums -- `prisma/migrations/20260514000000_add_review_plan_support/migration.sql` - DDL - -### Configuration & Jobs -- `lib/job-config.ts` - IDEA_REVIEW_PLAN config -- `scrum4me-mcp/src/lib/job-config.ts` - Mirrored config - -### Server Actions -- `actions/ideas.ts` - startReviewPlanJobAction() - -### Prompts -- `lib/idea-prompts/review-plan-job.md` - Main prompt -- `scrum4me-mcp/src/prompts/idea/review-plan.md` - MCP server copy -- `scrum4me-mcp/src/lib/kind-prompts.ts` - Prompt registration - -### MCP Tools & Integration -- `scrum4me-mcp/src/tools/update-idea-plan-reviewed.ts` - MCP tool (NEW) -- `scrum4me-mcp/src/tools/wait-for-job.ts` - Updated discriminator -- `scrum4me-mcp/src/lib/kind-prompts.ts` - Prompt loader - -### UI Components -- `components/ideas/review-log-viewer.tsx` - Review-log display (NEW) -- `components/ideas/idea-detail-layout.tsx` - Integrated viewer -- `components/ideas/idea-timeline.tsx` - Added PLAN_REVIEW_RESULT icon -- `components/ideas/idea-list.tsx` - Added new statuses to filters -- `components/ideas/idea-detail-layout.tsx` - API_TO_DB mappings -- `components/jobs/job-card.tsx` - Added REVIEW kind label -- `components/jobs/jobs-column.tsx` - Added REVIEW filter option -- `app/(app)/ideas/[id]/page.tsx` - Load and pass plan_review_log - -### Status & Color Definitions -- `lib/idea-status.ts` - Status transitions & editability rules -- `lib/idea-status-colors.ts` - Color mappings for new statuses - -### Documentation & Tests -- `docs/runbooks/review-plan-job.md` - Implementation guide -- `__tests__/review-plan-job.test.ts` - Test suite (NEW) - ---- - -## Data Flow - -``` -User clicks "Review Plan" on PLAN_READY idea - ↓ -startReviewPlanJobAction() queues IDEA_REVIEW_PLAN job - ↓ -Server: PLAN_READY → REVIEWING_PLAN (atomic with job creation) - ↓ -Worker claims job via wait_for_job - ↓ -Prompt orchestrates review: - • Ronde 1: Structure check - • Ronde 2: Logic & patterns - • Ronde 3: Risk assessment - ↓ -Convergence detection triggers - ↓ -User approves via ask_user_question - ↓ -update_idea_plan_reviewed(approval_status='approved') - ↓ -Atomic transaction: - • Save plan_review_log - • Save reviewed_at timestamp - • Transition REVIEWING_PLAN → PLAN_REVIEWED - • Create IdeaLog entry (PLAN_REVIEW_RESULT) - ↓ -UI updates: ReviewLogViewer shows results in plan tab -``` - ---- - -## Key Features - -1. **Multi-Model Review:** Haiku (structure) → Sonnet (logic) → Opus (risk) -2. **Convergence Detection:** Auto-stop when plan stabilizes (< 5% changes 2 rounds) -3. **Approval Gate:** User must approve before plan transitions to PLAN_REVIEWED -4. **Rich Logging:** Detailed review-log JSON with issues, scores, diffs per round -5. **Status Transitions:** Proper state machine with allowed transitions -6. **IdeaLog Audit:** PLAN_REVIEW_RESULT entries track all reviews -7. **UI Integration:** ReviewLogViewer shows convergence metrics, issues, approval status - ---- - -## Review-Log Schema - -```typescript -{ - plan_file: string; - created_at: ISO8601; - rounds: Array<{ - round: number; - model: string; - role: string; - focus: string; - plan_before: string; - plan_after: string; - issues: Array<{ category, severity, suggestion }>; - score: 0-100; - plan_diff_lines: number; - converged: boolean; - timestamp: ISO8601; - }>; - convergence?: { stable_at_round, final_diff_pct }; - approval: { status: 'pending'|'approved'|'rejected', timestamp?: ISO8601 }; - summary: string; -} -``` - ---- - -## Testing Status - -- ✅ Unit tests: 862/862 passing -- ✅ Review-plan schema tests: 13/13 passing -- ✅ TypeScript compilation: Clean -- ⏳ End-to-end testing: Pending (Phase 6) -- ⏳ Live job execution: Pending (Phase 6) - ---- - -## Next Steps (Phase 6) - -1. **Create test idea** with PLAN_READY status -2. **Trigger review job** and monitor execution -3. **Verify review-log** is saved correctly -4. **Check IdeaLog** entries for PLAN_REVIEW_RESULT -5. **Test approval workflow** (approve/reject) -6. **Verify state transitions** (REVIEWING_PLAN → PLAN_REVIEWED) -7. **Test UI display** of review-log in plan tab -8. **Test cancellation** mid-review (revert to PLAN_READY) -9. **Test error paths** (malformed plan_md, parse failures) -10. **Staging rollout** (24h test with feature flag) - ---- - -## Known Limitations - -1. **No multi-model API calls:** Reviews are simulated by Opus (future: direct model switching via API) -2. **No codex injection:** Docs not auto-loaded (future: inject patterns + architecture docs) -3. **No re-review detection:** No diff against previous review-logs (future: highlight what changed) -4. **Manual review-log edit:** Users cannot edit review-log directly (could be added in future) - ---- - -## References - -- `docs/runbooks/review-plan-job.md` — Full implementation guide -- `lib/idea-prompts/review-plan-job.md` — Prompt documentation -- `__tests__/review-plan-job.test.ts` — Test examples -- `CLAUDE.md` — Project rules and patterns diff --git a/docs/implementation-complete/IMPLEMENTATION-COMPLETE.md b/docs/implementation-complete/IMPLEMENTATION-COMPLETE.md deleted file mode 100644 index e806f8a..0000000 --- a/docs/implementation-complete/IMPLEMENTATION-COMPLETE.md +++ /dev/null @@ -1,337 +0,0 @@ -# IDEA_REVIEW_PLAN Implementation — COMPLETE ✅ - -**Status:** Feature Implementation Complete | Ready for End-to-End Testing -**Build Date:** May 14, 2026 -**Version:** 1.0 -**Build Status:** ✅ All 862 tests passing | ✅ TypeScript clean | ✅ All files verified - ---- - -## Executive Summary - -The IDEA_REVIEW_PLAN feature has been fully implemented across all 5 phases (database, MCP tools, server actions, UI, and documentation). The implementation enables automated multi-model iterative review of implementation plans with convergence detection and approval gates. - -**Delivery:** -- ✅ Feature-complete implementation -- ✅ 100% of acceptance criteria met -- ✅ All tests passing (862/862) -- ✅ TypeScript compilation clean -- ✅ Comprehensive documentation -- ✅ Ready for staging rollout - ---- - -## Implementation Phases Summary - -### Phase 1: Database & Config ✅ COMPLETE -- Database schema extended with `plan_review_log` (Json) and `reviewed_at` (DateTime) -- New IdeaStatus enum values: `REVIEWING_PLAN`, `PLAN_REVIEW_FAILED`, `PLAN_REVIEWED` -- ClaudeJobKind: `IDEA_REVIEW_PLAN` with opus-4-7 model, 6000 thinking tokens -- IdeaLogType: `PLAN_REVIEW_RESULT` for audit trail -- Prisma migration applied and verified -- Schema synchronized across both repositories (main + MCP) - -**Key Files:** -- `prisma/schema.prisma` — Schema definition -- `prisma/migrations/20260514000000_add_review_plan_support/migration.sql` — DDL -- `lib/job-config.ts` + `scrum4me-mcp/src/lib/job-config.ts` — Job config (mirrored) - -### Phase 2: MCP Tool Implementation ✅ COMPLETE -- Created `update_idea_plan_reviewed` MCP tool for transaction-safe database updates -- Implemented Zod validation for input types -- Added proper error handling and access control -- Tool registered in MCP server index -- Function signature: `update_idea_plan_reviewed({ idea_id, approval_status })` - -**Key Files:** -- `scrum4me-mcp/src/tools/update-idea-plan-reviewed.ts` — MCP tool (NEW) - -### Phase 3: Server Actions & UI Components ✅ COMPLETE -- Implemented `startReviewPlanJobAction(id)` server action -- Updated `cancelIdeaJobAction()` to handle IDEA_REVIEW_PLAN cancellation -- Status transition rules: `PLAN_READY → REVIEWING_PLAN → PLAN_REVIEWED/PLAN_REVIEW_FAILED` -- Proper status colors and badges added -- Job filtering and status display updated - -**Key Files:** -- `actions/ideas.ts` — `startReviewPlanJobAction()` (lines 421-423) -- `lib/idea-status.ts` — Status transition rules -- `lib/idea-status-colors.ts` — Color definitions for new statuses - -### Phase 4: Grill Prompt Implementation ✅ COMPLETE -- Created comprehensive review orchestration prompt (194 lines) -- Multi-model review strategy: Haiku (structure) → Sonnet (logic) → Opus (risk assessment) -- Convergence detection algorithm: < 5% change over 2 consecutive rounds -- Approval gate: User must approve before status transition -- Prompt registered in kind-prompts.ts -- Extensive documentation in runbook format -- Test suite created: 13/13 tests passing - -**Key Files:** -- `lib/idea-prompts/review-plan-job.md` — Main prompt (7.2 KB) -- `scrum4me-mcp/src/prompts/idea/review-plan.md` — MCP copy (7.2 KB) -- `scrum4me-mcp/src/lib/kind-prompts.ts` — Prompt registration -- `docs/runbooks/review-plan-job.md` — Implementation guide (10.3 KB) -- `__tests__/review-plan-job.test.ts` — Test suite (7.9 KB) - -### Phase 5: ReviewLogViewer UI Component ✅ COMPLETE -- Created `ReviewLogViewer` component (241 lines) for displaying review results -- Proper TypeScript types exported (ReviewLog, ReviewRound, IssueItem) -- Integration in idea detail page (plan tab) -- Display features: - - Round-by-round analysis with model, role, score, changes - - Convergence metrics (stable at round, final diff %) - - Approval status badge with timestamp - - Issue list per round with severity colors - - Metadata: file, creation date, round count -- MD3 styling with proper color tokens - -**Key Files:** -- `components/ideas/review-log-viewer.tsx` — Component (8.4 KB) -- `components/ideas/idea-detail-layout.tsx` — Integration -- `app/(app)/ideas/[id]/page.tsx` — Data loading - -### Phase 6.1: Wait-for-Job Discriminator ✅ COMPLETE -- Added IDEA_REVIEW_PLAN to job kind condition (line 511, wait-for-job.ts) -- Updated branch naming logic: returns 'review' for IDEA_REVIEW_PLAN -- Worker can now receive and process review jobs - -**Key Files:** -- `scrum4me-mcp/src/tools/wait-for-job.ts` — Job discriminator (lines 511, 574) - ---- - -## Quality Metrics - -| Metric | Status | -|--------|--------| -| Unit Tests | 862/862 passing ✅ | -| TypeScript Compilation | Clean ✅ | -| ESLint | 1 warning (unrelated), 0 errors ✅ | -| Type Coverage | 100% (ReviewLog exported) ✅ | -| Documentation | Complete (3 docs + runbook) ✅ | -| Test Coverage | Review plan schema + status transitions ✅ | - ---- - -## Verification Results - -``` -File Verification: 13/13 checks passed ✅ - -✅ Review Plan Prompt (Main) — 7.2 KB -✅ Review Plan Prompt (MCP) — 7.2 KB -✅ ReviewLogViewer Component — 8.4 KB -✅ Idea Actions — 28.8 KB -✅ startReviewPlanJobAction — Found -✅ MCP Update Plan Reviewed Tool — 3.8 KB -✅ IDEA_REVIEW_PLAN in kind-prompts.ts — Found -✅ IDEA_REVIEW_PLAN in wait-for-job.ts — Found -✅ Review Plan Job Runbook — 10.3 KB -✅ Phase 6 Test Plan — 9.7 KB -✅ Implementation Summary — 8.3 KB -✅ Review Plan Job Tests — 7.9 KB -✅ Migration SQL — 353 bytes -``` - ---- - -## Job Execution Flow - -``` -User Action: startReviewPlanJobAction(idea_id) - ↓ -Server: Atomic transaction - • Create ClaudeJob (status=QUEUED, kind=IDEA_REVIEW_PLAN) - • Update Idea (status=REVIEWING_PLAN) - • Create IdeaLog (type=JOB_EVENT) - • Notify via pg_notify - ↓ -Worker: wait_for_job claims job (QUEUED → CLAIMED → RUNNING) - ↓ -MCP Prompt Execution (3 rounds) - 1. Haiku: Structure review - 2. Sonnet: Logic & patterns - 3. Opus: Risk assessment - ↓ -Convergence Check: Auto-stop if stable (< 5% changes 2 rounds) - ↓ -User Approval: ask_user_question with metrics - ↓ -On Approval: update_idea_plan_reviewed(approval_status='approved') - • Save plan_review_log to DB - • Set reviewed_at timestamp - • Transition status: REVIEWING_PLAN → PLAN_REVIEWED - • Create IdeaLog (type=PLAN_REVIEW_RESULT) - ↓ -UI: ReviewLogViewer displays results in plan tab -``` - ---- - -## Data Model - -### ReviewLog JSON Schema -```json -{ - "plan_file": "IDEA-016", - "created_at": "2026-05-14T03:15:00Z", - "rounds": [ - { - "round": 0, - "model": "claude-3-5-haiku", - "role": "Structure Review", - "focus": "YAML parsing, format, syntax", - "issues": [ - { - "category": "structure|logic|risk|pattern", - "severity": "error|warning|info", - "suggestion": "text" - } - ], - "score": 75, - "plan_diff_lines": 3, - "converged": false, - "timestamp": "2026-05-14T03:15:30Z" - } - ], - "convergence": { - "stable_at_round": 2, - "final_diff_pct": 2.1, - "convergence_metric": "plan_stability" - }, - "approval": { - "status": "pending|approved|rejected", - "timestamp": "2026-05-14T03:20:00Z" - }, - "summary": "Plan reviewed across 3 rounds..." -} -``` - ---- - -## Documentation Artifacts - -### Technical Documentation -1. **IDEA_REVIEW_PLAN-implementation-summary.md** (8.3 KB) - - Complete phase-by-phase checklist - - Files modified/created per phase - - Data flow diagram - - Testing status - -2. **PHASE6-END-TO-END-TEST-PLAN.md** (9.7 KB) - - 6 detailed test scenarios - - Test checklist (20+ items) - - Review-log schema validation - - Feature flag and rollout strategy - -3. **review-plan-job.md (runbook)** (10.3 KB) - - Implementation guide - - MCP integration instructions - - Testing strategy - - Future enhancement ideas - -### Code Documentation -- ReviewLog types exported from `review-log-viewer.tsx` -- Inline comments explaining database JSON field handling -- Prompt documentation in review-plan-job.md - ---- - -## Ready for Phase 6: End-to-End Testing - -### Prerequisites Met -✅ All database migrations applied -✅ All MCP tools registered -✅ All server actions implemented -✅ All UI components created -✅ Prompts ready for worker execution -✅ Tests (862) all passing -✅ TypeScript clean -✅ Documentation complete - -### Next Steps -1. **Phase 6.2:** End-to-end testing with live job execution - - Trigger review job on PLAN_READY idea - - Monitor multi-round execution - - Verify review-log persistence - - Test approval workflow - -2. **Phase 6.3:** Verify IdeaLog entries - - Check JOB_EVENT logs for job lifecycle - - Verify PLAN_REVIEW_RESULT log entries - - Validate metadata in timeline display - -3. **Phase 6.4:** Feature flag setup - - Configure gradual rollout - - Set staging to 100% - - Production: 10% → 50% → 100% - -4. **Phase 6.5:** Staging rollout (24h) - - Deploy to staging - - Monitor job success rate (target: > 95%) - - Verify no regressions in existing workflows - -5. **Phase 6.6:** Production rollout - - Gradual enable per percentage - - Monitor metrics continuously - - Rollback plan if needed - ---- - -## Known Limitations & Future Work - -| Item | Current | Future | -|------|---------|--------| -| Model Switching | Simulated (all Opus) | Direct API calls per round | -| Codex Injection | Static context | Smart selection per round | -| Re-review Detection | Not supported | Diff against previous reviews | -| Manual Edit | Not allowed | Could be added in future | -| Multi-user Reviews | Not supported | Collaborative mode could be added | - ---- - -## Deployment Checklist - -- [ ] Code review approval (if required by org) -- [ ] Security audit (data handling, JSON parsing) -- [ ] Performance testing (concurrent jobs) -- [ ] Staging 24h rollout complete -- [ ] Feature flag operational -- [ ] Monitoring dashboards set up -- [ ] Runbook accessible to ops -- [ ] Rollback plan documented -- [ ] Production rollout begins - ---- - -## Key Contacts & Resources - -**Documentation:** -- `docs/runbooks/review-plan-job.md` — Operational guide -- `docs/implementation-complete/` — All implementation artifacts - -**Testing:** -- `__tests__/review-plan-job.test.ts` — Unit tests -- `scripts/verify-review-plan-files.sh` — File verification - -**Code References:** -- Main prompt: `lib/idea-prompts/review-plan-job.md` -- MCP prompt: `scrum4me-mcp/src/prompts/idea/review-plan.md` -- Server action: `actions/ideas.ts` (lines 421-423) -- Component: `components/ideas/review-log-viewer.tsx` -- MCP tool: `scrum4me-mcp/src/tools/update-idea-plan-reviewed.ts` - ---- - -## Sign-Off - -**Implementation Status:** ✅ COMPLETE -**Quality Assurance:** ✅ PASSED -**Documentation:** ✅ COMPLETE -**Ready for Testing:** ✅ YES - -Implementation completed successfully on **May 14, 2026**. - -All phases delivered on schedule with comprehensive documentation and full test coverage. - diff --git a/docs/implementation-complete/PHASE6-END-TO-END-TEST-PLAN.md b/docs/implementation-complete/PHASE6-END-TO-END-TEST-PLAN.md deleted file mode 100644 index 02586f0..0000000 --- a/docs/implementation-complete/PHASE6-END-TO-END-TEST-PLAN.md +++ /dev/null @@ -1,258 +0,0 @@ -# Phase 6: End-to-End Testing & Rollout Plan - -**Status:** In Progress (Phase 6.2 - End-to-End Testing) -**Date:** May 14, 2026 -**Build Status:** ✅ All 862 tests passing, TypeScript clean - ---- - -## Completion Status: Phases 1-5 - -### Phase 1: Database & Config ✅ -- ✅ Schema extended with `plan_review_log` (Json) and `reviewed_at` (DateTime) -- ✅ IdeaStatus enum: `REVIEWING_PLAN`, `PLAN_REVIEW_FAILED`, `PLAN_REVIEWED` -- ✅ ClaudeJobKind: `IDEA_REVIEW_PLAN` -- ✅ IdeaLogType: `PLAN_REVIEW_RESULT` -- ✅ Prisma migration created and applied -- ✅ MCP schema synchronized - -### Phase 2: MCP Tool Implementation ✅ -- ✅ MCP tool: `update_idea_plan_reviewed` (transaction-safe database updates) -- ✅ Type validation via Zod -- ✅ Error handling and access control -- ✅ Tool registered in MCP server index - -### Phase 3: Server Actions & UI Components ✅ -- ✅ Server action: `startReviewPlanJobAction()` -- ✅ Server action: `cancelIdeaJobAction()` updated for IDEA_REVIEW_PLAN -- ✅ Status transitions: `PLAN_READY → REVIEWING_PLAN → PLAN_REVIEWED/PLAN_REVIEW_FAILED` -- ✅ UI status colors and labels -- ✅ Job cards and filtering updated - -### Phase 4: Grill Prompt Implementation ✅ -- ✅ Prompt: `lib/idea-prompts/review-plan-job.md` (194 lines) -- ✅ Prompt copied to MCP: `scrum4me-mcp/src/prompts/idea/review-plan.md` -- ✅ Prompt registered in `kind-prompts.ts` -- ✅ Documentation: `docs/runbooks/review-plan-job.md` -- ✅ Test suite: `__tests__/review-plan-job.test.ts` (13/13 passing) - -### Phase 5: ReviewLogViewer UI Component ✅ -- ✅ Component: `components/ideas/review-log-viewer.tsx` (241 lines) -- ✅ ReviewLog type exported (properly typed) -- ✅ Integration in idea detail page -- ✅ Display: round-by-round analysis, convergence metrics, approval status -- ✅ Styling: MD3 tokens for severity levels - -### Phase 1-5 Verification ✅ -- ✅ TypeScript compilation: Clean -- ✅ All tests passing: 862/862 -- ✅ ESLint: Fixed no-explicit-any errors with proper ReviewLog typing -- ✅ Implementation is feature-complete and production-ready - ---- - -## Phase 6: Integration & Rollout - -### 6.1: Wire wait-for-job Discriminator ✅ DONE -- ✅ Line 511 in `scrum4me-mcp/src/tools/wait-for-job.ts`: Added `IDEA_REVIEW_PLAN` to job kind condition -- ✅ Line 574: Branch naming logic updated to return 'review' for IDEA_REVIEW_PLAN - -### 6.2: End-to-End Testing 🔄 IN PROGRESS - -#### Test Scenarios - -**Scenario 1: Trigger Review Job on PLAN_READY Idea** -- [ ] Select idea with status `PLAN_READY` (e.g., IDEA-016, IDEA-043, IDEA-049) -- [ ] Verify idea has `product_id` with valid `repo_url` -- [ ] Trigger `startReviewPlanJobAction()` -- [ ] Verify: - - ClaudeJob created with status `QUEUED` - - Idea status flipped to `REVIEWING_PLAN` - - IdeaLog entry created with type `JOB_EVENT` - - Job payload contains correct job-config snapshot - -**Scenario 2: Job Execution by MCP Worker** -- [ ] Worker claims job via `wait_for_job(IDEA_REVIEW_PLAN)` -- [ ] Verify returned payload contains: - - idea_id, kind, plan_md, grill_md - - plan_md parsed into YAML structure - - job_config with model (claude-opus-4-7), thinking_budget (6000), allowed_tools -- [ ] Verify job status transitions to `CLAIMED` → `RUNNING` - -**Scenario 3: Multi-Round Review Execution** -- [ ] Worker executes prompt: 3 review rounds (Haiku → Sonnet → Opus) -- [ ] Each round produces issues[], score (0-100), plan_diff_lines -- [ ] Convergence detection: diff < 5% for 2 consecutive rounds triggers approval gate -- [ ] Verify review-log JSON structure matches schema (see below) - -**Scenario 4: Approval Gate & Status Transition** -- [ ] Worker calls `ask_user_question` with convergence metrics -- [ ] User approves/rejects via chat interface -- [ ] On approval: `update_idea_plan_reviewed(approval_status='approved')` -- [ ] Verify atomic transaction: - - plan_review_log saved to DB - - reviewed_at timestamp set - - Idea status: `REVIEWING_PLAN` → `PLAN_REVIEWED` - - IdeaLog entry created with type `PLAN_REVIEW_RESULT` -- [ ] On rejection: status → `PLAN_REVIEW_FAILED` - -**Scenario 5: UI Display of Review Results** -- [ ] Open idea page in plan tab -- [ ] Verify ReviewLogViewer displays: - - Summary and approval status badge - - Convergence metrics (if present) - - Round-by-round analysis (model, role, score, diff_lines, timestamp) - - Issue badges per round (category, severity, suggestion) - - Metadata: plan_file, creation date, round count - -**Scenario 6: State Transitions & Cancellation** -- [ ] While job is `RUNNING`, trigger `cancelIdeaJobAction()` -- [ ] Verify: - - Job status → `CANCELLED` - - Idea status → `PLAN_READY` (revert to before review) - - IdeaLog entry created: `JOB_EVENT` with cancel note - -#### Review-Log Schema Validation - -```json -{ - "plan_file": "IDEA-016", - "created_at": "2026-05-14T03:15:00Z", - "rounds": [ - { - "round": 0, - "model": "claude-3-5-haiku", - "role": "Structure Review", - "focus": "YAML parsing, format, syntax", - "issues": [ - { - "category": "structure|logic|risk|pattern", - "severity": "error|warning|info", - "suggestion": "string" - } - ], - "score": 75, - "plan_diff_lines": 3, - "converged": false, - "timestamp": "2026-05-14T03:15:30Z" - } - ], - "convergence": { - "stable_at_round": 2, - "final_diff_pct": 2.1, - "convergence_metric": "plan_stability" - }, - "approval": { - "status": "pending|approved|rejected", - "timestamp": "2026-05-14T03:20:00Z" - }, - "summary": "Plan reviewed across 3 rounds..." -} -``` - -#### Test Checklist -- [ ] Database: plan_review_log field persists correctly -- [ ] MCP: Prompt injection (codex context) works -- [ ] MCP: Model switching simulates correctly (all rounds via Opus) -- [ ] Convergence: Math correct (< 5% change threshold) -- [ ] Approval: Atomic transaction commits on approve/reject -- [ ] UI: ReviewLogViewer renders all data correctly -- [ ] UI: Status transitions visible in idea detail page -- [ ] Error paths: Handle malformed plan_md gracefully -- [ ] Error paths: Handle missing product repo_url -- [ ] Error paths: Handle parse failures in Zod validation - ---- - -### 6.3: Verify IdeaLog Entries & Persistence 📋 -- [ ] JOB_EVENT log entries: queued, claimed, running, done, failed, cancelled -- [ ] PLAN_REVIEW_RESULT log entry with convergence metadata -- [ ] Timeline display: logs appear in idea detail → timeline tab -- [ ] Metadata validation: all fields present and correctly typed - -### 6.4: Feature Flag Management 📋 -- [ ] If feature flag exists: gate IDEA_REVIEW_PLAN creation to enabled users -- [ ] If not: decide on rollout strategy (gradual or all-at-once) -- [ ] Document flag semantics (server-side or client-side) - -### 6.5: Staging Rollout (24h Test) 📋 -- [ ] Deploy to staging environment -- [ ] Enable IDEA_REVIEW_PLAN for staging users (100%) -- [ ] Monitor: job execution, error rates, performance -- [ ] Verify: no regressions in existing idea workflows (grill, make-plan) -- [ ] Smoke test: trigger review jobs on 3-5 different ideas -- [ ] Check: review-log data integrity, IdeaLog audit trail - -### 6.6: Gradual Rollout to Production 📋 -- [ ] Phase 1: 10% of active users get IDEA_REVIEW_PLAN enabled -- [ ] Phase 2 (24h later): 50% of users -- [ ] Phase 3 (24h later): 100% of users -- [ ] Rollback plan: disable feature flag if error rate > threshold -- [ ] Monitor: - - Job success rate (goal: > 95%) - - Review-log schema validation errors - - Worker capacity utilization - - User feedback (approval acceptance rate) - ---- - -## Key Implementation Details - -### Job-Config Snapshot -```typescript -{ - kind: 'IDEA_REVIEW_PLAN', - model_override: 'claude-opus-4-7', - thinking_budget: 6000, - allowed_tools: ['read', 'write', 'grep', 'glob', ...mcp_tools], - verify_required: 'ALIGNED_OR_PARTIAL', - verify_only: false -} -``` - -### Prompt Execution Pipeline -1. Worker loads plan_md + grill_md from DB -2. Codex injection: load docs/patterns/*, docs/architecture/*, CLAUDE.md -3. Round 1: Haiku reviews structure -4. Round 2: Sonnet reviews logic/patterns -5. Round 3: Opus reviews risks/edge-cases -6. Convergence check: break if stable -7. Ask user approval via ask_user_question -8. On approval: save review-log, transition status, log PLAN_REVIEW_RESULT - -### Status Transition Rules -- PLAN_READY → REVIEWING_PLAN: `startReviewPlanJobAction()` -- REVIEWING_PLAN → PLAN_REVIEWED: User approves via ask_user_question -- REVIEWING_PLAN → PLAN_REVIEW_FAILED: User rejects -- REVIEWING_PLAN → PLAN_READY: User cancels job - ---- - -## Known Limitations & Future Work - -1. **No multi-model API calls**: All rounds use Opus (future: leverage Claude API direct model switching) -2. **No codex re-loading**: Docs injected once (future: smart context selection per round) -3. **No re-review detection**: No diff against previous reviews (future: highlight deltas) -4. **Manual review-log edit**: Users cannot edit review-log directly (future: could add) - ---- - -## References - -- Phase 4 prompt: `lib/idea-prompts/review-plan-job.md` -- Implementation guide: `docs/runbooks/review-plan-job.md` -- ReviewLog types: `components/ideas/review-log-viewer.tsx` -- Server action: `actions/ideas.ts` → `startReviewPlanJobAction()` -- MCP tool: `scrum4me-mcp/src/tools/update-idea-plan-reviewed.ts` -- Tests: `__tests__/review-plan-job.test.ts` - ---- - -## Next Steps (Immediate) - -1. **Start Phase 6.2**: Manually trigger review job on IDEA-016 -2. **Monitor job execution**: Check logs, review-log schema -3. **Verify UI display**: ReviewLogViewer renders correctly -4. **Document blockers**: If any failures occur, diagnose and document -5. **Proceed to staging**: Once E2E test passes - diff --git a/docs/manual/01-overview.md b/docs/manual/01-overview.md deleted file mode 100644 index bb99663..0000000 --- a/docs/manual/01-overview.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -title: "Overview" -status: active -audience: [contributor] -language: en -last_updated: 2026-05-07 -when_to_read: "First chapter — start here for the elevator pitch and project structure." ---- - -# 01 — Overview - -## What is Scrum4Me? - -Scrum4Me is a **desktop-first fullstack web app for solo developers and small Scrum teams** who manage multiple software projects in parallel. It models the Scrum hierarchy explicitly (Product → PBI → Story → Task), supports Sprints with split-screen drag-and-drop planning, and integrates Claude Code as an automated implementation worker — every result the agent produces is logged back into the originating story. - -The app is deployable to **Vercel + Neon** (default) and can run **fully local** via the worker container. A built-in demo user has read-only access; Product Owners add Developers by username, and those Developers gain write access to that product's stories, tasks, and sprints. - -## Entity hierarchy - -```mermaid -flowchart TB - Product["Product<br/>(per repo)"] - Idea["Idea<br/>(pre-PBI staging)"] - PBI["PBI<br/>(Product Backlog Item)"] - Story["Story"] - Task["Task"] - Sprint["Sprint<br/>(cross-cutting)"] - - Product --> Idea - Idea -.->|"AI-grilled & planned"| PBI - Product --> PBI - PBI --> Story - Story --> Task - Sprint -.->|"contains stories<br/>denormalised on tasks"| Story - Sprint -.-> Task -``` - -- **Product** — one row per repo. `repo_url`, `definition_of_done`, members. -- **Idea** — pre-PBI staging entity introduced in M12. Goes through `IDEA_GRILL` (AI Q&A loop) and `IDEA_MAKE_PLAN` jobs to produce a structured plan that can be turned into a PBI tree. -- **PBI** — a Product Backlog Item. Has `priority` (1–4) and `sort_order` (float, see [`docs/patterns/sort-order.md`](../patterns/sort-order.md)). -- **Story** — a unit of value under a PBI; has acceptance criteria. Lives in the backlog (`OPEN`) until added to a sprint. -- **Task** — the smallest unit; has an `implementation_plan` consumed by the Claude worker. `sprint_id` is denormalised from the parent story for query efficiency. -- **Sprint** — cross-cutting time-box. Stories are added to a sprint; tasks inherit `sprint_id`. Sprint execution has two modes: `PER_TASK` and `SPRINT_BATCH` — see [`docs/architecture/sprint-execution-modes.md`](../architecture/sprint-execution-modes.md). - -For status lifecycles of each entity, see [02 — Statuses & Transitions](./02-statuses-and-transitions.md). - -## Stack - -| Layer | Technology | -|---|---| -| Framework | Next.js 16 (App Router) + React 19 | -| Language | TypeScript (strict) | -| Styling | Tailwind CSS + shadcn/ui + Material Design 3 tokens via [`app/styles/theme.css`](../../app/styles/theme.css) | -| Client state | Zustand + dnd-kit | -| Database | Prisma v7 + PostgreSQL (Neon) | -| Auth | iron-session + bcryptjs | -| Utilities | Zod, Sonner, Sharp, Vercel Analytics | -| Hosting | Vercel (app), Neon (DB), Mac/NAS Docker (worker) | - -For the rationale behind each choice and the technologies we explicitly **don't** use, see [`docs/architecture/overview.md`](../architecture/overview.md). - -## Repository layout - -``` -Scrum4Me/ -├── app/ # Next.js App Router routes -│ ├── (app)/ # authenticated desktop UI -│ ├── (auth)/ # login, register, demo -│ ├── (mobile)/ # /m/* mobile shell (3 screens) -│ ├── api/ # REST route handlers (Claude integration) -│ └── styles/ # MD3 token CSS -├── components/ # shared UI components -├── lib/ # server/client utilities -│ └── task-status.ts # the ONLY place DB↔API enum mapping happens -├── prisma/ # schema + migrations -├── docs/ # this manual + ADRs, runbooks, patterns, specs -└── scripts/ # codegen, seeders, link checkers -``` - -The `*-server.ts` filename suffix marks server-only modules (DB, Node APIs). They must never be imported into a client component — see the hardstop in [`CLAUDE.md`](../../CLAUDE.md#hardstop-regels). - -For a deeper structural breakdown including stores, realtime channels, and the job queue, see [`docs/architecture/project-structure.md`](../architecture/project-structure.md). - -## Glossary refresh - -A few terms used throughout this manual that often differ from "generic Scrum" usage: - -- **PBI** — Product Backlog Item. Not "Feature" or "Epic". -- **Story** — A unit of work under a PBI. Not "Ticket" or "Issue". -- **Sprint Goal** — The narrative for a sprint. Not "Objective". -- **Worker** — A Claude Code agent claiming jobs from the Scrum4Me queue (M13). -- **Demo user** — A read-only built-in user; writes return `403`. See [`docs/adr/0006-demo-user-three-layer-policy.md`](../adr/0006-demo-user-three-layer-policy.md). -- **Idea** — Pre-PBI staging artefact (M12). Has its own state machine; see [02](./02-statuses-and-transitions.md#idea). - -The complete glossary lives at [`docs/glossary.md`](../glossary.md). - -## What's next - -→ [02 — Statuses & Transitions](./02-statuses-and-transitions.md) covers how each entity moves through its lifecycle, with state-machine diagrams. diff --git a/docs/manual/02-statuses-and-transitions.md b/docs/manual/02-statuses-and-transitions.md deleted file mode 100644 index 916f579..0000000 --- a/docs/manual/02-statuses-and-transitions.md +++ /dev/null @@ -1,222 +0,0 @@ ---- -title: "Statuses & Transitions" -status: active -audience: [contributor] -language: en -last_updated: 2026-05-07 -when_to_read: "Whenever an entity's status changes unexpectedly or you need to know what status comes next." ---- - -# 02 — Statuses & Transitions - -Every persistent entity in Scrum4Me has an explicit status enum. This chapter documents them all, with state-machine diagrams showing allowed transitions, the trigger for each transition (user action vs system / job-driven), and the side effects. - -> **Hardstop:** the database stores enums in `UPPER_SNAKE`; the REST API exposes them in `lowercase`. Conversion happens **only** through [`lib/task-status.ts`](../../lib/task-status.ts) — never call `.toLowerCase()` or `.toUpperCase()` directly. See the [DB vs API mapping](#db-vs-api-mapping) section at the end. - -## Quick reference - -| Entity | Source enum | Statuses | -|---|---|---| -| [PBI](#pbi) | `PbiStatus` | `READY`, `BLOCKED`, `DONE`, `FAILED` | -| [Story](#story) | `StoryStatus` | `OPEN`, `IN_SPRINT`, `DONE`, `FAILED` | -| [Task](#task) | `TaskStatus` | `TO_DO`, `IN_PROGRESS`, `REVIEW`, `DONE`, `FAILED` | -| [Sprint](#sprint) | `SprintStatus` | `ACTIVE`, `COMPLETED`, `FAILED` | -| [SprintRun](#sprintrun) | `SprintRunStatus` | `QUEUED`, `RUNNING`, `PAUSED`, `DONE`, `FAILED`, `CANCELLED` | -| [ClaudeJob](#claudejob) | `ClaudeJobStatus` | `QUEUED`, `CLAIMED`, `RUNNING`, `DONE`, `FAILED`, `CANCELLED`, `SKIPPED` | -| [Idea](#idea) | `IdeaStatus` | `DRAFT`, `GRILLING`, `GRILL_FAILED`, `GRILLED`, `PLANNING`, `PLAN_FAILED`, `PLAN_READY`, `PLANNED` | - -## PBI - -A **Product Backlog Item** holds one or more stories. Its status reflects whether the PBI as a whole is ready to be picked up, blocked on something external, finished, or written off. - -```mermaid -stateDiagram-v2 - [*] --> READY: create_pbi - READY --> BLOCKED: user marks blocked - BLOCKED --> READY: user unblocks - READY --> DONE: all stories DONE - READY --> FAILED: user gives up - BLOCKED --> FAILED: user gives up - DONE --> [*] - FAILED --> [*] -``` - -| Transition | Trigger | Side effect | -|---|---|---| -| `* → READY` | `create_pbi` MCP tool or PBI dialog | New PBI lands in `priority` group, `sort_order = last + 1` | -| `READY ↔ BLOCKED` | User toggles via PBI dialog | None besides log entry | -| `READY → DONE` | All child stories reach `DONE` | Auto-promotion (see [ST-1109 plan](../plans/ST-1109-pbi-status.md)) | -| `* → FAILED` | User gives up on the PBI | Stories may remain `OPEN`; PBI is filtered out of active boards | - -## Story - -A **Story** sits under a PBI. It moves out of the backlog when added to a Sprint, and reaches `DONE` when its tasks are complete and the implementation is verified. - -```mermaid -stateDiagram-v2 - [*] --> OPEN: create_story - OPEN --> IN_SPRINT: added to sprint - IN_SPRINT --> OPEN: removed from sprint - IN_SPRINT --> DONE: all tasks DONE + verify passes - IN_SPRINT --> FAILED: verify fails / abandoned - DONE --> [*] - FAILED --> [*] -``` - -| Transition | Trigger | Side effect | -|---|---|---| -| `* → OPEN` | `create_story` MCP tool or Story dialog | Lives in product backlog | -| `OPEN ↔ IN_SPRINT` | Drag onto Sprint board, or sprint-removal | Tasks denormalise `sprint_id` | -| `IN_SPRINT → DONE` | Story completion via MCP / UI; auto-PR flow may trigger | Auto-PR flow ([`runbooks/auto-pr-flow.md`](../runbooks/auto-pr-flow.md)) may run; PBI is re-evaluated for `READY → DONE` | -| `IN_SPRINT → FAILED` | Verification failure or manual abandon | Logged in story log | - -## Task - -A **Task** is the smallest unit. The Claude worker mainly reads `implementation_plan` and writes status transitions through MCP tools. - -```mermaid -stateDiagram-v2 - [*] --> TO_DO: create_task - TO_DO --> IN_PROGRESS: agent claims / user starts - IN_PROGRESS --> REVIEW: implementation done, awaiting verify - REVIEW --> DONE: verify passes - REVIEW --> IN_PROGRESS: verify fails, retry - IN_PROGRESS --> FAILED: unrecoverable error - REVIEW --> FAILED: gives up after retries - DONE --> [*] - FAILED --> [*] -``` - -| Transition | Trigger | Side effect | -|---|---|---| -| `* → TO_DO` | `create_task` MCP tool / Task dialog | Inherits `sprint_id` from parent story | -| `TO_DO → IN_PROGRESS` | Worker claim or user starts | Story may auto-promote to `IN_SPRINT` | -| `IN_PROGRESS → REVIEW` | Implementation logged | Optional `verify_task_against_plan` runs | -| `REVIEW → DONE` | Verify passes / human accepts | When all sibling tasks are `DONE`, the parent story is eligible for `DONE` | -| `* → FAILED` | Unrecoverable error or human marks failed | Story may auto-promote to `FAILED` | - -The MCP tool is `update_task_status({ task_id, status })` accepting lowercase API values: `todo | in_progress | review | done | failed`. - -## Sprint - -A **Sprint** is the cross-cutting time-box. Its status tracks the overall sprint container, not the agent execution. - -```mermaid -stateDiagram-v2 - [*] --> ACTIVE: create sprint - ACTIVE --> COMPLETED: user closes sprint - ACTIVE --> FAILED: user abandons sprint - COMPLETED --> [*] - FAILED --> [*] -``` - -For execution semantics (PER_TASK vs SPRINT_BATCH) see [`docs/architecture/sprint-execution-modes.md`](../architecture/sprint-execution-modes.md). - -## SprintRun - -A **SprintRun** is one execution attempt of a sprint by the agent worker. Multiple runs may exist over a sprint's lifetime (if a run is cancelled or paused and restarted). - -```mermaid -stateDiagram-v2 - [*] --> QUEUED: trigger sprint run - QUEUED --> RUNNING: worker claims - RUNNING --> PAUSED: pause requested - PAUSED --> RUNNING: resume - RUNNING --> DONE: all tasks done - RUNNING --> FAILED: unrecoverable - QUEUED --> CANCELLED: user cancels - RUNNING --> CANCELLED: user cancels - PAUSED --> CANCELLED: user cancels - DONE --> [*] - FAILED --> [*] - CANCELLED --> [*] -``` - -The cascade rules (which task transitions automatically promote the SprintRun) are described in [`docs/plans/sprint-pr-worktree-state-machines.md`](../plans/sprint-pr-worktree-state-machines.md). When calling `update_task_status` from inside a sprint run, pass the optional `sprint_run_id` so the server can validate ownership and propagate cascades. - -## ClaudeJob - -The agent **job queue** (M13). Each enqueued unit of work is a `ClaudeJob` with a `kind` (`TASK_IMPLEMENTATION`, `IDEA_GRILL`, `IDEA_MAKE_PLAN`, `PLAN_CHAT`, `SPRINT_IMPLEMENTATION`). - -```mermaid -stateDiagram-v2 - [*] --> QUEUED: enqueue - QUEUED --> CLAIMED: wait_for_job (FOR UPDATE SKIP LOCKED) - CLAIMED --> RUNNING: worker starts - RUNNING --> DONE: update_job_status('done') - RUNNING --> FAILED: update_job_status('failed') - QUEUED --> CANCELLED: user cancels - CLAIMED --> QUEUED: stale (>30min) - QUEUED --> SKIPPED: superseded - DONE --> [*] - FAILED --> [*] - CANCELLED --> [*] - SKIPPED --> [*] -``` - -| Transition | Trigger | Side effect | -|---|---|---| -| `QUEUED → CLAIMED` | `wait_for_job` atomically claims | Bearer token is bound to the job (`claimed_by_token_id`) | -| `CLAIMED → QUEUED` | Stale claim (>30 min) | Auto-requeue on next `wait_for_job` | -| `RUNNING → DONE` | `update_job_status('done')` | Optional token-cost telemetry stored on the row | -| `RUNNING → FAILED` | `update_job_status('failed')` | For `IDEA_GRILL`/`IDEA_MAKE_PLAN`, idea status auto-rolls to `GRILL_FAILED` / `PLAN_FAILED` | - -For idempotency rules and recovery procedures see [`docs/runbooks/worker-idempotency.md`](../runbooks/worker-idempotency.md). - -## Idea - -The **Idea** entity (M12) is a pre-PBI staging area. It goes through two AI-driven phases: a **grill** (Q&A loop with the user to clarify the idea) and a **plan** (single-pass output of a structured PBI tree). Failures are explicit terminal-ish states that allow retry. - -```mermaid -stateDiagram-v2 - [*] --> DRAFT: create idea - DRAFT --> GRILLING: enqueue IDEA_GRILL - GRILLING --> GRILLED: update_idea_grill_md - GRILLING --> GRILL_FAILED: job failed - GRILL_FAILED --> GRILLING: retry - GRILLED --> PLANNING: enqueue IDEA_MAKE_PLAN - PLANNING --> PLAN_READY: update_idea_plan_md (parse ok) - PLANNING --> PLAN_FAILED: parsePlanMd rejected - PLAN_FAILED --> PLANNING: retry - PLAN_READY --> PLANNED: PBI tree created - PLANNED --> [*] -``` - -| Transition | Trigger | Side effect | -|---|---|---| -| `DRAFT → GRILLING` | User clicks "Grill" | Enqueues `IDEA_GRILL` job; worker reads `prompt_text` + `idea.grill_md` | -| `GRILLING → GRILLED` | `update_idea_grill_md` | Logs `IdeaLog{GRILL_RESULT}` | -| `* → GRILL_FAILED` | `update_job_status('failed')` for `IDEA_GRILL` | Idea remains usable; user can retry | -| `GRILLED → PLANNING` | User clicks "Make plan" | Enqueues `IDEA_MAKE_PLAN`; worker outputs strict YAML-frontmatter | -| `PLANNING → PLAN_READY` | `update_idea_plan_md` parse ok | Logs `IdeaLog{PLAN_RESULT}` | -| `PLANNING → PLAN_FAILED` | `parsePlanMd` rejected | Logs `IdeaLog{JOB_EVENT, errors}` | -| `PLAN_READY → PLANNED` | PBI tree generated from plan | Idea is archived; PBI/Story/Task tree appears in the backlog | - -For the full Idea workflow, prompts, and `prompt_text` contents, see [`docs/plans/M12-ideas.md`](../plans/M12-ideas.md). - -## DB vs API mapping - -> **Hardstop:** never bypass [`lib/task-status.ts`](../../lib/task-status.ts). - -The database stores enums in `UPPER_SNAKE` (`TO_DO`, `IN_PROGRESS`, `IN_SPRINT`, …) because Prisma + PostgreSQL prefer that convention. The REST API exposes them in `lowercase` (`todo`, `in_progress`, `in_sprint`, …) because that's the convention HTTP consumers expect. - -The two are mapped **only** through the helpers in [`lib/task-status.ts`](../../lib/task-status.ts): - -```ts -taskStatusToApi(status) // DB → API -taskStatusFromApi(input) // API → DB (returns null on bad input) -storyStatusToApi(status) -storyStatusFromApi(input) -pbiStatusToApi(status) -pbiStatusFromApi(input) -sprintStatusToApi(status) -sprintStatusFromApi(input) -sprintRunStatusToApi(status) -sprintRunStatusFromApi(input) -``` - -Bad input on the inbound side (`*FromApi`) returns `null` — the route handler converts that to a `422` Zod-style error. See [`docs/adr/0004-status-enum-mapping.md`](../adr/0004-status-enum-mapping.md) for the rationale. - -## What's next - -→ [03 — Git Workflow](./03-git-workflow.md) covers branching, commits, and the cost-driven PR rules. diff --git a/docs/manual/03-git-workflow.md b/docs/manual/03-git-workflow.md deleted file mode 100644 index 888c7f1..0000000 --- a/docs/manual/03-git-workflow.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -title: "Git Workflow" -status: active -audience: [contributor] -language: en -last_updated: 2026-05-07 -when_to_read: "Before creating a branch, before committing, and especially before pushing or opening a PR." ---- - -# 03 — Git Workflow - -The Scrum4Me git workflow is shaped by two pressures that don't usually appear together: - -1. An **AI agent** that can produce many commits per hour without human review, -2. A **Vercel Hobby plan** that meters preview deployments and bills for them. - -These two together drive a workflow that looks unusual compared to "feature-branch + PR-per-story". This chapter explains the *why*; the authoritative *how* lives in the runbooks linked at the bottom. - -## The five guiding rules - -### 1. One branch per milestone, not per story - -A milestone (e.g. `M10-qr-login`) groups multiple stories that ship together. The agent runs through them on a single branch named `feat/M{N}-{slug}` (or `feat/ST-XXX-{slug}` for one-off stories without a milestone). All commits accumulate on that branch. - -> **Why?** Every push to a feature branch triggers a Vercel preview build. Pushing per story would multiply the build cost without producing more reviewable units of work — the user reviews the milestone, not the story. - -See [`docs/adr/0003-one-branch-per-milestone.md`](../adr/0003-one-branch-per-milestone.md) for the full rationale. - -### 2. Commit per layer, not per task - -A single task can touch the database, the API, and the UI. Each of those layers gets its own commit. The pattern: - -``` -feat(ST-XXX): add field X to Prisma schema # DB -feat(ST-XXX): add Y endpoint accepting X # API -feat(ST-XXX): wire X into the editor component # UI -chore(ST-XXX): configure sharp for X processing # config -docs(ST-XXX): document the X feature # docs -``` - -> **Why?** Reviewers and `git bisect` both benefit when one commit can be reverted without touching unrelated layers. A `feat: add profile system` mega-commit is an antipattern. - -### 3. Push only after the user has tested - -Commits accumulate **locally** until the milestone is functionally complete and the user has confirmed it works. Then — and only then — `git push` and `gh pr create`. - -> **Why?** Same cost reason as rule 1. Mid-milestone "save points" should be local tags or `git stash`, not pushes. Some exceptions exist (planning-only PRs, emergency hotfixes); they're enumerated in [`branch-and-commit.md`](../runbooks/branch-and-commit.md#uitzonderingen-op-de-push-regel). - -### 4. One PR per batch → one preview build - -When the worker runs through a queue of jobs, the entire run produces **one** PR with one commit per task. No interim pushes, no force-pushes to clean up history, no PR-per-story splits. - -The end-to-end verification — that one batch produces exactly one Vercel deployment — is in [`branch-and-commit.md`](../runbooks/branch-and-commit.md) (see the *End-to-end verificatie* section). - -### 5. Auto-PR flow at the end - -Once a story reaches `DONE`, the auto-PR flow takes over: it pushes the branch, opens a PR, waits for the scope to be complete, waits for checks, and merges. The contract for "scope complete" and the path-filter / label rules that decide whether a deploy actually runs are split between two runbooks: - -- **End-to-end pipeline**: [`docs/runbooks/auto-pr-flow.md`](../runbooks/auto-pr-flow.md) -- **Selective deploy controls** (`skip-deploy` label, path-filter for `app/`/`components/`/`lib/`): [`docs/runbooks/deploy-control.md`](../runbooks/deploy-control.md) - -## Commit message format - -``` -<type>(ST-XXX): short description -``` - -Where `<type>` is one of `feat`, `fix`, `chore`, `docs`. The story code in parentheses links the commit back to the Scrum4Me MCP entity. - -For PBI-level work (no single story), use the PBI code: `docs(PBI-58): scaffold developer manual`. - -## Merge conflicts - -| Scenario | Conflict? | Mitigation | -|---|---|---| -| Multiple tasks on the same batch branch | No — they stack linearly on one branch | None needed | -| Two parallel batches touching the same files | Yes, possible | Serialise batches via the MCP `get_claude_context` flow (one story at a time per agent), or rebase before push | -| Long-lived branch drifting from `main` | Yes, possible | `git fetch origin main && git rebase origin/main` before `gh pr create` | - -`git push --force` to "wipe" earlier preview builds is forbidden — it costs the same build again on recreation, defeating the purpose of the cost-control rules. - -## When **not** to follow the strict rules - -When the Vercel account moves to Pro (or another billing tier without per-build cost), this workflow can revert to the more conventional "branch + PR per story". When that happens, update the rule in [`branch-and-commit.md`](../runbooks/branch-and-commit.md) and log the change in [`docs/decisions/agent-instructions-history.md`](../decisions/agent-instructions-history.md). - -## Deep links - -| Topic | Authoritative source | -|---|---| -| Branch & commit rules (full normative spec) | [`docs/runbooks/branch-and-commit.md`](../runbooks/branch-and-commit.md) | -| Auto-PR flow (story-DONE → merged-PR pipeline) | [`docs/runbooks/auto-pr-flow.md`](../runbooks/auto-pr-flow.md) | -| Deploy controls (labels, path-filter) | [`docs/runbooks/deploy-control.md`](../runbooks/deploy-control.md) | -| Vercel deployment specifics | [`docs/runbooks/deploy-vercel.md`](../runbooks/deploy-vercel.md) | -| Decision rationale (one-branch-per-milestone) | [`docs/adr/0003-one-branch-per-milestone.md`](../adr/0003-one-branch-per-milestone.md) | -| Worker idempotency & job-status protocol | [`docs/runbooks/worker-idempotency.md`](../runbooks/worker-idempotency.md) | - -## What's next - -→ [04 — MCP Integration](./04-mcp-integration.md) covers how the Claude agent drives this workflow from the queue side. diff --git a/docs/manual/04-mcp-integration.md b/docs/manual/04-mcp-integration.md deleted file mode 100644 index 5860621..0000000 --- a/docs/manual/04-mcp-integration.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -title: "MCP Integration" -status: active -audience: [contributor] -language: en -last_updated: 2026-05-07 -when_to_read: "Whenever Claude Code is interacting with Scrum4Me — opening a story, claiming a job, asking the user a question." ---- - -# 04 — MCP Integration - -Scrum4Me exposes its REST API as native Claude Code tools through a dedicated **MCP server** living in [`madhura68/scrum4me-mcp`](https://github.com/madhura68/scrum4me-mcp). Schemas are shared via a git submodule (`vendor/scrum4me`) so there's exactly one definition of every type. From the agent's perspective, Scrum4Me looks like a set of native tools prefixed `mcp__scrum4me__*`. - -This chapter is the **onboarding tour**. The full tool reference (all 18 tools, their parameters, and edge cases) is in [`docs/runbooks/mcp-integration.md`](../runbooks/mcp-integration.md). - -## Three ways the agent works - -| Mode | Triggered by | Loop | -|---|---|---| -| **Track A — MCP-driven** | User says *"implement the next story"* | `get_claude_context` → execute tasks → `update_task_status` → commit per layer → repeat until queue empty → push + PR | -| **Track B — Manual** | User describes a one-off change in chat | Read pattern + styling → edit → verify → wait for `commit it` → commit | -| **Worker — Queue-driven** | Background worker container running on a Mac/NAS | `wait_for_job` (blocks ≤600s) → switch on `kind` → execute → `update_job_status` → loop forever | - -CLAUDE.md describes Track A and Track B; this manual focuses on the **Worker** mode because it's the most novel and the most likely to surprise a new contributor reading server logs. - -## A typical Track A run - -```mermaid -sequenceDiagram - participant U as User - participant C as Claude - participant M as MCP server - participant DB as Postgres - - U->>C: "implement the next story" - C->>M: get_claude_context(product_id) - M->>DB: SELECT product, sprint, next story, tasks - M-->>C: { story, tasks[], pbi, sprint } - loop per task in sort_order - C->>M: update_task_status(task_id, 'in_progress') - C->>C: read pattern + styling, edit files - C->>M: log_implementation(story_id, content) - C->>M: update_task_status(task_id, 'review') - C->>M: log_test_result(story_id, 'PASSED') - C->>M: update_task_status(task_id, 'done') - end - C->>U: "milestone ready for your test" - U->>C: "looks good, push it" - C->>C: git push + gh pr create -``` - -The contract every step relies on: - -- All inputs are **lowercase API enums** (`'in_progress'`, never `'IN_PROGRESS'`); the MCP server applies [`lib/task-status.ts`](../../lib/task-status.ts) under the hood. -- Status writes are **forbidden for demo accounts** — they return `403`. See [02 — Statuses](./02-statuses-and-transitions.md#db-vs-api-mapping) and [`docs/adr/0006-demo-user-three-layer-policy.md`](../adr/0006-demo-user-three-layer-policy.md). -- Bearer tokens are bound to a product. `list_products` returns only what the token can see; `get_claude_context` is product-scoped. - -## Idea jobs vs task implementation - -The worker `wait_for_job` returns a payload with a `kind` discriminator. The agent must switch on it: - -| `kind` | Behaviour | -|---|---| -| `TASK_IMPLEMENTATION` | Default. Execute the `implementation_plan`, follow the [git workflow](./03-git-workflow.md), end with `update_job_status('done')`. | -| `IDEA_GRILL` | Read embedded `prompt_text` + existing `idea.grill_md`. Iterate with `ask_user_question` / `get_question_answer`. End with `update_idea_grill_md(markdown)`. | -| `IDEA_MAKE_PLAN` | Read `prompt_text` + `idea.grill_md`. **Do not ask questions** — single-pass output in strict YAML-frontmatter. End with `update_idea_plan_md(markdown)`. Server-side parser may reject → `PLAN_FAILED`. | -| `PLAN_CHAT` | Conversational refinement loop on an existing plan (M12+). | -| `SPRINT_IMPLEMENTATION` | Sprint-level run that cascades through every task; `update_task_status` calls must include the `sprint_run_id`. | - -For the full Idea state machine (DRAFT → GRILLING → … → PLANNED) see [02 — Statuses & Transitions § Idea](./02-statuses-and-transitions.md#idea). - -## The Q&A channel - -When Claude needs a human decision mid-run, it doesn't block silently — it posts a question through the MCP and either polls or returns control: - -```mermaid -sequenceDiagram - participant C as Claude - participant M as MCP - participant DB as Postgres - participant U as User (NavBar bell) - C->>M: ask_user_question({ story_id, question, wait_seconds: 600 }) - M->>DB: INSERT user_question; NOTIFY user_question_created - DB-->>U: SSE event → bell pulses - U->>M: POST /api/questions/:id/answer - M->>DB: UPDATE user_question; NOTIFY user_question_answered - DB-->>C: ask_user_question returns { answer } - C->>C: continue execution -``` - -Key facts: - -- `wait_seconds` is capped at 600. If the user doesn't answer in time, `ask_user_question` returns with status `pending`; Claude can resume later via `get_question_answer(question_id)`. -- Idea questions (`{ idea_id }` instead of `{ story_id }`) are **user-private** — they bypass `productAccessFilter`, so collaborators don't see them. -- A question can be cancelled by the asker via `cancel_question`. - -The persistent design (table + `LISTEN/NOTIFY`) is documented in [`docs/architecture/claude-question-channel.md`](../architecture/claude-question-channel.md). - -## The worker's pre-flight quota check - -The worker doesn't blindly call `wait_for_job`. Each iteration it first checks Anthropic API quota via `bin/worker-quota-probe.sh` so it doesn't burn a 10-minute block on a queue it can't actually process. The full algorithm — settings, `worker_heartbeat` SSE event, sleep-until-reset — is in [`docs/runbooks/mcp-integration.md`](../runbooks/mcp-integration.md#pre-flight-quota-check-m13). The Docker chapter ([05](./05-docker.md#quota-probe)) shows how to test it locally. - -## Schema-drift watchdog - -If Scrum4Me's Prisma schema changes but `scrum4me-mcp` isn't synced, the MCP server will fail at runtime — not at deploy. To prevent that, a remote agent runs every Monday at 08:00 Amsterdam time, syncs `vendor/scrum4me`, and runs `prisma:generate` + `tsc --noEmit` in `scrum4me-mcp`. Drift reports must be resolved **before** any Scrum4Me PR with schema changes can merge. See [`docs/runbooks/mcp-integration.md`](../runbooks/mcp-integration.md#schema-drift-bewaking). - -## Deep links - -| Topic | Authoritative source | -|---|---| -| Tool reference (all 18 tools) | [`docs/runbooks/mcp-integration.md`](../runbooks/mcp-integration.md) | -| Worker idempotency & job-status protocol | [`docs/runbooks/worker-idempotency.md`](../runbooks/worker-idempotency.md) | -| Q&A channel architecture (table + LISTEN/NOTIFY) | [`docs/architecture/claude-question-channel.md`](../architecture/claude-question-channel.md) | -| Idea-laag plan & prompts | [`docs/plans/M12-ideas.md`](../plans/M12-ideas.md) | -| Sprint execution modes (PER_TASK vs SPRINT_BATCH) | [`docs/architecture/sprint-execution-modes.md`](../architecture/sprint-execution-modes.md) | -| Realtime NOTIFY payload contract | [`docs/patterns/realtime-notify-payload.md`](../patterns/realtime-notify-payload.md) | -| Demo-user write protection | [`docs/adr/0006-demo-user-three-layer-policy.md`](../adr/0006-demo-user-three-layer-policy.md) | - -## What's next - -→ [05 — Docker](./05-docker.md) covers how the worker container is run, debugged, and operated. diff --git a/docs/manual/05-docker.md b/docs/manual/05-docker.md deleted file mode 100644 index 4400160..0000000 --- a/docs/manual/05-docker.md +++ /dev/null @@ -1,149 +0,0 @@ ---- -title: "Docker" -status: active -audience: [contributor] -language: en -last_updated: 2026-05-07 -when_to_read: "Before running the worker locally, debugging a stuck job, or operating the Mac/NAS deployment." ---- - -# 05 — Docker - -This chapter is the contributor's tour of the Docker side of Scrum4Me. Two important up-front facts: - -1. **The Next.js app is not containerised.** The web UI, API routes, server actions, and database connection all run on **Vercel** (serverless functions + Edge runtime). There is no `Dockerfile` in this repo and no `docker-compose.yml`. -2. **Only the worker is containerised.** The "worker" is a Claude Code agent in a long-running container that polls the Scrum4Me job queue via MCP and executes `TASK_IMPLEMENTATION` / `IDEA_GRILL` / `IDEA_MAKE_PLAN` / `SPRINT_IMPLEMENTATION` jobs. - -The container image and its supporting scripts live in a **separate repo**: [`madhura68/scrum4me-docker`](https://github.com/madhura68/scrum4me-docker). This manual documents the consumer side — what the worker is, how it relates to Scrum4Me, and how to diagnose issues. The container internals (Dockerfile, entrypoint, agent provisioning) are out of scope for this manual; see that repo's README. - -> **Note:** A separate sandbox repo `scrum4me-sbx` ([`SC-4`](https://github.com/madhura68/scrum4me-sbx)) exists for Docker exploration. Treat it as a scratchpad, not as the production worker. - -## Topology - -```mermaid -flowchart LR - subgraph Vercel - App[Next.js app<br/>+ API routes] - end - subgraph Neon - DB[(Postgres)] - end - subgraph Mac["Mac (default) / NAS (opt-in)"] - Worker[Worker container<br/>Claude Code + MCP] - end - Worker -- MCP over HTTPS --> App - App -- Prisma --> DB - Worker -- git push --> GH[GitHub] - GH -- webhooks --> App -``` - -- The worker **never connects to the database directly**. All state changes go through MCP tools, which call the Vercel-hosted REST API, which writes to Neon via Prisma. -- The worker **does** push commits directly to GitHub. GitHub then notifies Vercel and the auto-PR flow ([03 — Git Workflow](./03-git-workflow.md)) takes over. - -## Mac vs NAS - -| Flow | When to use | Status | -|---|---|---| -| **Mac-native (arm64)** | Default for development and small teams | Active | -| **NAS** | Self-hosted always-on worker on a Synology / Asustor / similar | Opt-in, validated by historical smoke tests in [`docs/docker-smoke/`](../docker-smoke/) | - -The Mac flow is the default because it doesn't require dedicated hardware. The container runs natively on Apple Silicon (arm64) — no x86 emulation overhead. - -## Environment variables the worker needs - -The worker container needs **only** what's required to authenticate to MCP and push to GitHub: - -| Var | Purpose | -|---|---| -| `SCRUM4ME_BEARER_TOKEN` | Bearer token bound to a product. Returned by the user's API-token settings page. | -| `SCRUM4ME_BASE_URL` | Usually `https://scrum4me.vercel.app` (or the user's domain). | -| `GITHUB_TOKEN` | Personal access token with `repo` scope, used by `git push` and `gh pr create`. | -| `ANTHROPIC_API_KEY` | The Claude API key used by the worker process. | -| `MIN_QUOTA_PCT` | Optional. Worker pauses if Anthropic quota drops below this percentage. | - -> **Hardstop:** the worker does **not** need `DATABASE_URL`, `SESSION_SECRET`, or `CRON_SECRET`. Those belong to the Next.js app; the worker only talks to MCP. If you find yourself adding DB env vars to the worker, stop — you're solving the wrong problem. - -The full list and provisioning instructions live in the [`scrum4me-docker` README](https://github.com/madhura68/scrum4me-docker). **TODO:** link to specific sections of that README once it's stable. - -## What the worker loop does, on a single iteration - -```mermaid -sequenceDiagram - participant W as Worker - participant Q as worker-quota-probe.sh - participant M as MCP server - W->>Q: probe Anthropic quota - Q-->>W: { pct, reset_at_iso } - alt pct < MIN_QUOTA_PCT - W->>M: worker_heartbeat(pct, last_quota_check_at) - W->>W: sleep until reset_at_iso (cap 1h) - else quota ok - W->>M: worker_heartbeat(pct, last_quota_check_at) - W->>M: wait_for_job (block ≤600s, claim atomically) - alt queue empty - W->>W: continue (no work, loop again) - else got job - W->>W: execute by `kind` - W->>M: update_job_status(done|failed) - end - end - Note over W: continue forever -``` - -The loop is described authoritatively in [`docs/runbooks/mcp-integration.md`](../runbooks/mcp-integration.md#batch-loop-verplichte-agent-flow) and [`docs/runbooks/worker-idempotency.md`](../runbooks/worker-idempotency.md). - -### Quota probe - -`bin/worker-quota-probe.sh` (in `scrum4me-docker`) makes a tiny call to the Anthropic API to read the current quota percentage and reset time. Cost: ~1 output token per probe (~12 tokens/hour at 5-minute intervals). The default `MIN_QUOTA_PCT` is **20%** — typically high enough on Pro/Max plans that the worker never pauses during normal day-job hours. - -### Heartbeat - -Every iteration the worker calls `worker_heartbeat({ last_quota_pct, last_quota_check_at })`. The MCP server emits an SSE event so the NavBar in the Next.js app shows the worker as live. A heartbeat older than 15 seconds is rendered as "offline" / "stand-by" in the UI. - -### Stale-claim recovery - -If a worker dies mid-job (process crash, container kill, network partition), its claimed job stays as `CLAIMED` in the database. After **30 minutes** the next `wait_for_job` call automatically requeues it (`CLAIMED → QUEUED`) before claiming a fresh one. No manual intervention is required for clean recovery. - -When you **do** need to manually requeue a job (e.g. you killed it intentionally and don't want to wait 30 min), the operator route is the admin board → "Requeue job" button. **TODO:** confirm the exact UI path; this is not yet documented in `docs/runbooks/`. - -## Running the worker locally - -The intended local workflow per the project's standing memory is **Mac-native Docker** (the user's `project_docker_default_target` memory). High-level steps (verify against the [scrum4me-docker README](https://github.com/madhura68/scrum4me-docker) for exact commands): - -1. Clone `scrum4me-docker` next to `Scrum4Me/` (so `~/Development/Scrum4Me/scrum4me-docker/`). -2. Provision the env vars above (typically a `.env` file in that repo, **not committed**). -3. `docker build` the image and `docker run` it with the env file mounted. -4. Watch container logs for the heartbeat/quota cycle. -5. Trigger a job from the UI ("Voer alle uit" on the Solo Board) and verify the worker picks it up within ~5 seconds. - -> **TODO:** once the `scrum4me-docker` README has stabilised, replace the bullets above with copy-paste-ready commands. Until then, defer to that repo for canonical instructions. - -## Debugging a stuck worker - -| Symptom | Likely cause | Fix | -|---|---|---| -| Worker shows offline in NavBar but container is running | `worker_heartbeat` not reaching MCP | Check `SCRUM4ME_BASE_URL` and `SCRUM4ME_BEARER_TOKEN`; tail container logs for HTTP errors | -| Worker logs say "stand-by" indefinitely | `pct < MIN_QUOTA_PCT` and reset_at not reached | Lower `MIN_QUOTA_PCT` for testing, or wait for the printed `reset_at_iso` | -| Job stuck `CLAIMED` for >30 min | Worker died mid-job | Wait — auto-requeue triggers on next `wait_for_job` | -| Worker claims job but never updates status | Crashed before `update_job_status`; container restarted in a loop | Check `docker logs`; the next `wait_for_job` will requeue stale claims | -| `update_job_status` returns `403` | Bearer token doesn't match `claimed_by_token_id` | The token was rotated mid-run; restart with fresh token | - -For deeper troubleshooting see [06 — Troubleshooting](./06-troubleshooting.md). - -## Smoke-test references - -Historical Docker smoke tests live in [`docs/docker-smoke/`](../docker-smoke/). They validated the worktree-isolation + branch-per-story flow when the Docker worker was first introduced. They are **historical** — don't expect them to be runnable as-is — but they're a useful reference when you want to verify the same flow on a new container image. - -## Deep links - -| Topic | Source | -|---|---| -| Container image, Dockerfile, build | [`scrum4me-docker` repo](https://github.com/madhura68/scrum4me-docker) | -| Worker loop & quota check | [`docs/runbooks/mcp-integration.md`](../runbooks/mcp-integration.md#pre-flight-quota-check-m13) | -| Worker idempotency / job-status protocol | [`docs/runbooks/worker-idempotency.md`](../runbooks/worker-idempotency.md) | -| Historical smoke tests | [`docs/docker-smoke/`](../docker-smoke/) | -| Sandbox / exploration repo | [`scrum4me-sbx` repo](https://github.com/madhura68/scrum4me-sbx) | - -## What's next - -→ [06 — Troubleshooting](./06-troubleshooting.md) covers error codes and recovery procedures across the full stack. diff --git a/docs/manual/06-troubleshooting.md b/docs/manual/06-troubleshooting.md deleted file mode 100644 index 05df556..0000000 --- a/docs/manual/06-troubleshooting.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -title: "Troubleshooting" -status: active -audience: [contributor] -language: en -last_updated: 2026-05-07 -when_to_read: "When something breaks. Start with the symptom table; fall back to the error-code reference." ---- - -# 06 — Troubleshooting - -This chapter is the **first place to look** when something is wrong. Each row links to the authoritative source so you can dig deeper without losing your trail. - -## Error code reference - -These three HTTP status codes are non-negotiable hardstops in the API surface — they always mean the same thing across every route handler. - -| Code | Meaning | Where it comes from | -|---|---|---| -| **`400`** | JSON parse error | Body couldn't be parsed as JSON. Usually a malformed request from a client. | -| **`422`** | Zod validation error | Body parsed, but failed schema validation. Response includes the offending field path. | -| **`403`** | Demo-user write blocked | Authenticated user `is_demo = true` attempted a write. Three layers enforce this — see [`docs/adr/0006-demo-user-three-layer-policy.md`](../adr/0006-demo-user-three-layer-policy.md). | - -> **Hardstop:** these codes are reserved. Do not use `400` for validation errors or `422` for unauthorised access. The contract is enforced at the route-handler level — see the [Route Handler pattern](../patterns/route-handler.md). - -Other common codes: - -| Code | Meaning | -|---|---| -| `401` | No session / invalid bearer token | -| `404` | Resource not found, or token does not have access | -| `409` | State conflict — e.g. trying to claim a job that's already `CLAIMED` | -| `429` | Rate-limited — typically the Anthropic quota cap, not Scrum4Me itself | -| `500` | Unhandled server error. Always check Vercel function logs. | - -## Symptom → cause → fix - -### MCP - -| Symptom | Likely cause | Fix | -|---|---|---| -| `mcp__scrum4me__get_claude_context` returns `null` or empty story | Bearer token doesn't have access to that product | Run `mcp__scrum4me__list_products` to confirm scope; rotate the token if needed | -| `mcp__scrum4me__update_task_status` returns `403` | Demo user, or token mismatch in a sprint run | Check user identity; if inside a sprint run, the bearer token must match `claimed_by_token_id` of the parent job | -| `mcp__scrum4me__wait_for_job` returns nothing for the full 600s block | Queue is genuinely empty | This is normal — loop and call again. See [`runbooks/mcp-integration.md`](../runbooks/mcp-integration.md#batch-loop-verplichte-agent-flow) | -| Job stays `CLAIMED` for >30 minutes | Worker died mid-job | Auto-requeue triggers on next `wait_for_job`; no manual action needed | -| `update_idea_plan_md` causes idea to flip to `PLAN_FAILED` | `parsePlanMd` server-side rejected the YAML-frontmatter | Inspect `IdeaLog{JOB_EVENT, errors}` for the parse error; re-run `IDEA_MAKE_PLAN` after fixing the prompt | - -### Statuses & data integrity - -| Symptom | Likely cause | Fix | -|---|---|---| -| Status displayed differently in DB vs UI | Some code path bypassed `lib/task-status.ts` | Grep the codebase for direct enum string usage; force everything through the mappers. See [`adr/0004-status-enum-mapping.md`](../adr/0004-status-enum-mapping.md) | -| Story stuck `IN_SPRINT` when all tasks are `DONE` | Auto-promotion not triggered | Check the most recent `update_task_status` call — it may have failed silently. Re-issue with the correct task | -| PBI not auto-promoting to `DONE` | Not all child stories are `DONE` yet | List stories under the PBI; one is probably still `OPEN` or `IN_SPRINT` | -| `422` from `create_pbi` / `create_story` / `create_task` | Zod validation failed (length cap, missing required field) | Response body includes field path — fix and retry | -| `IdeaStatus` stays `GRILLING` long after the worker stopped | The job ended without calling `update_idea_grill_md` | Check the worker logs for an exception; manually requeue or mark `GRILL_FAILED` to allow retry | - -### Git & deploy - -| Symptom | Likely cause | Fix | -|---|---|---| -| Unexpected Vercel preview build appeared mid-batch | An interim push happened that shouldn't have | Inspect `git log --all --graph` for the offending push; review [`runbooks/branch-and-commit.md`](../runbooks/branch-and-commit.md) | -| PR has multiple Vercel deployments for the same commit range | Force-push, or push-then-revert | Don't force-push. If genuinely needed, document in the PR description | -| Auto-PR didn't open after story `DONE` | Story not actually `DONE`, or auto-PR pre-conditions unmet | Walk through [`runbooks/auto-pr-flow.md`](../runbooks/auto-pr-flow.md); typically a missing `update_task_status('done')` for the last task | -| Vercel skipped the deploy entirely | `skip-deploy` label or path-filter excluded the changed paths | See [`runbooks/deploy-control.md`](../runbooks/deploy-control.md) for the rules | -| Merge conflict between two parallel batches | Two branches touched the same files | Serialise: merge the first PR before pushing the second. Then `git fetch origin main && git rebase origin/main` | - -### Realtime - -| Symptom | Likely cause | Fix | -|---|---|---| -| Solo Board doesn't update when status changes | SSE connection dropped, or NOTIFY payload missing fields | Reload the page; if it persists, check `DIRECT_URL` (LISTEN/NOTIFY needs the pooler-bypass URL). See [`patterns/realtime-notify-payload.md`](../patterns/realtime-notify-payload.md) | -| NavBar bell doesn't pulse on new question | SSE/event channel mismatched, or payload missing required fields | Confirm the question was actually inserted (`mcp__scrum4me__list_open_questions`); inspect the Network tab for the SSE connection | -| Worker shows offline despite a running container | `worker_heartbeat` not reaching MCP | Verify `SCRUM4ME_BASE_URL` and bearer token; tail container logs | - -### Auth & sessions - -| Symptom | Likely cause | Fix | -|---|---|---| -| Login redirects in a loop | Session cookie not set; usually `SESSION_SECRET` mismatch between deployments | Check Vercel env vars for `SESSION_SECRET` (must be ≥32 chars); see [`patterns/iron-session.md`](../patterns/iron-session.md) | -| All write buttons disabled with "Niet beschikbaar in demo-modus" tooltip | You're logged in as the demo user | Log out and log in with a real account | -| `403` on a route that should be allowed | Proxy or server-action layer rejected the request | Walk through the three layers in [`adr/0006-demo-user-three-layer-policy.md`](../adr/0006-demo-user-three-layer-policy.md); each can independently say "no" | - -### Build & dev-server - -| Symptom | Likely cause | Fix | -|---|---|---| -| `npm run build` fails with `Cannot find module '@/...'` | TypeScript path alias mismatch | Check `tsconfig.json` `paths`; rerun `npm run prebuild` if codegen is stale | -| Mermaid diagram renders as plain text in the in-app `/manual` viewer | `MermaidBlock` not picking up `language-mermaid` | See [04 — MCP Integration](./04-mcp-integration.md) won't help here — open `app/(app)/manual/_components/mermaid-block.tsx` and confirm the dynamic import is `ssr: false` | -| "Server-only" import error in browser | A `*-server.ts` module was imported into a client component | Refactor — split server logic out, or use a server action. Hardstop in [`CLAUDE.md`](../../CLAUDE.md#hardstop-regels) | -| `npm run dev` shows hydration mismatch | Server and client render diverge — usually time-based or random values | Wrap in `useEffect` for client-only state, or pass server time as a prop | - -## When in doubt - -1. **Read the runbook.** Each runbook in [`docs/runbooks/`](../runbooks/) starts with a `when_to_read` field — match the situation. -2. **Check the ADRs.** The ADR index in [`docs/INDEX.md`](../INDEX.md) lists the rationale for every cross-cutting decision. If your fix would contradict an ADR, talk to a maintainer first. -3. **Read the agent-flow pitfalls log.** [`docs/runbooks/agent-flow-pitfalls.md`](../runbooks/agent-flow-pitfalls.md) is a living list of issues found during agent runs and how they were resolved. -4. **Look at recent commits.** `git log --oneline --since='7 days ago'` often reveals the very change that broke whatever you're debugging. - -## Escalation - -If after the steps above the issue is still unresolved: - -- **AI agent / MCP issues** → file in the [`scrum4me-mcp` repo](https://github.com/madhura68/scrum4me-mcp). -- **Worker container issues** → file in the [`scrum4me-docker` repo](https://github.com/madhura68/scrum4me-docker). -- **App / data / status issues** → file in the [`Scrum4Me` repo](https://github.com/madhura68/Scrum4Me). - -## What's next - -You've reached the end of the manual. Bookmark this troubleshooting chapter — it's the most-revisited page once you're past onboarding. - -Back to [index](./index.md). diff --git a/docs/manual/index.md b/docs/manual/index.md deleted file mode 100644 index 5fe0fa7..0000000 --- a/docs/manual/index.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: "Scrum4Me Developer Manual" -status: active -audience: [contributor] -language: en -last_updated: 2026-05-07 -when_to_read: "Onboarding to Scrum4Me as a human contributor." ---- - -# Scrum4Me Developer Manual - -Welcome. This manual is the **map** of Scrum4Me — a guided tour through the moving parts of the project. It is written for a new human contributor who needs to understand how the pieces fit together before diving into the authoritative reference docs (the runbooks, ADRs, and patterns under [`docs/`](../INDEX.md)). - -> **The manual is the map. The runbooks are the territory.** -> When two sources disagree, trust the runbook or ADR linked from this manual. - -## Audience - -- **New human contributors** picking up the project for the first time. -- **Returning contributors** who want a quick refresher on how a specific subsystem (statuses, git, MCP, Docker) fits into the whole. -- **Not for**: AI agents — they should follow [`CLAUDE.md`](../../CLAUDE.md) and the agent-specific runbooks under [`docs/runbooks/`](../runbooks/). - -## How to read this manual - -| You want to… | Read | -|---|---| -| …get the elevator pitch and project structure | [01 — Overview](./01-overview.md) | -| …understand how a PBI/Story/Task moves through its lifecycle | [02 — Statuses & Transitions](./02-statuses-and-transitions.md) | -| …know when to branch, commit, push, and open a PR | [03 — Git Workflow](./03-git-workflow.md) | -| …see how Claude Code drives stories via the MCP server | [04 — MCP Integration](./04-mcp-integration.md) | -| …run the worker container locally or understand the deploy topology | [05 — Docker](./05-docker.md) | -| …diagnose an error code, stuck job, or weird state | [06 — Troubleshooting](./06-troubleshooting.md) | - -A linear read takes about 30 minutes. As a lookup reference, jump straight to a chapter — each one stands alone. - -## Conventions - -- **Cross-references** use relative links (`../runbooks/...`) so they work both in GitHub and inside the in-app `/manual` viewer. -- **Callouts** use blockquotes prefixed with a label: `> **Note:**`, `> **Warning:**`, `> **Hardstop:**` (a non-negotiable rule from [`CLAUDE.md`](../../CLAUDE.md)). -- **Code blocks** show shell commands with no `$` prefix, so they're copy-pasteable. -- **State diagrams** use Mermaid `stateDiagram-v2`; they render in GitHub and in the in-app viewer. -- **Status labels** are written in `UPPER_SNAKE` when referring to the database value and `lowercase` when referring to the API representation — see [02 — Statuses & Transitions](./02-statuses-and-transitions.md#db-vs-api-mapping) for the contract. - -## In-app rendering - -Every chapter in this manual is also browsable inside the running Scrum4Me app at `/manual`. The in-app sidebar mirrors this index, and Mermaid diagrams render in place. The markdown files under `docs/manual/` are the **source of truth** — the in-app page reads them at build time via the `scripts/build-manual.mjs` generator. - -## What this manual does **not** cover - -- **REST API reference** → [`docs/api/rest-contract.md`](../api/rest-contract.md) -- **Component & dialog specs** → [`docs/specs/dialogs/`](../specs/dialogs/) -- **Architecture deep-dives** → [`docs/architecture.md`](../architecture.md) breadcrumb -- **Decision rationale** → [`docs/adr/`](../adr/) -- **Implementation patterns** → [`docs/patterns/`](../patterns/) -- **AI-agent instructions** → [`CLAUDE.md`](../../CLAUDE.md) and [`docs/runbooks/mcp-integration.md`](../runbooks/mcp-integration.md) - -## Table of contents - -1. [Overview](./01-overview.md) — what Scrum4Me is, the entity hierarchy, the stack, repository layout -2. [Statuses & Transitions](./02-statuses-and-transitions.md) — state machines for every entity -3. [Git Workflow](./03-git-workflow.md) — branching, commits, PRs, deploy controls -4. [MCP Integration](./04-mcp-integration.md) — the agent loop, idea jobs, the Q&A channel -5. [Docker](./05-docker.md) — worker container, local dev, scrum4me-docker -6. [Troubleshooting](./06-troubleshooting.md) — error codes, stuck jobs, recovery procedures diff --git a/docs/obsidian-authoring.md b/docs/obsidian-authoring.md index 0848e43..4d7f017 100644 --- a/docs/obsidian-authoring.md +++ b/docs/obsidian-authoring.md @@ -163,7 +163,8 @@ Use with care: Avoid as canonical source: - **Canvas**, **Excalidraw** — not diff-able, not agent-readable. Keep - diagrams as committed SVG or as Mermaid blocks inside Markdown. + diagrams as committed SVG (`docs/assets/erd.svg`) or as Mermaid blocks inside + Markdown. ## Index generator interaction diff --git a/docs/old/plans/Local github setup.md b/docs/old/plans/Local github setup.md deleted file mode 100644 index 07d160a..0000000 --- a/docs/old/plans/Local github setup.md +++ /dev/null @@ -1,371 +0,0 @@ -# Advies — Zelf een Git-platform hosten naast of in plaats van GitHub - -## Situatie - -Je wilt onderzoeken of je lokaal of op een eigen server een Git repository-platform kunt draaien vergelijkbaar met GitHub. - -In jouw situatie spelen mee: - -- Next.js/Vercel apps -- AI-workers / automation -- batch processing -- deploy pipelines -- private code -- mogelijk draaien op NAS of VPS -- integratie met Claude Code / Codex / agents - -Het antwoord is: ja, dit kan uitstekend. - ---- - -# Architectuur-opties - -## Optie 1 — Alleen een centrale Git remote - -De lichtste oplossing. - -Je draait alleen een zogenaamde "bare repo" op een Linux server. - -### Voordelen - -- extreem simpel -- weinig resources -- volledige controle -- SSH push/pull - -### Nadelen - -- geen webinterface -- geen PR’s -- geen issues -- geen gebruikersbeheer -- geen CI/CD UI - -### Setup - -Server: - -```bash -mkdir -p /srv/git/myapp.git -cd /srv/git/myapp.git -git init --bare -``` - -Client: - -```bash -git remote add origin ssh://user@server:/srv/git/myapp.git -git push -u origin main -``` - ---- - -# Optie 2 — Self-hosted GitHub alternatief - -Dit is meestal de beste keuze. - -Software opties: - -| Software | Omschrijving | -|---|---| -| Gitea | Lichtgewicht GitHub alternatief | -| Forgejo | Community fork van Gitea | -| GitLab | Zeer compleet maar zwaar | -| OneDev | Moderne alles-in-één oplossing | - ---- - -# Aanbevolen keuze: Gitea - -## Waarom - -Voor jouw situatie is Gitea waarschijnlijk de beste balans tussen: - -- eenvoud -- performance -- features -- beheerlast - -Je krijgt: - -- Git hosting -- web UI -- pull requests -- issues -- SSH support -- webhooks -- CI integratie -- Docker support -- private repos -- multi-user support - ---- - -# Aanbevolen architectuur voor jouw setup - -## Huidige richting - -```text -MacBook - ↓ -GitHub - ↓ -Vercel deploy -``` - -## Uitgebreide AI workflow - -```text -MacBook - ↓ -Gitea / GitHub - ↓ webhook -AI Worker Server - ↓ -Repo clone - ↓ -Code generatie - ↓ -Commit + push - ↓ -PR creation - ↓ -Merge - ↓ -Vercel deploy -``` - ---- - -# Beste strategie voor jouw situatie - -## Advies: hybride model - -Gebruik: - -| Component | Platform | -|---|---| -| publieke repos | GitHub | -| deploys | Vercel | -| AI worker orchestration | eigen server | -| interne experimenten | Gitea | -| automation | self-hosted | - -Waarom: - -- GitHub ecosystem blijft beschikbaar -- recruiters herkennen GitHub -- Copilot integratie blijft optimaal -- minder beheer -- sneller stabiel - ---- - -# Wanneer volledig self-hosted interessant wordt - -Volledig self-hosted wordt interessant als: - -- privacy belangrijk is -- AI agents autonoom moeten kunnen werken -- je volledige controle wilt -- je GitHub limieten wilt vermijden -- je meerdere workers wilt draaien - -Dan bouw je: - -```text -Gitea - + Postgres - + Docker Registry - + CI Runners - + Reverse Proxy - + Backups - + Monitoring -``` - ---- - -# Aanbevolen infrastructuur - -## Lichtgewicht setup - -### Hardware - -- Synology NAS of mini-PC -- 8–16 GB RAM -- SSD opslag - -### Software stack - -| Component | Advies | -|---|---| -| OS | Ubuntu Server | -| Containers | Docker Compose | -| Git platform | Gitea | -| Reverse proxy | Traefik | -| Database | Postgres | -| SSL | Let's Encrypt | -| Deploys | Vercel | - ---- - -# Docker Compose voorbeeld - -```yaml -services: - gitea: - image: gitea/gitea:latest - container_name: gitea - - ports: - - "3000:3000" - - "222:22" - - volumes: - - ./gitea:/data - - restart: always -``` - -Starten: - -```bash -docker compose up -d -``` - -Daarna bereikbaar via: - -```text -http://server-ip:3000 -``` - ---- - -# Belangrijke aandachtspunten - -## Backups - -Bij self-hosting moet je zelf regelen: - -- database backups -- repo backups -- disaster recovery - ---- - -## Security - -Je bent zelf verantwoordelijk voor: - -- updates -- SSH security -- firewall -- SSL certificaten -- gebruikersbeheer - ---- - -## CI/CD - -GitHub Actions vervang je mogelijk door: - -- Gitea Actions -- Drone CI -- Woodpecker CI -- self-hosted runners - ---- - -# Integratie met jouw AI-worker ideeën - -Dit sluit zeer goed aan op jouw eerdere ideeën: - -- Neon database events -- worker servers -- auto-generated PR’s -- selective deploys -- batch execution - -Je kunt bijvoorbeeld: - -1. story wordt aangemaakt -2. worker krijgt event via SSE/webhook -3. repo wordt gecloned -4. AI implementeert wijziging -5. commit + push -6. PR automatisch aangemaakt -7. review pipeline start -8. merge → deploy - -Dit wordt veel eenvoudiger wanneer je volledige controle hebt over de Git infrastructuur. - ---- - -# Concrete roadmap - -## Fase 1 — huidige setup stabiliseren - -Hou: - -- GitHub -- Vercel -- Neon - -Voeg toe: - -- AI worker server -- webhooks -- automation pipeline - ---- - -## Fase 2 — interne Git infrastructuur - -Installeer: - -- Gitea -- Docker -- Postgres - -Gebruik dit voor: - -- experimenten -- AI-generated branches -- interne repos -- automation testing - ---- - -## Fase 3 — geavanceerde automation - -Later toevoegen: - -- self-hosted runners -- preview environments -- deploy approvals -- selective deployments -- agent orchestration - ---- - -# Eindadvies - -Voor jouw situatie: - -## Niet meteen GitHub vervangen - -Dat levert nu vooral extra beheerlast op. - -## Wel nu al beginnen met: - -- eigen AI worker server -- webhook automation -- lokale Git orchestration -- Gitea testomgeving - -Dat sluit perfect aan op: - -- Scrum4Me -- AI-assisted development -- batch story execution -- autonome pipelines diff --git a/docs/old/plans/PBI-11-mobile-shell.md b/docs/old/plans/PBI-11-mobile-shell.md deleted file mode 100644 index b3df338..0000000 --- a/docs/old/plans/PBI-11-mobile-shell.md +++ /dev/null @@ -1,198 +0,0 @@ -# PBI-11 — Mobile-shell met landscape-lock (settings + backlog + solo) - -> **Status:** READY · priority 3 · sort_order 8 -> **Stories:** ST-1133 (TaskDialog full-screen) · ST-1134 (foundation) · ST-1135 (UA-redirect) · ST-1136 (settings) · ST-1137 (backlog) · ST-1138 (solo) · ST-1139 (docs + E2E) - -## Doel - -Scrum4Me bruikbaar maken op een mobiele telefoon, beperkt tot drie schermen — Settings (account + product-selector + QR-pairing-instructie + logout), Product Backlog (PBI/Story/Task aanmaken), Solo Paneel (voortgang vastleggen). Landscape-orientatie afgedwongen via PWA-manifest + CSS-overlay. App-naam en -icoon onderdrukken op `/m/*`. Desktop-app blijft ongewijzigd. - -## Drie architectuur-beslissingen - -### Beslissing A — gedeelde dialog-classes (raakt ST-1133 + ST-1138) - -Alle entity-dialogen (PbiDialog, StoryDialog, TaskDialog, TaskDetailDialog) delen dezelfde class-string in [components/shared/entity-dialog-layout.ts](../../components/shared/entity-dialog-layout.ts): - -```ts -export const entityDialogContentClasses = cn( - 'flex flex-col p-0 gap-0', - 'max-h-[90vh] w-full max-w-[calc(100%-2rem)]', - 'sm:max-w-[90vw] sm:max-h-[85vh]', - 'lg:max-w-[50vw] lg:min-w-[480px]', -) -``` - -→ Mobile-fullscreen wordt via één edit op deze constant geregeld: - -```ts -'max-sm:w-screen max-sm:h-screen max-sm:max-w-none max-sm:rounded-none' -``` - -**Gevolg voor stories:** -- ST-1133 T-317 muteert `entity-dialog-layout.ts`, niet `task-dialog.tsx` rechtstreeks -- ST-1138 T-332 vervalt als file-edit — wordt verify-only (controleer dat TaskDetailDialog mee-erft) -- PBI/Story-dialogen krijgen mobile-fullscreen "voor niets" (handig voor ST-1137) - -### Beslissing B — eigen route group `app/(mobile)/` - -Parent layout `app/(app)/layout.tsx` rendert NavBar, MinWidthBanner, StatusBar, SoloRealtimeBridge, NotificationsBridge. Een nested layout in `(app)/m/` kan deze parent-output **niet** verwijderen (Next.js layouts erven naar binnen, niet vervangen). - -**Keuze:** verplaats `/m/*` naar een eigen route group `app/(mobile)/m/{settings,pair,products}/...` met eigen `app/(mobile)/layout.tsx`. - -**Auth-guard duplicatie voorkomen** door `getSession()`-check te extraheren naar `lib/auth-guard.ts`: - -```ts -// lib/auth-guard.ts -export async function requireSession() { - const session = await getSession() - if (!session.userId) redirect('/login') - return session -} -``` - -Beide layouts (`(app)/layout.tsx` en `(mobile)/layout.tsx`) roepen deze helper aan. Bestaande `/m/pair/page.tsx` (M10 QR-pairing) verhuist mee naar `app/(mobile)/m/pair/page.tsx` — geen URL-wijziging, alleen filesystem-move. - -**Gevolg voor stories:** -- ST-1134 T-321 schrijft `app/(mobile)/layout.tsx`, niet `app/(app)/m/layout.tsx` -- ST-1136 page wordt `app/(mobile)/m/settings/page.tsx` -- ST-1137 page wordt `app/(mobile)/m/products/[id]/page.tsx` -- ST-1138 page wordt `app/(mobile)/m/products/[id]/solo/page.tsx` -- M10's `/m/pair` verhuist naar `app/(mobile)/m/pair/` — URL ongewijzigd, geen redirect-migratie nodig - -### Beslissing C — gescheiden SplitPane cookie-key - -ST-1137 hergebruikt `BacklogSplitPane` (drie panelen). Op mobile rendert die in tab-mode (auto-switch + back-button uit ST-1116). De SplitPane bewaart split-percentages in een cookie. - -**Keuze:** gescheiden cookie-key voor mobile — `split-pane:backlog-3-mobile:<id>` — zodat mobile-gebruikers (die in tab-mode geen split-percentages bewerken maar wel terug kunnen schakelen) de desktop-split niet beïnvloeden. - -**Gevolg voor stories:** -- ST-1137 T-328 geeft expliciete `cookieKey`-prop aan `BacklogSplitPane` op de mobile-route - -## Hergebruik (al aanwezig) - -| Wat | Bron | -|---|---| -| Mobile tab-mode in `SplitPane` (incl. `tabLabels`, `mobileBreakpoint`, `activeTab`) | ST-1116 — [components/split-pane/split-pane.tsx](../../components/split-pane/split-pane.tsx) | -| Click-cascade auto-switch in `BacklogSplitPane` | ST-1116 commit `3e86a8d` | -| QR-pairing route `/m/pair` | M10 commit `625221f` | -| `/m/pair` confirmation page | bestaand | -| Functional-spec mobile-tabs sectie | `docs/specs/functional.md:234-235` | - -## Stories - -### ST-1133 — TaskDialog full-screen op mobile (verifieer en fix) - -**Doel:** entity-dialogen renderen 100vw × 100vh op viewport `<640px`. - -**Acceptance:** -- `entityDialogContentClasses` in `components/shared/entity-dialog-layout.ts` bevat `max-sm:w-screen max-sm:h-screen max-sm:max-w-none max-sm:rounded-none` -- Sticky header en footer blijven bereikbaar; body scrollt -- Werkt voor TaskDialog, TaskDetailDialog, PbiDialog, StoryDialog (alle gebruiken de constant) -- Tests dekken mobile-render via `window.innerWidth`-mock voor minstens TaskDialog en TaskDetailDialog -- Geen regressie op desktop (`sm:max-w-[90vw]` blijft op `>=640px`) - -**Tasks:** -- T-316 inventariseer huidige render -- T-317 fix de gedeelde constant -- T-318 tests - -### ST-1134 — Mobile shell foundation (route group + landscape-guard + tab-bar + manifest) - -**Doel:** route group `(mobile)`, landscape-overlay, bottom tab-bar, PWA-manifest. - -**Acceptance:** -- `app/(mobile)/layout.tsx` rendert zonder NavBar / AppIcon / MinWidthBanner / StatusBar -- Auth-guard via gedeelde `lib/auth-guard.ts` helper; `(app)/layout.tsx` gebruikt dezelfde helper -- `<LandscapeGuard>` toont rotate-overlay in portrait (window.matchMedia) -- `<MobileTabBar>` bottom-fixed met 3 lucide-iconen (ListTree, Activity, Settings); tap-targets ≥44×44 px -- `public/manifest.json` bevat `"orientation": "landscape"` -- M10 `/m/pair` verhuist filesystem-only naar `app/(mobile)/m/pair/` — URL onveranderd -- Tests: LandscapeGuard render-states, TabBar route-active, auth-guard helper - -**Tasks:** -- T-319 LandscapeGuard -- T-320 MobileTabBar -- T-321 `(mobile)/layout.tsx` + manifest + auth-guard extractie + filesystem-move van `/m/pair` - -### ST-1135 — Mobile UA-redirect bij login - -**Acceptance:** -- `lib/user-agent.ts` exporteert `isPhoneUA(ua: string | null): boolean` op basis van `Mobi`-substring -- `actions/auth.ts` `loginAction` redirect bij phone-UA naar `/m/products/[active]/solo`; zonder actief product naar `/m/settings` -- Tablet-UA en desktop-UA blijven op `/dashboard` -- Demo-user volgt zelfde routing -- Tests dekken alle paden (phone met/zonder product, tablet, desktop, null UA, demo) - -**Tasks:** T-322 helper · T-323 loginAction integratie · T-324 tests - -### ST-1136 — Mobile Settings-pagina - -**Acceptance:** -- `app/(mobile)/m/settings/page.tsx` -- Toont username, isDemo-badge, actief-product-naam -- Product-selector — klik → `setActiveProductAction` + redirect `/m/products/[id]/solo` -- QR-pairing-instructie — link "Open scrum4me.app/login op je desktop om in te loggen via QR" -- Logout-knop met AlertDialog "Uitloggen?" → `logoutAction` -- Geen avatar-upload, geen bio-edit -- Tests render-states + logout-flow - -**Tasks:** T-325 layout · T-326 logout-flow · T-327 tests - -### ST-1137 — Mobile Product Backlog-pagina - -**Acceptance:** -- `app/(mobile)/m/products/[id]/page.tsx` hergebruikt PbiList/StoryPanel/TaskPanel + backlog-store -- `BacklogSplitPane` rendert in tab-mode op `<1024px`; auto-switch op selectie blijft werken -- TaskDialog-searchParams wiring (`?newTask=`, `?editTask=`, `?storyId=`) werkt -- Cookie-key gescheiden: `split-pane:backlog-3-mobile:<id>` -- + knoppen voor PBI/Story/Task werken; demo blijft read-only -- Tests: page-rendering met initial state, tab-mode, click-cascade-flow - -**Tasks:** T-328 page wrapper + cookie-key · T-329 TaskDialog wiring · T-330 tests - -### ST-1138 — Mobile Solo Paneel - -**Acceptance:** -- `app/(mobile)/m/products/[id]/solo/page.tsx` hergebruikt SoloBoard -- 3 kanban-kolommen blijven; horizontal scroll -- TaskDetailDialog rendert 100vw × 100vh op `<640px` — **gedekt door beslissing A** (entityDialogContentClasses) -- "Voer uit"-knop bereikbaar -- SSE-stream blijft werken -- Tests: solo-page rendert, TaskDetailDialog erft mobile-classes (zonder eigen file-edit) - -**Tasks:** -- T-331 page wrapper -- T-332 verify-only (geen file-edit; controleer dat shared constant uit ST-1133 doorwerkt) -- T-333 tests - -### ST-1139 — Docs sync + end-to-end verificatie - -**Acceptance:** -- `docs/specs/functional.md` heeft "Mobile shell"-sectie; desktop-first-clausule herzien -- `docs/architecture.md` beschrijft route group `(mobile)`, manifest landscape, UA-redirect, gedeelde auth-guard -- `npm run lint && npm test && npm run build` slagen -- E2E checklist (11 punten — zie hieronder) -- Bekende limiet: iOS Safari PWA-orientation-lock werkt niet 100% — CSS-overlay als fallback - -**Tasks:** T-334 functional-spec · T-335 architecture-doc · T-336 E2E-verificatie - -## Verificatie (E2E checklist uit T-336) - -1. `npm run lint && npm test && npm run build` slagen -2. DevTools mobile-emulatie iPhone 12 landscape: `/m/products/[id]` rendert tab-mode, geen NavBar, tab-bar onderaan -3. Portrait → rotate-overlay zichtbaar; landscape → overlay verdwijnt -4. Tab-bar 3 iconen werken (Backlog/Solo/Settings) -5. Login phone-UA → redirect `/m/products/[id]/solo`; desktop-UA → `/dashboard` -6. Backlog-flow: + PBI, + Story, + Task in TaskDialog -7. Solo-flow: tap task → TaskDetailDialog full-screen, "Voer uit"-knop bereikbaar -8. TaskDialog full-screen op `<640px` (via shared constant) -9. PWA-installatie test op echte mobile (Android of iOS) -10. `/m/pair` QR-flow intact na route-group-verhuizing -11. Demo op mobile read-only; logout via `/m/settings` werkt; geen Scrum4Me-tekst of AppIcon op `/m/*` - -## Out of scope - -- Tablets (geen Mobi-UA) blijven desktop-flow gebruiken -- iOS PWA full-orientation-lock (CSS-overlay is fallback) -- Avatar/bio editor op mobile-settings -- 1-koloms-kanban (3-koloms blijft, swipe horizontaal) diff --git a/docs/old/plans/PBI-75-sprint-task-edit-store.md b/docs/old/plans/PBI-75-sprint-task-edit-store.md deleted file mode 100644 index b122553..0000000 --- a/docs/old/plans/PBI-75-sprint-task-edit-store.md +++ /dev/null @@ -1,128 +0,0 @@ -# PBI-75 — Sprint task-edit client-side via workspace-store - -## Context - -In het Sprint-scherm (`/products/<id>/sprint/<sprintId>`) duurt het bewerken van een taak onevenredig lang. Klik op een taakregel of het potlood-icoon roept `router.push(?editTask=<id>)` aan vanuit [`components/sprint/task-list.tsx:226`](../../components/sprint/task-list.tsx). Dat triggert: - -- **Volledige server re-render** van [`app/(app)/products/[id]/sprint/[sprintId]/page.tsx`](../../app/(app)/products/[id]/sprint/[sprintId]/page.tsx) met zware queries (sprint, alle sprintStories+tasks, productMembers, alle PBIs+stories voor het backlog-paneel) -- **Tweede Prisma-query** in [`EditTaskLoader`](../../app/_components/tasks/edit-task-loader.tsx) voor task-detail (incl. `implementation_plan`) -- **Na save**: `router.push(closePath)` + `revalidatePath` → opnieuw alle queries - -De [`sprint-workspace-store`](../../stores/sprint-workspace/store.ts) (sinds PBI-74 Story 9) bevat al alles voor een client-side edit-flow: - -- [`setActiveTask(taskId)`](../../stores/sprint-workspace/store.ts) (regel 337) — zet `context.activeTaskId` + roept `ensureTaskLoaded()` aan -- [`ensureTaskLoaded(taskId)`](../../stores/sprint-workspace/store.ts) (regel 414) — `GET /api/tasks/{id}` → upsert met `_detail: true` -- [`selectActiveTask`](../../stores/sprint-workspace/selectors.ts) (regel 91) — bestaat, nog geen consumer -- [`applyTaskEvent`](../../stores/sprint-workspace/store.ts) (regel 748) — SSE-events propageren idempotent na server-save -- Optimistic-mutation primitives (`applyOptimisticMutation` / `settleMutation` / `rollbackMutation`) - -Het patroon "URL-param → store" bestaat al voor product-workspace in [`components/backlog/url-task-sync.tsx`](../../components/backlog/url-task-sync.tsx) — we volgen dat als precedent. - -**Doel**: klik op een taak opent de edit-dialoog client-side via store-state binnen ~100ms. Geen URL-navigatie, geen server re-render, alleen `GET /api/tasks/{id}` voor het detail. - -## Aanpak - -**Architectuur**: store-mounted dialog + URL-sync component voor deeplinks. - -1. **Klik-flow**: `TaskList.openEditDialog` roept `setActiveTask(taskId)` aan op de store. Geen `router.push`. -2. **Render-flow**: nieuwe client-component `SprintTaskDialogMount` zit binnen `SprintHydrationWrapper`, subscribet `selectActiveTask`, en rendert `<TaskDialog>` zodra de active task `_detail === true` is. -3. **Save-flow**: `TaskDialog` krijgt optionele `onClose`/`onSaved` callbacks (backwards compatible met bestaande `closePath`). Mount geeft `onClose = () => setActiveTask(null)`. Server action `saveTask` blijft `revalidatePath` doen voor server-cache; SSE-event update store via `applyTaskEvent`. -4. **Deeplink-flow**: nieuwe `SprintUrlTaskSync` leest `?editTask=<id>` en roept `setActiveTask(id)` aan (analoog aan `url-task-sync.tsx`). - -## Bestanden + wijzigingen - -### Nieuw — `components/sprint/sprint-task-dialog-mount.tsx` -Client component. Subscribet `selectActiveTask` (single-value, geen `useShallow`). Wanneer task aanwezig is en `isDetail(task)` true, mappt naar `TaskDialogTask`-shape: -- `status`: via `taskStatusFromApi` uit [`lib/task-status.ts`](../../lib/task-status.ts) (lowercase API → Prisma UPPER_SNAKE) -- `implementation_plan: task.implementation_plan ?? null` -- `created_at: new Date(task.created_at)` - -Rendert `<TaskDialog task={mapped} productId={productId} onClose={() => setActiveTask(null)} isDemo={isDemo} />`. Geen render tussen `setActiveTask` en `_detail: true` (detail-fetch <100ms). - -### Nieuw — `components/sprint/sprint-url-task-sync.tsx` -Kopie van [`components/backlog/url-task-sync.tsx`](../../components/backlog/url-task-sync.tsx) maar tegen `useSprintWorkspaceStore` en `writeTaskHint` uit [`stores/sprint-workspace/restore`](../../stores/sprint-workspace/restore.ts). - -### Wijziging — `components/sprint/task-list.tsx` (regels 225-227) -Vervang: -```ts -function openEditDialog(taskId: string) { - router.push(`${pathname}?editTask=${taskId}`) -} -``` -door: -```ts -function openEditDialog(taskId: string) { - useSprintWorkspaceStore.getState().setActiveTask(taskId) -} -``` -`openCreateDialog` (regel 222) blijft URL-gebaseerd — out-of-scope. - -### Wijziging — `app/(app)/products/[id]/sprint/[sprintId]/page.tsx` -- Verwijder `editTask` uit searchParams-destructuring (regel 36) -- Verwijder `editTask &&`-block met `<Suspense><EditTaskLoader>` (regels 250-260) -- Verwijder ongebruikte imports (`EditTaskLoader`, `TaskDialogSkeleton`, evt. `Suspense`) -- Mount binnen `SprintHydrationWrapper`: - ```tsx - <SprintHydrationWrapper ...> - <SprintBoardClient ... /> - <SprintTaskDialogMount productId={id} isDemo={isDemo} /> - <SprintUrlTaskSync /> - </SprintHydrationWrapper> - ``` -- `newTask`-block (regels 241-248) blijft ongemoeid — out-of-scope. - -### Wijziging — `app/_components/tasks/task-dialog.tsx` -Maak `closePath` optioneel + voeg `onClose`/`onSaved` toe (backwards compatible): -```ts -interface TaskDialogProps { - task?: TaskDialogTask - storyId?: string - productId: string - closePath?: string - onClose?: () => void - onSaved?: (taskId: string) => void - isDemo?: boolean -} -``` -Refactor de drie `router.push(closePath)`-calls (regels 104, 120, 155) naar één helper: -```ts -function close() { - if (onClose) { onClose(); return } - if (closePath) router.push(closePath) -} -``` -Bestaande callers (`EditTaskLoader`, mobile, product-page, sprint `newTask`-block) blijven werken via `closePath`. Nieuwe `SprintTaskDialogMount` gebruikt `onClose`. - -### Geen wijziging -- `stores/sprint-workspace/selectors.ts` — `selectActiveTask` bestaat al -- `app/_components/tasks/edit-task-loader.tsx` — nog gebruikt door product-page en mobile - -## Edge cases - -- **Status-enum mapping**: store API-lowercase → Prisma UPPER_SNAKE via `taskStatusFromApi`, fallback `'TO_DO'` -- **`_detail: true` race**: mount rendert pas wanneer `isDetail(task)` true is — geen flash met undefined velden -- **Demo-mode**: prop blijft via server doorlopen, dialog respecteert al `isDemo` -- **Dirty-close-guard**: ingebouwd in dialog (regels 107, 172) — werkt via `onClose` -- **SSE na save**: `applyTaskEvent` updatet store automatisch -- **Deeplink + task niet bestaat**: `GET /api/tasks/{id}` 404 → store doet niets, dialog opent niet (huidige `redirect()` verdwijnt — acceptabel) - -## Verificatie - -1. **Browser** (`npm run dev`): klik op task in takenlijst → dialog opent <100ms, geen URL-verandering, alleen `GET /api/tasks/<id>` in Network -2. **Save**: wijzig titel → Opslaan → dialog sluit → store toont nieuwe titel via SSE -3. **Deeplink**: `?editTask=<id>` → dialog opent via `SprintUrlTaskSync` -4. **Bestaande flows ongebroken**: product-page edit, mobile edit, sprint `?newTask=1` -5. **`npm run verify && npm run build`** -6. **Vitest**: `__tests__/components/sprint/sprint-task-dialog-mount.test.tsx` — hydreer store, mock fetch, `setActiveTask(id)`, assert UPPER_SNAKE status + `onClose` clear - -## Risico's - -- Andere mounts (mobile, product-backlog, sprint `newTask`) blijven URL-gebaseerd — `closePath?` optional houdt ze werkend -- Geen `redirect()` bij not-found-deeplink (klein UX-verschil) -- SSE-latency 100-500ms na save — eventueel later mitigeren via `applyOptimisticMutation` in `onSaved`-callback - -## Out-of-scope (follow-up PBIs) - -- `?newTask=1`-flow naar store -- Mobile + product-backlog mounts -- `EditTaskLoader` verwijderen wanneer alle callers over zijn diff --git a/docs/old/plans/PBI-78-cost-analysis-widget.md b/docs/old/plans/PBI-78-cost-analysis-widget.md deleted file mode 100644 index 4847656..0000000 --- a/docs/old/plans/PBI-78-cost-analysis-widget.md +++ /dev/null @@ -1,186 +0,0 @@ -# PBI-78 — Cost-analyse widget op Insights-pagina - -## Context - -De insights-pagina heeft al een `TokenUsageCard` die KPI's + per-job tabel toont, maar **alleen voor de actieve sprint van een gefilterd product**. Daardoor mis je het globale plaatje: hoeveel geef je deze maand uit, welk model is de grootste kostenpost, hoe goed werkt prompt-caching, en welke job-kinds (IDEA_MAKE_PLAN met Opus vs TASK_IMPLEMENTATION met Sonnet) trekken het budget. - -We voegen een nieuwe sectie **"Cost analyse"** toe (tussen Sprint Health en Plan-quality). Eén shared periode-selector (7d/30d/90d/MTD) stuurt vier visualisaties aan op basis van best practices uit de Anthropic Console + LLM-observability tools (Datadog, Portkey): - -1. Trend-chart over tijd -2. Breakdown per model -3. Breakdown per job-kind -4. Cache efficiency - -De bestaande `TokenUsageCard` blijft staan als sprint-detail (top duurste jobs voor de actieve sprint). - -## Bestaande infrastructuur (hergebruik) - -**Reeds aanwezig in DB:** - -- [prisma/schema.prisma](../../prisma/schema.prisma) — `ClaudeJob` heeft `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `actual_thinking_tokens`, `model_id`, `kind`, `finished_at` -- `ModelPrice` tabel met prijzen per 1M tokens (input/output/cache_read/cache_write) -- Prijzen worden gesynced via [scripts/sync-model-prices.ts](../../scripts/sync-model-prices.ts) - -**Hergebruikbare patronen:** - -- KPI-strip stijl: zie [app/(app)/insights/components/token-usage.tsx](../../app/(app)/insights/components/token-usage.tsx) (regels 43-64) -- URL-param-gestuurde filter met `useTransition` + `router.replace`: zie [app/(app)/insights/components/agent-throughput.tsx](../../app/(app)/insights/components/agent-throughput.tsx) (regels 38-62) -- Recharts BarChart pattern: zie [app/(app)/insights/components/agent-throughput.tsx](../../app/(app)/insights/components/agent-throughput.tsx) (regels 110-130) -- Cost-formule (zelfde overal): zie [lib/insights/token-stats.ts](../../lib/insights/token-stats.ts) (regels 73-79) — input + output + cache_read + cache_write + thinking, allemaal `× price_per_1m / 1_000_000` -- Server component → parallel data-fetch via `Promise.all`: zie [app/(app)/insights/page.tsx](../../app/(app)/insights/page.tsx) (regels 46-80) - -## Te bouwen - -### Taak 1 — Data-laag — `lib/insights/cost-analysis.ts` (nieuw) - -Eén bestand met vijf functies, allemaal `(userId, period)` als parameters. Period wordt naar `WHERE cj.finished_at >= NOW() - INTERVAL '<n> days'` vertaald (MTD = `>= date_trunc('month', NOW())`). - -```ts -export type Period = '7d' | '30d' | '90d' | 'mtd' - -export interface CostKpi { - totalCostUsd: number - totalTokens: number - jobCount: number - avgPerDayUsd: number - cacheSavingsUsd: number // (input_price - cache_read_price) × cache_read_tokens - topModelId: string | null - topModelCostUsd: number -} - -export interface CostByDayRow { day: string; costUsd: number } -export interface CostByModelRow { modelId: string; costUsd: number; jobCount: number } -export interface CostByKindRow { kind: string; costUsd: number; jobCount: number } -export interface CacheEfficiency { - cacheReadTokens: number - uncachedInputTokens: number - cacheHitRatio: number // cache_read / (cache_read + input) - savingsUsd: number - spentOnCacheWriteUsd: number -} - -export async function getCostKpi(userId: string, period: Period): Promise<CostKpi> -export async function getCostByDay(userId: string, period: Period): Promise<CostByDayRow[]> -export async function getCostByModel(userId: string, period: Period): Promise<CostByModelRow[]> -export async function getCostByKind(userId: string, period: Period): Promise<CostByKindRow[]> -export async function getCacheEfficiency(userId: string, period: Period): Promise<CacheEfficiency> -``` - -**Belangrijke details:** - -- Alle queries: `WHERE cj.user_id = ${userId} AND cj.status = 'DONE' AND cj.finished_at >= <periodStart>` -- Geen `productAccessFilter` nodig — `cj.user_id = ${userId}` filtert al op de eigenaar -- `getCostByDay` vult ontbrekende dagen op met `0` (anders breekt de chart-x-as) — vul aan client- of server-side, kies één -- Periode → days mapping inline: `7d`→7, `30d`→30, `90d`→90, `mtd`→huidige dag-van-maand -- Cache savings: `cache_read_tokens × (input_price - cache_read_price) / 1_000_000` — "wat je betaald zou hebben zonder cache, minus wat je betaalde mét cache" - -### Taak 2 — UI — `app/(app)/insights/components/cost-analysis.tsx` (nieuw) - -Eén client-component die de hele sectie rendert. Structuur: - -``` -[Period selector rechtsboven] -[KPI strip: Totaal | Cache savings | Avg/dag | Top model ($X op claude-opus-4-7)] -[grid grid-cols-1 md:grid-cols-2 gap-4] - [Daily cost line/bar chart] [Model breakdown - horizontal bar of donut] - [Job-kind breakdown - bar] [Cache efficiency - donut + label "X% hit, $Y bespaard"] -``` - -**Period selector:** kopieer pattern uit [agent-throughput.tsx](../../app/(app)/insights/components/agent-throughput.tsx) (regels 50-61) — `useTransition` + `router.replace` met `?period=` in URL. Default tonen als "30d". - -**Charts:** Recharts (al gebruikt in `BurndownChart`, `AgentThroughputCard`, `VelocityChart`): - -- Daily: `<BarChart>` met één bar (cost in USD), x-as = dag (`MM-DD`), tooltip toont `$X.XXXX` -- Model: `<BarChart layout="vertical">` met model_id labels — beperkt tot top 5 -- Kind: `<BarChart layout="vertical">` met kind labels — beperkt tot top 5 -- Cache: `<PieChart>` met twee segmenten (cached / uncached input) + tekst "X% cache hit · $Y bespaard" - -**Empty state:** als `kpi.jobCount === 0`: render één regel "Geen jobs in deze periode." - -### Taak 3 — Integratie — `app/(app)/insights/page.tsx` (edit) - -Wijzigingen: - -```diff - interface InsightsPageProps { -- searchParams: Promise<{ product?: string }> -+ searchParams: Promise<{ product?: string; period?: string }> - } -``` - -```diff -- const { product: filterProductId } = await searchParams -+ const { product: filterProductId, period: rawPeriod } = await searchParams -+ const period = (['7d','30d','90d','mtd'].includes(rawPeriod ?? '') ? rawPeriod : '30d') as Period -``` - -In de `Promise.all`, voeg toe: - -```ts -getCostKpi(userId, period), -getCostByDay(userId, period), -getCostByModel(userId, period), -getCostByKind(userId, period), -getCacheEfficiency(userId, period), -``` - -Nieuwe sectie tussen Sprint Health en Plan-quality: - -```tsx -<section className="space-y-3"> - <h2 className="text-lg font-medium text-foreground">Cost analyse</h2> - <CostAnalysisCard - period={period} - kpi={costKpi} - byDay={costByDay} - byModel={costByModel} - byKind={costByKind} - cache={cacheEff} - /> -</section> -``` - -De bestaande "Token gebruik" sectie blijft staan (sprint-detail tabel). - -## Bestanden - -**Nieuw:** - -- `lib/insights/cost-analysis.ts` — 5 query-functies + types -- `app/(app)/insights/components/cost-analysis.tsx` — client-component met period-selector + 4 charts - -**Edit:** - -- `app/(app)/insights/page.tsx` — period uit searchParams, parallel-fetch, nieuwe sectie - -**Geen wijzigingen aan:** - -- Prisma schema (alle data is er al) -- MCP server (token-data wordt al weggeschreven via `update_job_status`) -- `TokenUsageCard` (blijft als sprint-detail tabel) - -## Verificatie - -```bash -npm run verify && npm run build -``` - -**Handmatig:** - -1. Open `/insights` zonder query — period default `30d`, sectie toont KPI + 4 charts -2. Wissel period via selector → URL updatet `?period=7d`, charts laden nieuwe data via `router.replace` -3. Check empty state: kies periode zonder jobs → "Geen jobs in deze periode." -4. Sanity-check KPI's tegen ruwe DB-query: - ```sql - SELECT SUM(input_tokens * mp.input_price_per_1m / 1e6 - + output_tokens * mp.output_price_per_1m / 1e6 - + cache_read_tokens * mp.cache_read_price_per_1m / 1e6 - + cache_write_tokens * mp.cache_write_price_per_1m / 1e6 - + COALESCE(actual_thinking_tokens, 0) * mp.input_price_per_1m / 1e6) - FROM claude_jobs cj - LEFT JOIN model_prices mp ON mp.model_id = cj.model_id - WHERE cj.user_id = '<id>' AND cj.status = 'DONE' - AND cj.finished_at >= NOW() - INTERVAL '30 days'; - ``` -5. Cache savings sanity: `cacheSavingsUsd ≈ cache_read_tokens × 0.9 × input_price / 1M` - (cache_read prijs = 0.1× input prijs, dus savings is 90%) diff --git a/docs/old/plans/PBI-79-backlog-sprint-workflow.md b/docs/old/plans/PBI-79-backlog-sprint-workflow.md deleted file mode 100644 index 6912e0f..0000000 --- a/docs/old/plans/PBI-79-backlog-sprint-workflow.md +++ /dev/null @@ -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-meeg­e­update 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-meeg­e­updated 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) diff --git a/docs/old/plans/auto-pr-deploy-sync.md b/docs/old/plans/auto-pr-deploy-sync.md deleted file mode 100644 index 9375c46..0000000 --- a/docs/old/plans/auto-pr-deploy-sync.md +++ /dev/null @@ -1,486 +0,0 @@ -# Plan — Auto-PR + selectieve deploy-controle + sync-zicht (end-to-end batch flow) - -> Bij merge: dit plan verplaatsen naar `docs/plans/auto-pr-deploy-sync.md` -> conform feedback-memory (plans in `docs/plans/`). - -## Context - -Drie samenhangende problemen rond de "idee → uitvoeren"-keten: - -1. **Worker stopt bij `commit`.** De Scrum4Me NAS-worker werkt lokaal: - commits blijven op de machine staan totdat de gebruiker zelf pusht en - een PR aanmaakt. Voor batch-uitvoer van story-jobs is dit een harde - menselijke gate. -2. **Deploy is alles-of-niets.** `.github/workflows/ci.yml` deployt nu - **elke** push naar `main` automatisch naar productie en **elke** PR - naar preview. `vercel.json` heeft geen `git.deploymentEnabled: false`, - dus Vercel's eigen Git-integratie deployt waarschijnlijk parallel mee - → dubbele deploys en geen selectieve controle. -3. **Geen zicht op voortgang per Idea/PBI.** Concreet getest geval: - PBI-33 wordt nu de eerste sprint-batch — er is **geen git-voetafdruk** - (geen branch/commit/PR met "PBI-33"), **geen activiteitenlog-entry**, - en geen UI-pagina die per Story toont of er een ClaudeJob loopt, een - commit gepusht is, of een PR open/merged is. De data zit in - `Story.status`, `ClaudeJob.pushed_at/branch/pr_url`, - `Pbi.pr_url/pr_merged_at` — er is alleen geen view die het joint. - -Doel: de complete keten **plan → job → commit → push → PR → auto-merge → -deploy** in één coherent ontwerp leggen, met (a) selectieve -deploy-controle als veiligheidsklep en (b) een sync-tab die per Idea -laat zien wat er werkelijk in git/PR-land gebeurd is. - -## Vastgelegde keuzes - -### Deploy-controle -1. **Mechanisme**: PR-labels (B) + path-filter (C) gecombineerd. -2. **Eigenaar**: GitHub Actions-workflow (A). Vercel Git-integratie uit. -3. **Defaults**: PR → preview, push naar `main` → productie. -4. **Override-richtingen**: - - `skip-deploy` label: voorkomt preview-deploy op een PR. - - `force-deploy` label: forceert deploy ook als path-filter doc-only - zegt. - -### Auto-PR (uit IDEA-007-grill) -5. **Triggers in worker**: na elke succesvolle `update_job_status('done')` - pusht de worker; na laatste story van een PBI maakt de worker een PR - aan en activeert auto-merge (SQUASH). -6. **Auth**: `GITHUB_TOKEN` als omgevingsvariabele op de worker; geen UI - of GitHub App in v1. -7. **Foutafhandeling**: push/PR-aanmaak-fail → `update_job_status('failed', - error: …)`; geen force-push, geen automatische retry. - -### Interactie tussen beide -8. **Worker-PRs gebruiken hetzelfde labelsysteem als alle andere PRs.** - Default = preview deploy, auto-merge wacht op CI groen, na merge - prod-deploy (mits path-filter zegt "code"). De worker zet **geen** - labels automatisch — als je batch-output zonder preview wilt mergen - moet je `skip-deploy` zelf toevoegen, of preview later uitzetten via - een product-instelling (out-of-scope v1). -9. **Implementatievolgorde**: eerst deploy-controle (infra, - onafhankelijk), daarna auto-PR (afhankelijk van stabiele deploy-flow). - -## Architectuur in één plaat - -``` - auto-merge wacht op -[story-job DONE] ─push branch─┐ deploy-preview groen - ▼ │ -[laatste story?]──ja──[PR + auto-merge]──CI──┴──merge naar main - │ - [job: ci] altijd - │ - [paths-filter] - │ - ├ PR → deploy-preview - │ if code && !skip-deploy - │ || force-deploy - │ - └ push → deploy-production - if code -``` - ---- - -## Deel A — Deploy-controle - -### A.1 `vercel.json` — Vercel Git-deploy uitzetten - -```json -{ - "$schema": "https://openapi.vercel.sh/vercel.json", - "git": { "deploymentEnabled": false }, - "crons": [ - { "path": "/api/cron/expire-questions", "schedule": "0 4 * * *" }, - { "path": "/api/cron/cleanup-agent-artifacts", "schedule": "0 3 * * *" } - ] -} -``` - -Effect: Vercel deployt niet meer automatisch op git-events. Alleen -`vercel deploy` vanuit de workflow (met `VERCEL_TOKEN`) maakt nog -deployments. - -### A.2 `.github/workflows/ci.yml` — path-filter + label-checks - -Triggers uitbreiden met `workflow_dispatch`: - -```yaml -on: - push: - branches: [main] - pull_request: - branches: [main] - workflow_dispatch: - inputs: - target: - type: choice - description: Deploy target - options: [preview, production] - default: preview -``` - -Nieuwe job vóór de deploy-jobs: - -```yaml - changes: - name: Detect deploy-relevant changes - runs-on: ubuntu-latest - needs: ci - outputs: - code: ${{ steps.filter.outputs.code }} - steps: - - uses: actions/checkout@v5 - - uses: dorny/paths-filter@v3 - id: filter - with: - filters: | - code: - - 'app/**' - - 'components/**' - - 'lib/**' - - 'actions/**' - - 'stores/**' - - 'prisma/**' - - 'public/**' - - 'package.json' - - 'package-lock.json' - - 'next.config.ts' - - 'tsconfig.json' - - 'vercel.json' - - 'proxy.ts' - - 'middleware.ts' - - '.github/workflows/**' -``` - -`deploy-preview` if-conditie aanpassen: - -```yaml - deploy-preview: - needs: [ci, changes] - if: | - github.event_name == 'pull_request' && ( - (needs.changes.outputs.code == 'true' - && !contains(github.event.pull_request.labels.*.name, 'skip-deploy')) - || contains(github.event.pull_request.labels.*.name, 'force-deploy') - ) -``` - -`deploy-production` if-conditie aanpassen: - -```yaml - deploy-production: - needs: [ci, changes] - if: | - github.ref == 'refs/heads/main' - && github.event_name == 'push' - && needs.changes.outputs.code == 'true' -``` - -Nieuwe `deploy-manual` job voor `workflow_dispatch` met `inputs.target` -→ `vercel deploy` of `vercel deploy --prod`. - -### A.3 GitHub-labels aanmaken - -```bash -gh label create skip-deploy --color BFBFBF --description "Preview-deploy overslaan" -gh label create force-deploy --color 0E8A16 --description "Forceer deploy ondanks path-filter" -``` - -### A.4 Documentatie - -`docs/runbooks/deploy-control.md` — triggers, labels, path-filter, -voorbeelden. `CLAUDE.md` § Deployment-regel verwijst naar runbook. - ---- - -## Deel B — Auto-PR (worker → GitHub) - -### B.1 Acceptatiecriteria (uit IDEA-007) - -- **AC 1 — Push per story**: Na succesvolle `update_job_status('done')` - pusht de worker via HTTPS (`https://$GITHUB_TOKEN@github.com/…`) naar - origin. Push-timestamp via nieuwe MCP-call in `ClaudeJob.pushed_at`. -- **AC 2 — Detectie laatste story**: Nieuwe MCP-call `check_pbi_complete` - retourneert `{ complete: boolean, pbi_id }`. -- **AC 3 — PR aanmaken**: Op `complete: true` POST naar - `/repos/{owner}/{repo}/pulls`; titel/body uit PBI-naam + voltooide - stories; PR-URL via `set_pbi_pr`. -- **AC 4 — Auto-merge activeren**: Direct na PR-aanmaak GraphQL - `enablePullRequestAutoMerge` (SQUASH). -- **AC 5 — Foutafhandeling**: push/PR-fail → - `update_job_status('failed', error)`; PR-URL blijft bewaard voor - handmatige inspectie. - -### B.2 Server-side wijzigingen (Scrum4Me-repo) - -Velden bestaan al in schema: -- `Product.auto_pr Boolean @default(false)` (regel 176) -- `Pbi.pr_url String?` + `Pbi.pr_merged_at DateTime?` (regel 207–208) -- `ClaudeJob.pushed_at DateTime?` + `ClaudeJob.pr_url String?` + - `ClaudeJob.branch String?` (regel 335, 338, 339) - -Geen migratie nodig. - -Server actions / REST: bestaande `set_pbi_pr` en `mark_pbi_pr_merged` -MCP-tools blijven. Nieuwe action: -- `actions/jobs.ts` → `recordJobPushedAtAction(jobId)` voor - `pushed_at`-write (als die nog niet via MCP gaat). - -### B.3 MCP-laag (`scrum4me-mcp`-repo) - -Nieuwe tool: -- `check_pbi_complete(pbi_id) → { complete: boolean, pbi_id }`. Leest - alle ClaudeJobs gelinkt aan PBI; aggregeert status. `complete = true` - als **alle** story-jobs status DONE hebben. - -Uitbreiding bestaande tool: -- `update_job_status`: bij `status: 'done'` ook `pushed_at` accepteren - (worker geeft timestamp door). -- `set_pbi_pr`: ongewijzigd, bestaat al. - -Schema-drift watchdog (`docs/runbooks/mcp-integration.md`) moet groen -voor merge. - -### B.4 Worker-laag (lokaal Claude-CLI worker) - -Nieuwe stappen na elke story: - -``` -1. update_job_status('done', pushed_at: null) ← bestaand -2. git push https://$GITHUB_TOKEN@github.com/$OWNER/$REPO.git $BRANCH -3. record_pushed_at(job_id, now) ← nieuwe MCP-call -4. { complete } = check_pbi_complete(pbi_id) -5. if complete: - prNumber = POST /repos/.../pulls - set_pbi_pr(pbi_id, pr_url) - enablePullRequestAutoMerge(prNumber, MERGE_METHOD: SQUASH) -6. on any HTTP/git failure → update_job_status('failed', error) -``` - -GITHUB_TOKEN-scope: `repo` voor private, `public_repo` voor public. -Documenteer in worker-readme. - -### B.5 Repo-instellingen (handmatig, one-time) - -- GitHub repo Settings → General → "Allow auto-merge" → **aanvinken**. -- Branch protection op `main`: required CI checks = `ci`, - `deploy-preview` is **niet** required (kan skipped zijn door label). - ---- - -## Deel C — Interactie & demo-policy - -### C.1 Interactie deploy-controle ↔ auto-PR - -| Scenario | Preview-deploy | Prod-deploy bij merge | -|--------------------------------------------------|----------------|------------------------| -| Worker maakt PR met code-changes (default) | ✅ runt | ✅ runt | -| Worker maakt PR met `skip-deploy` (manueel toegevoegd) | ❌ skipped | ✅ runt | -| Worker maakt PR met enkel docs-changes (path-filter) | ❌ skipped | ❌ skipped | -| User voegt `force-deploy` toe aan doc-only PR | ✅ runt | ✅ runt (path-filter) of ❌ (doc-only push) | - -Auto-merge wacht op required CI checks. `deploy-preview` mag skipped -zijn — branch protection markeert hem niet als required. - -### C.2 Demo-policy - -Auto-PR-flow draait op de worker, niet vanuit de webapp. Geen -demo-sessie kan deze code triggeren — geen extra proxy.ts of -`session.isDemo`-guards nodig. Wel: `check_pbi_complete` MCP-call moet -`requireWriteAccess` doen (consistent met andere write-MCP-tools), zodat -demo-tokens hem niet kunnen aanroepen. - ---- - ---- - -## Deel D — Sync-tab op Idea-detail (zicht op voortgang) - -### D.1 Wat bestaat al - -- `model StoryLog` (`prisma/schema.prisma:251`) met types - `IMPLEMENTATION_PLAN | TEST_RESULT | COMMIT`, plus `commit_hash`, - `commit_message`, `metadata`. **Dit is de activiteitenlog.** -- MCP-tools `log_implementation`, `log_commit`, `log_test_result` - schrijven naar deze tabel. -- UI-component `components/shared/story-log.tsx` rendert - `StoryLogEntry[]` met type-styling. -- `Story.status`, `ClaudeJob.pushed_at/branch/pr_url`, - `Pbi.pr_url/pr_merged_at` zijn al gevuld door bestaande flows. - -Geen nieuwe tabellen, geen migraties. - -### D.2 Nieuwe tab op `/ideas/[id]` - -Voeg vijfde tab **Sync** toe (naast Idee · Grill · Plan · Timeline) op -Idea-detail-page. Alleen zichtbaar als `Idea.status === 'PLANNED'` en -`pbi_id` gevuld. - -Layout per tab-content: -- Header: PBI-link + `pr_url` + `pr_merged_at` als badge. -- Per Story (volgorde uit PBI): collapsible card met: - - **Story-header**: code · titel · status-badge. - - **Job-rij**: voor elke `ClaudeJob` (kind=TASK_IMPLEMENTATION) gelinkt - aan een Task van deze Story → status, `branch`, `pushed_at`, - `pr_url`. Toont "geen job" als nog niets gequeued. - - **Activity-log**: `<StoryLog logs={logs} repoUrl={product.repo_url} />` - — bestaande component, ongewijzigd. - -### D.3 Server-laag - -Nieuwe loader in `app/(app)/ideas/[id]/page.tsx` (of nieuw -`sync-tab-server.ts`): - -```ts -async function loadIdeaSyncData(ideaId: string, userId: string) { - // Auth-scope: idea.user_id === userId (M12-keuze 2) - return prisma.idea.findFirst({ - where: { id: ideaId, user_id: userId }, - include: { - pbi: { - include: { - stories: { - orderBy: { sort_order: 'asc' }, - include: { - tasks: { include: { claude_jobs: true } }, - logs: { orderBy: { created_at: 'desc' } }, - }, - }, - }, - }, - }, - }) -} -``` - -Server-only. Nooit importeren in client component (zie hardstop -`*-server.ts` regel). - -### D.4 Realtime refresh - -Sync-tab abonneert op bestaande SSE-streams: -- `app/api/realtime/solo/route.ts` — `JobPayload` voor job-status-updates - (al uitgebreid met `kind` en `idea_id` per Deel B). -- `app/api/realtime/notifications/route.ts` — voor StoryLog-inserts; als - story_logs nog geen pg_notify-trigger heeft, voeg er een toe (nieuwe - migratie, payload `{op: 'INSERT', entity: 'story_log', id, story_id}`). - -Op event → `router.refresh()` of `revalidate` van Sync-tab data. - -### D.5 PBI-33 als live testgeval - -PBI-33 is **nu** in TODO + gequeued als ClaudeJobs (gebruiker bevestigt: -"taken op TODO gezet en claude-job aangemaakt"). Verwacht gedrag zodra -deze sprint live is: - -| Moment | Sync-tab toont | -|----------------------------|-----------------------------------------------| -| Job QUEUED | "Wachtend op worker" | -| Job RUNNING | Status RUNNING + log-entry IMPLEMENTATION_PLAN| -| Worker commit | log-entry COMMIT (hash + message) | -| Worker test | log-entry TEST_RESULT (status) | -| Worker push (Deel B AC 1) | `branch` + `pushed_at` zichtbaar | -| Laatste story → PR | PBI.`pr_url` zichtbaar | -| Auto-merge | PBI.`pr_merged_at` zichtbaar | - -Als één van deze niet verschijnt: bug in MCP-tool of worker (niet in -sync-tab zelf). - ---- - -## Bestanden - -| Wijziging | Pad | -|-------------------|--------------------------------------------------| -| Edit | `vercel.json` | -| Edit | `.github/workflows/ci.yml` | -| Nieuw | `docs/runbooks/deploy-control.md` | -| Edit | `CLAUDE.md` (verwijzing toevoegen) | -| Nieuw (mcp-repo) | `src/tools/check-pbi-complete.ts` | -| Edit (mcp-repo) | `src/tools/update-job-status.ts` (pushed_at) | -| Edit | `actions/jobs.ts` (optioneel: record-pushed-at) | -| Edit | Worker-script (post-story-hook + PR-aanmaak) | -| Doc | `docs/runbooks/auto-pr-flow.md` (worker-flow) | -| Nieuw | `app/(app)/ideas/[id]/sync-tab-server.ts` | -| Nieuw | `components/ideas/idea-sync-tab.tsx` | -| Edit | `app/(app)/ideas/[id]/page.tsx` (5e tab toevoegen) | -| Migratie | `prisma/migrations/<ts>_story_logs_notify/migration.sql` (pg_notify-trigger op story_logs) | -| Edit | `app/api/realtime/notifications/route.ts` (story_log-payload doorlaten) | -| GitHub (extern) | Labels `skip-deploy`, `force-deploy` aanmaken | -| GitHub (extern) | Repo Settings → "Allow auto-merge" aan | -| Vercel-dashboard | `git.deploymentEnabled: false` actief verifiëren | - -## Implementatievolgorde - -1. **Deel A — Deploy-controle** - 1. `vercel.json` aanpassen - 2. `ci.yml` uitbreiden (path-filter, labels, dispatch) - 3. Labels op GitHub aanmaken - 4. Runbook + CLAUDE.md-verwijzing - 5. Test-PR voor elk scenario (zie Verificatie) - -2. **Deel D — Sync-tab** (kan parallel met B; alleen DB-reads + UI) - 1. `loadIdeaSyncData` server-loader - 2. `idea-sync-tab.tsx` component met `<StoryLog>`-hergebruik - 3. 5e tab in `app/(app)/ideas/[id]/page.tsx` - 4. pg_notify-trigger op `story_logs` + SSE-route uitbreiden - 5. **Live test op PBI-33** (sprint loopt al — check of activity - verschijnt zodra worker logs schrijft) - -3. **Deel B — Auto-PR** - 1. MCP `check_pbi_complete` + `update_job_status(pushed_at)` PR - (parallel-repo, schema-drift-watchdog groen) - 2. Worker-hook: push na done, PR + auto-merge bij complete - 3. Repo-instelling "Allow auto-merge" aan - 4. End-to-end smoke met één test-PBI - -## Verificatie - -Lokaal: - -```bash -npm run lint && npm test && npm run build -``` - -Workflow-syntax: - -```bash -gh workflow view ci.yml -``` - -End-to-end deploy-controle: - -1. **Doc-only PR** → `deploy-preview` skipped. -2. **Doc-only PR + `force-deploy`** → `deploy-preview` runt. -3. **Code-PR + `skip-deploy`** → `deploy-preview` skipped. -4. **Code-PR zonder labels** → `deploy-preview` runt. -5. **Push naar `main` met code-change** → `deploy-production` runt. -6. **Push naar `main` doc-only** → `deploy-production` skipped. -7. **`workflow_dispatch` target=production** → manuele prod. -8. **Vercel dashboard** → geen auto-deploy bij geforceerde test-push. - -End-to-end auto-PR: - -9. Maak een test-PBI met 1 story + 1 task. -10. Worker draait → na `done`: `pushed_at` gevuld, branch op origin - zichtbaar. -11. `check_pbi_complete` → `complete: true`. -12. PR verschijnt op GitHub met titel = PBI-naam, body = story-list. -13. Auto-merge actief; CI groen → squash-merge. -14. `mark_pbi_pr_merged` getriggerd door `pull_request: closed`-webhook - (al bestaand) → `Pbi.pr_merged_at` gevuld. -15. Push-event op `main` → `deploy-production` runt (path-filter ja). -16. **Failure-test**: revoke GITHUB_TOKEN tijdelijk → push faalt → - `update_job_status('failed')` met error; geen PR aangemaakt. - -## Out-of-scope (v1) - -- UI-toggle voor `auto_pr` per product (veld bestaat, geen UI-wiring). -- GitHub App-installatie (per-repo tokens, scopes-finetuning). -- Multi-repo PBI's (huidig ontwerp: één `repo_url` per PBI). -- Force-push / non-fast-forward retry-flow. -- Notificaties (Slack, e-mail) bij merge of CI-failure. -- Rollback-flow bij gemergende regressie. -- Migratie naar `vercel.ts` (knowledge-update beveelt het aan; later). -- Auto-skip preview-deploy specifiek voor worker-PRs op basis van - product-instelling. diff --git a/docs/old/plans/lees-de-readme-md-validated-book.md b/docs/old/plans/lees-de-readme-md-validated-book.md deleted file mode 100644 index 9804ebe..0000000 --- a/docs/old/plans/lees-de-readme-md-validated-book.md +++ /dev/null @@ -1,205 +0,0 @@ -# Scrum4Me-Research — Zustand rearchitecture (reset + execute) - -> **Scope:** dit plan is geschreven voor de research-repo -> [`madhura68/Scrum4Me-Research`](https://github.com/madhura68/Scrum4Me-Research), -> niet voor dit hoofdproject. Bestandsverwijzingen die naar -> `stores/data-store.ts`, `hooks/use-event-stream.ts`, -> `components/*-select.tsx` etc. wijzen, bestaan in de research-repo — -> niet hier. Ze staan in `code`-tags zodat de doc-link-checker ze niet -> probeert te resolven. - -## Context - -Het bestaande [zustand-store-rearchitecture.md](./zustand-store-rearchitecture.md) beschrijft een doel-architectuur (`product-workspace-store` met genormaliseerde entities, race-safe loaders, resync-laag, optimistic mutations). De research-repo is dé plek om dat eerst te testen voordat het in `Scrum4Me/` belandt. - -Probleem nu: de research-repo wijkt af van het hoofdproject. Mijn custom `data-store.ts` lijkt qua vorm op de doel-architectuur, maar springt over de baseline heen. We willen aantonen dat de migratie *vanaf* de huidige Scrum4Me-patronen werkt, niet vanaf een verzonnen tussenvorm. - -Dus: eerst de research-repo terugbrengen naar dezelfde stores/hooks/routes als Scrum4Me nu heeft, dan de rearchitecture daarop uitvoeren. - -## Bron-documenten - -- **Doel-architectuur**: [zustand-store-rearchitecture.md](./zustand-store-rearchitecture.md) (in research-repo). Dit plan voert dat document uit; herhaalt het niet. -- **Conventies**: [CLAUDE.md](../../CLAUDE.md) hoofdproject. Taal NL, MD3 tokens, `@base-ui/react` render-prop, `*-server.ts`, enum UPPER_SNAKE↔lowercase via `lib/task-status.ts`. - -## Drie-faseplan - -### Fase A — Reset naar Scrum4Me-patronen - -Doel: onze research-pagina werkt op exact dezelfde store/hook/route-vorm als het hoofdproject, met identiek gedrag. - -**Verwijderen** (research-repo): -- `stores/data-store.ts` (research-repo) — mijn megastore -- `hooks/use-event-stream.ts` (research-repo) — vervangen door `use-backlog-realtime.ts` -- `hooks/use-browser-presence.ts` (research-repo) — niet in main, drop voor reset -- `app/api/realtime/events/route.ts` (research-repo) — vervangen door `app/api/realtime/backlog/route.ts` -- Mijn custom `loadX/resyncAll`-paden in selectie-componenten - -**Kopiëren uit `/Users/janpetervisser/Development/Scrum4Me/`** (1-op-1 of stripped van auth): -| Bron | Doel | -|---|---| -| `stores/backlog-store.ts` | `stores/backlog-store.ts` (`pbis`, `storiesByPbi`, `tasksByStory`; `setInitialData`, `applyChange`) | -| `stores/planner-store.ts` | `stores/planner-store.ts` (DnD-order; voor research nog niet gebruikt maar we zetten 'm klaar) | -| `stores/selection-store.ts` | overschrijf bestaand (state: `selectedPbiId`, `selectedStoryId`, geen taskId/productId in main; add `selectedTaskId` + `productId` als research-uitbreiding) | -| `stores/product-store.ts` | `stores/product-store.ts` (`currentProduct`) | -| `stores/products-store.ts` | `stores/products-store.ts` (lijst, voor pulldown) | -| `lib/realtime/use-backlog-realtime.ts` | `lib/realtime/use-backlog-realtime.ts` (SSE-client → `applyChange` op backlog-store) | -| `lib/task-status.ts` | `lib/task-status.ts` (enum-converters) | -| `app/api/realtime/backlog/route.ts` | `app/api/realtime/backlog/route.ts` (SSE+LISTEN, **research-only: strip auth/session/getAccessibleProduct** — vraagt enkel `product_id` querystring) | - -> ⚠️ **Auth-strip is research-only.** Het hoofdproject MOET sessie + `getAccessibleProduct()`-check op SSE en read-routes behouden. Bij backport vanaf de research-repo nooit de geknipte route 1-op-1 overnemen. Dit geldt voor zowel `app/api/realtime/backlog/route.ts` als alle read-routes onder `app/api/products/...`, `/pbis/...`, `/stories/...`, `/tasks/...`. - -**API-routes (read)** — bestaande paden behouden, alleen `force-dynamic` blijft: -- `GET /api/products` (list voor pulldown) -- `GET /api/products/[id]/pbis` (open: READY+BLOCKED) -- `GET /api/pbis/[id]/stories` -- `GET /api/stories/[id]/tasks` -- `GET /api/tasks/[id]` - -**Componenten herschrijven**: -- `components/product-select.tsx` (research-repo) → leest `useProductsStore`, schrijft naar `useProductStore.setCurrentProduct` -- `components/pbi-select.tsx` (research-repo) → leest `useBacklogStore` (filter op currentProduct), `useSelectionStore.selectPbi`. Triggert fetch op product-mount via een `useBacklogLoader`-helper die initial data binnenhaalt. -- `components/story-select.tsx` (research-repo) → idem voor stories -- `components/tasks-table.tsx` (research-repo) → leest `tasksByStory[selectedStoryId]`. **Max 10 rijen, scrollbaar** (al ingebouwd, behouden) -- `components/task-detail-card.tsx` (research-repo) → fetcht task detail apart (geen full-fat backlog veld; matcht main's `tasks/[id]` route) -- `components/event-stream-panel.tsx` (research-repo) → blijft bestaan voor research-doel (event-tap), maar luistert nu mee op dezelfde EventSource via `use-backlog-realtime` (of een tweede readonly listener); selecteerbare events met JSON-detail rechts blijven. Twee checkboxes (Postgres / Browser). Truncate met ellipsis in de lijst. - -**Werkwijzen (verifiëren tijdens reset)**: -- Comments en UI-tekst NL -- Geen `bg-blue-500` etc; enkel MD3 tokens (`bg-primary`, `bg-card`, `bg-status-done`, ...) -- shadcn-componenten al `base-nova` style -- Server-only files krijgen `*-server.ts` suffix waar van toepassing (in deze fase niet nodig — alle DB-toegang loopt via `lib/prisma.ts` in route handlers) -- TaskStatus-mapping via `lib/task-status.ts` als de UI lowercase wil - -**Acceptatie Fase A**: -- `npx tsc --noEmit` schoon -- Pagina rendert, cascading werkt, tabel toont taken, detail-card vult, events stromen door (preview-verificatie) -- Stores matchen het hoofdproject qua vorm (vergelijking via `diff` uitvoerbaar voor backlog-store etc.) - -### Fase B — Rearchitecture uitvoeren - -Volgt de 15 stappen uit [zustand-store-rearchitecture.md](./zustand-store-rearchitecture.md) §Implementatiepad. Concreet voor de research-repo: - -1. **Map** `stores/product-workspace/` aanmaken (factory + provider + selectors). -2. **`activeProduct`** wordt nu nog gespiegeld vanuit `useProductStore`; voor de research-pagina geen layout/server-side bepaling — we lezen het uit de pulldown-state. -3. **Selection migreren** — `selection-store` → `context.{activePbiId, activeStoryId, activeTaskId}` + `productId`. Setters cascaden de reset naar children (zoals doc beschrijft). -4. **Backlog naar entities + relations** — `pbisById`, `storiesById`, `tasksById`, `pbiIds`, `storyIdsByPbi`, `taskIdsByStory`. Selectors: - - `selectVisiblePbis(productId)` - - `selectStoriesForActivePbi(state)` - - `selectTasksForActiveStory(state)` - - `selectActivePbi/Story/Task(state)` -5. **Planner-state** in dezelfde workspace-store landen (`relations` slice); voor research: niet actief gebruikt, wel structureel meekoppen. -6. **Race-safe loaders** — `ensureProductLoaded`, `ensurePbiLoaded`, `ensureStoryLoaded`, `ensureTaskLoaded` met `requestId`-guard. Implementatie: - ```ts - setActivePbi(pbiId) { - const requestId = crypto.randomUUID() - set({ context: { ..., activePbiId: pbiId, ... }, loading: { ..., activeRequestId: requestId } }) - void get().ensurePbiLoaded(pbiId, requestId) - } - // in ensure: if (get().loading.activeRequestId !== requestId) return - ``` -7. **localStorage = restore hints** — `lastActivePbiIdByProduct`, `lastActiveStoryIdByProduct`, `lastActiveTaskIdByProduct`. Niet de waarheid, alleen hint die getoetst wordt aan toegankelijkheid. -8. **`use-backlog-realtime` dispatcht naar `applyRealtimeEvent`** — store interpreteert pbi/story/task I|U|D events, doet upsert + parent-id move + sort. -9. **Hidden tab beleid** — `EventSource` openhouden bij `hidden`. Op `visible` → `resyncActiveScopes('visible')`. -10. **Reconnect resync** — bij `ready` na disconnect of na exponential backoff: `resyncActiveScopes('reconnect')`. -11. **Unknown-event fallback** — onbekend event met `payload.product_id === activeProductId` → `resyncActiveScopes('unknown-event')`. Dit is wat het "veel events maar geen update" issue oplost. -12. **`force-dynamic` + `cache: 'no-store'`** — al gedaan in mijn fixes; behouden bij reset en versterken. -13. **Componenten naar selectors** — backlog-componenten lezen via `selectStoriesForActivePbi` etc., niet via raw store-velden. -14. **Tests** (Vitest, conform main): - - hydrate snapshot - - active selectie cascade - - race-safe ensure (laat trage promise van oude selectie geen nieuwe data overschrijven) - - SSE I|U|D voor pbi/story/task - - parent-move (story verandert van pbi) - - hidden→visible resync - - reconnect resync - - unknown-event resync - - delete-cleanup van actieve selectie - - localStorage restore-hint validatie tegen toegankelijkheid -15. **Sprint-workspace** — buiten scope; flag voor latere herhaling. - -**Optimistic mutations** (§Optimistic in doc): voor research geen DnD, dus alleen het patroon dropunten en niet bouwen. Wel: `applyOptimisticMutation`-action wel klaarzetten in de store-API zodat het patroon zichtbaar is. - -### Fase C — Werkwijzen verweven en doortrekken - -**Tijdens Fase A én B respecteren**: -1. **Plan mode workflow** — eerst Plan, ExitPlanMode, dan code. Bij grote wendingen opnieuw plannen. -2. **TodoWrite** voor multi-step werk; markeer immediate completion. -3. **Verify via preview** voor elke observable verandering (de hook reminder doet dit al). -4. **`tsc --noEmit`** voor afronden van een stap. -5. **Comments/Dutch** consistent. WHY-comments over de invariant; geen WHAT-comments. -6. **MD3 tokens** alleen. -7. **Geen secrets in chat** — `.env.local` blijft lokaal. -8. **Niet schrijven naar shared DB** zonder expliciete user-toestemming (geen `pg_notify` op shared channel). -9. **Source of truth = DB**. Zustand is projectie. localStorage = hint. -10. **Vóór elke fase**: kort statusrapport in de chat met wat er aankomt en waarom. - -**Doortrekken naar hoofdproject** (out-of-scope deze run, maar geflagd): -- Na bewezen werking in research-repo: backport `product-workspace-store` + selectors + realtime-apply + resync-laag naar `Scrum4Me/stores/product-workspace/`. -- **Niet backporten**: de auth-stripped routes uit research. Main behoudt iron-session, `getAccessibleProduct()`, en alle product-access/sprint/personal filters in z'n SSE- en read-routes. -- **Wel backporten**: store-shape, selectors, race-safe `ensure*Loaded`, hidden-tab beleid, `resyncActiveScopes`, unknown-event fallback, restore-hint patroon, `force-dynamic` + `cache: 'no-store'`. -- Migratie main-project zal langer duren (DnD, sprint, jobs, tests). Apart plan. - -## Bestandsmutaties (overzicht) - -### Verwijderen na Fase A -- `stores/data-store.ts` (research-repo) -- `hooks/use-event-stream.ts` (research-repo) -- `hooks/use-browser-presence.ts` (research-repo) — komt deels terug in Fase B als helper voor visibility/online resync trigger -- `app/api/realtime/events/route.ts` (research-repo) - -### Toevoegen Fase A (uit Scrum4Me) -- `stores/backlog-store.ts` -- `stores/planner-store.ts` -- `stores/selection-store.ts` (overschrijf) -- `stores/product-store.ts` -- `stores/products-store.ts` -- `lib/realtime/use-backlog-realtime.ts` -- `lib/task-status.ts` -- `app/api/realtime/backlog/route.ts` (zonder auth) - -### Toevoegen Fase B (nieuw, conform doc) -- `stores/product-workspace/store.ts` (zustand factory) -- `stores/product-workspace/selectors.ts` -- `stores/product-workspace/types.ts` -- `stores/product-workspace/restore.ts` (localStorage hints) -- `stores/product-workspace/realtime-apply.ts` (SSE event → store) -- `stores/product-workspace/resync.ts` (`resyncActiveScopes`, `resyncLoadedScopes`) -- `tests/product-workspace/*.test.ts` (Vitest, install vitest als devDep) - -### Te aanpassen in Fase B -- Alle `components/*.tsx` (nu shadcn select/table/card panels) → consumeren via selectors uit workspace-store -- `lib/realtime/use-backlog-realtime.ts` → dispatcht `applyRealtimeEvent` naar workspace-store i.p.v. `applyChange` naar backlog-store -- `event-stream-panel.tsx` → blijft bestaan (research-tap), maar leest events ook uit workspace-store of via een dunne `event-log-store` ernaast (in bounded-context-stijl: aparte log-store voor pure observatie hoort er niet thuis in de workspace-store) - -## Verificatie - -### Na Fase A (baseline) -1. `npm run dev` op port 3001 -2. Pagina laadt, cascading werkt: product → PBI → story → tasks -3. Detail-card vult bij klik op task -4. Event-paneel toont realtime events (truncate + JSON-detail) -5. `npx tsc --noEmit` schoon -6. Vergelijk: `diff Scrum4Me/stores/backlog-store.ts Scrum4Me-Research/stores/backlog-store.ts` → identiek (modulo lokale interface-uitbreidingen waar gedocumenteerd) - -### Na Fase B (target) -Alle acceptatiecriteria uit [zustand-store-rearchitecture.md §Acceptatiecriteria](./zustand-store-rearchitecture.md): -- Eén waarheid per entity in de store ✓ -- Selectors als enige UI-leesweg ✓ -- SSE patcht zonder full-page refresh ✓ -- Hidden→visible herstelt missers binnen één resync-cyclus ✓ -- Reconnect resync werkt zonder NOTIFY-replay ✓ -- Directe task-edits zonder `entity:'task'` NOTIFY worden via unknown-event fallback zichtbaar ✓ -- LocalStorage = hint, geen forced state ✓ -- `force-dynamic` + `cache: 'no-store'` overal ✓ - -### Manuele preview-verificatie (na elke fase) -- TODO via TodoWrite tijdens uitvoer; preview-screenshot na grote stappen -- Tab-switch test: open page, switch tab, doe een wijziging via een ander mechanisme (psql na user-akkoord, of UI in main-project), keer terug → verwacht: zonder warnings + data gerefresht - -## Open vragen / risico's - -1. **Reset-import** uit hoofdproject: voor de research-repo strippen we auth/session-deps uit de gekopieerde routes (research-repo heeft geen auth-laag, draait lokaal). Belangrijk: **dit is een research-repo-keuze; main behoudt de volledige auth-filters**. Zie de waarschuwing onder "API-routes (read)" hierboven. -2. **`use-backlog-realtime` heeft mogelijk auth-headers/session-checks**: bevestigen tijdens copy. Indien zo: research-versie gebruikt geen auth, route is publiek-bereikbaar binnen lokale dev. Geldt alleen lokaal — geen wijziging aan main. -3. **Tests-deps** (vitest, @testing-library/react) toevoegen tijdens Fase B. Of pas in Fase B step 14 vanwege scope. -4. **Event-paneel toekomst**: blijft het in research-repo of stoten we het af zodra de workspace-store af is? Voorstel: behouden als observatie-tool, maar er aparte `event-log-store` (kleine UI store) voor maken zodat het niet meelift in de workspace-store. -5. **README.md** update na Fase B (optioneel) — kort beschrijven dat dit nu het canonical migratie-pad demonstreert. diff --git a/docs/old/plans/user-settings-store.md b/docs/old/plans/user-settings-store.md deleted file mode 100644 index ea8ee91..0000000 --- a/docs/old/plans/user-settings-store.md +++ /dev/null @@ -1,212 +0,0 @@ ---- -title: "User-settings store (DB-backed user prefs)" -status: draft -audience: [contributor, ai-agent] -language: nl -last_updated: 2026-05-10 ---- - -# User-settings store (DB-backed user prefs) - -> **Locatie na approval:** verhuis dit bestand naar `docs/plans/user-settings-store.md` in de repo. -> Trigger voor dit plan: zichtbare hydratie-flits op het sprint-scherm in v1.3.3 ([PR #184](https://github.com/madhura68/Scrum4Me/pull/184)). -> De fix daar (useEffect-hydratie + `prefsLoaded`-gate) is een tijdelijke patch; deze migratie elimineert de flits volledig. - -## Context - -Filter- en view-prefs zitten nu verspreid over `localStorage` (en deels cookies). -Bij SSR weet de server niets van `localStorage`, dus bij users met saved-state ≠ -default ontstaat één render-flits direct na hydratie. Daarnaast werken die prefs -alleen per browser — geen cross-device, en cross-tab-sync vereist `storage`-events. - -Doel: **één `User.settings` JSON-veld** als single source of truth, met: - -- Server-component leest het veld bij elke page-render → SSR-correct, geen flits -- Zustand-store met optimistic updates patroon (zoals `product-workspace-store`) -- Cross-tab sync via bestaande `LISTEN/NOTIFY` + SSE-bridge -- Cross-device persistence (login op andere browser/laptop ziet zelfde prefs) - ---- - -## Scope (gefaseerd) - -### Fase 0 — Infrastructuur - -Aparte PR. Geen UI-wijziging; legt het fundament. Resultaat is een werkende store -zonder migraties; bestaande localStorage-flow blijft intact tot Fase 1. - -| # | Bestand | Wat | -|---|---|---| -| 0.1 | `prisma/schema.prisma` | `settings Json @default("{}")` op `User` model + migration | -| 0.2 | `lib/user-settings.ts` | Zod-schema + types + `mergeSettings(prev, patch)` deep-merge helper + defaults | -| 0.3 | `actions/user-settings.ts` | `updateUserSettingsAction(patch: Partial<UserSettings>)` — auth-guard, Zod-validate, deep-merge in DB transactie, `NOTIFY scrum4me_changes 'user_settings:${userId}'` | -| 0.4 | `stores/user-settings/store.ts` | Zustand met `entities.settings: UserSettings`, `hydrate(initial)`, generieke `setPref(path, value)` met optimistic + rollback. Zelfde mutation-flow als `product-workspace-store` | -| 0.5 | `app/api/realtime/user-settings/route.ts` | SSE-route per user, `LISTEN user_settings:${userId}`, push patches | -| 0.6 | `components/shared/user-settings-bridge.tsx` | Server reads `prisma.user.findUnique({select:{settings:true}})`, geeft door als prop, client mount roept `store.hydrate()` aan + opent SSE | -| 0.7 | Mount in `app/(app)/layout.tsx` | Bridge bovenin de app-layout zodat de store altijd beschikbaar is voor alle authenticated pagina's | -| 0.8 | Tests | `__tests__/lib/user-settings.test.ts` (merge-logic), `__tests__/actions/user-settings.test.ts` (auth + validation), `__tests__/stores/user-settings.test.ts` (optimistic flow) | - -**Demo/anon-fallback:** `useUserSettingsStore` detecteert `session.isDemo` of geen `userId` -en valt terug op in-memory state (geen server-write). Bridge wordt voor demo niet -gemount — defaults blijven actief, geen persistence-verwachting. - -### Fase 1 — Migreer huidige flits-bronnen - -| Component | localStorage-keys | → `settings`-pad | -|---|---|---| -| `components/sprint/sprint-backlog.tsx` | `scrum4me:sprint_pb_*` (6) | `views.sprintBacklog.{filterPriority,filterStatus,sort,sortDir,collapsedPbis,filterPopoverOpen}` | -| `components/backlog/pbi-list.tsx` | `scrum4me:pbi_*` (4) | `views.pbiList.{sort,filterPriority,filterStatus,sortDir}` | -| `components/backlog/story-panel.tsx` | `scrum4me:story_sort` (1) | `views.storyPanel.sort` | -| `components/jobs/jobs-column.tsx` | `${prefix}_filter_kind`, `${prefix}_filter_status` (2 dyn.) | `views.jobsColumns[prefix].{kinds,statuses}` | -| `stores/debug-store.ts` (via `status-bar-debug-toggle`) | `scrum4me:debug-mode` (1) | `devTools.debugMode` | - -Per component: -- Verwijder `useState` + `useEffect`-hydratie + `useEffect`-write -- Vervang door `useUserSettingsStore(s => s.entities.settings.views.sprintBacklog?.filterStatus ?? 'OPEN')` -- Setter wordt `useUserSettingsStore.getState().setPref(['views','sprintBacklog','filterStatus'], value)` -- `prefsLoaded`-state en helpers (`readLocalStoragePref`) verdwijnen -- `lib/use-local-storage-pref.ts` wordt verwijderd (niet meer in gebruik) - -**Migratie-pad voor bestaande users:** bij eerste mount, voor de eerste `setPref`-call, -leest een one-shot `useEffect` de oude localStorage-keys en pusht ze als één bulk-patch -naar de server. Daarna `localStorage.removeItem(...)` om geen verwarring te wekken. -Idempotent: als `settings.views.sprintBacklog.filterStatus` al gezet is, sla over. - -### Fase 2 — Cookie-consolidatie (optioneel, later PR) - -| Bron | Huidig | → `settings`-pad | -|---|---|---| -| `components/shared/split-pane.tsx` | `document.cookie` (`sp:` prefix) | `layout.splitPanePositions[cookieKey]` | -| `lib/active-sprint.ts` + `actions/active-sprint.ts` | server-side cookie per product | `layout.activeSprints[productId]` | - -Server-component-lezers veranderen — apart traject met meer regression-risico. -Niet onderdeel van de eerste user-settings-PR. - -### Fase 3 — Skip / al persistent - -- `idea-md-editor.tsx` drafts — werk-in-progress, geen pref -- `iron-session` cookies — auth, andere zorg -- `User.active_product_id` — al in DB (kolom op model) -- Modal/popover open-state behalve `filterPopoverOpen` — ephemeral - ---- - -## JSON-shape (Fase 1) - -```ts -// lib/user-settings.ts -import { z } from 'zod' - -export const UserSettingsSchema = z.object({ - views: z.object({ - sprintBacklog: z.object({ - filterPriority: z.union([z.number().int().min(1).max(4), z.literal('all')]).optional(), - filterStatus: z.enum(['OPEN', 'IN_SPRINT', 'DONE', 'all']).optional(), - sort: z.enum(['priority', 'status', 'code']).optional(), - sortDir: z.enum(['asc', 'desc']).optional(), - collapsedPbis: z.array(z.string()).optional(), - filterPopoverOpen: z.boolean().optional(), - }).optional(), - pbiList: z.object({ - sort: z.enum(['priority', 'code', 'date']).optional(), - filterPriority: z.union([z.number().int().min(1).max(4), z.literal('all')]).optional(), - filterStatus: z.enum(['ready', 'blocked', 'done', 'all']).optional(), - sortDir: z.enum(['asc', 'desc']).optional(), - }).optional(), - storyPanel: z.object({ - sort: z.enum(['priority', 'code', 'date']).optional(), - }).optional(), - jobsColumns: z.record(z.string(), z.object({ - kinds: z.array(z.string()), - statuses: z.array(z.string()), - })).optional(), - }).optional(), - devTools: z.object({ - debugMode: z.boolean().optional(), - }).optional(), -}).strict() - -export type UserSettings = z.infer<typeof UserSettingsSchema> -``` - -Defaults zijn impliciet (alle keys optioneel). Selectors in de store geven -fallback-waardes terug zodat consumers niet `?? 'OPEN'` hoeven te schrijven — -maar het mag, geen big deal. - ---- - -## Realtime-notificatie - -Bestaand kanaal `scrum4me_changes` blijft. Payload-conventie: - -```json -{ "kind": "user_settings", "userId": "...", "patch": { "views": { ... } } } -``` - -`/api/realtime/user-settings/route.ts` filtert payloads op `userId === session.userId`. -Andere tabs van zelfde user krijgen patches binnen, store roept `applyServerPatch(patch)` -aan zonder optimistic flow. - ---- - -## Verificatie (per fase) - -### Fase 0 -- [ ] `npm run verify && npm run build` groen -- [ ] Migration draait op fresh + bestaande DB zonder data-verlies -- [ ] `updateUserSettingsAction` weigert auth-loze calls (test) -- [ ] Zod-validatie geeft 422 bij invalid patch (test) -- [ ] Optimistic update + rollback gedraagt zich zoals `product-workspace-store` (test) -- [ ] SSE-route levert patches alleen aan zelfde user (manueel: open twee tabs als A, schrijf, zie update; tab van user B blijft stil) - -### Fase 1 -- [ ] Geen `localStorage.getItem` of `localStorage.setItem` meer in de gemigreerde componenten -- [ ] Sprint screen: refresh → filter direct correct, geen flits, geen hydration error in console -- [ ] Product backlog screen: idem -- [ ] Jobs page: idem (per kolom-instance) -- [ ] Two-tab test: filter wijzigen in tab A → tab B updatet binnen ~100ms -- [ ] Demo-user: filter wijzigen werkt binnen sessie, niet gepersisteerd na refresh (verwacht) -- [ ] One-shot localStorage-migratie: bestaande user met oude keys ziet bij eerste login zijn waardes terug; na refresh zijn de localStorage-keys leeg - -### Fase 2 -- [ ] Split-pane positie persistent en SSR-correct -- [ ] Active-sprint per product werkt zonder cookie - ---- - -## Schatting - -| Fase | Tijd | -|---|---| -| 0 — Infra | ~3 uur | -| 1 — Migratie | ~2 uur | -| 2 — Cookies | ~2 uur (apart) | -| Totaal Fase 0 + 1 | **~5 uur**, 1 PR (of 2 als we 0 en 1 splitsen) | - -Aanbevolen: **Fase 0 + 1 in één PR** als de infra klein blijft, anders splitsen -per fase. Fase 2 is altijd een aparte PR. - ---- - -## Open vragen - -1. **Cross-device merge-conflict.** Twee tabs van zelfde user op verschillende - devices wijzigen tegelijk. Server-side: `last-write-wins` of `JSON_PATCH`-merge? - Voorstel: deep-merge per top-level path, dus `views.sprintBacklog.filterStatus` - en `views.pbiList.sort` botsen niet — laatste schrijver per veld wint. -2. **Storage-grens.** PostgreSQL JSON kolom kan ~1GB; we zitten op <5KB per user. - Geen concern. -3. **Schema-versionering.** Als we het JSON-schema later wijzigen: voorzichtig - migreren via Zod `.catch()` voor onbekende keys. Voor v1: start klein. -4. **One-shot localStorage-migratie weglaten?** Voor solo-dev-tool kan het - acceptabel zijn dat users hun saved filters verliezen bij de migratie. Scheelt - ~30 minuten implementatie + tests. - ---- - -## Eerste stappen na approval - -1. Verhuis dit plan naar `docs/plans/user-settings-store.md` in een nieuwe branch (bv. `feat/user-settings-store`) -2. Maak via Scrum4Me-MCP een PBI met story + taken voor Fase 0 (volgens CLAUDE.md werkflow) -3. Start met taken in `sort_order`; commit per laag -4. Fase 1 als opvolg-PBI (of in dezelfde sprint, los gelabeld) diff --git a/docs/old/plans/v1-readiness.md b/docs/old/plans/v1-readiness.md deleted file mode 100644 index 799af33..0000000 --- a/docs/old/plans/v1-readiness.md +++ /dev/null @@ -1,148 +0,0 @@ ---- -title: "Scrum4Me — v1.0 readiness" -status: active -audience: [maintainer, contributor] -language: nl -last_updated: 2026-05-04 ---- - -# Scrum4Me — v1.0 readiness - -**Versie:** v0.9.0 (zojuist gepusht naar productie via Vercel) -**Doel:** v1.0.0 als eerste stabiele release. Living document — bijwerken na elke sprint of merge naar `main`. - ---- - -## Summary - -De kernfunctionaliteit (auth, producten, PBI/story/task-hiërarchie, sprints, solo-paneel, REST-API, MCP-integratie, QR-pairing, mobile-shell) is **af en in productie**. Tests, lint, build en doc-link-checker zijn allemaal groen. Wat ontbreekt voor v1 is geen feature-werk maar **launch-discipline**: een paar UI-gaten dichten, ops-instrumentatie (error monitoring, rate-limiting beredeneren), accessibility-audit, en de stale backlog-index opschonen. Alle "Expliciet buiten scope voor v1"-items uit de functional spec ([docs/specs/functional.md:20](../specs/functional.md)) blijven bewust uit scope. - ---- - -## What's already done - -- **#3 Rate-limiting op alle mutation-endpoints** — `enforceUserRateLimit(scope, userId)` helper in `lib/rate-limit.ts` met 11 nieuwe scopes; toegepast op create-actions (PBI/Story/Task/Todo/Sprint/Product/Token), enqueueClaudeJob(s), answerQuestion, en API-routes (story log POST, avatar upload). Limits zijn ruim genoeg voor normaal gebruik, eng genoeg om abuse-loops te stoppen -- **#2 Sentry error-monitoring** — `@sentry/nextjs` geconfigureerd via PR [#85](https://github.com/madhura68/Scrum4Me/pull/85); SDK is no-op zonder DSN, activatie via Vercel env-vars -- **#1 Edit-icoon op Product** (todo `cmoq3ox51`) — pencil-icoon op dashboard-card via PR [#83](https://github.com/madhura68/Scrum4Me/pull/83); product-detail-header behoudt tekst -- v0.9.0 ([release](https://github.com/madhura68/Scrum4Me/releases/tag/v0.9.0)): mobile-shell met landscape-lock (PBI-11, 7 stories, 21 tasks) -- v0.4.0 t/m v0.8.x: ondermeer sprint-screen filter-popover + edit-iconen, PBI/story/task edit-icons, code-velden verplicht, demo read-only, M11 Claude-vragen-kanaal, M10 QR-pairing -- CI op `main` en PR's: lint + typecheck + prisma validate + test + build via [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml) -- 432 unit/integration tests · 60 test-files · doc-link-checker 86/86 valid -- Drie architectuur-beslissingen voor mobile geformaliseerd in [docs/architecture/project-structure.md](../architecture/project-structure.md) - ---- - -## Now - -Korte lijst (3-5 items) die je vóór de v1.0-tag wil afronden. Deze blokkeren een betekenisvolle launch. - -### 1. ~~Edit-icoon op Product~~ ✅ klaar in PR [#83](https://github.com/madhura68/Scrum4Me/pull/83) - -Verschoven naar *What's already done*. Pencil-icoon op dashboard-card; product-detail page-header behoudt tekst (matched naast andere text-acties). - -### 2. Error monitoring (Sentry of vergelijkbaar) - -CI vangt build-fouten af, maar er is geen runtime-monitoring. Voor een echte v1 wil je productie-fouten zien voordat een gebruiker het meldt. Vercel heeft native Sentry-integratie (Marketplace → Sentry). - -Concreet: -- `npm i @sentry/nextjs` -- `npx @sentry/wizard@latest -i nextjs` -- DSN als env-var via Vercel project settings (development + production environments) -- Sample-rate conservatief (10% performance, 100% errors) — Hobby-plan-vriendelijk -- Bevestig dat Postgres-LISTEN/NOTIFY-fouten in worker-routes (`/api/realtime/*`) gevangen worden - -### 3. ~~Rate-limiting op alle mutation-endpoints~~ ✅ klaar - -Verschoven naar *What's already done*. Helper `enforceUserRateLimit(scope, userId)` in `lib/rate-limit.ts` toegepast op alle high-value create-paths. - -### 4. Accessibility audit op happy-path - -`@base-ui/react` levert WAI-ARIA defaults; we gebruiken semantische HTML; maar er is geen audit-bewijs. - -Concreet: -- DevTools Lighthouse a11y-pass op `/login`, `/dashboard`, `/products/[id]`, `/products/[id]/sprint`, `/products/[id]/solo`, `/m/products/[id]`, `/m/products/[id]/solo` -- Score-doel ≥95 per pagina -- Fix wat onder de 95 valt — meestal contrast of missende labels -- Documenteer score in [docs/specs/functional.md § Niet-functionele vereisten](../specs/functional.md) - ---- - -## Next - -Belangrijk maar niet-blokkerend voor v1. - -### Backlog-index sync - -[docs/old/backlog/index.md](../old/backlog/index.md) toont M10 (ST-1001 t/m 1008) en M11 (ST-1101 t/m 1108) als unchecked, terwijl ze allemaal gemerged zijn. Loop één keer door en zet `[x]`. Is een 5-min-job die de doc weer betrouwbaar maakt voor wie 'm leest. - -### Solo observaties (todo `cmohuu5h8`) - -"Filters en sortering. blokjes kleiner maken 2 op een rij" — UX-polish op het Solo-paneel. Niet trivial: vereist een filter-popover-pattern (we hebben er net een uitgerold op het sprint-screen — herbruik kan). - -### Algemene observaties (todo `cmohthfyw`) - -"Dunne border om tekstvlakken (onzichtbaar als niet actief), default active PB kiezen, hover-card voor detail-info, landingpage AI-assisted/AI-driven framing." — verzameling kleinere UI-aanscherpingen, ieder eigen scope. - -### ToDo prioriteit + AI-suggesties (todos `cmohtgdwf`, `cmohswbb9`) - -Twee verwante todo's over de todo-feature uitbreiden. Past bij de strategische richting "AI-driven dev-flow" maar geen v1-blokker. - ---- - -## Before launch - -Must-do voor publieke aankondiging, maar mag pas vlak vóór v1.0-tag. - -- [ ] **Smoke-test productie** — checklist klaar in [docs/runbooks/v1-smoke-test.md](../runbooks/v1-smoke-test.md), 11 secties, ~15 min -- [ ] **PWA-installatie test** op echt mobiel (Android + iOS) — bevestig manifest landscape, controleer iOS-fallback via CSS-overlay -- [x] **Demo-policy regression-pass** — code-side gefixt: 3 gaps gedicht (toggleTodo, archiveCompletedTodos, leaveProduct). Drielaags-block geverifieerd voor alle mutation-actions -- [x] **Privacy review** — Sentry sendDefaultPii=false; geen PII in logs; 4 debug-routes nu NODE_ENV-guarded (404 in productie) -- [x] **README + Quick start verifiëren** — test-count 69 → 445 gecorrigeerd, env-vars-tabel uitgebreid (CRON_SECRET, Sentry vars), CHANGELOG-link toegevoegd -- [x] **CHANGELOG.md** aangemaakt (Keep a Changelog formaat met [Unreleased] + [0.9.0]) -- [ ] **Bump naar v1.0.0** + GitHub release met release-notes - ---- - -## Later - -Bewust uit scope voor v1 (uit functional spec § Expliciet buiten scope) — of grotere domein-uitbreidingen die hun eigen PBI verdienen. - -- **Daily Scrum / Sprint Review / Retrospective**-schermen — v2 -- **E-mail-uitnodigingsflow voor teams** — nu enkel via username -- **Notificaties + reminders** — out of scope -- **Native mobile app** — web-first; mobile-shell is genoeg -- **Tijdregistratie / burndown-charts** — buiten positionering -- **WIA AI agent** (todo `cmog2gzjb`) — eigen project-domein -- **Claude-code-integratie via tabel-trigger** (todo `cmohn3728`) — past bij M12-richting maar geen v1 -- **Inspaningsmonitor-import** (todo `cmohul0ri`) — separate product -- **GitHub Issues / Linear / Jira-integratie** — v2 - ---- - -## Priority order (quick reference) - -``` -Now: ~~1. Edit-icoon op Product~~ ✅ - ~~2. Sentry/error-monitoring~~ ✅ - ~~3. Rate-limiting op mutation-endpoints~~ ✅ - 4. Accessibility-audit (Lighthouse a11y ≥95) - -Next: 5. Backlog-index.md sync - 6. Solo observaties (filters/sortering) - 7. Algemene UI-observaties - 8. Todo prioriteit + AI-suggesties - -Before launch: 9. Smoke-test productie (desktop + mobile) - 10. PWA-installatie test op echte mobiel - 11. Demo-policy regression-pass - 12. Privacy/PII review - 13. README quick-start verificatie - 14. CHANGELOG.md - 15. Bump → v1.0.0 + release - -Later: (zie sectie hierboven — v2-domein of buiten scope) -``` - ---- - -*Updated: 2026-05-04 (na v0.9.0 release). Refresh dit document na elke sprint of major merge.* diff --git a/docs/patterns/debug-id.md b/docs/patterns/debug-id.md deleted file mode 100644 index ae4c5b2..0000000 --- a/docs/patterns/debug-id.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -title: "Debug-id op component-root" -status: active -audience: [ai-agent, contributor] -language: nl -last_updated: 2026-05-09 -when_to_read: "Wanneer je een named-component aanmaakt of aanpast." ---- - -# Patroon: Debug-id op component-root - -## Regel: named-component boundary - -Elk named-component plaatst `data-debug-id` en `data-debug-label` via de -`debugProps`-helper op zijn root JSX-element. Zes concrete regels: - -1. **Import** `debugProps` uit `@/lib/debug` — geen inline attribuut schrijven. -2. **Spread** het resultaat op het root element: `{...debugProps(id, component, file)}`. -3. **`id`** is kebab-case van de componentnaam, bijv. `sprint-board`. -4. **`component`** is de PascalCase naam zoals die geëxporteerd wordt, bijv. `SprintBoard`. -5. **`file`** is het relatieve pad vanaf de repo-root, bijv. `components/sprint/sprint-board.tsx`. -6. **Root = het buitenste JSX-element** dat de component rendert — niet een wrapper div die je extra toevoegt. - -In productie (`NODE_ENV=production`) retourneert `debugProps` een leeg object `{}` -zodat er geen debug-attributen in de gebundelde HTML staan. - -## Helper-voorbeeld - -```tsx -import { debugProps } from '@/lib/debug' - -export function SprintBoard({ ... }: SprintBoardProps) { - return ( - <div - className="..." - {...debugProps('sprint-board', 'SprintBoard', 'components/sprint/sprint-board.tsx')} - > - {/* inhoud */} - </div> - ) -} -``` - -## Skip-criteria - -Voeg **geen** `debugProps` toe aan: - -| Categorie | Reden | -|---|---| -| `components/ui/*` | shadcn-primitives — ongebrand, niet onze componenten | -| Bridges / mounts | Niet-renderende wrappers zoals `notifications-bridge`, `realtime-bridge`, `sync-active-sprint-cookie` | -| Hooks-only files | Files die alleen hooks exporteren en niets renderen | - -## Motivatie: geen build-time injectie van pad - -Een alternatief is het bestandspad automatisch injecteren via een Babel/SWC-plugin -of een ESLint-codefixin. Dit is bewust **niet** gekozen omdat: - -- de plugin afhankelijk wordt van de build-toolchain-configuratie (Next.js, Turbopack), -- bij rename/move van een bestand de injectie verouderd raakt zonder dat de compiler waarschuwt, -- expliciete argumenten in de broncode reviewbaar en grep-baar zijn. - -Het handmatig meegeven van `id`, `component` en `file` maakt de intentie zichtbaar -en voorkomt verborgen afhankelijkheden. diff --git a/docs/patterns/debug-labels.md b/docs/patterns/debug-labels.md deleted file mode 100644 index a688f7d..0000000 --- a/docs/patterns/debug-labels.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -title: "Debug-labels: BEM data-debug-id patroon" -status: active -audience: [ai-agent, contributor] -language: nl -last_updated: 2026-05-09 -when_to_read: "Wanneer je een component aanmaakt of aanpast en debug-ids wilt toevoegen aan sub-elementen." ---- - -# Patroon: Debug-labels (BEM data-debug-id) - -## Doel - -`data-debug-id` geeft Claude (en ontwikkelaars) een ondubbelzinnige naam voor elk -DOM-element. In plaats van "de blauwe knop rechtsonder" zeg je `status-bar__build-info` -— uniek, herleidbaar naar de broncode, en grep-baar. - -## Toggle - -Een `{ }`-knop verschijnt alleen in development (`NODE_ENV !== 'production'`) in de -`StatusBar`. De knop beheert `localStorage['scrum4me:debug-mode']` via -`stores/debug-store.ts` en zet de klasse `debug-mode` op `<body>`. - -In `app/globals.css` activeren de regels onder `body.debug-mode [data-debug-id]`: -- een dashed outline rondom elk geïnstrumenteerd element, -- een hover-tooltip die de waarde van `data-debug-id` toont. - -In productie worden geen `data-debug-id`-attributen gerenderd — `debugProps()` retourneert -een leeg object wanneer `NODE_ENV === 'production'`. - -## Patroon - -### Root-element - -De root van elke named-component gebruikt `debugProps()` uit `@/lib/debug`: - -```tsx -import { debugProps } from '@/lib/debug' - -export function StatusBar() { - return ( - <footer - className="..." - {...debugProps('status-bar')} - > - {/* inhoud */} - </footer> - ) -} -``` - -`debugProps(id)` plaatst `data-debug-id={id}` in development en `{}` in productie. -De `id` is de kebab-case variant van de bestandsnaam, bijv. `status-bar` voor -`components/shared/status-bar.tsx`. - -### Sub-elementen (BEM) - -Interactieve of significante sub-elementen krijgen een inline `data-debug-id` met -BEM-notatie: `<root>__<sub>`. Sub-elementen schrijven het attribuut **direct** (niet -via `debugProps`), want ze zijn altijd genest binnen het root-element en zichtbaar -alleen samen met de root: - -```tsx -export function StatusBar() { - return ( - <footer - className="..." - {...debugProps('status-bar')} - > - <span data-debug-id="status-bar__copyright"> - © {new Date().getFullYear()} Scrum4Me - </span> - <span data-debug-id="status-bar__build-info"> - v{version} · gebouwd op {buildDate} - {isDev && <DebugToggle />} - </span> - </footer> - ) -} -``` - -## Welke sub-elementen instrumenteren - -| Instrumenteer | Voorbeelden | -|---|---| -| Interactieve elementen | `<button>`, triggers, links, tabs | -| Sectie-headers | `<h1>` t/m `<h6>` | -| Primaire inhoudstitels of -tekst | hoofdtitel van een kaart, badge-label | - -Bij twijfel: **skip**. Liever te weinig dan ruis. - -## Wat NIET instrumenteren - -| Categorie | Reden | -|---|---| -| `components/ui/*` | shadcn-primitives — herbruikt op te veel plekken, id zou clashen | -| `app/(...)/page.tsx` | v1 scope is alleen `components/` | -| Bridges / mounts | Niet-renderende wrappers (`notifications-bridge`, `realtime-bridge`, …) | -| Hooks-only files | Files die alleen hooks exporteren en niets renderen | - -## Geen `data-debug-label` - -Het attribuut heet uitsluitend `data-debug-id`. Er bestaat geen `data-debug-label`. -De `app/globals.css` tooltip leest `attr(data-debug-id)` — een tweede attribuut -zou alleen verwarring geven. - -## Gerelateerde bestanden - -| Bestand | Rol | -|---|---| -| `lib/debug.ts` | `debugProps()`-helper; retourneert `{}` in productie | -| `stores/debug-store.ts` | Zustand-store voor `debugMode`-state en `toggleDebugMode` | -| `components/shared/status-bar-debug-toggle.tsx` | `{ }`-knop — synchroniseert localStorage en `body.debug-mode` | -| `app/globals.css` | `body.debug-mode [data-debug-id]` — outline + hover-tooltip | diff --git a/docs/patterns/demo-client-state.md b/docs/patterns/demo-client-state.md deleted file mode 100644 index f88c473..0000000 --- a/docs/patterns/demo-client-state.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -title: "Demo client-state (UI-prefs zonder DB)" -status: active -audience: [ai-agent, contributor] -language: nl -last_updated: 2026-05-12 -when_to_read: "Bij elk nieuw UI-element dat de demo-gebruiker zou willen kunnen wijzigen — filter, sortering, panel-state, geselecteerde scope (product/sprint), enz." ---- - -# Patroon: Demo client-state - -De demo-gebruiker (`session.isDemo === true`) deelt één DB-rij met alle andere -demo-bezoekers. DB-writes voor demo zouden cross-bezoeker-pollution geven, dus -de three-layer policy uit [ADR-0006](../adr/0006-demo-user-three-layer-policy.md) -blokkeert ze. PBI-80 introduceert één uitzondering: **client-side UI-state mag -gewijzigd worden, in-memory en zonder server-call.** - ---- - -## Wanneer toepassen - -| Soort wijziging | Voor demo? | Hoe | -|---|---|---| -| Filter / sortering / collapse / split-pane / selectie | **Ja** | `useUserSettingsStore.setPref([...], value)` — store regelt de demo-fork al | -| Wisselen van actief product of sprint | **Ja** | `router.push('/products/...')` zonder server-action | -| PBI/story/taak/sprint create/update/delete/reorder | **Nee** | Server-action met 403-guard blijft hard verplicht | -| Account, rollen, pairing, web-push | **Nee** | Idem | - ---- - -## Hoe `isDemo` lezen (client component) - -```tsx -import { useUserSettingsStore } from '@/stores/user-settings/store' - -const isDemo = useUserSettingsStore(s => s.context.isDemo) -``` - -`UserSettingsBridge` hydrateert deze waarde in `app/(app)/layout.tsx`, -dus elke client child ziet meteen de juiste vlag. - ---- - -## Voorbeeld 1 — UI pref (filters, sort, layout) - -Geen extra werk. De store-actie regelt de demo-fork zelf: - -```tsx -// Werkt voor alle gebruikers, demo + niet-demo -useUserSettingsStore.getState().setPref( - ['views', 'pbiList', 'filterStatus'], - 'OPEN', -) -``` - -Voor demo doet `setPref` een lokale Zustand-merge zonder server-call; -voor niet-demo gaat het via `updateUserSettingsAction` (DB + SSE). - ---- - -## Voorbeeld 2 — Scope-wissel (product/sprint) - -Fork in de UI-handler — server-action blijft achter de fork onveranderd: - -```tsx -function handleSwitchProduct(productId: string) { - if (productId === activeId) return - if (isDemo) { - router.push(`/products/${productId}`) - return - } - startTransition(async () => { - const result = await setActiveProductAction(productId) - // ... bestaande not-demo flow - }) -} -``` - -Voor pagina's waarvan de scope al in de URL zit (zoals `/products/[id]/sprint/[sprintId]`) -is `router.push` met de gewenste path voldoende — server resolveert de -juiste data uit de URL-params. - ---- - -## Visuele consistentie na URL-only switch - -Server-rendered layouts blijven voor demo de seed-default lezen -(`user.active_product_id`, `user.settings.layout.activeSprints[...]`). Als de -UI een "actief X"-label toont dat van de server-prop komt, leid het voor demo -af uit `pathname`: - -```tsx -const urlProductId = pathname.match(/^\/products\/([^/]+)/)?.[1] ?? null -const displayActive = - isDemo && urlProductId - ? products.find(p => p.id === urlProductId) ?? activeProduct - : activeProduct -``` - -Gebruik `displayActive` in de render in plaats van de prop. - ---- - -## Verboden voor demo - -- Server-action aanroepen zonder fork — 403 + onnodige toast. -- Wegschrijven naar cookies of localStorage — pollutie tussen bezoekers. -- `setActiveSprintInSettings` / vergelijkbare DB-helpers rechtstreeks aanroepen. -- Web-push subscription registreren — schrijft naar gedeelde `PushSubscription`-tabel. - ---- - -## Defense in depth - -Server-actions (`actions/active-product.ts`, `actions/active-sprint.ts`, -`actions/user-settings.ts`) **behouden** hun `if (session.isDemo) return 403`-guard. -Als toekomstige UI-code per ongeluk de fork mist, faalt de call hard met 403 en -zien we het via toast/logs. - ---- - -## Zie ook - -- [ADR-0006](../adr/0006-demo-user-three-layer-policy.md) — three-layer - beschermingen + de PBI-80-uitzondering. -- [docs/patterns/proxy.md](./proxy.md) — proxy-laag die `/api/*`-writes voor - demo afvangt. -- [stores/user-settings/store.ts](../../stores/user-settings/store.ts) — bron - van waarheid voor `isDemo` + `setPref` met demo-fork. diff --git a/docs/patterns/dialog.md b/docs/patterns/dialog.md index 81aa801..751dfaf 100644 --- a/docs/patterns/dialog.md +++ b/docs/patterns/dialog.md @@ -3,13 +3,13 @@ title: "Entity Dialog" status: active audience: [ai-agent, contributor] language: nl -last_updated: 2026-05-08 +last_updated: 2026-05-03 when_to_read: "Before building any create/edit/detail dialog component." --- # Pattern — Entity Dialog -Deze pagina is **bindend** voor elke create/edit/detail-dialog in Scrum4Me, ongeacht het achterliggende dataobject (PBI, Story, Task, Idea, Sprint, Product, User, of toekomstige entiteiten). Een nieuwe dialog die hier niet aan voldoet, hoort niet gemerged te worden. +Deze pagina is **bindend** voor elke create/edit/detail-dialog in Scrum4Me, ongeacht het achterliggende dataobject (PBI, Story, Task, Todo, Sprint, Product, User, of toekomstige entiteiten). Een nieuwe dialog die hier niet aan voldoet, hoort niet gemerged te worden. > **Doel:** elke dialog voelt identiek aan voor de gebruiker, hergebruikt dezelfde primitives, en heeft de drielaagse demo-policy + auth-scoping standaard ingebakken. @@ -23,7 +23,7 @@ Voor entity-specifieke afwijkingen of velden: schrijf één begeleidende doc per |---|---|---| | 1.1 | Bouw op `components/ui/dialog.tsx` (de bestaande shadcn/`@base-ui/react`-wrapper). **Geen** directe imports van dialog-primitives uit `@base-ui/react`. | Voorkomt twee parallelle dialog-implementaties met inconsistente animatie/focus-trap/theming | | 1.2 | Gebruik composition via de **`render`-prop** (zie `CLAUDE.md` "UI Library Conventions"). Nooit Radix' `asChild`. | Project gebruikt `@base-ui/react`, niet Radix | -| 1.3 | Mode (`create` vs `edit` vs `detail` vs `inspector`) wordt afgeleid uit één input — een prop, een `state`-object of een `searchParam`. **Niet** twee aparte componenten. Voor `inspector`: zie § 4a. | Voorkomt code-duplicatie en inconsistente labels/footer-layouts | +| 1.3 | Mode (`create` vs `edit` vs `detail`) wordt afgeleid uit één input — een prop, een `state`-object of een `searchParam`. **Niet** twee aparte componenten. | Voorkomt code-duplicatie en inconsistente labels/footer-layouts | | 1.4 | Auth-scoping op elke server action via `productAccessFilter(userId)` (of het scope-helper-equivalent). Cross-tenant writes mogen onmogelijk zijn. | `CLAUDE.md` "Toegangsmodel" + `docs/patterns/server-action.md` | | 1.5 | **Drielaagse demo-policy** (verplicht — zie § 6) op elke write-actie. | `CLAUDE.md` "Demo-check" + `docs/architecture.md#demo-user-policy` | | 1.6 | Validatie via één gedeeld zod-schema (`lib/schemas/<entity>.ts`) — gebruikt door zowel form als server action. | `CLAUDE.md` "Validatie" | @@ -99,49 +99,6 @@ Verplicht: --- -## 4a — Inspector-mode (hybrid detail + inline-edit) - -Een inspector-dialog is een **detail-overlay met inline-bewerkbare velden** voor een lopend record (typisch een taak, run of job). Onderscheidt zich op drie punten van create/edit/detail: - -| Aspect | Create/Edit/Detail | Inspector | -|---|---|---| -| Persistence | submit-knop in footer roept één Server Action aan | per-veld blur-save via Route Handler (`PATCH`) of fine-grained Server Actions | -| Footer | statisch (`Annuleren` + `Opslaan`/`Aanmaken`/`Verwijderen`) | dynamisch — bevat status-indicatoren en context-knoppen (bv. "Voer uit", "Annuleer agent", "Open PR") afhankelijk van een job/run-status | -| Body | sequentieel form (één entiteit invullen) | gegroepeerde secties: read-only metadata + bewerkbare controls + activity-status | -| Dirty-guard | verplicht (§8.1) | n.v.t. — wijzigingen worden direct gepersisteerd | -| Submit-shortcut | Cmd/Ctrl+Enter verplicht (§8.2) | n.v.t. — geen submit | -| Validatie | 422-fieldErrors in form | toast bij PATCH-fout, optimistisch terugdraaien | - -**Wanneer kiezen voor inspector i.p.v. detail-mode?** -- Het record is "actief" (bv. agent draait erop) en meta-edits moeten direct effect hebben zonder save-cycle -- Verschillende velden gaan naar verschillende endpoints en willen niet gebundeld worden -- De footer toont liveness-info (job-status) i.p.v. acties op het hele record - -**Layout-eisen (verplicht, gelijk aan §4):** -- Bouw op `components/ui/dialog.tsx` -- `<DialogContent className={entityDialogContentClasses}>` -- Sticky header met `entityDialogHeaderClasses` of equivalent (`shrink-0` + `border-b border-outline-variant`) -- Body in `entityDialogBodyClasses` (`flex-1 overflow-y-auto px-6 py-6 space-y-6`) -- Footer in `entityDialogFooterClasses` + extra modifiers voor wrap-gedrag bij dynamische knoppen (`flex flex-wrap items-center gap-2`) - -**Wat blijft hetzelfde als bij andere modi:** -- Drielaagse demo-policy (§6) — proxy-guard, server/route-handler `session.isDemo`-check, `<DemoTooltip>` rond bewerkbare controls -- MD3-tokens (§9), motion (§8.4), backdrop (§8.5), focus return (§8.3) -- Auth-scoping op elke write (§1.4) -- Eén entity-profile in `docs/specs/dialogs/<entity>.md` - -**Wat je expliciet niet doet in inspector-mode:** -- ❌ Geen `useDirtyCloseGuard` (geen dirty-state) — Esc/backdrop sluit direct -- ❌ Geen `useDialogSubmitShortcut` (geen submit) -- ❌ Geen verplichte `lib/schemas/<entity>.ts` voor het hele record — wél schema's per PATCH-veld of per fine-grained action -- ❌ Geen footer met statische save/cancel-knoppen — die suggereren bundle-save - -**Voorbeeld in deze codebase:** `components/solo/task-detail-dialog.tsx` — opent een lopende solo-taak, plan-textarea slaat op blur op via `PATCH /api/tasks/:id`, verify-toggles direct via dezelfde route, footer toont job-status met context-acties (Voer uit / Wacht op agent / Annuleer / Open PR / Mislukt). - -Profiel: `docs/specs/dialogs/task-detail.md`. - ---- - ## 5 — Validatie & foutcodes ### 5.1 zod-schema diff --git a/docs/patterns/prisma-client.md b/docs/patterns/prisma-client.md index c995eec..bbc98a4 100644 --- a/docs/patterns/prisma-client.md +++ b/docs/patterns/prisma-client.md @@ -49,17 +49,27 @@ export default defineConfig({ }) ``` -## Prisma generator +## Prisma generators -`prisma/schema.prisma` bevat één generator: +`prisma/schema.prisma` bevat twee generators: ```prisma generator client { provider = "prisma-client-js" } + +generator erd { + provider = "prisma-erd-generator" + output = "../docs/assets/erd.svg" +} ``` -`prisma generate` bouwt de Prisma Client naar `node_modules/@prisma/client`. +`prisma generate` bouwt dus twee artifacts: + +- Prisma Client in `node_modules/@prisma/client` +- het ERD-diagram in `docs/assets/erd.svg` + +Gebruik volledige `prisma generate` alleen lokaal. De ERD-generator gebruikt Mermaid/Puppeteer en mag niet in CI of Vercel draaien. ## Commands @@ -67,5 +77,9 @@ generator client { |---|---| | `npx prisma db push` | Schema synchroniseren naar de database | | `npx prisma db seed` | Seeddata laden | -| `npx prisma generate` | Prisma Client genereren (lokaal of CI) | -| `npx prisma migrate deploy` | Pending migrations toepassen op de database | +| `npx prisma generate --generator client` | Alleen Prisma Client genereren; gebruiken in CI/deployment | +| `npm run db:erd` | `prisma generate`: Prisma Client en `docs/assets/erd.svg` genereren | +| `npm run db:erd:watch` | `prisma/schema.prisma` watchen en ERD opnieuw genereren | +| `npm run dev` | Next.js dev server plus ERD watcher starten | + +Belangrijk: `db push` schrijft naar de database, maar genereert geen ERD. Gebruik na schemawijzigingen lokaal `npm run db:erd` of laat `npm run dev` de watcher draaien. Gebruik in CI en deployment alleen `npx prisma generate --generator client`. diff --git a/docs/patterns/proxy.md b/docs/patterns/proxy.md index 0dd992f..b6aa7bc 100644 --- a/docs/patterns/proxy.md +++ b/docs/patterns/proxy.md @@ -3,7 +3,7 @@ title: "Proxy (route protection)" status: active audience: [ai-agent, contributor] language: nl -last_updated: 2026-05-08 +last_updated: 2026-05-03 when_to_read: "When adding or modifying route-level access control in proxy.ts." --- @@ -17,16 +17,7 @@ import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' import { sessionOptions } from '@/lib/session' -const protectedRoutes = [ - '/dashboard', - '/products', - '/ideas', - '/solo', - '/jobs', - '/insights', - '/manual', - '/settings', -] +const protectedRoutes = ['/dashboard', '/products', '/todos', '/settings'] const authRoutes = ['/login', '/register'] export function proxy(request: NextRequest) { diff --git a/docs/patterns/route-handler.md b/docs/patterns/route-handler.md index f0e7628..98cc371 100644 --- a/docs/patterns/route-handler.md +++ b/docs/patterns/route-handler.md @@ -3,7 +3,7 @@ title: "Route Handler (REST API)" status: active audience: [ai-agent, contributor] language: nl -last_updated: 2026-05-08 +last_updated: 2026-05-03 when_to_read: "When writing a new Next.js route handler (GET/POST/PATCH/DELETE)." --- @@ -59,7 +59,7 @@ export async function GET( const { id } = await params const sprint = await prisma.sprint.findFirst({ - where: { product_id: id, status: 'OPEN', product: productAccessFilter(auth.userId) }, + where: { product_id: id, status: 'ACTIVE', product: productAccessFilter(auth.userId) }, }) if (!sprint) { return Response.json({ error: 'Geen actieve Sprint gevonden' }, { status: 404 }) @@ -91,16 +91,13 @@ export async function GET( | Methode | Endpoint | Doel | |---|---|---| -| GET | `/api/health` | Liveness; `?db=1` voor DB-ping (geen auth) | | GET | `/api/products` | Actieve producten ophalen | | GET | `/api/products/:id/next-story` | Hoogst geprioriteerde open story | -| GET | `/api/products/:id/claude-context` | Bundled MCP-context | | GET | `/api/sprints/:id/tasks?limit=10` | Eerste N taken van de Sprint | | PATCH | `/api/stories/:id/tasks/reorder` | Taakvolgorde aanpassen | | POST | `/api/stories/:id/log` | Plan / testresultaat / commit vastleggen | -| PATCH | `/api/tasks/:id` | Taakstatus / `implementation_plan` bijwerken | -| GET / POST | `/api/ideas`, `GET / PATCH /api/ideas/:id` | Idea CRUD (vervangt voormalig `/api/todos`) | -| GET | `/api/jobs/:id/sub-tasks` | `sprint_task_executions` van een SPRINT_IMPLEMENTATION-job | +| PATCH | `/api/tasks/:id` | Taakstatus bijwerken | +| POST | `/api/todos` | Todo aanmaken | ## Security-invarianten diff --git a/docs/patterns/server-action.md b/docs/patterns/server-action.md index 7237459..09ea52d 100644 --- a/docs/patterns/server-action.md +++ b/docs/patterns/server-action.md @@ -3,7 +3,7 @@ title: "Server Action" status: active audience: [ai-agent, contributor] language: nl -last_updated: 2026-05-08 +last_updated: 2026-05-03 when_to_read: "When writing a new server action with auth and Zod validation." --- @@ -66,7 +66,7 @@ export async function createPbi(formData: FormData) { - Controleer auth en `session.isDemo` voordat er geschreven wordt. - Gebruik `productAccessFilter(userId)` voor resources waar eigenaar en gekoppelde Developer beide toegang hebben. -- Gebruik eigenaar-only filters (`user_id: session.userId`) alleen voor eigenaarsacties zoals product archiveren, teamleden beheren of persoonlijke ideas. +- Gebruik eigenaar-only filters (`user_id: session.userId`) alleen voor eigenaarsacties zoals product archiveren, teamleden beheren of persoonlijke todos. - Vertrouw nooit losse client-ID's. Als een action meerdere IDs ontvangt, haal ze eerst op met `id in (...)` plus de parent-scope en weiger de operatie als het aantal gevonden records niet exact gelijk is. - Weiger dubbele IDs in reorder-lijsten of beslissingsobjecten. - Leid denormalized foreign keys af uit de database-parent. Voorbeeld: gebruik `pbi.product_id` bij story creation, niet `formData.get('productId')`. diff --git a/docs/patterns/sort-order.md b/docs/patterns/sort-order.md index 2c3ef2c..2aa41b0 100644 --- a/docs/patterns/sort-order.md +++ b/docs/patterns/sort-order.md @@ -1,22 +1,15 @@ --- -title: "sort_order — PBI drag-and-drop vs. code-bindende volgorde voor stories/taken" +title: "Float sort_order (drag-and-drop volgorde)" status: active audience: [ai-agent, contributor] language: nl -last_updated: 2026-05-14 -when_to_read: "When implementing ordering for PBIs (drag-and-drop) or stories/tasks (code-binding)." +last_updated: 2026-05-03 +when_to_read: "When implementing drag-and-drop reordering or inserting between items." --- -# Patroon: sort_order — PBI vs. Story/Taak +# Patroon: Float sort_order (drag-and-drop volgorde) -`sort_order` heeft voor PBI's een andere betekenis dan voor stories en taken. - ---- - -## PBI — float-insertion (drag-and-drop) - -PBI's ondersteunen drag-and-drop herordening. `sort_order` is een `Float` die via de -midpoint-formule wordt berekend bij tussenvoeging: +## Berekening bij tussenvoeging ```ts function getSortOrder(before: number | null, after: number | null): number { @@ -27,9 +20,9 @@ function getSortOrder(before: number | null, after: number | null): number { } ``` -### Herindexeer als precisie opraakt +## Herindexeer als precisie opraakt -Trigger wanneer het kleinste verschil tussen twee opeenvolgende PBI's < 0.001 is: +Trigger wanneer het kleinste verschil tussen twee opeenvolgende items < 0.001 is. ```ts async function reindexIfNeeded(items: { id: string; sort_order: number }[]) { @@ -44,62 +37,12 @@ async function reindexIfNeeded(items: { id: string; sort_order: number }[]) { } ``` -### Reorder Server Action (PBI-only) +## Reorder Server Actions -Een drag-and-drop reorder stuurt client-controlled ID-lijsten naar de server. -Behandel die lijst als onbetrouwbaar: +Een drag-and-drop reorder stuurt altijd client-controlled ID-lijsten naar de server. Behandel die lijst als onbetrouwbaar. - Weiger dubbele IDs. -- Haal alle IDs op met de parent-scope (`product_id`). +- Haal alle IDs op met de parent-scope, bijvoorbeeld `product_id`, `pbi_id`, `sprint_id` of `story_id`. - Weiger de operatie als het aantal gevonden records niet exact gelijk is aan het aantal aangeleverde IDs. - Update pas daarna `sort_order` in een transactie. - Gebruik bij priority changes dezelfde parent uit de database, niet een los meegegeven `productId`. - ---- - -## Story / Taak — code-bindende volgorde (geen drag-and-drop) - -Voor stories en taken is `sort_order` een **numerieke spiegel van `code`**, berekend via -`parseCodeNumber(code)` uit `lib/code.ts`. Drag-and-drop herordening bestaat niet voor -stories en taken. - -### Wanneer `sort_order` wordt gezet - -| Moment | Wat er gebeurt | -|---|---| -| `story.create` / `task.create` | `sort_order = parseCodeNumber(code)` | -| Idea-materialisatie (`materializeIdeaPlanAction`) | idem — stories en taken krijgen `sort_order = parseCodeNumber(storyCode / taskCode)` | -| Code-edit (PATCH met nieuw `code`) | `sort_order = parseCodeNumber(newCode)` wordt bijgewerkt | -| Sprint-membership-acties | `sort_order` wordt **niet** aangeraakt | - -### `parseCodeNumber` - -Extraheert het trailertal uit een code-string: - -```ts -// lib/code.ts -export function parseCodeNumber(code: string): number { - const match = code.match(/(\d+)$/) - return match ? parseInt(match[1], 10) : 0 -} -``` - -Voorbeelden: `"ST-042"` → `42`, `"T-7"` → `7`, `"CUSTOM-FOO"` → `0`. - -### Ordering queries - -Stories en taken worden **uitsluitend** op `sort_order` geordend — nooit op `priority`: - -```ts -// stories binnen een sprint -orderBy: [{ sort_order: 'asc' }] - -// taken binnen een story -orderBy: { sort_order: 'asc' } - -// taken binnen een sprint (story-volgorde eerst) -orderBy: [{ story: { sort_order: 'asc' } }, { sort_order: 'asc' }] -``` - -`priority` is een **label** (urgentie-aanduiding voor de gebruiker), geen -sorteerkriteria voor stories of taken. diff --git a/docs/patterns/web-push.md b/docs/patterns/web-push.md deleted file mode 100644 index 24c83ff..0000000 --- a/docs/patterns/web-push.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -title: "Web Push" -status: active -audience: [ai-agent, contributor] -language: nl -last_updated: 2026-05-07 -when_to_read: "When sending push notifications from the server or MCP layer, or when troubleshooting PWA push on iOS." ---- - -# Patroon: Web Push - -## Wat & wanneer - -Gebruik Web Push voor OS-niveau notificaties die ook verschijnen wanneer de browser-tab gesloten is (b.v. Claude-vragen, sprint-completion). Gebruik het **niet** voor in-app realtime feedback — daarvoor dient de SSE/realtime/notifications-stack (`stores/notifications-store`). - -## Architectuur - -``` -MCP / cron - │ - ▼ -POST /api/internal/push/send ← Bearer: INTERNAL_PUSH_SECRET - │ - ▼ -lib/push-server.ts → sendPushToUser(userId, payload) - │ • VAPID-check (disabled = warn + return) - │ • prisma.pushSubscription.findMany - │ • Promise.allSettled(sendOne[]) - │ • 404/410 → auto-delete stale subscription - ▼ -Web Push Service (FCM / APNS) - │ - ▼ -public/sw.js → showNotification + notificationclick -``` - -## Payload-shape - -```ts -type PushPayload = { - title: string // max 80 tekens - body: string // max 300 tekens - url: string // absoluut pad ('/dashboard') of volledige URL - tag?: string // dedupliceert notificaties met dezelfde tag -} -``` - -## Foutcodes - -| Code | Betekenis | Actie | -|------|-----------|-------| -| 404 / 410 | Stale endpoint (browser heeft sub verwijderd) | Auto-delete in `sendOne` | -| 5xx | Tijdelijke fout push-service | Geen automatische retry in v1 — log + swallow | -| 401 (send-route) | Verkeerd of ontbrekend Bearer-secret | Check `INTERNAL_PUSH_SECRET` | -| 422 (send-route) | Ongeldige body | Zie payload-shape hierboven | -| 503 (send-route) | `INTERNAL_PUSH_SECRET` niet geconfigureerd | Zet env-var | - -## VAPID-configuratie - -Genereer sleutels eenmalig: -```bash -npx web-push generate-vapid-keys -``` - -Zet in `.env.local`: -```bash -NEXT_PUBLIC_VAPID_PUBLIC_KEY="<public key>" -VAPID_PRIVATE_KEY="<private key>" -VAPID_SUBJECT="mailto:admin@example.com" -INTERNAL_PUSH_SECRET="<min 32 chars, openssl rand -base64 32>" -``` - -Als de VAPID-envs ontbreken, returnt `sendPushToUser` vroeg met een `console.warn`; de app crasht **niet**. - -## iOS-quirks - -- Vereist iOS 16.4+ (Safari 16.4). -- De gebruiker moet de app eerst via **Zet op beginscherm** als PWA installeren. Push werkt niet vanuit een normale Safari-tab. -- In de EU (iOS 17.4+ na DMA) worden meldingen door Apple beperkt voor alternatieve browser-engines; test op Safari specifiek. -- `isIOSSafari()` + `!isStandalonePWA()` → `PushToggle` toont de installatie-banner in plaats van een toggle. - -## Demo-users - -`subscribeToPushAction` controleert `session.isDemo` en returnt zonder schrijven. Demo-gebruikers kunnen zich dus niet aanmelden voor push. - -## Triggeren vanuit MCP of server-code - -```ts -// Directe server-aanroep (binnen Next.js): -import { sendPushToUser } from '@/lib/push-server' -await sendPushToUser(userId, { title: 'Vraag van Claude', body: question, url: `/products/${productId}` }) - -// Vanuit MCP / externe service (HTTP): -await fetch(`${BASE_URL}/api/internal/push/send`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${INTERNAL_PUSH_SECRET}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ userId, payload: { title, body, url } }), -}) -// 204 = verzonden, 503 = VAPID niet geconfigureerd -``` - -## Admin testroute - -```bash -curl -X POST https://<host>/api/internal/push/test-send \ - -H "Cookie: <admin session cookie>" -# Vereist ingelogde admin-sessie; stuurt push naar eigen account. -``` diff --git a/docs/patterns/workspace-store.md b/docs/patterns/workspace-store.md deleted file mode 100644 index 20691c9..0000000 --- a/docs/patterns/workspace-store.md +++ /dev/null @@ -1,414 +0,0 @@ ---- -title: "Workspace-store + realtime — bounded-context patroon" -status: active -audience: [ai-agent, contributor] -language: nl -last_updated: 2026-05-10 -when_to_read: "When adding a new bounded-context client store backed by SSE, or when modifying product/sprint workspace state." ---- - -# Patroon: workspace-store + realtime - -Sinds PBI-74 is `product-workspace-store` de blueprint voor client-state op een -**bounded context** (één coherente workflow). Andere bounded contexts mogen -hetzelfde patroon volgen — `sprint-workspace-store`, `solo-store`, -`notifications-store`. Dit document beschrijft wanneer je een workspace-store -opzet, hoe je 'm structureert, hoe SSE en de store samenwerken, en welke -gotchas in code-comments hoort. - -Bron-ontwerp: [zustand-store-rearchitecture.md](../plans/zustand-store-rearchitecture.md). -Referentie-implementatie: [stores/product-workspace/](../../stores/product-workspace/). - ---- - -## Wanneer een workspace-store - -Eén store **per bounded context**, niet per pagina en niet één megastore. - -| Workflow | Store | -|---|---| -| Product backlog (PBI/story/task selectie + DnD) | `product-workspace-store` | -| Sprint board | `sprint-workspace-store` (toekomstig, PBI > 74) | -| Solo execution | `solo-store` | -| Notifications/questions | `notifications-store` | -| Idea grill/plan-flow | `idea-store` | -| Lijst van producten | `products-store` (≠ active product) | - -Splits niet per panel; bundel niet over workflows. - ---- - -## State-shape - -Vlak en **genormaliseerd**. Vijf slices: - -```ts -{ - context: { active*Id } // huidige selectie - entities: { *ById } // entity-maps per kind - relations: { ids[], idsByParent } // gesorteerde id-lijsten - loading: { loaded*Ids, activeRequestId } // race-safe markers - sync: { realtimeStatus, lastResyncAt, resyncReason } - pendingMutations: { [id]: { mutation, createdAt } } -} -``` - -**Acties** zijn in dezelfde store: -`hydrateSnapshot`, `setActive*`, `ensure*Loaded`, `applyRealtimeEvent`, -`resyncActiveScopes`, `resyncLoadedScopes`, -`applyOptimisticMutation`/`rollbackMutation`/`settleMutation`. - -Gebruik `zustand/middleware/immer`. Mutation-style (G3 — return nooit een -nieuwe state uit een immer-recipe; muteer de draft). - ---- - -## Selectors - -Module-level **`EMPTY`**-refs (G1) en `useShallow` voor lijsten (G2). - -```ts -// stores/product-workspace/selectors.ts -const EMPTY_PBIS: BacklogPbi[] = [] - -export function selectVisiblePbis(s: Store): BacklogPbi[] { - if (s.relations.pbiIds.length === 0) return EMPTY_PBIS - return s.relations.pbiIds.map((id) => s.entities.pbisById[id]).filter(Boolean) -} -``` - -```tsx -// component -import { useShallow } from 'zustand/react/shallow' -import { selectVisiblePbis } from '@/stores/product-workspace/selectors' - -const pbis = useStore(useShallow(selectVisiblePbis)) -const activePbiId = useStore((s) => s.context.activePbiId) // primitive — geen useShallow -``` - -Single-value selectors (`selectActivePbi`) hebben geen `useShallow` nodig. - ---- - -## ensure*Loaded — race-safe loaders - -Elke setter genereert een nieuwe `requestId`, schrijft 'm in -`loading.activeRequestId`, en triggert de loader. De loader checkt **na de -fetch** of de guard nog matcht — anders bail-out. - -```ts -setActivePbi(pbiId) { - const requestId = newRequestId() - set((s) => { - s.context.activePbiId = pbiId - s.context.activeStoryId = null - s.context.activeTaskId = null - s.loading.activeRequestId = requestId - }) - if (pbiId) void get().ensurePbiLoaded(pbiId, requestId) -} - -async ensurePbiLoaded(pbiId, requestId) { - const stories = await fetchJson(`/api/pbis/${pbiId}/stories`) - if (requestId && get().loading.activeRequestId !== requestId) return - if (!Array.isArray(stories)) return - set((s) => { /* apply */ }) -} -``` - -**Belangrijke regels:** - -- Gebruik `get().method()` per call (G4) — nooit `state.method()` via een - gecaptured snapshot. Method-refs zijn niet stabiel over immer state-versies. -- `fetch(url, { cache: 'no-store' })` op alle client-fetches. -- Server read-routes: `export const dynamic = 'force-dynamic'`. - ---- - -## SSE-hook beheert transport, store beheert betekenis - -```txt -useXxxRealtime(activeId) --> opent /api/realtime/xxx?... --> parsed event --> dispatcht naar store.applyRealtimeEvent(event) --> beheert reconnect/backoff/status --> op 'ready' na (re)connect: telt cycles; latere ready triggert resync('reconnect') -``` - -```ts -// applyRealtimeEvent regels -known pbi/story/task event - → upsert + sort, parent-move bij wijziging parent_id - → idempotent: bestaande id bij INSERT → return - → DELETE → ruim child entities op + clear actieve selectie als die viel - -unknown entity met matching product_id, geen 'type' veld - → resyncActiveScopes('unknown-event') - -job/worker/heartbeat (heeft 'type' veld) - → negeer -``` - -**Idempotent:** een event dat al via een optimistic mutation is toegepast, -mag geen dubbele insert of verkeerde rollback veroorzaken. INSERTs checken -`if (entity exists) return`. UPDATEs zijn altijd merge-into-existing. - -Payload-contract: zie [realtime-notify-payload.md](./realtime-notify-payload.md). - ---- - -## Hidden tab + reconnect resync - -EventSource blijft open als de tab `hidden` wordt — gemiste events worden -opgehaald via een expliciete resync-laag. - -```ts -// In de realtime-hook -const onVisibility = () => { - if (document.visibilityState === 'visible' && sourceRef.current === null) { - connect() // alleen als de stream weg is (b.v. server hard-close na 240s) - } -} -// Geen close() bij hidden. - -source.addEventListener('ready', () => { - readyCountRef.current += 1 - if (readyCountRef.current > 1) { - void store.resyncActiveScopes('reconnect') - } -}) -``` - -```ts -// useWorkspaceResync — visibility + online -useEffect(() => { - const onVisibility = () => { - if (document.visibilityState === 'visible') { - void store.resyncActiveScopes('visible') - } - } - const onOnline = () => void store.resyncActiveScopes('reconnect') - - document.addEventListener('visibilitychange', onVisibility) - window.addEventListener('online', onOnline) - return () => { /* remove */ } -}, []) -``` - -**Mount in dezelfde wrapper als de realtime-hook.** Doe nooit alleen het -sluiten-op-hidden weghalen zonder de resync-laag erbij — dan verlies je het -vangnet. - -`resyncActiveScopes` triggert alleen de loaders die gekoppeld zijn aan de -huidige selectie: - -```ts -async resyncActiveScopes(reason) { - const ctx = get().context - const tasks: Promise<void>[] = [] - if (ctx.activeProduct?.id) tasks.push(get().ensureProductLoaded(ctx.activeProduct.id)) - if (ctx.activePbiId) tasks.push(get().ensurePbiLoaded(ctx.activePbiId)) - if (ctx.activeStoryId) tasks.push(get().ensureStoryLoaded(ctx.activeStoryId)) - if (ctx.activeTaskId) tasks.push(get().ensureTaskLoaded(ctx.activeTaskId)) - set((s) => { s.sync.lastResyncAt = Date.now(); s.sync.resyncReason = reason }) - await Promise.allSettled(tasks) -} -``` - ---- - -## LocalStorage = restore-hint, niet waarheid - -Selectie-id's worden gepersisteerd om bij cold reload de vorige selectie te -herstellen, **maar de hint wordt pas toegepast nadat ensure-load is gelukt -en de hint-id bevestigd is in `entities.byId`**. - -```ts -setActiveProduct(product) { - set((s) => { s.context.activeProduct = product; ... }) - writeProductHint(product?.id ?? null) - - if (product) { - void (async () => { - await get().ensureProductLoaded(product.id, requestId) - if (get().loading.activeRequestId !== requestId) return - const hint = readHints().perProduct[product.id]?.lastActivePbiId - if (hint && get().entities.pbisById[hint]) { - get().setActivePbi(hint) // cascade — die doet zelfde voor story - } - })() - } -} -``` - -**Geen `setTimeout(0)` of microtask-trick.** De fetch is dan nog niet klaar, -de validatie `entities.byId[hint]` faalt altijd. Chain altijd `await -ensureXxxLoaded` en valideer in dezelfde `requestId`-cycle. - -**URL wint van hint.** Maak een client-component (b.v. -[`UrlTaskSync`](../../components/backlog/url-task-sync.tsx)) die op mount -`useSearchParams().get('editTask')` leest, de hint overschrijft via -`writeTaskHint`, en `setActiveTask` aanroept. De restore-flow leest de -task-hint pas na drie ensure-awaits, dus de URL-write komt altijd eerder. - ---- - -## Optimistic mutations - -Voor DnD en status-toggles. De store registreert alleen het rollback-snapshot; -de component muteert state direct én roept de server aan. - -```tsx -function handleDragEnd(event) { - const store = useStore.getState() - const prevOrder = [...store.relations.pbiIds] - const newOrder = arrayMove(prevOrder, oldIndex, newIndex) - - // 1. Snapshot voor rollback - const mutationId = store.applyOptimisticMutation({ - kind: 'pbi-order', - prevPbiIds: prevOrder, - }) - - // 2. Optimistisch toepassen - useStore.setState((s) => { s.relations.pbiIds = newOrder }) - - // 3. Server bevestigt (of niet) - startTransition(async () => { - const result = await reorderPbisAction(productId, newOrder) - const st = useStore.getState() - if (result.success) { - st.settleMutation(mutationId) - } else { - st.rollbackMutation(mutationId) - toast.error('Volgorde opslaan mislukt') - } - }) -} -``` - -**Cross-priority drag** vereist twee mutaties: een `pbi-order` voor de lijst -plus een `entity-patch` voor de priority op de PBI zelf. Beide settle/rollback -samen. - -**SSE-echo van een net optimistisch toegepaste wijziging** moet idempotent -zijn — INSERT → bestaat al → return; UPDATE → merge into existing. - ---- - -## API endpoints - -Voor elke `ensure*Loaded` een GET-route met: - -- Auth via `authenticateApiRequest` (Bearer-token of iron-session cookie). -- Access-control via `productAccessFilter(userId)` voor product-context; - `getAccessibleProduct` voor explicit guards. -- `export const dynamic = 'force-dynamic'`. -- Status-vertaling via `taskStatusToApi` / `storyStatusToApi` / - `pbiStatusToApi` (DB UPPER_SNAKE → API lowercase). - -Referentie: -[GET /api/products/:id/backlog](../../app/api/products/[id]/backlog/route.ts), -[GET /api/pbis/:id/stories](../../app/api/pbis/[id]/stories/route.ts), -[GET /api/stories/:id/tasks](../../app/api/stories/[id]/tasks/route.ts), -[GET /api/tasks/:id](../../app/api/tasks/[id]/route.ts). - -`TaskDetail` shape extends `BacklogTask` met `_detail: true` plus extra -velden (`implementation_plan`, `acceptance_criteria`, `requires_opus`, -`verify_only`, `verify_required`). Gebruik de `isDetail()` typeguard om de -extra velden te tonen. - ---- - -## Tests - -Vitest + jsdom; setup in [`tests/setup.ts`](../../tests/setup.ts): - -- `MemoryStorage` shim voor localStorage (G6 — vitest 4 + jsdom 29 mist 'm - als configurable global). -- `globalThis.fetch` herconfigureerbaar gemaakt zodat `vi.spyOn` werkt - (anders krijg je `Cannot redefine property: fetch`). -- Default fetch-stub die `null` JSON returnt — voorkomt unhandled rejections - uit fire-and-forget `ensure*Loaded` calls die in tests niet expliciet - gemockt zijn. Tests overrulen met `vi.spyOn(globalThis, 'fetch')` per case. -- `mockImplementation` (G8) — niet `mockResolvedValue` — anders is de - Response-body na de eerste `.json()` weg. - -```ts -// G5: snapshot original actions module-level, restore in beforeEach -const originalActions = (() => { - const s = useStore.getState() - return { /* alle action-refs */ } -})() - -function resetStore() { - useStore.setState((s) => { - Object.assign(s, initialDataSlices) - Object.assign(s, originalActions) - }) -} - -beforeEach(resetStore) -``` - -**Acties mocken:** gebruik `setState((s) => { s.method = vi.fn() })`. Niet -`vi.spyOn(state, 'method')` — de immer-frozen state is niet redefinable. - -**Verplichte test-cases per workspace-store:** - -- `hydrateSnapshot` vult entities + relations met sortering. -- Selection cascade: `setActivePbi` reset story+task; `setActiveStory` reset - task. -- `setActiveProduct(null)` ruimt entities en relations op. -- `applyRealtimeEvent` pbi/story/task `I|U|D` met sortering en parent-move. -- Event voor ander `product_id` wordt genegeerd. -- Unknown entity met matching product → `resyncActiveScopes('unknown-event')` - trigger. -- Job/worker/heartbeat/question events met `type`-veld → geen resync. -- Delete-cleanup van actieve selectie. -- Race-safe `ensure*Loaded` met requestId-guard (oude in-flight mag niet - nieuwere selectie overschrijven). -- `ensureTaskLoaded` zet `_detail: true`. -- `resyncActiveScopes` triggert ensure-keten met juiste URLs en zet - `lastResyncAt` + `resyncReason`. -- localStorage restore-hints per setter. -- Hint die niet (meer) in entities zit wordt genegeerd. -- Optimistic mutation rollback/settle/SSE-echo idempotent. - ---- - -## Gotchas — comment-template voor in code - -Documenteer deze in code via comments boven de fix. - -| # | Symptoom | Fix | -|---|---|---| -| **G1** | "Maximum update depth exceeded" — `s.byId[x] ?? []` levert nieuwe array per render | Module-level `EMPTY` const als fallback | -| **G2** | Component re-rendert op iedere store-mutatie ondanks dat z'n data niet wijzigt | `useShallow(selectXxx)` voor lijsten | -| **G3** | Hele state lijkt gewist na een `setState((s) => ({ context: ... }))` | Gebruik mutation-style: `setState((s) => { s.context.x = y })` (immer recipe muteert draft) | -| **G4** | "method is not a function" in async context, of inconsistente state-mutaties | `get().method()` per call; nooit `const m = state.method` cachen | -| **G5** | Tests beïnvloeden elkaar via `setState({ resyncActiveScopes: vi.fn() })` | `originalActions` snapshot op module-load + restore in `beforeEach` | -| **G6** | `localStorage.clear is not a function` in vitest | `MemoryStorage` shim in `tests/setup.ts` | -| **G7** | "Failed to parse URL from /api/..." in test-fetch | Mock fetch via `vi.spyOn(globalThis, 'fetch')` of stub in setup | -| **G8** | "Body is unusable: Body has already been read" | `vi.fn().mockImplementation(() => Promise.resolve(new Response(...)))` — niet `mockResolvedValue` met een vooraf-gemaakte Response | - ---- - -## Migratiepad voor een nieuwe workspace-store - -Volg dezelfde 8 stappen als PBI-74 (zie -[zustand-workspace-store-implementation.md](../plans/zustand-workspace-store-implementation.md)): - -1. Skelet — types, store, selectors, restore + tests; geen UI-impact. -2. Hydratie overstappen (parallel naast bestaande store). -3. Componenten omzetten — `useShallow` voor lijsten, `setActiveX` setters. -4. Race-safe loaders + restore-hints + URL-prioriteit. -5. Hidden-tab + reconnect-resync (één PR — anders verlies je vangnet). -6. Unknown-event filter (`isUnknownEntityEvent`). -7. Cache-headers + LIST-endpoints (`force-dynamic`, `cache: 'no-store'`). -8. Oude store opruimen. - -Stap 9 ("sprint-workspace-store") is de toepassing van dit patroon op de -sprint-flow — kan starten zodra `product-workspace-store` enkele weken -stabiel in productie staat. diff --git a/docs/patterns/zustand-optimistic.md b/docs/patterns/zustand-optimistic.md index 792c02a..0a75f95 100644 --- a/docs/patterns/zustand-optimistic.md +++ b/docs/patterns/zustand-optimistic.md @@ -3,99 +3,34 @@ title: "Zustand optimistische update + rollback" status: active audience: [ai-agent, contributor] language: nl -last_updated: 2026-05-10 -when_to_read: "When adding client-side state mutations that need optimistic UI and rollback (DnD, status toggles)." +last_updated: 2026-05-03 +when_to_read: "When adding client-side state mutations that need optimistic UI and rollback." --- # Patroon: Zustand optimistische update + rollback -Sinds PBI-74 lopen optimistic mutations via `applyOptimisticMutation`/ -`rollbackMutation`/`settleMutation` op de **workspace-store**. Het bredere -patroon (store-design, SSE-integratie, restore-hints, tests) staat in -[workspace-store.md](./workspace-store.md). Dit document beschrijft het -DnD/status-mutation flow specifiek. +Gebruik dit patroon bij elke dnd-kit `onDragEnd` handler. -## Patroon +```ts +const { pbiOrder, reorderPbis, rollbackPbis } = usePlannerStore() -1. Snapshot rollback-info via `applyOptimisticMutation` — krijgt `mutationId`. -2. Pas state direct aan via `setState`. -3. Server-actie aanroepen. -4. Op success: `settleMutation(mutationId)` (ruimt pending-record op). -5. Op error: `rollbackMutation(mutationId)` (herstelt vorige state + toast). - -Cross-priority drag vereist twee mutaties (order + entity-patch) die samen -settlen of rollbacken. - -## Voorbeeld — PBI reorder - -```tsx -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' - -function handleDragEnd(event: DragEndEvent) { +async function handleDragEnd(event: DragEndEvent) { const { active, over } = event if (!over || active.id === over.id) return - const store = useProductWorkspaceStore.getState() - const prevOrder = [...store.relations.pbiIds] - const oldIndex = prevOrder.indexOf(active.id as string) - const newIndex = prevOrder.indexOf(over.id as string) - if (oldIndex === -1 || newIndex === -1) return - const newOrder = arrayMove([...prevOrder], oldIndex, newIndex) + const prevOrder = [...pbiOrder[productId]] + const newOrder = arrayMove(prevOrder, oldIndex, newIndex) - // 1. Snapshot rollback-info - const mutationId = store.applyOptimisticMutation({ - kind: 'pbi-order', - prevPbiIds: prevOrder, - }) + // 1. Optimistisch updaten (direct zichtbaar voor gebruiker) + reorderPbis(productId, newOrder) - // 2. Optimistisch toepassen - useProductWorkspaceStore.setState((s) => { - s.relations.pbiIds = newOrder - }) + // 2. Persisteren via Server Action + const result = await reorderPbisAction(productId, newOrder) - // 3-5. Server bevestigt of niet - startTransition(async () => { - const result = await reorderPbisAction(productId, newOrder) - const st = useProductWorkspaceStore.getState() - if (result.success) { - st.settleMutation(mutationId) - } else { - st.rollbackMutation(mutationId) - toast.error('Volgorde opslaan mislukt') - } - }) + // 3. Rollback bij fout + if (!result.success) { + rollbackPbis(productId, prevOrder) + toast.error('Volgorde opslaan mislukt') + } } ``` - -## Voorbeeld — entity-patch (priority-wijziging) - -```tsx -const prevPbi = store.entities.pbisById[id] -const patchMutationId = store.applyOptimisticMutation({ - kind: 'entity-patch', - entity: 'pbi', - id, - prev: prevPbi, -}) -useProductWorkspaceStore.setState((s) => { - const pbi = s.entities.pbisById[id] - if (pbi) pbi.priority = newPriority -}) -// settle/rollback identiek aan order-flow -``` - -## Mutation-soorten - -| `kind` | Rollback-data | Use-case | -|---|---|---| -| `pbi-order` | `prevPbiIds` | DnD reorder van PBI's | -| `story-order` | `pbiId` + `prevStoryIds` | DnD reorder van stories binnen een PBI | -| `task-order` | `storyId` + `prevTaskIds` | DnD reorder van tasks binnen een story | -| `entity-patch` | `entity` + `id` + `prev` (volledig vorig record of `undefined` voor delete-rollback) | Property-wijzigingen (priority, status), of optimistic delete/undelete | - -## SSE-echo idempotent verwerken - -Wanneer de server bevestigt en de NOTIFY-trigger het bijbehorende event -emitteert, mag `applyRealtimeEvent` **geen dubbele insert** veroorzaken en -**geen rollback triggeren**. INSERTs checken bestaan; UPDATEs mergen -into-existing. Zie `applyRealtimeEvent` in [`stores/product-workspace/store.ts`](../../stores/product-workspace/store.ts). diff --git a/docs/old/pbi-dialog.md b/docs/pbi-dialog.md similarity index 100% rename from docs/old/pbi-dialog.md rename to docs/pbi-dialog.md diff --git a/docs/old/plans/M10-qr-pairing-login.md b/docs/plans/M10-qr-pairing-login.md similarity index 100% rename from docs/old/plans/M10-qr-pairing-login.md rename to docs/plans/M10-qr-pairing-login.md diff --git a/docs/old/plans/M11-claude-questions.md b/docs/plans/M11-claude-questions.md similarity index 100% rename from docs/old/plans/M11-claude-questions.md rename to docs/plans/M11-claude-questions.md diff --git a/docs/plans/M12-ideas.md b/docs/plans/M12-ideas.md deleted file mode 100644 index 988e490..0000000 --- a/docs/plans/M12-ideas.md +++ /dev/null @@ -1,299 +0,0 @@ ---- -title: "M12 — Idea entity + Grill/Plan Claude jobs" -status: planned -audience: implementation -language: nl ---- - -# M12 — Idea entity + Grill/Plan Claude jobs - -## Context - -Scrum4Me ondersteunt `Todo` als lichtgewicht voorstel-laag, en kan dat handmatig promoveren naar PBI/Story. Dat slaat het *denkproces* niet vast: waarom werd iets een PBI, welke alternatieven zijn afgewogen, welke randvoorwaarden waren er. - -Doel: een nieuw concept **Idee** dat: -- werkt als een Todo (top-level lijst, privé per gebruiker), met een **Grill Me**- en **Make Plan**-knop; -- via de bestaande Claude-job/worker-infrastructuur een gestructureerd plan oplevert; -- het hele planningsproces vastlegt (Q&A, beslissingen, grill-md, plan-md, link naar PBI); -- na goedkeuring deterministisch materialiseert tot PBI + stories + taken (incl. `implementation_plan`). - -## Vastgelegde keuzes (uit grill-sessie) - -1. **UI-plek**: top-level `/ideas`, naast `/todos`. -2. **Auth-scope**: strikt `user_id`-only (privé, ook ná `PLANNED`). Geen `productAccessFilter` op idea-acties; geen `pbi.idea_id`-veld nodig. -3. **Product-binding**: een idee mag bestaan zonder product, maar **Grill Me** én **Make Plan** vereisen een product met `repo_url` (de worker leest sources/docs uit de repo). `claude_jobs.product_id` blijft NOT NULL. -4. **Executie-model**: bestaand worker-model. `ClaudeJob{kind:IDEA_*}` QUEUED → lokale Claude-CLI claimt via `wait_for_job`. Knoppen zijn **disabled als `connectedWorkers === 0`** (exact zoals `solo/task-detail-dialog.tsx`). -5. **Skill-afhankelijkheid**: **embedded prompts** in `lib/idea-prompts/{grill,make-plan}.md`; meegestuurd in payload. Geen externe `anthropic-skills:grill-me`-plugin-vereiste op de worker. -6. **Make-Plan flow**: preview-en-bevestigen. Job produceert `Idea.plan_md`, status → `PLAN_READY`. Aparte knop **"Materialiseer plan"** parseert md → entiteiten in één Prisma-transactie, status → `PLANNED`. -7. **Plan-md formaat**: YAML-frontmatter (structuur) + markdown-body (overwegingen, alternatieven, vrije reasoning). -8. **Make-Plan-job**: single-pass (geen `ask_user_question`). Twijfels → terug naar grill (append-context). -9. **Backward transitions**: - - Re-grill vanuit `GRILLED`/`PLAN_READY`: nieuwe `IDEA_GRILL`-job met **append-context** (oude `grill_md` als input); oude versie naar `IdeaLog{type:GRILL_RESULT}` als history. - - Re-plan vanuit `PLAN_READY`: idem voor `plan_md`. - - PBI-verwijdering vanuit `PLANNED`: **expliciete user-actie "Re-link plan"** (geen DB-trigger). Zet `pbi_id=null`, status `PLAN_READY`. - - Failed grill/plan: dedicated states **`GRILL_FAILED` / `PLAN_FAILED`** (zichtbaar voor user), niet stilzwijgend resetten. -10. **Logging-model**: `IdeaLog` smal (`DECISION | NOTE | GRILL_RESULT | PLAN_RESULT | STATUS_CHANGE | JOB_EVENT`). Q&A blijft uitsluitend in `claude_questions`. Timeline-tab in UI doet `UNION ALL` over beide bronnen. -11. **Opslag md-bestanden**: alleen DB (`Idea.grill_md`, `Idea.plan_md`). Geen auto-commit naar repo (zou strict-private auth-keuze ondergraven). UI biedt **"Download .md"**. -12. **Editability**: beide md's bewerkbaar door user in hun ready-states (`GRILLED` voor grill_md, `PLAN_READY` voor plan_md). Bij `PLANNED`: read-only. Yaml-frontmatter wordt zod-gevalideerd-on-save voor `plan_md`. -13. **Promotie vanuit Todo**: nieuwe `promoteTodoToIdeaAction` (Todo → DRAFT-Idea + Todo wordt `archived=true`). Bestaande Todo→PBI/Story-acties blijven onaangetast. -14. **Demo-policy** (3-laag, zoals Todo): create/edit/archive **mag**; Grill / Make Plan / Materialiseer / promote-from-Todo zijn **geblokkeerd** (proxy.ts 403 + `session.isDemo`-guard + `<DemoTooltip>`). -15. **Idea-code**: `Idea.code = "IDEA-{nnn}"`, `@@unique([user_id, code])`, counter op `User.idea_code_counter`. -16. **Realtime-store**: nieuwe `stores/idea-store.ts`. `connectedWorkers` direct selecten via `useSoloStore(s => s.connectedWorkers)` (lift naar shared store is opvolg-refactor). -17. **Sidebar**: nieuwe entry **Ideeën** (`Lightbulb`-icon) direct boven Todo's. -18. **Q&A-expiry**: 24h aanhouden (consistent met bestaand). Verlopen → re-grill (append-context). - -## State machine - -``` - ┌──── re-grill ────┐ - ▼ │ -DRAFT ──Grill Me──▶ GRILLING ─done──▶ GRILLED ─Make Plan─▶ PLANNING ─done──▶ PLAN_READY ─Materialiseer─▶ PLANNED - │ fail │ fail │ ▲ │ - ▼ ▼ │ │ │ - GRILL_FAILED PLAN_FAILED └──┘ re-plan │ - │ │ - └────── retry/edit ──────────────────────────────────────── PBI verwijderd ──────────┘ - + "Re-link plan" -``` - -`archived: boolean` is orthogonaal en kan vanuit elke status. - -## Datamodel - -### Nieuwe enums -```prisma -enum IdeaStatus { - DRAFT - GRILLING - GRILL_FAILED - GRILLED - PLANNING - PLAN_FAILED - PLAN_READY - PLANNED -} - -enum ClaudeJobKind { - TASK_IMPLEMENTATION - IDEA_GRILL - IDEA_MAKE_PLAN -} - -enum IdeaLogType { - DECISION - NOTE - GRILL_RESULT - PLAN_RESULT - STATUS_CHANGE - JOB_EVENT -} -``` - -### `User` -- Veld toevoegen: `idea_code_counter Int @default(0)`. - -### Nieuwe tabel `ideas` -```prisma -model Idea { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) - product_id String? - code String @db.VarChar(30) - title String - description String? @db.VarChar(4000) - grill_md String? @db.Text - plan_md String? @db.Text - pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull) - pbi_id String? @unique - status IdeaStatus @default(DRAFT) - archived Boolean @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - questions ClaudeQuestion[] - jobs ClaudeJob[] - logs IdeaLog[] - - @@unique([user_id, code]) - @@index([user_id, archived, status]) - @@index([user_id, product_id]) - @@map("ideas") -} -``` - -### Nieuwe tabel `idea_logs` -```prisma -model IdeaLog { - id String @id @default(cuid()) - idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade) - idea_id String - type IdeaLogType - content String @db.Text - metadata Json? - created_at DateTime @default(now()) - - @@index([idea_id, created_at]) - @@map("idea_logs") -} -``` - -### Aanpassingen `claude_jobs` -- `task_id` → **nullable**. -- `idea_id String?` toegevoegd, FK → `Idea`, `onDelete: Cascade`. -- `kind ClaudeJobKind @default(TASK_IMPLEMENTATION)` toegevoegd. -- `product_id` blijft NOT NULL. -- Raw-SQL check-constraint: `(task_id IS NOT NULL) <> (idea_id IS NOT NULL)`. -- Index: `@@index([idea_id, status])`. - -### Aanpassingen `claude_questions` -- `story_id` → **nullable**. -- `idea_id String?` toegevoegd, FK → `Idea`, `onDelete: Cascade`. -- Raw-SQL check-constraint: `(story_id IS NOT NULL) <> (idea_id IS NOT NULL)`. -- Index: `@@index([idea_id, status])`. -- pg_notify-trigger payload uitbreiden met `idea_id` (nullable). SSE-filter laat idea-payloads alleen door naar `idea.user_id === session.user_id`. - -## Server-laag - -### Schemas + helpers -- `lib/schemas/idea.ts` — `ideaCreateSchema`, `ideaUpdateSchema`, `ideaPlanMdFrontmatterSchema`. -- `lib/idea-status.ts` — DB-enum ↔ API-string mapping. -- `lib/idea-plan-parser.ts` — synchroon: `parsePlanMd(md): ParsedPlan | ZodError`. Gebruikt `yaml`-package + zod. -- `lib/idea-code.ts` — atomair `nextIdeaCode(userId)` via Prisma-transactie. - -### Embedded prompts -- `lib/idea-prompts/grill.md` — eigen scrum4me-versie. Instrueert: gebruik `ask_user_question` MCP, schrijf via `update_idea_grill_md` aan eind. -- `lib/idea-prompts/make-plan.md` — strict yaml-frontmatter-format. Instrueert: lees `grill_md`, gebruik repo-files, **geen vragen**, eindig met `update_idea_plan_md`. - -### Server actions — `actions/ideas.ts` -Volg `docs/patterns/server-action.md`: auth → demo-check → zod → user-id-scope-check → write → `revalidatePath`. - -- `createIdeaAction(input)` — `nextIdeaCode(userId)`, status `DRAFT`. -- `updateIdeaAction(id, input)` — alleen `DRAFT|GRILL_FAILED|GRILLED|PLAN_FAILED|PLAN_READY`. -- `archiveIdeaAction(id)` / `unarchiveIdeaAction(id)`. -- `deleteIdeaAction(id)` — geweigerd als `pbi_id` gevuld. -- `updateGrillMdAction(id, md)` — alleen in `GRILLED|PLAN_READY`. Logt `IdeaLog{NOTE}`. -- `updatePlanMdAction(id, md)` — alleen in `PLAN_READY`. Eerst `parsePlanMd(md)`; bij parse-fail → 422 met line-info. -- `startGrillJobAction(id)` — vereist product met `repo_url`, `connectedWorkers > 0`. `ClaudeJob{kind:IDEA_GRILL, idea_id, product_id, QUEUED}`. Status → `GRILLING`. Demo: 403. -- `startMakePlanJobAction(id)` — vereist `GRILLED|PLAN_FAILED|PLAN_READY` voor re-plan, product met repo, worker. Status → `PLANNING`. Demo: 403. -- `cancelIdeaJobAction(id)` — actieve job CANCELLED, idea-status terug naar vorige. -- `materializeIdeaPlanAction(id)` — `PLAN_READY` → `parsePlanMd` → Prisma-`$transaction`: - 1. Counters incrementeren (PBI/Story/Task). - 2. INSERT PBI + N stories + M tasks (incl. `implementation_plan`). - 3. UPDATE idea: `pbi_id`, `status:PLANNED`. - 4. INSERT `IdeaLog{type:PLAN_RESULT, metadata}`. - Rollback bij ANY fail. Demo: 403. -- `relinkIdeaPlanAction(id)` — alleen als `status===PLANNED && pbi_id===null`. Status → `PLAN_READY`. -- `downloadIdeaMdAction(id, kind: 'grill'|'plan')` — server returnt md. - -### Promote van Todo → Idea -- `actions/todos.ts`: nieuwe `promoteTodoToIdeaAction(todoId)` — auth + demo + scope. Maakt Idea (DRAFT) met title/description; zet Todo `archived=true`. Demo: 403. - -### REST-routes -- `app/api/ideas/route.ts` (GET, POST) en `app/api/ideas/[id]/route.ts` (GET, PATCH). - -### proxy.ts (demo-laag) -- 403 op `POST/PATCH/DELETE /api/ideas*` voor demo-token. -- 403 op grill/make-plan/materialize-endpoints. - -## MCP-laag (`scrum4me-mcp`-repo) - -### Nieuwe tools -- `get_idea_context(idea_id)` — `{idea, product, repo_url, grill_md_so_far, open_questions, prompt_text}`. -- `update_idea_grill_md(idea_id, markdown)` — schrijft veld; status → `GRILLED`; logt `IdeaLog{GRILL_RESULT}`. -- `update_idea_plan_md(idea_id, markdown)` — schrijft veld; parser draait server-side; status → `PLAN_READY` of `PLAN_FAILED`. -- `log_idea_decision(idea_id, type, content, metadata?)` — types: `DECISION | NOTE`. - -### Uitbreiding bestaande tools -- `ask_user_question`: contract uitbreiden — exact één van `story_id` of `idea_id`. -- `wait_for_job`: response uitbreiden: - - `kind: 'TASK_IMPLEMENTATION' | 'IDEA_GRILL' | 'IDEA_MAKE_PLAN'` - - bij `IDEA_*`: `idea`, `product`, `repo_url`, `prompt_text`. -- `update_job_status`: bij `failed` voor IDEA_*-jobs zet idea-status `GRILL_FAILED` / `PLAN_FAILED`. - -### Schema-drift -- `docs/runbooks/mcp-integration.md:62`: schema-drift-watchdog moet groen zijn vóór merge. MCP-server-PR parallel. - -## Realtime-laag - -- `app/api/realtime/notifications/route.ts` — idea-questions alleen aan `idea.user_id === session.user_id`. -- `app/api/realtime/solo/route.ts` — `JobPayload` uitbreiden met `kind` en `idea_id`. Idea-jobs op `user_id`. -- `stores/idea-store.ts` (nieuw). `connectedWorkers` direct uit `useSoloStore`. - -## UI-laag - -### Routing -- `app/(app)/ideas/page.tsx` — top-level lijst. -- `app/(app)/ideas/[id]/page.tsx` — detailpagina met tabs **Idee** · **Grill** · **Plan** · **Timeline**. -- Sidebar-entry: `Lightbulb`, label "Ideeën", boven Todo's. - -### Componenten — `components/ideas/` -- `idea-list.tsx` — TanStack Table; kolommen code/title/product/status/archived. Bulk-archive. -- `idea-row-actions.tsx` — Grill Me / Make Plan / Materialiseer / Edit / Archive met disabled-rules. -- `idea-dialog.tsx` + `components/dialogs/idea-dialog.tsx` (wrapper) volgens dialog-pattern. -- `idea-md-editor.tsx` — markdown editor met yaml-validate voor plan_md. -- `idea-timeline.tsx` — UNION-view IdeaLog + claude_questions. -- `idea-pbi-link-card.tsx` — incl. "Re-link plan"-banner. -- `download-md-button.tsx`. - -### Promote-from-Todo UI -- `components/todos/todo-list.tsx`: extra menu-item "Promote naar Idee". - -### Profiel-doc -- `docs/specs/dialogs/idea.md` — verplicht volgens dialog-pattern. - -## Te raken / aan te maken bestanden - -| Laag | Bestand | -|---|---| -| Schema | `prisma/schema.prisma` | -| Migratie | `prisma/migrations/<ts>_add_ideas/migration.sql` | -| Schemas | `lib/schemas/idea.ts`, `lib/idea-status.ts`, `lib/idea-plan-parser.ts`, `lib/idea-code.ts` | -| Prompts | `lib/idea-prompts/grill.md`, `lib/idea-prompts/make-plan.md` | -| Actions | `actions/ideas.ts`, uitbreiding `actions/todos.ts` | -| API | `app/api/ideas/route.ts`, `app/api/ideas/[id]/route.ts`, `proxy.ts` | -| Realtime | `app/api/realtime/notifications/route.ts`, `app/api/realtime/solo/route.ts` | -| Pages | `app/(app)/ideas/page.tsx`, `app/(app)/ideas/[id]/page.tsx` | -| UI | `components/ideas/*.tsx`, `components/dialogs/idea-dialog.tsx`, sidebar-update | -| Store | `stores/idea-store.ts` | -| Docs | `docs/specs/dialogs/idea.md`, `docs/runbooks/mcp-integration.md`, `docs/backlog/index.md` | -| MCP-server | `madhura68/scrum4me-mcp` (parallel-PR) | - -## Implementatievolgorde - -1. **DB & migratie** -2. **Lib + schemas + prompts** -3. **Server actions + Todo-promote** -4. **REST + proxy demo-laag** -5. **Realtime SSE + idea-store** -6. **MCP-server tools (extern repo, parallel)** -7. **UI lijst + row-actions** -8. **UI detail + dialog + tabs** -9. **UI promote-from-Todo + sidebar-entry** -10. **End-to-end smoke + docs** - -## Verificatie - -```bash -npm run lint && npm test && npm run build -``` - -End-to-end: -1. `npm run dev` + lokale Claude-CLI met `wait_for_job`-loop. -2. Maak idee, koppel aan product. Status `DRAFT`. -3. Grill Me → vragen via answer-modal → `update_idea_grill_md` → `GRILLED`. -4. Edit grill_md handmatig → `IdeaLog{NOTE}`. -5. Make Plan → `update_idea_plan_md` → `PLAN_READY`. -6. Yaml-fout in plan_md → save geblokkeerd. -7. Materialiseer → PBI + stories + taken in transactie. `idea.pbi_id` gezet, `PLANNED`. -8. PBI verwijderen → "Re-link plan"-banner → `PLAN_READY`. -9. Demo-test: knoppen geblokkeerd via DemoTooltip + 403. -10. Failure-test: kill worker → `GRILL_FAILED`/`PLAN_FAILED`. -11. Promote-test: Todo → "Promote naar Idee" → DRAFT-Idea, Todo archived. - -## Open punten (niet-blokkerend) - -- Concrete copy-finetuning van prompt-md's tijdens implementatie. -- Lift `connectedWorkers` naar gedeelde `worker-presence-store` (opvolg-refactor). -- Optionele "Commit plan_md naar repo"-knop (buiten v1). diff --git a/docs/plans/M8-bootstrap-wizard-upload.md b/docs/plans/M8-bootstrap-wizard-upload.md deleted file mode 100644 index 4b0f736..0000000 --- a/docs/plans/M8-bootstrap-wizard-upload.md +++ /dev/null @@ -1,607 +0,0 @@ ---- -pbi: - title: "Bootstrap-wizard voor nieuwe Product-repo" - description: | - Bij het aanmaken van een nieuw Product wil de user direct een GitHub-repo - bootstrappen volgens canonical conventies (MD3-theme, ADR-systeem, - docs-structuur, tooling). De catalogus van aanvinkbare opties + uitvoer- - recepten leeft in de database (configureerbaar, audit-bar). Uitvoering - gebeurt server-side via een aparte `bootstrap-service` — een deterministic - runtime onder ClaudeJobKind `BOOTSTRAP_REPO`. UX: twee-staps (Product - eerst, wizard later) met Configure → Preview → Run. - Volledig technisch plan: docs/plans/M8-bootstrap-wizard.md (v3.5). - priority: 2 - -stories: - - title: "Sprint 1a — Deterministic-job contracten + drift-CI" - description: | - Leg de fundamentele contracten vast voordat schema/UI/service worden - gebouwd: discriminated-union JobConfig, docker-runner skip-filter, - transactionele status-sync helper, shared bootstrap-actions package - scaffold, en vendor-copy drift-detectie via CI hash-check. - acceptance_criteria: | - - ADR-0009 in docs/adr/ met status accepted - - JobConfig is een discriminated union; BOOTSTRAP_REPO → runtime:'deterministic' - - scrum4me-docker claimt geen BOOTSTRAP_REPO-jobs (skip-filter actief) - - packages/bootstrap-actions/ scaffold bestaat in Scrum4Me-repo - - notify-helper doet post-commit pg_notify (NOTIFY niet in transaction) - - check-bootstrap-actions-hash.sh faalt CI bij drift - priority: 1 - tasks: - - title: "Schrijf ADR-0009 voor bootstrap-wizard architectuur" - description: | - Nygard-template ADR die de architectuur-keuze vastlegt: aparte - bootstrap-service als sibling-directory, deterministic runtime, - PAT-secret-boundary, declarative recipes in DB. - implementation_plan: | - Bestanden: - - `docs/adr/0009-bootstrap-wizard.md` (nieuw) - - `docs/adr/README.md` (update) - - `docs/INDEX.md` (regenereer) - - Stappen: - 1. Maak `docs/adr/0009-bootstrap-wizard.md` op basis van `docs/adr/templates/nygard.md` - 2. Sectie Context: waarom deze feature; verwijs naar `docs/plans/M8-bootstrap-wizard.md` - 3. Sectie Decision: bootstrap-service als sibling; ClaudeJob queue hergebruikt; declarative actions - 4. Sectie Consequences: positive (consistent product-onboarding), negative (extra service om te beheren) - 5. Status: accepted - 6. Update `docs/adr/README.md` met nieuwe ADR-link - 7. Regenereer `docs/INDEX.md` via `npm run docs` - priority: 1 - verify_required: ALIGNED_OR_PARTIAL - - - title: "Implementeer JobConfig discriminated union" - description: | - Vervang het bestaande JobConfig-type door een discriminated union - met `runtime: 'claude' | 'deterministic'`. BOOTSTRAP_REPO returnt - `{ runtime: 'deterministic', executor: 'bootstrap-repo' }` zonder - model/thinking_budget/permission_mode. - implementation_plan: | - Bestanden: - - `lib/job-config.ts` - - `scrum4me-mcp/src/lib/job-config.ts` (gespiegeld) - - `__tests__/lib/job-config.test.ts` (nieuw) - - Stappen: - 1. Refactor `lib/job-config.ts` naar discriminated union (runtime-discriminator) - 2. KIND_DEFAULTS toevoegen: BOOTSTRAP_REPO → deterministic - 3. resolveJobConfig() returnt union; consumers krijgen exhaustive switch - 4. getJobConfigSnapshot() schrijft requested_* als null voor deterministic kinds - 5. Spiegel `scrum4me-mcp/src/lib/job-config.ts` identiek (geen drift) - 6. Tests: BOOTSTRAP_REPO → runtime='deterministic'; alle bestaande kinds → runtime='claude' - priority: 1 - verify_required: ALIGNED_OR_PARTIAL - - - title: "scrum4me-docker skip-filter voor BOOTSTRAP_REPO" - description: | - De docker-runner mag geen BOOTSTRAP_REPO-jobs claimen — die zijn - voor de aparte bootstrap-service. Voeg kind-filter toe aan - tryClaimJob. - implementation_plan: | - Bestanden: - - `scrum4me-docker/bin/run-one-job.ts` - - `scrum4me-docker/README.md` (note over filter) - - Stappen: - 1. Open `scrum4me-docker/bin/run-one-job.ts` - 2. In tryClaimJob SQL: voeg `AND kind <> 'BOOTSTRAP_REPO'` toe aan WHERE - 3. Test: enqueue BOOTSTRAP_REPO-job; verifieer dat docker-runner het overslaat - 4. Update `scrum4me-docker/README.md` met note over kind-filter - priority: 1 - verify_required: ALIGNED_OR_PARTIAL - - - title: "Scaffold packages/bootstrap-actions/ shared package" - description: | - Nieuw package binnen Scrum4Me-repo dat schema + handler-interfaces - bevat. Geen secrets; gedeeld tussen app (dry-run) en service (echte - run via vendor-copy). - implementation_plan: | - Bestanden: - - `packages/bootstrap-actions/package.json` (nieuw) - - `packages/bootstrap-actions/tsconfig.json` (nieuw) - - `packages/bootstrap-actions/src/types.ts` (nieuw) - - `packages/bootstrap-actions/src/schema.ts` (nieuw) - - `packages/bootstrap-actions/src/index.ts` (nieuw) - - `tsconfig.json` (path-alias toevoegen) - - Stappen: - 1. Maak directory `packages/bootstrap-actions/src/` - 2. `packages/bootstrap-actions/package.json` met name "@scrum4me/bootstrap-actions" version 0.1.0 - 3. `packages/bootstrap-actions/tsconfig.json` extending root config - 4. `packages/bootstrap-actions/src/types.ts`: ActionContext, DryRunReport, CatalogSnapshot, RecipeSnapshot interfaces - 5. `packages/bootstrap-actions/src/schema.ts`: skelet ActionSchema (lege discriminated union; uitgewerkt in story 2) - 6. `packages/bootstrap-actions/src/index.ts`: re-exports - 7. `tsconfig.json` path-alias `@scrum4me/bootstrap-actions` → `./packages/bootstrap-actions/src` - 8. Verifieer build: `npm run typecheck` slaagt - priority: 2 - verify_required: ALIGNED_OR_PARTIAL - - - title: "lib/bootstrap/notify.ts post-commit pg_notify helper" - description: | - Helper voor transactionele status-updates met NOTIFY ná commit - (niet IN transaction). Payload-contract: type='claude_job_status', - user_id verplicht, kind, status (lowercase via jobStatusToApi), - bootstrap_run_id. - implementation_plan: | - Bestanden: - - `lib/bootstrap/notify.ts` (nieuw) - - `__tests__/lib/bootstrap/notify.test.ts` (nieuw) - - Stappen: - 1. Maak `lib/bootstrap/notify.ts` - 2. Functie notifyClaudeJobStatus(jobId, userId, status, extra?) die pg_notify('scrum4me_changes', payload) - 3. status wordt door jobStatusToApi() naar lowercase - 4. Wrapper-functie withPostCommitNotify(tx, payload) die NOTIFY ná tx commit doet - 5. Unit-tests in `__tests__/lib/bootstrap/notify.test.ts`: NOTIFY niet aangeroepen bij rollback; wel bij commit; payload-shape klopt - priority: 1 - verify_required: ALIGNED_OR_PARTIAL - - - title: "Schema-hash drift CI-check script" - description: | - Voorkomt drift tussen Scrum4Me/packages/bootstrap-actions en de - vendor-copy in bootstrap-service. Hash-vergelijking faalt CI. - implementation_plan: | - Bestanden: - - `scripts/check-bootstrap-actions-hash.sh` (nieuw) - - `.github/workflows/ci.yml` (CI-job toevoegen) - - `docs/runbooks/bootstrap-wizard.md` (placeholder, uitgewerkt sprint 1d) - - Stappen: - 1. Maak `scripts/check-bootstrap-actions-hash.sh` - 2. Script berekent sha256 over `packages/bootstrap-actions/src/**` - 3. Schrijf hash naar `packages/bootstrap-actions/.schema-hash` bij build - 4. CI-job in `.github/workflows/ci.yml`: vergelijk geschreven hash met bron-hash; faal bij mismatch - 5. Documenteer in `docs/runbooks/bootstrap-wizard.md` - priority: 2 - verify_required: ALIGNED_OR_PARTIAL - - - title: "Sprint 1b — Schema + seed + path-safety" - description: | - Volledige Prisma-modellen voor catalog (Category/Option/Action), - BootstrapRun met side-effect checkpoints, Product/User uitbreidingen, - partial unique index voor "1 active run per product". Plus seed met - alle 6 core categorieën en Zod-validatie per action-kind. - acceptance_criteria: | - - npx prisma migrate dev slaagt - - npm run seed produceert 7 categorieën (6 core SINGLE + 1 addons MULTI) - - Partial unique index "bootstrap_runs_one_active_per_product" bestaat - - Action-Zod schema rejected path-traversal en absolute paden - - Jobs-board (job-card/jobs-column) toont BOOTSTRAP_REPO label - - npm run typecheck groen na enum-uitbreiding - priority: 1 - tasks: - - title: "Prisma-modellen + migration" - description: | - BootstrapCategory/Option/Action/Run + enums (BootstrapSelectionType, - BootstrapActionKind, BootstrapRunStatus, RiskLevel, RoleRequired, - SideEffect) + Product/User uitbreidingen. Snake-case via @@map. - implementation_plan: | - Bestanden: - - `prisma/schema.prisma` - - `prisma/migrations/<ts>_bootstrap_wizard/migration.sql` (Prisma genereert + manual append) - - `scrum4me-mcp/prisma/schema.prisma` (gesynced via `sync-schema.sh`) - - Stappen: - 1. Open `prisma/schema.prisma` - 2. Voeg modellen toe: BootstrapCategory, BootstrapOption, BootstrapAction, BootstrapRun met @@map(snake_case) - 3. Enums: BootstrapSelectionType (SINGLE|MULTI), BootstrapActionKind, BootstrapRunStatus (incl. FAILED_NEEDS_CLEANUP), RiskLevel, RoleRequired, SideEffect - 4. Product: voeg repo_owner, repo_slug, template_version, last_bootstrap_run_id velden + @@unique([repo_owner, repo_slug]) + relaties met disjoint names (ProductBootstrapRuns history + ProductLastBootstrapRun pointer) - 5. User: voeg github_pat_encrypted, github_username, github_pat_verified_at, github_pat_scopes (@default([])), github_pat_expires_at, github_orgs velden - 6. ClaudeJob: voeg claimed_by_worker_id en bootstrap_run relation. ClaudeJobKind enum: BOOTSTRAP_REPO erbij - 7. BootstrapRun met @unique claude_job_id, github_repo_created_at/id/full_name, push_completed_at, recipe_hash, catalog_version, action_schema_version, dry_run_report - 8. Indexes: bootstrap_runs (product_id, status), (user_id, created_at), (status, finished_at) - 9. `npx prisma migrate dev --name bootstrap_wizard` - 10. Append raw SQL aan migration: `CREATE UNIQUE INDEX bootstrap_runs_one_active_per_product ON bootstrap_runs (product_id) WHERE status IN ('PENDING','RUNNING')` - 11. Sync schema naar `scrum4me-mcp/prisma/schema.prisma` via `sync-schema.sh` - priority: 1 - verify_required: ALIGNED_OR_PARTIAL - - - title: "Action-handlers + Zod-schema in shared package" - description: | - Per BootstrapActionKind een handler-functie + Zod-validatie. - Path-safety regels (deny .git, absolute paths, traversal); run-level - caps (200 acties, 256KiB log). - implementation_plan: | - Bestanden: - - `packages/bootstrap-actions/src/schema.ts` (uitbreiden) - - `packages/bootstrap-actions/src/handlers/copy-file.ts` - - `packages/bootstrap-actions/src/handlers/write-file.ts` - - `packages/bootstrap-actions/src/handlers/append-to-file.ts` - - `packages/bootstrap-actions/src/handlers/replace-string.ts` - - `packages/bootstrap-actions/src/handlers/create-adr-stub.ts` - - `packages/bootstrap-actions/src/handlers/add-dependency.ts` - - `packages/bootstrap-actions/src/recipe-hash.ts` - - `packages/bootstrap-actions/src/catalog-hash.ts` - - `packages/bootstrap-actions/src/__tests__/*.test.ts` - - Stappen: - 1. `packages/bootstrap-actions/src/schema.ts`: discriminated union met SafeRelPath validator - 2. SafeRelPath: max 256, regex [A-Za-z0-9_./-], deny absolute/'..'/'.git' - 3. Handlers: COPY_FILE, WRITE_FILE, APPEND_TO_FILE, REPLACE_STRING, CREATE_ADR_STUB, ADD_DEPENDENCY (regex docs MVP-beperking: alleen exact/range semver) - 4. RUN_BASH_TEMPLATE met allowlist (commented out in MVP — opt-in via fase-2) - 5. `packages/bootstrap-actions/src/recipe-hash.ts`: canonicalize() + sha256 - 6. `packages/bootstrap-actions/src/catalog-hash.ts`: canonical JSON over categories+options+actions, sha256 - 7. Run-level caps in runner-helper: maxActions=200, maxOutputLog=256KiB - 8. Tests per handler: idempotent, path-safety negative cases, hash determinisme - priority: 1 - verify_required: ALIGNED_OR_PARTIAL - - - title: "Seed bootstrap catalog (6 core + addons)" - description: | - prisma/seed.ts uitbreiden met seedBootstrapCatalog() die alle - categorieën + opties + acties insert. Idempotent (upsert op slug). - implementation_plan: | - Bestanden: - - `prisma/seed.ts` - - Stappen: - 1. Open `prisma/seed.ts`; voeg seedBootstrapCatalog() toe - 2. Categorieën (SINGLE/required): deploy, auth, database, ui-components, state-management, testing - 3. Categorie (MULTI/optional): addons - 4. Per categorie 2-4 opties met is_default-flag - 5. Per optie de bijbehorende acties (COPY_FILE/CREATE_ADR_STUB/ADD_DEPENDENCY/WRITE_FILE) - 6. Elke verplichte categorie genereert 1 CREATE_ADR_STUB action met number 1-6 - 7. Run `npm run seed`; verifieer 7 categorieën via psql - priority: 1 - verify_required: ALIGNED_OR_PARTIAL - - - title: "Jobs-board BOOTSTRAP_REPO kind-uitbreidingen" - description: | - Alle Record<ClaudeJobKind, ...> en exhaustive switches updaten; - BOOTSTRAP_REPO krijgt label/kleur/SSE-filter-set. - implementation_plan: | - Bestanden: - - `components/jobs/job-card.tsx` - - `components/jobs/jobs-column.tsx` - - `lib/insights/agent-throughput.ts` - - `app/api/realtime/jobs/route.ts` - - Stappen: - 1. `components/jobs/job-card.tsx`: voeg label-mapping BOOTSTRAP_REPO → 'Bootstrap repo' - 2. `components/jobs/jobs-column.tsx`: voeg kolom-titel + filter - 3. `lib/insights/agent-throughput.ts`: BOOTSTRAP_REPO opnemen in kind-aggregatie (nullable cost ok) - 4. `app/api/realtime/jobs/route.ts`: voeg kind toe aan initial-payload + filter - 5. JobPayload-type uitbreiding: bootstrap_run_id?: string (additive extension) - 6. `npm run typecheck` — alle exhaustive switches groen - priority: 2 - verify_required: ALIGNED_OR_PARTIAL - - - title: "Sprint 1c — PAT-settings + Dry-run + Wizard config/preview" - description: | - User kan classic PAT plakken in settings; preview-action draait - non-mutating handlers in tmpdir + Octokit-preflight; wizard heeft - Configure-stap (radio/checkbox) en Preview-stap (DryRunReport). - acceptance_criteria: | - - GitHub PAT plakken in settings → "Test" toont username + scopes - - PAT staat encrypted in DB (niet in plaintext) - - Preview-stap toont gefilterde file-tree (cap 500), action-log, warnings - - Geen DB-row in bootstrap_runs tijdens preview - - Wizard accepteert geen submit zonder geslaagde preview - priority: 1 - tasks: - - title: "lib/crypto/pat.ts AES-256-GCM encryption" - description: | - Encrypt-only in app-laag (decrypt leeft in bootstrap-service). - Prefix 'v1:' voor toekomstige key-rotation. - implementation_plan: | - Bestanden: - - `lib/crypto/pat.ts` (nieuw) - - `lib/env.ts` (uitbreiden) - - `.env.example` (instructie toevoegen) - - `__tests__/lib/crypto/pat.test.ts` (nieuw) - - Stappen: - 1. Maak `lib/crypto/pat.ts` - 2. Functie encryptPat(plaintext, key) returnt 'v1:<base64-ciphertext>' - 3. AES-256-GCM via Node's crypto module; random IV per call - 4. Voeg BOOTSTRAP_ENCRYPTION_KEY (required, min 32) toe aan `lib/env.ts` Zod-schema - 5. Voeg BOOTSTRAP_TEMPLATE_REPO (default 'madhura68/nextjs-baseline') toe - 6. Tests in `__tests__/lib/crypto/pat.test.ts`: encrypt → decrypt round-trip; verschillende ciphertexts bij zelfde plaintext (IV); rejectie bij key < 32 - 7. Update `.env.example` met genereer-instructie (`openssl rand -base64 32`) - priority: 1 - verify_required: ALIGNED_OR_PARTIAL - - - title: "GitHubPatSettings UI + saveGitHubPatAction" - description: | - Settings-page sectie waar user PAT plakt. Test-knop doet Octokit-call - en valideert classic-PAT scope=repo. Toon scopes + verified_at. - implementation_plan: | - Bestanden: - - `app/(app)/settings/_components/github-pat-settings.tsx` (nieuw) - - `actions/bootstrap.ts` (nieuw) - - `__tests__/actions/bootstrap.test.ts` (nieuw) - - Stappen: - 1. Maak `app/(app)/settings/_components/github-pat-settings.tsx` - 2. Form met password-input (gemaskeerd) + Test-knop + Save-knop - 3. UI-copy: "Vereist een classic PAT met 'repo' scope — fine-grained tokens nog niet ondersteund" - 4. Server-action in `actions/bootstrap.ts` → saveGitHubPatAction(token): - - Demo-check (403) - - Octokit.users.getAuthenticated() → username - - Parse x-oauth-scopes header → array - - Reject als scope 'repo' ontbreekt - - encryptPat() → store github_pat_encrypted/username/verified_at/scopes - 5. UI toont na save: "✓ <username> · scopes: repo" - 6. Tests in `__tests__/actions/bootstrap.test.ts`: scope-rejection; encryption-roundtrip; demo-block - priority: 1 - verify_required: ALIGNED_OR_PARTIAL - - - title: "previewBootstrapAction + dry-run executor" - description: | - Server-action die recipe resolved, alle non-mutating handlers in - tmpdir draait, Octokit preflight doet (collision + best-effort - owner-discovery), DryRunReport retourneert. Geen DB-writes. - implementation_plan: | - Bestanden: - - `actions/bootstrap.ts` (previewBootstrapAction toevoegen) - - `lib/bootstrap/recipe.ts` (nieuw) - - `lib/bootstrap/dry-run.ts` (nieuw) - - `__tests__/lib/bootstrap/dry-run.test.ts` (nieuw) - - Stappen: - 1. Voeg previewBootstrapAction(productId, selections, repoOwner, repoSlug) toe aan `actions/bootstrap.ts` - 2. Auth + demo-check + Zod-validate selections + GitHub-name regex - 3. Resolve recipe via `lib/bootstrap/recipe.ts`: selections → BootstrapAction[] (geordend op execution_order) - 4. Compute recipe_hash + catalog_version - 5. Maak `lib/bootstrap/dry-run.ts`: clone template (geen cache MVP), iterate handlers met supports_dry_run=true - 6. Filter file-tree: deny .git/node_modules/.next/dist/build/out/coverage/*.log/.env*/.DS_Store; cap 500 entries met truncated-flag - 7. Octokit preflight: `octokit.repos.get({ owner, repo })` voor collision; `octokit.orgs.get()` voor best-effort owner-status - 8. Return DryRunReport { fileTree, truncated, actionLog, warnings, canProceed, collisions } - 9. Tests in `__tests__/lib/bootstrap/dry-run.test.ts`: collision-detect; path-safety enforcement; report-shape - priority: 1 - verify_required: ALIGNED_OR_PARTIAL - - - title: "BootstrapWizardDialog: Configure + Preview steps" - description: | - Multi-step wizard dialog vanuit product-detail-pagina. Step 1 - radios/checkboxes; Step 2 toont DryRunReport; Step 3 placeholder - voor status (sprint 1d). - implementation_plan: | - Bestanden: - - `app/(app)/products/[id]/_components/bootstrap-wizard-dialog.tsx` (nieuw) - - `app/(app)/products/[id]/_components/repo-owner-picker.tsx` (nieuw) - - `app/(app)/products/[id]/_components/bootstrap-preview-panel.tsx` (nieuw) - - `app/(app)/products/[id]/page.tsx` (Bootstrap-knop toevoegen) - - Stappen: - 1. Maak `app/(app)/products/[id]/_components/bootstrap-wizard-dialog.tsx` - 2. Volg `docs/patterns/dialog.md` Entity Dialog conventie (base-ui render-prop) - 3. Step Configure: render 6 radio-groups + 1 checkbox-array (Add-ons) op basis van catalog-query - 4. `repo-owner-picker.tsx`: user + orgs als opties met hint-badges (zichtbaar/onbekend/policy-blokkeert); GEEN automatisch verbergen - 5. repo_slug input met GitHub-naam-regex - 6. Step Preview: call previewBootstrapAction; toon `bootstrap-preview-panel.tsx` met file-tree, action-log, warnings, collision-banner - 7. Voorkom Run-knop tot canProceed=true - 8. `app/(app)/products/[id]/page.tsx`: Bootstrap-knop (verborgen voor demo-users) - 9. Tests: wizard-state-machine; preview-roundtrip - priority: 2 - verify_required: ALIGNED_OR_PARTIAL - - - title: "Sprint 1d — bootstrap-service + transactionele sync + E2E" - description: | - Sibling-repo bootstrap-service/ als nieuw Node-proces dat - BOOTSTRAP_REPO-jobs claimt, recipe uitvoert via isomorphic-git - (template-clone + commit + push), Octokit createRepo, met side-effect - checkpoints en transactionele status-sync. Plus stale-recovery cron - en realtime SSE-status panel. - acceptance_criteria: | - - bootstrap-service claimt BOOTSTRAP_REPO-jobs binnen 2s na NOTIFY - - E2E: nieuw product → wizard → preview → Run → SUCCEEDED in <60s - - GitHub repo bestaat met .scrum4me/bootstrap.json metadata en 6 ADR-stubs - - claude_jobs.status=DONE, bootstrap_runs.status=SUCCEEDED, product.repo_url gevuld - - Invalid PAT → FAILED zonder orphan repo - - Twee gelijktijdige submits: één gaat door, ander krijgt unique violation - - Stale-recovery cron markeert verlopen leases correct (FAILED vs FAILED_NEEDS_CLEANUP) - - CI-job faalt bij hash-drift van vendor-copy - priority: 1 - tasks: - - title: "Setup bootstrap-service sibling-repo skeleton" - description: | - Nieuwe directory ~/Development/bootstrap-service/ met package.json, - tsconfig, Dockerfile (multi-arch arm64-primary), sync-schema.sh, - sync-bootstrap-actions.sh. - implementation_plan: | - Bestanden (in sibling-repo `~/Development/bootstrap-service/`): - - `package.json` - - `tsconfig.json` - - `env.ts` - - `prisma/schema.prisma` (gesynced) - - `sync-schema.sh` - - `sync-bootstrap-actions.sh` - - `Dockerfile` - - `docker-compose.yml` - - `README.md` - - Plus in Scrum4Me-repo: - - `docs/manual/06-bootstrap-service.md` (nieuw) - - Stappen: - 1. `mkdir ~/Development/bootstrap-service` - 2. `package.json`: deps prisma, @prisma/client, isomorphic-git, @octokit/rest, zod - 3. `tsconfig.json` met strict mode - 4. `env.ts`: Zod-schema voor DATABASE_URL, DIRECT_URL, BOOTSTRAP_ENCRYPTION_KEY, BOOTSTRAP_TEMPLATE_REPO - 5. `prisma/schema.prisma` symlinked of synced via `sync-schema.sh` - 6. `sync-bootstrap-actions.sh` kopieert `packages/bootstrap-actions/` vanuit Scrum4Me met hash-write - 7. `Dockerfile`: FROM --platform=$BUILDPLATFORM node:24-alpine, multi-arch (arm64 + amd64) - 8. `docker-compose.yml`: arm64 default voor Mac dev - 9. `README.md`: setup-instructies + env-template - 10. Voeg `docs/manual/06-bootstrap-service.md` toe in Scrum4Me - priority: 1 - verify_required: ALIGNED_OR_PARTIAL - - - title: "Claim-loop + LISTEN + lease-renewal" - description: | - Daemon-loop in bin/run.ts: LISTEN op scrum4me_changes filter - claude_job_enqueued/BOOTSTRAP_REPO; SKIP-LOCKED claim; - claimed_by_worker_id (hostname-pid-startTs); lease-renewal elke 30s. - implementation_plan: | - Bestanden (sibling-repo `~/Development/bootstrap-service/`): - - `bin/run.ts` (nieuw) - - `src/claim.ts` (nieuw) - - `src/__tests__/claim.test.ts` (nieuw) - - Stappen: - 1. `bin/run.ts`: daemon-loop - 2. WORKER_ID = `${hostname}-${pid}-${startTs}` als string - 3. `src/claim.ts` tryClaimBootstrapJob: UPDATE claude_jobs SET status='CLAIMED', lease_until=NOW()+60s, claimed_at, claimed_by_worker_id WHERE id=(SELECT id FROM claude_jobs WHERE status='QUEUED' AND kind='BOOTSTRAP_REPO' ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING id - 4. Lease-renewal setInterval(30s) UPDATE lease_until=NOW()+60s WHERE id=? AND claimed_by_worker_id=? (only-mine guard) - 5. LISTEN scrum4me_changes; bij claude_job_enqueued met kind=BOOTSTRAP_REPO → trigger claim-poll - 6. Fallback poll-interval 30s - 7. Tests in `src/__tests__/claim.test.ts`: SKIP-LOCKED safety bij parallel claim; lease-renewal-guard - priority: 1 - verify_required: ALIGNED_OR_PARTIAL - - - title: "Execute-flow: clone + recipe + Octokit + push + checkpoints" - description: | - Volledige bootstrap-uitvoer: isomorphic-git clone (tag-pinned), - recipe-iteratie via shared handlers, placeholder-replacement, - Octokit repo-create, isomorphic-git push (PAT via onAuth-callback), - .scrum4me/bootstrap.json metadata, side-effect checkpoints op DB. - implementation_plan: | - Bestanden (sibling-repo `~/Development/bootstrap-service/`): - - `src/runner.ts` (nieuw) - - `src/github.ts` (nieuw — Octokit wrapper) - - `src/template-clone.ts` (nieuw — isomorphic-git) - - `src/__tests__/runner.test.ts` (nieuw) - - Stappen: - 1. `src/runner.ts`: executeRecipe(run, pat) - 2. mkdtemp(); `src/template-clone.ts` isomorphic-git clone met depth=1 en ref=template_version - 3. Capture template_source_sha via resolveRef HEAD - 4. fs.rm tmpdir/.git; git.init met defaultBranch='main' - 5. Iterate run.recipe_snapshot.actions (sorted by execution_order); ActionSchema.parse runtime - 6. Dispatch per kind → handler uit `@scrum4me/bootstrap-actions` (vendor-copy) - 7. replacePlaceholders(tmpdir) voor __PRODUCT_NAME__/__PRODUCT_SLUG__/__GITHUB_OWNER__ - 8. writeFile `.scrum4me/bootstrap.json` met metadata (template/recipe_hash/catalog_version/etc.) - 9. git.add + git.commit - 10. `src/github.ts` Octokit createForAuthenticatedUser/createInOrg → checkpoint write github_repo_created_at/id/full_name - 11. git.addRemote + git.push met onAuth-callback { username: 'x-access-token', password: pat } → checkpoint write push_completed_at - 12. Cleanup tmpdir in finally; zeroize pat - 13. Tests in `src/__tests__/runner.test.ts`: dry-run-handlers identiek aan service-handlers (geen drift); push-via-onAuth zonder URL-leak - priority: 1 - verify_required: ALIGNED_OR_PARTIAL - - - title: "Transactionele status-sync (running/success/failed)" - description: | - syncRunning/syncSuccess/syncFailed in één prisma.$transaction met - count-checks. Lease_until + claimed_by_worker_id terminal op null. - NOTIFY na commit. FAILED vs FAILED_NEEDS_CLEANUP afhankelijk van - github_repo_full_name. - implementation_plan: | - Bestanden (sibling-repo `~/Development/bootstrap-service/`): - - `src/status-sync.ts` (nieuw) - - `src/__tests__/status-sync.test.ts` (nieuw) - - Stappen: - 1. `src/status-sync.ts` - 2. syncRunning(runId, jobId, userId): één now=new Date(); transaction: bootstrap_runs.started_at = now WHERE status='PENDING'; claude_jobs.started_at = now WHERE status='CLAIMED'; count-check beide; rollback bij mismatch - 3. syncSuccess: transaction met updateMany WHERE status='RUNNING' op zowel run als job; lease_until=null, claimed_by_worker_id=null; product.repo_url + template_version + last_bootstrap_run_id - 4. syncFailed: zelfde pattern; terminal-status = run.github_repo_full_name ? 'FAILED_NEEDS_CLEANUP' : 'FAILED'; bij created repo zonder push: compensating octokit.repos.delete in catch-pad - 5. NOTIFY pas na commit; status via jobStatusToApi(...) lowercase - 6. Tests in `src/__tests__/status-sync.test.ts`: cancel-tijdens-success blijft CANCELLED; lease-cleanup; status-mapping - priority: 1 - verify_required: ALIGNED_OR_PARTIAL - - - title: "Stale-recovery cron + service-startup recovery" - description: | - Verlopen BOOTSTRAP_REPO-leases (lease_until < NOW) splitsen tussen - FAILED en FAILED_NEEDS_CLEANUP op basis van github_repo presence. - Cron-route in app + globale startup-sweep in service. - implementation_plan: | - Bestanden: - - `app/api/cron/bootstrap-stale-recovery/route.ts` (nieuw, in Scrum4Me) - - `vercel.json` (cron-schedule toevoegen) - - `~/Development/bootstrap-service/src/stale-recovery.ts` (nieuw) - - `__tests__/api/cron/bootstrap-stale-recovery.test.ts` (nieuw) - - Stappen: - 1. Maak `app/api/cron/bootstrap-stale-recovery/route.ts` met Bearer-CRON_SECRET guard - 2. SQL stap 1: `UPDATE claude_jobs SET status='FAILED', error='lease expired', lease_until=null, claimed_by_worker_id=null WHERE status IN ('CLAIMED','RUNNING') AND kind='BOOTSTRAP_REPO' AND lease_until < NOW()` - 3. SQL stap 2a: `UPDATE bootstrap_runs → FAILED_NEEDS_CLEANUP WHERE github_repo_full_name IS NOT NULL OR github_repo_created_at IS NOT NULL` - 4. SQL stap 2b: `UPDATE bootstrap_runs → FAILED WHERE github_repo_full_name IS NULL AND github_repo_created_at IS NULL` - 5. Voeg cron-schedule toe in `vercel.json` (elke 5 min) - 6. `~/Development/bootstrap-service/src/stale-recovery.ts`: zelfde SQL bij service-startup (globale recovery — NIET filteren op claimed_by_worker_id) - 7. Tests in `__tests__/api/cron/bootstrap-stale-recovery.test.ts`: split-strategie; kind-filter respecteert bestaande Claude-jobs ongemoeid - priority: 1 - verify_required: ALIGNED_OR_PARTIAL - - - title: "Service-startup logging + drift CI-verificatie" - description: | - Bij service-startup: log action_schema_version, schema-hash van - geladen bootstrap-actions package, en catalog-version. CI faalt - release bij hash-mismatch met Scrum4Me-bron. - implementation_plan: | - Bestanden (sibling-repo `~/Development/bootstrap-service/`): - - `src/telemetry.ts` (nieuw) - - `.github/workflows/release.yml` (nieuw — drift-check) - - Stappen: - 1. `src/telemetry.ts`: bootSummary() print actionSchemaVersion, schemaHash (sha256 over geladen package src), catalogVersion (huidige DB) - 2. Print bij service-startup vóór claim-loop - 3. Telemetry-log gebruikt token-scrubbing helper (geen PAT/secrets in logs) - 4. CI `.github/workflows/release.yml`: run `scripts/check-bootstrap-actions-hash.sh` tegen Scrum4Me-bron-hash (vergelijk via env var of release-tag) - 5. Release-pipeline faalt bij drift - priority: 2 - verify_required: ALIGNED_OR_PARTIAL - - - title: "BootstrapStatusPanel realtime SSE" - description: | - Tijdens RUNNING-fase toont wizard-step 3 realtime status via SSE - op /api/realtime/jobs filtered op bootstrap_run_id. - implementation_plan: | - Bestanden: - - `app/(app)/products/[id]/_components/bootstrap-status-panel.tsx` (nieuw) - - Stappen: - 1. Component `app/(app)/products/[id]/_components/bootstrap-status-panel.tsx` - 2. Subscribe op SSE-stream `/api/realtime/jobs` (bestaand) - 3. Filter payloads op type='claude_job_status' + bootstrap_run_id=runId - 4. Render status-badge (queued/running/done/failed) + progress-hints - 5. Bij DONE: toon repo_url met "Open op GitHub"-link - 6. Bij FAILED/FAILED_NEEDS_CLEANUP: toon error + retry-knop placeholder (fase-2) - 7. Tests: SSE-event-mapping; UI-state-machine - priority: 2 - verify_required: ALIGNED_OR_PARTIAL - - - title: "E2E happy-path verificatie" - description: | - End-to-end test: maak product, run wizard (alle 6 core), preview, - submit, wacht op SUCCEEDED, verifieer GitHub-repo en DB-state. - implementation_plan: | - Bestanden: - - `__tests__/e2e/bootstrap-happy-path.test.ts` (nieuw) - - Stappen: - 1. Maak product 'e2e-bootstrap-test' via UI of seed - 2. Settings: PAT met repo-scope geconfigureerd voor test-user - 3. Wizard: deploy=self-hosted, auth=iron-session, db=postgres-prisma, ui=shadcn-baseui, state=zustand, testing=vitest-jsdom; repo_owner=test-org - 4. Preview-step → groen → Run - 5. Verifieer binnen 60s: bootstrap_runs.status=SUCCEEDED; claude_jobs.status=DONE; product.repo_url niet null; product.template_version='v1.0.0' - 6. Verifieer GitHub: repo bestaat private; `docs/adr/` bevat 0000+0001..0006; `.scrum4me/bootstrap.json` bevat recipe_hash/catalog_version/selected_options - 7. SQL-query met JOIN: `SELECT br.status, br.repo_url, cj.lease_until > NOW() AS lease_active FROM bootstrap_runs br JOIN claude_jobs cj ON cj.id=br.claude_job_id ORDER BY br.started_at DESC NULLS LAST LIMIT 1` - 8. Failure-pad: invalid PAT → FAILED + geen orphan repo - 9. Demo-pad: login als demo → Bootstrap-knop verborgen; direct API → 403 - priority: 1 - verify_required: ALIGNED_OR_PARTIAL - verify_only: false ---- - -# M8 Bootstrap-wizard — Upload variant - -Dit is de upload-variant van het volledige technische plan -`docs/plans/M8-bootstrap-wizard.md` (v3.5). De YAML-frontmatter hierboven -is bedoeld voor de "Upload plan"-functie in Scrum4Me die idea-status naar -`PLAN_READY` brengt en daarna via `materializeIdeaPlanAction` een PBI met -4 Stories en bijbehorende Tasks aanmaakt. - -## Mapping naar het volledige plan - -| Story | Sprint | Volledige plan-sectie | -|---|---|---| -| Story 1 | Sprint 1a — Contracten | "Fasering" Sprint 1a + "Deterministic-job contract" + "Vendor-copy CI-check" | -| Story 2 | Sprint 1b — Schema + seed + safety | "Domein-model (Prisma)" + "Action-schema + path-safety" + "Seed catalog" | -| Story 3 | Sprint 1c — PAT + Dry-run + Wizard | "PAT-secret-boundary" + "Dry-run als feature" + "Wizard-componenten" | -| Story 4 | Sprint 1d — bootstrap-service + E2E | "Executor: bootstrap-service" + "Status-sync" + "Stale-recovery" + "Verificatie" | - -Voor uitgebreide review-historie (5 reviews), architectuur-besluiten, -overwogen alternatieven, secret-boundary-onderbouwing, en open punten: -zie het volledige plan-document. diff --git a/docs/plans/M8-bootstrap-wizard.md b/docs/plans/M8-bootstrap-wizard.md deleted file mode 100644 index 25a5c54..0000000 --- a/docs/plans/M8-bootstrap-wizard.md +++ /dev/null @@ -1,1170 +0,0 @@ ---- -status: reviewed -author: Claude -version: 6 -created_at: 2026-05-14 -reviewed_by: [haiku, sonnet, opus] ---- - -# Plan v3.5 — Bootstrap-wizard voor nieuwe Product-repo (Scrum4Me feature) - -## Context - -Bij het aanmaken van een nieuw Product in Scrum4Me wil de user direct een GitHub-repo bootstrappen volgens canonical conventies (MD3-theme, ADR-systeem, docs-structuur, tooling). De catalogus van aanvinkbare opties + uitvoer-recepten leeft in de **database** (configureerbaar, audit-bar). Uitvoering gebeurt server-side via een **aparte `bootstrap-service`** — geen Claude-CLI, geen serverless fire-and-forget. - -**v3.2 verwerkt twee reviews + vijf pre-implementatie correcties + vijf clarificaties**: - -- **v1 review**: deterministic runtime, status-sync, schema-relaties, secret-boundary, action-validatie, ADR-coverage, route-groups, repo-slug -- **v2 web-research review**: één executor-model, PAT-shape, dry-run als feature, owner-picker, action-permissions, catalog-versioning, conditie weg uit MVP, tag-pinning behouden -- **Pre-implementatie correcties (v3.1)**: - 1. Snake-case DB-tables via `@@map` - 2. NOTIFY payload contract: `type: 'claude_job_status'`, `user_id` verplicht - 3. `lease_until` veld (bestaand) voor lease-renewal - 4. Git push via `isomorphic-git` (in-process credentials) - 5. `bootstrap-service` als sibling-directory -- **Pre-implementatie clarificaties (v3.2)**: - 1. Shared package npm-publicatie: trigger op derde consumer of coordination-pijn - 2. Recipe-hash determinisme: hash over `recipe_snapshot` (niet `selected_options`), canonicalized - 3. Dry-run file-tree: gefilterd ignore-set + cap 500 entries - 4. Template cache: geen cache in MVP; fase-2 disk-cache met TTL-sweep - 5. Deployment target: multi-arch Dockerfile, Mac arm64 als primary -- **Plan v3.3 verwerkt review-v3.2** (5 P1 + 6 P2): - 1. **Claim-identiteit**: `claimed_by_worker_id String?` toegevoegd aan ClaudeJob (niet het bestaande `claimed_by_token_id` misbruiken) - 2. **Shared package in Scrum4Me-repo**: `packages/bootstrap-actions/` binnen deze repo (geen secrets, deploybaar); bootstrap-service consumeert via release - 3. **GitHub-side-effect checkpoints**: `github_repo_created_at`/`github_repo_id`/`github_repo_full_name`/`push_completed_at` + status `FAILED_NEEDS_CLEANUP` - 4. **Stale-recovery kind-filter**: SQL altijd `AND kind='BOOTSTRAP_REPO'` - 5. **Atomic enqueue**: pre-generated cuid IDs binnen transaction-array - 6. **Cancel-safe terminal sync**: conditional `updateMany` voor success/failure - 7. **`last_bootstrap_run_id`** met expliciete Prisma-relation + relation-name (om ambiguïteit met `Product.bootstrap_runs` te voorkomen) + `onDelete: SetNull` - 8. **Action-permissions naar action-niveau**: `risk_level`/`requires_role` op `BootstrapAction`; option-level derived van max - 9. **ID-strategie**: alle nieuwe modellen gebruiken `@default(cuid())` (consistent met 22 bestaande modellen) - 10. **Classic PAT MVP**: scope-detectie via `x-oauth-scopes` werkt alleen voor classic; fine-grained PATs als open punt - 11. **`.env.example` + deployment docs** in filelijst -- **Plan v3.4 verwerkt review-v3.3** (3 P1 + 4 P2): - 1. **Status-sync echt transactioneel**: één `prisma.$transaction(async tx => …)` callback met alle 3 updates + count-checks; lease_until + claimed_by_worker_id terminal op null - 2. **Stale-recovery split**: `FAILED_NEEDS_CLEANUP` alleen bij `github_repo_full_name IS NOT NULL OR github_repo_created_at IS NOT NULL`; rest `FAILED` - 3. **Geen `@paralleldrive/cuid2` dep**: transaction-callback vorm met door Prisma gegenereerde cuid's - 4. `User.github_pat_scopes` krijgt `@default([])` voor migration-safety - 5. NOTIFY-payload `status` is **lowercase** (`jobStatusToApi`-output); DB blijft UPPER_SNAKE - 6. Stale-recovery komt naar **Sprint 1d** (MVP), niet pas fase-2 - 7. **Org-owner preflight via Octokit-call**: `RepoOwnerPicker` toont alleen owners waarvoor `octokit.repos.create…`-preflight slaagt; scope alleen is niet genoeg -- **Plan v3.5 verwerkt review-v3.4** (4 P2 + 3 P3 — geen P1's; go-signaal na P2-verwerking): - 1. **Org-owner preflight expliciet best-effort**: discovery toont owners; collision via `GET /repos/{owner}/{repo}`; finale autorisatie pas bij service-create; 403/422 → duidelijke wizard-fout; org-policy via `members_can_create_repositories` waar beschikbaar; ontbrekende info = "unknown" (niet automatisch verbergen) - 2. **`syncRunning` timestamp-contract**: bootstrap_runs.started_at én claude_jobs.started_at in **dezelfde transaction** met **dezelfde `now`-waarde**; unit-test voor PENDING/CLAIMED → RUNNING - 3. **`catalog_version` deterministisch**: canonical JSON over categories+options+actions, gesorteerd op display_order/slug/execution_order, alle relevante velden geïncludeerd, sha256 (niet md5) - 4. **E2E verification-query** JOIN naar `claude_jobs` voor `lease_until` - 5. **Stale-recovery globaal** (geen `claimed_by_worker_id`-filter); `claimed_by_worker_id` alleen voor renewal/observability - 6. **Vendor-copy drift CI-check** als concrete sprint-taak in Sprint 1a + verificatie-stap; service logt geladen `ActionSchema`-hash bij startup - 7. **`ADD_DEPENDENCY.version` regex**: MVP expliciet "alleen exact/range semver"; fase-2 `npm-package-arg`-parser voor `latest`/prerelease/`workspace:*`/`npm:`-aliases - ---- - -## Architectuur-besluiten - -| # | Onderwerp | Keuze | -|---|---|---| -| 1 | Scope | Server doet GitHub-side via **Octokit** (repo-create) + **isomorphic-git** (clone + push) | -| 2 | DB-model | Declaratieve recepten + Zod-validatie + action-permissions | -| 3 | Wizard | Gemengd radio/checkbox + **dry-run preview-stap** | -| 4 | Uitvoer | `ClaudeJobKind.BOOTSTRAP_REPO` voor uniforme UI/status; **aparte `bootstrap-service` claimt** | -| 5 | GitHub-auth | Per-user PAT (encrypted); service decrypt per run binnen execution-boundary | -| 6 | Schema | Product (`repo_owner`, `repo_slug`, `template_version`, …) + `BootstrapRun` + versioning-velden | -| 7 | UX | Twee-staps: Product → wizard (Configure → Preview → Run) | -| 8 | Catalog mgmt | Hybride — seed canonical, admin-UI fase-2 met `recipe_hash`-publish | - ---- - -## Executor: nieuwe `bootstrap-service` (sibling-directory) - -### Locatie - -**Sibling-folder**: `~/Development/bootstrap-service/` — naast `Scrum4Me/`, `scrum4me-mcp/`, `scrum4me-docker/`. Eigen `package.json`, `tsconfig.json`, `Dockerfile`, `prisma/schema.prisma` (gesynced via `sync-schema.sh` zoals scrum4me-mcp). - -**Niet**: -- In deze repo (zou worker-secrets in app-build mengen) -- Monorepo-package (deze codebase heeft geen monorepo-tooling; zou aparte tooling-investering vereisen) - -### Environment (`bootstrap-service/env.ts`) - -```ts -const Env = z.object({ - DATABASE_URL: z.string().url(), - DIRECT_URL: z.string().url(), // LISTEN/NOTIFY - BOOTSTRAP_ENCRYPTION_KEY: z.string().min(32), // AES-256-GCM key (gedeeld met app) - BOOTSTRAP_TEMPLATE_REPO: z.string().default('madhura68/nextjs-baseline'), - // GEEN ANTHROPIC_API_KEY, SESSION_SECRET, CRON_SECRET -}) -``` - -### Claim-protocol (gebruikt bestaand `lease_until` + nieuw `claimed_by_worker_id`) - -ClaudeJob krijgt een nieuw veld `claimed_by_worker_id String?` (separaat van bestaand `claimed_by_token_id` dat voor ApiToken-claim wordt gebruikt). Worker-ID is een unieke service-instance-identifier (`${hostname}-${pid}-${startTs}`). - -```ts -// In bootstrap-service/src/claim.ts: -const row = await prisma.$queryRaw<{ id: string }[]>` - UPDATE claude_jobs - SET status = 'CLAIMED', - lease_until = NOW() + INTERVAL '60 seconds', - claimed_at = NOW(), - claimed_by_worker_id = ${WORKER_ID} - WHERE id = ( - SELECT id FROM claude_jobs - WHERE status = 'QUEUED' AND kind = 'BOOTSTRAP_REPO' - ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1 - ) - RETURNING id -` -``` - -**Lease-renewal**: setInterval(30s) doet `UPDATE claude_jobs SET lease_until = NOW() + INTERVAL '60 seconds' WHERE id = $1 AND claimed_by_worker_id = ${WORKER_ID}` (only-mine-guard). - -**Stale-recovery** — **strikt kind-gefilterd én split op externe side-effects**. Hoort in **Sprint 1d (MVP)**, niet pas fase-2; zonder dit kan een service-crash een job langdurig in `CLAIMED`/`RUNNING` laten hangen. - -```sql --- Stap 1: markeer verlopen BOOTSTRAP_REPO jobs als FAILED (DB-side) -UPDATE claude_jobs -SET status='FAILED', error='lease expired', finished_at=NOW(), - lease_until=NULL, claimed_by_worker_id=NULL -WHERE status IN ('CLAIMED','RUNNING') - AND kind = 'BOOTSTRAP_REPO' - AND lease_until < NOW(); - --- Stap 2a: runs met external side-effects → FAILED_NEEDS_CLEANUP (orphan repo mogelijk) -UPDATE bootstrap_runs -SET status='FAILED_NEEDS_CLEANUP', error='lease expired', finished_at=NOW() -WHERE status IN ('PENDING','RUNNING') - AND claude_job_id IN ( - SELECT id FROM claude_jobs - WHERE status='FAILED' AND kind='BOOTSTRAP_REPO' AND error='lease expired' - ) - AND (github_repo_full_name IS NOT NULL OR github_repo_created_at IS NOT NULL); - --- Stap 2b: runs zonder external side-effects → FAILED (clean failure) -UPDATE bootstrap_runs -SET status='FAILED', error='lease expired', finished_at=NOW() -WHERE status IN ('PENDING','RUNNING') - AND claude_job_id IN ( - SELECT id FROM claude_jobs - WHERE status='FAILED' AND kind='BOOTSTRAP_REPO' AND error='lease expired' - ) - AND github_repo_full_name IS NULL - AND github_repo_created_at IS NULL; -``` - -Bestaande Claude-runner cleanup (in `app/api/cron/cleanup-agent-artifacts/route.ts`) blijft ongemoeid — dit is een dedicated SQL-pad voor BOOTSTRAP_REPO. - -**Wanneer draait dit**: -- In MVP via een dedicated cron-route (`app/api/cron/bootstrap-stale-recovery/route.ts`) elke 5 minuten, getrigggerd door Vercel-cron of de bestaande cron-runner. Bearer-secret-protected zoals andere cron-routes. -- `bootstrap-service` draait dezelfde SQL bij startup als **globale recovery** voor alle verlopen `BOOTSTRAP_REPO`-leases (van wie dan ook). **Niet** filteren op `claimed_by_worker_id` — een herstartende service heeft een nieuw `worker_id` (hostname + pid + start-timestamp) en zou zijn eigen oude leases anders niet matchen. `claimed_by_worker_id` is dus puur voor lease-renewal-only-mine-guard en observability/log-correlatie; stale-recovery is `kind`- en `lease_until`-gebaseerd. - -**LISTEN-fallback**: service luistert op `scrum4me_changes` filtered op nieuwe enqueues (`type: 'claude_job_enqueued'`, `kind: 'BOOTSTRAP_REPO'`); poll-interval 30s als safety. - -### `scrum4me-docker` skip-filter - -`scrum4me-docker/bin/run-one-job.ts` claim-query toevoegen: `AND kind <> 'BOOTSTRAP_REPO'`. Twee runners delen de `claude_jobs`-tabel zonder overlap. - ---- - -## Status-sync (transactional + post-commit NOTIFY) - -```ts -// In bootstrap-service/src/status-sync.ts: -import { jobStatusToApi } from '@/lib/job-status' // gespiegeld via shared-package of bootstrap-service-eigen kopie - -async function syncSuccess(runId: string, jobId: string, productId: string, userId: string, repoUrl: string, templateVersion: string) { - // ÉÉN transaction; geen partial commits. - const result = await prisma.$transaction(async (tx) => { - const runUpdate = await tx.bootstrapRun.updateMany({ - where: { id: runId, status: 'RUNNING' }, - data: { status: 'SUCCEEDED', finished_at: new Date(), repo_url: repoUrl, push_completed_at: new Date() }, - }) - if (runUpdate.count === 0) { - // Was al CANCELLED of FAILED — abort, geen verdere writes - return { committed: false as const } - } - const jobUpdate = await tx.claudeJob.updateMany({ - where: { id: jobId, status: 'RUNNING' }, - data: { - status: 'DONE', - finished_at: new Date(), - summary: `Bootstrap completed: ${repoUrl}`, - lease_until: null, - claimed_by_worker_id: null, - }, - }) - if (jobUpdate.count === 0) { - // Race: job is buiten verwachting al terminal — gooi om transaction te rollbacken - throw new Error('job-state-mismatch') - } - await tx.product.update({ - where: { id: productId }, - data: { repo_url: repoUrl, template_version: templateVersion, last_bootstrap_run_id: runId }, - }) - return { committed: true as const } - }) - - if (!result.committed) return // niets te notifyen - - // NA commit: - await prisma.$executeRaw` - SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ - type: 'claude_job_status', - job_id: jobId, - user_id: userId, - kind: 'BOOTSTRAP_REPO', - status: jobStatusToApi('DONE'), // 'done' lowercase per bestaand contract - bootstrap_run_id: runId, - repo_url: repoUrl, - })}::text) - ` -} -``` - -**Belangrijke eigenschappen**: -- **Echt transactioneel**: bootstrap_runs, claude_jobs, en products updates zitten in dezelfde DB-transaction. Faal in een willekeurige stap → volledige rollback. Geen partial-committed-state mogelijk. -- **Cancel-safe**: status-filter in `where` zorgt dat een `CANCELLED`-overgang die tussendoor gebeurde niet door late terminal-write wordt overschreven. -- **Lease-cleanup terminal**: `lease_until` en `claimed_by_worker_id` worden expliciet `null` gezet bij terminal status; voorkomt dat stale-recovery deze record nog "ziet". -- **NOTIFY na commit**: pas na succesvolle commit; rollback betekent geen NOTIFY. -- **Status lowercase in payload**: `jobStatusToApi('DONE') → 'done'` matched bestaande SSE-clients; DB blijft UPPER_SNAKE. - -Voor `syncFailed`: identieke vorm, met aanvullende beslislogica voor terminal-status: -```ts -const terminalRunStatus = (run.github_repo_full_name || run.github_repo_created_at) - ? 'FAILED_NEEDS_CLEANUP' - : 'FAILED' -``` - -Voor `syncRunning`: **cancel-safe** analoog aan syncSuccess én **timestamp-contract expliciet**. Beide tabellen krijgen `started_at = now` in dezelfde transaction met dezelfde `now`-waarde, zodat downstream metrics, UI-sortering en E2E-queries betrouwbaar zijn. - -```ts -async function syncRunning(runId: string, jobId: string, userId: string) { - const now = new Date() // één waarde, gedeeld tussen run + job - const result = await prisma.$transaction(async (tx) => { - const runUpdate = await tx.bootstrapRun.updateMany({ - where: { id: runId, status: 'PENDING' }, - data: { status: 'RUNNING', started_at: now }, - }) - if (runUpdate.count === 0) return { committed: false as const } // CANCELLED ertussen - const jobUpdate = await tx.claudeJob.updateMany({ - where: { id: jobId, status: 'CLAIMED' }, - data: { status: 'RUNNING', started_at: now }, - }) - if (jobUpdate.count === 0) throw new Error('job-state-mismatch') // rollback - return { committed: true as const } - }) - if (!result.committed) return - - await prisma.$executeRaw` - SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ - type: 'claude_job_status', job_id: jobId, user_id: userId, - kind: 'BOOTSTRAP_REPO', status: jobStatusToApi('RUNNING'), // 'running' - bootstrap_run_id: runId, - })}::text) - ` -} -``` - -**Verplichte unit-test** (Sprint 1a): `bootstrap_runs.started_at == claude_jobs.started_at` na `syncRunning`; allebei niet-null; transitie PENDING/CLAIMED → RUNNING. - -**GitHub side-effect checkpoint writes** (apart van terminal-sync, na elke external mutatie): -```ts -// Na Octokit createForAuthenticatedUser: -await prisma.bootstrapRun.update({ - where: { id: runId }, - data: { - github_repo_created_at: new Date(), - github_repo_id: repo.id, - github_repo_full_name: repo.full_name, - }, -}) -// Na isomorphic-git push success: -await prisma.bootstrapRun.update({ - where: { id: runId }, - data: { push_completed_at: new Date() }, -}) -``` - -Dit zorgt dat na een service-crash een stale-recovery weet wat er extern gebeurd is en kan beslissen tussen compensating delete vs. `FAILED_NEEDS_CLEANUP`-flag. - -**Payload-contract** matched bestaand `JobPayload`-type uit `app/api/realtime/jobs/route.ts`: -```ts -type JobPayload = { - type: 'claude_job_enqueued' | 'claude_job_status' - job_id: string - user_id: string // verplicht voor SSE-filter - kind?: string // 'BOOTSTRAP_REPO' - status: string - bootstrap_run_id?: string // nieuw extension-veld voor deze kind - task_id?: null - idea_id?: null - sprint_run_id?: null - ... -} -``` - -`bootstrap_run_id` is een **additieve uitbreiding**; bestaande consumers negeren onbekende velden veilig. - ---- - -## Git operaties: `isomorphic-git` (pure-JS) - -**Waarom isomorphic-git, niet shell `git`**: -- Geen subprocess in service-container -- PAT als HTTP-header in-process (`onAuth` callback), nooit in URL of shell-argv -- Geen credential-helper-state op disk -- Werkt op alle platforms zonder git-binary-dependency - -```ts -import * as git from 'isomorphic-git' -import http from 'isomorphic-git/http/node' -import fs from 'fs' - -// Clone template (tag-pinned): -await git.clone({ - fs, http, dir: tmpdir, - url: `https://github.com/${BOOTSTRAP_TEMPLATE_REPO}.git`, - ref: templateVersion, // bv. 'v1.0.0' - singleBranch: true, - depth: 1, -}) -const sourceSha = await git.resolveRef({ fs, dir: tmpdir, ref: 'HEAD' }) - -// (recipe-acties muteren tmpdir hier) - -// Init opnieuw als clean git-history voor target: -await fs.promises.rm(`${tmpdir}/.git`, { recursive: true, force: true }) -await git.init({ fs, dir: tmpdir, defaultBranch: 'main' }) -await git.add({ fs, dir: tmpdir, filepath: '.' }) -await git.commit({ - fs, dir: tmpdir, - message: `Bootstrap: ${selectedOptionsSummary}\n\nFrom template ${BOOTSTRAP_TEMPLATE_REPO}@${templateVersion}`, - author: { name: user.github_username ?? 'Scrum4Me', email: 'bootstrap@scrum4me.dev' }, -}) - -// Create remote repo via Octokit: -const octokit = new Octokit({ auth: pat }) -const { data: repo } = await octokit.repos.createForAuthenticatedUser({ // of createInOrg - name: repoSlug, private: true, auto_init: false, -}) - -// Push via isomorphic-git met token in onAuth-callback (nooit in URL): -await git.addRemote({ fs, dir: tmpdir, remote: 'origin', url: repo.clone_url }) -await git.push({ - fs, http, dir: tmpdir, remote: 'origin', ref: 'main', - onAuth: () => ({ username: 'x-access-token', password: pat }), // header-only -}) -``` - -`pat` blijft binnen function-scope; `onAuth` returnt closure-scoped value zonder logging. - ---- - -## Domein-model (Prisma) — met `@@map` - -### `BootstrapCategory` → table `bootstrap_categories` -```prisma -model BootstrapCategory { - id String @id @default(cuid()) - slug String @unique - label String - description String? - selection_type BootstrapSelectionType // SINGLE | MULTI - display_order Int - is_required Boolean @default(false) - options BootstrapOption[] - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - @@map("bootstrap_categories") -} -``` - -### `BootstrapOption` → table `bootstrap_options` -**Action-permissions verplaatst naar `BootstrapAction`** (review-fix). `BootstrapOption` houdt alleen catalog-eigenschappen; risk/role is per action. Option-level "effective risk" wordt server-side afgeleid (`MAX(action.risk_level) FOR action IN option.actions`) maar niet als kolom opgeslagen — alleen runtime-computed waar nodig. - -```prisma -model BootstrapOption { - id String @id @default(cuid()) - category_id String - category BootstrapCategory @relation(fields: [category_id], references: [id], onDelete: Cascade) - slug String - label String - description String? - is_default Boolean @default(false) - display_order Int - archived Boolean @default(false) - enabled Boolean @default(true) - actions BootstrapAction[] - @@unique([category_id, slug]) - @@index([category_id, display_order]) - @@map("bootstrap_options") -} -``` - -### `BootstrapAction` → table `bootstrap_actions` -```prisma -model BootstrapAction { - id String @id @default(cuid()) - option_id String - option BootstrapOption @relation(fields: [option_id], references: [id], onDelete: Cascade) - kind BootstrapActionKind - params Json // Zod-validated per kind - execution_order Int - supports_dry_run Boolean @default(true) - side_effects SideEffect[] // FILESYSTEM | GITHUB_REPO | GITHUB_SETTINGS | NETWORK - risk_level RiskLevel @default(LOW) // verplaatst van Option - requires_role RoleRequired @default(ANY) // verplaatst van Option - @@index([option_id, execution_order]) - @@map("bootstrap_actions") -} -``` - -Action-permissions worden bij start-action gevalideerd: `RUN_BASH_TEMPLATE` action met `requires_role=ADMIN` blokkeert non-admin users in `startBootstrapAction`. Dry-run-validatie idem (Backstage-pattern). - -**`condition` veld weggelaten uit MVP** — mini-DSL in fase-2. - -### `BootstrapRun` → table `bootstrap_runs` -**GitHub-side-effect checkpoints toegevoegd** (review-fix) — durable record van externe mutaties zodat crash-recovery weet wat opgeruimd moet worden. - -```prisma -model BootstrapRun { - id String @id @default(cuid()) - product_id String - product Product @relation(name: "ProductBootstrapRuns", fields: [product_id], references: [id], onDelete: Cascade) - user_id String - user User @relation(fields: [user_id], references: [id]) - claude_job_id String? @unique - claude_job ClaudeJob? @relation(name: "BootstrapRunJob", fields: [claude_job_id], references: [id], onDelete: SetNull) - status BootstrapRunStatus // PENDING | RUNNING | SUCCEEDED | FAILED | CANCELLED | FAILED_NEEDS_CLEANUP - template_version String - template_source_sha String? - catalog_version String - recipe_hash String - action_schema_version String - repo_owner_snapshot String - repo_slug_snapshot String - selected_options Json - recipe_snapshot Json - dry_run_report Json? - - // GitHub side-effect checkpoints (durable mutaties) - github_repo_created_at DateTime? - github_repo_id BigInt? // GitHub's numeric repo id — JSON.stringify() gooit TypeError; cast via .toString() of Number() bij API-serialisatie - github_repo_full_name String? // 'owner/repo' - push_completed_at DateTime? - - repo_url String? // gevuld na push_completed_at - started_at DateTime? - finished_at DateTime? - error String? @db.VarChar(8192) - output_log String? - created_at DateTime @default(now()) - - @@index([product_id, status]) - @@index([user_id, created_at]) - @@index([status, finished_at]) // voor stale-recovery sweep - @@map("bootstrap_runs") -} - -enum BootstrapRunStatus { - PENDING - RUNNING - SUCCEEDED - FAILED // recoverable / no orphan side-effects - FAILED_NEEDS_CLEANUP // orphan GitHub-repo or partial push; manual or compensating - CANCELLED -} -``` - -**Partial unique index** (raw SQL toegevoegd in migration): -```sql -CREATE UNIQUE INDEX bootstrap_runs_one_active_per_product - ON bootstrap_runs (product_id) - WHERE status IN ('PENDING','RUNNING'); -``` - -### `Product` uitbreiding (table `products`, `@@map` blijft) -```prisma -model Product { - // ... bestaande velden ... - repo_owner String? - repo_slug String? - template_version String? - last_bootstrap_run_id String? - last_bootstrap_run BootstrapRun? @relation(name: "ProductLastBootstrapRun", fields: [last_bootstrap_run_id], references: [id], onDelete: SetNull) - bootstrap_runs BootstrapRun[] @relation(name: "ProductBootstrapRuns") // history - @@unique([repo_owner, repo_slug]) - // ... bestaande @@map("products") ... -} -``` - -Twee expliciete relaties met disjoint relation-names: `ProductLastBootstrapRun` voor de huidige pointer (SetNull bij delete van de run), `ProductBootstrapRuns` voor de history (Cascade bij delete van de product). Prisma vereist named relations bij meerdere relaties tussen dezelfde modellen. - -### `User` uitbreiding (table `users`) -```prisma -model User { - // ... bestaande velden ... - github_pat_encrypted String? // prefix 'v1:<base64>' - github_username String? - github_pat_verified_at DateTime? - github_pat_scopes String[] @default([]) // default-array voor migration-safety op bestaande users - github_pat_expires_at DateTime? - github_orgs Json? - bootstrap_runs BootstrapRun[] - // ... bestaande @@map("users") ... -} -``` - -`@default([])` zorgt dat de migration op een bestaande database met users geen backfill nodig heeft; bestaande rijen krijgen een lege array. - -### `ClaudeJob` uitbreiding (table `claude_jobs`) -```prisma -model ClaudeJob { - // ... bestaande velden, inclusief lease_until + claimed_by_token_id ... - claimed_by_worker_id String? // NIEUW: voor bootstrap-service (en toekomstige worker-types) - bootstrap_run BootstrapRun? @relation(name: "BootstrapRunJob") - // ... bestaande @@map("claude_jobs") ... -} -``` - -`claimed_by_worker_id` blijft naast `claimed_by_token_id` (bestaand): laatste is voor ApiToken-claim door de Claude-CLI runner, eerste is een free-form service-instance-identifier voor `bootstrap-service`. Geen FK; pure log/correlation-veld. - -`ClaudeJobKind` enum: `BOOTSTRAP_REPO` toegevoegd. - -### Env (`lib/env.ts`) -- App: `BOOTSTRAP_ENCRYPTION_KEY` (required, min 32), `BOOTSTRAP_TEMPLATE_REPO` (default `madhura68/nextjs-baseline`) - ---- - -## PAT-secret-boundary - -**`startBootstrapAction` decrypt nooit**: -- Bewaart geen plaintext PAT -- Geeft alleen `runId` mee aan executie - -**`bootstrap-service` decrypt per run** binnen execution-scope: -```ts -const run = await prisma.bootstrapRun.findUnique({ where:{ id: runId }, include:{ user: true }}) -let pat = decryptPat(run.user.github_pat_encrypted, env.BOOTSTRAP_ENCRYPTION_KEY) -try { await executeRecipe(run, pat) } -finally { pat = ''; /* GC */ } -``` - -**Test-flow voor PAT** (`saveGitHubPatAction`) — **classic PAT in MVP**: -```ts -const octokit = new Octokit({ auth: token }) -const { data: me, headers } = await octokit.rest.users.getAuthenticated() -const scopes = (headers['x-oauth-scopes'] ?? '').split(',').map(s => s.trim()).filter(Boolean) -if (!scopes.includes('repo')) { - throw new Error('Classic PAT met scope "repo" vereist. Fine-grained PATs nog niet ondersteund — zie open punten.') -} -// Encrypt + opslaan + verified_at/scopes -``` - -**Fine-grained PATs werken anders** — geen `x-oauth-scopes` header, wel `x-accepted-github-permissions` of repository-permission-set via `GET /user/installations`. Voor MVP **alleen classic PAT ondersteund**; settings-UI toont dit expliciet ("Vereist een classic PAT met `repo` scope — fine-grained tokens nog niet ondersteund."). Fine-grained support staat in open punten. - ---- - -## Dry-run / preview (eerste-klas) - -> **Implementatienoot**: `previewBootstrapAction` doet een git clone + recipe-run (~2-5s). Als dit te zwaar wordt voor een Next.js Server Action (Vercel function geheugendruk bij hoog volume), migreer naar een Route Handler (`POST /api/bootstrap/preview`) met streaming response. In MVP is Server Action acceptabel; monitor Vercel function-metrics. - -`previewBootstrapAction(productId, selections, repoOwner, repoSlug)`: -1. Auth + demo-check (403) -2. Zod-validate selections + GitHub-name regex -3. Resolve recipe + compute `recipe_hash` + `catalog_version` -4. Spin up tmpdir + clone template (geen cache in MVP — zie Template-cache-sectie) -5. Run alle handlers met `supports_dry_run=true` tegen tmpdir; `RUN_BASH_TEMPLATE` logged als "skipped" -6. Octokit preflight: `octokit.repos.get({ owner, repo })` om collision te detecteren -7. Octokit preflight: `octokit.orgs.list...` om owner-rechten te valideren -8. **File-tree filter en cap** (zie hieronder) -9. Retourneer `DryRunReport`: - ```ts - { fileTree: string[]; truncated: boolean; actionLog: Array<{ kind, summary, status }>; warnings: string[]; canProceed: boolean; collisions: { owner: string; slug: string } | null } - ``` - -Geen DB-write, geen GitHub-write. Wel telemetry. - -### Org-owner preflight — **best-effort discovery** (geen harde create-permission proof) - -De wizard heeft drie verschillende garanties op verschillende plekken; **alleen de service-side `octokit.repos.create*`-call is de finale autorisatie**. Eerdere checks zijn best-effort hints, niet bewijzen. - -**Stap 1: Best-effort owner-discovery** (in `RepoOwnerPicker`) - -```ts -// Voor user-owner: altijd tonen indien PAT geldig -await octokit.users.getAuthenticated() // moet slagen voor PAT-validiteit - -// Voor elke org uit github_orgs cache: best-effort metadata-fetch -try { - const { data: org } = await octokit.orgs.get({ org: orgLogin }) - return { - login: orgLogin, - member_status: 'visible', - members_can_create_private: org.members_can_create_private_repositories ?? null, // null = onbekend - members_creation_type: org.members_allowed_repository_creation_type ?? null, - } -} catch (err) { - // 404 / 403 = onbekend, niet automatisch verbergen - return { login: orgLogin, member_status: 'unknown' } -} -``` - -De UI toont **alle** zichtbare orgs (plus user) met een hint-badge: "✓ kan repos maken" / "⚠ org-policy onbekend" / "⚠ org-policy blokkeert private repos". Geen owner wordt **automatisch verborgen** op basis van twijfelachtige info — dat zou false negatives (legitieme orgs verborgen) creëren. - -**Stap 2: Collision-check** vóór wizard-submit - -```ts -try { - await octokit.repos.get({ owner, repo }) // 200 = collision; 404 = vrij - return { canProceed: false, collision: { owner, repo }} -} catch (err) { - if (err.status === 404) return { canProceed: true } - return { canProceed: false, reason: 'preflight-failed' } -} -``` - -**Stap 3: Finale autorisatie** = de werkelijke `octokit.repos.createForAuthenticatedUser`/`createInOrg` in `bootstrap-service`. GitHub-antwoord is de waarheid: -- `201 Created` → succes, ga door -- `403 Forbidden` → vertaal naar wizard-fout "Geen rechten om repo te maken in `<owner>`. Mogelijke oorzaken: SSO niet geautoriseerd, org-policy blokkeert, of PAT mist scope. [Wijzig owner] [Wijzig PAT]" -- `422 Unprocessable Entity` → typisch naam-conflict (race vs. stap 2); zelfde wizard-fout met "naam al in gebruik" -- Andere fouten → generieke "GitHub-fout: <message>" + retry-knop - -**Documenteer dit expliciet in `RepoOwnerPicker`-tooltip**: "Owners worden best-effort gedetecteerd. Sommige org-policies (SSO, admin-only-repo-create) zijn niet vooraf zichtbaar; de daadwerkelijke create-actie is de finale check." - -Scope `repo` blijft een **noodzakelijke** voorwaarde (gecheckt bij `saveGitHubPatAction`), maar de UI gebruikt geen scope-alleen heuristiek om owners te verbergen. - -### File-tree scope (filter + cap) - -`fileTree` is **gefilterd** anders wordt het onleesbaar (~200+ files in een Next.js-template). - -Ignore-patterns (gitignore-stijl): -``` -.git/ -node_modules/ -.next/ -dist/ build/ out/ -*.log -.DS_Store -.env* -coverage/ -``` - -**Hard cap** van 500 entries; bij overschrijding `truncated: true` met indicator `[+12 more files omitted]`. MVP: flat `string[]`. Fase-2 kan migreren naar: -```ts -type DryRunFileTreeNode = { name: string; type: 'file' | 'dir'; children?: DryRunFileTreeNode[]; size?: number } -``` - -### Template-cache lifecycle - -**MVP: geen cache** — `git clone --depth=1` met `isomorphic-git` is snel (~2-3s op kleine templates); cache-complexity is YAGNI tot het pijn doet. - -**Fase-2 strategie** (als preview-latency UX-probleem wordt): -- Disk-cache in `/var/cache/bootstrap-service/<template_repo_slug>/<template_version>/` -- Persistent across service-restarts (snelle warm-start) -- **Niet** clearen op deploy (deploys frequent, cache-warmup duur) -- **Geen invalidation nodig** voor tag-pinned versies (semver-tags zijn immutable; re-tag is mis-use) -- TTL-sweep via `last_used.json` mark-file per cached version; cron verwijdert versies >30 dagen ongebruikt - ---- - -## Catalog/recipe versioning - -### Hash-input bepaling (determinisme) - -`recipe_hash` wordt berekend over **`recipe_snapshot`** (de resolved action-list), **niet** over `selected_options`. Reden: identieke `selected_options` met een andere catalog-versie produceert andere acties → andere uitkomst; de hash moet dat onderscheid tonen. `catalog_version` blijft een orthogonaal apart veld. - -Canonicalization-regels (in `packages/bootstrap-actions/recipe-hash.ts`): -```ts -function canonicalize(recipe: RecipeSnapshot): string { - const sorted = { - actions: recipe.actions - .sort((a, b) => a.execution_order - b.execution_order) - .map(a => ({ - kind: a.kind, - execution_order: a.execution_order, - params: sortObjectKeysRecursive(a.params), - })), - } - return JSON.stringify(sorted) -} -export function recipeHash(recipe: RecipeSnapshot): string { - return createHash('sha256').update(canonicalize(recipe)).digest('hex') -} -``` - -Geen timestamps, geen UUIDs in de hash-input. Identieke recipe ⇔ identieke hash, garandeert deterministische replay. - -### Velden op `BootstrapRun` - -- `recipe_hash` = sha256(canonicalize(recipe_snapshot)) -- `catalog_version` = sha256(canonicalize(catalog_snapshot)) — zelfde discipline als recipe_hash -- `action_schema_version` = hardcoded in shared package; bumped bij schema-breaks -- `template_source_sha` = git SHA na clone - -### Deterministische `catalog_version` berekening - -Niet `md5(string_agg(...))` — dat is zonder ordering niet deterministisch en mist categories/actions. Gebruik dezelfde canonical-JSON-aanpak als `recipe_hash`: - -```ts -// In packages/bootstrap-actions/catalog-hash.ts -export function catalogVersion(catalog: CatalogSnapshot): string { - const sorted = { - categories: catalog.categories - .sort((a, b) => a.display_order - b.display_order || a.slug.localeCompare(b.slug)) - .map(cat => ({ - slug: cat.slug, - selection_type: cat.selection_type, - is_required: cat.is_required, - display_order: cat.display_order, - options: cat.options - .sort((a, b) => a.display_order - b.display_order || a.slug.localeCompare(b.slug)) - .map(opt => ({ - slug: opt.slug, - is_default: opt.is_default, - enabled: opt.enabled, - archived: opt.archived, - display_order: opt.display_order, - actions: opt.actions - .sort((a, b) => a.execution_order - b.execution_order || a.id.localeCompare(b.id)) - .map(act => ({ - kind: act.kind, - execution_order: act.execution_order, - params: sortObjectKeysRecursive(act.params), - supports_dry_run: act.supports_dry_run, - side_effects: [...act.side_effects].sort(), - risk_level: act.risk_level, - requires_role: act.requires_role, - })), - })), - })), - } - return createHash('sha256').update(JSON.stringify(sorted)).digest('hex') -} -``` - -Wijzigingen in **elk** van deze velden (selection_type, required/default, enabled/archived, action kind, params, dry-run support, side effects, risk_level, requires_role) leveren een nieuwe `catalog_version`. Geen md5; sha256 voor consistentie met `recipe_hash`. SQL-loading: `SELECT * FROM bootstrap_categories WHERE archived=false ORDER BY display_order, slug` + nested fetches; transformatie in TypeScript via `catalogVersion()`. - -### `.scrum4me/bootstrap.json` in target-repo (na succes) -```json -{ "template_repo": "...", "template_version": "v1.0.0", "template_source_sha": "...", - "catalog_version": "...", "recipe_hash": "...", "action_schema_version": "1.0", - "generated_at": "...", "selected_options": {...} } -``` - ---- - -## UI-componenten (`app/(app)/...`) - -| Component | Locatie | -|---|---| -| `BootstrapWizardDialog` | `app/(app)/products/[id]/_components/bootstrap-wizard-dialog.tsx` | -| `BootstrapWizardStep` | idem (per-categorie) | -| `RepoOwnerPicker` | idem | -| `BootstrapPreviewPanel` | idem | -| `BootstrapStatusPanel` | idem (SSE-status) | -| `GitHubPatSettings` | `app/(app)/settings/_components/github-pat-settings.tsx` | -| `BootstrapAdminPage` (fase-2) | `app/(app)/admin/bootstrap/page.tsx` | -| Product-detail-knop | `app/(app)/products/[id]/page.tsx` | - -Wizard-flow: **Configure → Preview → Run**. - ---- - -## Server-actions (`actions/bootstrap.ts`) - -- `previewBootstrapAction` → `DryRunReport` (**Sprint 1c**) -- `startBootstrapAction` → `{ runId }`; gebruikt partial unique index voor concurrency (**Sprint 1c**) -- `cancelBootstrapAction(runId)` → markeert `ClaudeJob.status='CANCELLED'`; service detecteert per-action (**Fase 2** — stub in MVP, geen UI-knop) -- `retryBootstrapAction(failedRunId)` → nieuwe run met zelfde selections (**Fase 2**) -- `saveGitHubPatAction(token)` → encrypt + verify + scope-detect + opslaan (**Sprint 1c**) - -Alle vijf: demo-check (403) + Zod + rate-limit. In MVP worden `cancelBootstrapAction` en `retryBootstrapAction` als gated stub geïmplementeerd (403 of 501) maar nog niet aangeroepen via UI. - -Atomisch enqueue via **transaction callback** — Prisma genereert beide cuid's intern, geen externe `cuid2`-dependency nodig: - -```ts -import { jobStatusToApi } from '@/lib/job-status' - -const { jobId, runId } = await prisma.$transaction(async (tx) => { - const job = await tx.claudeJob.create({ - data: { - kind: 'BOOTSTRAP_REPO', - status: 'QUEUED', - user_id: userId, - product_id: productId, - // requested_* allemaal null (deterministic runtime) - }, - select: { id: true }, - }) - const run = await tx.bootstrapRun.create({ - data: { - product_id: productId, - user_id: userId, - claude_job_id: job.id, - status: 'PENDING', - template_version, - catalog_version, - recipe_hash, - action_schema_version, - repo_owner_snapshot, - repo_slug_snapshot, - selected_options, - recipe_snapshot, - }, - select: { id: true }, - }) - return { jobId: job.id, runId: run.id } -}) - -// NA commit (niet IN transaction): -await prisma.$executeRaw` - SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ - type: 'claude_job_enqueued', - job_id: jobId, - user_id: userId, - kind: 'BOOTSTRAP_REPO', - status: jobStatusToApi('QUEUED'), // 'queued' lowercase - bootstrap_run_id: runId, - })}::text) -` - -return { runId } -``` - -Concurrency wordt afgedwongen door partial unique index — een tweede gelijktijdige insert mislukt met unique violation. Geen externe ID-library nodig; Prisma's built-in `cuid()` default doet het werk. - ---- - -## Shared action-package (in **Scrum4Me-repo**) - -**Locatie**: `packages/bootstrap-actions/` **binnen de Scrum4Me-repo** (review-fix). Reden: dit package bevat geen secrets, alleen Zod-schema's en pure-JS file-handlers. Het mag dus mee in de Scrum4Me build — een `file:` link naar een sibling-directory zou de app onbouwbaar maken in Vercel/CI (sibling zit niet in de checkout). - -**Inhoud**: -- `schema.ts` — `ActionSchema` (Zod discriminated union) -- `handlers/{copy-file,write-file,append-to-file,replace-string,create-adr-stub,add-dependency,run-bash}.ts` -- `allowed-commands.ts` — RUN_BASH_TEMPLATE regex-array -- `recipe-hash.ts` — canonicalize + sha256 -- `types.ts` — gedeelde DryRunReport, ActionContext, etc. -- `package.json` met `"name": "@scrum4me/bootstrap-actions"`, `"version": "0.1.0"` - -**Consumers**: -- Scrum4Me-app: `lib/bootstrap/dry-run.ts` importeert `@scrum4me/bootstrap-actions` via workspace-resolution -- `bootstrap-service/src/runner.ts`: idem; consumeert via release (zie hieronder) - -**Hoe Scrum4Me-app het package gebruikt**: -- Als de Scrum4Me-repo één package.json heeft (geen workspaces): bouw als pure TS-bibliotheek in `packages/bootstrap-actions/` en consumeer via path-alias of monorepo-light setup -- Als workspaces wel beschikbaar zijn: voeg `"workspaces": ["packages/*"]` toe aan root `package.json` (minimal pnpm/npm workspaces; geen Turborepo nodig) -- MVP-pragmatisch: TypeScript path-alias `@scrum4me/bootstrap-actions` → `./packages/bootstrap-actions/src` in `tsconfig.json`, bundlen via Next.js default. **Let op**: ook `vitest.config.ts` krijgt `resolve.alias: { '@scrum4me/bootstrap-actions': './packages/bootstrap-actions/src' }` anders falen unit-tests op dit package. - -**Hoe bootstrap-service het consumeert**: -- **Optie A (MVP)**: `bootstrap-service` doet bij iedere release een **vendor-copy** van het package via een sync-script (`scripts/sync-bootstrap-actions.sh` — vergelijkbaar met `sync-schema.sh`). Sources blijven in Scrum4Me-repo authoritative; service kopieert een snapshot bij CI-build. -- **Optie B (later)**: publiceer naar GitHub Packages (`@madhura68/bootstrap-actions`) zodra een derde consumer of coordination-pijn dit rechtvaardigt. - -**Upgrade-trigger naar B** (geen sprint-automatisme — pas wanneer): -1. Derde consumer verschijnt (CI-pipeline, tweede service, externe contributor) -2. Coordination-pijn: `ActionSchema`-wijziging die app/service apart-deploy verbreekt zonder version-pin -3. Externe gebruiker vraagt het package buiten lokale dev - ---- - -## Action-schema + path-safety - -```ts -// packages/bootstrap-actions/src/schema.ts -const SafeRelPath = z.string().min(1).max(256) - .regex(/^[A-Za-z0-9_./-]+$/) - .refine(p => !p.startsWith('/'), 'absolute denied') - .refine(p => !p.split('/').includes('..'), 'parent traversal denied') - .refine(p => !p.split('/').includes('.git'), '.git denied') - -export const ActionSchema = z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('COPY_FILE'), params: z.object({ source: SafeRelPath, dest: SafeRelPath })}), - z.object({ kind: z.literal('WRITE_FILE'), params: z.object({ path: SafeRelPath, content: z.string().max(65_536) })}), - z.object({ kind: z.literal('APPEND_TO_FILE'), params: z.object({ path: SafeRelPath, content: z.string().max(65_536), marker: z.string().min(1).max(256) })}), - z.object({ kind: z.literal('REPLACE_STRING'), params: z.object({ file: SafeRelPath, find: z.string().min(1).max(1024), replace: z.string().max(8192) })}), - z.object({ kind: z.literal('CREATE_ADR_STUB'), params: z.object({ number: z.number().int().min(1).max(99), title: z.string().min(1).max(160), template: z.enum(['nygard','madr']) })}), - // MVP: alleen exact/range semver (geen 'latest', 'workspace:*', 'npm:'-aliases, geen prerelease-labels). - // Fase-2: vervang regex door npm-package-arg + allowlist van toegestane spec-types. - z.object({ kind: z.literal('ADD_DEPENDENCY'), params: z.object({ name: z.string().regex(/^[@a-z0-9/_.-]+$/), version: z.string().regex(/^[\^~>=<.\d-]+$/), dev: z.boolean() })}), - z.object({ kind: z.literal('RUN_BASH_TEMPLATE'), params: z.object({ command: z.string().refine(c => allowedCommands.some(rx => rx.test(c))) })}), -]) -``` - -Path-resolution in handlers: `path.resolve(tmpdir, params.dest)` + assert `result.startsWith(tmpdir + path.sep)`. - -**Run-level caps**: -- ≤ 200 acties per recipe -- ≤ 256 KiB output_log -- 30-min runtime watchdog -- ≤ 64 KiB per WRITE/APPEND content -- ≤ 50 MB tmpdir total - ---- - -## Enum-uitbreiding — exhaustive scope - -`ClaudeJobKind.BOOTSTRAP_REPO` ripple: -- `components/jobs/job-card.tsx` (`Record<ClaudeJobKind, …>`) -- `components/jobs/jobs-column.tsx` -- `lib/insights/agent-throughput.ts` -- SSE initial-payload + filter-set -- `lib/job-config.ts` + spiegel-MCP: discriminated union returns `runtime: 'deterministic'` -- Tests rond exhaustive switches - ---- - -## Bestanden te wijzigen / aan te maken - -### Database (Scrum4Me) -- `prisma/schema.prisma` — modellen + enums + `@@map` annotaties + Product/User uitbreidingen -- `prisma/migrations/<ts>_bootstrap_wizard/migration.sql` — incl. partial unique index (raw SQL) -- `prisma/seed.ts` — `seedBootstrapCatalog()` met 6 core + add-ons -- `scrum4me-mcp/prisma/schema.prisma` — gesynced via `sync-schema.sh` -- `~/Development/bootstrap-service/prisma/schema.prisma` — gesynced idem - -### App-side (Scrum4Me) -- `lib/env.ts` — `BOOTSTRAP_ENCRYPTION_KEY`, `BOOTSTRAP_TEMPLATE_REPO` -- `lib/job-config.ts` (+ MCP-spiegel) — discriminated union, BOOTSTRAP_REPO → deterministic -- `lib/crypto/pat.ts` — encrypt only (decrypt leeft in service) -- `actions/bootstrap.ts` — preview/start/cancel/retry/savePat -- `actions/products.ts` — `repo_owner`, `repo_slug` velden -- `lib/bootstrap/dry-run.ts` — gebruikt shared handlers -- `lib/bootstrap/recipe.ts` — recipe-resolver van selections naar action-list - -### Shared package (in deze repo) -- `packages/bootstrap-actions/` — schema, handlers, types, recipe-hash, allowed-commands. Path-alias `@scrum4me/bootstrap-actions` in `tsconfig.json` -- `scripts/sync-bootstrap-actions.sh` — sync-script (analoog aan `sync-schema.sh`) dat het package naar `~/Development/bootstrap-service/packages/bootstrap-actions/` vendor-copyt bij service-build - -### Jobs-board (Scrum4Me) -- `components/jobs/job-card.tsx`, `jobs-column.tsx` -- `lib/insights/agent-throughput.ts` -- SSE-routes (`app/api/realtime/jobs/route.ts` etc.) - -### UI (Scrum4Me) -- `app/(app)/products/[id]/page.tsx` + `_components/*` -- `app/(app)/settings/_components/github-pat-settings.tsx` -- `app/(app)/admin/bootstrap/page.tsx` (fase-2) - -### Worker (scrum4me-docker) -- `bin/run-one-job.ts` — skip-filter `AND kind <> 'BOOTSTRAP_REPO'` -- `docs/manual/05-docker.md` — herbevestig secret-boundary - -### Nieuwe sibling-repo (`~/Development/bootstrap-service/`) -``` -bootstrap-service/ -├─ package.json # deps: prisma, isomorphic-git, @octokit/rest, zod -├─ tsconfig.json -├─ env.ts # service-env Zod-schema -├─ bin/run.ts # daemon: LISTEN + claim-loop + lease-renewal -├─ src/ -│ ├─ claim.ts # tryClaim, releaseClaim, renewLease (lease_until) -│ ├─ runner.ts # executeRecipe(run, pat) -│ ├─ template-clone.ts # isomorphic-git clone --depth=1 --tag -│ ├─ github.ts # Octokit wrapper + isomorphic-git push (onAuth) -│ ├─ status-sync.ts # transactionele update + post-commit pg_notify -│ ├─ crypto/pat.ts # decrypt -│ ├─ telemetry.ts # log-helper met token-scrubbing -│ └─ stale-recovery.ts # lease_until < NOW recovery -├─ prisma/schema.prisma # gesynced -├─ packages/bootstrap-actions/ # shared package (zie boven) -├─ Dockerfile # multi-arch, Mac arm64 primary -├─ docker-compose.yml # default target arm64 lokale Mac dev -├─ sync-schema.sh # zelfde patroon als scrum4me-mcp -└─ README.md -``` - -**Deployment target** (volgt Scrum4Me-pattern uit memory: "Docker deploy = Mac default; scrum4me-docker arm64 native; NAS-flow opt-in"): -- `Dockerfile` start met `FROM --platform=$BUILDPLATFORM node:24-alpine` -- Build met `docker buildx build --platform linux/amd64,linux/arm64 --push` -- `docker-compose.yml` default target arm64 voor lokale Mac dev -- CI publiceert beide platforms naar GitHub Container Registry (`ghcr.io/madhura68/bootstrap-service`) -- Geen platform-specifieke deps — `isomorphic-git`, `@octokit/rest` zijn pure-JS; Prisma client multi-arch native -- NAS/Linux-deploy (Beelink server etc.) is opt-in via `--platform=linux/amd64` - -### ADR + docs (Scrum4Me) -- `docs/adr/0009-bootstrap-wizard.md` (Nygard, accepted) -- `docs/architecture/bootstrap-service.md` -- `docs/runbooks/bootstrap-wizard.md` — operatorpad incl. PAT-setup, troubleshooting, FAILED_NEEDS_CLEANUP handling -- `docs/INDEX.md` regenereren - -### Env + deployment docs -- `.env.example` (in Scrum4Me-repo) — voeg `BOOTSTRAP_ENCRYPTION_KEY` en `BOOTSTRAP_TEMPLATE_REPO` toe met instructies hoe te genereren (`openssl rand -base64 32`) -- `~/Development/bootstrap-service/.env.example` — `DATABASE_URL`, `DIRECT_URL`, `BOOTSTRAP_ENCRYPTION_KEY` (gedeeld met app) -- `~/Development/bootstrap-service/README.md` — setup-instructies (clone, sync-schema, npm install, env, npm run dev) -- `docs/manual/06-bootstrap-service.md` — deployment-runbook voor productie (Mac arm64 default, Linux opt-in) - -### Externe template-repo -- Apart traject: `madhura68/nextjs-baseline` v1.0.0 — geen wijzigingen in deze PR - ---- - -## Fasering - -**Sprint 1a — Contracten** -1. ADR-0009 -2. `JobConfig` discriminated union + tests -3. `scrum4me-docker/bin/run-one-job.ts` skip-filter + tests -4. Shared `bootstrap-actions` package scaffold (schema + handler-interfaces) -5. `lib/bootstrap/notify.ts` post-commit pg_notify helper + tests -6. **Schema-hash drift-check** (vendor-copy mitigatie): `scripts/check-bootstrap-actions-hash.sh` berekent `sha256` over `packages/bootstrap-actions/src/**` en vergelijkt met `~/Development/bootstrap-service/packages/bootstrap-actions/.hash`. CI faalt bij mismatch. Service logt bij startup geladen hash + `action_schema_version`. - -**Sprint 1b — Schema + seed + safety** -6. Prisma-modellen (incl. `@@map`) + migration (incl. partial unique index) -7. Action-handlers in shared package (idempotent, path-safe) -8. Seed met 6 core categorieën + minimale add-ons -9. Jobs-board enum-uitbreidingen - -**Sprint 1c — PAT + Dry-run** -10. `lib/crypto/pat.ts` (encrypt) + tests -11. `GitHubPatSettings` UI + `saveGitHubPatAction` (scope-detect + verify) -12. `previewBootstrapAction` + `lib/bootstrap/dry-run.ts` -13. Wizard: Configure + Preview steps - -**Sprint 1d — bootstrap-service + E2E** -14. Sibling-repo `bootstrap-service/` opzetten: package.json, env, sync-schema.sh, sync-bootstrap-actions.sh, Dockerfile -15. Claim-loop + LISTEN + lease-renewal (gebruikt bestaand `lease_until` + nieuw `claimed_by_worker_id`) -16. Execute-flow: isomorphic-git clone + recipe + Octokit createRepo + isomorphic-git push + side-effect checkpoints -17. Status-sync transactional + post-commit NOTIFY (lowercase status) -18. **Stale-recovery cron** `/api/cron/bootstrap-stale-recovery` met split-strategie (`FAILED` vs `FAILED_NEEDS_CLEANUP`) — MVP, niet fase-2 -19. **Service-startup logging**: print `action_schema_version`, schema-hash, en geladen catalog-version voor observability/drift-detectie -20. `BootstrapStatusPanel` realtime SSE -21. E2E: nieuw product → wizard → preview → run → SUCCEEDED → repo bestaat -22. **Drift-verificatie-stap**: CI-job die `check-bootstrap-actions-hash.sh` draait na elke bootstrap-service-image-build; release-pipeline faalt bij mismatch met Scrum4Me-bron - -**Fase 2**: -- Add-ons (sentry, web-push, realtime, demo-policy, MD3-theme) -- `RUN_BASH_TEMPLATE` met allowlist -- `condition` mini-DSL -- `retryBootstrapAction` + `cancelBootstrapAction` -- Admin-CRUD UI + catalog-publish met dry-run-gate -- Update-detection -- `FAILED_NEEDS_CLEANUP` admin-UI met handmatige "Cleanup orphan repo"-knop - -**Fase 3**: -- Sandbox-isolatie per run -- GitHub App (van per-user PAT naar app-installation) -- Multi-tenant cross-org owners -- GitHub-webhook back-channel - ---- - -## Verificatie - -```bash -# 1. Contracten -npm test -- lib/job-config -npm test -- lib/bootstrap/notify -npm test -- packages/bootstrap-actions/schema - -# 2. Schema (alle tables snake_case) -npx prisma migrate dev -npm run seed -psql "$DATABASE_URL" -c "\dt" # toont bootstrap_categories, bootstrap_options, bootstrap_actions, bootstrap_runs -psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM bootstrap_categories" # 7 -psql "$DATABASE_URL" -c "SELECT slug FROM bootstrap_categories WHERE is_required = true ORDER BY display_order" -# Expect: deploy, auth, database, ui-components, state-management, testing - -# 3. PAT -# - Settings → plak PAT met repo-scope → Test toont "✓ <username>" + scopes -psql "$DATABASE_URL" -c "SELECT length(github_pat_encrypted), github_pat_scopes FROM users WHERE id=X" -# Expect: lengte > 100; scopes = ['repo'] - -# 4. Dry-run -# - Wizard configure → Preview-stap → DryRunReport file-tree zichtbaar -# - Geen DB-row bij bootstrap_runs aangemaakt -# - Geen GitHub-write-call (alleen GET /repos/{owner}/{repo}) - -# 5. End-to-end -# Maak product 'ops-dashboard'; wizard alle 6 core; repo_owner=madhura68; preview groen; Run -# Service claimt binnen 2s; lease_until wordt verlengd; status SUCCEEDED in <60s -psql "$DATABASE_URL" -c " - SELECT br.status, - br.repo_url, - br.recipe_hash, - cj.lease_until > NOW() AS lease_active - FROM bootstrap_runs br - JOIN claude_jobs cj ON cj.id = br.claude_job_id - ORDER BY br.started_at DESC NULLS LAST, br.created_at DESC - LIMIT 1 -" -# NB: lease_until staat op claude_jobs, niet bootstrap_runs — JOIN nodig -psql "$DATABASE_URL" -c " - SELECT status, finished_at FROM claude_jobs - WHERE kind = 'BOOTSTRAP_REPO' ORDER BY created_at DESC LIMIT 1 -" -# Expect: DONE, finished_at gevuld -gh api repos/madhura68/ops-dashboard/contents/.scrum4me/bootstrap.json | jq . -# Expect: { template_version: 'v1.0.0', recipe_hash: ..., selected_options: {...} } -gh api repos/madhura68/ops-dashboard/contents/docs/adr | jq '.[].name' -# Expect: 0000…0006-... (alle 6 stubs) - -# 6. Failure -# Invalid PAT → bootstrap → FAILED met "Bad credentials" — geen orphan repo -psql "$DATABASE_URL" -c "SELECT status, error FROM bootstrap_runs WHERE status='FAILED' ORDER BY finished_at DESC LIMIT 1" - -# 7. Concurrency -# Twee gelijktijdige starts → één gaat door, andere unique violation -psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM bootstrap_runs WHERE product_id=X AND status IN ('PENDING','RUNNING')" -# Expect: 1 - -# 8. Demo-policy -# Login als demo → Bootstrap-knop niet zichtbaar; direct API call → 403 - -# 9. Lease-renewal -# Tijdens RUNNING-fase: verifieer dat lease_until > NOW + 30s -psql "$DATABASE_URL" -c "SELECT lease_until - NOW() FROM claude_jobs WHERE status='RUNNING'" - -# 10. Stale-recovery (Sprint 1d — niet fase-2) -# Kill bootstrap-service tijdens RUNNING; cron picks up na lease_until < NOW -psql "$DATABASE_URL" -c "SELECT status FROM claude_jobs WHERE id=X" -# Expect na cleanup: FAILED (geen GitHub side-effects) of FAILED_NEEDS_CLEANUP (repo al aangemaakt) - -# 11. Project-acceptatie -npm run verify && npm run build -``` - -**Lokaal dev**: -```bash -# In Scrum4Me/: -npm run dev - -# In ~/Development/bootstrap-service/ (apart terminal): -npm run dev # of `docker compose up bootstrap-service` -``` - ---- - -## Deployment prerequisites (vóór Sprint 1d) - -- `madhura68/nextjs-baseline` repo **must be public** en tag `v1.0.0` aanwezig (anders falen alle clone-ops) -- `BOOTSTRAP_ENCRYPTION_KEY` genereren: `openssl rand -base64 32` — **zelfde waarde** in Scrum4Me-app en bootstrap-service -- Prisma migration geapplied + seed gedraaid - ---- - -## Accepted risks (bewuste keuzes) - -- **PAT in JS function-closure**: `pat = ''` helpt niet bij GC van de originele immutable string — in Node.js geen betere optie zonder `Buffer`-gebruik. Accepted: PAT leeft kort (functie-scope), geen logging, geen serialisatie buiten de closure. -- **previewBootstrapAction als Server Action**: kan zware Vercel function zijn bij hoog volume — monitor in productie; migreer naar Route Handler als nodig. -- **Vendor-copy sync-drift**: als `ActionSchema` in Scrum4Me-repo wijzigt maar sync-script niet gedraaid is, silent mismatch in bootstrap-service. Mitigatie: CI-check in bootstrap-service die de schema-hash vergelijkt. - ---- - -## Open punten (post-MVP) - -- **Idempotency** voor APPEND_TO_FILE (marker-based) en RUN_BASH_TEMPLATE -- **Key rotation** voor `BOOTSTRAP_ENCRYPTION_KEY` (`v1:` prefix maakt versionering mogelijk) -- **PAT-expiry honoring** — UI-prompt als `github_pat_expires_at < now + 7d` -- **Fine-grained PAT support** — vereist andere scope-detection-logica (`x-accepted-github-permissions` of `GET /user/installations`); UI moet kunnen kiezen tussen classic en fine-grained -- **GitHub App migratie** — fase-3 alternative voor PAT (granular permissions, geen user-token-management) -- **Shared package upgrade** — van vendor-copy naar gepubliceerd GitHub Packages-package als derde consumer komt -- **`FAILED_NEEDS_CLEANUP` recovery flow** — admin-UI knop voor handmatige `octokit.repos.delete` of "mark as resolved" -- **Bootstrap-service unit-tests** — claim.ts, runner.ts, status-sync.ts hebben geen test-strategie beschreven; overweeg integration-tests met een lokale Postgres-instance -- **`ADD_DEPENDENCY.version` spec-broadening** — `npm-package-arg`-parser ondersteuning voor `latest`, prerelease (`^1.2.3-beta.1`), `workspace:*`, `npm:`-aliases, git/tarball specs; per-spec-type allowlist - ---- - -## Plan-locatie noot - -Bij implementatie-go: hernoem naar `docs/plans/M8-bootstrap-wizard.md` voor zichtbaarheid binnen Scrum4Me + MCP-Milestone-koppeling. diff --git a/docs/old/plans/M9-active-product-backlog.md b/docs/plans/M9-active-product-backlog.md similarity index 100% rename from docs/old/plans/M9-active-product-backlog.md rename to docs/plans/M9-active-product-backlog.md diff --git a/docs/plans/PBI-80-demo-prefs.md b/docs/plans/PBI-80-demo-prefs.md deleted file mode 100644 index f91a309..0000000 --- a/docs/plans/PBI-80-demo-prefs.md +++ /dev/null @@ -1,229 +0,0 @@ -# PBI-80 — Demo-gebruiker mag eigen UI-voorkeuren wijzigen - -> Stories: ST-1345, ST-1346, ST-1347, ST-1348 -> Branch: `feat/demo-prefs` -> Aangemaakt: 2026-05-11 - ---- - -## Context - -De demo-gebruiker (`username='demo'`, één gedeelde DB-rij met `is_demo=true`) zit nu -vast op het seed-default product en de seed-default sprint. Elke poging om te wisselen -of een filter te wijzigen geeft een 403-toast ("Niet beschikbaar in demo-modus"), wat -een potentiële klant geen goed beeld geeft van wat de app kan: hij kan niet door -producten bladeren, geen alternatieve sprint openen, geen filter ervaren. - -**Beperking.** Alle demo-bezoekers delen één DB-rij. Directe DB-persistentie van -demo-prefs zou cross-bezoeker-pollution geven (A's keuze zichtbaar voor B). Schrijven -naar `user.settings` voor de demo-rij is dus structureel onveilig. - -**Doel.** Demo mag binnen één browsertab zijn UI-context vrij wijzigen -(product, sprint, filters, layout). Geen DB-mutaties — alle wijzigingen sterven aan -het einde van de tab/refresh. De huidige three-layer beschermingen voor data-mutaties -blijven volledig intact. - -**Bestaande infra die we hergebruiken.** -- [stores/user-settings/store.ts:80](../../stores/user-settings/store.ts) — `setPref` - heeft al een demo-fork (lokale merge zonder server-call). -- [components/shared/user-settings-bridge.tsx:34](../../components/shared/user-settings-bridge.tsx) - — skipt SSE en server-sync voor demo. -- Filters/sort/layout/selecties lopen volledig via `useUserSettingsStore` — alleen - verifiëren dat de UI niets extra's vraagt aan de server. - ---- - -## Beslissingen - -| Onderwerp | Keuze | Implicatie | -|---|---|---| -| Persistentie | **In-memory** (Zustand) | Geen cookie, geen localStorage, geen DB. Refresh = reset. | -| Scope prefs | Filters/sort + layout (split-panes, collapsed PBIs, selecties) | Debug-mode en notificaties **buiten** scope. | -| Documentatie | ADR-0006 update + addendum | One-stop: lezer ziet uitzondering bij oorspronkelijke beslissing. | -| Server-actions | **Behouden 403 voor demo** | Defense in depth blijft intact. UI roept ze gewoon niet aan voor demo. | - ---- - -## Scope - -**Demo MAG (nieuw):** -- Wisselen van actief product (URL-navigatie + NavBar reflectie) -- Wisselen van actieve sprint binnen een product (URL-navigatie) -- Filters wijzigen (status, priority) op backlog en sprint-board — *werkt al* -- Sortering wijzigen (kolom-headers) — *werkt al* -- Collapse/expand van PBIs, selectie van actieve PBI/story/taak — *werkt al* -- Split-pane breedte verslepen — *werkt al* - -**Demo MAG NIET (ongewijzigd):** -- PBI/story/taak aanmaken, wijzigen, verwijderen, verplaatsen -- Sprints openen/sluiten, builden, archiveren -- Rollen toekennen of intrekken (`UserRole`) -- Accountgegevens wijzigen (username, password, email) -- QR-pairing, web-push abonnement, notificaties -- Debug-mode toggle -- Cron / webhook secrets - ---- - -## Architectuur - -**In-memory only.** Client-side `useUserSettingsStore` is de enige bron van -demo-state. Server-actions blijven 403 retourneren — die blokkade is geen bug maar -een veiligheidsnet voor het geval client-code per ongeluk een server-call doet. De UI -moet voor demo de server-call dus *gewoon overslaan*. - -**Product-switch (geen DB-write).** -- Vandaag: NavBar roept `setActiveProductAction(productId)` → 403 → toast. -- Nieuw: voor demo doet NavBar alleen `router.push('/products/X')`. Geen action. -- Server-render van layouts blijft `user.active_product_id` (de seed-default) lezen, - maar de NavBar leidt z'n weergegeven actieve product voor demo af uit `pathname`, - zodat label en highlight kloppen met waar je daadwerkelijk bent. - -**Sprint-switch (geen DB-write).** -- Vandaag: SprintSwitcher roept `setActiveSprintAction` → 403 → toast. -- Nieuw: voor demo doet de switcher alleen `router.push('/products/X/sprint/Y')`. De - sprint-pagina is `[sprintId]`-driven, dus de juiste sprint laadt zonder dat - `user.settings.layout.activeSprints[X]` ge-update hoeft te worden. - -**Filters / layout / selecties.** Ongewijzigd. Te verifiëren dat alle UI-componenten -`setPref` gebruiken (niet rechtstreeks een server-action of fetch). - ---- - -## Stories & taken - -### ST-1345 — SprintSwitcher demo-fork - -| Taak | Bestand | Beschrijving | -|---|---|---| -| T-950 | [components/shared/sprint-switcher.tsx](../../components/shared/sprint-switcher.tsx) | Lees `isDemo` uit store + fork `handleSwitchSprint` | -| T-951 | `__tests__/components/shared/sprint-switcher.test.tsx` | Vitest: demo vs niet-demo gedrag | - -### ST-1346 — NavBar demo-fork + URL-derived display - -| Taak | Bestand | Beschrijving | -|---|---|---| -| T-952 | [components/shared/nav-bar.tsx](../../components/shared/nav-bar.tsx) | Fork `handleSwitchProduct` voor demo | -| T-953 | [components/shared/nav-bar.tsx](../../components/shared/nav-bar.tsx) | URL-derived `displayActive` voor label + highlight | -| T-954 | `__tests__/components/shared/nav-bar.test.tsx` | Vitest: handler-fork + URL-derived display | - -### ST-1347 — ADR-0006 update + patroon-doc - -| Taak | Bestand | Beschrijving | -|---|---|---| -| T-955 | [docs/adr/0006-demo-user-three-layer-policy.md](../adr/0006-demo-user-three-layer-policy.md) | "Updated 2026-05-11"-sectie met uitzondering | -| T-956 | `docs/patterns/demo-client-state.md` (optioneel) | Patroon-doc + CLAUDE.md quickref-rij | - -### ST-1348 — Verificatie - -| Taak | Bestand | Beschrijving | -|---|---|---| -| T-957 | `__tests__/**` | Bestaande tests bijwerken die 403-toast voor demo verwachten | -| T-958 | n.v.t. | Browser-flow + DB-no-pollution + defense-in-depth + build | - ---- - -## Concrete code-wijzigingen (samengevat) - -### 1. SprintSwitcher fork - -[components/shared/sprint-switcher.tsx:54](../../components/shared/sprint-switcher.tsx): - -```tsx -const isDemo = useUserSettingsStore(s => s.context.isDemo) - -function handleSwitchSprint(sprintId: string) { - if (sprintId === activeSprint?.id) return - if (isDemo) { - router.push(`/products/${productId}/sprint/${sprintId}`) - return - } - startTransition(async () => { - const result = await setActiveSprintAction(productId, sprintId) - // ... bestaande logica - }) -} -``` - -### 2. NavBar fork + URL-derived display - -[components/shared/nav-bar.tsx:48](../../components/shared/nav-bar.tsx): - -```tsx -const pathname = usePathname() -const urlProductId = pathname.match(/^\/products\/([^/]+)/)?.[1] ?? null -const displayActive = isDemo && urlProductId - ? (products.find(p => p.id === urlProductId) ?? activeProduct) - : activeProduct - -function handleSwitchProduct(productId: string) { - if (productId === activeProduct?.id) return - if (isDemo) { - router.push(`/products/${productId}`) - return - } - startTransition(async () => { - const result = await setActiveProductAction(productId) - // ... bestaande logica - }) -} -``` - -In de render: vervang gebruik van `activeProduct` door `displayActive` in label, -highlight-class en onClick-equality-check. - -### 3. ADR-0006 addendum - -Nieuwe sectie **"Updated 2026-05-11 — Exception for client-side UI preferences"** -na "Consequences" — zie T-955 implementation_plan voor volledige tekst. - -### 4. Server-actions — *geen wijziging* - -Alle 403-guards blijven: -- [actions/active-product.ts:20](../../actions/active-product.ts) -- [actions/active-sprint.ts:24](../../actions/active-sprint.ts) -- [actions/user-settings.ts:28](../../actions/user-settings.ts) - ---- - -## Verificatie (end-to-end checklist) - -```bash -npm run verify && npm run build -``` - -**Functioneel — handmatig in browser (zie T-958):** - -1. Reset: `psql $DATABASE_URL -c "UPDATE \"User\" SET settings='{}'::jsonb, active_product_id=NULL WHERE username='demo';"` -2. Login als `demo`/`demo1234`. -3. `/dashboard` → producten zichtbaar → klik ander product → URL klopt → label klopt → géén toast. -4. Backlog → status/priority filter wijzigen → werkt direct → géén POST in Network-tab. -5. Sort op kolom → werkt direct. -6. Sprint-switcher → andere sprint → URL klopt → board laadt → géén toast. -7. Split-pane verslepen → blijft binnen sessie. -8. Hard refresh → defaults terug (verwacht in-memory). -9. Tweede tab → eigen state, geen kruisbestuiving. - -**Defense in depth:** - -10. DevTools console: `await fetch('/api/products', {method:'POST',body:'{}'})` → 403. -11. `grep -rn "session.isDemo" actions/` → alle write-actions houden hun guard. - -**DB-no-pollution:** - -12. `SELECT settings, active_product_id FROM "User" WHERE username='demo';` → `{}` en NULL. - -**Tests:** - -13. `npm test` → alle tests slagen, inclusief nieuwe NavBar/SprintSwitcher tests. - ---- - -## Risico's & mitigaties - -| Risico | Mitigatie | -|---|---| -| Toekomstige UI-code roept per ongeluk een write-action aan voor demo | Server-action 403 blijft + nieuwe `demo-client-state.md` patroon-doc + ADR-0006 update | -| Server-side render van NavBar toont seed-default `activeProduct` na product-switch | URL-derived `displayActive` (T-953) | -| `setActiveSprintInSettings()` in [lib/active-sprint.ts:51](../../lib/active-sprint.ts) heeft geen interne demo-check (huidige tech debt) | Buiten scope: alle bekende callers checken al `session.isDemo`. Eventueel apart op te pakken. | -| Demo verliest filterkeuze bij refresh | Acceptabel volgens vragenronde (in-memory gekozen). | diff --git a/docs/plans/PBI-84-code-binding-order.md b/docs/plans/PBI-84-code-binding-order.md deleted file mode 100644 index 6946dc8..0000000 --- a/docs/plans/PBI-84-code-binding-order.md +++ /dev/null @@ -1,164 +0,0 @@ -# Plan — `code` wordt bindende volgorde voor stories & taken; drag-and-drop eruit - -> **Status:** goedgekeurd 2026-05-14 · gematerialiseerd via Scrum4Me-MCP op de Ubuntu-DB. -> Sprint `S-2026-05-14-code-order` · **PBI-84** · Story **ST-1358** (IN_SPRINT) · Taken **T-992 t/m T-1001** (uitvoervolgorde = sort_order 1–10). -> -> _v3 — herzien na review (P0/P1/P2) + na onderzoek van het plan→onderdelen-mechanisme._ - -## Context - -Stories en taken zijn nu vrij herordenbaar via drag-and-drop (dnd-kit) in de backlog, het sprint-bord en het sprint-taakpaneel. Die drag schrijft een Float `sort_order` weg via reorder-acties. De volgorde is daardoor "los" en dubbel bepaald: `priority` (1–4) groepeert, `sort_order` sorteert daarbinnen. - -Gewenste situatie: de volgorde van stories en taken is **bindend gekoppeld aan hun `code`** (de bestaande `ST-NNN` / `T-N` identifier die al bij creatie wordt toegekend en al bijna overal zichtbaar is). Drag-and-drop verdwijnt. Het `code`-veld blijft bewerkbaar via de bestaande dialogs — dat is de bewuste, in-scope "aanpas-manier"; er komt géён vervangende herorden-UI. `priority` wordt herijkt als puur team-belang-label dat de bindende volgorde niet meer bepaalt. - -Netto: één voorspelbare volgorde = de code. De worker voert stories/taken uit in code-volgorde. - -## Beslissingen (uit grill-sessie + review) - -1. **Scope:** alleen Story + Taak. PBI's houden drag-and-drop én priority-groepering — volledig ongemoeid. -2. **Sorteersleutel:** `code` is de bindende volgorde voor stories/taken — default-weergave én worker-uitvoering. `priority` bepaalt de bindende volgorde nergens meer. **Uitzondering (P2):** in `components/backlog/story-panel.tsx` blijft een priority-sorteermodus als *niet-persistente leesweergave* (muteert niets, alleen een lens). -3. **Volgnummer = bestaande `code`.** Geen nieuw veld, geen schema-wijziging. -4. **Nummering:** product-breed met gaten (bv. `ST-001, ST-004` binnen één PBI) is prima. Geen wijziging aan code-generatie. -5. **`code` blijft bewerkbaar** via de bestaande dialog-inputs — ongemoeid. "Bindend" = volgorde volgt de code + geen drag meer. -6. **MCP in scope:** zowel de app als de externe `scrum4me-mcp`-repo worden bijgewerkt. -7. **Sprint-bord:** alleen herordenen verdwijnt (story-reorder + taak-reorder); backlog↔sprint membership-drag blijft. -8. **`priority` blijft bewerkbaar** via de edit-dialogs (label, geen bindende volgorde) — priority-afhandeling verandert verder niet. - -## Mechanisme: plan → onderdelen - -Een plan wordt op **twee manieren** omgezet in PBI + stories + taken; beide moeten voortaan code-volgorde respecteren. - -**A. Idea-materialisatie (app, UI-knop).** `actions/ideas.ts` → `materializeIdeaPlanAction` (~620-799). Leest `Idea.plan_md` — markdown met YAML-frontmatter (schema `lib/schemas/idea.ts` `ideaPlanMdFrontmatterSchema`, parser `lib/idea-plan-parser.ts` `parsePlanMd`). Frontmatter = `{ pbi, stories: [{ …, tasks: [...] }] }`; **volgorde = array-positie**, geen expliciet indexveld. De actie loopt `stories[]`/`tasks[]` in volgorde af in één transactie en kent codes toe via een inline product-brede teller (`nextStoryN++` / `nextTaskN++`). Omdat die teller monotoon meeloopt met de lus is **code-volgorde == plan-volgorde**; `sort_order = parseCodeNumber(code)` behoudt de plan-volgorde dus vanzelf. PBI-creatie hierin blijft ongemoeid (PBI buiten scope). - -**B. MCP-flow (Claude Code, ná plan-goedkeuring).** Runbook `docs/runbooks/plan-to-pbi-flow.md`: `create_sprint → create_pbi → create_story → create_task`, los aangeroepen. Elke `create_*`-tool genereert zelf de `code` (product-brede teller); volgorde = aanroep-volgorde → code-volgorde. Er is **geen** worker-job die een plan materialiseert (`IDEA_MAKE_PLAN` schrijft alleen `plan_md`). - -Beide paden zetten `sort_order` nu nog los van `code` — dat is precies wat §2 rechttrekt. - -## Aanpak - -### 1. Sorteersleutel: `sort_order` wordt numerieke spiegel van `code` - -`orderBy: { code }` kan niet (string-sortering breekt: `T-1, T-10, T-2`). De bestaande **`sort_order` Float-kolom** blijft daarom de interne numerieke sorteersleutel, maar wordt voortaan afgeleid van `code` i.p.v. via drag/membership gemuteerd. Geen schema-wijziging; `Pbi.sort_order` blijft een echte muteerbare Float. - -- Nieuwe pure helper **`parseCodeNumber(code: string): number`** in `lib/code.ts` (gedeeld, non-server): pakt de afsluitende cijferreeks (`/(\d+)$/`) en parse't naar int. Geen cijfers → grote fallback-constante (niet-conforme codes sorteren achteraan). -- Spiegel die helper in de MCP-repo (kleine bewuste duplicaat, conform `lib/job-config.ts`-patroon). -- Regel die overal geldt: **elke story/taak-create en elke `code`-edit zet `sort_order = parseCodeNumber(code)`**. Niets anders mag `Story.sort_order` / `Task.sort_order` schrijven. - -### 2. Creatie-paden — `sort_order = parseCodeNumber(code)` [P1: idea-flow + beide mechanisme-paden] - -Bereken `sort_order` ná het vaststellen van de definitieve `code`. **Eerst** `rg -n "\.(story|task)\.create" app/ actions/ scrum4me-mcp/src/` om de inventaris te bevestigen; bekende paden: - -- `actions/stories.ts` → `createStoryAction` (binnen `createWithCodeRetry`-callback). -- `actions/tasks.ts` → `saveTask` (create-tak) en `createTaskAction`. -- **`actions/ideas.ts` → `materializeIdeaPlanAction` (~723-756)** — pad A. Zet nu `code` via inline template-literal en `sort_order: si + 1` / `ti + 1`. Herstructureer: `const code = …` eerst, dan `sort_order: parseCodeNumber(code)` (= de tellerwaarde). Plan-volgorde blijft zo behouden. **PBI-creatie in deze actie niet aanraken.** -- MCP: `scrum4me-mcp/src/tools/create-story.ts` en `create-task.ts` — pad B. Vervang `(last?.sort_order ?? 0) + 1.0` door `parseCodeNumber(code)`. **Verwijder bovendien de `sort_order`-inputparameter** uit deze twee tool-schema's (anders kan een caller de code-binding omzeilen) en werk de tool-descriptions bij. **`create-pbi.ts` blijft ongemoeid** — PBI buiten scope, mag `sort_order`-input houden. - -### 3. Edit-paden — `code`-wijziging hersynchroniseert `sort_order` - -- `actions/stories.ts` → `updateStoryAction` en `actions/tasks.ts` → `saveTask` (update-tak): als `code` wijzigt, herbereken `sort_order = parseCodeNumber(nieuweCode)`. **`priority`-write blijft ongewijzigd.** -- MCP: bevestig met `rg` dat geen `update_*`-tool de `code` van een bestaande story/taak wijzigt; zo niet → geen MCP edit-werk. - -### 4. Sprint-membership ontkoppelen van `sort_order` [P0] - -Membership-acties mogen uitsluitend `sprint_id` + `status` schrijven, nooit `sort_order` — anders verliest een story z'n code-afgeleide volgorde zodra hij een sprint in/uit gaat. - -- `actions/sprints.ts` → `createSprintAction` (~regels 440-444): verwijder `sort_order: i + 1` uit de `story.update`-data. -- `actions/sprints.ts` → `addStoryToSprintAction` (~regel 541): verwijder `sort_order: (last?.sort_order ?? 0) + 1.0` (en de bijbehorende `last`-query). -- Géён wijziging nodig (raken `sort_order` al niet): `commitSprintMembershipAction`, `createSprintWithSelectionAction`, `createSprintWithPbisAction`, `removeStoryFromSprintAction`. - -### 5. Drag-and-drop & reorder verwijderen [P0: task-list.tsx toegevoegd] - -- Acties: verwijder `reorderStoriesAction` (`actions/stories.ts`), `reorderTasksAction` (`actions/tasks.ts`), `reorderSprintStoriesAction` (`actions/sprints.ts`). **Behoud** `reorderPbisAction` + `updatePbiPriorityAction` (PBI). -- REST: verwijder `app/api/stories/[id]/tasks/reorder/route.ts`. -- Backlog-UI: `components/backlog/story-panel.tsx` en `task-panel.tsx` — dnd-kit eruit (DndContext/SortableContext/handlers), platte lijst renderen. Priority-sorteermodus + priority-filter in `story-panel.tsx` blijven (zie beslissing 2). -- Sprint-bord: `components/sprint/sprint-board-client.tsx` + `sprint-backlog.tsx` — alleen de reorder-binnen-sprint-tak uit `handleDragEnd`/`handleReorder` halen; `addStoryToSprintAction`/`removeStoryFromSprintAction` (membership-drag) blijven. -- **Sprint-taakpaneel: `components/sprint/task-list.tsx`** — heeft volledige taak-reorder-DnD (`reorderTasksAction`-import, `DndContext`/`SortableContext`, `SortableTaskRow` met `useSortable`, `handleDragEnd` met `sprint-task-order` optimistic mutation). Verwijder alle DnD; `SortableTaskRow` wordt een platte rij. **Behoud** de click-based status-toggle (`handleStatusToggle` → `updateTaskStatusAction`) en de edit-actie — die zijn niet drag-gebaseerd. -- Stores: - - `stores/product-workspace/store.ts` — `story-order` en `task-order` mutatie-types/handlers eruit; **`pbi-order` blijft**. - - `stores/sprint-workspace/types.ts` — `OptimisticSprintTaskOrderMutation` (`sprint-task-order`) én `sprint-story-order` + de union-takken eruit. - - `stores/sprint-workspace/store.ts` — `rollbackMutation`-cases voor `sprint-task-order` en `sprint-story-order` eruit. -- `components/solo/solo-board.tsx`: **ongemoeid** — status-kanban (TO_DO/IN_PROGRESS/DONE), geen `sort_order`-herordening. - -### 6. Lees-queries — `priority` uit story/taak-ordering [P1: volledige inventaris] - -Story/taak-ordering wordt puur `sort_order` asc (eventueel met `created_at` als tiebreaker). **PBI-keys (`pbi.priority`, `pbi.sort_order`) blijven staan.** Begin met een `rg`-sweep, gebruik onderstaande geverifieerde lijst als minimum: - -``` -rg -n "orderBy|\.sort\(" app/ lib/ actions/ stores/ scrum4me-mcp/src/ | rg -i "priority" -``` - -**Story-ordering — `priority` laten vallen:** `app/api/products/[id]/backlog/route.ts:49` · `app/api/pbis/[id]/stories/route.ts:33` · `app/(app)/products/[id]/page.tsx:59` · `app/(mobile)/m/products/[id]/page.tsx:45` · `app/(app)/products/[id]/sprint/[sprintId]/page.tsx:131` · `app/api/products/[id]/claude-context/route.ts:61` · `app/api/products/[id]/next-story/route.ts:26` · `app/api/sprints/[id]/workspace/route.ts:47` · `actions/sprint-runs.ts:91` · `scrum4me-mcp/src/tools/get-claude-context.ts:66` · `scrum4me-mcp/src/tools/wait-for-job.ts:612` - -**Taak-ordering — `priority` laten vallen:** `app/(app)/products/[id]/page.tsx:86` · `app/(mobile)/m/products/[id]/page.tsx:72` · `app/(app)/products/[id]/sprint/[sprintId]/page.tsx:75` · `app/api/sprints/[id]/tasks/route.ts:29-32` (`story.sort_order` behouden) · `actions/sprint-runs.ts:88` + in-memory `.sort()` (~164-173: `a.priority - b.priority` voor story/taak weg, `a.pbi.*` behouden) · `lib/solo-workspace-server.ts:49-50` · `scrum4me-mcp/src/tools/get-claude-context.ts:76` · `scrum4me-mcp/src/tools/wait-for-job.ts:609` - -**Al compliant (taak al puur op `sort_order`) — niet aanraken:** `backlog/route.ts:66` · `claude-context/route.ts:64` · `next-story/route.ts:29` · `workspace/route.ts:55` - -**PBI-ordering — MOET blijven (priority behouden):** `backlog/route.ts:35` · `page.tsx:53` · `m/products/[id]/page.tsx:39` · `sprint/[sprintId]/page.tsx:128` - -**Stores:** `stores/sprint-workspace/store.ts` `compareStory`/`compareTask` — eventuele priority-tak verwijderen. - -### 7. `BacklogTask` krijgt `code` [P1] - -De directe kaart-aanpassing compileert niet zonder dit: - -- `stores/product-workspace/types.ts` (~28-37): voeg `code: string | null` toe aan `BacklogTask`. -- `app/api/stories/[id]/tasks/route.ts` (~34-43): `code: true` toevoegen aan de task-`select`. -- `app/api/products/[id]/backlog/route.ts` (~67-76): `code: true` toevoegen aan de task-`select`. -- Eventuele normalisatie die DB-task → `BacklogTask` mapt: `code` meenemen. - -### 8. Zichtbaarheid volgnummer - -Na stap 7: in `components/backlog/task-panel.tsx` `task.code` doorgeven aan `<BacklogCard code={...}>` (hergebruik bestaande `CodeBadge`). Overige views (backlog-story-kaart, sprint-rijen, sprint-taaklijst, solo-kaart, dialogs) tonen de code al. - -### 9. Backfill bestaande data - -Eenmalige Prisma-migratie (raw SQL): zet `stories.sort_order` en `tasks.sort_order` = numerieke staart van `code` (`CAST(SUBSTRING(code FROM '[0-9]+$') AS DOUBLE PRECISION)`, `COALESCE` voor niet-numerieke codes). Gevolg: bestaande stories/taken verspringen eenmalig naar code-volgorde — de bedoelde uitkomst. Niet draaien terwijl een PER_TASK-run actief is (SPRINT_BATCH is snapshot-beschermd via `SprintTaskExecution.order`). - -### 10. Docs & tests - -- `docs/patterns/sort-order.md` herschrijven: float-insertion geldt nog uitsluitend voor PBI; story/taak-`sort_order` is een afgeleide numerieke spiegel van `code`, nooit handmatig/membership-gemuteerd. -- **`docs/runbooks/plan-to-pbi-flow.md` herschrijven:** story/taak-volgorde = code-volgorde (= aanroep-volgorde), niet "priority dan call-order". -- **`lib/idea-prompts/make-plan.md` controleren:** verwijder/verbeter eventuele uitspraken die suggereren dat `priority` de uitvoervolgorde bepaalt (priority = label; array-volgorde = volgorde). -- `docs/api/rest-contract.md`: ordering-omschrijving voor stories/taken bijwerken (code-volgorde i.p.v. `[priority, sort_order]`). -- Nieuwe ADR in `docs/adr/`: "`code` is de bindende volgordesleutel voor stories/taken; priority is label". -- Tests: `__tests__/api/reorder.test.ts` verwijderen; `__tests__/actions/ideas-crud.test.ts` (~478-576, materialisatie) bijwerken; backlog-component-tests + `product-workspace`- en `sprint-workspace`-store-tests bijwerken (drag/`*-order`-mutaties weg); `sprint-tasks`/`next-story`/`sprint-runs`-tests: orderBy-verwachtingen bijwerken. Nieuw: `parseCodeNumber`-unit; create zet `sort_order = numeriek(code)` (incl. idea-materialisatie); code-edit hersynchroniseert; membership-actie laat `sort_order` ongemoeid. - -## Kritieke bestanden - -**App — wijzigen:** `lib/code.ts` (+`parseCodeNumber`), `actions/stories.ts`, `actions/tasks.ts`, `actions/sprints.ts`, `actions/ideas.ts`, `actions/sprint-runs.ts`, `lib/solo-workspace-server.ts`, `app/api/products/[id]/backlog/route.ts`, `app/api/pbis/[id]/stories/route.ts`, `app/api/products/[id]/claude-context/route.ts`, `app/api/products/[id]/next-story/route.ts`, `app/api/sprints/[id]/tasks/route.ts`, `app/api/sprints/[id]/workspace/route.ts`, `app/api/stories/[id]/tasks/route.ts`, `app/(app)/products/[id]/page.tsx`, `app/(mobile)/m/products/[id]/page.tsx`, `app/(app)/products/[id]/sprint/[sprintId]/page.tsx`, `components/backlog/story-panel.tsx`, `components/backlog/task-panel.tsx`, `components/sprint/sprint-board-client.tsx`, `components/sprint/sprint-backlog.tsx`, `components/sprint/task-list.tsx`, `stores/product-workspace/types.ts`, `stores/product-workspace/store.ts`, `stores/sprint-workspace/types.ts`, `stores/sprint-workspace/store.ts`. -**App — verwijderen:** `app/api/stories/[id]/tasks/reorder/route.ts`. -**MCP:** `scrum4me-mcp/src/tools/create-story.ts`, `create-task.ts` (sort_order-logica + input-param weg), `get-claude-context.ts`, `wait-for-job.ts` (+ kleine `parseCodeNumber`-duplicaat). **Niet:** `create-pbi.ts`. -**Hergebruiken:** `nextSequential`/`generateNext*Code` (`lib/code-server.ts`), `createWithCodeRetry`, `parsePlanMd` (`lib/idea-plan-parser.ts`), `CodeBadge`, `BacklogCard`. - -## Verificatie - -1. `npm run verify && npm run build` (lint + typecheck + test) — in app én MCP-repo. -2. Handmatig (dev-server): nieuwe story/taak (auto-code) landt op code-positie; `code` editen → herordent; `priority` editen → kleur wijzigt, bindende volgorde níét; priority-sorteermodus in story-panel werkt nog als tijdelijke lens; slepen is weg in backlog, sprint-bord én sprint-taaklijst; story toevoegen/verwijderen aan sprint via drag werkt nog en behoudt code-volgorde. -3. Worker-pad: `GET /api/products/:id/next-story` en `GET /api/sprints/:id/tasks` geven code-volgorde terug; MCP `get_claude_context` / `wait_for_job` idem. -4. Creatie: story/taak via app-actie, idea-materialisatie (`materializeIdeaPlanAction` — plan-volgorde behouden) én MCP → `sort_order` = numeriek(code). -5. Membership: story aan sprint toevoegen/verwijderen → `sort_order` ongewijzigd. -6. Backfill-migratie draaien → bestaande stories/taken in code-volgorde. - -## Open implementatie-details (niet-blokkerend) - -- `parseCodeNumber`: niet-numerieke code → grote fallback-constante; `created_at`/`id` als tiebreaker bij gelijke numerieke waarde. -- `app/api/sprints/[id]/tasks/route.ts` toont nu een afgeleide `${story.code}.${positie}` — optioneel vervangen door echte `task.code` (nu beschikbaar). -- `sort_order` blijft `Float` en behoudt zijn naam (Float→Int of hernoemen = extra migratie + MCP schema-resync; niet de moeite — wel duidelijk documenteren dat het veld voor story/taak een afgeleide is). -- Idea-materialisatie houdt zijn eigen inline code-teller (niet consolideren met `lib/code-server.ts` — buiten scope). - -## Taken-mapping (product Scrum4Me, Ubuntu-DB) - -| Taak | sort_order | Plan-§ | Repo | -|---|---|---|---| -| T-992 — `parseCodeNumber`-helper | 1 | §1 | app | -| T-993 — `code` op `BacklogTask`-type + queries | 2 | §7 | app | -| T-994 — create + edit: `sort_order` afgeleid van code | 3 | §2, §3 | app | -| T-995 — sprint-membership ontkoppelen van `sort_order` | 4 | §4 | app | -| T-996 — `priority` uit story/taak-orderings | 5 | §6 | app | -| T-997 — drag-and-drop & reorder verwijderen | 6 | §5 | app | -| T-998 — `code` op backlog-taakkaarten | 7 | §8 | app | -| T-999 — scrum4me-mcp: code-volgorde + priority eruit | 8 | §2, §6 | scrum4me-mcp | -| T-1000 — backfill-migratie | 9 | §9 | app | -| T-1001 — docs & tests | 10 | §10 | app | - -> De MCP-`create_story` `sprint_id`-koppeling (de "gap" uit de planfase) is **al** geïmplementeerd buiten deze story om, samen met de DB-pointer-fix (`~/.claude.json` → Ubuntu). Taak T-999 mag die wijziging niet terugdraaien. diff --git a/docs/plans/PBI-91-pb-screen-state.md b/docs/plans/PBI-91-pb-screen-state.md deleted file mode 100644 index 8144c95..0000000 --- a/docs/plans/PBI-91-pb-screen-state.md +++ /dev/null @@ -1,98 +0,0 @@ -# Plan — Expliciete schermstaat + draft-zichtbaarheid op de Product Backlog page - -> **Status:** goedgekeurd 2026-05-15 · gematerialiseerd via Scrum4Me-MCP. -> **PBI-91** · Story **ST-1369** (OPEN — in de productbacklog, geen sprint) · Taken **T-1033 t/m T-1037** (uitvoervolgorde = sort_order 1–5). -> -> Vervolg op **PBI-88** ("Product Backlog page workflow & states", PR #208) — implementeert 3 van de 4 niet-bindende aanbevelingen uit `docs/architecture/product-backlog-workflow.md`. G3 uitgesteld. - -## Context - -PBI-88 leverde een as-is/to-be analyse op: [docs/architecture/product-backlog-workflow.md](../architecture/product-backlog-workflow.md). Dat doc sluit af met vier **niet-bindende** aanbevelingen (G1, G3, G5, G6) als "input voor latere PBI's". - -Deze PBI implementeert er **drie** van. **G3** (expliciete ERROR-schermstaat) wordt **uitgesteld** — het doc zegt zelf "alleen oppakken als falende commits of SSE-verlies een echt UX-probleem blijken", en het is meer een ontwerpkeuze dan een heldere implementatie. - -De directe aanleiding voor PBI-88 was een **bug**: de concept-sprint (`pendingSprintDraft`) is niet zichtbaar op de SprintSwitcher-trigger-knop — alleen in de (disabled) dropdown. Dat is **G5** en wordt hier opgelost. - -**Scope:** -- **G1** — `deriveScreenState()`: één pure functie die de vandaag verspreide schermstaat-afleiding consolideert. -- **G5** — draft-status zichtbaar op de SprintSwitcher-trigger (de oorspronkelijke bug). -- **G6** — `NewSprintTrigger` achter een `isActiveProduct`-gate. -- **G3** — *uitgesteld*, vastgelegd als follow-up. - -## Aanpak - -### Nieuwe bestanden - -**`stores/product-workspace/screen-state.ts`** — pure module, géén React, spiegelt `selectors.ts`: - -```ts -export type ScreenState = - | { kind: 'NO_SPRINT' } - | { kind: 'DRAFT' } - | { kind: 'ACTIVE'; building: boolean } - | { kind: 'EDITING'; building: boolean } - -export interface ScreenStateInput { - activeSprintItem: { id: string } | null // SSR-prop uit page.tsx - buildingSprintIds: string[] // SSR-prop uit page.tsx - hasPendingDraft: boolean // user-settings store - pendingAdds: string[] // product-workspace store - pendingRemoves: string[] // product-workspace store -} - -export function deriveScreenState(i: ScreenStateInput): ScreenState { - if (i.hasPendingDraft) return { kind: 'DRAFT' } // draft wint van alles - if (i.activeSprintItem) { - const building = i.buildingSprintIds.includes(i.activeSprintItem.id) - const dirty = i.pendingAdds.length > 0 || i.pendingRemoves.length > 0 - return dirty ? { kind: 'EDITING', building } : { kind: 'ACTIVE', building } - } - return { kind: 'NO_SPRINT' } -} -``` - -Bewust **geen** `useScreenState`-hook: consumers roepen `deriveScreenState()` inline aan met store-slices + SSR-props. `ScreenStateInput` is daar precies voor ontworpen. Hook-extractie is "straks" als meer componenten meedoen — niet nu. `PRODUCT_NOT_ACTIVE` en `DEMO_MODE` blijven **buiten** `ScreenState` (gates, geen knopen — conform het doc). - -**`__tests__/stores/product-workspace/screen-state.test.ts`** — pure input→output tests, patroon van [__tests__/lib/product-switch-path.test.ts](../../__tests__/lib/product-switch-path.test.ts) (vitest, geen mocks). - -### Te wijzigen bestanden - -| Bestand | Wijziging | -|---|---| -| [components/shared/sprint-switcher.tsx](../../components/shared/sprint-switcher.tsx) | Leest `pendingAdds`/`pendingRemoves` uit `useProductWorkspaceStore`; `hasPendingDraft` = bestaand `draftGoal !== null` (regel 57). Roept `deriveScreenState()` aan. Trigger-label (regel 142-156) vertakt op `screenState.kind`: bij `DRAFT` toont de **trigger** `⚙ Concept — {draftGoal}` i.p.v. "Selecteer sprint"; status/BUILDING-badge verborgen in `DRAFT`. **(G1 + G5)** | -| [components/backlog/new-sprint-trigger.tsx](../../components/backlog/new-sprint-trigger.tsx) | `isActiveProduct: boolean` toevoegen aan props; `if (!isActiveProduct) return null` — spiegelt het bestaande `if (hasDraft) return null` patroon (regel 25). **(G6)** | -| [app/(app)/products/[id]/page.tsx](../../app/(app)/products/%5Bid%5D/page.tsx) | Regel 134: `isActiveProduct={isActiveProduct}` doorgeven aan `NewSprintTrigger` (`isActiveProduct` bestaat al op regel 49). **(G6)** | -| [__tests__/components/shared/sprint-switcher.test.tsx](../../__tests__/components/shared/sprint-switcher.test.tsx) | Uitbreiden: trigger toont "⚙ Concept" als er een draft is. **(G5)** | -| `__tests__/components/backlog/new-sprint-trigger.test.tsx` | Toevoegen of uitbreiden: component returnt `null` bij `isActiveProduct={false}`. **(G6)** | - -### Hergebruik (niets nieuws bouwen) - -- Pure-selector-patroon: `selectIsDirty` / `selectPendingCount` — [stores/product-workspace/selectors.ts:166](../../stores/product-workspace/selectors.ts) -- Bestaande `draftGoal`-selector — [components/shared/sprint-switcher.tsx:57](../../components/shared/sprint-switcher.tsx) -- Bestaande `buildingSet`-logica voor dropdown-items — [components/shared/sprint-switcher.tsx:51](../../components/shared/sprint-switcher.tsx) -- `sprintMembership.pending` shape `{ adds, removes }` — bestaande store-slice -- Test-patroon pure functie — [__tests__/lib/product-switch-path.test.ts](../../__tests__/lib/product-switch-path.test.ts) - -## Taken (Story ST-1369) - -| Code | Taak | Kern | -|---|---|---| -| T-1033 | `screen-state.ts` — `ScreenState` type + pure `deriveScreenState()` | Nieuw bestand, pure module | -| T-1034 | Unit tests `screen-state.test.ts` | 4 kinds + `building`-flag + precedence (draft wint) | -| T-1035 | `SprintSwitcher` op `deriveScreenState()` + G5: draft op de trigger-knop | G1-wiring + G5 | -| T-1036 | `NewSprintTrigger` achter `isActiveProduct`-gate (component + `page.tsx`) | G6 | -| T-1037 | Component-tests: sprint-switcher (G5) + new-sprint-trigger (G6) | Regressie-dekking | - -## Verificatie - -- `npm run verify` — lint + typecheck + test (de lokale gate; `npm run build` kan in een worktree falen op ontbrekende `DATABASE_URL`) -- `npm test -- screen-state` — de nieuwe pure-functie-tests geïsoleerd -- `npm run dev` + browser: - - **G5**: start een nieuwe sprint-draft → de SprintSwitcher-**trigger** toont "⚙ Concept — [goal]" (niet alleen de dropdown) - - **G5**: annuleer de draft → trigger valt terug op sprint-code / "Selecteer sprint" - - **G6**: open een **niet-actief** product → de "Nieuwe sprint"-knop is afwezig; activeer het product → knop verschijnt - - regressie: actieve sprint zonder draft toont gewoon code + status/BUILDING-badge - -## Uitgesteld (follow-up) - -**G3 — expliciete ERROR-schermstaat.** Vandaag: server-action-fout → `toast.error`, scherm blijft in huidige state. Reden uitstel: het doc adviseert dit alleen op te pakken als falende commits of SSE-verlies een aantoonbaar UX-probleem blijken; ERROR past bovendien niet natuurlijk in de pure `deriveScreenState()` (een fout is geen afgeleide van de input-flags). Vereist eerst een aparte ontwerpkeuze. diff --git a/docs/Ideas/ST-1114-copilot-reviews.md b/docs/plans/ST-1114-copilot-reviews.md similarity index 100% rename from docs/Ideas/ST-1114-copilot-reviews.md rename to docs/plans/ST-1114-copilot-reviews.md diff --git a/docs/old/plans/2026-04-27-claude-md-workflow-update.md b/docs/plans/archive/2026-04-27-claude-md-workflow-update.md similarity index 100% rename from docs/old/plans/2026-04-27-claude-md-workflow-update.md rename to docs/plans/archive/2026-04-27-claude-md-workflow-update.md diff --git a/docs/old/plans/2026-04-27-insert-milestone-tool.md b/docs/plans/archive/2026-04-27-insert-milestone-tool.md similarity index 100% rename from docs/old/plans/2026-04-27-insert-milestone-tool.md rename to docs/plans/archive/2026-04-27-insert-milestone-tool.md diff --git a/docs/old/plans/2026-04-27-m8-realtime-solo.md b/docs/plans/archive/2026-04-27-m8-realtime-solo.md similarity index 100% rename from docs/old/plans/2026-04-27-m8-realtime-solo.md rename to docs/plans/archive/2026-04-27-m8-realtime-solo.md diff --git a/docs/old/plans/docs-restructure-ai-lookup.md b/docs/plans/docs-restructure-ai-lookup.md similarity index 100% rename from docs/old/plans/docs-restructure-ai-lookup.md rename to docs/plans/docs-restructure-ai-lookup.md diff --git a/docs/plans/docs-restructure-pbi-spec.md b/docs/plans/docs-restructure-pbi-spec.md index 8c7edad..859827c 100644 --- a/docs/plans/docs-restructure-pbi-spec.md +++ b/docs/plans/docs-restructure-pbi-spec.md @@ -28,7 +28,7 @@ notes: | ## 1. Context (this becomes the PBI description) This PBI executes the docs-restructure plan -([`docs/old/plans/docs-restructure-ai-lookup.md`](../old/plans/docs-restructure-ai-lookup.md)) +([`docs/plans/docs-restructure-ai-lookup.md`](./docs-restructure-ai-lookup.md)) over eight phases, mapped here as eight stories with three to eight tasks each. The goal is to cut the documentation surface an AI agent has to read to find the right reference, without breaking existing workflows. @@ -81,7 +81,7 @@ in parallel with Stories 3–5 if you want. ### Where to look first - This file (the PBI context block above). -- [`docs/old/plans/docs-restructure-ai-lookup.md`](../old/plans/docs-restructure-ai-lookup.md) +- [`docs/plans/docs-restructure-ai-lookup.md`](./docs-restructure-ai-lookup.md) — the full plan, especially §3 (Goals), §4 (Target structure), §6 (Front-matter spec), §8 (Phased migration). - [`docs/adr/README.md`](../adr/README.md) — when writing an ADR in @@ -143,7 +143,7 @@ pbi: - One commit per logical layer (`docs(<story-slug>):` prefix). - No pushes without user approval. - Update every internal link in the same commit as a rename. - Read docs/old/plans/docs-restructure-ai-lookup.md §3, §4, §6, §8 first. + Read docs/plans/docs-restructure-ai-lookup.md §3, §4, §6, §8 first. priority: 2 stories: @@ -337,7 +337,7 @@ pbi: acceptance_criteria: | - docs/ root contains only INDEX.md and (later) glossary.md. - All existing docs moved into the right folder per - docs/old/plans/docs-restructure-ai-lookup.md §4. + docs/plans/docs-restructure-ai-lookup.md §4. - Internal links updated in the same commit as each move. - `npm run docs:index` shows docs grouped correctly. priority: 2 diff --git a/docs/plans/job-model-selection.md b/docs/plans/job-model-selection.md deleted file mode 100644 index e6786d9..0000000 --- a/docs/plans/job-model-selection.md +++ /dev/null @@ -1,152 +0,0 @@ -# Plan: model + mode-selectie per ClaudeJob-kind - -## Context - -`ClaudeJob` heeft 5 kinds (`TASK_IMPLEMENTATION`, `IDEA_GRILL`, `IDEA_MAKE_PLAN`, `PLAN_CHAT`, `SPRINT_IMPLEMENTATION`) maar er is **geen per-kind model-/mode-configuratie**. Alle jobs draaien op de Claude Code CLI default. `ClaudeJob.model_id` ([prisma/schema.prisma](../../prisma/schema.prisma)) wordt alleen post-hoc gevuld voor kostenberekening via [lib/insights/token-stats.ts](../../lib/insights/token-stats.ts) en `model_prices` (PBI-66). - -Probleem: een grill-sessie verdient meer thinking-budget en geen file-edits, terwijl een task-implementation acceptEdits/bypassPermissions in een worktree wil. Nu is dat allemaal hetzelfde — wat leidt tot: - -- Te dure runs (Opus voor triviale Haiku-waardige taken) -- Te schrale runs (Sonnet zonder thinking voor architectuurkeuze in `IDEA_MAKE_PLAN`) -- Geen cost-attribution per kind voor budgettering -- Geen product-level override voor klanten met eigen model-voorkeur - -**Doel:** een resolver die per `ClaudeJob` bepaalt: model, thinking-budget, permission-mode, max_turns, allowed_tools — gebaseerd op kind-defaults met overrides per product en per job. De worker geeft de geresolveerde config door aan Claude Code via CLI-flags. - -**Niet-doel:** de runtime vervangen door de Claude Agent SDK. Worker blijft Claude Code CLI; we bouwen erbovenop. - -## Aanpak - -### 1. Datamodel-uitbreiding - -| Tabel | Veld | Type | Doel | -|---|---|---|---| -| `Product` | `preferred_model` | `String?` | Product-brede default (bv. "alle taken op Sonnet voor budget") | -| `Product` | `thinking_budget_default` | `Int?` | Idem voor thinking | -| `Task` | `requires_opus` | `Boolean @default(false)` | Per-task escalatie (cross-file refactor) | -| `ClaudeJob` | `requested_model` | `String?` | Snapshot van resolved model (audit) | -| `ClaudeJob` | `requested_thinking_budget` | `Int?` | Snapshot van resolved budget | -| `ClaudeJob` | `requested_permission_mode` | `String?` | Snapshot van resolved mode | -| `ClaudeJob` | `actual_thinking_tokens` | `Int?` | Werkelijk verbruikte thinking-tokens (cost-attribution) | - -Migration is additief. Bestaande rijen krijgen `NULL` → resolver valt terug op kind-defaults. - -### 2. Centrale resolver - -**Locatie:** `scrum4me-mcp/src/lib/job-config.ts` (nieuw bestand in MCP-repo). - -```ts -type JobConfig = { - model: 'claude-opus-4-7' | 'claude-sonnet-4-6' | 'claude-haiku-4-5-20251001' - thinking_budget: number // 0 = uit - permission_mode: 'plan' | 'default' | 'acceptEdits' | 'bypassPermissions' - max_turns: number | null // null = onbegrensd - allowed_tools: string[] | null // null = alle -} - -function resolveJobConfig(job: ClaudeJob, product: Product, task?: Task): JobConfig -``` - -**Resolutie-volgorde** (eerste match wint): -1. `task.requires_opus === true` → forceer model = `claude-opus-4-7` -2. `job.requested_*` (al ingevuld door enqueue-laag) -3. `product.preferred_*` -4. **Kind-default** uit deze tabel: - -| Kind | Model | Thinking | Permission | max_turns | allowed_tools | -|---|---|---|---|---|---| -| `IDEA_GRILL` | sonnet-4-6 | 12000 | `plan` | 15 | Read, Grep, Glob, WebSearch, AskUserQuestion | -| `IDEA_MAKE_PLAN` | opus-4-7 | 24000 | `plan` | 20 | Read, Grep, Glob, WebSearch, AskUserQuestion, Write | -| `PLAN_CHAT` | sonnet-4-6 | 6000 | `plan` | 5 | Read, Grep, AskUserQuestion | -| `TASK_IMPLEMENTATION` | sonnet-4-6 | 6000 | `bypassPermissions` | 50 | null | -| `SPRINT_IMPLEMENTATION` | sonnet-4-6 | 6000 | `bypassPermissions` | null | null | - -`bypassPermissions` is verdedigbaar omdat task/sprint-implementatie altijd in een geïsoleerde git-worktree draait (zie [docs/runbooks/branch-and-commit.md](../runbooks/branch-and-commit.md)). Voor productie-omgevingen kan `Product.preferred_permission_mode = 'acceptEdits'` als opt-in. - -### 3. `wait_for_job` response uitbreiden - -Huidig: `wait_for_job` returnt `{ job_id, kind, context }`. Toevoegen: `config: JobConfig`. - -Worker leest `config` en spawnt Claude Code subprocess met: -``` -claude --model {config.model} \ - --permission-mode {config.permission_mode} \ - --thinking-budget {config.thinking_budget} \ - [--max-turns {config.max_turns}] \ - [--allowed-tools "{config.allowed_tools.join(',')}"] -``` - -Documentatie van vlaggen verwijst naar [Claude Code model-config](https://code.claude.com/docs/en/model-config). Als een vlag (nog) niet bestaat in de huidige CLI: skippen + log-warning, niet hardcrashen. - -### 4. Audit + cost-attribution - -Bij job-completion (in `update_job_status` MCP-tool): -- `actual_thinking_tokens` schrijven naar `ClaudeJob` (al beschikbaar in Claude Code result-payload) -- Bestaande `model_id`-update behouden (cost-berekening via `model_prices`) - -Token-stats-laag ([lib/insights/token-stats.ts](../../lib/insights/token-stats.ts)) uitbreiden: -- Aggregeren per kind (nu per dag/product) — feature-gate tot ST-N nodig -- Thinking-tokens apart tonen (andere prijs dan output-tokens) - -### 5. Documentatie - -- **Nieuw:** [docs/runbooks/job-model-selection.md](../runbooks/job-model-selection.md) — de matrix + wanneer je override gebruikt -- **CLAUDE.md** Hardstop-bullet: "Model/mode per ClaudeJob: kind-default → product → task — zie runbook" -- **Patterns quickref** in CLAUDE.md: regel toevoegen voor `job-config.ts` resolver-pattern - -## Voorgestelde PBI/story-breakdown - -Voor de Scrum4Me-MCP `create_pbi` / `create_story` / `create_task` ronde na goedkeuring: - -**PBI:** "Model + mode-selectie per ClaudeJob-kind" - -| Story | Doel | Tasks (indicatief) | -|---|---|---| -| **ST-1: Datamodel + migration** | Velden op Product/Task/ClaudeJob | Schema wijzigen · migration · Prisma generate · seed/factories updaten | -| **ST-2: Resolver in scrum4me-mcp** | `job-config.ts` met kind-defaults + override-cascade | Resolver-functie · unit tests per kind · export voor MCP-tools | -| **ST-3: `wait_for_job` integratie** | Config in response + snapshot in `requested_*` | Tool-output uitbreiden · enqueue-laag snapshot · worker-flag-passing documenteren | -| **ST-4: Audit + cost-attribution** | `actual_thinking_tokens` opslaan + tonen | `update_job_status` uitbreiden · token-stats per kind · admin/jobs UI-kolom | -| **ST-5: Documentatie** | Runbook + CLAUDE.md updates | runbook schrijven · CLAUDE.md hardstop · patterns-row | - -ST-1 → ST-2 → ST-3 zijn de kritieke pad-stories. ST-4 en ST-5 kunnen parallel met ST-3. - -## Bestanden - -| Bestand | Repo | Actie | -|---|---|---| -| `prisma/schema.prisma` | scrum4me | **Wijzigen** — 7 nieuwe velden | -| `prisma/migrations/<ts>_job_model_selection/` | scrum4me | **Nieuw** — additive migration | -| `scrum4me-mcp/src/lib/job-config.ts` | scrum4me-mcp | **Nieuw** — resolver | -| `scrum4me-mcp/src/lib/job-config.test.ts` | scrum4me-mcp | **Nieuw** — unit tests per kind | -| `scrum4me-mcp/src/tools/wait-for-job.ts` | scrum4me-mcp | **Wijzigen** — config in response | -| `scrum4me-mcp/src/tools/update-job-status.ts` | scrum4me-mcp | **Wijzigen** — `actual_thinking_tokens` | -| `lib/insights/token-stats.ts` | scrum4me | **Wijzigen** — per-kind aggregatie + thinking-prijs | -| `actions/admin/jobs.ts` + UI-kolom | scrum4me | **Wijzigen** — model/mode tonen | -| `docs/runbooks/job-model-selection.md` | scrum4me | **Nieuw** — runbook | -| `CLAUDE.md` | scrum4me | **Wijzigen** — hardstop-bullet + patterns-row | - -## Verificatie - -Per story: -- **ST-1:** `npm run verify` slaagt na schema-wijziging; migration runt clean op test-DB -- **ST-2:** unit tests dekken alle 5 kinds × 4 cascade-niveaus (default/product/job/task) -- **ST-3:** integratietest: enqueue een `IDEA_GRILL` met product-override → `wait_for_job` returnt config met override toegepast -- **ST-4:** end-to-end: run een dummy-job, verifieer dat `actual_thinking_tokens` ingevuld wordt en dat token-stats het kostbedrag correct rekent (input + output + thinking-input rate) -- **ST-5:** `npm run docs:check-links` groen; CLAUDE.md ≤ 150 regels - -End-to-end-validatie van het geheel: -1. Maak een nieuw idee → `IDEA_GRILL`-job → controleer dat de worker met `--permission-mode plan` en `--thinking-budget 12000` start -2. Approve het idee → `IDEA_MAKE_PLAN`-job → controleer Opus-aanroep met thinking 24000 -3. Sprint starten → `SPRINT_IMPLEMENTATION` met `bypassPermissions` in worktree -4. Admin-jobs-pagina toont per job het gebruikte model + thinking-tokens - -## Vastgelegde beslissingen (review-uitkomst) - -1. **`bypassPermissions` als default voor implement-kinds** (TASK_IMPLEMENTATION, SPRINT_IMPLEMENTATION). Verdedigbaar door git-worktree-isolatie. `Product.preferred_permission_mode` blijft beschikbaar als opt-in voor productie-product -2. **Opus-cost-controle = per-task** via `Task.requires_opus`-flag. Géén product-budget, géén automatische Opus-escalatie. Ad-hoc beslissing per taak -3. **`PLAN_CHAT` runtime bevestigd: Claude Code CLI** — `wait_for_job` (`scrum4me-mcp/src/tools/wait-for-job.ts:386`) selecteert `IDEA_GRILL`, `IDEA_MAKE_PLAN` én `PLAN_CHAT` uit dezelfde queue. Resolver past 1:1, geen aparte runtime-route -4. **`wait_for_job`-response: pure additief** (geen `protocol_version`-veld). Worker negeert onbekende velden veilig; mismatch is operationeel zichtbaar via `model_id` in token-stats. Geen multi-tenant fleet → geen versioning-overhead nodig - ---- - -Bij goedkeuring: PBI + 5 stories + ~20 tasks aanmaken via `mcp__scrum4me__create_pbi/story/task`. Volgorde: ST-1 → ST-2 → ST-3 → (ST-4 ‖ ST-5). diff --git a/docs/plans/landing-local-first.md b/docs/plans/landing-local-first.md deleted file mode 100644 index 8a6e7c5..0000000 --- a/docs/plans/landing-local-first.md +++ /dev/null @@ -1,224 +0,0 @@ ---- -title: "Landing v2 — lokaal & veilig + architectuurdiagram" -status: active -audience: [maintainer, contributor] -language: nl -last_updated: 2026-05-03 -applies_to: [SCRUM4ME] -story_id: cmoq2qoik0001qa175iynfnaa -pbi_id: cmoq2q50s0000qa174rmrjove -archived: true -archived_reason: niet-uitgevoerd, uit standaard sessiecontext gehouden -archived_at: 2026-05-11 ---- - -# Landing v2 — lokaal & veilig + architectuurdiagram - -**Story:** Als bezoeker van de landingspagina wil ik direct begrijpen dat Scrum4Me's unieke propositie is dat code-uitvoering lokaal blijft op mijn eigen hardware, zodat ik weet of dit product bij mijn werkwijze past. - -**Branch:** `feat/landing-local-first` - -## Context - -Sinds de laatste landingspagina-update (commit `feat(landing): highlight realtime updates and beta/desktop-first notice`, 2026-04-27, app v0.4.0) zijn meerdere milestones afgerond of in uitvoering die niet op de pagina staan: - -- **M5** — Todo's -- **M8** — Realtime Solo Paneel (Postgres LISTEN/NOTIFY + SSE) -- **M9** — Active Product selectie -- **M10** — QR-pairing passwordless login -- **M11** — Claude question channel -- **M13** — Claude job queue + worker mode -- **MCP-server** in losse repo `madhura68/scrum4me-mcp` met 18 tools - -Belangrijker: het echte unieke aspect — dat **code-uitvoering lokaal blijft op de developer's eigen hardware** — staat nergens prominent op de huidige pagina. De Vercel-app + Neon-DB zijn een coördinatielaag voor metadata; klantcode draait nooit in de cloud. Dat is dé propositie en moet bovenaan staan, ondersteund door een architectuurdiagram dat GitHub + Neon Postgres + Vercel + Lokale worker in samenhang toont. - -**Genuanceerde claim (geen overpromise).** In Neon staan wél: `Task.implementation_plan`, `StoryLog.content/commit_message`, `ClaudeJob.plan_snapshot/summary/error`, `ClaudeQuestion.question` — vrije-tekstvelden die agents/users zelf invullen. Wat **niet** in de cloud belandt: broncodebestanden, diffs/patches, build-artefacten. De hero claimt dit voorzichtig ("executie + code blijven aan jouw kant"); de architectuur-sectie legt per box uit wat er wél staat. - -## Doelgroep - -Mix met zwaartepunt op (a) privacy-bewuste indie devs en (c) kleine teams met homelab/NAS. Compliance-publiek (b) wordt niet expliciet aangesproken (geen SOC2-claims), maar ook niet uitgesloten. - -## Aanpak — sectievolgorde - -| # | Sectie | Wijziging | -|---|---|---| -| 1 | Header | ongewijzigd | -| 2 | Hero | herschreven (B+Z, zie hieronder) | -| 3 | **Architectuur** | **nieuw** — twee-zone mermaid-diagram + 4 callouts | -| 4 | Tour (screenshots) | ongewijzigd | -| 5 | Wat is Scrum4Me? | 6 kaarten (set C, zie hieronder) | -| 6 | **Quickstart: lokale agent in 3 stappen** | **vervangt** "Twee manieren om Claude te koppelen" | -| 7 | Scrum in Scrum4Me | ongewijzigd | -| 8 | Gebruikershandleiding | uitgebreid naar 10 stappen, MCP als hoofdroute | -| 9 | REST API | ongewijzigd | -| 10 | Footer | + link naar `madhura68/scrum4me-mcp` | - -LIVE-callout onder de feature-grid vervalt (gaat op in feature-card "Realtime updates"). - -## Sectie-detail - -### §2 Hero - -- **H1**: *"Plannen in de cloud. Uitvoeren op je eigen machine."* -- **Subhead**: *"De UI draait op Vercel, je code draait op jou. Een gedeelde job-queue laat lokale Claude Code agents (laptop, NAS of VM) stories autonoom oppakken — zonder dat je broncode ooit de cloud hoeft te raken."* -- **CTA's**: *"Account aanmaken"* (primary) · *"Hoe het werkt"* (secondary, anchor naar `#architectuur`). De oude *"Demo bekijken"* vervalt; demo-credentials staan al in de body-tekst eronder. -- **Beta-notice blok**: ongewijzigd. - -### §3 Architectuur (nieuw) - -**Diagrambron**: `docs/diagrams/architecture.mmd` (mermaid). Twee zones via `subgraph`: - -```mermaid -flowchart LR - User([Jij in je browser]):::user - - subgraph Scrum["Scrum4Me-stack (managed)"] - direction TB - Vercel["Vercel<br/>UI · Server Actions · cron"] - Neon[("Neon Postgres<br/>metadata · jobs · logs")] - Vercel <-->|Prisma + SSE| Neon - end - - subgraph Yours["Jouw kant (lokaal)"] - direction TB - Worker["Lokale worker<br/>laptop / NAS / VM<br/>Claude Code + MCP"] - GitHub[("GitHub<br/>jouw repo")] - Worker -->|git push| GitHub - end - - User -->|HTTPS| Vercel - Neon <-.->|job claim<br/>+ LISTEN/NOTIFY| Worker - - classDef user fill:none,stroke-dasharray:3 3 -``` - -**Renderpijp**: -- Bron in `docs/diagrams/architecture.mmd`. -- Output naar `public/diagrams/architecture-light.svg` + `architecture-dark.svg` (gecommit in git). -- Nieuw npm-script: `"diagrams": "mmdc -i docs/diagrams/architecture.mmd -t default -b transparent -o public/diagrams/architecture-light.svg && mmdc -i docs/diagrams/architecture.mmd -t dark -b transparent -o public/diagrams/architecture-dark.svg"`. -- **Geen** `prebuild`-hook — handmatig draaien bij wijziging. -- In `page.tsx`: twee `<Image>`-tags, één met `className="dark:hidden"` en één met `className="hidden dark:block"`. - -**Onder het diagram**, 4 callout-cards "Wat draait waar?": - -1. **Vercel** — alleen UI, Server Actions en cron. Geen sourcecode, geen build-artefacten. -2. **Neon Postgres** — Scrum-metadata, plan-tekstvelden, logs en commit-hashes. Geen volledige diffs, geen broncodebestanden. -3. **Lokale worker** — jouw machine. Claude Code via stdio-MCP, claimt jobs (`FOR UPDATE SKIP LOCKED`), executeert lokaal, commit lokaal, push lokaal. Multi-worker (laptop + NAS) parallel veilig. -4. **GitHub** — jouw eigen repo. Scrum4Me kent alleen de `repo_url`-string en commit-hashes uit logs. - -### §5 Feature-kaarten (6 stuks, set C) - -1. **Hiërarchisch plannen** — Product → PBI → Story → Taak. -2. **Sprint Board + Solo Paneel** — twee weergaven van dezelfde data: team-bord en persoonlijk Kanban. -3. **Lokale Claude-agents** — job-queue, "Voer uit"-knop, atomic claim, multi-worker. -4. **Realtime updates** — SSE + Postgres LISTEN/NOTIFY; UI binnen 1–2s in sync (vervangt LIVE-callout). -5. **Async vraagkanaal** — Claude vraagt input via bel-icoon zodra plan-ambiguïteit optreedt. -6. **Todo's** — lichtgewicht notities los van sprint-hiërarchie. - -QR-login en Active Product selection krijgen géén kaart (QR-login wel genoemd in handleiding stap 1). - -### §6 Quickstart (vervangt "Twee manieren") - -Activerende sectie met code-snippet: - -```bash -# 1. Clone en installeer de MCP-server -git clone https://github.com/madhura68/scrum4me-mcp -cd scrum4me-mcp && npm install - -# 2. Voeg toe aan Claude Code config (zie repo-README) -# 3. Start Claude Code en vraag: -# "pak de volgende job uit de Scrum4Me-queue" -``` - -Daarnaast korte tekst-bullet: *"Liever zonder MCP? Gebruik de REST API met een Bearer-token (zie API-overzicht hieronder)."* - -### §8 Handleiding — 10 stappen - -1. Account aanmaken (+ bijzin: *"of paar je telefoon één keer en log voortaan in via QR"*) -2. Product aanmaken -3. Product Backlog opbouwen -4. Sprint starten -5. Sprint Board -6. Solo Paneel -7. **API-token aanmaken** (nodig voor MCP én REST) -8. **Claude Code koppelen** — installeer `scrum4me-mcp` (aanbevolen) of gebruik REST API -9. **Story laten uitvoeren** — klik "Voer uit", lokale agent pakt 'm op via `wait_for_job`, commit + status-update gaan automatisch; NavBar toont actieve workers -10. Sprint afronden - -### §10 Footer - -Voeg link toe naar `https://github.com/madhura68/scrum4me-mcp` naast bestaande Scrum4Me-repo-link. Geen drie-kolommen-layout. - -## Bestanden - -**Wijzigen:** -- `app/page.tsx` — alle sectie-aanpassingen -- `package.json` — nieuwe `diagrams`-script - -**Nieuw:** -- `docs/diagrams/architecture.mmd` — mermaid-bron -- `public/diagrams/architecture-light.svg` — gegenereerd -- `public/diagrams/architecture-dark.svg` — gegenereerd - -**Referentie (geen edits):** -- `docs/runbooks/mcp-integration.md` -- `docs/architecture/claude-question-channel.md` -- `docs/plans/ST-1111-claude-job-trigger.md` - -## Wat we niet doen - -- Geen losse `/architecture`-route — diagram blijft op homepage. -- Geen extracted components onder `components/landing/`. -- Geen runtime-mermaid (zou ~350KB bundle-impact zijn). -- Geen `prebuild`-hook. -- Geen Engelse vertaling. -- Geen wijziging aan `theme.css` of shadcn-config. -- Geen LIVE-callout meer onder feature-grid. -- Geen drie CTA's in hero. - -## Verificatie - -```bash -npm install -npm run diagrams -npm run dev # http://localhost:3000 in 1024px+ -``` - -Visueel checken: -- Hero leest *"Plannen in de cloud. Uitvoeren op je eigen machine."* -- Architectuur-sectie toont diagram met twee zones; in dark-mode switcht automatisch. -- Anchor-link `#architectuur` werkt vanuit hero-CTA. -- Feature-grid toont 6 kaarten in `lg:grid-cols-3` over 2 rijen. -- Quickstart-codeblock leesbaar en kopieerbaar. -- Handleiding heeft 10 stappen, MCP staat in stap 8 als aanbevolen. -- Footer toont MCP-repo én Scrum4Me-repo. -- Demo-credentials nog steeds zichtbaar onder hero-CTA's. - -```bash -npm run lint && npm test && npm run build -``` - -Daarna pas commit. Niet pushen zonder bevestiging (CLAUDE.md hardstop). - -## Grilling-uitkomsten (samenvatting) - -15 vragen via `/grill-me` op 2026-05-03 hebben volgende beslissingen vastgelegd: - -| # | Onderwerp | Beslissing | -|---|---|---| -| 1 | Veiligheidsclaim | Genuanceerd: hero zacht, architectuur-sectie eerlijk over plan-tekstvelden in DB | -| 2 | Doelgroep | Mix (D), zwaartepunt op privacy-bewuste indie + homelab-team | -| 3 | Diagram-nodes | 4 boxen + label *"Lokale worker (laptop / NAS / VM)"* | -| 4 | Renderkeuze | Mermaid via mmdc (al in repo) | -| 5 | Diagram-zonering | Twee subgraphs (Scrum4Me-stack vs Jouw kant) | -| 6 | Embedding | `<Image>` x2, light + dark SVG, switch via Tailwind | -| 7 | Hero-copy | H1 "Plannen in de cloud. Uitvoeren op je eigen machine." + subhead Z | -| 8 | Sectievolgorde | Architectuur op #3, Tour op #4 | -| 9 | Feature-cards | Set C: 6 stuks, Sprint+Solo gecombineerd, Todo's erbij | -| 10 | Handleiding | 10 stappen, MCP als hoofdroute, "Voer uit" als stap 9 | -| 11 | Diagram-bron | `docs/diagrams/architecture.mmd`, output gecommit | -| 12 | Twee manieren → Quickstart | Vervangen door code-snippet | -| 13 | Hero-CTA's | "Account aanmaken" + "Hoe het werkt" (anchor) | -| 14 | Plan-bestand | Story aangemaakt, plan in `docs/plans/landing-local-first.md` | -| 15 | Beta + LIVE + Footer | Beta-notice behouden, LIVE-callout schrappen, één extra footer-link | diff --git a/docs/plans/landing-v3-idea-flow.md b/docs/plans/landing-v3-idea-flow.md deleted file mode 100644 index 69a2072..0000000 --- a/docs/plans/landing-v3-idea-flow.md +++ /dev/null @@ -1,164 +0,0 @@ ---- -title: "Landing v3 — van idee tot pull request" -status: active -audience: [maintainer, contributor] -language: nl -last_updated: 2026-05-04 -applies_to: [SCRUM4ME] -story_id: cmot8226500017h174z5qpphx -story_code: ST-1224 -pbi_id: cmoq2q50s0000qa174rmrjove -archived: true -archived_reason: niet-uitgevoerd, uit standaard sessiecontext gehouden -archived_at: 2026-05-11 ---- - -# Landing v3 — van idee tot pull request - -**Story:** [ST-1224](../../docs/INDEX.md). Vervolg op Landing v2 (lokaal-first, PR #72). - -**Branch:** `feat/landing-new-screenshots` — gestart als screenshot-update, uitgebreid met de volledige v3-rewrite. - -## Context - -Sinds Landing v2 (2026-05-03, commit `4ff50cb`, op main via merge `b47f629`) is M12 — *Ideas* — geland en is het **kernconcept** van het product geworden: - -- Nieuw `/ideas`-dashboard per user. Idea staat **boven** Product/PBI in de hiërarchie en is de manier waarop nieuw werk binnenkomt. -- State-machine: `DRAFT → GRILLING → GRILLED → PLANNING → PLAN_READY → PLANNED` (+ `GRILL_FAILED`, `PLAN_FAILED` recovery-paden). -- Twee nieuwe `ClaudeJobKind`'s: `IDEA_GRILL` (Claude stelt kritische vragen via `ask_user_question`-loop) en `IDEA_MAKE_PLAN` (Claude genereert YAML-plan, geen vragen). Naast bestaande `TASK_IMPLEMENTATION`. -- `materializeIdeaPlanAction` is een atomaire transformatie: `plan_md` (YAML) → 1 PBI + N stories + M tasks. -- Job-flow voor IDEA_GRILL gebruikt het bestaande **async vraagkanaal**. - -Daarnaast (kleiner maar zichtbaar): -- **Auto-PR + auto-merge (SQUASH)**: na `update_job_status('done')` op de laatste task pusht de worker automatisch en opent/mergt een PR. -- **Sync-tab**: realtime overzicht van Story-status / `ClaudeJob.pushed_at` / `Pbi.pr_url` / `pr_merged_at` per Idea. -- **Deploy-controle**: labels `skip-deploy` / `force-deploy` overrulen path-filter. - -User's eigen formulering (relevant voor toon): *"het nadenken over een idee, plannen en dan laten uitvoeren. idee na idee kan omgezet worden in acties met resultaten"*. - -## Doelgroep - -Ongewijzigd t.o.v. v2 (mix met zwaartepunt op privacy-bewuste indie devs en homelab-teams). De Idea-laag versterkt vooral de aantrekkingskracht voor solo-devs die *"niet aan een ticket-fabriek willen"*. - -## Doel - -1. **Hero verbreden** van "executie lokaal" naar volledige cyclus: idee → grill → plan → execute → PR. -2. **Nieuwe sectie #3 "Van idee tot PR"** vóór de architectuur-sectie — toont de procesflow (4 stappen). -3. **Architectuur-diagram lichtjes uitbreiden** met de Idea-job-kinds in de Worker-box. -4. **Feature-grid herschikken**: vervang "Hiërarchisch plannen" door "Ideas — Grill & Plan"; werk auto-PR in als bullet onder "Lokale Claude-agents". -5. **Handleiding uitbreiden** van 10 → 12 stappen met de Idea-route ervoor. -6. **Quickstart** ongewijzigd; één regel toevoegen over UI-route (`/ideas`). -7. **Tour** al gedaan (commit 6ce12df) — 6 echte screenshots in plaats van 3. - -## Sectievolgorde - -| # | Sectie | Wijziging | -|---|---|---| -| 1 | Header | ongewijzigd | -| 2 | Hero | rewrite — H1 + subhead verbreden | -| 3 | **Van idee tot PR** | nieuw — 4-stappen procesflow | -| 4 | Architectuur | diagram regenereren; callout-card "Lokale worker" tekst aanvullen | -| 5 | Tour (screenshots) | **klaar** (commit 6ce12df) — 6 figures | -| 6 | Wat is Scrum4Me? | feature-grid herschikt (set D) | -| 7 | Quickstart | + één regel UI-route | -| 8 | Scrum in Scrum4Me | + 2 termen (Idea, Grill/Plan) + twee-rij-hiërarchie | -| 9 | Gebruikershandleiding | 10 → 12 stappen (Idea-route ervoor) | -| 10 | REST API | ongewijzigd | -| 11 | Footer | ongewijzigd | - -## Sectie-detail - -### §2 Hero — rewrite - -- **H1**: *"Van idee tot pull request — op je eigen hardware."* -- **Subhead** (~45 woorden): *"Leg een idee vast, laat Claude het kritisch bevragen, accepteer het plan en zet het door een lokale agent uit. Code, executie en agents draaien op je eigen machine; alleen metadata loopt via Vercel + Neon. Idee na idee, automatisch omgezet in commits en pull requests."* -- **CTA's** ongewijzigd: *"Account aanmaken"* + *"Hoe het werkt"* (anchor `#architectuur`). - -### §3 Van idee tot PR (nieuw) - -Tussen Hero en Architectuur. Procesflow als 4 horizontaal geschakelde kaarten: - -``` -[1. Idee] → [2. Grill] → [3. Plan] → [4. Execute] -DRAFT GRILLING PLAN_READY DONE → PR -``` - -Implementatie: `grid grid-cols-1 md:grid-cols-4 gap-4` met tussen kaarten een `→` op `hidden md:flex`. MD3-tokens. Status-chips matchen de PATCH/POST-badges in de API-sectie. - -Onder de 4 kaarten één paragraaf van ~3 zinnen die de state-machine, `materializeIdeaPlanAction` en auto-PR samenvat. - -### §4 Architectuur — diagram regenereren - -`docs/diagrams/architecture.mmd` Worker-label uitbreiden: - -``` -Worker["Lokale worker -laptop / NAS / VM -Claude Code + MCP -jobs: GRILL · PLAN · IMPL"] -``` - -Daarna `npm run diagrams` om beide SVG's te regenereren. - -Callout-card "Lokale worker" in `app/page.tsx` krijgt aanvullende zin: *"Doet drie soorten jobs: bevragen van een idee (GRILL), plan-generatie (PLAN), taak-implementatie (IMPL). Allemaal op dezelfde machine."* - -### §6 Feature-grid — set D (6 kaarten) - -Vervang **"Hiërarchisch plannen"** door **"Ideas — Grill & Plan"** (nieuwe entry-point): - -1. **Ideas — Grill & Plan** *(nieuw)* -2. Sprint Board + Solo Paneel -3. **Lokale Claude-agents** *(uitbreiden — auto-PR)* -4. **Realtime updates** *(uitbreiden — Sync-tab)* -5. Async vraagkanaal *(uitbreiden — Grill-vragen)* -6. Todo's - -### §7 Quickstart — kleine aanvulling - -Onder de bestaande code-block één regel: *"Liever in de UI beginnen? Open `/ideas`, druk op 'Nieuw idee' en klik 'Grill me' — de eerste vraag verschijnt binnen seconden in je belicoon."* - -### §8 Scrum in Scrum4Me — terminologie + hiërarchie - -Twee tegels toevoegen aan terminologie-grid: **Idea** en **Grill / Plan**. - -Hiërarchie-rij wordt twee-rij-systeem: -- Bovenste rij: één tegel "Idea (DRAFT → GRILLED → PLAN_READY)" met "materialiseert ↓" pijl -- Onderste rij: bestaande Product → PBI → Story → Taak - -### §9 Handleiding — 10 → 12 stappen - -1. Account aanmaken (+ QR-bijzin) -2. Product aanmaken -3. **Een idee vastleggen** *(nieuw)* -4. **Laat Claude grillen** *(nieuw)* -5. **Maak het plan + materialiseer** *(nieuw)* -6. Product Backlog finetunen *(was: opbouwen)* -7. Sprint starten -8. Sprint Board -9. Solo Paneel -10. Claude Code koppelen *(token + MCP gecombineerd)* -11. **Voer uit + Sync-tab volgen** -12. Sprint afronden - -Stappen 3-5 markeren met visueel accent (`border-l-4 border-primary` of chip "Idea-route"). - -## Bestanden - -**Wijzigen:** -- `app/page.tsx` — Hero, nieuwe §3, callout in §4, feature-grid (§6), Quickstart-regel (§7), terminologie + hiërarchie (§8), handleiding (§9) -- `docs/diagrams/architecture.mmd` — Worker-label uitbreiden -- `public/diagrams/architecture-light.svg` + `architecture-dark.svg` — regenereren - -**Klaar (commit 6ce12df):** -- `public/screenshots/*` — 6 nieuwe screenshots vervangen oude 3 -- Tour-array in `app/page.tsx` - -## Verificatie - -```bash -npm run diagrams # regenereer SVG's -npm run dev # http://localhost:3000 op 1024px+ -npm run lint && npm test && npm run build -``` - -Niet pushen zonder bevestiging (CLAUDE.md hardstop). diff --git a/docs/plans/load-render-improvement-plan-2026-05-10.md b/docs/plans/load-render-improvement-plan-2026-05-10.md deleted file mode 100644 index a47c665..0000000 --- a/docs/plans/load-render-improvement-plan-2026-05-10.md +++ /dev/null @@ -1,201 +0,0 @@ ---- -title: "Verbeterplan load/render Product Backlog, Sprint en Solo" -date: 2026-05-10 -status: draft -scope: ["Product Backlog", "Sprint", "Solo", "workspace stores", "realtime resync"] -source_review: "../recommendations/load-render-implementation-review-2026-05-10.md" -chosen_solo_option: "5B - Solo workspace-store migratie" ---- - -# Verbeterplan load/render Product Backlog, Sprint en Solo - -## Doel - -Maak de load/render-flow van Product Backlog, Sprint en Solo voorspelbaar, gelijkvormig en goedkoper: - -- geen dubbele initial loads; -- een expliciet status-contract tussen server, API, stores en UI; -- consistente hydration/resync na reconnect, tab-visible en refresh; -- minder stale render state in Solo; -- duidelijke store-eigenaarschap per scherm. - -## Uitgangspunten - -- De route/API-boundary mag lowercase API-statussen blijven gebruiken. -- De interne Product/Sprint workspace UI verwacht nu story/task statussen als DB `UPPER_SNAKE`. -- Product Backlog en Sprint zijn de referentie voor het gewenste patroon: server snapshot, client hydration wrapper, workspace-store, SSE, directe store-resync. -- Solo hoeft niet in dezelfde grote store te worden gemigreerd in de eerste stap, maar zijn refresh-hydration moet wel correct worden. - -## Fase 1 - Status-contract vastleggen en afdwingen - -### Stap 1.1 - Leg het interne contract vast - -Besluit en documenteer: - -- PBI-status in Product Backlog blijft API-lowercase zolang `PBI_STATUS_LABELS` en `PBI_STATUS_COLORS` daarop gebouwd zijn. -- Story-status en task-status in Product/Sprint workspace-stores zijn intern `UPPER_SNAKE`. -- API routes blijven lowercase teruggeven aan externe/REST clients. - -### Stap 1.2 - Voeg adapters toe aan de workspace-store boundary - -Maak kleine adapterfuncties voor API-responses voordat data in stores wordt gehydrateerd: - -- Product workspace: - - full backlog snapshot; - - PBI stories; - - story tasks; - - task detail. -- Sprint workspace: - - sprint workspace snapshot; - - story tasks; - - task detail. - -Gebruik bestaande mappers uit `lib/task-status.ts`, bijvoorbeeld `storyStatusFromApi` en `taskStatusFromApi`. - -### Stap 1.3 - Voeg regressietests toe - -Test minimaal: - -- API lowercase `todo` wordt in task UI-store `TO_DO`; -- API lowercase `in_sprint` wordt in story UI-store `IN_SPRINT`; -- bestaande PBI lowercase status blijft lowercase; -- Sprint `STATUS_CYCLE` krijgt nooit lowercase input vanuit de store. - -## Fase 2 - Dubbele Product Backlog load verwijderen - -### Stap 2.1 - Maak hydration eigenaar van de initial backlog snapshot - -Pas Product Backlog aan naar hetzelfde eigenaarschap als Sprint: - -- `BacklogHydrationWrapper` hydrateert snapshot; -- wrapper zet ook `context.activeProduct`; -- wrapper markeert `loadedProductId`; -- `SetCurrentProduct` start op routes met eigen hydration geen full `ensureProductLoaded`. - -### Stap 2.2 - Guard `setActiveProduct` - -Voeg een guard toe zodat `setActiveProduct(product)` geen `ensureProductLoaded` start als: - -- hetzelfde product al actief is; -- `loading.loadedProductId === product.id`; -- er al een volledige snapshot gehydrateerd is. - -### Stap 2.3 - Meet en verifieer - -Controleer in devtools/server logs: - -- openen van Product Backlog doet geen extra `/api/products/:id/backlog` na de server-render; -- navigeren tussen product routes laadt nog steeds correct; -- restore hints voor laatste PBI/story/task blijven werken. - -## Fase 3 - Sprint selectie gelijkvormig maken - -### Stap 3.1 - Verplaats geselecteerde story naar de sprint workspace-store - -Vervang lokale `selectedStoryId` in `SprintBoardClient` door: - -- `useSprintWorkspaceStore((s) => s.context.activeStoryId)`; -- `useSprintWorkspaceStore.getState().setActiveStory(storyId)`; -- reset via `setActiveStory(null)` bij verwijderen uit sprint. - -### Stap 3.2 - Laat `TaskList` active-context gebruiken - -Maak `TaskList` gelijkvormig met Product Backlog: - -- lees taken via `selectTasksForActiveStory`; -- behoud `storyId` alleen als fallback of verwijder de prop; -- zorg dat `resyncActiveScopes` nu de actieve story/task werkelijk kan meenemen. - -### Stap 3.3 - Restore-hints testen - -Verifieer: - -- story-selectie blijft behouden na refresh/reconnect; -- task-paneel toont dezelfde story na tab-visible resync; -- verwijderen van de actieve story reset taakpaneel netjes. - -## Fase 4 - Solo refresh-hydration correct maken - -### Stap 4.1 - Vervang task-id-only dependency - -Vervang `taskKey = initialTasks.map(t => t.id).join(',')` door een render-relevante fingerprint, bijvoorbeeld: - -- `id`; -- `status`; -- `sort_order`; -- `title`; -- `implementation_plan`; -- `story_id`; -- `story_title`; -- `story_code`; -- `task_code`; -- relevante verify/queue velden. - -Of hydrateer op iedere nieuwe `initialTasks` prop als performance acceptabel is. - -### Stap 4.2 - Sync unassigned stories uit props - -Voeg een effect toe die `unassignedStories` bijwerkt wanneer `initialUnassigned` inhoudelijk wijzigt. - -### Stap 4.3 - Sorteer solo kolommen expliciet - -Render `columnTasks` gesorteerd op `sort_order` en daarna stabiel op code/titel/id. Vertrouw niet op object insertion order. - -### Stap 4.4 - Test gemiste event scenario's - -Test: - -- tab hidden, task status wijzigt extern, tab visible: kaart staat in juiste kolom; -- reconnect met dezelfde task ids maar gewijzigde titel/status: UI update; -- nieuwe unassigned story verschijnt na refresh; -- gewijzigde `sort_order` past de render-volgorde aan. - -## Fase 5 - Solo naar een gelijkvormig workspace-store patroon - -Gekozen route: **Optie B**. Solo wordt naar een workspace-store patroon gemigreerd dat aansluit op Product Backlog en Sprint. - -### Optie B - Grote stap - -Migreer Solo naar een workspace-store patroon vergelijkbaar met Product/Sprint: - -- normalized entities; -- active sprint/product context; -- loaded scopes; -- resync methods; -- realtime event adapters. - -Concrete taken: - -- Introduceer `stores/solo-workspace/{types,selectors,store}.ts`. -- Introduceer een `SoloHydrationWrapper` die server snapshot en actieve context hydrateert. -- Laat `SoloBoard` renderen vanuit selectors in de solo workspace-store. -- Verplaats realtime event handling en job/worker status naar de solo workspace-store. -- Vervang `router.refresh()` als primaire resync door `resyncActiveScopes`. -- Houd route refresh alleen over als expliciete fallback voor onbekende events of navigatiecases. - -## Fase 6 - Observability en performance check - -Voeg tijdelijk of permanent meetpunten toe: - -- log of dev-only counter voor hydration calls per scherm; -- log of dev-only counter voor API `ensure*Loaded` calls; -- React Profiler rond Product Backlog/Sprint/Solo pane containers; -- netwerkcheck op dubbele fetches. - -Acceptatiecriteria: - -- Product Backlog doet bij eerste openen maximaal een server snapshot plus SSE connect, geen extra full-backlog client fetch. -- Product en Sprint stores bevatten geen lowercase story/task statussen. -- Solo refresh verwerkt bestaande tasks met gewijzigde velden. -- Product Backlog, Sprint en Solo hebben per scherm precies een duidelijke eigenaar voor initial hydration. - -## Voorgestelde implementatievolgorde - -1. Status adapters en tests toevoegen. -2. Product Backlog dubbele load verwijderen. -3. Sprint active story selectie naar store verplaatsen. -4. Solo workspace-store introduceren en hydrateren. -5. Solo realtime/resync naar workspace-store verplaatsen. -6. Performance/netwerk verifiëren. - -Deze volgorde beperkt risico: eerst het data-contract, daarna de extra load, daarna gelijkvormigheid en Solo-resync. diff --git a/docs/plans/queue-loop-extraction.md b/docs/plans/queue-loop-extraction.md deleted file mode 100644 index 1ec71de..0000000 --- a/docs/plans/queue-loop-extraction.md +++ /dev/null @@ -1,349 +0,0 @@ -# Queue-loop verplaatsen van Claude naar runner - -## Context - -Vandaag draait `scrum4me-docker/bin/run-agent.sh` één lange `claude -p`-sessie met de seed-prompt _"draai queue leeg"_. Claude roept zelf herhaaldelijk `wait_for_job` aan binnen die ene CLI-invocation. Het probleem: alle jobs in zo'n run gebruiken dezelfde CLI-flags — terwijl PBI-67 (`lib/job-config.ts`) per job een ander model, permission-mode, thinking-budget en allowed-tools voorschrijft. Een `IDEA_MAKE_PLAN` job moet met Opus + plan-mode draaien, een `TASK_IMPLEMENTATION` met Sonnet + bypassPermissions; binnen één Claude-proces is die switch niet te maken. - -Doel: **één Claude-invocation per geclaimde job**. De runner (buiten Claude) claimt, leest `job.config`, bouwt de juiste CLI-flags, spawnt `claude -p` voor precies die ene job, wacht op exit, claimt de volgende. Claude zelf doet niet meer aan claim-management. - -## Kritische CLI-correctie (vóór alles) - -`claude --version` op het host-systeem en in de Docker base = **2.1.132**. Geverifieerde flag-set: - -| Beschreven in runbook | Bestaat | Vervanging | -|---|---|---| -| `--thinking-budget <int>` | ❌ | `--effort {low,medium,high,xhigh,max}` | -| `--max-turns <int>` | ❌ | géén equivalent (gebruik `--max-budget-usd` als budget-cap of laat cosmetisch) | -| `--model`, `--permission-mode`, `--allowedTools`, `--mcp-config`, `--output-format` | ✅ | ongewijzigd | - -`docs/runbooks/worker-idempotency.md:113-150` documenteert flags die niet bestaan. PBI-67-velden `thinking_budget` en `max_turns` zijn vandaag al cosmetisch (de seed-prompt-loop geeft ze ook niet door). Deze refactor is het natuurlijke moment om dat goed te zetten. - -**Beslissing**: voeg `mapBudgetToEffort(budget: number): string | null` toe in beide `job-config.ts`-spiegels: -- `0` → `null` (flag niet meegeven) -- `1..6000` → `"medium"` -- `6001..12000` → `"high"` -- `12001..24000` → `"xhigh"` -- `>24000` → `"max"` - -`max_turns` blijft audit-only — comment in `KIND_DEFAULTS` toevoegen, runner negeert het. - -## Architectuur in één pagina - -``` -run-agent.sh (daemon, backoff, health, log-rotation, token-expiry detectie) - └── tsx /opt/agent/bin/run-one-job.ts ← één iteratie = één job - ├── 1. quota-probe (was Claude's verantwoordelijkheid) - ├── 2. resetStaleClaimedJobs(userId) - ├── 3. tryClaimJob(userId, tokenId) - │ └── null? LISTEN scrum4me_changes met deadline 270s; bij timeout → exit 0 - ├── 4. getFullJobContext(jobId) ← public export uit scrum4me-mcp - ├── 5. attachWorktreeToJob (alleen TASK_IMPLEMENTATION) - ├── 6. Schrijf payload → /tmp/job-<id>/payload.json - ├── 7. Bouw CLI-args uit config + map effort - ├── 8. setInterval(60s) lease-renewal ← alleen SPRINT_IMPLEMENTATION - ├── 9. spawnSync('claude', [...]); cwd = worktree_path - ├── 10. try/finally rollbackClaim + releaseLocksOnTerminal als spawn faalt - ├── 11. clearInterval; await prisma.$disconnect() - └── 12. exit met claude's code (3 = TOKEN_EXPIRED) -``` - -Claude zelf: -- krijgt **geen** `wait_for_job` in `--allowedTools` — vangrail tegen recursieve claims. -- krijgt **geen** "draai queue leeg"-prompt meer — per kind een eigen prompt-template. -- doet alleen job-uitvoering: tool-calls voor logging, status-updates, verify, en `update_job_status` aan het einde. - -## Hoe `run-one-job.ts` aan jobId + config komt - -Vier stappen, allemaal binnen het Node-proces — geen aparte CLI-call, geen MCP-stdio-roundtrip. - -### 1. Wie ben ik (auth) - -```ts -import { getAuth } from '/opt/scrum4me-mcp/src/auth.js' -const { userId, tokenId } = await getAuth() -``` - -`getAuth()` (scrum4me-mcp/src/auth.ts:11-40) hashed `process.env.SCRUM4ME_TOKEN` (SHA-256), zoekt in `ApiToken` op `token_hash`, en returnt `{ userId, tokenId, username, isDemo }`. Token komt uit Docker compose `.env`. - -### 2. Welke job (claim) - -```ts -import { tryClaimJob, resetStaleClaimedJobs } from '/opt/scrum4me-mcp/src/tools/wait-for-job.js' - -await resetStaleClaimedJobs(userId) // requeu/FAIL stale CLAIMED-jobs (lease verlopen) -const jobId: string | null = await tryClaimJob(userId, tokenId) -``` - -`tryClaimJob` (scrum4me-mcp/src/tools/wait-for-job.ts:358-447) doet één atomic transactie: -1. `SELECT cj.id FROM claude_jobs cj LEFT JOIN tasks t ... LEFT JOIN sprint_runs sr ... WHERE user_id = $userId AND status = 'QUEUED' AND (kind IN idea-kinds OR (kind IN task/sprint AND sprint_run.status IN QUEUED|RUNNING)) ORDER BY created_at ASC LIMIT 1 FOR UPDATE OF cj SKIP LOCKED` -2. `UPDATE claude_jobs SET status='CLAIMED', claimed_by_token_id=$tokenId, claimed_at=NOW(), plan_snapshot=..., lease_until=NOW()+INTERVAL '5 minutes' WHERE id=$jobId` -3. Optioneel: SprintRun QUEUED → RUNNING bij eerste claim. - -`FOR UPDATE SKIP LOCKED` garandeert dat parallelle workers nooit dezelfde job pakken — concurrency-safety op DB-niveau. - -Bij `null`: `LISTEN scrum4me_changes` met deadline 270s; bij notify op `claude_job_enqueued`-event opnieuw `tryClaimJob`. Bij timeout exit 0 (run-agent.sh sleept 2s en herstart). - -### 3. Welke config (resolve op claim-moment) - -```ts -import { getFullJobContext } from '/opt/scrum4me-mcp/src/tools/wait-for-job.js' // export-fix in Fase 1 -const ctx = await getFullJobContext(jobId) -``` - -`getFullJobContext` (scrum4me-mcp/src/tools/wait-for-job.ts:449-788) doet **één Prisma-findUnique met joins** (task → story → pbi/sprint, idea, product met `preferred_*`-velden), en roept dan `resolveJobConfig(...)` aan: - -```ts -const config = resolveJobConfig( - { - kind: job.kind, - requested_model: job.requested_model, // snapshot bij enqueue - requested_thinking_budget: job.requested_thinking_budget, - requested_permission_mode: job.requested_permission_mode, - }, - { - preferred_model: job.product.preferred_model, // product-override - thinking_budget_default: job.product.thinking_budget_default, - preferred_permission_mode: job.product.preferred_permission_mode, - }, - job.task ? { requires_opus: job.task.requires_opus } : undefined, -) -``` - -`resolveJobConfig` ([lib/job-config.ts:97-124](../../lib/job-config.ts)) past de override-cascade toe (eerste match wint): -1. `task.requires_opus === true` → `model = 'claude-opus-4-7'` -2. `job.requested_*` (al ingevuld bij enqueue door [lib/job-config-snapshot.ts](../../lib/job-config-snapshot.ts) in de Next.js webapp) -3. `product.preferred_*` -4. `KIND_DEFAULTS[kind]` - -Resultaat zit in `ctx.config`: -```ts -ctx.config = { - model: 'claude-sonnet-4-6', - thinking_budget: 6000, - permission_mode: 'bypassPermissions', - max_turns: 50, // audit-only, geen CLI flag - allowed_tools: ['Read','Edit','Write','Bash','Grep','Glob','mcp__scrum4me__update_task_status', ...], -} -``` - -Plus de kind-specifieke velden: `task`, `story`, `pbi`, `sprint`, `idea`, `product`, `worktree_path`, `branch_name`, `task_executions[]` (sprint), `prompt_text` (idea). - -### 4. Bouw CLI-args en spawn - -```ts -import { mapBudgetToEffort } from '/opt/scrum4me-mcp/src/lib/job-config.js' -import { getKindPromptText } from '/opt/scrum4me-mcp/src/lib/kind-prompts.js' - -const promptText = getKindPromptText(ctx.kind).replace('$PAYLOAD_PATH', payloadPath) - -const args = [ - '-p', promptText, - '--model', ctx.config.model, - '--permission-mode', ctx.config.permission_mode, - '--allowedTools', ctx.config.allowed_tools.join(','), - '--mcp-config', '/opt/agent/mcp-config.json', - '--add-dir', '/opt/agent', - '--output-format', 'text', -] -const effort = mapBudgetToEffort(ctx.config.thinking_budget) -if (effort) args.push('--effort', effort) - -spawnSync('claude', args, { - stdio: 'inherit', - cwd: ctx.worktree_path ?? ctx.primary_worktree_path ?? '/opt/agent', -}) -``` - -### Twee resolver-passages: bewust ontwerp - -``` -[Webapp enqueue] [Runner claim] -actions/createJob tryClaimJob(jobId) - ↓ ↓ -snapshotFromConfig (lib/job-config-snapshot.ts) - getFullJobContext - ↓ - resolveJobConfig - (leest requested_* terug) - ↓ ↓ -DB: claude_jobs.requested_* ctx.config → CLI flags -``` - -De **enqueue-resolver** legt de keuze vast als snapshot in `ClaudeJob.requested_*` (audittrail). De **claim-resolver** leest die snapshot terug — als een operator handmatig `requested_model` heeft gewijzigd tussen enqueue en claim (bv. "ad-hoc Opus voor deze ene story"), wint die wijziging. Bewust ontwerp. - -## Implementatie — 3 fases, 3 PR's - -### Fase 1 — `scrum4me-mcp` (publieke API + prompts + KIND_DEFAULTS) - -**Bestanden:** -- `scrum4me-mcp/src/tools/wait-for-job.ts` — regel 449 `async function getFullJobContext` → `export async function getFullJobContext`. Niets anders aan de body wijzigen. -- `scrum4me-mcp/src/lib/idea-prompts.ts` → hernoemen naar `src/lib/kind-prompts.ts`. Nieuwe export `getKindPromptText(kind: ClaudeJobKind): string` met cases voor alle vijf kinds. Behoud `getIdeaPromptText` als re-export voor back-compat (wait-for-job.ts roept 'm aan). -- `scrum4me-mcp/src/lib/job-config.ts`: - - Voeg `mapBudgetToEffort(budget: number): string | null` toe. - - Update `KIND_DEFAULTS`: - - `TASK_IMPLEMENTATION.allowed_tools` = expliciete lijst zonder `wait_for_job`/`check_queue_empty`/`get_idea_context`. Inhoud: `['Read','Edit','Write','Bash','Grep','Glob', 'mcp__scrum4me__get_claude_context','mcp__scrum4me__update_task_status','mcp__scrum4me__update_task_plan','mcp__scrum4me__log_implementation','mcp__scrum4me__log_test_result','mcp__scrum4me__log_commit','mcp__scrum4me__verify_task_against_plan','mcp__scrum4me__update_job_status','mcp__scrum4me__ask_user_question','mcp__scrum4me__get_question_answer','mcp__scrum4me__list_open_questions','mcp__scrum4me__cancel_question','mcp__scrum4me__worker_heartbeat']` - - `SPRINT_IMPLEMENTATION.allowed_tools` = bovenstaande + `mcp__scrum4me__update_task_execution`, `mcp__scrum4me__verify_sprint_task`. **Géén** `mcp__scrum4me__job_heartbeat` — de runner verlengt de lease (zie Fase 2). Claude hoeft hier niet aan te denken. - - `IDEA_GRILL.allowed_tools` = bestaand + `mcp__scrum4me__update_idea_grill_md`, `mcp__scrum4me__log_idea_decision`, `mcp__scrum4me__update_job_status`, `mcp__scrum4me__ask_user_question`, `mcp__scrum4me__get_question_answer`. - - `IDEA_MAKE_PLAN.allowed_tools` = bestaand + `mcp__scrum4me__update_idea_plan_md`, `mcp__scrum4me__log_idea_decision`, `mcp__scrum4me__update_job_status`. - - Comment toevoegen: `max_turns` is audit-only (Claude CLI 2.1.x mist `--max-turns`). `thinking_budget` mapt via `mapBudgetToEffort`. -- **Nieuwe prompts** in `src/prompts/`: - - `task/implementation.md` — single-task workflow, payload via `$PAYLOAD_PATH`, expliciet géén `wait_for_job`, instructie voor verify-gate + `update_job_status` aan het einde. - - `sprint/implementation.md` — sprint-workflow, Claude verwerkt `task_executions[]` sequentieel. Géén heartbeat-instructie nodig: de runner verlengt de lease via setInterval. - - `plan-chat/chat.md` — placeholder voor PLAN_CHAT. -- **Tests** in `__tests__/` of vergelijkbaar: snapshot-test voor `mapBudgetToEffort` en de nieuwe `KIND_DEFAULTS.allowed_tools`-lijsten. - -**Verificatie van Fase 1:** -```bash -cd /Users/janpetervisser/Development/scrum4me-mcp -npm run typecheck && npm test -``` - -### Fase 2 — `scrum4me-docker` (de runner) - -**Bestanden:** -- **Nieuw**: `scrum4me-docker/bin/run-one-job.ts` — implementeert de stappen uit het architectuur-diagram. Imports uit `/opt/scrum4me-mcp/src/`: - - `getAuth` (auth.ts) - - `tryClaimJob`, `resetStaleClaimedJobs`, `attachWorktreeToJob`, `releaseLocksOnTerminal`, `rollbackClaim`, `getFullJobContext` (tools/wait-for-job.ts) - - `prisma` (prisma.ts) - - `mapBudgetToEffort`, type `JobConfig` (lib/job-config.ts) - - `getKindPromptText` (lib/kind-prompts.ts) -- LISTEN-loop: kopie van `wait-for-job.ts:838-889` (270s deadline ipv 300s — ruim binnen `MAX_WAIT_SECONDS`). -- Quota-probe verhuist hierheen: roep `bin/worker-quota-probe.sh` (bestaat al) en `getWorkerSettings` direct via prisma; sleep tot reset bij beneden-quota. Was voorheen Claude's verantwoordelijkheid in CLAUDE.md stappen 0.1-0.5. -- **Lease-renewal voor SPRINT_IMPLEMENTATION**: `setInterval(60_000, () => prisma.$executeRaw\`UPDATE claude_jobs SET lease_until = NOW() + INTERVAL '5 minutes' WHERE id = ${jobId}\`)`. Stop op spawn-exit (in finally-block). Werkt onafhankelijk van Claude's tool-call-cadans, dus ook tijdens lange synchrone Bash-calls (zoals `npm install`) blijft de lease vers. -- Token-expiry: detecteer Anthropic-auth-errors uit Claude's output én uit eigen Prisma-fouten; exit 3 → run-agent.sh schrijft `TOKEN_EXPIRED` marker. -- Cleanup: `prisma.$disconnect()` in `process.on('SIGTERM'/'exit')` zodat connection-pool niet blijft hangen tussen iteraties. - -- **Refactor**: `scrum4me-docker/bin/run-agent.sh` - - Verwijder regels 43-44 (SEED_PROMPT) en 46-49 (ALLOWED_TOOLS). - - Vervang regels 73-79 (`claude -p ...` aanroep) door: - ```bash - set +e - tsx /opt/agent/bin/run-one-job.ts > "${run_log}" 2>&1 - exit_code=$? - set -e - ``` - - Behoud: pre-flight token-check, exponential backoff (regels 106-126), UNHEALTHY na 5 fouten, log-rotation, state.json updates. - - Pas regel 87 (token-expiry regex) aan: detecteer ook `exit_code == 3` als trigger naast de stdout-regex. - -- **Update**: `scrum4me-docker/CLAUDE.md` - - Verwijder de "operationele loop"-sectie (Claude doet die niet meer). - - Korte sectie toevoegen: "deze container draait runner-loop in run-one-job.ts; per geclaimde job spawnt 'ie één Claude-invocation met kind-specifieke flags + prompt". - - Behoud: project-conventions die Claude in de worktree-cwd nodig heeft. - -- **Dockerfile**: niets wijzigen — `tsx@4` global is al geïnstalleerd (regel 39), scrum4me-mcp wordt al gecloned (regel 59-63). - -**Verificatie van Fase 2:** -```bash -cd /Users/janpetervisser/Development/scrum4me-docker -docker compose build -docker compose up -d -# Trigger een TASK_IMPLEMENTATION via de webapp (Sonnet + bypassPermissions verwacht) -# Trigger een IDEA_MAKE_PLAN via de webapp (Opus + plan-mode verwacht) -# Logs: docker compose logs -f -# Verifieer dat per job een nieuwe Claude-invocation logt met de juiste --model en --permission-mode -``` - -### Fase 3 — `Scrum4Me` web-app (lib + runbook) - -**Bestanden:** -- [lib/job-config.ts](../../lib/job-config.ts) — spiegel `mapBudgetToEffort` en de KIND_DEFAULTS-updates uit Fase 1. Comment toevoegen over CLI-mapping. -- [docs/runbooks/worker-idempotency.md](../runbooks/worker-idempotency.md) — herschrijf sectie "Config doorgeven aan Claude Code" (regels 113-150): vervang `--thinking-budget` door `--effort` met mapping-tabel, schrap `--max-turns`, voeg toe dat de runner (`bin/run-one-job.ts`) verantwoordelijk is voor flag-bouw en heartbeat (niet Claude meer). -- [docs/runbooks/job-model-selection.md](../runbooks/job-model-selection.md) — voeg note toe dat `max_turns` audit-only is en dat de runner per job spawnt. -- Tests: vitest snapshot voor `mapBudgetToEffort` (zelfde als in scrum4me-mcp Fase 1). - -**Verificatie van Fase 3:** -```bash -npm run verify && npm run build -``` - -## Logging-contract van `run-one-job.ts` - -Alle log-regels gaan naar **stdout** met format `<ISO-8601-UTC> [run-one-job] <message>`. `run-agent.sh` redirect dit al naar `/var/log/agent/runs/<timestamp>.log`. Eén regel per event, key=value-format zodat het grep-baar blijft. - -**Verplichte events:** - -| Moment | Voorbeeld-regel | -|---|---| -| Quota-probe start/eind | `2026-05-08T13:11:40Z [run-one-job] quota probe ok used_pct=42 limit_pct=90` | -| Claim attempt start | `2026-05-08T13:11:41Z [run-one-job] claim attempt user_id=usr_… token_id=tok_…` | -| Claim resultaat | `2026-05-08T13:11:42Z [run-one-job] claimed job_id=cl_abc kind=TASK_IMPLEMENTATION task_id=t_xyz product_id=prd_…` of `claim timeout after 270s — exiting 0` | -| **Resolved config** (verplicht) | `2026-05-08T13:11:42Z [run-one-job] config job_id=cl_abc model=claude-sonnet-4-6 permission_mode=bypassPermissions thinking_budget=6000 effort=medium max_turns=50 allowed_tools_count=20 source=kind_default` (waar `source` ∈ `kind_default` / `product_override` / `task_requires_opus` / `job_snapshot` — bepaald door welke override-laag gewonnen heeft) | -| Worktree + payload | `2026-05-08T13:11:42Z [run-one-job] worktree path=/home/agent/.scrum4me-agent-worktrees/cl_abc branch=feat/story-12345678 base_sha=abc123ef` | -| Payload-pad | `2026-05-08T13:11:42Z [run-one-job] payload written path=/tmp/job-cl_abc/payload.json size_bytes=2456` | -| **Claude spawn start** (verplicht) | `2026-05-08T13:11:43Z [run-one-job] spawn claude job_id=cl_abc cwd=/home/agent/.scrum4me-agent-worktrees/cl_abc args="--model claude-sonnet-4-6 --permission-mode bypassPermissions --effort medium --allowedTools <…> --mcp-config /opt/agent/mcp-config.json --add-dir /opt/agent --output-format text"` | -| Lease-renewal tick (alleen SPRINT) | `2026-05-08T13:12:43Z [run-one-job] heartbeat tick job_id=cl_abc lease_until=2026-05-08T13:17:43Z` (bij errors: `heartbeat error: <message>`) | -| **Claude spawn end** (verplicht) | `2026-05-08T13:14:21Z [run-one-job] claude done job_id=cl_abc exit_code=0 duration_ms=158234 wall_clock_seconds=158` | -| Cleanup | `2026-05-08T13:14:21Z [run-one-job] cleanup payload_removed=true prisma_disconnected=true heartbeat_stopped=true` | -| Process exit | `2026-05-08T13:14:21Z [run-one-job] exit code=0 job_id=cl_abc` | - -**Foutpaden ook expliciet:** -- `claim error <message>` (DB-fout vóór claim) -- `getFullJobContext error job_id=cl_abc <message>` → triggert rollbackClaim + log `rollback claim job_id=cl_abc reason=context_fetch_failed` -- `attachWorktreeToJob error job_id=cl_abc <message>` → idem -- `spawn error <errno>` → process kon `claude` niet starten -- Detected token-expiry: `2026-05-08T13:11:43Z [run-one-job] TOKEN_EXPIRED detected pattern="<matched-string>" exiting code=3` - -**Implementatie-helper** in `run-one-job.ts`: -```ts -const log = (msg: string) => console.log(`${new Date().toISOString()} [run-one-job] ${msg}`) -const logError = (msg: string) => console.error(`${new Date().toISOString()} [run-one-job] ERROR ${msg}`) -``` - -Geen JSON-logger of structured logging library — `run-agent.sh` parsed niets. Plain text houdt het grep-baar en consistent met de bestaande `_lib.sh`-`log()` shell-helper. - -## Crash-veiligheid - -| Failure-mode | Detectie | Recovery | -|---|---|---| -| Claim gelukt, runner crasht vóór spawn | `lease_until < NOW()` | `resetStaleClaimedJobs` (5 min) — automatisch | -| Spawn faalt (exit ≠ 0 vóór `update_job_status`) | exit code | `try/finally rollbackClaim + releaseLocksOnTerminal` in run-one-job | -| Claude crasht mid-run | exit code | rollbackClaim uit run-one-job; `update_job_status('failed')` is dan optional retry door operator | -| Token-expiry tijdens run | regex op claude-stdout + exit 3 | runner exit 3 → run-agent.sh schrijft TOKEN_EXPIRED marker → container blijft hangen voor diagnose | -| Runner-heartbeat faalt (DB onbereikbaar tijdens setInterval-tick) | error log + lease_until verstrijkt | resetStaleClaimedJobs requeu't (PBI-50 lease-driven recovery, 5 min). Mitigatie: log de Prisma-error in run-one-job zodat het opvalt in run-logs | - -## Verificatie end-to-end - -```bash -# 1. Build alle drie de repos -(cd ~/Development/scrum4me-mcp && npm run typecheck && npm test) -(cd ~/Development/Scrum4Me/.claude/worktrees/festive-jackson-78c3ff && npm run verify && npm run build) -(cd ~/Development/scrum4me-docker && docker compose build) - -# 2. Lokale Docker-run -docker compose up -d -docker compose logs -f agent & - -# 3. Smoke-test scenario's (via webapp of direct via prisma seed): -# a. enqueue IDEA_GRILL → verifieer log toont --model=claude-sonnet-4-6 + --permission-mode=plan + --effort=high -# b. enqueue IDEA_MAKE_PLAN → verifieer --model=claude-opus-4-7 + --effort=max -# c. enqueue TASK_IMPLEMENTATION → verifieer --model=claude-sonnet-4-6 + --permission-mode=bypassPermissions -# d. enqueue task met requires_opus=true → verifieer --model=claude-opus-4-7 -# e. enqueue product met preferred_permission_mode='acceptEdits' → verifieer dat override doorkomt - -# 4. Verifieer in DB na elke run: -# SELECT id, kind, status, requested_model, model_id, requested_permission_mode FROM claude_jobs ORDER BY created_at DESC LIMIT 5; -# requested_model en model_id moeten matchen (tenzij Claude zelf een ander rapporteert) - -# 5. Verifieer queue-loop met meerdere jobs: -# Vul de queue met 3 verschillende kinds; observeer in logs dat per job een nieuwe spawn gebeurt met andere flags. -``` - -## Niet-doelen - -- Geen wijzigingen aan de MCP-tool-set (`wait_for_job` blijft beschikbaar voor handmatige dev-mode; alleen niet meer in Claude's `allowedTools` voor docker-runs). -- Geen herstructurering van het ClaudeJob Prisma-schema. -- Geen wijzigingen aan `lib/job-config-snapshot.ts` (enqueue-laag) — die werkt al goed; deze refactor zit volledig aan de claim/exec-kant. -- Geen migratie van de `wait_for_job`-tool naar HTTP/REST — direct-import is voldoende. - -## Critical files - -- /Users/janpetervisser/Development/scrum4me-mcp/src/tools/wait-for-job.ts (export-fix + ref) -- /Users/janpetervisser/Development/scrum4me-mcp/src/lib/job-config.ts (KIND_DEFAULTS + mapBudgetToEffort) -- /Users/janpetervisser/Development/scrum4me-mcp/src/lib/idea-prompts.ts (rename → kind-prompts.ts) -- /Users/janpetervisser/Development/scrum4me-mcp/src/prompts/task/implementation.md (nieuw) -- /Users/janpetervisser/Development/scrum4me-mcp/src/prompts/sprint/implementation.md (nieuw) -- /Users/janpetervisser/Development/scrum4me-docker/bin/run-one-job.ts (nieuw) -- /Users/janpetervisser/Development/scrum4me-docker/bin/run-agent.sh (refactor) -- /Users/janpetervisser/Development/scrum4me-docker/CLAUDE.md (operationele loop sectie weg) -- lib/job-config.ts (spiegel) -- docs/runbooks/worker-idempotency.md (CLI-flag fix) diff --git a/docs/plans/sprint-mcp-tools.md b/docs/plans/sprint-mcp-tools.md deleted file mode 100644 index 17ae366..0000000 --- a/docs/plans/sprint-mcp-tools.md +++ /dev/null @@ -1,153 +0,0 @@ ---- -title: "Sprint MCP-tools — create_sprint & update_sprint" -status: draft -audience: [maintainer, ai-agent] -language: nl -last_updated: 2026-05-11 -applies_to: [scrum4me-mcp] ---- - -# Plan — `create_sprint` + `update_sprint` in scrum4me-mcp - -## Context - -Het runbook [docs/runbooks/plan-to-pbi-flow.md](../runbooks/plan-to-pbi-flow.md) (draft) beschrijft een sprint-lifecycle als onderdeel van de plan→PBI→story→task workflow: - -- **Bij plan-goedkeuring** opent Claude een nieuwe sprint (`status: OPEN`) -- **Na PR-merge + verify groen** sluit Claude die sprint (`status: CLOSED`) -- **Cron** mag stale/falende sprints later op `FAILED` zetten - -Hiervoor zijn twee MCP-tools nodig die nog **niet** bestaan in `~/Development/scrum4me-mcp/`: - -| Tool | Wat | Wie roept aan | -|---|---|---| -| `create_sprint` | Maakt nieuwe sprint, status `OPEN` | Claude bij plan-goedkeuring | -| `update_sprint` | Wijzigt status / dates / sprint_goal | Claude bij PR-close & cron bij stale-detect | - -Door één generieke `update_sprint` te bouwen (i.p.v. losse `close_sprint`/`fail_sprint`) is de tool-oppervlakte minimaal en zijn alle transities tussen `OPEN | CLOSED | ARCHIVED | FAILED` mogelijk. - -## Bestaande conventies (te respecteren) - -- **Toolpattern:** elk tool is één bestand onder `~/Development/scrum4me-mcp/src/tools/`, registreert via `register{ToolName}Tool(server: McpServer)` in `src/index.ts`. Voorbeeld-template: [scrum4me-mcp/src/tools/create-pbi.ts](https://github.com/madhura68/scrum4me-mcp/blob/main/src/tools/create-pbi.ts) -- **DB-toegang:** direct via `import { prisma } from '../prisma.js'` — **geen** REST-tussenstap, geen Next-deps -- **Auth:** `requireWriteAccess(token)` + `userCanAccessProduct(userId, productId)` zoals in `create-pbi.ts` -- **Error-pad:** `withToolErrors(...)`, `toolError(...)`, `toolJson(...)` uit `../errors.js` -- **Zod-input** apart gedefinieerd, status-enum gespiegeld uit Prisma -- **Schema-sync:** Prisma-schema is een git-submodule in `vendor/scrum4me`; geen schema-wijzigingen nodig (Sprint-model heeft alle statussen al) - -## Scope - -### A. `create_sprint` - -**Bestand:** `~/Development/scrum4me-mcp/src/tools/create-sprint.ts` - -**Input-schema:** - -```ts -const inputSchema = z.object({ - product_id: z.string().min(1), - code: z.string().min(1).max(30).optional(), // auto-generate als leeg - sprint_goal: z.string().min(1).max(500), - start_date: z.string().date().optional(), // ISO YYYY-MM-DD; default = today -}) -``` - -**Gedrag:** - -1. `requireWriteAccess(token)` → user_id -2. `userCanAccessProduct(user_id, product_id)` -3. **Code-generatie** (als niet meegegeven): `S-{YYYY-MM-DD}-{N}` waarbij `N` = `count(sprints van product op datum) + 1`. Dezelfde retry-on-unique-conflict pattern als `generateNextPbiCode()`. -4. `prisma.sprint.create({ data: { product_id, code, sprint_goal, status: 'OPEN', start_date } })` -5. Return: `{ id, code, status, start_date }` - -**Niet doen:** géén check op bestaande OPEN-sprints (per runbook-beslissing: "altijd nieuwe sprint"). - -### B. `update_sprint` - -**Bestand:** `~/Development/scrum4me-mcp/src/tools/update-sprint.ts` - -**Input-schema:** - -```ts -const inputSchema = z.object({ - sprint_id: z.string().min(1), - status: z.enum(['OPEN', 'CLOSED', 'ARCHIVED', 'FAILED']).optional(), - sprint_goal: z.string().min(1).max(500).optional(), - end_date: z.string().date().optional(), - start_date: z.string().date().optional(), -}).refine(d => - d.status !== undefined || d.sprint_goal !== undefined || - d.end_date !== undefined || d.start_date !== undefined, - { message: 'Minstens één veld vereist' } -) -``` - -**Gedrag:** - -1. `requireWriteAccess(token)` → user_id -2. Laad sprint → check `userCanAccessProduct(user_id, sprint.product_id)` -3. **Geen state-machine validatie** in deze tool — elke status-transitie is toegestaan. Het resubmit/heropen-pad wordt elders (buiten deze MCP-tool) afgehandeld. -4. **Auto-`end_date`:** als status naar `CLOSED`/`FAILED`/`ARCHIVED` gaat en `end_date` is niet meegegeven → set op `today()`. -5. `prisma.sprint.update({ where: { id }, data: {...} })` -6. Return: `{ id, code, status, start_date, end_date }` - -### C. `index.ts` — tool-registratie - -Twee regels toevoegen aan `~/Development/scrum4me-mcp/src/index.ts`: - -```ts -import { registerCreateSprintTool } from './tools/create-sprint.js' -import { registerUpdateSprintTool } from './tools/update-sprint.js' -// … -registerCreateSprintTool(server) -registerUpdateSprintTool(server) -``` - -## Out-of-scope (apart op te pakken) - -- **Cron auto-close/fail:** een Vercel cron-route (`/api/cron/sprint-lifecycle`) die OPEN-sprints scant, PR-status + verify check, en `update_sprint` aanroept met `CLOSED` of `FAILED`. Drempels: PR mergedAt → CLOSED, PR closed && !merged → FAILED, PR stale > 14d → FAILED. **Apart PBI** want vereist GitHub-API-koppeling en threshold-policy-besluiten. -- **Sprint-koppeling bij `create_story`:** runbook merkt op dat als er meerdere OPEN-sprints zijn de gebruiker moet bevestigen welke. Schoner is `create_story` uitbreiden met optionele `sprint_id`-param. Klein patch in `create-story.ts`, maar **niet** in deze PBI — eerst de basis-tools werkend hebben. -- **Sprint-events / SSE:** elke status-transitie zou een NOTIFY moeten emiteren zodat de UI live update. Bestaande pattern in [docs/patterns/realtime-notify-payload.md](../patterns/realtime-notify-payload.md). **Niet** in v1 van deze PBI — handmatige refresh acceptabel tot cron-flow er is. -- **REST-endpoints:** `POST /api/sprints` en `PATCH /api/sprints/[id]` in de Scrum4Me-app voor UI-pariteit. **Niet** in deze PBI — MCP gaat direct via Prisma, UI kan dat later naadloos volgen. - -## Testen - -In `scrum4me-mcp` zelf (Vitest): - -- `create-sprint.test.ts`: happy-path (alle velden + minimal), code-auto-generatie, code-conflict-retry, user-access-denied -- `update-sprint.test.ts`: legal transities (×3), illegal transities (×3), auto-`end_date` bij CLOSE/FAIL/ARCHIVE, multi-field update, access-denied - -In Scrum4Me-app: één integration-test in `__tests__/sprint-lifecycle.test.ts` die via een geseed token de MCP-tools aanroept en het Prisma-record verifieert. - -## Implementatie-stappen (volgorde) - -1. **`create-sprint.ts`** schrijven + registreren in `index.ts` -2. **`update-sprint.ts`** schrijven + registreren in `index.ts` -3. Unit-tests in scrum4me-mcp -4. `npm run verify` in scrum4me-mcp (typecheck + tests) -5. **Sync naar Scrum4Me-app:** `sync-schema.sh` is voor Prisma-schema; voor tool-discovery hoeft niets — MCP is een aparte service en de Scrum4Me-app importeert niets uit `scrum4me-mcp/src/tools/` -6. Update [docs/runbooks/mcp-integration.md](../runbooks/mcp-integration.md): voeg de twee tools toe aan de tool-lijst -7. Update [docs/runbooks/plan-to-pbi-flow.md](../runbooks/plan-to-pbi-flow.md): verwijder de ⚠️-tooling-banner; status van `draft` → `active` -8. PR-flow zoals gewend (branch-and-commit-runbook) - -## Open vragen — uitgesteld tot later - -Bewust pas later beslissen (niet blokkerend voor de eerste implementatie): - -- **`code`-conventie** — voor v1 default `S-{YYYY-MM-DD}-{N}`; later evalueren of `S-{N}` doorlopend per product (zoals PBI-N) beter past -- **Cron-drempels** — pas relevant in de vervolg-PBI voor de cron zelf -- **`update_sprint` zonder status-wijziging** — toegestaan (alle velden optioneel; refine eist minstens één) - -## Risico's - -- **Multi-sprint-context** bij `create_story`: nu impliciet (server resolveert "active sprint"). Met meerdere OPEN-sprints kan dit fout gaan. Mitigatie: korte termijn → het runbook waarschuwt, gebruiker bevestigt; lange termijn → expliciete `sprint_id` param in `create_story`. -- **Cron racet met handmatige close:** als gebruiker `update_sprint(CLOSED)` doet vóór de cron, en cron daarna `FAILED` zet, overschrijft cron de eerdere status. Acceptabel voor v1 — last-write-wins. Het externe resubmit-mechanisme bepaalt of een sprint überhaupt nog door cron geraakt mag worden. -- **Demo-modus:** demo-users mogen geen schrijfacties; `requireWriteAccess` checkt al op `isDemo`, dus geen extra werk. - -## Klaar wanneer - -- [ ] Beide tools live in scrum4me-mcp `main` -- [ ] Tests groen -- [ ] mcp-integration.md tool-lijst bijgewerkt -- [ ] plan-to-pbi-flow.md banner weg + status `active` -- [ ] Eén end-to-end smoke-test gedraaid: create_sprint → create_pbi → ... → update_sprint(CLOSED) op een lokale dev-DB diff --git a/docs/plans/sprint-pr-worktree-state-machines.md b/docs/plans/sprint-pr-worktree-state-machines.md deleted file mode 100644 index afc8349..0000000 --- a/docs/plans/sprint-pr-worktree-state-machines.md +++ /dev/null @@ -1,345 +0,0 @@ ---- -title: "Advies - SprintRun, PR en worktree lifecycle als state machines" -status: draft -audience: [ai-agent, developer] -language: nl -last_updated: 2026-05-06 ---- - -# Advies - SprintRun, PR en worktree lifecycle als state machines - -## Context - -Het combinatieplan voor F3 auto-merge worker-flow en persistente product-worktrees raakt meerdere lifecycles tegelijk: - -- `SprintRun`: queue, running, paused, done, failed, cancelled. -- `ClaudeJob`: claim, run, verify, push, done, failed, cancelled. -- Worktree/lock: acquire, create/reuse, sync, ready, release. -- PR-flow: push branch, create PR, wait for scope completion, checks, merge/ready/pause. - -De grootste maintainability-risico's ontstaan waar deze lifecycles elkaar kruisen. Voorbeelden: - -- Een PR krijgt auto-merge terwijl latere story-tasks nog niet klaar zijn. -- Een job wordt `DONE`, maar de product-worktree lock blijft hangen. -- Een `SprintRun` wordt `PAUSED`, maar de UI/server action kan alleen een failed sprint hervatten. -- Per-task verificatie gebruikt `origin/main...HEAD`, terwijl meerdere tasks dezelfde story- of sprint-branch hergebruiken. - -Het advies is om deze lifecycles expliciet te modelleren als state machines, met centrale transition-regels en declaratieve side effects. - -## Kernadvies - -Houd de worker zo dom mogelijk. De worker voert werk uit in de meegegeven context en rapporteert via MCP-tools. De MCP-server is eigenaar van lifecycle-transitions, cleanup, locks, PR-status, pause/resume en terminal states. - -Concreet: - -1. Maak pure transition-modules in `scrum4me-mcp`. -2. Laat `wait-for-job.ts` en `update-job-status.ts` transitions aanroepen. -3. Laat transitions declaratieve effects teruggeven. -4. Voer effects idempotent server-side uit. -5. Persistente waarheid blijft Postgres. - -Niet doen: - -- Lock-release afhankelijk maken van een worker-prompt-instructie. -- PR/merge-flow deels in worker hooks en deels in MCP-tools stoppen. -- Auto-merge activeren voordat de volledige scope klaar is. -- Worktree internals rechtstreeks muteren zonder Git-commando's zoals `git rev-parse --git-path`. - -## Voorgestelde Machines - -### 1. WorktreeLeaseMachine - -Doel: product-worktrees en locks betrouwbaar beheren. - -States: - -```text -idle - -> acquiring_lock - -> creating_or_reusing - -> syncing - -> ready - -> releasing - -> released - -error: - -> lock_timeout - -> sync_failed - -> stale_released -``` - -Belangrijke regels: - -- Acquire lock voordat de worktree wordt aangemaakt of gesynct. -- Gebruik een lock-pad dat buiten de worktree bestaat, bijvoorbeeld: - `~/.scrum4me-agent-worktrees/_locks/product-{productId}`. -- Release locks server-side bij: - - `update_job_status(done)` - - `update_job_status(failed)` - - job cancellation - - stale reset - - process shutdown waar mogelijk -- Gebruik `proper-lockfile` alleen als single-host/single-filesystem aanname klopt. -- Overweeg PostgreSQL advisory locks of een DB-lease-tabel als meerdere MCP-processen of machines dezelfde product-worktrees kunnen beheren. - -### 2. PrFlowMachine - -Doel: PR's consistent aanmaken, bijwerken, ready zetten en eventueel auto-mergen. - -States: - -```text -none - -> branch_pushed - -> pr_opened - -> waiting_for_scope_done - -> waiting_for_checks - -> auto_merge_enabled - -> merged - -draft path: - -> draft_opened - -> ready_for_review - -failure/pause: - -> checks_failed - -> merge_conflict_paused -``` - -Regels per `PrStrategy`: - -| Strategie | Eerste task | Tijdens scope | Laatste task | -|---|---|---|---| -| `STORY` | Branch push + PR openen | PR open houden, geen auto-merge | Na laatste story-task: checks groen -> auto-merge | -| `SPRINT` | Branch push + draft PR openen | Commits blijven op sprint-branch | Na sprint DONE: PR ready-for-review, geen auto-merge | - -Belangrijke regels: - -- PR openen mag vroeg; auto-merge activeren mag pas wanneer de scope klaar is. -- Gebruik GitHub branch protection, required checks en eventueel merge queue als merge-gate. -- Gebruik bij merge-acties waar mogelijk een head-SHA guard, bijvoorbeeld via `gh pr merge --match-head-commit`. -- Maak auto-merge/merge-fouten typed: - - `CHECKS_FAILED` - - `MERGE_CONFLICT` - - `GH_AUTH_ERROR` - - `AUTO_MERGE_NOT_ALLOWED` - - `UNKNOWN` -- Alleen `MERGE_CONFLICT` hoort naar `SprintRun.PAUSED`; CI rood hoort naar task/sprint failure. - -### 3. SprintRunMachine - -Doel: sprint-run status niet verspreid over UI, server actions en MCP-tools laten ontstaan. - -States: - -```text -queued - -> running - -> paused_merge_conflict - -> running - -> done - -failure: - -> failed - -manual: - -> cancelled -``` - -Events: - -```ts -type SprintRunEvent = - | { type: 'CLAIM_FIRST_JOB' } - | { type: 'TASK_DONE'; taskId: string } - | { type: 'TASK_FAILED'; taskId: string; error: string } - | { type: 'MERGE_CONFLICT'; prUrl: string; files: string[] } - | { type: 'USER_RESUMED' } - | { type: 'USER_CANCELLED' } -``` - -PAUSED moet context hebben: - -- `pause_reason = 'MERGE_CONFLICT'` -- `pr_url` -- conflict-bestanden -- `claude_question_id` -- eventueel `resume_instructions` - -Zonder die context wordt PAUSED lastig te onderhouden in UI, MCP en worker-flow. - -## Pure Transitions En Declaratieve Effects - -Een transition-functie moet geen GitHub-call, Prisma-write of filesystem-operatie direct doen. Laat de functie teruggeven wat er moet gebeuren. - -Voorbeeld: - -```ts -type FlowEffect = - | { type: 'CREATE_CLAUDE_QUESTION'; payload: { sprintRunId: string; prUrl: string; files: string[] } } - | { type: 'SET_SPRINT_RUN_STATUS'; sprintRunId: string; status: 'PAUSED' | 'RUNNING' | 'DONE' | 'FAILED' } - | { type: 'ENABLE_AUTO_MERGE'; prUrl: string; expectedHeadSha: string } - | { type: 'MARK_PR_READY'; prUrl: string } - | { type: 'RELEASE_WORKTREE_LOCKS'; jobId: string } - -type TransitionResult<State> = { - nextState: State - effects: FlowEffect[] -} -``` - -Daarna voert een executor de effects idempotent uit: - -```ts -async function executeEffects(effects: FlowEffect[]) { - for (const effect of effects) { - switch (effect.type) { - case 'RELEASE_WORKTREE_LOCKS': - await releaseJobLocks(effect.jobId) - break - case 'MARK_PR_READY': - await markPullRequestReady({ prUrl: effect.prUrl }) - break - // enzovoort - } - } -} -``` - -Voordelen: - -- Transitions zijn unit-testbaar zonder GitHub, Git of database. -- Side effects zijn apart idempotent te testen. -- Verboden transitions worden expliciet. -- UI en tools kunnen dezelfde statusbetekenis gebruiken. - -## Per-Job Verificatie: Base SHA Vastleggen - -De huidige verificatie met `git diff origin/main...HEAD` is niet geschikt wanneer meerdere jobs dezelfde story- of sprint-branch hergebruiken. Task 2 ziet dan ook wijzigingen van task 1. - -Aanbevolen wijziging: - -- Leg bij claim `ClaudeJob.base_sha` vast. -- Verifieer job-scope met: - - `git diff <base_sha>...HEAD`, of - - `git diff <previous_job_head_sha>..HEAD` als lineaire task-commits verplicht zijn. -- Leg na succesvolle push `ClaudeJob.head_sha` vast. -- Gebruik die SHA ook als guard voor PR/merge-acties. - -Dit maakt task-verificatie, PR lifecycle en auto-merge veel voorspelbaarder. - -## XState, Eigen Module Of Temporal - -### Eigen pure TypeScript module - -Beste eerste stap. - -Gebruik dit wanneer: - -- De workflow lokaal in MCP-tools draait. -- Postgres de persistente waarheid blijft. -- Je vooral transitions en guards wilt centraliseren. - -Voordeel: weinig dependency- en runtime-complexiteit. - -### XState - -XState is passend wanneer: - -- transitions complexer worden; -- nested states nuttig zijn; -- je visualisatie of model-based tests wilt; -- meerdere lifecycles als actors gaan samenwerken. - -Gebruik XState in deze fase bij voorkeur als pure transition layer, niet als long-running runtime. Persistente status blijft in Postgres. - -Bron: [XState docs](https://stately.ai/docs) - -### Temporal - -Temporal pas overwegen als de orchestration echt distributed en long-running wordt. - -Gebruik dit wanneer: - -- workflows uren/dagen lopen; -- meerdere worker-machines betrokken zijn; -- retries, timers en signals crash-proof moeten zijn; -- je wilt dat workflow-code exact verdergaat na server crash. - -Niet kiezen als eerste stap: het brengt eigen infra, deployment, determinisme-regels en workflow/activity-splitsing mee. - -Bron: [Temporal docs](https://docs.temporal.io/) - -## Aanbevolen Implementatievolgorde - -### Stap 1 - Documenteer huidige transitions - -Maak een kleine inventarisatie: - -- Welke code wijzigt `SprintRun.status`? -- Welke code wijzigt `ClaudeJob.status`? -- Welke code maakt/verwijdert worktrees? -- Welke code maakt PR's, zet PR's ready of enablet auto-merge? - -### Stap 2 - Introduceer pure machines - -Nieuwe bestanden in `scrum4me-mcp`: - -- `src/flow/worktree-lease-machine.ts` -- `src/flow/pr-flow-machine.ts` -- `src/flow/sprint-run-machine.ts` -- `src/flow/effects.ts` - -### Stap 3 - Verplaats beslislogica uit tools - -Laat `update-job-status.ts` niet zelf bepalen welke lifecycle-actie volgt, maar: - -1. laad context uit DB; -2. stuur event naar machine; -3. persist next state; -4. voer effects uit; -5. emit SSE. - -### Stap 4 - Maak effects idempotent - -Elke effect moet veilig opnieuw uitvoerbaar zijn: - -- PR bestaat al -> return bestaande URL. -- PR is al ready -> success. -- Lock bestaat niet meer -> success. -- SprintRun staat al terminal -> geen mutatie. - -### Stap 5 - Voeg transition-tests toe - -Test vooral verboden of gevoelige paden: - -- STORY: auto-merge niet bij eerste task. -- STORY: auto-merge pas na laatste task en groene checks. -- SPRINT: draft PR blijft draft tot sprint DONE. -- MERGE_CONFLICT: SprintRun wordt PAUSED met question/context. -- CI rood: task wordt FAILED, niet PAUSED. -- Product-worktree: lock acquire gebeurt vóór create/sync. -- Stale reset: lock release wordt altijd uitgevoerd. - -## Aanpassing Aan Het Combinatieplan - -Het plan zou voor implementatie worden aangescherpt op deze punten: - -1. F3 niet implementeren als worker post-task hook, maar als MCP-owned PR-flow. -2. STORY auto-merge uitstellen tot story-scope klaar is. -3. Per-job `base_sha` en `head_sha` toevoegen voor verificatie en merge guards. -4. Product-worktree lock acquire vóór `getOrCreateProductWorktree`. -5. Lock-release niet via worker-prompt, maar via server-side terminal transitions. -6. PAUSED resume-path expliciet maken in server action en UI. -7. `PLAN_CHAT` alleen opnemen als die jobflow end-to-end bestaat. -8. Delete-only verifierverwachting corrigeren: delete-only is niet `EMPTY` als er daadwerkelijk bestanden zijn verwijderd. - -## Bronnen - -- [Git worktree documentation](https://git-scm.com/docs/git-worktree.html) -- [GitHub protected branches](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches) -- [GitHub merge queue](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue) -- [GitHub CLI `gh pr merge`](https://cli.github.com/manual/gh_pr_merge) -- [PostgreSQL explicit locking](https://www.postgresql.org/docs/current/explicit-locking.html) -- [XState documentation](https://stately.ai/docs) -- [Temporal documentation](https://docs.temporal.io/) diff --git a/docs/plans/sync-model-prices.md b/docs/plans/sync-model-prices.md deleted file mode 100644 index c075886..0000000 --- a/docs/plans/sync-model-prices.md +++ /dev/null @@ -1,106 +0,0 @@ -# Plan: wekelijkse sync van `model_prices` (PBI-66 / ST-1296) - -## Context - -De tabel `model_prices` ([prisma/schema.prisma:465](../../prisma/schema.prisma)) bevat nu 3 hardcoded rijen via [prisma/seed.ts:198](../../prisma/seed.ts) (Opus 4.7, Sonnet 4.6, Haiku 4.5). Die wordt door [lib/insights/token-stats.ts](../../lib/insights/token-stats.ts) en [lib/insights/token-history.ts](../../lib/insights/token-history.ts) ge-`LEFT JOIN`-d voor kostberekening. - -Probleem: prijzen + nieuwe modellen worden alleen bijgewerkt bij een full re-seed. Dat vergeet je. We willen een wekelijks handmatig draaibaar script dat: - -1. De actuele Claude 4.x modellijst ophaalt bij Anthropic (`GET /v1/models`), -2. Per model de prijzen bepaalt uit een onderhouden tabel in code, -3. Nieuwe modellen detecteert en logt (zodat we weten dat de tabel update nodig heeft), -4. Idempotent upsert in `model_prices`. - -**Belangrijke realiteit:** Anthropic biedt geen prijs-API. `/v1/models` levert id, display_name, max_tokens, capabilities — maar **geen pricing**. De prijzen onderhouden we daarom als constanten in het script. De API-call dient om de modellijst te valideren en nieuwe modellen op te merken. - -## Aanpak - -Eén nieuw TypeScript-script `scripts/sync-model-prices.ts` in dezelfde stijl als [scripts/insert-milestone.ts](../../scripts/insert-milestone.ts): - -- dotenv → DATABASE_URL + ANTHROPIC_API_KEY -- `pg.Pool` + `PrismaPg` adapter + `PrismaClient` (zelfde patroon als bestaande scripts) -- `--dry-run` flag voor preview zonder schrijven -- Aangeroepen via `npm run db:sync-model-prices` - -### Datastromen - -``` -ANTHROPIC API /v1/models - │ - ▼ (filter: model_id matcht /^claude-(opus|sonnet|haiku)-4/) - API model list ───────────┐ - │ - PRICE_TABLE (in script) ──┤── join op model_id - │ - ▼ - Per model: - - input_price = PRICE_TABLE[id].input - - output_price = PRICE_TABLE[id].output - - cache_read_price = input * 0.1 - - cache_write_price = input * 1.25 - │ - ▼ - prisma.modelPrice.upsert -``` - -### PRICE_TABLE in script - -```ts -const PRICE_TABLE: Record<string, { input: number; output: number }> = { - 'claude-opus-4-7': { input: 15.0, output: 75.0 }, - 'claude-sonnet-4-6': { input: 3.0, output: 15.0 }, - 'claude-haiku-4-5-20251001': { input: 0.8, output: 4.0 }, -} - -const CACHE_READ_RATIO = 0.1 -const CACHE_WRITE_RATIO = 1.25 // 5-minute cache write -``` - -Cache-ratio's komen overeen met de huidige seed: 1.5/15 = 0.1 en 18.75/15 = 1.25 — dus geen waarde-shift voor bestaande rijen. - -### Filter Claude 4.x - -Regex op `id` uit de API: `/^claude-(opus|sonnet|haiku)-4/`. Dit matcht `claude-opus-4-7`, `claude-sonnet-4-6`, `claude-haiku-4-5-20251001` en toekomstige 4.x varianten. Filtert oudere 3.x modellen weg. - -### Detectie nieuwe modellen - -Per Claude 4.x model uit de API: -- **In PRICE_TABLE** → upsert met de prijs -- **Niet in PRICE_TABLE** → log warning, sla over, exit code blijft 0 - -## Bestanden - -| Bestand | Actie | -|---|---| -| `scripts/sync-model-prices.ts` | **Nieuw** — sync-script | -| `package.json` | **Wijzigen** — entry `"db:sync-model-prices"` toevoegen | -| `.env.example` | **Wijzigen** — `ANTHROPIC_API_KEY=""` toevoegen | -| `lib/env.ts` | **Wijzigen** — `ANTHROPIC_API_KEY` als optional env var | -| `scripts/README.md` | **Wijzigen** — sectie "Sync model prices" toevoegen | -| `prisma/seed.ts` regels 198–229 | **Behouden** — fallback voor verse DB | - -## Edge cases - -| Geval | Gedrag | -|---|---| -| `ANTHROPIC_API_KEY` ontbreekt | Error + exit 1 | -| API geeft 401 | Error met hint "controleer API key" | -| API geeft 5xx | Retry 1× met 2s delay, dan falen | -| API levert 0 Claude 4.x modellen | Warning, exit 1 | -| Model uit DB staat niet meer in API | Niet verwijderen — alleen loggen | -| `--dry-run` | API-call gewoon doen, alleen geen `upsert` | - -## Verificatie - -```bash -npm run db:sync-model-prices -- --dry-run -npm run db:sync-model-prices -psql $DATABASE_URL -c "SELECT model_id, input_price_per_1m, output_price_per_1m, updated_at FROM model_prices ORDER BY model_id" -npm run lint && npm test && npm run build -``` - -## Buiten scope - -- Geen Vercel cron route — bewust gekozen: handmatig draaien geeft moment om PRICE_TABLE bij te werken. -- Geen pricing-page scraping — fragiel. -- Geen 1-uurs cache write tier — schema heeft één veld. diff --git a/docs/plans/zustand-store-rearchitecture.md b/docs/plans/zustand-store-rearchitecture.md deleted file mode 100644 index 957a77c..0000000 --- a/docs/plans/zustand-store-rearchitecture.md +++ /dev/null @@ -1,746 +0,0 @@ ---- -title: "Zustand store rearchitecture - active context, realtime en resync" -status: ready-to-execute -audience: [maintainer, contributor, ai-agent] -language: nl -last_updated: 2026-05-09 -revision: 3 ---- - -# Zustand store rearchitecture - -Doel: de client-state van Scrum4Me voorspelbaar houden terwijl de app groeit. -De database blijft de bron van waarheid. Zustand wordt de live client-projectie -voor de actieve workflow: snel genoeg voor optimistic UI, robuust genoeg tegen -gemiste SSE-events, hidden tabs en onbekende notify-vormen. - -## Kernkeuze - -Geen app-brede megastore en ook geen pure splits per pagina. Stores per bounded -context: - -| Store | Scope | Verantwoordelijkheid | -|---|---|---| -| `product-workspace-store` | actief product | active context, product backlog data, selectie, DnD-order, SSE, resync | -| `sprint-workspace-store` | actieve sprint | sprint stories, sprint tasks, assignment, sprint-DnD, SSE, resync | -| `solo-store` | actieve user + product | uitvoerbare taken, worker/job status, solo-kanban realtime | -| `notifications-store` | user | vragen, alerts, notification badge | -| `idea-store` | idee/product | grill/make-plan jobstate, open vragen, idea-status | -| `jobs-store` | jobs pagina | actieve/afgeronde jobs en jobs-page selectie | -| kleine UI stores | app/client | debug mode, lichte UI-voorkeuren | - -De huidige `backlog-store`, `planner-store`, `selection-store` en -`product-store` worden samengevoegd tot `product-workspace-store`. Ze -beschrijven dezelfde workflow en verdelen nu PBI/story/task waarheid, -order-state en selectie over meerdere stores. - -## Source of truth - -| Data | Waarheid | Zustand rol | Persistentie | -|---|---|---|---| -| `activeProductId` | `users.active_product_id` in DB | client mirror voor navigatie en actieve stream | DB | -| `activePbiId` | runtime selectie of URL | active context | optioneel localStorage restore hint | -| `activeStoryId` | runtime selectie of URL | active context | optioneel localStorage restore hint | -| `activeTaskId` | runtime selectie of URL dialog param | active context + task detail | optioneel localStorage restore hint | -| PBI/story/task data | DB | genormaliseerde live client-projectie | geen localStorage | -| DnD-order | DB `sort_order`, tijdelijk optimistic in store | relatie-arrays met rollback | DB na server action | -| filters/sort UI | client preference | component/store UI state | localStorage mag | - -LocalStorage is dus geen waarheid voor actieve entiteiten. Het mag alleen helpen -om een vorige selectie te herstellen nadat de server de actieve product-context -heeft bepaald — én alleen als de hint-id na laden nog bestaat in de store. - -## Product workspace store - -Vorm: - -```ts -type ProductWorkspaceStore = { - context: { - activeProduct: { id: string; name: string } | null - activePbiId: string | null - activeStoryId: string | null - activeTaskId: string | null - } - - entities: { - pbisById: Record<string, BacklogPbi> - storiesById: Record<string, BacklogStory> - tasksById: Record<string, BacklogTask | TaskDetail> - } - - relations: { - pbiIds: string[] - storyIdsByPbi: Record<string, string[]> - taskIdsByStory: Record<string, string[]> - } - - loading: { - loadedProductId: string | null - loadingProductId: string | null - loadedPbiIds: Record<string, true> - loadedStoryIds: Record<string, true> - loadedTaskIds: Record<string, true> - activeRequestId: string | null - } - - sync: { - realtimeStatus: 'connecting' | 'open' | 'disconnected' - lastEventAt: number | null - lastResyncAt: number | null - resyncReason: ResyncReason | null - } - - hydrateSnapshot(snapshot: ProductBacklogSnapshot): void - setActiveProduct(product: { id: string; name: string } | null): void - setActivePbi(pbiId: string | null): void - setActiveStory(storyId: string | null): void - setActiveTask(taskId: string | null): void - - ensureProductLoaded(productId: string, requestId?: string): Promise<void> - ensurePbiLoaded(pbiId: string, requestId?: string): Promise<void> - ensureStoryLoaded(storyId: string, requestId?: string): Promise<void> - ensureTaskLoaded(taskId: string, requestId?: string): Promise<void> - - applyRealtimeEvent(event: ProductRealtimeEvent): void - resyncActiveScopes(reason: ResyncReason): Promise<void> - resyncLoadedScopes(reason: ResyncReason): Promise<void> - - applyOptimisticMutation(mutation: OptimisticMutation): string - rollbackMutation(mutationId: string): void - settleMutation(mutationId: string): void -} -``` - -State blijft vlak en genormaliseerd. Componenten lezen via selectors: - -```ts -selectVisiblePbis(state) -selectStoriesForActivePbi(state) -selectTasksForActiveStory(state) -selectActivePbi(state) -selectActiveStory(state) -selectActiveTask(state) -``` - -Een task zit in `tasksById` als `BacklogTask` (lite) zolang alleen de lijst -geladen is, en wordt verrijkt naar `TaskDetail` zodra `ensureTaskLoaded` is -gedraaid. Componenten gebruiken een `isDetail()`-typeguard voor de extra -velden. - -## Active context flow - -### Product wisselen - -```txt -setActiveProduct(product) --> nieuw requestId, zet activeRequestId --> zet activeProduct, reset activePbiId/activeStoryId/activeTaskId --> reset entities + relations als product wisselt --> SSE-stream wisselt mee met product.id --> ;(async) await ensureProductLoaded(product.id, requestId) --> nadat ensure resolved + activeRequestId nog == requestId: - probeer restore hint (activePbiId) — alleen als hint-id in entities zit -``` - -`activeProduct` komt server-side uit de layout via `users.active_product_id`. -De client-store spiegelt dit zodat client componenten niet overal props hoeven -te ontvangen. - -### PBI selecteren - -```txt -setActivePbi(pbiId) --> nieuw requestId --> zet activePbiId, reset activeStoryId en activeTaskId --> schrijf lastActivePbiIdByProduct[productId] als restore hint --> ;(async) await ensurePbiLoaded(pbiId, requestId) --> nadat ensure resolved + activeRequestId nog == requestId: - probeer restore hint (activeStoryId) -``` - -### Story selecteren - -```txt -setActiveStory(storyId) --> nieuw requestId --> zet activeStoryId, reset activeTaskId --> ensureStoryLoaded(storyId, requestId) --> schrijf lastActiveStoryIdByProduct[productId] als restore hint -``` - -### Task selecteren - -```txt -setActiveTask(taskId) --> zet activeTaskId --> ensureTaskLoaded(taskId) --> schrijf lastActiveTaskIdByProduct[productId] als restore hint -``` - -### Race-safe loaders - -Setters mogen loaders starten, maar loaders moeten race-safe zijn. - -```ts -setActivePbi(pbiId) { - const requestId = crypto.randomUUID() - - set((s) => { - s.context.activePbiId = pbiId - s.context.activeStoryId = null - s.context.activeTaskId = null - s.loading.activeRequestId = requestId - }) - - void get().ensurePbiLoaded(pbiId, requestId) -} -``` - -Bij terugkomst: - -```ts -if (get().loading.activeRequestId !== requestId) return -``` - -Een trage fetch van een oude selectie mag nooit de nieuwste selectie of data -overschrijven. - -> **Niet `state.ensureXxx(...)` aanroepen vanuit een gecaptured snapshot.** -> Method-references zijn niet noodzakelijk identiek over verschillende -> immer-state-versies. Roep acties intern aan via `get().ensureXxx(...)` -> direct vóór gebruik. Zie §Implementation-gotchas G4. - -## Hydration-strategie - -Er zijn twee patronen. Kies bewust per pagina. - -### Patroon A — Server snapshot (productpagina, sprintboard) - -Voor pagina's die een specifieke product-route hebben en SSR/RSC kunnen -benutten: - -```txt -1. Server layout leest `users.active_product_id`. -2. Server-page fetcht initial backlog snapshot voor dat product. -3. Client krijgt snapshot via prop / `BacklogHydrationWrapper` → `hydrateSnapshot()`. -4. Vervolgens: - - leest store optionele restore hints (activePbiId/Story/Task). - - Herstelt alleen als id nog bestaat in entities en toegankelijk is. - - Anders selectie leeg laten. -5. SSE-hook mount op activeProductId. -``` - -### Patroon B — Cascading client-load (productpicker zonder server-context) - -Voor pagina's zonder server-determined product (b.v. een dashboard met -product-pulldown). De `hydrateSnapshot` blijft beschikbaar als API maar wordt -niet gebruikt; loaders worden door de UI getriggerd via `setActiveProduct`, -`setActivePbi`, etc. - -```txt -1. UI biedt productpicker; geen server-side activeProduct. -2. Op mount: lees `lastActiveProductId` uit localStorage (als hint). -3. setActiveProduct(restoredProduct) → trigger ensureProductLoaded. -4. Na elke ensure*Loaded: pas vervolg-restore-hint toe (zie restore-flow). -5. SSE-hook mount op activeProductId. -``` - -### Restore-hint flow - -```txt -setActiveProduct(p): - ;(async () => { - await ensureProductLoaded(p.id, requestId) - if (loading.activeRequestId !== requestId) return - const hint = hints.perProduct[p.id]?.lastActivePbiId - if (hint && entities.pbisById[hint]) setActivePbi(hint) - })() - -setActivePbi(pbiId): - ;(async () => { - await ensurePbiLoaded(pbiId, requestId) - if (loading.activeRequestId !== requestId) return - const hint = hints.perProduct[productId]?.lastActiveStoryId - if (hint && entities.storiesById[hint]) setActiveStory(hint) - })() -``` - -> **Geen setTimeout(0) of microtask-trick.** De fetch is dan nog niet klaar, -> dus de validatie `entities.byId[hint]` faalt altijd. Chain dus altijd -> `await ensureXxxLoaded` en valideer in dezelfde requestId-cycle. - -Als een route een expliciete task in de URL heeft, wint de URL boven de -restore hint. Voorbeeld: `?editTask=<id>` of een toekomstige deep link. - -## SSE integratie - -De SSE-hook beheert alleen transport: - -```txt -useProductWorkspaceRealtime(activeProductId) --> opent /api/realtime/backlog?product_id=... --> parsed events --> dispatcht naar store.applyRealtimeEvent(event) --> beheert reconnect/backoff/status (via store.setRealtimeStatus) --> op 'ready' na (re)connect: void store.resyncActiveScopes('reconnect') -``` - -De store beheert de betekenis: - -```txt -PBI insert/update --> upsert pbisById --> voeg id toe aan pbiIds indien nodig --> sorteer pbiIds op priority/sort_order - -PBI delete --> verwijder pbi --> verwijder child stories en tasks --> clear actieve selectie als die onder deze PBI viel - -Story insert/update --> upsert storiesById --> verplaats id tussen storyIdsByPbi indien pbi_id wijzigt --> sorteer alleen de betrokken parent-lijsten - -Story delete --> verwijder story --> verwijder child tasks --> clear activeStoryId/activeTaskId indien nodig - -Task insert/update --> upsert tasksById --> verplaats id tussen taskIdsByStory indien story_id wijzigt --> sorteer alleen de betrokken task-lijst - -Task delete --> verwijder task --> clear activeTaskId indien nodig -``` - -SSE-events zijn idempotent. Een event dat al optimistisch is toegepast, mag -geen dubbele insert of verkeerde rollback veroorzaken. - -## Reconciliation en resync - -SSE is snel, maar niet voldoende als enige correctheidsmechanisme: - -- browsers kunnen hidden tabs throttlen of freezen; -- Postgres NOTIFY heeft geen replay; -- de tab kan offline zijn; -- een event-router kan een relevant semantisch event niet herkennen; -- sommige wijzigingen vereisen refetch in plaats van een kleine patch. - -Daarom krijgt de store een expliciete resync-laag. - -```ts -type ResyncReason = - | 'visible' - | 'reconnect' - | 'manual' - | 'unknown-event' - | 'stale-scope' - | 'mutation-settled' -``` - -### Hidden tab beleid - -Sluit de SSE-stream niet actief zodra de tab hidden wordt. Laat `EventSource` -open zolang browser en netwerk dit toelaten. - -Bij overgang hidden -> visible: - -```txt -resyncActiveScopes('visible') -``` - -Bij reconnect of nieuw `ready` event na disconnect: - -```txt -resyncActiveScopes('reconnect') -``` - -> Het bestaande `use-backlog-realtime.ts` sluit de EventSource op `hidden`. -> Vervang dat gedrag in dezelfde PR als waarin `resyncActiveScopes('visible')` -> wordt toegevoegd; los gezien zou je oude gedrag kwijtraken zonder vangnet. - -### Active scopes - -Minimale resync: - -```txt -activeProductId -> refetch product/PBI snapshot -activePbiId -> refetch stories voor PBI -activeStoryId -> refetch tasks voor story -activeTaskId -> refetch task detail -``` - -Implementeer `resyncActiveScopes` zonder gecaptured snapshot: - -```ts -async resyncActiveScopes(reason) { - const ctx = get().context - const tasks: Promise<void>[] = [] - if (ctx.activeProduct?.id) tasks.push(get().ensureProductLoaded(ctx.activeProduct.id)) - if (ctx.activePbiId) tasks.push(get().ensurePbiLoaded(ctx.activePbiId)) - if (ctx.activeStoryId) tasks.push(get().ensureStoryLoaded(ctx.activeStoryId)) - if (ctx.activeTaskId) tasks.push(get().ensureTaskLoaded(ctx.activeTaskId)) - set((s) => { s.sync.lastResyncAt = Date.now(); s.sync.resyncReason = reason }) - await Promise.allSettled(tasks) -} -``` - -Als de UX merkt dat eerder bezochte panels stale blijven, breid dit uit naar -`resyncLoadedScopes`, dat alle scopes in `loadedPbiIds`, `loadedStoryIds` en -`loadedTaskIds` parallel herlaadt. - -### Unknown relevant events - -Een SSE-route mag onbekende product-events niet stil negeren — maar ook niet -elk geluid blind als refetch-trigger interpreteren. - -```txt -known pbi/story/task event --> applyRealtimeEvent(event) - -known semantic event, zoals story_log of claude_job_status --> patch specifieke slice of markeer scope stale - -unknown event met product_id == activeProductId EN entity-shape --> resyncActiveScopes('unknown-event') - -worker_*, claude_job_*, heartbeat, question-events --> negeer voor de product-workspace, behoort op andere bounded contexts - (solo-store, notifications-store, jobs-store) -``` - -Concrete filter: - -```ts -function isUnknownEntityEvent(p: Record<string, unknown>): boolean { - if (typeof p.entity !== 'string') return false - if (['pbi', 'story', 'task'].includes(p.entity)) return false - if ('type' in p) return false // job/worker hebben `type` - return true -} -``` - -## Fetch en cache regels - -Read-routes die store-data voeden: - -```ts -export const dynamic = 'force-dynamic' -``` - -Client fetches vanuit `ensure...Loaded` en `resync...`: - -```ts -fetch(url, { cache: 'no-store' }) -``` - -SSE-routes blijven ook `force-dynamic`. SSE-routes zelf veranderen niet door -deze rearchitecture: auth (`getSession()`) en `getAccessibleProduct()` blijven -leidend. De rearchitecture raakt alleen wat de client met de events doet. - -Waar nuttig stuurt de SSE-route na `LISTEN` een initial state event. Dat -voorkomt de race waarbij de status wijzigt tussen de eerste DB-read en het -moment dat LISTEN actief is. - -## Optimistic updates - -Voor DnD en status toggles: - -```txt -1. Maak mutationId. -2. Bewaar rollback snapshot van alleen de betrokken relatie/entity. -3. Patch store direct. -4. Start server action. -5. Success: settle mutation. -6. Error: rollback mutation. -7. SSE echo: idempotent toepassen of markeren als bevestigd. -``` - -Voor create/update/delete dialogs is optimisme optioneel. Standaard mag: - -```txt -server action -> resultaat in store verwerken -> SSE echo idempotent negeren -``` - -## Sprint workspace - -Na stabilisatie van product-workspace volgt dezelfde vorm voor sprint: - -```txt -sprint-workspace-store - activeSprintId - selectedStoryId - selectedTaskId - storiesById - tasksById - pbisById - storyIdsByPbi - sprintStoryIds - taskIdsByStory - loaded scopes - applyRealtimeEvent() - resyncActiveScopes() -``` - -Dit vervangt op termijn de combinatie van lokale state in de sprint board en -de huidige `sprint-store` order maps. Pak het pas op nadat product-workspace -in productie staat en de eerste paar weken stabiel draait. - -## Implementation-gotchas - -Deze pitfalls zijn subtiel genoeg om opnieuw te maken. Documenteer ze in code -via comments boven de fix. - -### G1. `s.byId[x] ?? []` triggert React-loop - -```ts -// FOUT: nieuwe array per render → "Maximum update depth exceeded" -const stories = useStore((s) => s.storiesByPbi[pbiId] ?? []) - -// GOED: stable empty const op module-level -const EMPTY: BacklogStory[] = [] -const stories = useStore((s) => s.storiesByPbi[pbiId] ?? EMPTY) -``` - -### G2. List-selectors materialiseren → `useShallow` verplicht - -`selectVisiblePbis(state)` en vrienden bouwen `ids.map(id => byId[id])` per -call. Zonder `useShallow` re-rendert het component op elke onafhankelijke -store-mutatie. - -```ts -import { useShallow } from 'zustand/react/shallow' -const list = useStore(useShallow(selectVisiblePbis)) -``` - -Single-value selectors (b.v. `selectActivePbi`) hebben dit niet nodig — die -retourneren een stable entity-reference. - -### G3. immer + `setState((s) => ({...}))` REPLACES de state - -Met `zustand/middleware/immer` interpreteert `produce` een return-waarde als -de nieuwe state. Dat lijkt op de pre-immer Zustand-API maar wist alle andere -slices én alle action-properties. - -```ts -// FOUT (in immer-middleware): vervangt hele state met { context: {...} } -useStore.setState((s) => ({ context: { ...s.context, activePbiId: 'x' } })) - -// GOED: mutation-style (immer recipe muteert draft) -useStore.setState((s) => { s.context.activePbiId = 'x' }) -``` - -### G4. Method-refs zijn niet stabiel over state-versies - -```ts -// FOUT: state-snapshot kan andere method-ref hebben dan de huidige -async resyncActiveScopes(reason) { - const state = get() - // ... - state.ensureProductLoaded(...) // niet betrouwbaar -} - -// GOED: per call fresh ophalen via get() -async resyncActiveScopes(reason) { - const ctx = get().context - // ... - get().ensureProductLoaded(...) -} -``` - -### G5. Tests die acties mocken via setState lekken naar volgende tests - -`useStore.setState({ resyncActiveScopes: vi.fn() })` blijft staan na de test. -`beforeEach` reset alleen data-velden. Snapshot originele acties op -module-load + restore in `beforeEach`: - -```ts -const originalActions = (() => { - const s = useStore.getState() - return { resyncActiveScopes: s.resyncActiveScopes, /* ... */ } -})() - -function resetStore() { - useStore.setState({ ...initialData, ...originalActions }) -} -``` - -### G6. localStorage in vitest 4 + jsdom 29 - -In deze combinatie is `localStorage.clear/getItem/setItem` niet aanwezig op -het globale localStorage-object. Bind in `tests/setup.ts` een eigen -MemoryStorage: - -```ts -class MemoryStorage implements Storage { /* ... */ } -const memory = new MemoryStorage() -Object.defineProperty(globalThis, 'localStorage', { value: memory, configurable: true }) -Object.defineProperty(window, 'localStorage', { value: memory, configurable: true }) -``` - -### G7. `fetch` in node-test omgeving accepteert geen relative URLs - -`/api/products/...` faalt met `Invalid URL`. Mock fetch in elke test die -indirect een `ensure*Loaded` aanroept, of stub de implementatie. - -### G8. `Response`-body wordt één keer geconsumeerd - -`vi.spyOn(fetch).mockResolvedValue(response)` levert dezelfde Response aan -elke fetch — eerste `.json()` werkt, daarna error. Gebruik -`mockImplementation()` voor een fresh body per call: - -```ts -vi.spyOn(globalThis, 'fetch').mockImplementation(() => - Promise.resolve(new Response(JSON.stringify([]), { status: 200 })), -) -``` - -## Testing setup (Vitest) - -Minimale config: - -```ts -// vitest.config.ts -import { defineConfig } from 'vitest/config' -import path from 'node:path' -export default defineConfig({ - test: { - environment: 'jsdom', - include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'], - setupFiles: ['tests/setup.ts'], - }, - resolve: { alias: { '@': path.resolve(__dirname, '.') } }, -}) -``` - -`tests/setup.ts` bevat MemoryStorage-binding (zie G6) en `vi.restoreAllMocks()` -in `beforeEach`. - -Verplichte test-cases per workspace-store: - -- `hydrateSnapshot` vult entities + relations. -- Selection cascade: `setActivePbi` reset story+task; `setActiveStory` reset - task. -- `setActiveProduct(null)` ruimt entities en relations op. -- `applyRealtimeEvent`: pbi/story/task `I|U|D` met sortering en parent-move. -- `applyRealtimeEvent`: event voor ander product wordt genegeerd. -- `applyRealtimeEvent`: unknown entity met matching product → resync trigger. -- Delete-cleanup van actieve selectie. -- `ensureProductLoaded` fetch + sortering. -- Race-safe `ensure*Loaded` met requestId-guard (oude in-flight mag niet - nieuwere selectie overschrijven). -- `ensureTaskLoaded` zet detail-flag. -- `resyncActiveScopes` triggert ensure-keten met juiste URLs en zet - `lastResyncAt` + `resyncReason`. -- localStorage restore-hints: `setActiveProduct` en `setActivePbi` schrijven - de juiste keys. -- Optimistic mutation: rollback herstelt vorige state; settle ruimt pending - op; SSE-echo wordt idempotent verwerkt. - -## Implementatiepad - -Migratie van het bestaande systeem (`backlog-store` + `planner-store` + -`selection-store` + `product-store`) naar `product-workspace-store`. Doe dit -in opeenvolgende PRs zodat elke stap in productie te verifiëren is. - -### Stap 1 — Skelet opzetten (nieuwe store, nog niet gebruikt) - -1. Maak `stores/product-workspace/` met: - - `types.ts` (entity types, snapshot, realtime event union, ResyncReason). - - `store.ts` (factory met immer-middleware, alle slices, alle acties). - - `selectors.ts` (pure functies, useShallow-vriendelijk). - - `restore.ts` (localStorage hints met validatie). -2. Voeg `applyRealtimeEvent`, `resyncActiveScopes`, `resyncLoadedScopes`, - `ensure*Loaded` toe — eerst zonder integraties; getest via Vitest. -3. Acceptatie: alle test-cases uit §Testing setup groen. Geen UI-impact nog. - -### Stap 2 — Hydration overstappen - -4. `BacklogHydrationWrapper` (of equivalent) roept `hydrateSnapshot` aan op de - nieuwe store i.p.v. `useBacklogStore.setInitialData`. -5. `lib/realtime/use-backlog-realtime.ts` dispatcht naar - `useProductWorkspaceStore.applyRealtimeEvent` i.p.v. `applyChange` op de - oude store. -6. Componenten lezen voorlopig nog van de oude stores; nieuwe store loopt - parallel mee als read-only schaduwkopie. Vergelijk in dev-tools dat de - inhoud klopt. - -### Stap 3 — Componenten omzetten - -7. Per panel/component: vervang `useBacklogStore`/`useSelectionStore`/ - `useProductStore` reads door selectors uit de workspace-store + `useShallow` - waar nodig. -8. Setters (`selectPbi`, `selectStory`, `setCurrentProduct`) vervangen door de - workspace-acties (`setActivePbi`, `setActiveStory`, `setActiveProduct`). -9. Handle G1 en G2 expliciet: stable empty refs, useShallow voor lijsten. - -### Stap 4 — Race-safe en restore-hints - -10. `ensure*Loaded` met `activeRequestId`-guard implementeren conform - §Race-safe loaders. -11. localStorage hints introduceren met `await ensure*Loaded`-chain (zie - §Restore-hint flow). -12. Verifieer in een staging-omgeving: cold reload met persisted hint - herstelt selectie zonder fouten. - -### Stap 5 — Hidden-tab + reconnect-resync - -13. `use-backlog-realtime` aanpassen: niet meer sluiten op `hidden`, blijven - luisteren. Op `ready` na reconnect: `resyncActiveScopes('reconnect')`. -14. Aparte `useWorkspaceResync()`-hook: trigger `resyncActiveScopes('visible')` - bij `visibilitychange` van hidden→visible, en bij `online`-event. -15. Doe deze twee in één PR — los gezien zou je oude gedrag kwijtraken zonder - vangnet. - -### Stap 6 — Unknown-event fallback - -16. `applyRealtimeEvent` handelt onbekende entity-events af volgens §Unknown - relevant events. Filter op `isUnknownEntityEvent(payload)`. -17. Verifieer dat job-/worker-/heartbeat-events GEEN refetch triggeren. - -### Stap 7 — Cache-headers - -18. Zet `cache: 'no-store'` op alle client fetches uit `ensure*Loaded` en - `resync...`. -19. Bevestig `force-dynamic` op alle read-routes die store-data leveren. - -### Stap 8 — Oude stores opruimen - -20. Verwijder `stores/backlog-store.ts`, `stores/planner-store.ts`, - `stores/selection-store.ts`, `stores/product-store.ts` zodra geen - component er nog op leest. Grep over de hele codebase om verrassingen - te voorkomen. -21. `stores/products-store.ts` blijft (lijst van producten ≠ active product). - -### Stap 9 — Sprint workspace - -22. Herhaal het patroon voor `sprint-workspace-store`. Eerste een paar weken - de product-workspace stabiel laten draaien. - -## Acceptatiecriteria - -- Een PBI/story/task bestaat als waarheid maar op één plek in de - client-store. -- Product backlog panels lezen via selectors uit dezelfde workspace-store. -- PBI/story/task SSE-events patchen de store zonder full page refresh. -- Hidden -> visible herstelt gemiste wijzigingen binnen één resync-cyclus. -- Reconnect herstelt gemiste wijzigingen zonder afhankelijkheid van NOTIFY - replay. -- Directe entity-edits zonder herkenbare delta worden via resync zichtbaar - (unknown-event filter staat aan, job/worker noise niet meegerekend). -- LocalStorage kan een vorige selectie herstellen, maar nooit ontoegankelijke - of verwijderde entiteiten forceren; valideer altijd na ensure-load. -- Optimistic DnD heeft rollback en wordt niet dubbel toegepast door SSE - echoes. -- Read-routes en client fetches leveren geen stale browser/Next cache data. -- Test-suite dekt §Testing setup checklist en draait groen in CI. -- Geen "Maximum update depth exceeded" of "result of getServerSnapshot - should be cached" warnings in de console (zie §G1 en §G2). -- Auth en `getAccessibleProduct()` blijven ongewijzigd op SSE/read-routes; - deze rearchitecture raakt alleen client-state, geen serverlaag-security. diff --git a/docs/plans/zustand-workspace-store-implementation.md b/docs/plans/zustand-workspace-store-implementation.md deleted file mode 100644 index 78de9e2..0000000 --- a/docs/plans/zustand-workspace-store-implementation.md +++ /dev/null @@ -1,191 +0,0 @@ ---- -title: "Zustand workspace-store implementatieplan (PBI-74)" -status: in-progress -audience: [maintainer, contributor, ai-agent] -language: nl -last_updated: 2026-05-10 -revision: 2 ---- - -# Zustand workspace-store implementatieplan - -PBI in Scrum4Me-MCP: **PBI-74** — _Zustand store rearchitecture — product- en sprint-workspace_. - -Bron-ontwerp (architectuur en gotchas): [zustand-store-rearchitecture.md](./zustand-store-rearchitecture.md) revisie 3. - -Dit document koppelt de stories en taken in MCP aan de implementatie. Per story acceptatiecriteria; per taak een concrete deliverable. - -**Status (2026-05-10):** Stories 1-8 merged via PR #180 (product-workspace-store productie). Story 9 (sprint-workspace-store) uitgevoerd op `feat/sprint-workspace-store` — automatische verify+build groen, manuele E2E-staging-checks van T-884 nog te doen voor merge. - -## Context - -De client-state ligt over vier los gegroeide stores: `backlog-store`, `planner-store`, `selection-store`, `product-store`. Vier zwakheden: - -- SSE sluit op tab `hidden` zonder resync bij `visible` — gemiste events blijven gemist. -- Geen reconcile bij reconnect (Postgres NOTIFY heeft geen replay). -- Onbekende entity-events worden stil genegeerd. -- LocalStorage soms behandeld als waarheid i.p.v. restore-hint. -- Geen race-safe loaders — trage fetch van oude selectie kan nieuwste overschrijven. - -De rearchitecture lost dit op via één `product-workspace-store` (en analoog `sprint-workspace-store`) met genormaliseerde entity-maps, race-safe `ensure*Loaded` met `activeRequestId`-guard, expliciete resync-laag (visible/reconnect/unknown-event), idempotente SSE-application en localStorage als pure restore-hint. - -## Aanpak - -- Eén PBI ([PBI-74](./zustand-store-rearchitecture.md)). -- Negen stories die mappen op de stappen 1-9 in het bron-ontwerp. -- Granulariteit Story 3 = één story met taken per component. -- Story 5 in één PR (visibility-handling + resync horen samen). -- Per story: PR, `npm run verify && npm run build` groen, status DONE pas na merge. -- Branch: `feat/zustand-workspace-store` (één branch voor alle stories). - -## Stories en taken - -| # | Story | MCP | Taken | Status | -|---|---|---|---|---| -| 1 | Skelet + test-infrastructuur | ST-1318 | T-837 … T-843 (7) | DONE (PR #180) | -| 2 | Hydratie overstappen (parallel-running) | ST-1319 | T-844 … T-847 (4) | DONE (PR #180) | -| 3 | Componenten omzetten naar workspace-store | ST-1320 | T-848 … T-855 (8) | DONE (PR #180) | -| 4 | Race-safe loaders en restore-hints | ST-1321 | T-856 … T-860 (5) | DONE (PR #180) | -| 5 | Hidden-tab + reconnect resync (één PR) | ST-1322 | T-861 … T-864 (4) | DONE (PR #180) | -| 6 | Unknown-event fallback | ST-1323 | T-865 … T-867 (3) | DONE (PR #180) | -| 7 | Cache-headers en read-routes | ST-1324 | T-868 … T-871 (4) | DONE (PR #180) | -| 8 | Oude stores opruimen | ST-1325 | T-872 … T-878 (7) | DONE (PR #180) | -| 9 | Sprint-workspace-store | ST-1326 | T-879 … T-884 (6) | T-879…T-883 DONE; T-884 review | - -Totaal: 48 taken. - -### Story 1 — Skelet + test-infrastructuur - -**Doel:** nieuwe store + selectors + restore-utils met volledige unit-test-suite, nog zonder UI-consumenten. - -**Belangrijkste taken:** -- T-837 — Vitest naar jsdom + `tests/setup.ts` met MemoryStorage (G6). -- T-838/839/840/841 — `stores/product-workspace/{types,store,selectors,restore}.ts`. -- T-842 — Volledige test-suite per §Testing setup-checklist (G5/G7/G8). -- T-843 — API endpoint-audit voor `ensure*Loaded` URLs. - -**Acceptatie:** alle test-cases groen, geen UI-impact. - -### Story 2 — Hydratie overstappen - -**Doel:** `BacklogHydrationWrapper` en `useBacklogRealtime` voeden zowel oude store als nieuwe store. Componenten lezen nog uit oude. - -**Taken:** T-844 (wrapper dual-dispatch), T-845 (realtime dual-dispatch), T-846 (dev-only fingerprint verifyer), T-847 (productpicker → setActiveProduct). - -**Acceptatie:** schaduw-store inhoud matcht oude store na elk SSE-event. - -### Story 3 — Componenten omzetten - -**Doel:** componenten lezen uit nieuwe workspace-store; oude stores hebben geen UI-consumenten meer. - -**Taken per component:** T-848 (split-pane), T-849 (pbi-list), T-850 (story-panel), T-851 (task-panel), T-852 (start-sprint-button), T-853 (set-current-product). Plus T-854 (G1/G2-audit) en T-855 (integration-tests bijwerken). - -**Acceptatie:** geen "Maximum update depth" warnings; oude store-imports alleen nog in tests die in Story 8 verdwijnen. - -### Story 4 — Race-safe loaders en restore-hints - -**Doel:** `ensure*Loaded` met `activeRequestId`-guard; localStorage hints met validatie. - -**Taken:** T-856 (guard), T-857 (restore-flow met await ensure-chain), T-858 (hint-persistentie), T-859 (URL-prioriteit), T-860 (race-safety tests). - -**Acceptatie:** trage fetch + her-selectie corrumpeert nooit; cold reload restoret zonder fout. - -### Story 5 — Hidden-tab + reconnect resync (één PR) - -**Doel:** SSE blijft open op hidden; resync via expliciete laag. - -**Taken:** T-861 (geen close op hidden), T-862 (ready-event triggert resync na reconnect), T-863 (`useWorkspaceResync` hook), T-864 (tests). - -**Acceptatie:** hidden→visible en reconnect herstellen gemiste wijzigingen in één cyclus. - -### Story 6 — Unknown-event fallback - -**Doel:** onbekende entity-events triggeren resync; job/worker noise wordt genegeerd. - -**Taken:** T-865 (`isUnknownEntityEvent` filter), T-866 (resync-trigger), T-867 (negatieve filter-tests). - -**Acceptatie:** directe DB UPDATE zonder herkenbare delta-event wordt zichtbaar binnen één resync; job-events triggeren geen resync. - -### Story 7 — Cache-headers en read-routes - -**Doel:** geen stale data uit Next/browser cache. - -**Taken:** T-868 (`cache: 'no-store'`), T-869 (`force-dynamic` audit), T-870 (LIST-endpoints toevoegen waar nodig), T-871 (SSE-route ready-event coverage). - -**Acceptatie:** response headers in productie tonen `cache-control: no-store`; LIST-endpoints bestaan voor alle `ensure*Loaded`. - -### Story 8 — Oude stores opruimen - -**Doel:** vier oude stores verwijderd. - -**Taken:** T-872 (grep), T-873/874/875/876 (delete vier files), T-877 (oude tests migreren), T-878 (`stores/products-store.ts` blijft + dev-fingerprint cleanup). - -**Acceptatie:** grep `useBacklogStore|usePlannerStore|useSelectionStore|useProductStore` = 0; `npm run verify && npm run build` groen. - -### Story 9 — Sprint-workspace-store - -**Doel:** zelfde patroon op sprint-workflow toegepast. - -**Taken:** -- **T-879 — Skelet** (DONE): `stores/sprint-workspace/{types,store,selectors,restore}.ts` + 45 unit-tests groen. Mirrort product-workspace blueprint met sprint-specifieke aanpassingen (sprintIdsByProduct, storyIdsBySprint, sprint-story-membership semantiek). -- **T-880 — Hydratie + realtime** (DONE): `app/api/realtime/sprint/route.ts` SSE-endpoint, `lib/realtime/use-sprint-realtime.ts`, `lib/realtime/use-sprint-workspace-resync.ts`, `components/sprint/sprint-hydration-wrapper.tsx`. Wrapper hydreert via fingerprint-check; SSE blijft open op hidden, ready-cycle triggert reconnect-resync. -- **T-881 — Componenten** (DONE): TaskList, SprintBacklogLeft, SprintBoardClient lezen via selectors uit `useSprintWorkspaceStore` met `useShallow`. DnD via `applyOptimisticMutation('sprint-story-order' | 'sprint-task-order')` met settle/rollback; add/remove via direct setState met manuele snapshot-rollback. -- **T-882 — Race-safe + restore + resync + unknown-event + read-routes** (DONE): `GET /api/products/[id]/sprints` en `GET /api/sprints/[id]/workspace` toegevoegd; activeRequestId-guard + restore-flow + useSprintWorkspaceResync + isUnknownEntityEvent waren al geïmplementeerd in T-879/T-880. -- **T-883 — Cleanup** (DONE): `stores/sprint-store.ts` verwijderd. Grep `useSprintStore` = 0. Verify (671 tests) + build groen. -- **T-884 — E2E sprint-board verificatie** (REVIEW — manuele staging-checks): - - [ ] Cold reload → laatste sprint hersteld - - [ ] Tab hidden > 30s + terug → resync - - [ ] Netwerk uit/aan → reconnect + resync - - [ ] DnD reorder → optimistic UI; SSE-echo idempotent - - [ ] DB UPDATE story zonder delta → unknown-event resync binnen 1 cycle - - [ ] Twee tabs open → mutatie zichtbaar in beide binnen ~2s - -> **Aanbeveling per ontwerpdoc:** Story 9 was bedoeld om pas te starten nadat product-workspace enkele weken stabiel in productie staat. PR #180 merged 2026-05-10; Story 9 vervolgens diezelfde dag uitgevoerd op gebruikersverzoek. Stabiliteit van product-workspace + impact van Story 9 op sprint-workflow nog te observeren in staging/productie. - -## Critical files - -**Te wijzigen:** -- `vitest.config.ts` — env naar jsdom, setupFiles -- nieuw: `tests/setup.ts` — MemoryStorage, restoreAllMocks -- nieuw: `stores/product-workspace/{types,store,selectors,restore}.ts` -- nieuw: `stores/sprint-workspace/{types,store,selectors,restore}.ts` -- nieuw: `lib/realtime/use-workspace-resync.ts` -- `components/backlog/backlog-hydration-wrapper.tsx` -- `lib/realtime/use-backlog-realtime.ts` -- `components/backlog/backlog-split-pane.tsx`, `pbi-list.tsx`, `story-panel.tsx`, `task-panel.tsx` -- `components/.../start-sprint-button.tsx`, `set-current-product.tsx` -- read-routes onder `app/api/...` voor PBI/story/task LIST + detail -- te verwijderen in Story 8: `stores/{backlog,planner,selection,product}-store.ts` - -**Te hergebruiken (geen wijziging):** -- `lib/product-access.ts` — `getAccessibleProduct`, blijft auth/access-bron -- `app/api/realtime/backlog/route.ts` — `ready`-event al aanwezig -- `docs/patterns/realtime-notify-payload.md` — payload-contract -- `docs/patterns/route-handler.md` — REST patroon -- `stores/products-store.ts`, `stores/solo-store.ts`, `stores/notifications-store.ts`, `stores/idea-store.ts`, `stores/jobs-store.ts` — blijven ongewijzigd - -## Verificatie per story - -- **Story 1:** Vitest groen voor alle test-cases (hydrate, cascade, realtime, ensureLoaded race, resync, restore-hints, optimistic mutation). -- **Story 2:** dev-server, productpagina, fingerprint match in console. -- **Story 3:** klik door 3 panels, DnD test, geen "Maximum update depth"-warnings. -- **Story 4:** staging — cold reload, throttle fetch + her-selecteer. -- **Story 5:** tab hidden > 30s + terug → resync zichtbaar; netwerk uit/aan → reconnect+resync. -- **Story 6:** DB UPDATE op story zonder delta-event → zichtbaar binnen 1 resync; job-events negeren resync. -- **Story 7:** response headers `cache-control: no-store`; tweede pageload toont verse data. -- **Story 8:** grep oude store-imports = 0; `npm run verify && npm run build` groen. -- **Story 9:** sprint-board flow analoog Story 1-8 verifications. - -**Eind-acceptatie PBI-74:** alle items uit §Acceptatiecriteria van [zustand-store-rearchitecture.md](./zustand-store-rearchitecture.md) (regels 727-746) behaald. - -## Workflow per story - -1. `git checkout -b feat/zustand-workspace-store` (eerste story); blijf op deze branch tot expliciete cut. -2. `mcp__scrum4me__get_claude_context` → pak next story uit PBI-74. -3. Voer taken uit in `sort_order`; update status per taak via `mcp__scrum4me__update_task_status`. -4. Lees relevante bestanden + patronen vóór begin (zie §Critical files). -5. `npm run verify && npm run build` per laag. -6. Commit per laag (`git add -A && git commit`); geen push tussendoor. -7. Story-status sluit zodra alle taken `DONE`. -8. Lege story-queue → `git push -u origin feat/zustand-workspace-store` + `gh pr create`. -9. Per story een eigen PR; merge één voor één. diff --git a/docs/old/product-backlog.md b/docs/product-backlog.md similarity index 100% rename from docs/old/product-backlog.md rename to docs/product-backlog.md diff --git a/docs/recommendations/beelink-ubuntu-scrum4me-server-caveman-plan.md b/docs/recommendations/beelink-ubuntu-scrum4me-server-caveman-plan.md deleted file mode 100644 index cb062c2..0000000 --- a/docs/recommendations/beelink-ubuntu-scrum4me-server-caveman-plan.md +++ /dev/null @@ -1,458 +0,0 @@ ---- -title: "Caveman plan — Beelink naar Ubuntu Scrum4Me server" -status: draft -audience: [maintainer, operator] -language: nl -last_updated: 2026-05-09 ---- - -# Caveman plan — Beelink naar Ubuntu Scrum4Me server - -## Doel - -Zet de Beelink mini-PC om naar een dual-boot machine waarop **Ubuntu Server 24.04 LTS** de standaard server-boot is. Windows blijft bestaan als fallback, maar Scrum4Me draait op Ubuntu. - -Doelopstelling: - -```text -Beelink mini-PC -├─ Windows fallback -└─ Ubuntu Server 24.04 LTS default - ├─ Docker Engine - ├─ Postgres - ├─ Scrum4Me webserver - ├─ worker-idea - ├─ worker-implementation - └─ worker-orchestrator -``` - -## Hardware - -Bekende specs: - -| Onderdeel | Waarde | -|---|---| -| Merk | Beelink | -| CPU | Intel Core i5-12450H | -| CPU boost | Tot 4,4 GHz | -| RAM | 32 GB DDR4 | -| Opslag | 1 TB | -| GPU | Intel integrated graphics | -| Vorm | Mini-PC | - -Conclusie: geschikt voor Scrum4Me als single-user/small-team server, mits implementation-concurrency op 1 blijft en resource limits strak staan. - -## Caveman Regels - -- Geen Ubuntu Desktop installeren. -- Geen GPU-driver installeren tenzij beeld echt kapot is. -- Geen Docker Desktop. -- Geen Postgres-poort naar internet. -- Geen Docker socket mounten in workercontainers. -- Geen repos, caches of worktrees op de Windows-partitie. -- Alles onder `/srv/scrum4me`. -- Eerst één worker werkend krijgen, daarna pas drie. -- Eerst via lokaal IP testen, daarna pas domein/TLS. -- Ubuntu wordt default boot; Windows is fallback. - -## Waarom Geen Drivergedoe Verwacht Wordt - -De CPU heeft Intel integrated graphics. Ubuntu Server heeft geen desktop nodig. Intel geeft aan dat de meeste Linux-distributies Intel graphics drivers al meeleveren. Voor deze machine verwacht je de kernel-driver `i915`. - -Na installatie alleen checken: - -```bash -lspci -k | grep -EA3 'VGA|3D|Display' -lsmod | grep i915 -``` - -Als `i915` zichtbaar is: klaar. Niet verder aan sleutelen. - -## Fase 0 — Voorbereiding In Windows - -1. Maak backup van belangrijke Windows-data. -2. Sla BitLocker recovery key op als BitLocker aan staat. -3. Zet Windows Fast Startup uit: - - Control Panel - - Power Options - - Choose what the power buttons do - - Turn off fast startup -4. Maak vrije ruimte: - - Open Disk Management. - - Shrink `C:`. - - Laat ongeveer `600 GB` unallocated voor Ubuntu. - -Aanbevolen diskverdeling: - -```text -Windows: 250-300 GB -Ubuntu /: 120 GB -/srv/scrum4me: rest van vrije ruimte -swapfile: 16 GB -EFI: bestaande EFI behouden -``` - -## Fase 1 — Ubuntu USB Maken - -1. Download Ubuntu Server 24.04 LTS amd64. -2. Maak USB-stick met Rufus of Balena Etcher. -3. Sluit ethernet aan op de Beelink. -4. Boot van USB. - -Veelvoorkomende Beelink toetsen: - -```text -Boot menu: F7 -BIOS: Del -``` - -BIOS-checks: - -```text -UEFI boot: aan -Secure Boot: mag aan blijven, maar uitzetten als install gedoe geeft -Power on after power loss: aan -Ubuntu later als eerste boot entry -``` - -## Fase 2 — Ubuntu Installeren - -Kies tijdens installatie: - -```text -Install Ubuntu Server -OpenSSH server: YES -Desktop: NO -Storage: Custom layout -``` - -Storage: - -```text -Bestaande EFI partition: - mount: /boot/efi - formatteren: NEE - -Nieuwe ext4 partition 120 GB: - mount: / - -Nieuwe ext4 partition rest: - mount: /srv/scrum4me -``` - -Niet kiezen: - -```text -Use entire disk -``` - -Dat zou Windows verwijderen. - -## Fase 3 — Eerste Boot - -Login op Ubuntu. - -```bash -sudo apt update -sudo apt upgrade -y -sudo reboot -``` - -Na reboot: - -```bash -sudo hostnamectl set-hostname scrum4me-server -ip a -``` - -Zet in je router een DHCP reservation voor het IP-adres. Dat is simpeler dan handmatige netwerkconfiguratie. - -## Fase 4 — Server Niet Laten Slapen - -```bash -sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target -``` - -In BIOS: - -```text -Power on after power loss: ON -Sleep: OFF als optie bestaat -Boot order: Ubuntu eerst -``` - -## Fase 5 — Basis Tools - -```bash -sudo apt install -y git curl ca-certificates gnupg htop iotop ufw fail2ban unzip jq -``` - -Firewall: - -```bash -sudo ufw allow OpenSSH -sudo ufw allow 80 -sudo ufw allow 443 -sudo ufw enable -sudo ufw status -``` - -Let op: Docker kan gepubliceerde containerpoorten buiten gewone `ufw`-verwachtingen om bereikbaar maken. Publiceer straks alleen reverse proxy poorten naar buiten. - -## Fase 6 — Docker Engine Installeren - -Gebruik Docker Engine native op Ubuntu. Geen Docker Desktop. - -```bash -sudo apt update -sudo apt install -y ca-certificates curl -sudo install -m 0755 -d /etc/apt/keyrings -sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc -sudo chmod a+r /etc/apt/keyrings/docker.asc -``` - -```bash -echo \ - "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ - $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \ - sudo tee /etc/apt/sources.list.d/docker.list > /dev/null -``` - -```bash -sudo apt update -sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -sudo usermod -aG docker $USER -sudo reboot -``` - -Na reboot: - -```bash -docker run hello-world -docker compose version -``` - -## Fase 7 — Scrum4Me Directories - -```bash -sudo mkdir -p /srv/scrum4me/{postgres,repos,worker-cache,worker-logs,worker-state,backups,compose,caddy} -sudo chown -R $USER:$USER /srv/scrum4me -``` - -Doelstructuur: - -```text -/srv/scrum4me/postgres database data -/srv/scrum4me/repos cloned GitHub repos / mirrors -/srv/scrum4me/worker-cache npm/git/cache -/srv/scrum4me/worker-logs worker logs -/srv/scrum4me/worker-state worker state -/srv/scrum4me/backups local backup staging -/srv/scrum4me/compose docker compose files -/srv/scrum4me/caddy reverse proxy config -``` - -## Fase 8 — Services - -Einddoel: - -```text -postgres -scrum4me-web -worker-idea -worker-implementation -worker-orchestrator -caddy -``` - -Aanbevolen resource limits voor 32 GB RAM: - -| Service | CPU limit | Memory limit | Opmerking | -|---|---:|---:|---| -| `postgres` | 2 CPU | 3-4 GB | Lokale DB | -| `scrum4me-web` | 2 CPU | 2-3 GB | Next.js runtime | -| `worker-idea` | 2 CPU | 4 GB | Grill, plan, chat | -| `worker-implementation` | 4-5 CPU | 10-12 GB | Zwaarste worker | -| `worker-orchestrator` | 2-3 CPU | 5-6 GB | PR review, CI triage, conflicts | -| `caddy` of `nginx` | 0.25 CPU | 256 MB | Reverse proxy | - -Laat 5-7 GB vrij voor Ubuntu, Docker overhead, filesystem cache en pieken. - -## Fase 9 — Tokens en Secrets - -Maak aparte tokens per rol: - -```text -SCRUM4ME_TOKEN_IDEA -SCRUM4ME_TOKEN_IMPLEMENTATION -SCRUM4ME_TOKEN_ORCHESTRATOR - -GH_TOKEN_IDEA -GH_TOKEN_IMPLEMENTATION -GH_TOKEN_ORCHESTRATOR - -CLAUDE_CODE_OAUTH_TOKEN_IDEA -CLAUDE_CODE_OAUTH_TOKEN_IMPLEMENTATION -CLAUDE_CODE_OAUTH_TOKEN_ORCHESTRATOR -``` - -Tokenbeleid: - -| Token | Rechten | -|---|---| -| Idea | Read-only waar mogelijk, markdown/status updates via Scrum4Me | -| Implementation | GitHub contents RW + pull requests RW | -| Orchestrator | PR RW, contents RW alleen voor conflict/repair | - -Bestandsrechten: - -```bash -chmod 600 /srv/scrum4me/compose/*.env -``` - -Geen secrets in git. - -## Fase 10 — Backups - -Minimum: - -```text -Elke nacht pg_dump -Elke nacht backup van /srv/scrum4me/compose -Offsite kopie naar cloud, NAS of externe disk -Backup restore maandelijks testen -``` - -Lokale backup alleen is onvoldoende. Als de SSD stuk gaat, is alles weg. - -## Fase 11 — Monitoring - -Simpel beginnen: - -```bash -docker ps -docker stats -htop -iotop -df -h -du -sh /srv/scrum4me/* -journalctl -u docker --no-pager -n 100 -``` - -Dagelijkse health checklist: - -```text -Docker containers up? -Disk < 80% vol? -Backups gelukt? -Workers online? -Postgres bereikbaar? -Webserver bereikbaar via HTTPS? -Geen runaway logs? -``` - -## Fase 12 — Uitrolvolgorde - -Niet alles tegelijk. - -1. Ubuntu werkt. -2. SSH werkt. -3. Docker werkt. -4. Caddy/nginx testpagina werkt. -5. Postgres container werkt. -6. Scrum4Me webserver werkt lokaal. -7. Scrum4Me webserver werkt via HTTPS. -8. Eén worker werkt: `worker-idea`. -9. Tweede worker werkt: `worker-implementation`. -10. Derde worker werkt: `worker-orchestrator`. -11. Role-aware queue claiming aanzetten. -12. Backups testen. - -## Eerste Smoke Test - -Na installatie: - -```bash -hostnamectl -free -h -df -h -lscpu -docker version -docker compose version -docker run hello-world -lspci -k | grep -EA3 'VGA|3D|Display' -lsmod | grep i915 -``` - -Verwacht: - -```text -Ubuntu 24.04 LTS -~32 GB RAM zichtbaar -Docker werkt -1 TB disk verdeeld zoals gepland -i915 zichtbaar voor Intel integrated graphics -``` - -## Foutscenario's - -### Geen beeld na Ubuntu install - -Eerst: - -```text -Gebruik HDMI-poort 1 -Gebruik andere kabel -Boot recovery mode -Probeer tijdelijk Secure Boot uit -``` - -Niet meteen drivers installeren. - -### Windows start direct, geen Ubuntu menu - -BIOS boot order aanpassen: - -```text -Ubuntu boven Windows Boot Manager -``` - -### Docker permission denied - -```bash -groups -``` - -Als `docker` ontbreekt: - -```bash -sudo usermod -aG docker $USER -sudo reboot -``` - -### Server wordt traag - -Check: - -```bash -docker stats -free -h -htop -iotop -``` - -Eerste maatregel: - -```text -implementation-worker alleen laten draaien -orchestrator zware builds verbieden -worker memory limits verlagen -``` - -## Bronnen - -- Ubuntu Server requirements: <https://ubuntu.com/server/docs/reference/installation/system-requirements/> -- Ubuntu Server install docs: <https://ubuntu.com/server/docs/how-to/installation/> -- Intel Linux graphics guidance: <https://www.intel.com/content/www/us/en/support/articles/000005520/graphics.html> -- Docker Engine on Ubuntu: <https://docs.docker.com/installation/ubuntulinux/> -- Next.js self-hosting: <https://nextjs.org/docs/app/guides/self-hosting> diff --git a/docs/recommendations/bootstrap-wizard-plan-review-2026-05-13.md b/docs/recommendations/bootstrap-wizard-plan-review-2026-05-13.md deleted file mode 100644 index 03a4c10..0000000 --- a/docs/recommendations/bootstrap-wizard-plan-review-2026-05-13.md +++ /dev/null @@ -1,89 +0,0 @@ ---- -title: "Review - Bootstrap-wizard plan" -status: draft -date: 2026-05-13 -source_plan: "/Users/janpetervisser/.claude/plans/als-ik-een-nieuwe-virtual-turtle.md" ---- - -# Review - Bootstrap-wizard plan - -## Korte conclusie - -Het plan is functioneel sterk, maar niet uitvoerbaar zoals het nu geschreven is. De hoofdblokkade is dat `ClaudeJob` wordt gebruikt als deterministische queue, terwijl de huidige runner-architectuur `ClaudeJob` nog behandelt als een Claude CLI job met een verplicht model/config-pad. Trek dat eerst recht, anders eindigt de feature in typefouten, jobs die nooit terminal worden, of een worker die toch Claude probeert te starten. - -## Bevindingen - -### P1 - `BOOTSTRAP_REPO` met `model: null` breekt het huidige job-config contract - -Het plan zet voor `BOOTSTRAP_REPO` expliciet `model: null` omdat er geen LLM draait (plan regels 101-106). In de huidige code is `JobConfig.model` niet nullable en beperkt tot `ClaudeModel`; `snapshotFromConfig` schrijft die waarde daarna naar `ClaudeJob.requested_model` als string (`lib/job-config.ts` regels 27-33 en 205-210). `getJobConfigSnapshot` is bovendien het bestaande enqueue-pad voor nieuwe jobs (`lib/job-config-snapshot.ts` regels 1-7 en 34-39). - -Fix: maak deterministische jobs een expliciet ander runtime-pad. Bijvoorbeeld een discriminated union `runtime: 'claude' | 'deterministic'`, of laat `BOOTSTRAP_REPO` de Claude config snapshot volledig overslaan. Alleen een `KIND_DEFAULTS` entry met `model: null` is onvoldoende. - -### P1 - De worker-eigenaar staat verkeerd of is te vaag - -Het plan plaatst de dispatch in `scrum4me-mcp/src/lib/job-runner.ts` en noemt worker-bestanden in `scrum4me-mcp` (plan regels 172 en 235-238). De actuele runner-architectuur zegt iets anders: `scrum4me-docker/bin/run-one-job.ts` claimt jobs, resolve't config, bouwt CLI flags en spawnt `claude`; MCP levert tools/schema (`docs/runbooks/worker-idempotency.md` regels 170-176 en `docs/runbooks/mcp-integration.md` regel 12). - -Als alleen Scrum4Me en `scrum4me-mcp` wijzigen, gaat de docker-runner de nieuwe kind nog steeds claimen en behandelen als Claude-job. Neem een expliciete wijziging op voor `scrum4me-docker`, of definieer een aparte bootstrap-executor. Let ook op: de huidige worker doet een Anthropic quota pre-flight voordat hij claimt (`docs/runbooks/mcp-integration.md` regels 80-93). Daardoor kan een no-LLM bootstrap-job onterecht wachten op quota. - -### P1 - De worker-flow sluit de `ClaudeJob` niet terminal af - -In de pseudo-flow wordt bij succes alleen `BootstrapRun` en `Product` bijgewerkt, gevolgd door een generieke `NOTIFY` (plan regels 185-186). Bij fouten noemt het plan eveneens vooral `BootstrapRun` (plan regel 187). Het bestaande queue-protocol verwacht dat de job zelf naar `DONE`, `FAILED` of `CANCELLED` gaat en dat een `claude_job_status` event wordt verstuurd (`docs/runbooks/mcp-integration.md` regels 44-49). - -Fix: maak `BootstrapRun.status` en `ClaudeJob.status` een transactionele status-sync. Bij succes: `BootstrapRun.SUCCEEDED`, `ClaudeJob.DONE`, `finished_at`, `summary`, `repo_url`/`template_version`. Bij failure/cancel: beide terminal, inclusief `error`, en een `claude_job_status` notify. Anders blijven jobs `CLAIMED` of `RUNNING` en grijpt stale recovery later fout in. - -### P1 - De enum-uitbreiding veroorzaakt build-fouten buiten de genoemde files - -Het plan noemt `ClaudeJobKind.BOOTSTRAP_REPO`, maar niet alle plekken die exhaustief over `ClaudeJobKind` heen lopen. `JobCard` en `JobsColumn` gebruiken bijvoorbeeld `Record<ClaudeJobKind, string>` (`components/jobs/job-card.tsx` regels 28-34 en `components/jobs/jobs-column.tsx` regels 16-22). Na Prisma generate mist daar een key en faalt typecheck. - -Fix: voeg jobs board labels/filters, initial SSE payloads, job detail rendering, cost/insight aggregaties en tests toe aan de scope. Dit is geen nice-to-have; het is build-path. - -### P1 - `BootstrapRun` koppeling mist relationele details - -Het plan zet `BootstrapRun.claude_job_id` als nullable FK en laat de worker de run ophalen via `run_id` (plan regels 83-90 en 175), maar `ClaudeJob` heeft nu alleen task/idea/sprint koppelingen (`prisma/schema.prisma` regels 385-424). Zonder helder model blijft onduidelijk hoe de geclaimde job precies bij de run komt. - -Fix: maak `BootstrapRun.claude_job_id` `@unique`, voeg relation names en een reverse relation op `ClaudeJob` toe, en indexeer `product_id/status`. Leg ook vast dat `startBootstrapAction` atomair voorkomt dat er meerdere actieve `PENDING`/`RUNNING` runs voor hetzelfde product ontstaan. Dit staat nu als open punt (plan regel 323), maar hoort in MVP. - -### P1 - PAT-encryptie botst met de huidige worker-secret boundary - -Het plan staat encryptie met `SESSION_SECRET` of een optionele `BOOTSTRAP_ENCRYPTION_KEY` toe (plan regels 98-110), en laat de worker de PAT decrypten (plan regel 176). De docker-worker docs zeggen juist dat de worker geen `DATABASE_URL`, `SESSION_SECRET` of `CRON_SECRET` hoort te hebben (`docs/manual/05-docker.md` regels 52-64). - -Fix: kies een expliciete credential-boundary. Waarschijnlijk moet `BOOTSTRAP_ENCRYPTION_KEY` verplicht worden voor app plus deterministische executor, of moet GitHub-side werk in de app/MCP-service gebeuren waar decryptie toegestaan is. Specificeer ook minimale PAT scopes, owner/namespace-keuze en voorkom dat de bestaande worker-level `GITHUB_TOKEN` per ongeluk repos onder de verkeerde account aanmaakt. - -### P1 - `BootstrapAction.params` is te vrij voor filesystem-acties - -Het plan gebruikt `params Json` voor acties en noemt alleen een bash allowlist als securitymaatregel (plan regels 57-75 en 210-214). Maar `COPY_FILE`, `WRITE_FILE`, `APPEND_TO_FILE` en `REPLACE_STRING` kunnen ook schade doen: path traversal via `../`, schrijven naar `.git/config`, absolute paden, te grote bestanden/logs, of onbedoelde workflow-mutaties. - -Fix: valideer elke action-kind met een Zod-schema bij seed/admin-save en opnieuw bij uitvoering. Normaliseer paden en assert dat source/dest binnen de template root of output root blijven. Deny `.git/**`, absolute paden en parent traversal. Cap `output_log`, `content` en aantal acties per run. - -### P1 - MVP spreekt de verplichte zes ADR-stubs tegen - -Het plan noemt zes verplichte ADR-stubs voor deploy/auth/DB/styling/state/testing (plan regel 25), maar de MVP seed bevat alleen deploy/auth/database (plan regels 253-260). De verificatie checkt ook alleen ADR-0001 tot ADR-0003 (plan regels 287-294). - -Fix: genereer de zes core ADR-stubs onvoorwaardelijk in MVP, of neem alle zes categorieen op in Sprint 1. Anders is de MVP niet consistent met de eigen acceptatie. - -### P2 - Fysieke UI-paden kloppen niet met de App Router route groups - -Het plan noemt fysieke files onder `app/products`, `app/settings` en `app/admin` (plan regels 159-164 en 240-244). In deze codebase zitten desktop routes onder `app/(app)/...` (`docs/architecture/project-structure.md` regels 18-42), bijvoorbeeld `app/(app)/products/[id]/page.tsx`. - -Fix: corrigeer de filelijst naar `app/(app)/products/[id]/...`, `app/(app)/settings/...` en `app/(app)/admin/...`. De URL blijft hetzelfde; de fysieke implementatieplek niet. - -### P2 - Verificatie noemt een niet-bestaand worker-script - -De verificatie zegt "manual: `npm run worker`" (plan regel 291), maar `package.json` heeft geen `worker` script (`package.json` regels 5-26). Dat maakt de E2E-stap niet reproduceerbaar. - -Fix: verwijs naar het echte `scrum4me-docker` runnercommando of voeg bewust een dev-script toe als onderdeel van de feature. - -### P2 - Repo-slug en GitHub owner zijn nog onvoldoende gespecificeerd - -De flow gebruikt `<productSlug>` en `<owner>` (plan regels 180-184), maar `Product` heeft nu `name`, optionele `code` en `repo_url`; geen slugveld (`prisma/schema.prisma` regels 196-227). `code` is bovendien niet hetzelfde als GitHub repo-validatie. - -Fix: voeg `repo_slug` toe aan de wizard of maak een gesnapshotte derivatie met GitHub-regels, collision-check, owner-keuze en duidelijke foutmelding wanneer de repo al bestaat. - -## Aanbevolen aanpassing van de volgorde - -1. Ontwerp eerst het deterministic-job contract: status-sync, runner-eigenaar, quota-bypass, config-bypass en `BootstrapRun` relation. -2. Voeg daarna schema + seed toe met path/action validatie en zes minimale ADR-stubs. -3. Bouw PAT settings en GitHub token test met expliciete scopes en owner-keuze. -4. Bouw pas daarna de wizard UI en E2E runner. - -Met die volgorde blijft de UI dun en voorkom je dat het meeste risico pas in de worker-integratie zichtbaar wordt. diff --git a/docs/recommendations/bootstrap-wizard-plan-v2-web-research-review-2026-05-13.md b/docs/recommendations/bootstrap-wizard-plan-v2-web-research-review-2026-05-13.md deleted file mode 100644 index a821dba..0000000 --- a/docs/recommendations/bootstrap-wizard-plan-v2-web-research-review-2026-05-13.md +++ /dev/null @@ -1,210 +0,0 @@ ---- -title: "Review - Bootstrap-wizard plan v2 met webresearch" -status: draft -date: 2026-05-13 -source_plan: "/Users/janpetervisser/.claude/plans/als-ik-een-nieuwe-virtual-turtle.md" -previous_review: "docs/recommendations/bootstrap-wizard-plan-review-2026-05-13.md" ---- - -# Review - Bootstrap-wizard plan v2 met webresearch - -## Conclusie - -De eerdere aanbevelingen zijn grotendeels verwerkt, maar nog niet "goed" genoeg om dit plan direct naar implementatie te brengen. V2 lost de meeste oude schema-, enum-, status- en path-safety punten op papier op. De grootste resterende fout is dat het plan twee executor-modellen tegelijk beschrijft: eerst `scrum4me-docker` als deterministic runner, later de Next.js app als executor met een fire-and-forget background promise. Kies er een. - -Mijn advies: maak de app niet de lange-running executor. Gebruik voor MVP een aparte `bootstrap-service` of breid de bestaande docker-runner expliciet uit met een veilig secret-contract. Vercel/Next fire-and-forget is te broos voor clone, file mutation, GitHub repo-create en push. - -## Zijn de eerdere aanbevelingen verwerkt? - -| Reviewpunt | Status | Oordeel | -|---|---:|---| -| Deterministic runtime ipv `model: null` | Ja | Goed concept, maar nog te veel gekoppeld aan `JobConfig` als de app uiteindelijk executor wordt. | -| Worker-eigenaar expliciet maken | Deels | V2 spreekt zichzelf tegen: docker-runner dispatch versus app-orchestrator. | -| Transactionele `BootstrapRun` + `ClaudeJob` status-sync | Ja | Goed. Hou notify na commit, niet in de DB-transaction zelf. | -| `ClaudeJobKind` exhaustive consumers | Ja | Goed opgenomen. | -| `BootstrapRun.claude_job_id @unique` + reverse relation | Ja | Goed. | -| Concurrency guard | Ja | Goed, vooral met DB-level partial unique index. | -| PAT secret-boundary | Deels | Docker krijgt geen DB/secrets meer, maar PAT wordt nu in memory doorgegeven aan een background promise. Dat is niet duurzaam. | -| Action-param validatie/path-safety | Ja | Goed, maar `condition: String?` blijft een risico. | -| Zes ADR-stubs in MVP | Ja | Goed. | -| App Router paden | Ja | Goed. | -| Niet-bestaand `npm run worker` | Ja | Gecorrigeerd. | -| `Product.repo_slug` | Ja | Goed begin, maar uniekheid moet eigenlijk per GitHub owner + slug, niet per Scrum4Me user. | - -## Nieuwe bevindingen - -### P1 - V2 heeft nog twee executor-architecturen tegelijk - -Regels 49-66 beschrijven dispatch in `scrum4me-docker/bin/run-one-job.ts`, inclusief deterministic dispatch en ephemeral PAT op job-claim. Regels 200-212 kiezen daarna voor "App is executor" en laten docker `BOOTSTRAP_REPO` juist niet claimen. Regels 285-322 werken vervolgens app-side fire-and-forget uit. - -Dat is geen detail; dit bepaalt wie claimt, wie secrets heeft, wie retries doet en wie eigenaar is van leases/timeouts. Maak de keuze expliciet: - -- Optie A: `bootstrap-service` claimt alleen `BOOTSTRAP_REPO`, heeft `DATABASE_URL` + `BOOTSTRAP_ENCRYPTION_KEY`, decrypt zelf de PAT per run, en gebruikt dezelfde status-sync. -- Optie B: bestaande docker-runner claimt ook deterministic jobs, maar dan moet de secret-boundary worden aangepast en gedocumenteerd. -- Optie C: Next.js app voert inline uit, maar dan geen queue/claim-semantiek en geen 60 minuten timeout claimen. - -Voor Scrum4Me past Optie A het best: klein apart Node-proces, geen Claude quota, wel durable retries. - -### P1 - Fire-and-forget in de app is niet betrouwbaar genoeg - -Het plan kiest `runBootstrapInBackground(runId, pat)` na de server-action response. Vercel documenteert dat niet-geawait async werk in Functions kan blijven hangen in een bevroren execution context; helpers als `waitUntil()` zijn bovendien nog steeds gebonden aan de maximale function timeout. Vercel Functions hebben harde duration-limieten; het plan noemt zelf een 60-minuten watchdog, wat niet past bij normale serverless limits. - -Fix: vervang `fire-and-forget` door een echte worker: - -- `startBootstrapAction` maakt alleen `BootstrapRun` + `ClaudeJob`. -- `bootstrap-service` claimt atomair `BOOTSTRAP_REPO` runs. -- Service decrypt de PAT op basis van `run.user_id`, voert de recipe uit, en sync't terminal status. -- UI blijft exact hetzelfde via SSE. - -### P1 - PAT doorgeven aan een background promise is de verkeerde secret-shape - -Regel 197 zegt dat `startBootstrapAction` decrypt voor enqueue, en regel 298 geeft `pat` door aan de background runner. Als het proces wegvalt, is de job niet hervatbaar zonder opnieuw vanuit user context te starten. Als logs of closures uitlekken, zit de PAT in app-memory buiten een duidelijk lifecycle-contract. - -Fix: geef alleen `runId` door. De executor haalt `User.github_pat_encrypted` zelf op, decrypt binnen de execution boundary, zeroized daarna best-effort, en logt nooit token-materiaal. Voeg `github_pat_verified_at`, `github_pat_scopes` en `github_pat_expires_at` toe of overweeg later GitHub App/OAuth. - -### P1 - Gebruik geen lange-running local git push in een serverless function - -De v2-flow gebruikt `mkdtemp`, template clone, lokale git commit, repo create en push. Dat is prima voor een worker/service, maar kwetsbaar in serverless: tijdslimieten, file descriptor limieten, cleanup bij timeout, en onduidelijke rollback wanneer push half lukt. - -Fix: zet dit in `bootstrap-service` of Vercel Sandbox/Workflow. Als je toch app-side wilt blijven, maak de eerste versie veel kleiner: GitHub template endpoint aanroepen, geen lokale mutaties, geen push, geen `RUN_BASH_TEMPLATE`. - -### P2 - Voeg een dry-run/preview toe voor de wizard en admin-catalog - -Backstage Scaffolder heeft dry-run support en een Template Editor waarmee templates in een echte omgeving getest kunnen worden zonder externe mutaties. Scrum4Me mist dit nog. - -Aanbevolen toevoeging: - -- `previewBootstrapAction(productId, selections)` bouwt `recipe_snapshot`, valideert acties, draait alle non-mutating file handlers in tmpdir, en retourneert file tree + action log + warnings. -- UI toont "Review" voor "Create repo". -- Admin-UI mag een recipe pas activeren nadat dry-run groen is. -- Tests draaien per action ook in dry-run mode. - -Dit verlaagt het risico van DB-gedreven recipes sterk. - -### P2 - Maak repository owner/slug een echte picker, geen impliciete username - -Backstage gebruikt een repository picker met allowed hosts, owners en repos. Het plan heeft `repo_slug`, maar owner blijft impliciet `user.github_username` en staat zelfs nog als open punt. - -Fix voor MVP: - -- `Product.repo_owner` of `BootstrapRun.repo_owner_snapshot`. -- `repo_slug` uniqueness op `(repo_owner, repo_slug)`, niet op `(user_id, repo_slug)`. -- `saveGitHubPatAction` haalt beschikbare orgs op en bewaart geen owner zonder permissiecheck. -- Wizard laat owner + slug zien en doet preflight `GET /repos/{owner}/{repo}` of equivalente Octokit call. - -### P2 - Gebruik GitHub template API bewust, of leg uit waarom niet - -GitHub heeft een officieel endpoint om een repository uit een template te maken. Dat is eenvoudiger en veiliger dan zelf init/remote/push doen, maar het endpoint werkt met de template repo en repo-name/owner, niet met een willekeurige tag/ref zoals `template_version`. - -Aanbevolen beslissing: - -- Als `template_version` hard nodig is: blijf bij "download/clone tagged template, mutate, push", maar documenteer dat GitHub's template endpoint bewust niet gebruikt wordt. -- Als default-branch voldoende is: gebruik GitHub's template endpoint voor MVP en beperk v1 tot variabelen die later via follow-up commits kunnen. - -Voor dit plan zou ik tag-pinning behouden, maar de trade-off expliciet maken. - -### P2 - Voeg action-permissions toe, niet alleen admin CRUD - -Backstage kan parameters, steps en actions autoriseren. Scrum4Me v2 heeft alleen "admin-UI fase 2" en path-safety. Dat beschermt niet tegen een legitieme recipe die te veel doet. - -Voeg toe aan `BootstrapAction` of `BootstrapOption`: - -- `risk_level: LOW | MEDIUM | HIGH` -- `requires_role: ADMIN | PRODUCT_OWNER` -- `enabled: boolean` -- `supports_dry_run: boolean` -- `side_effects: FILESYSTEM | GITHUB_REPO | GITHUB_SETTINGS | NETWORK` - -`RUN_BASH_TEMPLATE` en GitHub-mutaties mogen standaard alleen admin-authored en dry-run getest zijn. - -### P2 - Vervang `condition: String?` door een getypte mini-DSL of haal hem uit MVP - -Een vrije condition string in DB is op termijn een tweede interpreter. Gebruik liever: - -```ts -condition: { - allOf?: Array<{ category: string; option: string }> - anyOf?: Array<{ category: string; option: string }> - not?: Array<{ category: string; option: string }> -} -``` - -Valideer met Zod en snapshot de resolved action list. Voor MVP: geen conditions, alleen expliciete selected options. - -### P2 - Maak template/catalog versioning scherper - -Het plan heeft `template_version` en `recipe_snapshot`, maar mist nog: - -- `template_source_sha` of release asset checksum. -- `catalog_version` of `recipe_hash`. -- `action_schema_version`. -- `generated_from` metadata in de nieuwe repo, bijvoorbeeld `.scrum4me/bootstrap.json`. - -Dat maakt update-detection en latere "rerun/update repo" veel simpeler. - -## Webresearch: vergelijkbare ideeen - -### GitHub template repositories - -GitHub ondersteunt "create repository using a template" via REST. Belangrijk: token scopes verschillen voor public/private repos; het endpoint accepteert `owner`, `name`, `include_all_branches` en `private`. Dit bevestigt dat owner/slug en token-scope preflight first-class moeten zijn. - -Bron: <https://docs.github.com/en/rest/repos/repos#create-a-repository-using-a-template> - -### Backstage Software Templates / Scaffolder - -Backstage is het dichtstbijzijnde patroon: skeleton code laden, variabelen templaten, en publishen naar GitHub/GitLab. Het heeft ook built-in actions voor fetch/publish, een template editor, dry-run, secrets, repository picker en permission controls. - -Relevante lessen: - -- Scrum4Me's `BootstrapActionKind` lijkt sterk op Backstage scaffolder actions. -- Dry-run en template editor horen vroeg in het plan, niet pas na MVP. -- Secrets moeten apart van gewone parameters blijven. -- Repository owner/host/repo hoort een picker met policy te zijn. -- Action-level permissions zijn belangrijk als recipes in DB/admin UI leven. - -Bronnen: - -- <https://backstage.io/docs/features/software-templates/> -- <https://backstage.io/docs/features/software-templates/builtin-actions/> -- <https://backstage.io/docs/features/software-templates/writing-templates/> -- <https://backstage.io/docs/next/features/software-templates/dry-run-testing/> -- <https://backstage.io/docs/next/features/software-templates/authorizing-scaffolder-template-details/> - -### Cookiecutter, Plop, Hygen - -Cookiecutter bevestigt het template-repo model met prompts/context/replay. Plop en Hygen bevestigen het action/generator model, maar zijn vooral lokaal/dev-tooling, niet server-side repo provisioning. - -Lessen voor Scrum4Me: - -- Houd de action-set klein en composable. -- Zorg voor replay: bewaar parameters, template versie en recipe hash. -- Maak custom actions code-owned, niet vrij definieerbaar vanuit DB. - -Bronnen: - -- <https://cookiecutter.readthedocs.io/en/stable/> -- <https://plopjs.com/documentation/> -- <https://hygen.ecmascript.pizza/docs/create/> - -### Vercel Functions - -Omdat het plan de app als executor overweegt, zijn Vercel limits relevant. Vercel Functions hebben maximale duur en background helpers zijn nog steeds aan die max duration gebonden. Dat maakt app-side fire-and-forget ongeschikt als robuuste bootstrap-queue. - -Bronnen: - -- <https://vercel.com/docs/functions/limitations> -- <https://vercel.com/kb/guide/troubleshooting-inconsistent-logs-in-vercel-functions> - -## Aangepaste aanbeveling voor het plan - -Vervang de executor-sectie door deze keuze: - -1. `BOOTSTRAP_REPO` blijft een `ClaudeJobKind` alleen voor uniforme UI/SSE/status. -2. `scrum4me-docker` claimt `BOOTSTRAP_REPO` niet. -3. Nieuwe `bootstrap-service` claimt alleen `BOOTSTRAP_REPO` of `BootstrapRun(PENDING)`. -4. Service heeft `DATABASE_URL`, `DIRECT_URL`, `BOOTSTRAP_ENCRYPTION_KEY`, geen Anthropic key nodig. -5. Service decrypt PAT per run, voert recipe uit, en gebruikt dezelfde transactionele status-sync. -6. Voeg `previewBootstrapAction` dry-run toe voor wizard en admin. -7. Voeg owner picker, action permissions, catalog versioning en `.scrum4me/bootstrap.json` toe. - -Met die aanpassing wordt het plan duidelijker, veiliger en veel dichter bij bewezen scaffolder-patronen. diff --git a/docs/recommendations/bootstrap-wizard-plan-v3-2-review-2026-05-14.md b/docs/recommendations/bootstrap-wizard-plan-v3-2-review-2026-05-14.md deleted file mode 100644 index f64c87b..0000000 --- a/docs/recommendations/bootstrap-wizard-plan-v3-2-review-2026-05-14.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -title: "Review - Bootstrap-wizard plan v3.2" -status: draft -date: 2026-05-14 -source_plan: "/Users/janpetervisser/.claude/plans/als-ik-een-nieuwe-virtual-turtle.md" ---- - -# Review - Bootstrap-wizard plan v3.2 - -## Conclusie - -V3.2 is een stevige verbetering. De grote architectuurfout uit v2 is opgelost: er is nu één executor-model met een aparte `bootstrap-service`, geen app-side fire-and-forget. Ook snake_case tables, het bestaande SSE payload-contract, `lease_until`, owner/slug en tag-pinning zijn goed verwerkt. - -Nog niet direct implementeren zonder de punten hieronder te verwerken. De belangrijkste resterende blokkades zitten in claim-identiteit, deploybaarheid van het gedeelde package, en recovery wanneer GitHub-repo-aanmaak/push half slaagt. - -## Bevindingen - -### P1 - Claim-query gebruikt een niet-bestaand `claimed_by` veld - -Het claim-protocol zet `claimed_by = ${WORKER_ID}` op `claude_jobs`. Het huidige `ClaudeJob`-model heeft `claimed_by_token_id`, `claimed_at` en `lease_until`, maar geen `claimed_by`. Dit faalt in SQL/migratie tenzij je een nieuw veld toevoegt. - -Fix: kies expliciet: - -- Re-use `claimed_by_token_id` met een dedicated service `ApiToken`, of -- voeg `claimed_by_worker_id String?` / `claimed_by_service String?` toe, of -- laat claim-identiteit weg en vertrouw op `lease_until`. - -Mijn voorkeur: voeg `claimed_by_worker_id String?` toe voor `bootstrap-service`, zodat je logs en recovery kunt correleren zonder `ApiToken`-semantiek te misbruiken. - -### P1 - `file:../bootstrap-service/...` dependency maakt de app niet deploybaar - -V3.2 kiest voor een shared package onder `~/Development/bootstrap-service/packages/bootstrap-actions/` en een lokale `file:` link vanuit de Scrum4Me-app. Dat werkt lokaal, maar niet in een normale Vercel/GitHub build van de Scrum4Me repo: de sibling-directory zit niet in de repository checkout. - -Fix voor MVP: - -- Zet `packages/bootstrap-actions/` in de Scrum4Me repo, want dit package bevat geen secrets. -- Laat `bootstrap-service` dit package consumeren via git/package release, of tijdelijk via copied source met een sync-script. -- Of publiceer meteen naar GitHub Packages en pin een versie. - -Niet doen: de app afhankelijk maken van een sibling path buiten de repo. - -### P1 - Crash-recovery na externe GitHub-mutaties is nog onvoldoende - -De happy path en catch-path verwijderen een aangemaakte repo bij errors, maar er is geen duurzaam checkpoint als de service crasht nadat de repo is aangemaakt en voordat `SUCCEEDED` is opgeslagen. Stale recovery markeert dan alleen DB-statussen `FAILED`; de GitHub repo kan blijven bestaan als orphan. - -Fix: voeg expliciete externe side-effect checkpoints toe op `BootstrapRun`: - -- `github_repo_created_at` -- `github_repo_id` -- `github_repo_full_name` -- `push_completed_at` - -Stale recovery kan dan beslissen: compensating delete proberen, of `FAILED_NEEDS_CLEANUP`/manual intervention markeren. Zonder dit is rollback niet betrouwbaar. - -### P1 - Stale recovery moet strikt op `BOOTSTRAP_REPO` filteren - -De stale-recovery beschrijving update `claude_jobs` waar status `CLAIMED/RUNNING` en `lease_until < NOW`. Dat mag niet generiek op alle job kinds draaien, want de bestaande Claude/sprint runner gebruikt dezelfde tabel. - -Fix: filter altijd `kind = 'BOOTSTRAP_REPO'`, en update alleen de bijbehorende `bootstrap_runs`. Laat bestaande cleanup voor andere job kinds ongemoeid. - -### P1 - Transaction-array kan geen generated `jobId` doorgeven aan `BootstrapRun` - -De atomische enqueue pseudo-code gebruikt `prisma.$transaction([claudeJob.create(...), bootstrapRun.create({ claude_job_id }))])`. Als `jobId` door Prisma wordt gegenereerd, is die waarde in array-form niet beschikbaar voor de tweede create. - -Fix: gebruik een transaction callback en pregenereer IDs, of maak eerst de job in de transaction en gebruik de returned ID voor de run. Bijvoorbeeld `const jobId = createId()` vooraf en beide records met expliciete IDs schrijven. - -### P2 - Cancel kan alsnog door succes worden overschreven - -`cancelBootstrapAction` zet `ClaudeJob.status='CANCELLED'`; de service "detecteert per-action". Dat is goed, maar `syncSuccess` moet ook conditioneel zijn. Anders kan een cancel tussen de laatste checkpoint en success-sync alsnog eindigen als `DONE/SUCCEEDED`. - -Fix: voor terminal transitions eerst current job/run status lezen of conditional `updateMany` gebruiken. Als `CANCELLED`, geen success meer schrijven. - -### P2 - `last_bootstrap_run_id` mist relationele details - -Het plan noemt `Product.last_bootstrap_run_id String?`, maar niet de Prisma relation naar `BootstrapRun` met `onDelete: SetNull`. Voeg die expliciet toe, inclusief relation name om ambiguiteit met `Product.bootstrap_runs` te voorkomen. - -### P2 - Action permissions staan op option-niveau, maar risico kan action-niveau zijn - -`risk_level` en `requires_role` staan nu op `BootstrapOption`, terwijl `RUN_BASH_TEMPLATE` een action-kind is. Als een optie meerdere acties bevat, moet de optie-risk altijd afgeleid worden uit de zwaarste action, of je hebt action-level permissions nodig. - -Fix: ofwel permissions verplaatsen naar `BootstrapAction`, of `BootstrapOption.risk_level`/`requires_role` server-side afleiden en niet handmatig laten driften. - -### P2 - Houd ID-strategie consistent met de codebase - -Nieuwe modellen gebruiken `@default(uuid())`, terwijl bestaande Scrum4Me-tabellen vrijwel overal `@default(cuid())` gebruiken. Technisch kan UUID, maar het wijkt af zonder duidelijke reden. - -Fix: gebruik `cuid()` tenzij er een externe reden is voor UUID. - -### P2 - Fine-grained GitHub PATs passen niet netjes in alleen `repo` scope - -De verificatie verwacht `repo` in `x-oauth-scopes`. Dat is prima voor classic PATs, maar fine-grained PATs werken met repository permissions en tonen niet altijd hetzelfde scope-model. - -Fix: maak MVP expliciet "classic PAT met `repo` scope" of ondersteun fine-grained tokens met aparte permission checks. Zet dit ook in de settings UI-copy. - -### P2 - `.env.example` en deployment docs ontbreken in de filelijst - -`BOOTSTRAP_ENCRYPTION_KEY` wordt verplicht in de app en service. Voeg `.env.example`, deployment runbook en bootstrap-service README setup toe aan de scope, anders breken lokale onboarding en CI/deploy snel. - -## Aanbevolen aanpassing - -Verwerk vóór implementatie minimaal: - -1. Vervang `claimed_by` door een bestaand of nieuw veld. -2. Verplaats het shared package naar de Scrum4Me repo of publiceer het. -3. Voeg GitHub side-effect checkpoints toe. -4. Filter stale recovery hard op `kind='BOOTSTRAP_REPO'`. -5. Maak enqueue transaction-ID handling concreet. - -Daarna is het plan implementatieklaar genoeg om naar `docs/plans/M8-bootstrap-wizard.md` te verplaatsen. diff --git a/docs/recommendations/bootstrap-wizard-plan-v3-3-review-2026-05-14.md b/docs/recommendations/bootstrap-wizard-plan-v3-3-review-2026-05-14.md deleted file mode 100644 index 2c784fe..0000000 --- a/docs/recommendations/bootstrap-wizard-plan-v3-3-review-2026-05-14.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: "Review - Bootstrap-wizard plan v3.3" -status: draft -date: 2026-05-14 -source_plan: "/Users/janpetervisser/.claude/plans/als-ik-een-nieuwe-virtual-turtle.md" ---- - -# Review - Bootstrap-wizard plan v3.3 - -## Conclusie - -V3.3 verwerkt de v3.2-review goed. De claim-identiteit, shared package locatie, GitHub side-effect checkpoints, stale-recovery filter, action-level permissions, classic PAT-keuze en env/docs zijn nu expliciet. Dit plan is dicht bij implementatieklaar. - -Nog verwerken vóór uitvoering: de status-sync voorbeeldcode is nog niet echt transactioneel, stale-recovery zet runs te breed op `FAILED_NEEDS_CLEANUP`, en er staat nog een niet-bestaande ID-generator in het enqueue-voorbeeld. - -## Bevindingen - -### P1 - Status-sync is nog niet transactioneel genoeg - -De sectie heet "transactional + post-commit NOTIFY", maar `syncSuccess` doet eerst `bootstrapRun.updateMany(...)` buiten een transaction en daarna pas een transaction met `claudeJob.updateMany(...)` en `product.update(...)`. Als de tweede transaction faalt, staat de run al op `SUCCEEDED`. Als de job-update `count=0` oplevert, wordt het product alsnog bijgewerkt en wordt alsnog `DONE` genotify'd. - -Fix: doe run-update, job-update en product-update in één `prisma.$transaction(async tx => ...)`, check beide `updateMany.count` waarden, en notify pas na een volledig geslaagde commit. Zet ook `lease_until` en `claimed_by_worker_id` terminal op `null`. - -### P1 - Stale recovery zet alle verlopen runs op `FAILED_NEEDS_CLEANUP` - -De SQL zet alle bijbehorende `bootstrap_runs` op `FAILED_NEEDS_CLEANUP`, terwijl de tekst zegt dat dit alleen moet wanneer `github_repo_full_name IS NOT NULL`. Voor runs zonder externe side effects hoort status `FAILED` te zijn. - -Fix: split recovery in twee updates: - -- `FAILED_NEEDS_CLEANUP` alleen waar `github_repo_full_name IS NOT NULL` of `github_repo_created_at IS NOT NULL`. -- `FAILED` waar beide leeg zijn. - -Hou de `kind='BOOTSTRAP_REPO'` filter; die is goed. - -### P1 - Enqueue gebruikt `@paralleldrive/cuid2`, maar die dependency bestaat niet - -Het plan importeert `createId` uit `@paralleldrive/cuid2`, maar deze repo heeft die dependency niet. De bestaande schema's gebruiken Prisma `cuid()` defaults; applicatiecode genereert die IDs nu niet zelf. - -Fix: gebruik de transaction callback-vorm en laat Prisma de IDs genereren, of voeg expliciet een dependency toe en leg vast dat alle nieuwe ID-validatie `z.string().cuid()` blijft accepteren. Mijn voorkeur: transaction callback, geen nieuwe ID-library. - -### P2 - Nieuwe non-null arrayvelden op `User` hebben defaults nodig - -`github_pat_scopes String[]` is niet nullable en heeft geen default. Op een bestaande database met users maakt dat de migration lastig of onmogelijk zonder backfill. - -Fix: maak dit `github_pat_scopes String[] @default([])` of gebruik `Json?` als je fine-grained tokenmetadata later flexibeler wilt opslaan. - -### P2 - NOTIFY-status casing moet expliciet API-lowercase zijn - -De voorbeelden sturen `status: 'DONE'` en `status: 'QUEUED'`. Bestaande helpers mappen jobstatussen naar lowercase API-strings (`done`, `queued`, etc.). Sommige bestaande paden sturen al lowercase via `jobStatusToApi`. - -Fix: spreek af dat NOTIFY payloads API-lowercase gebruiken, en DB-writes UPPER_SNAKE houden. Dus `status: 'done'` in payload, `status: 'DONE'` in DB. - -### P2 - Stale recovery hoort niet pas fase 2 te zijn - -De service gebruikt leases in MVP, maar de verificatie noemt stale recovery "in fase-2". Zonder recovery kan een crash een job langdurig in `CLAIMED`/`RUNNING` laten hangen. - -Fix: neem minimale stale recovery op in Sprint 1d: markeer verlopen `BOOTSTRAP_REPO` jobs en runs correct als `FAILED` of `FAILED_NEEDS_CLEANUP`. - -### P2 - Org-owner preflight moet endpoint-gedreven zijn - -Voor classic PAT MVP is `repo` scope helder, maar repo creation in een org hangt ook af van de daadwerkelijke org-permissions. Scope-check alleen is niet genoeg. - -Fix: laat `RepoOwnerPicker` alleen owners tonen waarvoor de concrete Octokit preflight slaagt, en behandel de response als authority. Documenteer dat org-eigenaarschap/permissies via GitHub worden gevalideerd, niet afgeleid uit alleen scopes. - -## Aanbevolen minimale patch op het plan - -1. Herschrijf `syncSuccess/syncFailed/syncRunning` als één transaction callback met count-checks. -2. Split stale recovery in `FAILED` vs `FAILED_NEEDS_CLEANUP`. -3. Vervang pre-generated `createId()` door een transaction callback of voeg de dependency expliciet toe. -4. Voeg `@default([])` toe aan `github_pat_scopes`. -5. Maak NOTIFY statuswaarden lowercase. - -Daarna is v3.3 goed genoeg om naar `docs/plans/M8-bootstrap-wizard.md` te promoveren. diff --git a/docs/recommendations/bootstrap-wizard-plan-v3-4-review-2026-05-14.md b/docs/recommendations/bootstrap-wizard-plan-v3-4-review-2026-05-14.md deleted file mode 100644 index 2467b5f..0000000 --- a/docs/recommendations/bootstrap-wizard-plan-v3-4-review-2026-05-14.md +++ /dev/null @@ -1,121 +0,0 @@ -# Review — M8 bootstrap-wizard plan v3.4 - -Datum: 2026-05-14 -Bronplan: `docs/plans/M8-bootstrap-wizard.md` -Scope: plan-review, geen implementatie uitgevoerd. Ik heb ook kort vergeleken met bestaande repo-contracten zoals `prisma/schema.prisma`, `lib/job-status.ts`, `tsconfig.json` en `package.json`. - -## Conclusie - -De aanbevelingen uit de vorige review zijn grotendeels goed verwerkt. Ik zie geen P1-blocker meer in de laatste versie. De belangrijkste restpunten zitten in GitHub owner-permissies, catalog-hash determinisme en acceptatie-tests. - -## Findings - -### [P2] Org-owner preflight belooft meer zekerheid dan de beschreven checks kunnen leveren - -Referentie: `docs/plans/M8-bootstrap-wizard.md:50`, `docs/plans/M8-bootstrap-wizard.md:540-567` - -Het plan zegt dat `RepoOwnerPicker` alleen owners toont waarvoor een concrete repo-create-preflight slaagt. De uitgewerkte check doet echter `GET /orgs/{org}` plus membership-check. Dat bewijst lidmaatschap/zichtbaarheid, niet dat de PAT daadwerkelijk een private repo in die org mag maken. - -GitHub documenteert voor org-repo creation dat de authenticated user org-lid moet zijn en dat classic PATs `repo` nodig hebben voor private repositories. Daarnaast kunnen org-instellingen repo creation beperken; de org API exposeert velden zoals `members_can_create_repositories` en `members_allowed_repository_creation_type`. De huidige plan-check gebruikt die velden niet en kan daardoor false positives of false negatives geven. - -Aanbevolen wijziging: - -- Noem dit expliciet een best-effort owner discovery, niet een harde create-permission proof. -- Valideer collision met `GET /repos/{owner}/{repo}`. -- Laat de echte create-call in de service de finale autorisatie zijn en vertaal `403/422` naar een duidelijke wizard-fout. -- Als je org-policy vooraf wilt meenemen: lees org creation settings waar beschikbaar, maar behandel ontbrekende rechten/SSO/admin-scope als onbekend in plaats van owner automatisch te verbergen. - -Bronnen: GitHub REST docs voor [repositories](https://docs.github.com/en/rest/repos/repos) en [organizations](https://docs.github.com/en/rest/orgs/orgs). - -### [P2] `syncRunning` mist expliciete timestamp-contracten - -Referentie: `docs/plans/M8-bootstrap-wizard.md:230`, `docs/plans/M8-bootstrap-wizard.md:418-420`, `docs/plans/M8-bootstrap-wizard.md:965-968` - -Het plan specificeert voor `syncRunning` alleen de status-overgang `PENDING -> RUNNING` en `CLAIMED -> RUNNING`. De modellen hebben `started_at`, en de verificatie sorteert later op `started_at`. Als `syncRunning` die velden niet atomair vult, worden metrics, UI-sortering en acceptatiequeries onbetrouwbaar. - -Aanbevolen wijziging: - -- Zet in dezelfde transaction `bootstrap_runs.started_at = now` en `claude_jobs.started_at = now`. -- Gebruik dezelfde `now`-waarde voor run en job. -- Voeg een unit/integration-test toe voor `CLAIMED/PENDING -> RUNNING` inclusief `started_at`. - -### [P2] `catalog_version` is nog niet deterministisch genoeg gespecificeerd - -Referentie: `docs/plans/M8-bootstrap-wizard.md:603-634` - -`recipe_hash` is goed uitgewerkt, maar `catalog_version` blijft te vaag: `SELECT md5(string_agg(...)) FROM bootstrap_options ...` is zonder expliciete ordering niet deterministisch en lijkt alleen options te hashen. Catalog changes in categories, actions, params, roles, risk levels, `enabled`, `archived` of `supports_dry_run` kunnen dan gemist worden. - -Aanbevolen wijziging: - -- Gebruik dezelfde canonical JSON-aanpak als `recipe_hash`. -- Hash categories, options en actions samen. -- Sorteer expliciet op category `display_order/slug`, option `display_order/slug`, action `execution_order/id`. -- Include minstens: selection type, required/default flags, enabled/archived, action kind, action params, dry-run support, side effects, risk level en required role. -- Gebruik `sha256`, niet ad-hoc `md5(string_agg(...))`. - -### [P2] De E2E-verificatiequery leest `lease_until` uit de verkeerde tabel - -Referentie: `docs/plans/M8-bootstrap-wizard.md:965-968` - -De query selecteert `lease_until > NOW()` uit `bootstrap_runs`, maar `lease_until` staat op `claude_jobs`. Deze acceptatiestap faalt zodra iemand het letterlijk uitvoert en kan lease-regressies maskeren. - -Aanbevolen wijziging: - -```sql -SELECT br.status, - br.repo_url, - br.recipe_hash, - cj.lease_until > NOW() AS lease_active -FROM bootstrap_runs br -JOIN claude_jobs cj ON cj.id = br.claude_job_id -ORDER BY br.started_at DESC NULLS LAST, br.created_at DESC -LIMIT 1; -``` - -### [P3] Startup stale-recovery uitleg is inconsistent met de worker-id definitie - -Referentie: `docs/plans/M8-bootstrap-wizard.md:93`, `docs/plans/M8-bootstrap-wizard.md:149-151` - -De worker-id bevat hostname, pid en start timestamp. Een herstartende service heeft dus niet dezelfde `claimed_by_worker_id`. De SQL in het plan is gelukkig globaal en kind-gefilterd, maar de uitleg zegt dat dezelfde service-instance zichzelf herkent via de oude hostname. - -Aanbevolen wijziging: - -- Beschrijf startup recovery als globale recovery voor verlopen `BOOTSTRAP_REPO` leases. -- Niet filteren op `claimed_by_worker_id` bij stale recovery. -- Bewaar `claimed_by_worker_id` alleen voor renewal/observability. - -### [P3] Vendor-copy drift-mitigatie staat alleen als risico, niet als concrete sprint-taak - -Referentie: `docs/plans/M8-bootstrap-wizard.md:749-751`, `docs/plans/M8-bootstrap-wizard.md:1023-1028` - -Het plan erkent terecht dat vendor-copy drift tussen Scrum4Me en `bootstrap-service` gevaarlijk is. De mitigatie, een schema-hash CI-check, staat alleen bij accepted risks en niet bij fasering of verificatie. - -Aanbevolen wijziging: - -- Maak de hash-check onderdeel van Sprint 1a of Sprint 1d. -- Laat `bootstrap-service` bij startup loggen welke `ActionSchema` versie/hash geladen is. -- Voeg een verificatiestap toe die faalt als `packages/bootstrap-actions` in de service niet overeenkomt met de Scrum4Me-bron. - -### [P3] `ADD_DEPENDENCY.version` regex is te smal voor normale npm specs - -Referentie: `docs/plans/M8-bootstrap-wizard.md:770-778` - -De regex accepteert alleen cijfers en operators. Geldige npm-versies zoals `latest`, prerelease labels (`^1.2.3-beta.1`), `workspace:*`, `npm:` aliases of git/tarball specs worden afgewezen. Voor MVP kan dit acceptabel zijn als seed-data alleen simpele semver gebruikt, maar het moet expliciet zijn. - -Aanbevolen wijziging: - -- Documenteer MVP als "alleen exact/range semver". -- Of gebruik een echte parser zoals `npm-package-arg`/`semver` en allowlist de toegestane spec-types. - -## Wat goed verwerkt is - -- Transactionele status-sync staat nu in één `prisma.$transaction` met post-commit NOTIFY. -- `FAILED_NEEDS_CLEANUP` wordt alleen gebruikt bij bekende GitHub side-effects. -- `claimed_by_worker_id` is terecht apart gehouden van `claimed_by_token_id`. -- De `@paralleldrive/cuid2` afhankelijkheid is verdwenen; Prisma `cuid()` blijft consistent met het bestaande schema. -- Lowercase SSE-status via `jobStatusToApi` matcht het bestaande contract. -- Stale recovery staat nu in Sprint 1d en is dus onderdeel van MVP. - -## Go/no-go - -Go na verwerking van de P2-punten. De P3-punten kunnen mee in dezelfde planupdate, maar hoeven geen implementatie te blokkeren zolang ze expliciet als MVP-beperking of verificatietaak worden vastgelegd. diff --git a/docs/recommendations/claude-vm-job-flow-git-strategy.md b/docs/recommendations/claude-vm-job-flow-git-strategy.md deleted file mode 100644 index a87994e..0000000 --- a/docs/recommendations/claude-vm-job-flow-git-strategy.md +++ /dev/null @@ -1,213 +0,0 @@ ---- -title: "Aanbeveling — Claude VM jobflow en gitstrategie" -status: draft -audience: [product-owner, maintainer, ai-agent] -language: nl -last_updated: 2026-05-09 ---- - -# Aanbeveling — Claude VM jobflow en gitstrategie - -## Managementsamenvatting - -Scrum4Me heeft inmiddels een duidelijke jobarchitectuur: de app maakt jobs aan, de Docker-runner claimt jobs en start Claude op een VM, en `scrum4me-mcp` bewaakt de lifecycle, worktrees, branches, pushes en PR's. De huidige implementatie is daarmee sterker en veiliger dan een prompt-gestuurde agent die zelf jobs ophaalt, pusht of PR's maakt. - -De belangrijkste aanbeveling is om deze servergestuurde lijn expliciet leidend te maken: - -- Claude implementeert en commit lokaal, maar bepaalt niet de branch-, push- of PR-strategie. -- `scrum4me-mcp` blijft de enige partij die jobs claimt, worktrees koppelt, branches pusht, PR's maakt en auto-merge activeert. -- Productinstellingen bepalen bewust de PR-strategie: `SPRINT` als veilige default, `STORY` voor kleine auto-mergebare changes, `SPRINT_BATCH` alleen voor goed afgebakende single-repo sprints. -- De documentatie en prompts moeten worden bijgewerkt, omdat sommige oudere docs nog een handmatige "niet pushen tot user-test"-flow beschrijven terwijl de actuele VM-flow al automatisch pusht na een succesvolle job. - -## Huidige Flow - -```mermaid -flowchart TD - U["Gebruiker start sprint, idee of plan"] --> A["Scrum4Me app voert preflight uit"] - A --> Q["ClaudeJob wordt QUEUED"] - Q --> S{"Product.pr_strategy"} - - S -->|STORY| J1["TASK_IMPLEMENTATION jobs<br/>branch per story"] - S -->|SPRINT| J2["TASK_IMPLEMENTATION jobs<br/>branch per sprint"] - S -->|SPRINT_BATCH| J3["1 SPRINT_IMPLEMENTATION job<br/>hele sprint"] - Q --> J4["IDEA_GRILL / IDEA_MAKE_PLAN / PLAN_CHAT<br/>geen git PR-flow"] - - J1 --> R["scrum4me-docker runner<br/>quota, claim, payload, claude -p"] - J2 --> R - J3 --> R - J4 --> R - - R --> C["Claude op VM<br/>wijzigt code, commit lokaal, logt, verifieert"] - C --> M["scrum4me-mcp update_job_status<br/>verify gate, push, PR, statuspropagatie, cleanup"] - - M --> P{"PR-strategie"} - P -->|STORY| PS["PR per story<br/>auto-merge na groene checks"] - P -->|SPRINT| PP["draft PR per sprint<br/>ready bij sprint DONE"] - P -->|SPRINT_BATCH| PB["draft PR per sprint<br/>ready na batch DONE"] -``` - -## Rollen en Verantwoordelijkheden - -| Actor | Verantwoordelijkheid | Mag niet doen | -|---|---|---| -| Gebruiker / product owner | Productinstellingen kiezen, sprint starten, review/merge bij sprint-PR's | Impliciete gitregels in prompts laten zweven | -| Scrum4Me app | Preflight, jobcreatie, strategie snapshotten op SprintRun | Zelf VM-werk orkestreren | -| scrum4me-docker | Job claimen, Claude starten, lease vernieuwen bij batchjobs | Zelf branch/PR-beleid bepalen | -| Claude op VM | Implementeren, lokaal committen, logs schrijven, verificatie draaien | Pushen, PR's maken, jobs ophalen | -| scrum4me-mcp | Claimprotocol, worktrees, branchnaam, push, PR, auto-merge, cleanup | Beslissingen overlaten aan losse prompttekst | -| GitHub | Branch protection, status checks, auto-merge, merge queue | Onbeschermde main-merge toestaan | - -## Beslissingspunten - -| Moment | Beslissing | Eigenaar | Advies | -|---|---|---|---| -| Productconfiguratie | `pr_strategy` en `auto_pr` | Gebruiker / product owner | Maak dit expliciet zichtbaar als operationele keuze | -| Sprint-start | Welke jobs worden aangemaakt | Scrum4Me app | Blijf blokkeren op ontbrekende plannen, open vragen en cross-repo batchrisico | -| Jobclaim | Welke job mag draaien | scrum4me-mcp | Houd atomic claim met lease en stale reset centraal | -| Runtime | Model, thinking budget, permissions | Job-config resolver | Snapshot bij enqueue en log de gekozen configuratie | -| Implementatie | Welke codewijziging en commits | Claude | Commit lokaal per logische laag, geen push | -| Verify gate | `EMPTY`, `PARTIAL`, `DIVERGENT` acceptabel? | scrum4me-mcp | Maak gate-regels testbaar en documenteer per jobkind | -| Push | Branch pushen of no-changes | scrum4me-mcp | Push alleen na succesvolle terminale status en geldige verify gate | -| PR | Geen PR, draft PR, ready PR, auto-merge | scrum4me-mcp + GitHub | Gebruik branch protection en required checks als harde randvoorwaarde | -| Failure | Retry, fail, skip, pause, cascade | scrum4me-mcp | Houd foutafhandeling server-side, niet prompt-side | - -## Git- en PR-strategie - -| Strategie | Jobs | Branch | PR | Mergebeleid | Aanbevolen gebruik | -|---|---:|---|---|---|---| -| `STORY` | Een job per taak | `feat/story-<story-id>` | PR per story | Auto-merge na groene checks | Kleine, onafhankelijke stories met betrouwbare CI | -| `SPRINT` | Een job per taak | `feat/sprint-<sprint-run-id>` | Een draft PR per sprint | Ready bij sprint DONE, menselijke merge | Veilige default voor productwerk | -| `SPRINT_BATCH` | Een job voor hele sprint | `feat/sprint-<sprint-run-id>` | Een draft PR per sprint | Ready na batch DONE, menselijke merge | Single-repo sprint met stabiele scope en contextvoordeel | -| Idee/plan jobs | Een job | Geen normale featurebranch | Geen PR | Alleen status/docs/logs | Ideevorming en planvorming | - -Belangrijk: in de huidige implementatie betekent `auto_pr=false` niet automatisch "niet pushen". MCP pusht nog steeds branches wanneer een job succesvol afrondt en er commits zijn. `auto_pr` bepaalt vooral of daarna automatisch een PR wordt gemaakt. - -## Aanbevolen Default - -Gebruik `SPRINT` als standaardstrategie voor Scrum4Me-productwerk. - -Redenen: - -- Er is maar één PR per sprint, dus review en deployment blijven overzichtelijk. -- Claude kan per taak draaien en falen zonder dat de hele sprintcontext in één lange sessie hoeft te blijven leven. -- De PR blijft draft totdat de sprint klaar is, wat goed past bij menselijke review. -- Het voorkomt dat veel kleine story-PR's automatisch deployments of reviewprocessen starten. - -Gebruik `STORY` alleen wanneer: - -- De repository sterke branch protection heeft. -- Required checks verplicht zijn. -- De story klein genoeg is om automatisch te mergen. -- Auto-merge gewenst is en deploymentkosten acceptabel zijn. - -Gebruik `SPRINT_BATCH` alleen wanneer: - -- Alle taken in dezelfde repository zitten. -- De sprintscope stabiel is. -- Er weinig kans is op tussentijdse gebruikersvragen. -- Contextbehoud belangrijker is dan kleine herstelbare stappen. - -## Concrete Acties - -### 1. Maak MCP de formele orchestrator - -Leg in de docs vast dat `scrum4me-mcp` de enige eigenaar is van: - -- jobclaim en lease; -- worktree-aanmaak; -- branchnamen; -- push; -- PR-creatie; -- auto-merge; -- statuspropagatie; -- cleanup. - -Claude mag alleen lokaal committen en via MCP-tools status/logs/verificatie doorgeven. - -### 2. Trek documentatie gelijk - -Werk minimaal deze stukken bij: - -- `CLAUDE.md`: onderscheid maken tussen handmatige lokale agentflow en VM-jobflow. -- `docs/runbooks/branch-and-commit.md`: verouderde "push pas na user-test"-regel beperken tot handmatige runs. -- `docs/runbooks/auto-pr-flow.md`: expliciet maken dat MCP na `done` pusht en daarna optioneel PR maakt. -- `scrum4me-docker/README.md`: beschrijven dat de runner één job per `claude -p` uitvoert. -- `scrum4me-mcp/README.md`: branchnamen actualiseren naar `feat/story-*` en `feat/sprint-*`. - -### 3. Fix prompt/tool-contracten - -Los deze inconsistenties op: - -- Task prompt: `update_job_status(skipped)` vereist een `error`, niet alleen een `summary`. -- Sprint prompt: `verify_required` wordt gebruikt als enum/gate, niet als boolean. -- Sprint prompt: verduidelijk wanneer een task binnen een batch echt `SKIPPED` mag worden. -- Docker docs: verwijder de oude instructie dat Claude zelf `wait_for_job` blijft aanroepen. - -### 4. Maak de state machine expliciet - -Documenteer en test de lifecycle als state machine: - -```text -QUEUED -> CLAIMED -> RUNNING -> DONE - |-> FAILED - |-> SKIPPED - |-> CANCELLED -``` - -Aanbevolen start: een pure TypeScript transition table met tests. XState is pas nodig als visualisatie, model-based testing of complexere parallelle staten belangrijk worden. - -### 5. Versterk GitHub-randvoorwaarden - -Voor repositories waar `STORY` auto-merge gebruikt: - -- Require status checks before merging. -- Require pull request reviews of CODEOWNERS voor risicovolle paden. -- Disable force pushes op beschermde branches. -- Gebruik `gh pr merge --auto --squash --match-head-commit` of equivalent met head-SHA guard. -- Overweeg merge queue zodra meerdere workers tegelijk PR's kunnen laten landen. - -### 6. Beperk VM-risico - -De VM-runner moet blijven werken met: - -- least-privilege tokens; -- expliciete allowed tools; -- worktrees per job; -- geen secrets in logs; -- geïsoleerde runtime; -- duidelijke retry- en stale-claimregels; -- optioneel netwerkbeleid per repository of jobtype. - -## Governance-Regel - -De centrale regel voor Scrum4Me zou moeten zijn: - -> Claude mag code veranderen en lokaal committen. Alleen Scrum4Me MCP mag bepalen wanneer werk klaar is, welke branch wordt gepusht, of er een PR komt, en of die PR automatisch mag mergen. - -Deze regel voorkomt dat prompts, docs en runtimegedrag uit elkaar gaan lopen. - -## Bronnen en Lokale Referenties - -Lokale referenties: - -- `actions/sprint-runs.ts` — sprint-start, preflight en jobcreatie. -- `components/products/pr-strategy-select.tsx` — productkeuze voor `STORY`, `SPRINT`, `SPRINT_BATCH`. -- `scrum4me-docker/bin/run-one-job.ts` — runner claimt één job en start Claude. -- `scrum4me-mcp/src/tools/wait-for-job.ts` — claimprotocol, worktree en branchresolutie. -- `scrum4me-mcp/src/tools/update-job-status.ts` — verify gate, push, PR, auto-merge, statuspropagatie. -- `scrum4me-mcp/src/git/push.ts` — branch push. -- `scrum4me-mcp/src/git/pr.ts` — GitHub PR-create, PR-ready en auto-merge. - -Externe bronnen: - -- GitHub Docs — Protected branches: <https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches> -- GitHub Docs — Required status checks: <https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches#require-status-checks-before-merging> -- GitHub Docs — Auto-merge: <https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-auto-merge> -- GitHub CLI — `gh pr merge`: <https://cli.github.com/manual/gh_pr_merge> -- GitHub Docs — Merge queue: <https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/managing-a-merge-queue> -- Trunk Based Development — Short-lived branches: <https://trunkbaseddevelopment.com/short-lived-feature-branches/> -- DORA — Trunk-based development capability: <https://dora.dev/capabilities/trunk-based-development/> -- Temporal Docs — Durable workflows: <https://docs.temporal.io/> -- XState Docs — State machines: <https://stately.ai/docs/state-machines> -- Anthropic Docs — Claude Code headless mode: <https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-headless> -- Anthropic Docs — Secure deployment guidance: <https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-secure-deployment> diff --git a/docs/recommendations/load-render-implementation-review-2026-05-10.md b/docs/recommendations/load-render-implementation-review-2026-05-10.md deleted file mode 100644 index 9d32f14..0000000 --- a/docs/recommendations/load-render-implementation-review-2026-05-10.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -title: "Load/render implementatie review" -date: 2026-05-10 -status: review -scope: ["Product Backlog", "Sprint", "Solo"] ---- - -# Load/render implementatie review - -## Samenvatting - -De drie schermen zijn niet gelijkvormig opgebouwd. - -- Product Backlog en Sprint gebruiken allebei een server-fetched snapshot, een hydration wrapper, een genormaliseerde workspace-store, SSE en directe scope-resync. -- Solo gebruikt server props, een eigen `useSoloStore`, een globale SSE-bridge en `router.refresh()` als resync-mechanisme. -- Product Backlog wijkt af doordat het naast server hydration ook nog via de product layout een client-side full backlog fetch start. Dat kan de lange rendering verklaren. -- Product Backlog en Sprint hebben daarnaast een status-contract mismatch: server pages hydrateren story/task statussen als DB `UPPER_SNAKE`, maar API-resync routes geven lowercase API-statussen terug terwijl de UI maps uppercase verwachten. - -## Bevindingen - -### P1 - Statussen wisselen tussen uppercase en lowercase na client load/resync - -`lib/task-status.ts` zegt expliciet dat de DB `UPPER_SNAKE` houdt en de API lowercase exposeert (`lib/task-status.ts:1-2`). De API mapt bijvoorbeeld `TO_DO -> todo` en `OPEN -> open` (`lib/task-status.ts:12-35`). - -De server-render paden hydrateren story/task statussen echter direct uit Prisma: - -- Product Backlog stories/tasks blijven uppercase in `app/(app)/products/[id]/page.tsx:86-98`. -- Sprint stories/tasks blijven uppercase in `app/(app)/products/[id]/sprint/[sprintId]/page.tsx:94-125`. - -De client load/resync paden mappen dezelfde data naar lowercase: - -- Product Backlog full snapshot: `app/api/products/[id]/backlog/route.ts:80-99`. -- PBI stories: `app/api/pbis/[id]/stories/route.ts:49-50`. -- Story tasks: `app/api/stories/[id]/tasks/route.ts:46-48`. -- Sprint workspace snapshot: `app/api/sprints/[id]/workspace/route.ts:71-108`. - -De UI verwacht voor stories/tasks juist uppercase: - -- Backlog stories: `components/backlog/story-panel.tsx:41-50`. -- Backlog tasks: `components/backlog/task-panel.tsx:42-53`. -- Sprint stories: `components/sprint/sprint-backlog.tsx:33-38`. -- Sprint tasks: `components/sprint/task-list.tsx:33-54`. - -Impact: na een client fetch of resync kunnen labels, kleuren, filters en status-cycles anders of leeg renderen. In Sprint is dit extra riskant omdat `STATUS_CYCLE[task.status]` bij lowercase statussen terugvalt naar `TO_DO`. - -Aanpak: kies een intern store-contract. Het meest consistent met de bestaande UI is: DB-uppercase in de workspace-stores houden, en API lowercase alleen aan de route/API-boundary gebruiken. Converteer API-responses dus terug naar DB-statussen voordat ze in Product/Sprint workspace stores landen, of pas alle UI maps en acties consequent aan op API-statussen. - -### P1 - Product Backlog doet een dubbele full backlog load - -Product Backlog haalt op de server al alle PBI's, stories en tasks op (`app/(app)/products/[id]/page.tsx:47-84`) en hydrateert die in de client via `BacklogHydrationWrapper` (`components/backlog/backlog-hydration-wrapper.tsx:60-67`). - -Tegelijkertijd mount de product layout altijd `SetCurrentProduct` (`app/(app)/products/[id]/layout.tsx:19-22`). Die roept `setActiveProduct` aan (`components/shared/set-current-product.tsx:10-14`). `setActiveProduct` start altijd `ensureProductLoaded`, en die fetcht opnieuw de volledige backlog via `/api/products/:id/backlog` (`stores/product-workspace/store.ts:217-257`, `stores/product-workspace/store.ts:329-345`). - -Impact: op Product Backlog komt na de server render nog een client full-backlog API-call en store hydration. Dat veroorzaakt extra werk, extra renders, en door de status mismatch hierboven kan de tweede load de net gehydrateerde uppercase data overschrijven met lowercase data. - -Aanpak: maak server hydration en client ensure geen dubbele eigenaren van dezelfde initial load. Bijvoorbeeld: - -- `SetCurrentProduct` alleen context laten zetten zonder `ensureProductLoaded` wanneer de route zelf een snapshot hydrateert. -- Of `BacklogHydrationWrapper` ook `activeProduct` zetten en `loadedProductId` markeren, waarna `setActiveProduct`/`ensureProductLoaded` guarded wordt. -- Of Product Backlog hetzelfde patroon geven als Sprint: wrapper hydrateert snapshot en zet de actieve context direct. - -### P1 - Solo resync werkt niet voor bestaande taken met dezelfde ids - -`useSoloRealtime` gebruikt `router.refresh()` om gemiste events na reconnect/visible/online op te halen (`lib/realtime/use-solo-realtime.ts:96-104`, `lib/realtime/use-solo-realtime.ts:190-205`). De comment zegt dat server props opnieuw binnenkomen en `initTasks` de store reset. - -Maar `SoloBoard` roept `initTasks(initialTasks)` alleen opnieuw aan als de lijst task-ids verandert: - -- `const taskKey = initialTasks.map(t => t.id).join(',')` (`components/solo/solo-board.tsx:79`) -- effect dependency is alleen `[taskKey]` (`components/solo/solo-board.tsx:80-83`) -- `initTasks` vervangt de store (`stores/solo-store.ts:105-106`) - -Impact: als een gemist event alleen status, titel, sort_order, plan of andere velden wijzigt, en de task-id set gelijk blijft, dan doet de refresh niets in de solo-store. Het scherm blijft stale ondanks de resync. - -Aanpak: gebruik een volledige fingerprint van de render-relevante velden, of hydrateer de store op iedere nieuwe `initialTasks` prop. Als renderperformance een zorg is, maak de fingerprint expliciet met `id`, `status`, `sort_order`, `title`, story metadata en planvelden. - -### P2 - Solo sync't openstaande stories niet na refresh - -`SoloBoard` initialiseert `unassignedStories` eenmalig uit props (`components/solo/solo-board.tsx:66`). De knop en sheet renderen daarna vanuit lokale state (`components/solo/solo-board.tsx:220-225`, `components/solo/solo-board.tsx:278-284`). - -Impact: als `router.refresh()` nieuwe unassigned stories ophaalt, wordt de lokale state niet bijgewerkt. Het aantal en de sheet kunnen stale blijven. - -Aanpak: sync `initialUnassigned` via een effect/fingerprint, of maak unassigned stories onderdeel van dezelfde hydrateerbare solo-store. - -### P2 - Sprint gebruikt niet hetzelfde active-context patroon als Product Backlog - -Product Backlog selecteert PBI/story/task via de workspace-store context. `TaskPanel` leest bijvoorbeeld `context.activeStoryId` en `selectTasksForActiveStory` (`components/backlog/task-panel.tsx:108-115`). - -Sprint hydrateert wel een sprint workspace-store, maar de geselecteerde story staat lokaal in `SprintBoardClient`: - -- `useState<string | null>(null)` voor `selectedStoryId` (`components/sprint/sprint-board-client.tsx:66`) -- selectie wordt als prop doorgegeven (`components/sprint/sprint-board-client.tsx:238-257`) -- `TaskList` leest tasks via `selectTasksForStory(s, storyId)`, niet via de actieve store-context (`components/sprint/task-list.tsx:161-164`) - -De sprint-store heeft wel `setActiveStory`, `selectTasksForActiveStory` en resync van `activeStoryId`, maar het scherm gebruikt dat pad niet (`stores/sprint-workspace/store.ts:305-327`, `stores/sprint-workspace/store.ts:458-466`). - -Impact: Sprint werkt deels, maar is niet gelijkvormig met Product Backlog. De restore-hints en active-scope resync voor story/task zijn in dit scherm praktisch omzeild. - -Aanpak: zet story-selectie in de sprint workspace-store en laat `TaskList` dezelfde active-context selector gebruiken als Product Backlog, of verwijder de ongebruikte active story/task mechanismen uit de sprint-store. - -## Vergelijking per scherm - -| Scherm | Initial load | Client hydration | Realtime/resync | Selectie/render patroon | -| --- | --- | --- | --- | --- | -| Product Backlog | Server haalt full backlog op | `BacklogHydrationWrapper` hydrateert product workspace-store | SSE + `resyncActiveScopes` | Store-context voor actieve PBI/story/task | -| Sprint | Server haalt sprint snapshot op | `SprintHydrationWrapper` hydrateert sprint workspace-store en zet context | SSE + `resyncActiveScopes` | Sprint/story lijst uit store, maar story selectie lokaal | -| Solo | Server haalt solo props op | `SoloBoard` init `useSoloStore` via effect op task-ids | Globale SSE + `router.refresh()` | Eigen store voor tasks, lokale state voor unassigned stories | - -## Conclusie - -De lange rendering is waarschijnlijk niet door `debug_id` op zichzelf veroorzaakt. De meest concrete render/load oorzaak zit in Product Backlog: server snapshot plus een tweede client-side full backlog load via `SetCurrentProduct`. Daarnaast zorgt de status-contract mismatch ervoor dat die tweede load en latere resyncs een andere datastructuur in dezelfde UI stoppen. - -De schermen zijn functioneel verwant, maar niet gelijkvormig geimplementeerd. Product Backlog en Sprint moeten eerst hetzelfde status- en hydration-contract krijgen. Daarna kan Solo naar hetzelfde patroon groeien, of minimaal zijn `router.refresh()`-hydratie correct laten doorwerken op bestaande tasks en unassigned stories. diff --git a/docs/runbooks/agent-flow-pitfalls.md b/docs/runbooks/agent-flow-pitfalls.md deleted file mode 100644 index fb54597..0000000 --- a/docs/runbooks/agent-flow-pitfalls.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -title: "Agent-flow: open issues & decision log" -status: active -audience: [ai-agent, contributor, pb-owner] -language: nl -last_updated: 2026-05-03 -when_to_read: "When designing or auditing how the agent claims jobs and produces PRs across multiple stories, PBIs or products." ---- - -# Agent-flow: open issues & decision log - -Deze runbook bundelt vier valkuilen in de huidige agent-flow waarover later -een bewuste beslissing moet vallen. Elk issue is óf gedekt door een -bestaande PBI óf gekoppeld aan een story onder de anchor-PBI -[`Agent-flow: openstaande beslissingen`][anchor-pbi]. - -Status per issue is een van: `open` (nog geen besluit), `decided` (besluit -genomen, mitigatie volgt), `mitigated` (geïmplementeerd; story gesloten). -Promote een story naar een eigen prio-2 PBI zodra het issue acuut wordt. - -## 1. PBI-ordering binnen één batch — `decided` - -**Probleem**: in één batch kunnen jobs uit verschillende PBIs door elkaar -lopen, omdat `wait_for_job` FIFO claimt zonder PBI-grouping. - -**Status**: gedekt door PBI [`Agent merge-policy: geen auto-merge, -sequentieel per PBI`][merge-policy-pbi]. Daar wordt een gate ingebouwd -die voorkomt dat PBI B start zolang PBI A's PR nog open is. - -**Niet hier dupliceren**. - -## 2. Schema-conflict bij parallelle Prisma-migraties — `open` - -**Probleem**: twee stories die elk een Prisma-migratie toevoegen krijgen -elk een eigen migration-file met eigen timestamp. Mergen in willekeurige -volgorde kan de schema-state inconsistent maken; oudere timestamps die -ná nieuwere mergen geven Prisma-onverklaarbaar gedrag. - -**Mitigatie-opties**: sequential-gating (#1 lost dit grotendeels op), -migration-rename CI-hook, geen agent-migrations toelaten, of -`prisma migrate diff` als CI-gate. - -→ [Story `Schema-conflict tussen parallelle stories`][story-schema] - -## 3. Branch naam-collisie via 8-char-suffix — `open` - -**Probleem**: `feat/story-<last8-of-cuid>` is geen garantie tegen -botsingen. Met genoeg stories wordt botskans niet-triviaal en de -worktree-create faalt of vermengt commits. - -**Mitigatie-opties**: volledige cuid in branchnaam, `Story.code` als -branchnaam (bv. `feat/ST-1115`), suffix-lengte verhogen, of niets doen -en monitoren. - -→ [Story `Branch naam-collisie via 8-char-suffix van story-id`][story-branch] - -## 4. Cross-product orchestratie — `open` - -**Probleem**: een feature in twee producten (bv. tool in scrum4me-mcp + -gebruik in Scrum4Me) heeft een dependency-volgorde die niet door het -systeem wordt afgedwongen. Mergen in verkeerde volgorde breekt main. - -**Mitigatie-opties**: mens-discipline + description-flag, -Initiative-laag boven PBI (ADR-0010 optie C), gating per repo-pair, of -"blocked-by"-tekstuele link. - -→ [Story `Cross-product orchestratie: dependency-volgorde en -mens-tussenstappen`][story-cross-product] - -## Re-visit cadans - -Bekijk dit document maandelijks of na elke significant agent-incident. -Promote een story naar prio 2 zodra het issue concreet pijn doet. - -## Gerelateerde ADRs - -- [ADR-0010: Eén product = één repo; cross-product planning](../adr/0010-product-per-repo-cross-product-planning.md) - -[anchor-pbi]: # "PBI cmopwlxgu0016vt170ratrrur op product Scrum4Me" -[merge-policy-pbi]: # "PBI cmoppwpwu000evt17ev2c4oo4 op product Scrum4Me" -[story-schema]: # "Story cmopwmc0f0017vt17azn2aynr" -[story-branch]: # "Story cmopwmqcf0018vt17583a25q2" -[story-cross-product]: # "Story cmopwn5mc0019vt17wwrq0ib7" diff --git a/docs/runbooks/auto-pr-flow.md b/docs/runbooks/auto-pr-flow.md deleted file mode 100644 index 710965f..0000000 --- a/docs/runbooks/auto-pr-flow.md +++ /dev/null @@ -1,187 +0,0 @@ ---- -title: "Auto-PR flow: van story-DONE naar gemergde PR" -status: active -audience: [contributor, ai-agent] -language: nl -last_updated: 2026-05-06 -when_to_read: "Vóór het aanzetten van auto_pr op een product, of bij debugging van uitblijvende PR's na agent-jobs." ---- - -# Auto-PR flow - -Wanneer een Scrum4Me-agent een TASK_IMPLEMENTATION-job afrondt, kan de -hele keten **commit → push → PR → auto-merge → deploy** zonder -handmatige actie verlopen — mits het bijbehorende product `auto_pr=true` -heeft staan en de repo + tokens correct geconfigureerd zijn. - ---- - -## Volledige keten - -``` -Agent voltooit task in /tmp/job-<id> - │ - ▼ -Agent roept update_job_status('done', branch=feat/story-<n>) - │ - ▼ MCP-tool prepareDoneUpdate (scrum4me-mcp/src/tools/update-job-status.ts) - │ - ├─ pushBranchForJob → git push origin feat/story-<n> - │ (no-op als HEAD === origin/main → status DONE zonder pushed_at) - │ - ├─ Job.pushed_at = now() Job.branch = feat/story-<n> - │ - ▼ maybeCreateAutoPr (scrum4me-mcp/src/tools/update-job-status.ts:203) - │ - ├─ Product.auto_pr === false → STOP (niets meer) - │ - ├─ Product.auto_pr === true: - │ ├─ sibling-job in story heeft al pr_url → hergebruik - │ └─ eerste DONE-task in story: - │ ├─ gh pr create → Job.pr_url - │ └─ gh pr merge --auto --squash → CI groen → squash-merged - │ - ▼ -Push naar main → ci.yml deploy-production → Vercel -``` - ---- - -## Wat is wel/niet automatisch? - -| Stap | Automatisch? | Door wie | -|---|---|---| -| Commit op feature-branch | ✅ | Worker (Claude-CLI in container) | -| Push naar `origin/<branch>` | ✅ | scrum4me-mcp `pushBranchForJob` | -| `gh pr create` | ✅ (als `auto_pr=true`) | scrum4me-mcp `maybeCreateAutoPr` | -| `gh pr merge --auto --squash` | ✅ (als `auto_pr=true`) | scrum4me-mcp `createPullRequest` | -| CI groen | n.v.t. | GitHub Actions `ci.yml` | -| Squash-merge | ✅ (als CI groen + auto-merge actief) | GitHub | -| Productie-deploy na merge | ✅ (mits path-filter "code") | `ci.yml` deploy-production — zie [deploy-control.md](./deploy-control.md) | -| Sluiten van feature-branch | ✅ (na merge) | GitHub | - ---- - -## Setup-vereisten per product - -### 1. Toggle aanzetten - -In de webapp: **Product → Settings → Automatisch PR aanmaken na -succesvolle agent-job** → toggle aan. - -Equivalent op DB-niveau: -```sql -UPDATE products SET auto_pr = true WHERE id = '<product-id>'; -``` - -Per product. Standaard staat hij **uit** zodat nieuwe producten geen -verrassende auto-PR's produceren. - -### 2. GitHub repo-instellingen - -- Settings → General → **Allow auto-merge** → aanvinken. Zonder dit - weigert `gh pr merge --auto` met fout, maar de PR is al wel - aangemaakt — auto-merge moet je dan handmatig aanzetten. -- Settings → General → **Automatically delete head branches** → - aanbevolen aan zodat gemergde feature-branches opgeruimd worden. -- Settings → Branches → branch protection op `main`: - - **Required status checks**: minimaal `ci` (lint, typecheck, test, build) - - `deploy-preview` is **niet** required (mag skipped zijn door labels — - zie [deploy-control.md](./deploy-control.md)) - -### 3. Tokens op de scrum4me-mcp host - -`gh` CLI moet ingelogd zijn als een user/token met `repo`-scope (private) -of `public_repo` (public). De MCP-host (typisch een NAS-container) draait: - -```bash -gh auth login # of: -gh auth login --with-token < /path/to/token -``` - -### 4. Worker (scrum4me-docker) - -De agent-runner container heeft de relevante MCP-tools al in zijn -`ALLOWED_TOOLS`-lijst. Geen aanpassing nodig. - ---- - -## Wat zie je in de UI - -- **/admin/jobs** (admin): elke ClaudeJob toont `branch`, `pr_url`, - `error`. Status-badge: SKIPPED bij no-op (zie - [worker-idempotency.md](./worker-idempotency.md)). -- **Idea-detail Sync-tab** (PBI-36 ST-1219): per Story toont de PR-URL - en gemerged-status van de gekoppelde PBI. -- **Notifications**: SSE-stream vuurt op `claude_job_status`-event - zodat dashboard en bel-icoon real-time bijwerken. - ---- - -## Branch-strategie - -`feat/story-<8-char-suffix>` per Story. Sibling-tasks in dezelfde Story -hergebruiken zelfde branch + zelfde PR (zie `maybeCreateAutoPr` regel -229-239 in `scrum4me-mcp/src/tools/update-job-status.ts`). Eén PR per -Story, niet per Task. - -PBI-niveau aggregatie is **niet** geïmplementeerd: er is geen -`check_pbi_complete`-MCP-tool en geen "samengestelde" PR die alle -stories van een PBI bundelt. Eén story = één PR. - ---- - -## Foutpaden - -### Push faalt -`prepareDoneUpdate` zet `Job.status=FAILED` met `error: "push failed -(<reason>): <stderr-snippet>"`. Mogelijke `reason`: - -- `no-credentials` — `gh auth login` ontbreekt op MCP-host -- `conflict` — non-fast-forward; meestal omdat een sibling-job de - branch al aanpaste. Worker maakt geen retry — gebruiker moet handmatig - rebasen of nieuwe job aanmaken. -- `unknown` — netwerkfout / andere git-fout. Stderr in `error`-veld. - -### `gh pr create` faalt -Worktree blijft bestaan voor handmatige inspectie. `Job.pr_url` blijft -null. Gebruiker kan handmatig PR aanmaken vanuit de gepushte branch. - -### Auto-merge faalt -Best-effort: als `gh pr merge --auto` faalt (repo zonder "Allow -auto-merge", of token-scope ontoereikend) wordt alleen een warning -gelogd. PR-URL blijft teruggegeven, gebruiker kan handmatig auto-merge -aanzetten of mergen. - -### CI rood -PR blijft open. Auto-merge wacht eindeloos. Gebruiker moet ofwel CI -fixen (extra commit op zelfde branch — pakt MCP niet automatisch op, -worker is per-job) ofwel auto-merge uitzetten en handmatig sluiten. - ---- - -## Wanneer auto_pr UIT laten - -- **Tijdens initial-development** van een nieuw product — wil je elke PR - zelf reviewen. -- **Code-reviews vereist** door org-policy — auto-merge kan branch - protection rules omzeilen die niet als "required check" staan. -- **Externe contributors** — auto-PR met SQUASH-merge schrijft de agent - als author. Voor contributor-PRs wil je dat niet. - ---- - -## Referenties - -- Tool-implementatie: `scrum4me-mcp/src/tools/update-job-status.ts` - (`prepareDoneUpdate`, `maybeCreateAutoPr`) -- Push-implementatie: `scrum4me-mcp/src/git/push.ts` -- PR-implementatie: `scrum4me-mcp/src/git/pr.ts` (createPullRequest + - auto-merge) -- UI-toggle: `components/products/auto-pr-toggle.tsx` -- Server-action: `actions/products.ts` (`updateAutoPrAction`) -- Schema: `prisma/schema.prisma` → `Product.auto_pr` -- Gerelateerde runbooks: - - [deploy-control.md](./deploy-control.md) — wat gebeurt er na merge - - [worker-idempotency.md](./worker-idempotency.md) — JobStatus protocol - - [branch-and-commit.md](./branch-and-commit.md) — branch-strategie diff --git a/docs/runbooks/branch-and-commit.md b/docs/runbooks/branch-and-commit.md index 0e5ddbc..54d5227 100644 --- a/docs/runbooks/branch-and-commit.md +++ b/docs/runbooks/branch-and-commit.md @@ -4,7 +4,7 @@ status: active audience: [ai-agent, contributor] language: nl last_updated: 2026-05-03 -when_to_read: "Before creating a branch, commit, or PR. Also before any agent-batch run." +when_to_read: "Before creating a branch, commit, or PR." --- # Branch, PR & Commit Strategy @@ -45,60 +45,6 @@ Elke `git push` naar een feature-branch triggert een Vercel preview-deployment. Zodra het Vercel-account naar Pro (of andere omgeving zonder per-build-kosten) gaat: vervang deze regel door "branch + PR per story" zoals oorspronkelijk in dit document stond. Werk deze sectie bij én documenteer de wijziging in `docs/decisions/agent-instructions-history.md`. -### Agent-batch flow (verplicht voor worker-runs) - -Wanneer de NAS-agent (`/opt/agent/`) een batch jobs uitvoert: - -| Moment | Actie | Verbod | -|---|---|---| -| Start run | `git checkout -b feat/<batch-slug>` lokaal | `gh pr create` | -| Na elke taak | `git add -A && git commit -m "<type>(ST-XXX): <title>"` | `git push` | -| Queue leeg | `git push -u origin <branch>` + `gh pr create` | — | - -- Alle commits accumuleren op dezelfde branch — lopende state blijft op disk tot de run klaar is. -- Één PR per batch → één Vercel preview-deployment. -- Single-task batch (1 job in queue): dezelfde flow — 1 commit → push + PR. - -#### End-to-end verificatie: 1 batch = 1 Vercel-deploy - -Gebruik deze checklist om te verifiëren dat de batch-flow correct werkt na een agent-run: - -**Voorbereiding** -1. Seed ≥ 2 taken onder één story (bv. README-edits). -2. Trigger de batch via **"Voer alle uit"** op het Solo Board. -3. Wacht tot de agent alle jobs als `done` markeert. - -**GitHub-checks** -- [ ] Er is precies **één PR** aangemaakt voor de batch-branch. -- [ ] De PR bevat **één commit per taak** (geen squash, geen force-push). -- [ ] Er zijn **geen losse pushes** op de branch vóór de definitieve push (check via `git log --all --graph` of GitHub's "commits" tab). - -**Vercel-checks** -- [ ] In het Vercel-dashboard → **Deployments**: er is **exact één preview-deployment** voor de branch in het run-window. -- [ ] Geen extra "cancelled" of "building" deployments voor dezelfde branch uit hetzelfde tijdsvenster (zou wijzen op tussentijdse pushes). - -**Alternatieve verificatie via Vercel MCP** (indien beschikbaar): -``` -mcp__<vercel-plugin-id>__list_deployments - → filter op branchName = feat/<batch-slug> - → verwacht: 1 entry met state = READY of BUILDING -``` - -**Race-condition scenario**: als een nieuwe taak in de queue terechtkomt terwijl de agent de queue-check uitvoert, kan er een tweede push volgen. Dit is acceptabel — de tweede push triggert een tweede deployment voor de resterende commits. Documenteer dit afwijkend gedrag in de PR-description als het zich voordoet. - -### Merge conflicten — wanneer wel/niet? - -Een veelgestelde vraag: "als meerdere stories dezelfde bestanden raken, krijgen we dan geen merge conflicten?" - -**Binnen dezelfde batch (zelfde branch):** nee. Story B commit bovenop Story A op dezelfde branch — lineair, geen conflict. Dit is precies waarom de workflow één branch per batch voorschrijft in plaats van één per story. - -**Tussen parallelle batches (verschillende branches richting `main`):** ja, mogelijk. Als batch X en batch Y allebei dezelfde file aanpassen en allebei willen mergen, krijgt de tweede een rebase-conflict. - -Mitigaties: -1. **Seriële PRs** — start een nieuwe batch pas als de vorige PR gemerged is. De MCP `get_claude_context`-flow stuurt hier al op (één story tegelijk per agent). -2. **Slim batchen** — stories die hetzelfde domein raken (bv. alles rond Sprint Board) horen in dezelfde batch, niet verspreid over batches. -3. **Rebase vóór push** — `git fetch origin main && git rebase origin/main` vóór `gh pr create` lost kleine drift op zonder conflict. - --- ## Plan Mode diff --git a/docs/runbooks/deploy-control.md b/docs/runbooks/deploy-control.md deleted file mode 100644 index 1e5149d..0000000 --- a/docs/runbooks/deploy-control.md +++ /dev/null @@ -1,168 +0,0 @@ ---- -title: "Deploy-controle: triggers, labels, path-filter" -status: active -audience: [contributor, ai-agent] -language: nl -last_updated: 2026-05-07 -when_to_read: "Vóór een PR mergen, vóór doc-only changes pushen, of bij troubleshooting van Vercel-deployments." ---- - -# Deploy-controle - -Selectieve controle over wanneer Vercel-deployments worden uitgevoerd -vanuit de GitHub Actions workflow `.github/workflows/ci.yml`. Vercel's -eigen Git-integratie staat **uit** (`vercel.json: git.deploymentEnabled: -false`) — de workflow is de enige bron van deploy-truth. - -> **Auto-deploy staat momenteel UIT.** Zowel PR-preview als -> push-naar-main-productie deploys zijn afhankelijk van repo-variable -> `AUTO_DEPLOY_ENABLED=true`. Default ontbreekt die variable → beide -> auto-deploy-jobs worden overgeslagen om Actions-minuten te besparen. -> **Handmatig deployen blijft werken** via `workflow_dispatch` (zie -> onderaan). Aanzetten: *Settings → Secrets and variables → Actions → -> Variables → New repository variable → `AUTO_DEPLOY_ENABLED` = `true`*. - ---- - -## Triggers en defaults - -| Event | Default-deploy | -|---|---| -| `push` naar `main` | Productie (`vercel deploy --prod`) — alleen als path-filter zegt "code" **en** `AUTO_DEPLOY_ENABLED=true` | -| `pull_request` naar `main` | Preview (`vercel deploy`) — alleen als path-filter zegt "code", geen `skip-deploy` label **en** `AUTO_DEPLOY_ENABLED=true` | -| `workflow_dispatch` | Handmatig — kies `target: preview \| production` in Actions-tab. Werkt altijd, ongeacht `AUTO_DEPLOY_ENABLED`. | - -CI (lint, typecheck, test, build) draait **altijd** op push/PR — ook -voor doc-only changes. Alleen de deploy-jobs respecteren path-filter, -labels, en de `AUTO_DEPLOY_ENABLED` flag. - ---- - -## Path-filter - -Job `changes` (dorny/paths-filter@v3) zet output `code=true` als -één van deze paden gewijzigd is: - -``` -app/** -components/** -lib/** -actions/** -stores/** -prisma/** -public/** -package.json -package-lock.json -next.config.ts -tsconfig.json -vercel.json -proxy.ts -middleware.ts -.github/workflows/** -``` - -Wijzigingen aan `docs/`, `CLAUDE.md`, `README.md`, `.vscode/**`, etc. -zetten `code=false` → **geen deploy** (zelfs niet preview). - ---- - -## Labels (alleen op PRs) - -| Label | Effect | -|---|---| -| `skip-deploy` | Preview-deploy overslaan, ook als path-filter "code" zegt | -| `force-deploy` | Preview-deploy forceren, ook als path-filter "geen code" zegt | - -Beide labels werken alleen op **PR's**. Op pushes naar `main` heeft -alleen path-filter invloed (productie-gate). - ---- - -## Beslismatrix per scenario - -| Trigger | Path-filter | Labels | Resultaat | -|---|---|---|---| -| PR met code-change | `code=true` | (geen) | ✅ Preview-deploy | -| PR met code-change | `code=true` | `skip-deploy` | ❌ Preview overgeslagen | -| PR met doc-only | `code=false` | (geen) | ❌ Geen deploy | -| PR met doc-only | `code=false` | `force-deploy` | ✅ Preview-deploy | -| PR met code-change | `code=true` | `skip-deploy` + `force-deploy` | ✅ `force-deploy` wint | -| Push main code | `code=true` | n.v.t. | ✅ Productie-deploy + migrate | -| Push main doc-only | `code=false` | n.v.t. | ❌ Geen deploy | -| `workflow_dispatch` `target=preview` | n.v.t. | n.v.t. | ✅ Manuele preview | -| `workflow_dispatch` `target=production` | n.v.t. | n.v.t. | ✅ Manuele prod + migrate | - ---- - -## Voorbeelden - -**Doc-only PR die je niet wil deployen** (default): - -```bash -git checkout -b docs/fix-typo -# alleen docs/foo.md aanpassen -git commit -am "docs: typo" -gh pr create -# → CI runt, deploy-preview = SKIPPED -``` - -**Doc-only PR die je wél visueel wil checken**: - -```bash -gh pr create -gh pr edit --add-label force-deploy -# → CI runt, deploy-preview RUNT -``` - -**Code-PR die je niet wil deployen** (bv. WIP): - -```bash -gh pr create -gh pr edit --add-label skip-deploy -# → CI runt, deploy-preview SKIPPED -``` - -**Manuele productie-redeploy zonder push**: - -1. GitHub repo → Actions → CI workflow → "Run workflow" knop -2. `target: production` → Run -3. → `deploy-manual` job draait `prisma migrate deploy` + `vercel deploy --prod` - ---- - -## Troubleshooting - -**Probleem**: Twee deploys verschijnen op Vercel-dashboard per push. - -→ Check `vercel.json` bevat `"git": { "deploymentEnabled": false }`. Zo -niet: Vercel's eigen Git-integratie deployt parallel naast de workflow. - -**Probleem**: Mijn PR met code-change deployt geen preview. - -→ Check labels: heb je `skip-deploy` aangezet? Verwijder het label of -voeg `force-deploy` toe. -→ Check `changes`-job output in Actions-tab: zegt het `code=false`? -Mogelijk staat je wijziging buiten de path-filter (bv. een nieuw -top-level bestand). Pas filter aan in `ci.yml` als nodig. - -**Probleem**: Push naar main triggert geen prod-deploy. - -→ Check Actions-tab `changes`-job output. `code=false` betekent geen -deploy. -→ Forceer via `workflow_dispatch` met `target=production`. - -**Probleem**: `workflow_dispatch` toont geen "Run workflow" knop. - -→ Workflow moet minstens één keer op de default branch (main) hebben -gedraaid voordat de knop verschijnt. Eerste keer: merge naar main of -push direct naar main. - ---- - -## Referenties - -- Workflow: `.github/workflows/ci.yml` -- Vercel-config: `vercel.json` -- Plan: `docs/old/plans/auto-pr-deploy-sync.md` Deel A -- Branch- & commit-strategie: [`docs/runbooks/branch-and-commit.md`](./branch-and-commit.md) -- Auto-PR-flow (toekomstig): `docs/old/plans/auto-pr-deploy-sync.md` Deel B diff --git a/docs/runbooks/job-model-selection.md b/docs/runbooks/job-model-selection.md deleted file mode 100644 index b320718..0000000 --- a/docs/runbooks/job-model-selection.md +++ /dev/null @@ -1,141 +0,0 @@ ---- -title: "Job-model-selectie per ClaudeJob-kind" -status: active -audience: [ai-agent, contributor] -language: nl -last_updated: 2026-05-09 (idea-kinds + PLAN_CHAT permission_mode → acceptEdits) -when_to_read: "Vóór het wijzigen van model/thinking/permission-mode-keuze of bij debugging van 'verkeerd model gebruikt'-incidents." ---- - -# Job-model-selectie per ClaudeJob-kind - -PBI-67. Per `ClaudeJob.kind` bepaalt de Scrum4Me-mcp resolver -`scrum4me-mcp/src/lib/job-config.ts` welk Claude-model + thinking- -budget + permission-mode + max_turns + allowed_tools de Claude Code- -worker moet gebruiken. - -Dezelfde resolver staat — als één-op-één spiegel — in -[`lib/job-config.ts`](../../lib/job-config.ts) voor de enqueue-laag, -zodat we bij job-creatie het resolved resultaat al snapshotten in -`ClaudeJob.requested_*`. - ---- - -## Override-cascade - -``` - 1. Task.requires_opus = true → forceer claude-opus-4-7 - 2. Job.requested_* → snapshot bij enqueue - 3. Product.preferred_* → product-brede default - 4. KIND_DEFAULTS → per kind onderstaand -``` - -**Eerste match wint.** `max_turns` en `allowed_tools` blijven in V1 -altijd kind-default — geen product- of task-override. - ---- - -## Kind-default-matrix - -| Kind | Model | Thinking-budget | Permission-mode | max_turns | allowed_tools | -|---|---|---|---|---|---| -| `IDEA_GRILL` | `claude-sonnet-4-6` | 12 000 | `acceptEdits` | 15 | Read, Grep, Glob, WebSearch, AskUserQuestion | -| `IDEA_MAKE_PLAN` | `claude-opus-4-7` | 24 000 | `acceptEdits` | 20 | Read, Grep, Glob, WebSearch, AskUserQuestion, Write | -| `PLAN_CHAT` | `claude-sonnet-4-6` | 6 000 | `acceptEdits` | 5 | Read, Grep, AskUserQuestion | -| `TASK_IMPLEMENTATION` | `claude-sonnet-4-6` | 6 000 | `bypassPermissions` | 50 | (alle) | -| `SPRINT_IMPLEMENTATION` | `claude-sonnet-4-6` | 6 000 | `bypassPermissions` | (geen) | (alle) | - -**Note over `max_turns`** (sinds queue-loop-refactor): dit veld blijft -audit-only. Claude CLI 2.1.x heeft géén `--max-turns` flag — de waarde -wordt gesnapshot in `ClaudeJob.requested_*` voor cost-attribution maar -niet doorgegeven aan Claude. Zie -[worker-idempotency.md](./worker-idempotency.md#config-doorgeven-aan-claude-code-pbi-67). - -**Note over `thinking_budget`**: de CLI heeft alleen `--effort -{low,medium,high,xhigh,max}`. De runner mapt het numerieke budget naar -de juiste effort-laag via `mapBudgetToEffort()` in `lib/job-config.ts`. - -**Note over `allowed_tools`**: de defaults sluiten bewust -`mcp__scrum4me__wait_for_job`, `check_queue_empty` en -`get_idea_context` uit. De runner claimt voor Claude — vangrail tegen -recursieve claims binnen één invocation. - -**`bypassPermissions`** is verdedigbaar voor de implement-kinds omdat -elke run in een geïsoleerde git-worktree start (zie -[branch-and-commit.md](./branch-and-commit.md)). Productie-product? -Zet `Product.preferred_permission_mode = 'acceptEdits'`. - ---- - -## Wanneer overrul je een default? - -| Scenario | Wijzig op | Voorbeeld | -|---|---|---| -| Cross-file refactor of architectuurkeuze in TASK_IMPLEMENTATION | `Task.requires_opus = true` | Een PBI met "rip out auth middleware" | -| Klant wil budget-control op een product | `Product.preferred_model = claude-sonnet-4-6` | Side-product met Haiku-only-budget | -| Productie-product zonder bypassPermissions | `Product.preferred_permission_mode = 'acceptEdits'` | Klant-facing repo waar elke wijziging review nodig heeft | -| Ad-hoc: Opus voor één specifieke story-job | `ClaudeJob.requested_model = claude-opus-4-7` (handmatige UPDATE) | Nood-debug van prod-incident | -| Geen thinking voor een PLAN_CHAT (snelle reactie) | `Product.thinking_budget_default = 0` (alle kinds in dat product) | Demo-product | - ---- - -## Auditspoor - -| Kolom | Wat | Wanneer ingevuld | -|---|---|---| -| `requested_model` | Resolved model op enqueue-tijd | `actions/*` enqueue-laag via `lib/job-config-snapshot.ts` | -| `requested_thinking_budget` | Resolved budget op enqueue-tijd | idem | -| `requested_permission_mode` | Resolved permission-mode | idem | -| `model_id` | Werkelijk gebruikt model | `update_job_status` na worker-run | -| `actual_thinking_tokens` | Werkelijk verbruikte thinking-tokens | idem | - -Verschillen tussen `requested_model` en `model_id` zijn zichtbaar in -**admin → Jobs → Kosten** (rood-gemarkeerd modelveld + tooltip). -Meestal duidt dat op een worker die de CLI-flag niet doorgaf — -controleer de worker-script tegen de flag-tabel in -[worker-idempotency.md](./worker-idempotency.md#config-doorgeven-aan-claude-code-pbi-67). - ---- - -## Runner-architectuur (sinds 2026-05-09 queue-loop-refactor) - -`scrum4me-docker/bin/run-one-job.ts` draait **één Claude-invocation per -geclaimde job**. De runner leest de `config` uit de -`wait_for_job`-payload (resolved via deze module) en bouwt -kind-specifieke CLI-flags. Voor de exacte flag-mapping (incl. `--effort` -en `--allowedTools`) zie -[worker-idempotency.md](./worker-idempotency.md#config-doorgeven-aan-claude-code-pbi-67). - -Praktische gevolgen: - -- Per job-spawn een nieuwe Claude-invocation met andere flags — geen - config-mismatch tussen jobs in dezelfde queue-run. -- Verschillen tussen `requested_model` en `model_id` in admin → Jobs - duiden óf op handmatige DB-overrides tussen enqueue en claim, óf op - Claude die zelf een ander model rapporteerde (bv. fallback bij - overload). -- Plan: [queue-loop-extraction.md](../plans/queue-loop-extraction.md). - -## Cost-attribution - -Thinking-tokens worden bij Anthropic-billing gerekend tegen de -input-rate van het model. `lib/insights/token-stats.ts` en -`lib/insights/token-history.ts` doen hetzelfde: - -```sql -COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0 -``` - -Voor per-kind aggregatie binnen een sprint: gebruik -`getTokenStatsByKind(userId, sprintId)`. - ---- - -## Referenties - -- Plan: [docs/plans/job-model-selection.md](../plans/job-model-selection.md) -- Resolver (MCP): `scrum4me-mcp/src/lib/job-config.ts` -- Resolver (main): `lib/job-config.ts` -- Snapshot-helper: `lib/job-config-snapshot.ts` -- Worker-flag-mapping: [worker-idempotency.md](./worker-idempotency.md#config-doorgeven-aan-claude-code-pbi-67) -- Schema: `prisma/schema.prisma` → `Product`, `Task`, `ClaudeJob` velden uit migration `20260508085909_add_job_model_selection_fields` diff --git a/docs/runbooks/mcp-integration.md b/docs/runbooks/mcp-integration.md index d838c4f..bacc288 100644 --- a/docs/runbooks/mcp-integration.md +++ b/docs/runbooks/mcp-integration.md @@ -3,7 +3,7 @@ title: "MCP Integration — Scrum4Me Tools" status: active audience: [ai-agent] language: nl -last_updated: 2026-05-08 +last_updated: 2026-05-03 when_to_read: "When using MCP tools to interact with the Scrum4Me backlog." --- @@ -16,20 +16,13 @@ Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://g **Read / context:** - `mcp__scrum4me__health` — service + DB ping - `mcp__scrum4me__list_products` — producten waar de tokengebruiker toegang tot heeft -- `mcp__scrum4me__get_claude_context` — bundled product / actieve sprint (`status='OPEN'`) / next story (met tasks) / open ideas +- `mcp__scrum4me__get_claude_context` — bundled product / actieve sprint / next story (met tasks) / open todos **Authoring (PBI/Story/Task aanmaken):** - `mcp__scrum4me__create_pbi` — `{ product_id, title, description?, priority, sort_order? }`; auto sort_order = last+1 binnen prio-groep - `mcp__scrum4me__create_story` — `{ pbi_id, title, description?, acceptance_criteria?, priority, sort_order? }`; product_id afgeleid uit PBI; status=OPEN - `mcp__scrum4me__create_task` — `{ story_id, title, description?, implementation_plan?, priority, sort_order? }`; sprint_id geërfd van story; status=TO_DO - -**Sprint-lifecycle (PBI-12):** -- `mcp__scrum4me__create_sprint` — `{ product_id, code?, sprint_goal, start_date? }`; status start altijd op `OPEN`; code auto-gegenereerd als `S-{YYYY-MM-DD}-{N}` per product per dag als niet meegegeven; géén reuse-check op bestaande OPEN-sprints -- `mcp__scrum4me__update_sprint` — `{ sprint_id, status?, sprint_goal?, start_date?, end_date? }`; minimaal één veld vereist; **géén state-machine validatie** (last-write-wins, het resubmit/heropen-pad zit elders); auto-`end_date=vandaag` bij status → `CLOSED`/`FAILED`/`ARCHIVED` zonder expliciete end_date - -> Wanneer en hoe deze sprint-tools in de plan-flow gebruikt worden: zie [docs/runbooks/plan-to-pbi-flow.md](./plan-to-pbi-flow.md). - -> Idea-aanmaak loopt niet via MCP maar via de UI of `POST /api/ideas`. De voormalige `create_todo`-tool is verwijderd; idea-mutaties gaan via de Idea-tools onder *Idea-laag (M12)* hieronder. +- `mcp__scrum4me__create_todo` — losse todo (optioneel product-scoped) **Task / story writes:** - `mcp__scrum4me__update_task_status`, `mcp__scrum4me__update_task_plan` @@ -42,78 +35,21 @@ Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://g - `mcp__scrum4me__cancel_question` — asker-only annulering van een eigen open vraag **Job queue — agent worker mode (M13):** -- `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED. **Sinds M12** retourneert de payload een `kind`-discriminator: - - `kind: 'TASK_IMPLEMENTATION'` (default) — payload met `implementation_plan`, `story`, `pbi`, `sprint`, `repo_url` - - `kind: 'IDEA_GRILL'` of `'IDEA_MAKE_PLAN'` — payload met `idea`, `product`, `repo_url`, en `prompt_text` (de embedded prompt uit `lib/idea-prompts/`) - Stale CLAIMED-jobs (>30min) worden eerst terug naar QUEUED gezet. Lege queue na block-time = klaar. -- `mcp__scrum4me__update_job_status` — agent rapporteert `running|done|failed` + optionele branch/summary/error; triggert automatisch SSE-event. Bij `failed` voor `IDEA_GRILL`/`IDEA_MAKE_PLAN` wordt de idea-status automatisch op `GRILL_FAILED` resp. `PLAN_FAILED` gezet. Auth: Bearer-token moet matchen `claimed_by_token_id`. Optionele token-velden: `model_id` (string), `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens` (alle non-negative int) — worden opgeslagen op de ClaudeJob-rij bij done/failed. - -**Idea-jobs (M12) — agent gedrag per kind:** - -| Kind | Werkwijze | Eind-call | -|---|---|---| -| `IDEA_GRILL` | Lees `prompt_text` (embedded grill-prompt) + `idea.grill_md` als startpunt; itereer met `ask_user_question(idea_id=...)`/`get_question_answer`; log onderweg `log_idea_decision`; eindig met `update_idea_grill_md(markdown)` | `update_job_status('done')` | -| `IDEA_MAKE_PLAN` | Lees `prompt_text` (embedded make-plan-prompt) + `idea.grill_md` + repo-context. **Stel GEEN vragen** — single-pass output. Bouw plan in strict yaml-frontmatter format en eindig met `update_idea_plan_md(markdown)`. Server-side parser kan parse-fail → `PLAN_FAILED` | `update_job_status('done')` | - -**MCP-tools — Idea-laag (M12):** -- `mcp__scrum4me__get_idea_context(idea_id)` — `{ idea, product, repo_url, grill_md_so_far, open_questions, prompt_text }` -- `mcp__scrum4me__update_idea_grill_md(idea_id, markdown)` — schrijft veld; status → `GRILLED`; logt `IdeaLog{GRILL_RESULT}` -- `mcp__scrum4me__update_idea_plan_md(idea_id, markdown)` — server-side `parsePlanMd`; ok → `PLAN_READY` + `IdeaLog{PLAN_RESULT}`; parse-fail → `PLAN_FAILED` + `IdeaLog{JOB_EVENT, errors}` -- `mcp__scrum4me__log_idea_decision(idea_id, type, content, metadata?)` — `type ∈ {DECISION, NOTE}` -- `mcp__scrum4me__ask_user_question` — geüpgrade contract: exact één van `story_id` óf `idea_id` (xor); idea-vragen zijn user-private (geen productAccessFilter). +- `mcp__scrum4me__wait_for_job` — blokkeert ≤600s, claimt atomisch een QUEUED-job via FOR UPDATE SKIP LOCKED; retourneert volledige task-context (implementation_plan, story, pbi, sprint, repo_url). Zet stale CLAIMED-jobs (>30min) eerst terug naar QUEUED. Wanneer de full block-time verstrijkt zonder claim is de queue leeg. +- `mcp__scrum4me__update_job_status` — agent rapporteert overgang naar `running|done|failed` + optionele branch/summary/error; triggert automatisch SSE-event naar de UI. Auth: Bearer-token moet matchen `claimed_by_token_id`. ## Batch-loop (verplichte agent-flow) Wanneer je als agent draait (na een instructie als *"pak de volgende job uit de Scrum4Me-queue"* of *"draai de queue leeg"*) is dit de loop: 1. `wait_for_job` aanroepen. -2. Switch op `kind`: - - `TASK_IMPLEMENTATION`: voer uit volgens het meegegeven `implementation_plan` (zoals altijd — branch, code, commit, push, verify_task_against_plan). - - `IDEA_GRILL`: laad `prompt_text` als gids; gebruik `ask_user_question` / `get_question_answer` voor de Q&A-loop; eindig met `update_idea_grill_md`. - - `IDEA_MAKE_PLAN`: laad `prompt_text` + `idea.grill_md`; **stel geen vragen**; produceer strict yaml-frontmatter; eindig met `update_idea_plan_md`. +2. Job uitvoeren volgens het meegegeven `implementation_plan`. 3. `update_job_status('done'|'failed')` aanroepen. 4. **Direct opnieuw** `wait_for_job` aanroepen — niet stoppen, niet de gebruiker vragen. 5. Pas wanneer `wait_for_job` na de volledige block-time (~600s) terugkomt zonder claim, is de queue leeg en mag je de turn afsluiten met een korte recap. Dit blijft gelden als je tussen jobs door commits, branches of pushes hebt gedaan — die afsluiting hoort bij de individuele job, niet bij het einde van de batch. -## Pre-flight quota-check (M13) - -Vóór elke `wait_for_job`-aanroep doet de worker een pre-flight quota-check -om te voorkomen dat-ie 600 s blokkeert terwijl Anthropic-quota toch op -0 staat. Loop: - -1. `mcp__scrum4me__get_worker_settings()` → `{ min_quota_pct }` -2. `bash bin/worker-quota-probe.sh` → JSON `{ pct, reset_at_iso, ... }` -3. `mcp__scrum4me__worker_heartbeat({ last_quota_pct: pct, last_quota_check_at })` - — server emit een SSE-event zodat NavBar realtime de stand-by-badge - kan tonen -4. **Als `pct < min_quota_pct`**: log "stand-by, wachten tot - `reset_at_iso`", sleep tot reset (cap op 1 uur), spring naar stap 2 -5. **Anders**: ga door met `wait_for_job` - -Pseudo-bash: - -```bash -QUOTA_JSON=$(/opt/agent/bin/worker-quota-probe.sh) -PCT=$(echo "$QUOTA_JSON" | jq -r '.pct') -RESET=$(echo "$QUOTA_JSON" | jq -r '.reset_at_iso') - -# Stuur naar server (best-effort; failure niet-fataal) -mcp_call worker_heartbeat "{\"last_quota_pct\": $PCT}" - -if [[ "$PCT" -lt "$MIN_PCT" ]]; then - log "stand-by until $RESET (pct=$PCT < min=$MIN_PCT)" - sleep_until "$RESET" - continue -fi -``` - -**Beperking**: de probe kost ~1 outputtoken per check. 12 checks/uur = -12 tokens/uur overhead — verwaarloosbaar. De `min_quota_pct`-setting -staat per default op 20% — bij vrije Pro/Max-plans typisch ruim genoeg -om dagelijks werk niet te verstoren. - **Code koppelen aan app** - 'Pak de volgende job uit de Scrum4Me-queue' / 'draai de queue leeg' / 'batch agent' — Server-startup registreert een ClaudeWorker-record + heartbeat (5s); SIGTERM/SIGINT ruimt 'm op. UI in NavBar telt actieve workers via `last_seen_at < now() - 15s`. diff --git a/docs/runbooks/plan-to-pbi-flow.md b/docs/runbooks/plan-to-pbi-flow.md deleted file mode 100644 index beac63a..0000000 --- a/docs/runbooks/plan-to-pbi-flow.md +++ /dev/null @@ -1,207 +0,0 @@ ---- -title: "Plan → Sprint/PBI/Story/Task workflow" -status: active -audience: [ai-agent, maintainer] -language: nl -last_updated: 2026-05-11 -when_to_read: "Wanneer de gebruiker een plan goedkeurt en je het werk via Scrum4Me-MCP wilt vastleggen — inclusief sprint-lifecycle." ---- - -# Plan → Sprint / PBI / Story / Task workflow - -Hoe je een **goedgekeurd plan** omzet naar een hiërarchie van Sprint + PBI + Story + Task(s) via de Scrum4Me-MCP, zónder de taken meteen uit te voeren. Eén PBI = één increment = één sprint. - -Dit is de **creatie-kant** van het werk. De **uitvoer-kant** staat in [CLAUDE.md → "Hoe werk vinden"](../../CLAUDE.md) en [docs/runbooks/mcp-integration.md → Batch-loop](./mcp-integration.md). - -> Sprint-tools `create_sprint` en `update_sprint` zijn live in scrum4me-mcp (PBI-12). Tool-reference: [mcp-integration.md](./mcp-integration.md). - ---- - -## Wanneer wel — wanneer niet - -| Type werk | Sprint + PBI maken? | -|---|---| -| Nieuwe feature, refactor, UX-aanpassing, performance-fix | **Ja** | -| Bug-fix die meer dan een trivial-edit vereist | **Ja** | -| Doc-only edit (CLAUDE.md, runbook, README) | Nee — direct edit, commit, klaar | -| Typo, format-fix, dead-code verwijdering (<10 regels) | Nee | -| Spike / verkenning zonder concrete output | Nee — log eventueel als Idea (M12) | - -Sprint volgt PBI: **geen PBI → geen sprint**. Twijfel? Vraag het. - ---- - -## De vier-laagse flow - -``` -plan goedgekeurd - │ - ├─ create_sprint → Sprint-record (status=OPEN, start_date=vandaag) - │ │ - │ └─ create_pbi → PBI-record onder dat product - │ │ - │ └─ create_story → Story-record, koppelt aan de actieve sprint - │ │ - │ └─ create_task (× N) → sprint_id geërfd van story - │ - ├─ stop — wachten op uitvoer-instructie - │ - ├─ … execution-fase via "Hoe werk vinden" … - │ - └─ PR merged + verify groen → update_sprint(status=CLOSED, end_date=vandaag) -``` - -### 0. `create_sprint` (vóór alle andere create-calls) - -``` -{ product_id, code, sprint_goal, status: 'OPEN', start_date? } -``` - -- **`code`** — kort label, max 30 chars. Suggestie: `S-{YYYY-MM-DD}-{kebab-PBI-titel}` of een lopende teller (`S-2026-05-11-web-push`). -- **`sprint_goal`** — één regel, het increment in mensen-taal (komt uit de "Context" van het goedgekeurde plan). -- **`status`** — start op `OPEN`. -- **`start_date`** — vandaag; leeg laten als de server dit zelf invult. -- **Geen reuse:** altijd nieuw record. Bestaande OPEN-sprints van eerder werk blijven naast deze nieuwe leven; niet automatisch sluiten. - -> Eén PBI per sprint is de afgesproken één-op-één-koppeling. Als een plan logisch in meerdere onafhankelijke PBI's uiteenvalt, maak je ook meerdere sprints. - -### 1. `create_pbi` - -``` -{ product_id, title, description?, priority, sort_order? } -``` - -- **`title`** — korte feature-naam, geen PBI-nummer als prefix (DB kent al een id) -- **`description`** — markdown, het "wat & waarom" uit de Context-sectie van het plan -- **`priority`** — `LOW | NORMAL | HIGH` (default `NORMAL`); pas op `HIGH` zetten als de gebruiker het zelf zegt -- **`sort_order`** — leeg laten; server zet `last + 1` binnen de priority-groep -- Status start automatisch op `OPEN` - -### 2. `create_story` - -``` -{ pbi_id, sprint_id, title, description?, acceptance_criteria?, priority } -``` - -- **`title`** — concreet, in user-story stijl als dat past ("Als developer wil ik …") -- **`description`** — technische context, scope-grenzen, niet-doelen -- **`acceptance_criteria`** — markdown checklist (`- [ ] …`); bepaalt wanneer de Story `DONE` is -- `product_id` wordt afgeleid uit de PBI — niet meegeven -- **`sprint_id`** — geef de zojuist aangemaakte sprint mee. Als er meerdere OPEN-sprints bestaan: bevestig eerst met de gebruiker welke sprint geldt. -- **`sort_order`** — NIET meegeven. De server berekent `sort_order = parseCodeNumber(code)` automatisch. De volgorde van stories = de volgorde van hun codes (= aanroep-volgorde); niet priority. -- Status start op `OPEN` - -> **Eén story per PBI** is de gebruikelijke verhouding. Splits alleen op in meerdere stories als het plan logisch in onafhankelijk-shipbare delen valt — let op dat dit dan ook meerdere sprints betekent. - -### 3. `create_task` (één call per taak) - -``` -{ story_id, title, description?, implementation_plan?, priority } -``` - -- **`title`** — werkwoord-vorm: "Implementeer …", "Verplaats …", "Voeg test toe voor …" -- **`description`** — wat de taak afdekt, in 1-3 zinnen -- **`implementation_plan`** — **belangrijk**: markdown met de daadwerkelijke stappen + file-paths + reuse-pointers; dit is wat de Implementation-agent later inleest -- **`sort_order`** — NIET meegeven. De server berekent `sort_order = parseCodeNumber(code)` automatisch. De uitvoervolgorde = de aanroep-volgorde (= code-volgorde); niet priority. -- `sprint_id` wordt geërfd van de Story — niet meegeven -- Status start op `TO_DO` - -> De uitvoervolgorde van taken is gelijk aan de **aanroep-volgorde** van `create_task` (= code-volgorde). `priority` is een label (urgentie), géén sorteerkriteria. Zet voorbereidende taken (data-model, types) vóór UI-taken; tests komen ná de feature-implementatie tenzij TDD expliciet is afgesproken. - ---- - -## Hardstop — wachten op uitvoer-instructie - -Na `create_task` (de laatste): **stop**. Niet: - -- ❌ Branch aanmaken -- ❌ Code wijzigen -- ❌ `update_task_status` naar `IN_PROGRESS` zetten -- ❌ `get_claude_context` aanroepen om "vast te beginnen" - -De gebruiker leest de aangemaakte items, eventueel via de UI, en geeft expliciet de instructie *"voer de taken uit"* / *"pak deze story"* / *"begin met taak 1"*. Pas dan schakelt de flow over naar de **execution-loop** uit [CLAUDE.md → "Hoe werk vinden"](../../CLAUDE.md). - ---- - -## Sprint sluiten — `update_sprint` - -Na de execution-fase (laatste taak `DONE` → branch gepusht → PR aangemaakt) wordt de sprint pas `CLOSED` als **beide** condities waar zijn: - -1. **PR merged op `main`** — detecteer met `gh pr view <num> --json mergedAt` (niet-leeg = merged) of een GitHub-merge-webhook -2. **Verify groen** — `gh pr checks <num>` allemaal ✅, óf de bestaande [`mcp__scrum4me__verify_sprint_task`](./mcp-integration.md) tool slaagt voor de laatste taak - -Pas dan: - -``` -update_sprint({ sprint_id, status: 'CLOSED', end_date: today }) - → status = CLOSED -``` - -**Wat als één van beide rood is?** - -| Situatie | Handmatige flow | Cron-flow (auto) | -|---|---|---| -| PR merged, verify rood | Sprint blijft `OPEN`. Hot-fix taak/PR, daarna close-check herhalen. | Cron mag de sprint na *N* mislukte verify-runs (drempel TBD) op `FAILED` zetten. | -| Verify groen, PR niet merged | Sprint blijft `OPEN`. Wacht op review/merge. | Cron mag na *X* dagen zonder merge op `FAILED` (stale-detectie). | -| PR gesloten zonder merge | Sprint blijft `OPEN` totdat gebruiker beslist. | Cron mag direct op `FAILED` zetten — PR-`closed && !merged` is een eindstatus. | -| Werk geannuleerd door gebruiker | Sprint → `ARCHIVED` (handmatig). | Niet door cron — vereist gebruikersactie. | - -> **Cron-trigger:** een geplande job mag dus zowel `CLOSED` zetten (happy-path: merge + verify groen) als `FAILED` (sad-path: stale PR, blijvend rode verify, PR-closed zonder merge). De drempels (*N*, *X*) en transitie-policy komen in het vervolg-PBI voor de MCP-tools — zie [docs/plans/sprint-mcp-tools.md](../plans/sprint-mcp-tools.md). - ---- - -## Verhouding tot de planning-agent - -[docs/plans/tweede-claude-agent-planning.md](../plans/tweede-claude-agent-planning.md) (status: `proposal`) beschrijft een **automatische planning-agent** die deze flow uit een `PLANNING`-job zelfstandig uitvoert. Tot die agent er is, doet de Claude-Code-sessie het handmatig zoals hierboven. De toolchain (`create_sprint`/`create_pbi`/`create_story`/`create_task` + `update_sprint`) blijft identiek — de agent zal dezelfde MCP-tools gebruiken. - ---- - -## Korte voorbeeld-sessie - -``` -Gebruiker: "plan goedgekeurd" -Claude: create_sprint({ product_id, code: "S-2026-05-11-web-push", - sprint_goal: "Web-Push end-to-end voor open vragen", - status: "OPEN" }) - → sprint_id: 73 - - create_pbi({ product_id, title: "Web-Push notifications voor open vragen", - description: "<plan-context>", priority: "NORMAL" }) - → pbi_id: 142 - - create_story({ pbi_id: 142, - title: "PBI-142: web-push end-to-end", - description: "<scope>", - acceptance_criteria: "- [ ] Service worker geregistreerd\n- [ ] …", - priority: "NORMAL" }) - → story_id: 988 (gekoppeld aan sprint 73) - - create_task({ story_id: 988, title: "Voeg VAPID-keys toe aan env-schema", - implementation_plan: "1. lib/env.ts uitbreiden …", - priority: "NORMAL" }) - create_task({ story_id: 988, title: "Server action: subscribe-endpoint", - implementation_plan: "…", priority: "NORMAL" }) - create_task({ story_id: 988, title: "Vitest: subscribe-endpoint smoke", - implementation_plan: "…", priority: "NORMAL" }) - - "Sprint 73 (OPEN), PBI 142, Story 988 met 3 taken aangemaakt. - Klaar om uit te voeren zodra je 'voer uit' zegt." - -Gebruiker: "voer uit" -Claude: <execution-loop volgens 'Hoe werk vinden' — branch, code, commit, push, PR> - -…na PR-merge + verify groen… - -Claude: update_sprint({ sprint_id: 73, status: "CLOSED", end_date: "2026-05-12" }) - → sprint 73 status = CLOSED - "Sprint 73 gesloten. Increment shipped." -``` - ---- - -## Verwante docs - -- [docs/runbooks/mcp-integration.md](./mcp-integration.md) — volledige MCP-tool reference + execution-loop -- [docs/runbooks/branch-and-commit.md](./branch-and-commit.md) — git-discipline bij uitvoer -- [docs/runbooks/worker-idempotency.md](./worker-idempotency.md) — job-status protocol (uitvoer-fase) -- [docs/plans/tweede-claude-agent-planning.md](../plans/tweede-claude-agent-planning.md) — toekomstige planning-agent (proposal) diff --git a/docs/runbooks/review-plan-job.md b/docs/runbooks/review-plan-job.md deleted file mode 100644 index 296e4cd..0000000 --- a/docs/runbooks/review-plan-job.md +++ /dev/null @@ -1,285 +0,0 @@ -# Review-Plan Job Orchestration - -> Implementation guide for the IDEA_REVIEW_PLAN job kind and multi-model iterative plan review. - ---- - -## Overview - -The review-plan job is an autonomous agent that performs iterative multi-model review of implementation plans (YAML frontmatter + markdown documents). It coordinates three review stages (structure, logic/patterns, risk assessment), detects convergence, and either approves the plan or returns it for manual refinement. - -**Job Kind:** `IDEA_REVIEW_PLAN` -**Triggerable From:** `PLAN_READY`, `PLAN_REVIEWED` (re-review) -**Transitions To:** `PLAN_REVIEWED` (approved) or `PLAN_REVIEW_FAILED` (rejected/abandoned) - ---- - -## System Design - -### Data Flow - -``` -User clicks "Review Plan" on PLAN_READY idea - ↓ -startReviewPlanJobAction() queues IDEA_REVIEW_PLAN job - ↓ -Worker claims job via wait_for_job (MCP) - ↓ -Review-plan prompt orchestrates: - - Ronde 1: Structure check (YAML parsing, format correctness) - - Ronde 2: Logic & patterns (dependencies, architecture fit) - - Ronde 3: Risk assessment (edge cases, refactoring, type-safety) - ↓ -Convergence detection: if stable, ask approval - ↓ -On approval: update_idea_plan_reviewed(approval_status='approved') - → Idea transitions to PLAN_REVIEWED - → IdeaLog entry created with PLAN_REVIEW_RESULT - ↓ -On rejection: return for manual edit (status → PLAN_REVIEW_FAILED) -``` - -### Review-Log JSON Schema - -The orchestrator produces a detailed JSON log stored in `idea.plan_review_log`: - -```typescript -interface ReviewLog { - plan_file: string; // Idea code (e.g., "I-042") - created_at: ISO8601; // Review start timestamp - - rounds: Array<{ - round: number; // 0, 1, 2 (structure, logic, risk) - model: string; // claude-3-5-haiku | claude-3-5-sonnet | claude-opus-4-7 - role: string; // "Structure Review" | "Logic & Patterns" | "Risk Assessment" - focus: string; // Review focus summary - plan_before: string; // Original plan_md at round start - plan_after: string; // Revised plan after feedback - issues: Array<{ - category: 'structure' | 'logic' | 'risk' | 'pattern'; - severity: 'error' | 'warning' | 'info'; - suggestion: string; // Concrete fix recommendation - }>; - score: number; // 0-100 review score - plan_diff_lines: number; // Changed lines in this round - converged: boolean; // Did this round trigger convergence? - timestamp: ISO8601; // Round completion time - }>; - - convergence?: { - stable_at_round: number; // Round where convergence was detected - final_diff_pct: number; // Percentage of changed lines at convergence - convergence_metric: string; // "plan_stability" (constant for now) - }; - - approval: { - status: 'pending' | 'approved' | 'rejected'; - timestamp?: ISO8601; // When user made decision - }; - - summary: string; // 1–2 sentence summary for IdeaLog -} -``` - ---- - -## Assumptions & Constraints - -### Prompt Assumptions - -1. **Plan Format:** Idea's `plan_md` field contains YAML frontmatter (parsed at PLAN_READY) + markdown body. - - Frontmatter keys: `pbi`, `stories`, `tasks`, `priority`, `verify_required`. - - If parse fails, orchestrator transitions idea to `PLAN_REVIEW_FAILED`. - -2. **Context Availability:** The job payload includes: - - `idea.plan_md`: The plan to review (required) - - `idea.grill_md`: Context from grill phase (optional but recommended) - - `product.definition_of_done`: Product-level acceptance criteria - - `repo_url`: Local repository for pattern inspection - -3. **User Availability:** At least one worker is active (server-side check via `countActiveWorkers`). - -4. **No External APIs:** Orchestrator performs reviews entirely with information from job context. No external codex or multi-model APIs are called directly. - - Future improvement: Codex-injection from `docs/patterns/**/*.md` and `docs/architecture/**/*.md`. - -### Convergence Detection Assumptions - -1. **Stability Metric:** Two consecutive rounds with < 5% line changes = convergence. - - Threshold is hardcoded; future: make configurable per product. - - Diff percentage = `(changed_lines / total_lines) * 100`. - -2. **Max Iterations:** 3 initial rounds + 2 optional extra rounds (total max 5) before forced approval. - -3. **No Infinite Loops:** If max iterations reached, approval gate enforces a decision. - -### Validation Assumptions - -1. **Plan is Mutable:** Orchestrator can revise `plan_md` between rounds without breaking downstream parsing. - - If YAML structure is corrupted, `parsePlanMd` (server-side) will fail on approval. - - Orchestrator should never corrupt YAML syntax. - -2. **IdeaLog Persistence:** MCP tool `update_idea_plan_reviewed` atomically saves: - - `idea.plan_review_log` (full JSON) - - `idea.reviewed_at` (timestamp) - - `idea.status` (transition) - - `IdeaLog` entry (audit) - -3. **User Decisions are Final:** Once approved, plan-review log is immutable (until next re-review). - ---- - -## Implementation Details - -### Prompt Location - -- **Main Repo:** `lib/idea-prompts/review-plan-job.md` -- **MCP Server:** `scrum4me-mcp/src/prompts/idea/review-plan.md` -- **Synchronization:** Manual (for now); future: sync-schema.sh-like mechanism. - -### Job Config Snapshot - -Job created with config from `lib/job-config.ts`: - -```typescript -IDEA_REVIEW_PLAN: { - model: 'claude-opus-4-7', // Opus for final orchestration - thinking_budget: 6000, // Extended for multi-round analysis - permission_mode: 'acceptEdits', - max_turns: 1, - allowed_tools: [ - 'Read', 'Write', 'Grep', 'Glob', - 'mcp__scrum4me__update_idea_plan_reviewed', - 'mcp__scrum4me__log_idea_decision', - 'mcp__scrum4me__update_job_status', - 'mcp__scrum4me__ask_user_question', - ], -} -``` - -**Note:** Model is fixed to Opus for orchestration. Individual review rounds are simulated (not actual model switching) within Opus's analysis. Future: Direct multi-model support via Claude API. - -### MCP Tool: update_idea_plan_reviewed - -**Location:** `scrum4me-mcp/src/tools/update-idea-plan-reviewed.ts` - -**Input:** -```typescript -{ - idea_id: string; - review_log: object; // Full ReviewLog JSON - approval_status?: 'pending' | 'approved' | 'rejected'; -} -``` - -**Behavior:** -1. Validates user owns idea. -2. Transitions idea status: - - `approval_status='approved'` → `PLAN_REVIEWED` - - `approval_status='rejected'` → `PLAN_REVIEW_FAILED` - - Default → `PLAN_REVIEWED` -3. Saves `plan_review_log` and `reviewed_at` atomically. -4. Creates `IdeaLog` entry with type `PLAN_REVIEW_RESULT`. - ---- - -## Dependencies - -### Database - -- **Idea Model:** Must have fields `plan_review_log` (Json), `reviewed_at` (DateTime). -- **IdeaStatus Enum:** Must include `REVIEWING_PLAN`, `PLAN_REVIEW_FAILED`, `PLAN_REVIEWED`. -- **IdeaLogType Enum:** Must include `PLAN_REVIEW_RESULT`. - -### Server Actions - -- `startReviewPlanJobAction()` — Queues job, enforces status transitions. -- `cancelIdeaJobAction()` — Allows user to cancel mid-review (reverts to `PLAN_READY`). - -### MCP Tools - -- `update_idea_plan_reviewed()` — Saves review-log and transitions status. -- `log_idea_decision()` — Logs convergence/approval decisions. -- `update_job_status()` — Marks job as done/failed. -- `ask_user_question()` — Approval gate interaction. - -### Files - -- `lib/idea-prompts/review-plan-job.md` — Orchestrator prompt. -- `scrum4me-mcp/src/prompts/idea/review-plan.md` — MCP server copy. -- `scrum4me-mcp/src/lib/kind-prompts.ts` — Prompt loader. -- `scrum4me-mcp/src/tools/wait-for-job.ts` — Job context builder. - ---- - -## Error Handling - -### Parse Failures - -If `plan_md` cannot be parsed as valid YAML frontmatter: -1. Orchestrator logs error in review_log. -2. Calls `update_job_status('failed', error: 'plan_parse_failed')`. -3. Idea remains in `REVIEWING_PLAN` (no transition). -4. User can manually edit `plan_md` and retry. - -### User Cancellation - -If user cancels job via UI: -1. Server sets job status → `CANCELLED`. -2. Worker receives no further answer from `ask_user_question`. -3. Orchestrator gracefully saves partial review_log. -4. Calls `update_job_status('skipped', ...)`. -5. Idea reverts to `PLAN_READY`. - -### Question Timeout - -If approval question expires (24h): -1. Orchestrator logs timeout in review_log. -2. Calls `update_job_status('failed', error: 'approval_timeout')`. -3. Idea reverts to `PLAN_READY`. - ---- - -## Testing Strategy - -### Unit Tests - -- **Mock ReviewLog Generation:** Verify review-log JSON structure matches schema. -- **Convergence Calculation:** Diff percentage computation, stability threshold. -- **Status Transitions:** Valid state machine paths (PLAN_READY → REVIEWING_PLAN → PLAN_REVIEWED). - -### Integration Tests - -- **End-to-End:** Draft idea → Grill → Plan → Review → PLAN_REVIEWED. -- **Re-Review:** PLAN_REVIEWED → REVIEWING_PLAN → PLAN_REVIEWED (no data loss). -- **Cancellation:** Mid-review cancellation → revert to PLAN_READY. -- **Parse Errors:** Malformed plan_md → PLAN_REVIEW_FAILED. - -### Manual Testing - -1. Create test idea with PLAN_READY status. -2. Click "Review Plan". -3. Monitor job in Jobs dashboard. -4. Verify review-log in idea detail page. -5. Accept/reject approval. -6. Confirm status transition and IdeaLog entry. - ---- - -## Future Enhancements - -1. **Direct Multi-Model Calls:** Use Claude API to invoke Haiku, Sonnet, Opus separately with model switching. -2. **Codex Injection:** Auto-load and inject `docs/patterns/**/*.md` and `docs/architecture/**/*.md` as context. -3. **Configurable Thresholds:** Allow product-level convergence percentage and max-rounds settings. -4. **Review History:** Preserve all review-logs for audit trail and re-review diffs. -5. **Feedback Loop:** Log user edits between review rounds and suggest re-run based on delta. -6. **Scheduled Re-Review:** Auto-trigger review after N days (staleness check). - ---- - -## References - -- `docs/architecture/jobs.md` — Job system architecture. -- `docs/patterns/server-action.md` — Server action pattern (startReviewPlanJobAction). -- `docs/api/rest-contract.md` — API surface for plan-review. -- `lib/idea-status.ts` — Status transition graph and state machine. -- `lib/idea-plan-parser.ts` — Plan YAML parsing (validator for approved plans). diff --git a/docs/runbooks/v1-smoke-test.md b/docs/runbooks/v1-smoke-test.md deleted file mode 100644 index 3743338..0000000 --- a/docs/runbooks/v1-smoke-test.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -title: "v1.0 Smoke Test Checklist" -status: active -audience: [maintainer] -language: nl -last_updated: 2026-05-04 ---- - -# v1.0 Smoke Test Checklist - -Loop deze checklist door **vóór** je v1.0.0 tagt. Alleen handmatig — geen automation. -Time-budget: ~15 min. - -**Productie-URL:** https://scrum4me.jp-visser.nl - ---- - -## 1. Auth + dashboard (3 min) - -- [ ] **Demo-login:** `demo` / `demo1234` → dashboard rendert, alle write-acties geven 403 -- [ ] **Logout** vanuit user-menu → redirect naar `/login` -- [ ] **Register** met nieuwe gebruikersnaam → succesvol, redirect naar `/dashboard` -- [ ] **Login fout-flow:** verkeerd wachtwoord → generieke fout, geen leak - -## 2. Mobile UA-redirect (2 min) - -- [ ] DevTools mobile-emulatie iPhone 12 (UA-spoof) → log in → automatisch naar `/m/products/[id]/solo` (of `/m/settings` zonder actief product) -- [ ] Tablet-UA (iPad) → blijft op `/dashboard` -- [ ] Desktop blijft `/dashboard` - -## 3. Product → PBI → Story → Sprint → Task happy-path (5 min) - -- [ ] **Product aanmaken** (eigen account) → naam, code, DoD ingevuld -- [ ] **PBI aanmaken** in Product Backlog kolom → priority + status -- [ ] **Story aanmaken** onder PBI → titel + acceptatiecriteria -- [ ] **Sprint starten** met sprint-goal -- [ ] **Story slepen** vanuit Product Backlog naar Sprint Backlog -- [ ] **Task aanmaken** in Sprint → titel + implementation_plan -- [ ] **Task drag-and-drop** in Solo Paneel: To Do → Bezig → Klaar -- [ ] **Story-status auto-promotie:** alle taken DONE → story status DONE - -## 4. Mobile shell (2 min — op echte phone of DevTools landscape iPhone 12) - -- [ ] `/m/products/[id]` rendert in tab-mode (3 tabs onderaan: Backlog/Solo/Settings) -- [ ] Portrait-orientatie → rotate-overlay -- [ ] `/m/products/[id]/solo` toont 3-koloms kanban met horizontal scroll -- [ ] Task-detail dialog opent full-screen (`<640px`) — sticky header + footer bereikbaar -- [ ] `/m/settings` toont username + actieve product + logout-knop met bevestiging -- [ ] `/m/pair` toont QR-pairing-confirmation (M10 intact) -- [ ] **Geen** NavBar / AppIcon / Scrum4Me-tekst zichtbaar op `/m/*` - -## 5. Edit-flows (1 min) - -- [ ] **Pencil-icon op product-card** (dashboard hover) → ProductDialog opent -- [ ] **PBI ✎-icoon** (hover) → PbiDialog opent en saved -- [ ] **Story ✎-icoon** (sprint screen Sprint Backlog) → StoryDialog opent -- [ ] **Task ✎-icoon** (Taken-kolom) → TaskDialog opent - -## 6. Demo-policy (1 min) - -Inloggen als demo-gebruiker: -- [ ] PBI/Story/Task create-knoppen disabled met DemoTooltip -- [ ] Edit-iconen disabled -- [ ] Logout-knop bereikbaar (demo mag uitloggen) -- [ ] Productselector gaat door op view, maar Activeer-acties geven 403 - -## 7. Rate-limiting (steekproef, 1 min) - -- [ ] Probeer 31 PBIs in <60s aan te maken via UI → 31e geeft toast "Te veel acties achter elkaar" -- [ ] (Optioneel) `bash scripts/test-api.sh` → alle endpoints groen - -## 8. Realtime (1 min) - -- [ ] Open `/products/[id]/solo` in twee browsers (één als owner, één als teamlid) -- [ ] Status-toggle in browser A → ziet binnen 1s in browser B (SSE-pipe) -- [ ] Claude-vraag binnenkrijgen → bell-badge verschijnt zonder refresh - -## 9. Debug-routes (productie afgeschermd) - -- [ ] `https://scrum4me.jp-visser.nl/debug-env` → **404** (NODE_ENV-guard) -- [ ] `https://scrum4me.jp-visser.nl/debug-realtime` → **404** -- [ ] `https://scrum4me.jp-visser.nl/api/debug/realtime-stream` → **404** - -## 10. Lighthouse op happy-path - -- [ ] `/login` — a11y ≥95 -- [ ] `/dashboard` — a11y ≥95 -- [ ] `/products/[id]` — a11y ≥95 (was 86 vóór PR #88) -- [ ] `/products/[id]/sprint` — a11y ≥95 -- [ ] `/products/[id]/solo` — a11y ≥95 - -> Performance score in dev-mode is misleidend (dev-bundles, Chrome-extensies). -> Test op productie of `npm run build && npm run start` voor betrouwbare cijfers. - -## 11. Rollback-trigger - -Als één van bovenstaande faalt: -- Vercel dashboard → Deployments → vorige Ready deploy → "Promote to Production" -- Issue-titel: "v1.0 smoke-test failure: \<korte beschrijving\>" -- Tag v1.0.0 NIET totdat alles groen is diff --git a/docs/runbooks/worker-idempotency.md b/docs/runbooks/worker-idempotency.md deleted file mode 100644 index 3640377..0000000 --- a/docs/runbooks/worker-idempotency.md +++ /dev/null @@ -1,194 +0,0 @@ ---- -title: "Worker idempotency & job-status protocol" -status: active -audience: [ai-agent, contributor] -language: nl -last_updated: 2026-05-09 -when_to_read: "Vóór het implementeren of debuggen van Claude-CLI-worker logica die `update_job_status` aanroept." ---- - -# Worker idempotency & job-status protocol - -Beschrijft hoe de Scrum4Me-worker `ClaudeJob.status` moet zetten op basis -van `VerifyResult` × git-diff-staat × branch-staat. Doel: voorkom -status-divergentie zoals geconstateerd in de **PBI-33 batch (5-5-2026 -22:22)** waarin werk dat al gemerged was via PR #102/#103/#104 leidde -tot inconsistente combinaties van `verify=EMPTY → FAILED` en -`verify=DIVERGENT → DONE`. - ---- - -## Beslissingsboom - -Aan het einde van een story-job, ná `verify`-pass: - -| `verify_result` | netto diff t.o.v. `origin/main` | branch al gemerged | → `ClaudeJob.status` | `Task.status` | -|---|---|---|---|---| -| `ALIGNED` of `PARTIAL` | nieuwe commit aanwezig | n.v.t. | **`DONE`** | `DONE` | -| `EMPTY` | leeg (niets gewijzigd) | werk zit al op `origin/main` | **`SKIPPED`** | `DONE` | -| `EMPTY` | leeg, maar werk staat **niet** op origin | n.v.t. | **`FAILED`** (`error: "verify produced no output"`) | `IN_PROGRESS` (handmatig onderzoeken) | -| `DIVERGENT` | aanwezig, maar identiek aan al-gemergde branch | ja (PR closed/merged) | **`SKIPPED`** | `DONE` | -| `DIVERGENT` | aanwezig, niet matchend met main | nee | **`FAILED`** (`error: "verify divergent — handmatige review"`) | `IN_PROGRESS` | -| (compile-fail, test-fail, push-fail, exception) | n.v.t. | n.v.t. | **`FAILED`** met concrete `error` | `IN_PROGRESS` | -| (gebruiker drukt cancel) | n.v.t. | n.v.t. | **`CANCELLED`** | `TO_DO` | - -### Vuistregels - -- **`SKIPPED`** = "geen netto-output, maar geen fout" — werk was al - gedaan vóór deze job draaide. Task mag op `DONE` omdat het beoogde - resultaat in main aanwezig is. -- **`FAILED`** is gereserveerd voor échte fouten: code-fouten, - test-failures, push-fouten, onverklaarde diff. Niet voor - "implementatie was al gedaan". -- **`DONE`** alleen bij `ALIGNED`/`PARTIAL` mét nieuwe commit op de - feature-branch. Een lege `DIVERGENT` op een al-gemergde branch is - géén `DONE`. - ---- - -## StoryLog-verplichting - -Tijdens elke job moet de worker `story_logs`-entries schrijven via de -MCP-tools, anders is de Sync-tab leeg: - -| Wanneer | MCP-tool | Inhoud | -|---|---|---| -| Bij claim | `log_implementation` | "Start implementatie van T-XXX. Branch X. Plan: …" | -| Per commit | `log_commit` | hash + message + samenvatting van wijzigingen | -| Na verify | `log_test_result` | status `PASSED` of `FAILED` + samenvatting van checks | - -In **PBI-33 batch** zijn deze tools **niet** aangeroepen — `story_logs` -voor ST-1208/1209/1210 is leeg. Worker MAG geen job afronden zonder -minimaal één `log_implementation` (start) en één `log_test_result` -(eind). - ---- - -## Idempotency-protocol (vóór schrijven) - -Bij claim van een job: - -1. Lees `Task.implementation_plan` — beschrijft expliciet welke files - gewijzigd moeten worden. -2. Vergelijk de huidige `origin/main`-staat met die plan-instructies: - - Bestaat het bestand al met de beoogde inhoud? - - Bestaat de migratie al? - - Bevat de relevante codepad de nieuwe symbolen/types? -3. Bij **volledige hit**: roep `log_implementation` met inhoud "Werk - reeds aanwezig op origin/main vanaf commit X (Y)." Sla - verify-stap over en zet `JobStatus.SKIPPED`. Task naar `DONE`. -4. Bij **gedeeltelijke hit**: log de bevindingen via - `log_implementation` en doe alleen het resterende werk. Eindig met - `DONE` (`ALIGNED` of `PARTIAL`) als je netto-output hebt. - -Dit voorkomt dubbele commits op al-gemergde branches en houdt -`pushed_at` semantisch correct (alleen gevuld als er werkelijk -gepusht is). - ---- - -## Case-study: PBI-33 (5-5-2026 22:22) - -PBI-33 ("PLAN_CHAT — gebruikersvragen over plan") werd opnieuw aangemaakt -nadat de feature al via een eerdere batch was gemerged onder cuid-style -story-codes (`ST-bsjoqjnr`, `ST-p6d1odh0`, …). De worker draaide om -22:22 en zag: - -- **T-533** (`ST-1208` schema-werk): diff = leeg → `verify=EMPTY` → - `Job.FAILED` met error "Implementatie reeds voltooid en gemerged". - Volgens het nieuwe protocol had dit **`SKIPPED`** moeten zijn. -- **T-534…538**: diff niet leeg op feature-branches `feat/story-7pl4dsb6` - en `feat/story-0vtnydpi` (al-gemergde branches uit eerdere PR's) → - `verify=DIVERGENT` → `Job.DONE` met `pushed_at=now()`. Volgens het - nieuwe protocol had dit ook **`SKIPPED`** moeten zijn — branch was - al closed/merged, geen nieuwe commit. -- **`story_logs` voor ST-1208/1209/1210 is leeg** — geen - `log_implementation`, geen `log_commit`, geen `log_test_result`. - -Drie protocol-overtredingen die we met deze runbook + de nieuwe -`SKIPPED`-status aanpakken. - ---- - -## Config doorgeven aan Claude Code (PBI-67) - -`wait_for_job` levert sinds PBI-67 een `config`-object mee in de -response. **De runner** (`scrum4me-docker/bin/run-one-job.ts`) leest deze -config en bouwt per geclaimde job de juiste Claude CLI-flags. Eén -Claude-invocation per job — niet één lange sessie die zelf claimt. - -```bash -claude \ - -p "$PROMPT" \ - --model "$MODEL" \ - --permission-mode "$PERMISSION_MODE" \ - ${EFFORT:+--effort $EFFORT} \ - --allowedTools "$ALLOWED_TOOLS" \ - --mcp-config /opt/agent/mcp-config.json \ - --add-dir /opt/agent \ - --output-format text -``` - -Waar: - -| Variabele | Bron in response | Voorbeeld | -|---|---|---| -| `PROMPT` | `getKindPromptText(kind)` met `$PAYLOAD_PATH` vervangen | (kind-prompt-md) | -| `MODEL` | `config.model` | `claude-sonnet-4-6` | -| `PERMISSION_MODE` | `config.permission_mode` | `bypassPermissions` | -| `EFFORT` | `mapBudgetToEffort(config.thinking_budget)` (null = vlag weg) | `medium` | -| `ALLOWED_TOOLS` | `config.allowed_tools.join(',')` | `Read,Edit,…,mcp__scrum4me__update_task_status,…` | - -### Claude CLI 2.1.x flag-correctie - -De Claude CLI 2.1.x heeft géén numerieke `--thinking-budget` en géén -`--max-turns`. Mapping: - -| `config.thinking_budget` | CLI-flag | -|---|---| -| 0 | (geen `--effort` flag) | -| 1-6000 | `--effort medium` | -| 6001-12000 | `--effort high` | -| 12001-24000 | `--effort xhigh` | -| >24000 | `--effort max` | - -`config.max_turns` blijft **audit-only** — wordt gesnapshot in -`ClaudeJob.requested_*` voor cost-attribution maar niet doorgegeven aan -Claude. De resolver in `lib/job-config.ts` exporteert -`mapBudgetToEffort(budget)` voor deze mapping. - -### Verwachte CLI-aanroep per kind (defaults zonder overrides) - -| Kind | Model | thinking_budget | --effort | permission_mode | -|---|---|---|---|---| -| `IDEA_GRILL` | sonnet-4-6 | 12000 | high | acceptEdits | -| `IDEA_MAKE_PLAN` | opus-4-7 | 24000 | xhigh | acceptEdits | -| `PLAN_CHAT` | sonnet-4-6 | 6000 | medium | acceptEdits | -| `TASK_IMPLEMENTATION` | sonnet-4-6 | 6000 | medium | bypassPermissions | -| `SPRINT_IMPLEMENTATION` | sonnet-4-6 | 6000 | medium | bypassPermissions | - -### Wie doet wat in de runner-architectuur - -| Component | Verantwoordelijkheid | -|---|---| -| `bin/run-agent.sh` | Daemon-loop, exponential backoff, UNHEALTHY-marker, log-rotation, TOKEN_EXPIRED-detectie via exit-code 3 of stdout-regex | -| `bin/run-one-job.ts` | **Claim** (tryClaimJob + LISTEN-fallback 270s), config-resolve (getFullJobContext), payload schrijven, **CLI-flags bouwen**, spawn `claude`, lease-renewal voor SPRINT (setInterval 60s), rollbackClaim bij Claude exit≠0 zonder update_job_status, cleanup | -| `claude` (per invocation) | Voert exclusief de geclaimde job uit. **Mag geen** `wait_for_job`, `check_queue_empty`, of `job_heartbeat` aanroepen — zit niet in `allowed_tools` | - -Volledige resolver-uitleg + override-cascade staat in -[job-model-selection.md](./job-model-selection.md). Refactor-plan: -[queue-loop-extraction.md](../plans/queue-loop-extraction.md). - ---- - -## Referenties - -- Enum: `prisma/schema.prisma` → `enum ClaudeJobStatus` -- Mapping: `lib/job-status.ts` (DB↔API) en - `components/shared/job-status.ts` (label + kleur) -- Status-data-cleanup: `app/api/cron/cleanup-agent-artifacts/route.ts` -- KPI-aggregatie: `lib/insights/agent-throughput.ts` (terminal_7d - inclusief SKIPPED) -- Gerelateerd plan: `docs/old/plans/auto-pr-deploy-sync.md` Deel D -- PBI-67 resolver: `scrum4me-mcp/src/lib/job-config.ts` + `lib/job-config.ts` - (Sync-tab toont per-Story job-status incl. SKIPPED) diff --git a/docs/specs/dialogs/answer-modal.md b/docs/specs/dialogs/answer-modal.md deleted file mode 100644 index d762f2d..0000000 --- a/docs/specs/dialogs/answer-modal.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -title: "AnswerModal Profiel" -status: active -audience: [ai-agent, contributor] -language: nl -last_updated: 2026-05-15 ---- - -# AnswerModal Profiel - -> Volgt `docs/patterns/dialog.md`. Beschrijft alleen de Q&A-specifieke afwijkingen. - -## Doel - -Een Claude-agent vraagt tijdens een lopende job een verduidelijking aan de gebruiker. De vraag verschijnt als notification (bell + SSE event). Klik op de notification opent deze dialog waarin de gebruiker antwoordt — vrij tekst (max `ANSWER_MAX_CHARS`) of een keuze uit `options` als die meegegeven is. - -## Velden - -| Veld | Type | Mode | Validatie | -|---|---|---|---| -| `answer` | string | both | min 1, max `ANSWER_MAX_CHARS` (4000), trim | - -`questionId` komt uit de prop `question.id`, niet uit het formulier. - -## Schema - -`lib/schemas/question-answer.ts`: -- `answerQuestionSchema` — gedeeld door form + `answerQuestion` action -- `ANSWER_MAX_CHARS` constant — gebruikt door textarea + char-counter - -## URL- of state-pattern - -- Gekozen: **state-based** — `question: NotificationQuestion | null`-prop uit `notifications-sheet`. - -## Server action - -- `answerQuestion(questionId, answer)` in `actions/questions.ts` -- Result-shape: `{ ok: true } | { ok: false; error: string }` -- Demo-policy: `session.isDemo`-check in actie blokkeert demo-writes (laag 2). Laag 3 wordt verzorgd door `<DemoTooltip>` rond submit-knop en disabled-state op de textarea. -- Atomic transition met `updateMany` voorkomt double-submit races. - -## Layout - -Gebruikt `entityDialogContentClasses` (§4 spec). Body bevat naast de textarea ook de gestelde vraag (read-only block) en een link naar de bijbehorende sprint. Geen klassieke form-tag — de Textarea is een controlled component. - -## Speciale gedragingen - -### Multiple-choice mode - -Als `question.options` niet leeg is, worden de opties getoond als een lijst van knoppen. Klikken op een knop submit direct met die waarde. Het vrije tekstveld en de Verstuur-knop blijven altijd zichtbaar — ook in multiple-choice mode. Zo kan de gebruiker naast de vaste opties ook een eigen antwoord typen en versturen. - -### Optimistic remove - -Na succesvolle submit wordt de vraag direct uit `useNotificationsStore` verwijderd. De SSE-event komt later met dezelfde verwijdering — voorkomt extra render. - -### Dirty-tracking - -Single-field form: dirty = `answer.trim().length > 0`. Esc/backdrop/Cancel met dirty-state opent de standaard guard. - -## Foutcodes - -Action geeft alleen `{ ok, error: string }` terug — geen 422-fieldErrors omdat het een single-field form is. Errors worden via toast getoond. Validatie (`min 1`, `max 4000`) wordt UI-side voorkomen via maxLength + submit-disable. - -## Bewust NIET in v1 - -- ❌ **Markdown rendering** — antwoord wordt als plain text doorgegeven; Claude leest 'm direct als context. -- ✅ **Cmd/Ctrl+Enter shortcut** — werkt via `useDialogSubmitShortcut` in zowel textarea-mode als multiple-choice mode (het vrije tekstveld is altijd aanwezig). -- ❌ **Bulk-answer** — één vraag tegelijk per dialog. diff --git a/docs/specs/dialogs/batch-enqueue-blocker.md b/docs/specs/dialogs/batch-enqueue-blocker.md deleted file mode 100644 index f129001..0000000 --- a/docs/specs/dialogs/batch-enqueue-blocker.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -title: "BatchEnqueueBlockerDialog Profiel" -status: active -audience: [ai-agent, contributor] -language: nl -last_updated: 2026-05-04 ---- - -# BatchEnqueueBlockerDialog Profiel - -> Volgt `docs/patterns/dialog.md`. Dit is een **informational / confirm-dialog** zonder eigen entiteit — geen schema, geen demo-policy, geen server actions. - -## Doel - -Wanneer de gebruiker in solo-mode "stuur volgende N taken naar Claude" probeert maar er een blokkade voor de N-de taak ligt (een PBI op `blocked` of een taak op `review`), stopt de UI het in deze dialog en biedt aan om alleen de taken **vóór** de blokkade te queuen. - -## Modus - -Confirm-dialog. Geen create/edit/detail. Geen form. - -## Props - -```ts -{ - open: boolean - onOpenChange: (v: boolean) => void - prefixCount: number // hoeveel taken vóór de blokkade liggen - blockerReason: 'task-review' | 'pbi-blocked' - blockerLabel: string // titel van de blokkerende PBI/taak - onConfirm: () => void // alleen taken vóór de blokkade queuen - onCancel: () => void // helemaal annuleren -} -``` - -## URL- of state-pattern - -- Gekozen: **state-based** — gerendeerd door `solo-board` met een `BatchEnqueueState | null`-prop. - -## Layout - -Gebruikt `entityDialogContentClasses` voor responsive sizing. - -## Bewust NIET in v1 - -- ❌ **Geen demo-policy** — de dialog schrijft niet zelf; demo-blokkering vindt plaats wanneer `onConfirm` de daadwerkelijke `enqueueClaudeJobAction` aanroept (laag 2 demo-check zit daar). -- ❌ **Geen schema** — geen veldwaarden in/uit; alleen confirm/cancel. -- ❌ **Geen dirty-close-guard** — geen state om dirty te raken. -- ❌ **Geen Cmd/Ctrl+Enter** — niet zinvol voor confirm-only. diff --git a/docs/specs/dialogs/idea.md b/docs/specs/dialogs/idea.md deleted file mode 100644 index fbb32f9..0000000 --- a/docs/specs/dialogs/idea.md +++ /dev/null @@ -1,167 +0,0 @@ ---- -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". diff --git a/docs/specs/dialogs/pbi.md b/docs/specs/dialogs/pbi.md index 6067ad6..bd04ce3 100644 --- a/docs/specs/dialogs/pbi.md +++ b/docs/specs/dialogs/pbi.md @@ -3,7 +3,7 @@ title: "PbiDialog Profiel" status: active audience: [ai-agent, contributor] language: nl -last_updated: 2026-05-04 +last_updated: 2026-05-03 --- # PbiDialog Profiel @@ -74,11 +74,7 @@ Beide acties moeten de drielaagse demo-policy volgen (zie § Bekende gaps). ### Form-state via `useActionState` -PbiDialog gebruikt `useActionState` (Server Actions / native React), niet `react-hook-form`. Dit is een toegestaan alternatief volgens de generieke spec § 2. Field-errors komen uit het action-result als `result.fieldErrors: Record<string, string[]>` met `result.code === 422`; een lokale `fieldError(field)`-helper levert het eerste bericht op. - -### Dirty-tracking handmatig - -Omdat we geen `react-hook-form` gebruiken, zetten we `dirty` op `true` bij de eerste `onChange` op het form (en bij wijzigingen van de hidden-state-velden `priority`/`status`). De useDirtyCloseGuard hook gebruikt dit boolean om Esc/Cancel-sluiting te beschermen. +PbiDialog gebruikt het `useActionState` + `useFormStatus`-patroon (Server Actions / native React), niet `react-hook-form`. Dit is een toegestaan alternatief volgens de generieke spec § 2. Field-errors worden gemapt via een lokale `fieldError(field)`-helper die `result.error` als `Record<string, string[]>` interpreteert wanneer 'm geen string is. ### `key`-prop op `<form>` @@ -97,10 +93,16 @@ Het `<form>`-element heeft `key={isEdit ? pbi!.id : 'create'}` — dit reset nat --- -## Bewust NIET in v1 (PBI-specifiek) +## Bekende gaps t.o.v. generieke spec -- ❌ **Geen delete-knop / `deletePbiAction`-trigger vanuit deze dialog** — PBI's worden niet vernietigend verwijderd vanuit de UI; wijzig de status naar `done` of archiveer via een ander mechanisme. `deletePbiAction` bestaat in de codebase voor server-side cleanup maar wordt niet vanuit deze dialog aangeroepen. -- ❌ **Geen char-counter / markdown-hint** op description — PBI-descriptions zijn doorgaans kort en richtinggevend; auto-grow en markdown-rendering horen op StoryDialog/TaskDialog. +> Deze items wijken af van `docs/patterns/dialog.md` en horen in een vervolg-PR rechtgezet (niet onderdeel van de huidige docs-introductie). + +- ❌ **Geen `<DemoTooltip>`** rond submit-knop — laag 3 van de drielaagse demo-policy ontbreekt voor PBI-create/update. Dat betekent dat een demo-user de knop kan klikken; de server action blokkeert nog steeds (laag 2), maar de UX is suboptimaal. +- ❌ **Geen delete-knop / `deletePbiAction`** — alleen create + update. Of dat bewust is (PBI's worden nooit verwijderd, alleen status veranderd) of een gat, moet expliciet worden besloten en in dit profiel vastgelegd. +- ❌ **Geen dirty-close-guard** — Esc / backdrop / Cancel sluiten direct, ook met onopgeslagen wijzigingen. Generieke spec § 8.1 vereist een AlertDialog bij `isDirty`. +- ❌ **Geen Cmd/Ctrl+Enter shortcut** — alleen klik op submit-knop. +- ❌ **Geen char-counter / markdown-hint** op description — bewust weggelaten omdat PBI-descriptions kort zijn, maar verdient expliciete bevestiging. +- ⚠️ **Layout wijkt af** van de generieke responsive-tabel: `sm:max-w-md` i.p.v. de `max-w-[50vw]` / `90vw` / full-screen-progressie uit § 4. --- diff --git a/docs/specs/dialogs/product.md b/docs/specs/dialogs/product.md deleted file mode 100644 index f34841c..0000000 --- a/docs/specs/dialogs/product.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -title: "ProductDialog Profiel" -status: active -audience: [ai-agent, contributor] -language: nl -last_updated: 2026-05-04 ---- - -# ProductDialog Profiel - -> Volgt `docs/patterns/dialog.md`. Dit document beschrijft alleen de afwijkingen en entity-specifieke keuzes. - -## Velden - -| Veld | Type | Mode | Validatie | -|---|---|---|---| -| `name` | string | both | min 1, max 200 | -| `code` | string? | both | max 20, alleen `[a-zA-Z0-9._-]`; uniek per gebruiker | -| `description` | string? | both | max 4000, markdown vrij | -| `repo_url` | string? \| null | both | https URL, moet beginnen met `https://github.com/` | -| `definition_of_done` | string? | both | max 4000, vrije tekst | -| `auto_pr` | boolean | both | default `false` | - -## URL- of state-pattern - -- Gekozen: **state-based** (§11.2) -- Reden: dialog leeft binnen één parent-component (`ProductList` op `/dashboard` en de product-actions-bar op `/products/[id]`); deep-linking is niet vereist -- Open-state komt uit `ProductList` (lijst-context) of `EditProductButton` (single-item context) - -## Status-veld - -N.v.t. — Product heeft geen status-enum. `archived` is een boolean buiten dit dialog (eigen archive-flow). - -## Server actions - -- `createProductAction(data)` in `actions/products.ts` — context-arg via `revalidatePath('/products')` + `revalidatePath('/dashboard')` -- `updateProductAction(id, data)` in `actions/products.ts` — context-arg via `revalidatePath('/products/${id}')` + `revalidatePath('/dashboard')` + `pg_notify('product_updated')` -- Beide hebben `session.userId`-check, `session.isDemo`-check (laag 2 demo-policy) en `productAccessFilter` voor update -- Resultaat-shape: `{ success: true, productId? }` of `{ error: string, code?: 422|403, fieldErrors?: Record<string, string[]> }` - -## Foutcodes - -| Code | Wanneer | UI | -|---|---|---| -| 422 | zod-validatie of code-uniqueness | `fieldErrors` → `form.setError`, geen toast, focus naar eerste error-veld | -| 403 | niet ingelogd, demo-modus, of geen toegang tot product | toast met message | -| 500 | onverwacht | huidige behandeling: error wordt door React opgevangen — laat de form open | - -## Speciale gedragingen - -- **Custom switch voor `auto_pr`**: native `<button role="switch">` met MD3-kleur-tokens (geen aparte primitive in v1; zou gepromoot moeten worden naar `components/shared/switch.tsx` zodra elders nodig). -- **Code-uniqueness server-side**: bij conflict wordt `fieldErrors.code` gezet; veld krijgt rode rand. -- **`useProductsStore` updates**: na succesvolle save wordt de in-memory store synchroon bijgewerkt zodat de productlijst onmiddellijk reageert (lokaal-first). - -## Bewust NIET in v1 - -- Verwijderen vanuit deze dialog (loopt via `archiveProductAction` op een andere knop) -- Bulk edit -- Members beheren (eigen scherm op `/products/[id]/settings`) diff --git a/docs/specs/dialogs/sprint.md b/docs/specs/dialogs/sprint.md deleted file mode 100644 index c4ac771..0000000 --- a/docs/specs/dialogs/sprint.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -title: "Sprint Dialogs Profiel" -status: active -audience: [ai-agent, contributor] -language: nl -last_updated: 2026-05-04 ---- - -# Sprint Dialogs Profiel - -> Volgt `docs/patterns/dialog.md`. Dit document beschrijft alleen de Sprint-specifieke afwijkingen en keuzes. - -Sprint heeft drie dialog-flows verspreid over twee componenten: - -| Flow | Locatie | Mode | -|---|---|---| -| Sprint starten | `components/sprint/start-sprint-button.tsx` | create | -| Datums bewerken | `components/sprint/sprint-header.tsx` (inline) | edit | -| Sprint afronden | `components/sprint/sprint-header.tsx` (inline) | actie-bevestiging | - -Daarnaast bestaat een **inline edit-form** voor de Sprint Goal in `sprint-header.tsx` — dat is geen dialog (geen modale overlay) maar een toggleable form-row. - -## Velden - -### Create / Edit dates - -| Veld | Type | Validatie | -|---|---|---| -| `sprint_goal` | string (alleen create) | min 1, max 500 | -| `start_date` | date \| null | optioneel; `end_date >= start_date` | -| `end_date` | date \| null | optioneel; `end_date >= start_date` | - -### Complete sprint - -Geen form-velden. Per story-rij in de sprint kiest de gebruiker `'DONE'` of `'OPEN'` (default `'OPEN'`). De decisions-map wordt direct aan `completeSprintAction` doorgegeven. - -## URL- of state-pattern - -- Gekozen: **state-based** (§11.2) -- Reden: alle dialogen zijn local-state in hun parent-component (button of header). Sprint heeft geen deep-link-bare detail-pagina voor zijn dialogen. - -## Server actions - -- `createSprintAction(_prev, fd)` — `actions/sprints.ts` — revalidate `/products/${productId}` -- `updateSprintDatesAction(_prev, fd)` — idem — revalidate `/products/${productId}/sprint` -- `updateSprintGoalAction(_prev, fd)` — idem — revalidate `/products/${productId}/sprint` -- `completeSprintAction(sprintId, decisions)` — niet form-based; directe argumenten -- Alle hebben `session.userId`-check, `session.isDemo`-check (laag 2 demo-policy) en `productAccessFilter`/`getAccessibleProduct` voor scope -- Resultaat-shape: `{ success: true, ... }` of `{ error: string, code?: 422|403, fieldErrors?: Record<string, string[]> }` - -## Foutcodes - -| Code | Wanneer | UI | -|---|---|---| -| 422 | zod-validatie of date-order constraint | `fieldErrors` onder de velden, geen toast | -| 403 | niet ingelogd, demo-modus, of geen toegang | toast met message | - -## Schema - -`lib/schemas/sprint.ts` exporteert: -- `createSprintSchema` — productId, sprint_goal, start_date, end_date -- `updateSprintDatesSchema` — id, start_date, end_date -- `updateSprintGoalSchema` — id, sprint_goal -- `validateDateOrder` — refinement gebruikt door beide date-schemas - -Alle drie de actions importeren hier; geen inline schemas meer. - -## Bewust NIET in v1 - -- ❌ **Eén consolideerde `SprintDialog`-component** met `mode: 'create' | 'edit-dates' | 'complete'` — overwogen tijdens story 5 maar niet uitgevoerd; de dialogen leven natuurlijker in hun parent-component (button / header) en worden niet hergebruikt elders. Indien een vierde sprint-dialog ontstaat, hernieuw deze afweging. -- ❌ Bewerken van de Sprint Goal vanuit deze dialogen — gebeurt via een inline-form in `sprint-header.tsx` (toggleable, geen modal) -- ❌ Sprint-templates / kopiëren van vorige sprint diff --git a/docs/specs/dialogs/story.md b/docs/specs/dialogs/story.md index 10f2fca..137f935 100644 --- a/docs/specs/dialogs/story.md +++ b/docs/specs/dialogs/story.md @@ -3,7 +3,7 @@ title: "StoryDialog Profiel" status: active audience: [ai-agent, contributor] language: nl -last_updated: 2026-05-04 +last_updated: 2026-05-03 --- # StoryDialog Profiel @@ -104,17 +104,19 @@ In edit-mode wordt onder het form een `<StoryLog>`-paneel getoond met de chronol Dit is een **read-only side-panel** en valt binnen de uitzondering die de generieke spec § 13 maakt voor `<StoryLog>`-style activity-rendering. -### Delete-flow +### Delete-flow (afwijking van generieke spec) -Volgt generieke spec § 10.4: klik op "Verwijderen" opent een `AlertDialog` ("Story verwijderen — bijbehorende taken worden ook verwijderd"). Bevestigen roept `deleteStoryAction` aan. +Generieke spec § 10.4 vereist een **`AlertDialog`** voor delete-confirmatie. StoryDialog gebruikt in plaats daarvan een **inline-confirm** in dezelfde footer-rij: + +``` +[ Weet je het zeker? Taken worden ook verwijderd. [Verwijderen] [Annuleren] ] +``` + +Een `AlertDialog` zou een tweede modale laag toevoegen die in deze context onhandig voelt (de dialog zelf is al een interruptive overlay). De inline-confirm is een **bewuste afwijking** van de generieke spec. ### Form-state via `useActionState` -Net als PbiDialog gebruikt StoryDialog `useActionState`, niet `react-hook-form`. Pending-state komt uit de derde return-waarde (`useActionState[2]`). Dit is een toegestaan alternatief volgens de generieke spec § 2. - -### Dirty-tracking handmatig - -Geen `react-hook-form`, dus `dirty` wordt op `true` gezet bij de eerste `onChange` op het form en bij wijzigingen van de hidden-state (`priority`). De `useDirtyCloseGuard` hook gebruikt deze boolean om Esc/Cancel/backdrop te beschermen. +Net als PbiDialog gebruikt StoryDialog `useActionState` + `useFormStatus`, niet `react-hook-form`. Dit is een toegestaan alternatief volgens de generieke spec § 2. ### `key`-prop op `<form>` @@ -130,10 +132,16 @@ Het `<form>` heeft `key={isEdit ? story!.id : 'create'}` — reset native form-s --- -## Bewuste afwijkingen van generieke spec +## Bekende gaps t.o.v. generieke spec -- ⚠️ **Header-layout** met meerdere badges wijkt af van de sobere header in § 4. Bewuste keuze — story-context (priority + status) wil je direct zichtbaar bij record-wisselen. -- ❌ **Geen char-counter / markdown-hint** op description / acceptance_criteria — bewust weggelaten omdat stories meestal één zin lang zijn. +> Deze items wijken af van `docs/patterns/dialog.md` en horen in een vervolg-PR rechtgezet (niet onderdeel van de huidige docs-introductie). + +- ❌ **Geen dirty-close-guard** — Esc / backdrop / Cancel sluiten direct, ook met onopgeslagen wijzigingen. Generieke spec § 8.1 vereist een AlertDialog bij `isDirty`. +- ❌ **Geen Cmd/Ctrl+Enter shortcut** — alleen klik op submit-knop. +- ❌ **Geen char-counter / markdown-hint** op description / acceptance_criteria — bewust weggelaten, maar verdient expliciete bevestiging als design-keuze. +- ⚠️ **Inline-delete-confirm** in plaats van AlertDialog (zie § Speciale gedragingen). Bewuste afwijking; de generieke spec mag deze variant expliciet toestaan, of dit profile moet als precedent gelden voor toekomstige dialogen. +- ⚠️ **Header-layout** met meerdere badges wijkt af van de sobere header in § 4. Bewuste afwijking — context-zwaar bij story-wisselen. +- ⚠️ **Layout wijkt af** van de generieke responsive-tabel: `sm:max-w-lg` met eigen `max-h-[90vh]` + `flex flex-col` i.p.v. de exacte `max-w-[50vw]` / `90vw` / full-screen-progressie uit § 4. --- diff --git a/docs/specs/dialogs/task-detail.md b/docs/specs/dialogs/task-detail.md deleted file mode 100644 index e3c686e..0000000 --- a/docs/specs/dialogs/task-detail.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -title: "TaskDetailDialog Profiel" -status: active -audience: [ai-agent, contributor] -language: nl -last_updated: 2026-05-04 ---- - -# TaskDetailDialog Profiel - -> Volgt `docs/patterns/dialog.md` § 4a — **inspector-mode**. Dit document beschrijft alleen de Solo-specifieke invulling. - -> **Niet te verwarren met `TaskDialog`** (`app/_components/tasks/task-dialog.tsx`) — dat is de classic create/edit-dialog voor backlog-taken. **`TaskDetailDialog`** is een solo-board-specifieke inspector-dialog die een lopende taak laat zien terwijl de Claude-agent eraan werkt. - -## Doel - -Vanuit het solo-board kan de gebruiker op een task-card klikken om: -- Het implementation_plan te lezen / bewerken (markdown, blur-save) -- Verify-instellingen te wijzigen (`verify_only`, `verify_required`) -- Claude-job-status en branch-link te zien -- De huidige taak naar Claude te sturen / te annuleren - -## Velden - -| Veld | Type | Validatie | -|---|---|---| -| `implementation_plan` | string \| null | max 10000, markdown, blur-save | -| `verify_only` | boolean | toggle, direct opgeslagen | -| `verify_required` | enum `'ALIGNED' \| 'ALIGNED_OR_PARTIAL' \| 'ANY'` | radio, direct opgeslagen | - -## URL- of state-pattern - -- Gekozen: **state-based** — `task: SoloTask | null` prop uit `solo-board`. `null` = dialog gesloten. -- Reden: solo-board is one-page; de detail-dialog is altijd in context. - -## Persistence - -**Geen klassiek form-submit.** Wijzigingen schrijven via `fetch('/api/tasks/:id', { method: 'PATCH' })` (route handler), getriggerd door: -- Plan-textarea: blur of debounced auto-save -- Verify-toggles: direct bij click - -Dit valt buiten de standaard "Server Action met form" flow van `docs/patterns/dialog.md` § 7. Reden: het zijn fine-grained edits van een lopende taak, geen save-dan-sluit-flow. - -## Drielaagse demo-policy - -- **Laag 1 (proxy.ts):** `/api/tasks/[id]`-route is via `apiAuth`-helper beschermd; demo-write zou geblokkeerd moeten worden in de route handler zelf -- **Laag 2 (route handler):** `session.isDemo`-check in `app/api/tasks/[id]/route.ts` (PATCH) — verifieer dat dit aanwezig is -- **Laag 3 (UI):** `<DemoTooltip show={isDemo}>` rond plan-textarea en verify-toggles; `readOnly` resp `disabled`-state op de controls - -## Layout - -Volgt §4 + §4a inspector-layout: -- `<DialogContent className={entityDialogContentClasses}>` voor de outer (responsive breakpoints, max-h, flex column) -- Sticky header met `shrink-0 + px-6 pt-5 pb-4 + border-b border-outline-variant` -- Body in `entityDialogBodyClasses` (`flex-1 overflow-y-auto px-6 py-6 space-y-6`) — secties: Beschrijving, Implementatieplan, verify-only toggle, verify-gate select -- Footer in `entityDialogFooterClasses + flex flex-wrap items-center gap-2` — bevat dynamische job-status en context-knoppen (Voer uit / Wacht op agent / Annuleer / Open PR / Open op GitHub / Verify-result) -- Plan-textarea krijgt `max-h-[40vh]` zodat een groot plan niet meteen het hele body-gebied claimt; body kan dan scrollen langs de overige secties - -## Inspector-mode-vinkjes - -Volgens § 4a: -- ✓ Geen `useDirtyCloseGuard` — wijzigingen direct gepersisteerd -- ✓ Geen `useDialogSubmitShortcut` — geen submit -- ✓ Geen full-record `lib/schemas/<entity>.ts` — fine-grained PATCH per veld -- ✓ Dynamische footer met liveness-info (job-status) -- ✓ Drielaagse demo-policy aanwezig (zie boven) -- ✓ MD3-tokens, motion, backdrop, focus-return uit § 8.4-8.5 erven via `<DialogContent>` - -## Gerelateerde bestanden - -- `components/solo/task-detail-dialog.tsx` — implementatie -- `app/api/tasks/[id]/route.ts` — PATCH-handler -- `stores/solo-store.ts` — client-state diff --git a/docs/specs/functional.md b/docs/specs/functional.md index 7def620..4674dd7 100644 --- a/docs/specs/functional.md +++ b/docs/specs/functional.md @@ -3,7 +3,7 @@ title: "Scrum4Me — Functionele Specificatie" status: active audience: [maintainer, contributor] language: nl -last_updated: 2026-05-08 +last_updated: 2026-05-03 --- # Scrum4Me — Functionele Specificatie @@ -27,7 +27,7 @@ v1 is een desktop-first fullstack webapplicatie waarmee een solo developer of kl - Integratie met externe tools (GitHub Issues, Linear, Jira) — v2 - Notificaties en reminders — v2 - Native mobiele app — web-first; een toekomstige mobiele variant richt zich uitsluitend op taken afvinken -- Responsive layout voor schermen smaller dan 1024px — desktop-first hoofdpad. Voor telefoons (UA met `Mobi`) is er een aparte mobile-shell onder `/m/*` met drie schermen — zie sectie *Mobile shell* hieronder. +- Responsive layout voor schermen smaller dan 1024px — desktop-first in v1 --- @@ -210,8 +210,6 @@ Gebruikers kunnen producten aanmaken, bewerken en archiveren. Een product is het **Omschrijving:** De Product Backlog wordt weergegeven als een 3-paneels gesplitst scherm: PBI's (links) | Stories (midden) | Taken (rechts). De splitters zijn versleepbaar. Selectie cascadeert: klikken op een PBI toont de bijbehorende stories; klikken op een story toont de bijbehorende taken. Elk paneel heeft een eigen navigatiebar met acties. -> **Workflow & states:** dit spec beschrijft de *layout*. Voor het *gedrag* — architectuur-lagen, de impliciete workflow-states, transitions en de to-be state machine — zie [architecture/product-backlog-workflow.md](../architecture/product-backlog-workflow.md). - **Acceptatiecriteria:** - [ ] Standaard splitverhouding is 20/45/35 (PBI's / Stories / Taken) - [ ] Splitters zijn versleepbaar; positie wordt opgeslagen in een cookie (`sp:backlog-{id}`) @@ -324,30 +322,31 @@ Elke story heeft een activiteitenlog die alle door Claude Code vastgelegde stapp --- -### F-08: Ideeën-laag (Idea capture, grill, plan) +### F-08: Todo-lijst -**Prioriteit:** v1 — Hoog (M12 — vervangt Todo-lijst) +**Prioriteit:** v1 — Hoog **Persona:** Lars (snelle vastlegging), Dina (losse klantnotities) **Omschrijving:** -Een idea is een gestructureerde voorganger van een PBI. De gebruiker maakt een idea snel aan (titel + optioneel product). Een agent _grilt_ het idea via een interactieve Q&A-loop (`IDEA_GRILL`-job → `grill_md`); daarna materialiseert een tweede agent het idea in een deterministisch plan (`IDEA_MAKE_PLAN`-job → `plan_md`) dat parsed wordt naar PBI + stories + tasks. De `todos`-tabel uit eerdere versies is per migratie ST-1239 gedropt en volledig vervangen door `ideas`. +Een snelle todo-lijst voor taken die aan een specifiek product zijn gekoppeld. Todo-items kunnen worden afgevinkt en gepromoveerd naar een PBI of story in dat product. Zowel de UI als de REST API vereisen een `product_id` bij aanmaken — zodat Claude Code altijd werkt binnen de context van de actieve product backlog. **Acceptatiecriteria:** -- [ ] Idea aanmaken via snel-invoerveld (Enter om op te slaan); titel verplicht, product optioneel -- [ ] Idea-lijst toont items per status (`DRAFT`, `GRILLING`, `GRILLED`, `PLAN_READY`, `PLANNED`, …) -- [ ] Per idea kan de gebruiker een grill-job starten — agent stelt vragen via het claude-question-kanaal -- [ ] Per idea kan de gebruiker (na succesvol grillen) een make-plan-job starten — agent produceert strict yaml-frontmatter; parse-fail = `PLAN_FAILED` -- [ ] `PLAN_READY` toont de gegenereerde PBI/story/task-structuur als preview; bevestigen materialiseert ze in de productbacklog en zet de idea op `PLANNED` -- [ ] Idea's zijn user-private (geen `productAccessFilter`); secundaire producten via `idea_products` -- [ ] Demo-gebruiker kan idea's lezen maar niet schrijven of grillen +- [ ] Todo aanmaken via snel-invoerveld (Enter om op te slaan); product (dropdown) en titel verplicht +- [ ] Todo aanmaken ook mogelijk via REST API: `POST /api/todos` (body: `{ "title": string, "product_id": string }`) — zodat Claude Code bevindingen kan vastleggen binnen de actieve product backlog +- [ ] Todo-lijst is zichtbaar als apart scherm of persistent zijpaneel +- [ ] Todo afvinken markeert het als afgerond (visueel doorgestreept) +- [ ] Afgevinkte todo's blijven zichtbaar; kunnen worden gearchiveerd via "Archiveer afgeronde items" +- [ ] Todo promoveren naar PBI: dialoog pre-selecteert het gekoppelde product (bewerkbaar), vraagt prioriteit; todo verdwijnt na promotie +- [ ] Todo promoveren naar story: dialoog pre-selecteert het gekoppelde product (bewerkbaar), vraagt PBI en prioriteit; todo verdwijnt na promotie +- [ ] Titel van het todo-item is vooringevuld in de promotiedialoog (bewerkbaar) +- [ ] Promotie is niet ongedaan te maken; dialoog waarschuwt hiervoor **Randgevallen:** -- Idea heeft geen primair product → make-plan vraagt eerst om producttoekenning voordat materialisatie kan -- Grill-job time-out / agent crash → status valt terug naar `GRILL_FAILED`; gebruiker kan opnieuw grillen -- Plan-output past niet in het strict yaml-format → `PLAN_FAILED` + `IdeaLog{JOB_EVENT}` met de parse-error +- Geen producten aangemaakt → promotie-dialoog toont melding "Maak eerst een product aan" +- Promoveren naar story zonder PBI's in het product → dialoog toont melding "Maak eerst een PBI aan" **Data:** -- Opgeslagen: `ideas`, `idea_products`, `idea_logs`, `user_questions` — zie [data-model](../architecture/data-model.md) en het [M12 plan](../plans/M12-ideas.md). Het profiel voor de IdeaDialog staat in [docs/specs/dialogs/idea.md](./dialogs/idea.md). +- Opgeslagen: `todos` (id, user_id, product_id, title, done, archived, created_at, updated_at) --- @@ -357,13 +356,12 @@ Een idea is een gestructureerde voorganger van een PBI. De gebruiker maakt een i **Persona:** Lars, Dina, Remi **Omschrijving:** -Het Scrum Team kan meerdere Sprints per product aanmaken (PBI-63), elk met een eigen Sprint Goal en stabiele `code` (`SP-N`). Een sprint-switcher in de product-header schakelt tussen sprints. Stories worden via een gesplitst scherm vanuit de Product Backlog naar de Sprint Backlog gesleept. +Het Scrum Team kan een Sprint aanmaken met een Sprint Goal. Per product kan er één actieve Sprint zijn. Stories worden via een gesplitst scherm vanuit de Product Backlog naar de Sprint Backlog gesleept. **Acceptatiecriteria:** - [ ] Sprint aanmaken vereist een Sprint Goal (verplicht, max. 500 tekens) -- [ ] Sprint is gekoppeld aan een product en krijgt automatisch een `code` (`SP-1`, `SP-2`, …) sequentieel per product -- [ ] Een product mag tegelijk meerdere `OPEN`-sprints hebben; de sprint-switcher in de product-header bepaalt welke actief is in de UI -- [ ] Optionele `start_date` en `end_date` op een sprint (puur planningsmetadata) +- [ ] Sprint is gekoppeld aan een product +- [ ] Er kan maar één actieve Sprint per product tegelijk zijn - [ ] Sprint Backlog scherm is gesplitst: Sprint Backlog links, stories per PBI rechts - [ ] Rechterpaneel toont alle PBI's inklapbaar, met hun stories eronder - [ ] Stories die al in de Sprint zitten zijn visueel gemarkeerd en niet opnieuw sleepbaar @@ -371,14 +369,14 @@ Het Scrum Team kan meerdere Sprints per product aanmaken (PBI-63), elk met een e - [ ] Story in de Sprint Backlog is herrangschikbaar via drag-and-drop - [ ] Story uit Sprint verwijderen via contextmenu of verwijderknop → story keert terug in Product Backlog - [ ] Sprint Goal is bewerkbaar na aanmaken -- [ ] Sprint afronden zet status op `CLOSED` en past stories aan volgens de afsluit-keuze (DONE of terug naar OPEN, per story in afronden-dialoog) +- [ ] Sprint afronden zet alle stories op DONE of terug op OPEN (keuze per story in afronden-dialoog) **Randgevallen:** +- Gebruiker probeert tweede Sprint aan te maken terwijl er al een actieve Sprint is → foutmelding met link naar actieve Sprint - Story wordt uit Sprint verwijderd terwijl er taken aan hangen → taken blijven bestaan maar worden losgekoppeld van de Sprint -- Sprint wordt afgerond met openstaande stories → afsluit-dialoog dwingt een keuze per story; geen impliciete defaults **Data:** -- Opgeslagen: `sprints` (id, product_id, code, sprint_goal, status (`OPEN | CLOSED | ARCHIVED | FAILED`), start_date?, end_date?, created_at, completed_at?). Voor uitvoering door agents zie ook `sprint_runs` + `sprint_task_executions` in [data-model](../architecture/data-model.md). +- Opgeslagen: `sprints` (id, product_id, sprint_goal, status (ACTIVE | COMPLETED), created_at, completed_at?) --- @@ -397,16 +395,16 @@ In het Sprint Planning scherm worden stories uit de Sprint Backlog opgedeeld in - [ ] Omschrijving is optioneel (max. 1000 tekens) - [ ] Prioriteit is verplicht (1–4) - [ ] Taken zijn gerangschikt op prioriteit en volgorde; volgorde instelbaar via drag-and-drop (dnd-kit) -- [ ] Taakstatus is instelbaar via de UI: `TO_DO | IN_PROGRESS | REVIEW | DONE` (plus `FAILED` en `EXCLUDED` gezet door agent-flows; zie `lib/task-status.ts`) -- [ ] Story toont een voortgangsindicator (bijv. "2/5 taken Done"); auto-promotie van story naar DONE wanneer alle tasks DONE zijn +- [ ] Taakstatus is instelbaar via de UI: TO_DO | IN_PROGRESS | DONE +- [ ] Story toont een voortgangsindicator (bijv. "2/5 taken Done") - [ ] Taak verwijderen vereist bevestiging **Randgevallen:** - Story heeft geen taken → lege staat rechts met prompt om eerste taak aan te maken -- Alle taken van een story zijn Done → story promoot automatisch naar DONE in dezelfde transactie +- Alle taken van een story zijn Done → story-voortgang toont 100% maar story-status wijzigt niet automatisch **Data:** -- Opgeslagen: `tasks` (id, story_id, product_id, sprint_id?, code, title, description?, implementation_plan?, priority (1–4), sort_order, status (`TO_DO | IN_PROGRESS | REVIEW | DONE | FAILED | EXCLUDED`), verify_only, verify_required, repo_url?, created_at, updated_at) +- Opgeslagen: `tasks` (id, story_id, sprint_id, title, description, priority (1–4), sort_order, status (TO_DO | IN_PROGRESS | DONE), created_at, updated_at) --- @@ -421,16 +419,13 @@ Een REST API waarmee Claude Code stories en taken kan ophalen, de taakvolgorde k **Acceptatiecriteria:** **Endpoints:** -- [ ] `GET /api/health` — liveness, optioneel `?db=1` voor DB-ping (geen auth) - [ ] `GET /api/products` — lijst van actieve producten waarvoor de tokengebruiker eigenaar of teamlid is -- [ ] `GET /api/products/:id/next-story` — hoogst geprioriteerde open story van de actieve sprint -- [ ] `GET /api/products/:id/claude-context` — bundled context (product / sprint / story / tasks) voor MCP +- [ ] `GET /api/products/:id/next-story` — hoogst geprioriteerde open story van de actieve Sprint - [ ] `GET /api/sprints/:id/tasks?limit=10` — eerste N taken in huidige volgorde - [ ] `PATCH /api/stories/:id/tasks/reorder` — accepteert geordende lijst van taak-id's - [ ] `POST /api/stories/:id/log` — vastleggen van implementatieplan, testresultaat of commit -- [ ] `PATCH /api/tasks/:id` — status bijwerken (`todo → in_progress → review → done`) en/of `implementation_plan` opslaan -- [ ] `GET / POST /api/ideas` en `GET / PATCH /api/ideas/:id` — idea CRUD (vervangt voormalig `POST /api/todos`) -- [ ] `GET /api/jobs/:id/sub-tasks` — sprint-task-executions van een SPRINT_IMPLEMENTATION-job +- [ ] `PATCH /api/tasks/:id` — status bijwerken (TO_DO → IN_PROGRESS → DONE) en/of `implementation_plan` opslaan +- [ ] `POST /api/todos` — todo aanmaken vanuit Claude Code (body: `{ "title": string, "product_id": string }`) **Authenticatie:** - [ ] Alle endpoints vereisen `Authorization: Bearer <token>` header @@ -524,30 +519,6 @@ De app is deployable op Vercel + Neon PostgreSQL en lokaal draaibaar met een Neo --- -### F-14: Job-queue inzicht en beheer (`/jobs`) - -**Prioriteit:** v1 — Operationele controle -**Persona:** Lars - -**Omschrijving:** -De `/jobs`-pagina geeft een overzicht van alle `ClaudeJob`-records voor het actieve product. Vanuit de `JobDetailPane` kan de gebruiker een mislukte, geannuleerde of overgeslagen job opnieuw in de wachtrij zetten. - -**Acceptatiecriteria:** - -#### Mislukte job opnieuw starten - -- [ ] Een `ClaudeJob` in status `FAILED`, `CANCELLED` of `SKIPPED` toont een "Opnieuw starten"-knop in de `JobDetailPane`. -- [ ] De knop reset de bestaande job (geen nieuwe job aanmaken): `status → QUEUED`, `retry_count + 1`, alle run-velden gecleared. -- [ ] Bij `SPRINT_IMPLEMENTATION`-jobs worden alle bijbehorende `SprintTaskExecution`-rows in dezelfde transactie teruggezet naar `PENDING`. -- [ ] Tijdens de server-action is de knop disabled (loading-state). De UI updatet via SSE zonder handmatige refresh. -- [ ] Demo-sessies zien een `DemoTooltip` op de knop en kunnen niet restarten (drie-laagse policy: knop disabled + server action `session.isDemo`-check + HTTP 403). - -**Randgevallen:** -- Job is ondertussen al door een andere actie opnieuw gestart (race condition) → server-action controleert de huidige status vóór de update; als de status niet meer `FAILED/CANCELLED/SKIPPED` is, retourneert de action een foutmelding. -- Demo-token probeert via directe API-aanroep te restarten → 403 Forbidden. - ---- - ## Navigatiestructuur ``` @@ -558,104 +529,30 @@ De `/jobs`-pagina geeft een overzicht van alle `ClaudeJob`-records voor het acti /dashboard (productenlijst) /products/new (product aanmaken) /products/:id (Product Backlog — gesplitst scherm) -/products/:id/sprint (Sprint Backlog — gesplitst scherm; sprint-switcher in product-header) +/products/:id/sprint (Sprint Backlog — gesplitst scherm) /products/:id/sprint/planning (Sprint Planning — gesplitst scherm) -/solo (Solo board — Kanban per ingelogde gebruiker, top-level) -/ideas (Idea-laag, vervangt voormalige /todos) -/ideas/:id (Idea-detail met grill / make-plan) -/jobs (Job-queue inzicht) -/insights (Tokenkosten + run-statistieken) -/manual (In-app developer manual) +/todos (todo-lijst) /settings (profiel, account, product backlogs, rollen, API-tokens) /settings/tokens (API-tokenbeheer) - -# Mobile-shell (telefoon-UA) -/m/settings (account + product-selector + QR-instructie + logout) -/m/products/:id (Product Backlog — tab-mode op <1024px) -/m/products/:id/solo (Solo Paneel — 3-koloms-kanban met horizontal scroll) -/m/pair (QR-pairing bevestiging — verhuisd uit (app)/ naar (mobile)/) ``` --- -## Mobile shell - -**Prioriteit:** v1 — voor on-the-go gebruik (PBI-11) -**Persona:** Lars onderweg / tussendoor - -**Omschrijving:** -Telefoon-gebruikers (UA met `Mobi`-substring) krijgen een minimale mobile-shell met drie schermen onder `/m/*`. Tablets (iPad, Android-tablet zonder `Mobi`) en desktop blijven het bestaande `/dashboard`-pad volgen. De mobile-shell hergebruikt zoveel mogelijk content-componenten van de desktop-app (PbiList, StoryPanel, TaskPanel, SoloBoard, alle entity-dialogen) — er is geen aparte mobile-implementatie van de business-logica. - -**Architectuur in één regel:** eigen route group `app/(mobile)/` met eigen `layout.tsx` (zonder NavBar/StatusBar/MinWidthBanner) — een nested layout in `(app)/m/*` zou de NavBar erven. Auth via gedeelde `lib/auth-guard.ts` `requireSession()`. Zie [`docs/architecture/project-structure.md`](../architecture/project-structure.md) voor de volledige architectuur. - -**Acceptatiecriteria:** -- [ ] Phone-UA bij login → `/m/products/[active]/solo` (zonder actief product → `/m/settings`) -- [ ] Tablet-UA en desktop-UA blijven naar `/dashboard` -- [ ] `/m/*` rendert geen NavBar, AppIcon, MinWidthBanner of StatusBar — alleen tab-bar onderaan -- [ ] Portrait-modus toont rotate-overlay; landscape verbergt overlay -- [ ] PWA-manifest verzoekt `landscape`-orientatie (iOS Safari kan dit niet 100% afdwingen — CSS-overlay als fallback) -- [ ] Tab-bar onderaan: Backlog (ListTree), Solo (Activity), Settings — alleen iconen, geen labels, tap-target ≥44×44px -- [ ] Backlog op `<1024px` rendert in tab-mode (tabs: PBI's | Stories | Taken) met click-cascade auto-switch -- [ ] Entity-dialogen (PBI, Story, Task, Task-detail) renderen full-screen op `<640px` via gedeelde `entityDialogContentClasses` -- [ ] Solo-paneel behoudt 3-koloms-kanban met horizontal scroll (geen 1-koloms-mode) -- [ ] Settings: account-info read-only, product-selector activeert + redirect, QR-instructie naar desktop, logout met bevestiging -- [ ] `/m/pair` (QR-pairing-bevestiging) blijft werken — alleen filesystem-locatie verhuisd, URL onveranderd -- [ ] Demo-user op mobile: read-only werkt; logout staat toe - -**Bekende limiet:** iOS Safari respecteert `manifest.orientation` niet altijd in PWA-modus — de CSS-overlay (`<LandscapeGuard>`) is de feitelijke afdwinging. - -**Flow per scherm:** - -*Settings (`/m/settings`)* -1. Lars opent de app op zijn telefoon → wordt via UA-redirect naar `/m/settings` gestuurd (geen actief product) of keert terug via de tab-bar Settings-icoon. -2. Hij ziet zijn accountnaam en rol (read-only). Geen avatar-upload op mobiel in v1. -3. Via de product-selector activeert hij een product — app redirect naar `/m/products/[id]/solo`. -4. Onderaan staat de QR-pairing-instructie: "Scan een QR-code op de desktop om in te loggen zonder wachtwoord." Knop *"Inloggen op desktop via QR"* opent `/m/pair`. -5. Logout-knop met bevestigingsstap; na bevestiging → `/login`. - -*Backlog (`/m/products/:id`)* -1. Lars tikt op het Backlog-icoon in de tab-bar. -2. Scherm toont drie tabs bovenaan: **PBI's** | **Stories** | **Taken**. -3. In de PBI's-tab selecteert hij een PBI → app wisselt automatisch naar de Stories-tab met de bijbehorende stories. -4. In de Stories-tab selecteert hij een story → app wisselt automatisch naar de Taken-tab. -5. Tikken op een taak opent de TaskDetailDialog full-screen (`<640px` via `entityDialogContentClasses`). -6. Terugnavigatie via ← in de tab-header of via de tab-bar. - -*Solo (`/m/products/:id/solo`)* -1. Lars tikt op het Solo-icoon in de tab-bar. -2. Scherm toont het 3-koloms kanban-bord (TO_DO / IN_PROGRESS / DONE) met horizontal scroll — geen 1-koloms-mode. -3. Hij scrollt horizontaal om DONE-kolom te bereiken. -4. Tikken op een taakkaart opent de TaskDetailDialog full-screen. -5. Drag-and-drop tussen kolommen werkt via PointerSensor (touch-events); status persisteert met optimistische UI en rollback bij fout. -6. Knop bovenaan toont ongeclaimde stories; tik op "Pak op" claimt een story direct. - ---- - ## Datamodel (schets) -> Volledige tabeldefinities staan in [data-model](../architecture/data-model.md). Onderstaande tabel is een korte schets per entiteit. - | Entiteit | Sleutelvelden | Relaties / opmerkingen | |---|---|---| -| `users` | id, username, email?, password_hash, is_demo, must_reset_password, active_product_id?, idea_code_counter, min_quota_pct, bio?, bio_detail?, avatar_data?, created_at | Profielvelden optioneel; avatar als WebP bytea | -| `user_roles` | id, user_id, role (`PRODUCT_OWNER \| SCRUM_MASTER \| DEVELOPER \| ADMIN`) | Meervoudige rollen per gebruiker | -| `api_tokens` | id, user_id, token_hash, label, revoked_at | Max. 10 actief per gebruiker; gekoppeld aan max. 1 ClaudeWorker | -| `products` | id, user_id, name, code?, description, repo_url, definition_of_done, auto_pr, pr_strategy, archived | Hoogste niveau; eigenaar + members | -| `pbis` | id, product_id, code, title, description, priority (1–4), sort_order, status (`READY \| BLOCKED \| FAILED \| DONE`), pr_url?, pr_merged_at? | Geordend binnen prioriteitsgroep; auto-DONE bij sprint-close | -| `stories` | id, pbi_id, product_id, sprint_id?, assignee_id?, code, title, description, acceptance_criteria, priority, sort_order, status (`OPEN \| IN_SPRINT \| DONE \| FAILED`) | Auto-promotie als alle tasks DONE | -| `story_logs` | id, story_id, type (`IMPLEMENTATION_PLAN \| TEST_RESULT \| COMMIT`), content, status?, commit_hash?, commit_message?, metadata?, created_at | Aangemaakt via API; read-only in UI | -| `sprints` | id, product_id, code, sprint_goal, status (`OPEN \| CLOSED \| ARCHIVED \| FAILED`), start_date?, end_date?, created_at, completed_at? | Meerdere sprints per product (PBI-63) | -| `sprint_runs` | id, sprint_id, started_by_id, status (`QUEUED \| RUNNING \| PAUSED \| DONE \| FAILED \| CANCELLED`), pr_strategy, branch?, pr_url?, pause_context?, previous_run_id? | Eén run per uitvoering; chained retries | -| `tasks` | id, story_id, product_id, sprint_id?, code, title, description?, implementation_plan?, priority, sort_order, status (`TO_DO \| IN_PROGRESS \| REVIEW \| DONE \| FAILED \| EXCLUDED`), verify_only, verify_required, repo_url? | `code` blijft stabiel bij re-parenting | -| `claude_jobs` | id, user_id, product_id, task_id?, idea_id?, sprint_run_id?, kind, status, claimed_by_token_id?, model_id?, tokens, plan_snapshot?, base/head_sha?, branch?, pr_url?, summary?, error?, retry_count, lease_until? | Job-queue voor agents | -| `sprint_task_executions` | id, sprint_job_id, task_id, order, plan_snapshot, verify_required_snapshot, verify_only_snapshot, status (`PENDING \| RUNNING \| DONE \| FAILED \| SKIPPED`), verify_result?, verify_summary?, skip_reason? | Bevroren scope per SPRINT_IMPLEMENTATION-claim | -| `claude_workers` | id, user_id, token_id (unique), product_id?, started_at, last_seen_at, last_quota_pct?, last_quota_check_at? | Live-presence per actieve agent | -| `model_prices` | id, model_id (unique), input/output/cache_read/cache_write_price_per_1m, currency | Prijslookup voor jobs-pagina | -| `ideas` / `idea_products` / `idea_logs` / `user_questions` | zie data-model | Idea-laag (M12); vervangt voormalige `todos` | -| `claude_questions` | id, story_id?, task_id?, idea_id?, product_id, asked_by, question, options?, status, answer?, answered_by?, answered_at?, created_at, expires_at | Agent ↔ user vraag-kanaal (M11) | -| `login_pairings` | id, secret_hash, desktop_token_hash, status, user_id?, desktop_ua?, desktop_ip?, expires_at, approved_at?, consumed_at? | QR-pairing-flow (M10) | -| `push_subscriptions` | id, user_id, endpoint (unique), p256dh, auth, user_agent?, last_used_at | Web-push subscriptions | -| `product_members` | id, product_id, user_id, created_at | Many-to-many; alleen Developers; eigenaar via `products.user_id` | +| `users` | id, username, password_hash, is_demo, bio?, bio_detail?, avatar_data?, created_at | Profielvelden optioneel; avatar opgeslagen als WebP bytea | +| `user_roles` | id, user_id, role (enum) | Meervoudige rollen per gebruiker | +| `api_tokens` | id, user_id, token_hash, label, revoked_at | Max. 10 actief per gebruiker | +| `products` | id, user_id, name, description, repo_url, definition_of_done, archived | Hoogste niveau in de hiërarchie | +| `pbis` | id, product_id, title, description, priority (1–4), sort_order | Geordend binnen prioriteitsgroep | +| `stories` | id, pbi_id, product_id, title, description, acceptance_criteria, priority, sort_order, status, sprint_id? | Status: OPEN / IN_SPRINT / DONE | +| `story_logs` | id, story_id, type, content, status?, commit_hash?, commit_message?, created_at | Aangemaakt via API; read-only in UI | +| `sprints` | id, product_id, sprint_goal, status (ACTIVE / COMPLETED), created_at, completed_at? | Max. 1 actieve Sprint per product | +| `tasks` | id, story_id, sprint_id, title, description, implementation_plan?, priority, sort_order, status | Status: TO_DO / IN_PROGRESS / DONE; implementation_plan door MCP | +| `todos` | id, user_id, product_id, title, done, archived, created_at | Gekoppeld aan product backlog; verplicht in UI en API | +| `product_members` | id, product_id, user_id, created_at | Many-to-many; alleen Developers; eigenaar via products.user_id | --- @@ -716,18 +613,17 @@ Telefoon-gebruikers (UA met `Mobi`-substring) krijgen een minimale mobile-shell --- -### Flow 3: Idea grillen en materialiseren +### Flow 3: Todo promoveren naar story -**Startpunt:** Idea-lijst (`/ideas`) -1. Lars maakt een idea aan: "Voeg rate limiting toe aan de API" -2. Hij koppelt het aan product "Factuur-tool" en start een grill-job -3. Een agent claimt de `IDEA_GRILL`-job en stelt vragen via het claude-question-kanaal — Lars antwoordt in de UI -4. Agent eindigt met `update_idea_grill_md`; status → `GRILLED` -5. Lars start een make-plan-job; agent produceert strict yaml-frontmatter -6. Status → `PLAN_READY`; Lars bekijkt de preview (PBI + stories + tasks) -7. Bevestigen materialiseert de structuur in de Product Backlog; idea-status → `PLANNED` +**Startpunt:** Todo-lijst +1. Lars heeft een todo: "Voeg rate limiting toe aan de API" +2. Hij klikt op "Promoveren → Story" +3. Dialoog opent: product (vooringevuld met laatste product), PBI (dropdown), prioriteit +4. Hij kiest product "Factuur-tool", PBI "Beveiliging", prioriteit 2 +5. Bevestigen → todo verdwijnt, story is aangemaakt +6. Lars navigeert naar de Product Backlog → story staat in de juiste prioriteitsgroep -**Resultaat:** Een ruwe gedachte wordt via één gestructureerde dialoog een complete PBI-tak met stories en tasks — zonder handmatig opnieuw te tikken. +**Resultaat:** Losse gedachte is in drie stappen onderdeel van de formele Product Backlog. --- @@ -741,9 +637,9 @@ Een gebruiker kan één product als "actief" markeren. Dit actieve product wordt - **Producten** — altijd bereikbaar, toont alle producten van de gebruiker - **Product Backlog** — alleen klikbaar als er een actief product is -- **Sprint** — alleen klikbaar als er een actief product is én minimaal één sprint bestaat; sprint-switcher in de product-header bepaalt welke +- **Sprint** — alleen klikbaar als er een actief product is én een actieve sprint bestaat; anders tooltip "Geen actieve sprint" - **Solo** — alleen klikbaar als er een actief product is -- **Ideeën** — altijd bereikbaar (vervangt voormalig "Todo's") +- **Todo's** — altijd bereikbaar In het midden van de NavBar staat een dropdown met de naam van het actieve product. Via deze dropdown kan de gebruiker wisselen tussen producten of naar "Producten beheren" navigeren. @@ -966,7 +862,7 @@ export async function claimAllUnassignedInActiveSprintAction( const session = await requireProductWriter(productId) const activeSprint = await prisma.sprint.findFirst({ - where: { product_id: productId, status: 'OPEN' }, + where: { product_id: productId, status: 'ACTIVE' }, select: { id: true }, }) if (!activeSprint) throw new Error('Geen actieve sprint gevonden') @@ -1120,7 +1016,7 @@ export default async function SoloPage({ if (!product) notFound() const activeSprint = await prisma.sprint.findFirst({ - where: { product_id: id, status: 'OPEN' }, + where: { product_id: id, status: 'ACTIVE' }, select: { id: true, sprint_goal: true }, }) if (!activeSprint) return <NoActiveSprint product={product} /> @@ -1398,7 +1294,7 @@ Inhoud: ### 7h. `<NoActiveSprint>` — empty state -Geen OPEN sprint: nette empty-state met titel, korte uitleg en link naar productpagina om er een te starten (ST-302 stappen). +Geen ACTIVE sprint: nette empty-state met titel, korte uitleg en link naar productpagina om er een te starten (ST-302 stappen). --- @@ -1458,7 +1354,7 @@ Eenvoudig nu we weten dat `isDemo` in de sessiecookie zit: 3. **DndContext** — wrap kaarten zonder `useDraggable` als `isDemo`, of zet `disabled` op de hele context. **Seed-vereiste:** in `prisma/seed.ts` (ST-004) zorgen dat de demo-user (`is_demo = true`) een product heeft met: -- Een OPEN sprint +- Een ACTIVE sprint - Stories met `assignee_id = demoUser.id` en bijbehorende taken in alle drie statussen (om bord werkend te tonen) - Minstens 1 ongeclaimde story (om "Toon openstaande" te demonstreren — demo-user kan niet claimen, ziet wel hoe het werkt) @@ -1473,7 +1369,7 @@ Eenvoudig nu we weten dat `isDemo` in de sessiecookie zit: </NavLink> ``` -Plek: tussen "Producten" en "Ideeën" (of zoals layout het bepaalt). Altijd zichtbaar voor ingelogde users — geen product-context nodig, die kiest de redirect-handler zelf. +Plek: tussen "Producten" en "Todos" (of zoals layout het bepaalt). Altijd zichtbaar voor ingelogde users — geen product-context nodig, die kiest de redirect-handler zelf. --- diff --git a/docs/old/story-dialog.md b/docs/story-dialog.md similarity index 100% rename from docs/old/story-dialog.md rename to docs/story-dialog.md diff --git a/docs/old/task-dialog.md b/docs/task-dialog.md similarity index 100% rename from docs/old/task-dialog.md rename to docs/task-dialog.md diff --git a/eslint.config.mjs b/eslint.config.mjs index faac648..c2c6d35 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,7 +18,6 @@ const eslintConfig = defineConfig([ globalIgnores([ // Default ignores of eslint-config-next: ".next/**", - ".claude/**", "out/**", "build/**", "next-env.d.ts", diff --git a/hooks/use-jobs-realtime.ts b/hooks/use-jobs-realtime.ts deleted file mode 100644 index 8b2cd15..0000000 --- a/hooks/use-jobs-realtime.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { useEffect } from 'react' -import { useJobsStore } from '@/stores/jobs-store' -import type { ClaudeJobStatus } from '@prisma/client' - -interface JobStatusPayload { - job_id: string - kind?: string - status: string - task_id?: string | null - idea_id?: string | null - sprint_run_id?: string | null - branch?: string - pushed_at?: string - pr_url?: string - verify_result?: string - summary?: string - error?: string -} - -export default function useJobsRealtime() { - const upsertJob = useJobsStore(s => s.upsertJob) - - useEffect(() => { - let es: EventSource | null = null - let reconnectTimer: ReturnType<typeof setTimeout> | null = null - let active = true - const inFlight = new Set<string>() - - async function fetchAndUpsert(jobId: string) { - if (inFlight.has(jobId)) return - inFlight.add(jobId) - try { - const res = await fetch(`/api/jobs/${jobId}`) - if (!res.ok) return - const job = await res.json() - if (active) upsertJob(job) - } catch { - // netwerk-/parse-fout: stil - } finally { - inFlight.delete(jobId) - } - } - - function connect() { - if (!active) return - - es = new EventSource('/api/realtime/jobs') - - es.addEventListener('jobs_initial', (event) => { - // De server stuurt JobPayload[] (met `job_id`), niet JobWithRelations[]. - // Daarom geen initJobs-overwrite — de SSR-fetch heeft de volledige - // shape al in de store geplaatst. We reconcileren alleen status/branch - // van bekende jobs en fetchen onbekende jobs volledig via REST. - try { - const payload = JSON.parse(event.data) - if (!Array.isArray(payload)) return - const { activeJobs, doneJobs } = useJobsStore.getState() - for (const p of payload as JobStatusPayload[]) { - if (!p.job_id) continue - const known = activeJobs.some(j => j.id === p.job_id) || doneJobs.some(j => j.id === p.job_id) - if (!known) { - void fetchAndUpsert(p.job_id) - } else { - upsertJob({ - id: p.job_id, - status: p.status as ClaudeJobStatus, - branch: p.branch ?? null, - error: p.error ?? null, - summary: p.summary ?? null, - }) - } - } - } catch { - // malformed JSON - } - }) - - es.addEventListener('message', (event) => { - try { - const payload = JSON.parse(event.data) as JobStatusPayload - if (!payload.job_id) return - const { activeJobs, doneJobs } = useJobsStore.getState() - const known = activeJobs.some(j => j.id === payload.job_id) || doneJobs.some(j => j.id === payload.job_id) - if (!known) { - void fetchAndUpsert(payload.job_id) - return - } - upsertJob({ - id: payload.job_id, - status: payload.status as ClaudeJobStatus, - branch: payload.branch ?? null, - prUrl: payload.pr_url ?? null, - error: payload.error ?? null, - summary: payload.summary ?? null, - }) - } catch { - // malformed JSON - } - }) - - es.onerror = () => { - es?.close() - es = null - if (active) { - reconnectTimer = setTimeout(connect, 3000) - } - } - } - - connect() - - return () => { - active = false - if (reconnectTimer) clearTimeout(reconnectTimer) - es?.close() - } - }, [upsertJob]) -} diff --git a/instrumentation-client.ts b/instrumentation-client.ts deleted file mode 100644 index be93d1f..0000000 --- a/instrumentation-client.ts +++ /dev/null @@ -1,14 +0,0 @@ -// PBI/v1-readiness item 2: Sentry error-monitoring (client runtime). -// Geen Replay-integratie — overkill voor MVP, vereist eigen privacy-review. - -import * as Sentry from '@sentry/nextjs' - -Sentry.init({ - dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, - tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0, - sendDefaultPii: false, - enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN, - debug: false, -}) - -export const onRouterTransitionStart = Sentry.captureRouterTransitionStart diff --git a/instrumentation.ts b/instrumentation.ts deleted file mode 100644 index c870054..0000000 --- a/instrumentation.ts +++ /dev/null @@ -1,16 +0,0 @@ -// PBI/v1-readiness item 2: Next.js instrumentation hook — koppelt Sentry's -// server- en edge-configs aan de juiste runtime. Bestand moet in project-root -// staan voor Next.js 15+. - -import * as Sentry from '@sentry/nextjs' - -export async function register() { - if (process.env.NEXT_RUNTIME === 'nodejs') { - await import('./sentry.server.config') - } - if (process.env.NEXT_RUNTIME === 'edge') { - await import('./sentry.edge.config') - } -} - -export const onRequestError = Sentry.captureRequestError diff --git a/lib/active-sprint.ts b/lib/active-sprint.ts deleted file mode 100644 index a5e6033..0000000 --- a/lib/active-sprint.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { Prisma, SprintStatus } from '@prisma/client' -import { prisma } from '@/lib/prisma' -import { - mergeSettings, - parseUserSettings, - type UserSettings, -} from '@/lib/user-settings' - -export type ActiveSprint = { - id: string - code: string - status: SprintStatus -} - -async function readSettings(userId: string): Promise<UserSettings> { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { settings: true }, - }) - return parseUserSettings(user?.settings) -} - -async function writeSettings(userId: string, next: UserSettings): Promise<void> { - await prisma.user.update({ - where: { id: userId }, - data: { settings: next as unknown as Prisma.InputJsonValue }, - }) -} - -async function notifyUserSettings( - userId: string, - patch: Partial<UserSettings>, -): Promise<void> { - await prisma.$executeRaw` - SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ - kind: 'user_settings', - userId, - patch, - })}::text) - ` -} - -type StoredActiveSprintState = - | { kind: 'unset' } - | { kind: 'cleared' } - | { kind: 'set'; sprintId: string } - -export function readStoredActiveSprintState( - settings: UserSettings, - productId: string, -): StoredActiveSprintState { - const map = settings.layout?.activeSprints - if (!map || !(productId in map)) return { kind: 'unset' } - const value = map[productId] - if (value === null) return { kind: 'cleared' } - return { kind: 'set', sprintId: value } -} - -export async function setActiveSprintInSettings( - userId: string, - productId: string, - sprintId: string, -): Promise<void> { - const current = await readSettings(userId) - const patch: Partial<UserSettings> = { - layout: { - activeSprints: { - ...(current.layout?.activeSprints ?? {}), - [productId]: sprintId, - }, - }, - } - await writeSettings(userId, mergeSettings(current, patch)) - await notifyUserSettings(userId, patch) -} - -export async function clearActiveSprintInSettings( - userId: string, - productId: string, -): Promise<void> { - const current = await readSettings(userId) - const nextActiveSprints: Record<string, string | null> = { - ...(current.layout?.activeSprints ?? {}), - [productId]: null, - } - const next: UserSettings = { - ...current, - layout: { ...current.layout, activeSprints: nextActiveSprints }, - } - await writeSettings(userId, next) - await notifyUserSettings(userId, { - layout: { activeSprints: nextActiveSprints }, - }) -} - -/** - * PBI-79: persisteer sprint-keuze + bijbehorende PBI/story-selectie atomair. - * Sprintkeuze blijft 'sleutel met null = bewust geen sprint'-contract trouw; - * activePbi/activeStory volgen dezelfde semantiek (null = expliciet leeg). - */ -export async function setActiveSelectionInSettings( - userId: string, - productId: string, - selection: { - sprintId: string | null - pbiId?: string | null - storyId?: string | null - }, -): Promise<void> { - const current = await readSettings(userId) - const nextActiveSprints: Record<string, string | null> = { - ...(current.layout?.activeSprints ?? {}), - [productId]: selection.sprintId, - } - const nextActivePbis: Record<string, string | null> = { - ...(current.layout?.activePbis ?? {}), - } - if (selection.pbiId !== undefined) { - nextActivePbis[productId] = selection.pbiId - } - const nextActiveStories: Record<string, string | null> = { - ...(current.layout?.activeStories ?? {}), - } - if (selection.storyId !== undefined) { - nextActiveStories[productId] = selection.storyId - } - - const next: UserSettings = { - ...current, - layout: { - ...current.layout, - activeSprints: nextActiveSprints, - activePbis: nextActivePbis, - activeStories: nextActiveStories, - }, - } - await writeSettings(userId, next) - await notifyUserSettings(userId, { - layout: { - activeSprints: nextActiveSprints, - activePbis: nextActivePbis, - activeStories: nextActiveStories, - }, - }) -} - -export async function resolveActiveSprint( - productId: string, - userId: string, -): Promise<ActiveSprint | null> { - const settings = await readSettings(userId) - const state = readStoredActiveSprintState(settings, productId) - - if (state.kind === 'cleared') return null - - if (state.kind === 'set') { - const sprint = await prisma.sprint.findFirst({ - where: { id: state.sprintId, product_id: productId }, - select: { id: true, code: true, status: true }, - }) - if (sprint) return sprint - } - - const open = await prisma.sprint.findFirst({ - where: { product_id: productId, status: 'OPEN' }, - orderBy: { created_at: 'desc' }, - select: { id: true, code: true, status: true }, - }) - if (open) return open - - const closed = await prisma.sprint.findFirst({ - where: { product_id: productId, status: 'CLOSED' }, - orderBy: { created_at: 'desc' }, - select: { id: true, code: true, status: true }, - }) - return closed ?? null -} diff --git a/lib/auth-guard.ts b/lib/auth-guard.ts deleted file mode 100644 index b36b1af..0000000 --- a/lib/auth-guard.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { redirect } from 'next/navigation' -import { getSession } from '@/lib/auth' -import { isPairedSessionExpired } from '@/lib/auth/pairing' -import { prisma } from '@/lib/prisma' - -/** - * Layout-side auth guard. Returns the session when valid; otherwise redirects - * to /login (and destroys an expired paired-session first). - * - * Used by both `app/(app)/layout.tsx` (desktop) and `app/(mobile)/layout.tsx`. - */ -export async function requireSession() { - const session = await getSession() - - if (!session.userId) { - redirect('/login') - } - - if (isPairedSessionExpired(session)) { - await session.destroy() - redirect('/login') - } - - return session -} - -export async function requireAdmin() { - const session = await getSession() - if (!session.userId) { - redirect('/dashboard') - } - const adminRole = await prisma.userRole.findFirst({ - where: { user_id: session.userId, role: 'ADMIN' }, - }) - if (!adminRole) { - redirect('/dashboard') - } - return session -} diff --git a/lib/auth.ts b/lib/auth.ts index 027e9aa..52cede1 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -66,7 +66,3 @@ export async function verifyUser(username: string, password: string) { return user } - -export async function hashPassword(password: string): Promise<string> { - return bcrypt.hash(password, 12) -} diff --git a/lib/chart-colors.ts b/lib/chart-colors.ts index e7b2d9f..561d4dc 100644 --- a/lib/chart-colors.ts +++ b/lib/chart-colors.ts @@ -28,7 +28,6 @@ export const JOB_STATUS_COLORS = { done: 'var(--status-done)', failed: 'var(--priority-critical)', cancelled: 'var(--muted-foreground)', - skipped: 'var(--muted-foreground)', } as const export const SERIES_COLORS = [ diff --git a/lib/code-server.ts b/lib/code-server.ts index a74fc4b..5c09fcb 100644 --- a/lib/code-server.ts +++ b/lib/code-server.ts @@ -3,7 +3,7 @@ import { prisma } from '@/lib/prisma' const MAX_AUTO_CODE_ATTEMPTS = 3 -export function isCodeUniqueConflict(error: unknown): boolean { +function isCodeUniqueConflict(error: unknown): boolean { if (!(error instanceof Prisma.PrismaClientKnownRequestError)) return false if (error.code !== 'P2002') return false const target = (error.meta as { target?: string[] | string } | undefined)?.target @@ -40,8 +40,6 @@ export async function createWithCodeRetry<T>( const STORY_AUTO_RE = /^ST-(\d+)$/ const PBI_AUTO_RE = /^PBI-(\d+)$/ -const TASK_AUTO_RE = /^T-(\d+)$/ -const SPRINT_AUTO_RE = /^SP-(\d+)$/ function nextSequential(existing: (string | null)[], pattern: RegExp): number { let max = 0 @@ -73,22 +71,3 @@ export async function generateNextPbiCode(productId: string): Promise<string> { const next = nextSequential(pbis.map((p) => p.code), PBI_AUTO_RE) return `PBI-${next}` } - -export async function generateNextTaskCode(productId: string): Promise<string> { - const tasks = await prisma.task.findMany({ - where: { product_id: productId }, - select: { code: true }, - }) - const next = nextSequential(tasks.map((t) => t.code), TASK_AUTO_RE) - return `T-${next}` -} - -export async function generateNextSprintCode(productId: string): Promise<string> { - const sprints = await prisma.sprint.findMany({ - where: { product_id: productId }, - select: { code: true }, - }) - const next = nextSequential(sprints.map((s) => s.code), SPRINT_AUTO_RE) - return `SP-${next}` -} - diff --git a/lib/code.ts b/lib/code.ts index 5de2b23..1ce387a 100644 --- a/lib/code.ts +++ b/lib/code.ts @@ -1,12 +1,12 @@ // Pure helpers — safe to import from client components. // DB-backed helpers (generateNextStoryCode/PbiCode) live in lib/code-server.ts. -export const CODE_REGEX = /^[A-Za-z0-9._-]+$/ +const VALID_CODE_RE = /^[A-Za-z0-9._-]+$/ export const MAX_CODE_LENGTH = 30 export function isValidCode(code: string): boolean { - return code.length > 0 && code.length <= MAX_CODE_LENGTH && CODE_REGEX.test(code) + return code.length > 0 && code.length <= MAX_CODE_LENGTH && VALID_CODE_RE.test(code) } export function normalizeCode(input: string | null | undefined): string | null { @@ -15,12 +15,7 @@ export function normalizeCode(input: string | null | undefined): string | null { return trimmed === '' ? null : trimmed } -/** - * Extract the trailing numeric sequence from a code (e.g. "ST-007" → 7, "T-42" → 42). - * Non-conforming codes (no trailing digits, empty string) return Number.MAX_SAFE_INTEGER - * so they sort to the end. - */ -export function parseCodeNumber(code: string): number { - const m = code.match(/(\d+)$/) - return m ? Number.parseInt(m[1], 10) : Number.MAX_SAFE_INTEGER +export function deriveTaskCode(storyCode: string | null, indexOneBased: number): string | null { + if (!storyCode) return null + return `${storyCode}.${indexOneBased}` } diff --git a/lib/debug.ts b/lib/debug.ts deleted file mode 100644 index 1655eed..0000000 --- a/lib/debug.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type DebugProps = { - 'data-debug-id': string -} - -export function debugProps( - id: string, - _component?: string, - _file?: string -): DebugProps | Record<string, never> { - if (process.env.NODE_ENV === 'production') return {} - return { - 'data-debug-id': id, - } -} diff --git a/lib/env.ts b/lib/env.ts index f3efe8c..40d0676 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -9,20 +9,6 @@ const envSchema = z.object({ // /api/cron/expire-questions. In productie verplicht; lokaal dev mag missen // (de cron-route geeft 401 als de header niet matcht). CRON_SECRET: z.string().optional(), - // PBI-55 Web Push — alle vier .optional() zodat de app ook start zonder VAPID - NEXT_PUBLIC_VAPID_PUBLIC_KEY: z.string().optional(), - VAPID_PRIVATE_KEY: z.string().optional(), - VAPID_SUBJECT: z - .string() - .refine( - (v) => v.startsWith('mailto:') || z.string().email().safeParse(v).success, - { message: 'VAPID_SUBJECT must start with mailto: or be a valid email' } - ) - .optional(), - INTERNAL_PUSH_SECRET: z.string().min(32).optional(), - // PBI-66 — Anthropic API key voor scripts/sync-model-prices.ts. - // Niet nodig in app-runtime, alleen bij het wekelijkse sync-script. - ANTHROPIC_API_KEY: z.string().optional(), }) const parsed = envSchema.safeParse(process.env) diff --git a/lib/idea-code-server.ts b/lib/idea-code-server.ts deleted file mode 100644 index 819d4ed..0000000 --- a/lib/idea-code-server.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Atomic per-user idea-code generator (DB-side). -// Schema: User.idea_code_counter Int @default(0) — increment-and-return via -// Prisma `update` (which acquires a row-lock for the duration of the -// transaction; concurrent calls serialize). Format: "IDEA-001", "IDEA-002", … -// -// Concurrency: vertrouwt op Postgres row-locking binnen Prisma `update`. -// Geen aparte $transaction nodig voor enkelvoudige update — de update is -// atomisch op één rij. Voor combineren met een idea.create wordt -// nextIdeaCode aangeroepen binnen de bredere $transaction van de caller. -// -// Self-correcting: na de increment wordt de numerieke MAX van bestaande codes -// opgevraagd via raw SQL (geen string-vergelijking — die faalt boven IDEA-999). -// Als de counter achterloopt (bijv. na directe DB-inserts tijdens development) -// wordt nextN = MAX+1 gebruikt en de counter direct bijgewerkt. - -import { prisma } from '@/lib/prisma' -import { formatIdeaCode } from '@/lib/idea-code' - -import type { Prisma } from '@prisma/client' - -export async function nextIdeaCode( - userId: string, - client: Prisma.TransactionClient | typeof prisma = prisma, -): Promise<string> { - // Increment counter — acquires Postgres row lock, serializes concurrent calls. - const u = await client.user.update({ - where: { id: userId }, - data: { idea_code_counter: { increment: 1 } }, - select: { idea_code_counter: true }, - }) - - // Numeric MAX guards against counter drift (e.g. ideas inserted directly in - // DB without updating the counter). String MAX mis-sorts "IDEA-1000" < - // "IDEA-999", so we cast to INTEGER in SQL. - const rows = await (client as Prisma.TransactionClient).$queryRaw< - [{ max_n: number | bigint | null }] - >` - SELECT MAX(CAST(SUBSTRING(code FROM 6) AS INTEGER)) AS max_n - FROM ideas - WHERE user_id = ${userId} - ` - const maxExisting = rows[0].max_n !== null ? Number(rows[0].max_n) : 0 - const nextN = Math.max(u.idea_code_counter, maxExisting + 1) - - // Re-sync counter forward if it was behind the actual max. - if (nextN !== u.idea_code_counter) { - await client.user.update({ - where: { id: userId }, - data: { idea_code_counter: nextN }, - select: { id: true }, - }) - } - - return formatIdeaCode(nextN) -} diff --git a/lib/idea-code.ts b/lib/idea-code.ts deleted file mode 100644 index dfb1536..0000000 --- a/lib/idea-code.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Pure helpers voor IDEA-codes. Geen DB-imports — daarom client-safe. -// De DB-mutating nextIdeaCode staat in lib/idea-code-server.ts. - -const PAD = 3 // "IDEA-001". Bumps to 4 digits at counter 1000 organically. - -export function formatIdeaCode(n: number): string { - return `IDEA-${String(n).padStart(PAD, '0')}` -} diff --git a/lib/idea-dto.ts b/lib/idea-dto.ts deleted file mode 100644 index 3b6557c..0000000 --- a/lib/idea-dto.ts +++ /dev/null @@ -1,52 +0,0 @@ -// API-projection voor Idea — converteert Prisma-row naar het externe contract. -// Belangrijk: status wordt naar lowercase API-string vertaald (zelfde patroon -// als TaskStatus / StoryStatus / PbiStatus elders in de codebase). - -import { ideaStatusToApi } from '@/lib/idea-status' - -import type { Idea, IdeaStatus, Product } from '@prisma/client' - -type IdeaWithProduct = Idea & { - product: Pick<Product, 'id' | 'name' | 'repo_url'> | null - pbi?: { id: string; code: string; title: string } | null - secondary_products?: { id: string; product_id: string; product: { id: string; name: string } }[] -} - -export interface IdeaDto { - id: string - code: string - title: string - description: string | null - status: ReturnType<typeof ideaStatusToApi> - product_id: string | null - product: { id: string; name: string; repo_url: string | null } | null - pbi_id: string | null - pbi?: { id: string; code: string; title: string } | null - secondary_products: { id: string; product_id: string; product: { id: string; name: string } }[] - archived: boolean - has_grill_md: boolean - has_plan_md: boolean - created_at: string - updated_at: string -} - -export function ideaToDto(idea: IdeaWithProduct & { status: IdeaStatus }): IdeaDto { - return { - id: idea.id, - code: idea.code, - title: idea.title, - description: idea.description, - status: ideaStatusToApi(idea.status), - product_id: idea.product_id, - product: idea.product, - pbi_id: idea.pbi_id, - pbi: idea.pbi ?? null, - secondary_products: idea.secondary_products ?? [], - archived: idea.archived, - // Geen md-content in lijst-payloads (kan groot zijn) — enkel een vlag. - has_grill_md: idea.grill_md !== null, - has_plan_md: idea.plan_md !== null, - created_at: idea.created_at.toISOString(), - updated_at: idea.updated_at.toISOString(), - } -} diff --git a/lib/idea-plan-parser.ts b/lib/idea-plan-parser.ts deleted file mode 100644 index b8202b9..0000000 --- a/lib/idea-plan-parser.ts +++ /dev/null @@ -1,82 +0,0 @@ -// Parser voor de plan_md die make-plan-job produceert. -// Format: yaml-frontmatter (structuur, parseerbaar) + markdown-body (vrije -// reasoning). Frontmatter wordt gevalideerd via ideaPlanMdFrontmatterSchema. -// -// Wordt zowel door de server-action materializeIdeaPlanAction als door de -// MCP-tool update_idea_plan_md gebruikt. Synchroon — geen LLM-call. -// -// Zie docs/plans/M12-ideas.md "Plan-md formaat A" voor het format-voorbeeld. - -import { parse as parseYaml, YAMLParseError } from 'yaml' -import { - ideaPlanMdFrontmatterSchema, - type IdeaPlanFrontmatter, -} from '@/lib/schemas/idea' - -export type PlanParseError = { line?: number; message: string; hint?: string } - -export type PlanParseResult = - | { ok: true; plan: IdeaPlanFrontmatter; body: string } - | { ok: false; errors: PlanParseError[] } - -const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/ - -export function parsePlanMd(md: string): PlanParseResult { - const match = md.match(FRONTMATTER_RE) - if (!match) { - return { - ok: false, - errors: [ - { - line: 1, - message: - 'Plan ontbreekt yaml-frontmatter. Verwacht eerste regel: ---', - }, - ], - } - } - - const [, frontmatterRaw, body] = match - - let parsed: unknown - try { - parsed = parseYaml(frontmatterRaw) - } catch (err) { - if (err instanceof YAMLParseError) { - const yamlLine = err.linePos?.[0]?.line - const fileLine = yamlLine != null ? yamlLine + 1 : undefined - const offendingLine = - yamlLine != null - ? frontmatterRaw.split(/\r?\n/)[(yamlLine ?? 1) - 1] - : undefined - const isMarkdown = - offendingLine != null && - (/^\s*\d+\.\s+\*\*/.test(offendingLine) || - /^\s*[-*]\s+\*\*/.test(offendingLine) || - /^\s*\d+\..*:/.test(offendingLine)) - const hint = isMarkdown - ? 'Lijkt op markdown-content (genummerde of opsommingslijst) binnen YAML-frontmatter. Verplaats deze regels naar na de afsluitende `---`, of zet ze in een `description: |` blok.' - : undefined - return { - ok: false, - errors: [{ line: fileLine, message: err.message, hint }], - } - } - return { - ok: false, - errors: [{ message: err instanceof Error ? err.message : String(err) }], - } - } - - const validation = ideaPlanMdFrontmatterSchema.safeParse(parsed) - if (!validation.success) { - return { - ok: false, - errors: validation.error.issues.map((iss) => ({ - message: `${iss.path.join('.') || '<root>'}: ${iss.message}`, - })), - } - } - - return { ok: true, plan: validation.data, body: body.trimStart() } -} diff --git a/lib/idea-prompts/grill.md b/lib/idea-prompts/grill.md deleted file mode 100644 index d5af711..0000000 --- a/lib/idea-prompts/grill.md +++ /dev/null @@ -1,98 +0,0 @@ -# Grill-prompt voor IDEA_GRILL-jobs - -> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een -> `IDEA_GRILL`-job en gevolgd door de Claude-CLI-worker. Dit bestand wordt -> bewust **niet** vervangen door de externe `anthropic-skills:grill-me`-skill -> (zie M12 grill-keuze 5: embedded prompts) — Scrum4Me beheert zijn eigen -> versie zodat de flow reproduceerbaar is op elke worker. - ---- - -Je bent een **grill-agent** voor Scrum4Me-idee `{idea_code}` (titel: -`{idea_title}`). - -Je context (meegegeven in `wait_for_job`-payload): - -- `idea`: het volledige idee-record incl. eventueel bestaande `grill_md` -- `product`: het gekoppelde product (incl. `repo_url` en `definition_of_done`) -- `repo_url`: lokale repo om te lezen (worker bevindt zich daar al) - -## Doel - -Het idee zó concretiseren dat de **make-plan**-fase er een implementeerbaar -PBI van kan maken. Eindresultaat is een markdown-document dat je via -`mcp__scrum4me__update_idea_grill_md` opslaat. - -## Werkwijze (loop, één vraag per cyclus) - -1. Lees de huidige `idea.title`, `idea.description`, en (indien aanwezig) - `idea.grill_md` — bij re-grill bouw je voort op wat er al staat, je gooit - het niet weg. -2. Verken de repo voor context: `README`, `docs/`, `package.json`, en relevante - source-bestanden. Gebruik `Read`/`Grep`/`Glob` zoals normaal. -3. Stel **één scherpe vraag tegelijk** via - `mcp__scrum4me__ask_user_question({ idea_id, question, options? })`. Wacht - op het antwoord (`mcp__scrum4me__get_question_answer` of `wait_seconds`). -4. Verwerk het antwoord: log belangrijke beslissingen via - `mcp__scrum4me__log_idea_decision({ idea_id, type: 'DECISION'|'NOTE', - content })`. -5. Herhaal tot je voldoende hebt voor een PBI (zie stop-conditie). -6. Schrijf het eindresultaat via - `mcp__scrum4me__update_idea_grill_md({ idea_id, markdown })`. -7. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`. - -## Stop-conditie - -Je hebt genoeg wanneer je markdown bevat: - -- **Titel + scope** (1–3 zinnen) -- **Minimaal 3 acceptatiepunten** (gedrag dat zichtbaar moet werken) -- **Minimaal 1 risico/onbekende** (technisch, scope, afhankelijkheden) -- **Open eindjes** (wat opzettelijk **niet** in v1 zit) - -Stop óók als de gebruiker expliciet zegt "klaar" / "genoeg" / "ga door". - -## Output-format (strikt) - -```markdown -# Idee — {korte titel} - -## Scope -… - -## Acceptatie -- AC 1 -- AC 2 -- AC 3 - -## Risico's & onbekenden -- Risico 1 -- Onbekende 2 - -## Open eindjes (niet in v1) -- … -``` - -## Vraag-richtlijnen - -- **Scherp & specifiek**, geen open "wat denk je ervan?". -- Bij twijfel: bied **multi-choice** via `options: ["A", "B", "C"]`. -- Stel **één vraag per cyclus** — niet meerdere geneste. -- Vermijd vragen waarvan het antwoord uit de repo te lezen is — lees zelf. -- Geen meta-vragen ("zal ik nog meer vragen?"). Beslis zelf wanneer je stopt. - -## Foutgevallen - -- Vraag verloopt (24h): roep `update_job_status('failed', error: 'question expired')`. -- Repo niet leesbaar: roep `update_job_status('failed', error: 'repo access')`. -- Gebruiker annuleert via UI: job wordt door server op CANCELLED gezet; je krijgt geen verdere antwoorden — sluit netjes af. - -## Voorbeeld-vraag - -``` -ask_user_question({ - idea_id, - question: "Moet 'Plant-watering reminder' alleen lokale notifications doen, of ook web-push?", - options: ["Alleen lokaal (eenvoud)", "Web-push (multi-device)", "Beide"], -}) -``` diff --git a/lib/idea-prompts/make-plan.md b/lib/idea-prompts/make-plan.md deleted file mode 100644 index a53b8c9..0000000 --- a/lib/idea-prompts/make-plan.md +++ /dev/null @@ -1,223 +0,0 @@ -# Make-Plan-prompt voor IDEA_MAKE_PLAN-jobs - -> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een -> `IDEA_MAKE_PLAN`-job. Single-pass, **stel geen vragen** (zie M12 grill-keuze -> 8). Twijfels → terug naar grill via UI. - ---- - -Je bent een **planning-agent** voor Scrum4Me-idee `{idea_code}`. - -Je context (meegegeven in `wait_for_job`-payload): - -- `idea.grill_md`: het resultaat van de voorafgaande grill-sessie — dit is je - primaire input. -- `idea.plan_md`: bij re-plan bevat dit het vorige plan; gebruik als - referentie. -- `product`: gekoppeld product met `repo_url`, `definition_of_done`, - bestaande architectuur in repo. - -## Doel - -Eén `plan_md` produceren die je via `mcp__scrum4me__update_idea_plan_md` -opslaat. Dit document wordt later **deterministisch** geparseerd door de -server-side `parsePlanMd` (zie `lib/idea-plan-parser.ts`) en omgezet in -PBI + stories + taken via `materializeIdeaPlanAction`. - -## Werkwijze (single-pass) - -1. Lees `idea.grill_md` volledig. -2. Verken de repo voor patronen, bestaande modules, en `docs/`-structuur. -3. **Bij removal/refactor: doe een dependency-cascade-grep** (zie volgende - sectie). Voeg per geraakte file een taak toe vóór de schema/code-edit zelf. -4. Bouw het plan op in de **strikte format** hieronder. -5. Roep `mcp__scrum4me__update_idea_plan_md({ idea_id, markdown })`. -6. Roep `mcp__scrum4me__update_job_status({ job_id, status: 'done', summary })`. - -## Dependency-cascade-grep (verplicht bij removal/refactor) - -Wanneer het idee een **bestaand symbool, model, route of component -verwijdert of hernoemt**, MOET je éérst de consumers in kaart brengen voordat -je het plan vaststelt. Anders breekt `next build` op type-errors die `lint` -en `vitest run` niet pakken (zie hieronder waarom). - -**Concreet:** - -- Verwijder je een Prisma-model `Foo`? - ```bash - grep -rn "prisma\.foo\b\|prisma\.foos\b" actions/ app/ components/ lib/ \ - --include="*.ts" --include="*.tsx" - ``` - Voeg per geraakt bestand één of meer taken toe ("schoon `actions/foos.ts` - op", "verwijder `app/(app)/foos/`-route", "haal Foo-tegel uit - `app/page.tsx`-feature-grid", etc.) **vóór** de schema-edit-taak. - -- Verwijder je een component / utility / type? Idem: grep op de - bestandspaden en exports en plan per consumer een taak. - -- Hernoem je een model/route/component? Plan per geraakt bestand een edit-taak. - -- Wijzig je een `prisma.x.create`-veld (verplicht ↔ optioneel)? Grep op - `prisma.x.create` en `prisma.x.update` voor type-mismatches. - -- Voeg óók een **eind-taak** toe: `npm run typecheck` (= `tsc --noEmit`) - als sanity-check, los van `lint && test && build`. Type-errors verschijnen - daar het eerst en zijn 10× sneller dan een full `next build`. - -**Waarom zo strikt?** `eslint` doet geen diepe type-check. `vitest` met -esbuild-transpile slaat type-errors over. `next build` is de eerste step die -álles type-checkt — en die zit aan het einde van de pijp. Een gemist -consumer-bestand wordt pas zichtbaar bij verify, niet bij implementation. - -## STEL GEEN VRAGEN - -`mcp__scrum4me__ask_user_question` is in deze fase **verboden**. Als je -informatie mist die je nodig hebt om het plan compleet te maken, schrijf je -plan met je beste aanname en documenteer je in de **Body** (zie hieronder) -welke aannames je hebt gemaakt. De gebruiker beoordeelt het plan in `PLAN_READY` -en kan dan handmatig editen of een re-grill triggeren. - -## Output-format (strikt — frontmatter wordt server-side geparseerd) - -````markdown ---- -pbi: - title: "Korte PBI-titel (≤200 chars)" - description: | - 1-3 zinnen die de PBI samenvatten. - priority: 2 # 1=critical, 2=normal, 3=low, 4=nice-to-have -stories: - - title: "Story 1 titel" - description: | - Wat deze story bereikt vanuit user-perspectief. - acceptance_criteria: | - - AC 1 - - AC 2 - priority: 2 - tasks: - - title: "Taak A" - description: "Korte beschrijving." - implementation_plan: | - 1. Bestand X aanpassen — concrete steps - 2. Test toevoegen Y - 3. Verifieer Z - # task.priority is optioneel en wordt door materialize GENEGEERD. - # Tasks erven story.priority; sort_order wordt afgeleid van de auto-code. - verify_required: ALIGNED_OR_PARTIAL # ALIGNED | ALIGNED_OR_PARTIAL | ANY - verify_only: false # true voor pure verify-passes - - title: "Taak B" - priority: 2 - implementation_plan: | - ... - - title: "Story 2 titel" - priority: 2 - tasks: - - title: "..." - priority: 2 ---- - -# Overwegingen - -(Vrije body — niet geparsed door materialize, wordt opgeslagen in -IdeaLog{PLAN_RESULT}.metadata.body voor latere referentie.) - -Beschrijf: -- Waarom deze opdeling in stories/taken -- Welke aannames je hebt gemaakt (indien grill onvolledig was) -- Architectuur-keuzes & verwijzingen naar bestaande modules in repo - -# Alternatieven - -- Optie X (verworpen omdat …) -- Optie Y (overwogen voor v2 …) - -# Beslissingen - -- ... - -# Aannames (indien van toepassing) - -- ... -```` - -## Validatie-regels die de parser afdwingt - -- `pbi.title`: 1–200 chars, **verplicht**. -- `pbi.priority`, `story.priority`: integer 1–4, **verplicht**. -- `task.priority`: integer 1–4, **optioneel**. **Wordt door materialize genegeerd** - ten faveure van story-priority — alle tasks binnen een story erven dezelfde - priority. `priority` is een **label** (urgentie), géén sorteerkriteria voor stories - of taken. De **YAML-array-volgorde** is de execution-volgorde: de server berekent - `sort_order = parseCodeNumber(auto-code)` op basis van aanroep-volgorde. -- Minimaal 1 story; per story minimaal 1 taak. -- `implementation_plan`: max 8000 chars. -- `verify_required`: enum exact `ALIGNED` | `ALIGNED_OR_PARTIAL` | `ANY`. -- Alle string-velden trimmen, geen lege strings. - -Een parse-fout zet het idee op `PLAN_FAILED`. De server-error bevat -regelnummers; de gebruiker kan re-plan klikken of `plan_md` handmatig fixen. - -## Schaal-richtlijnen (geen harde limieten) - -- 1 PBI per idee. -- 2–6 stories per PBI (te veel = te grote PBI; splits dan in idee-niveau). -- 2–5 taken per story. -- Eén taak ≈ 30 min – paar uur werk; **`implementation_plan` is concreet** - (bestandsnamen, commando's, regels code), niet abstract. - -## Voorbeelden van goede vs slechte taken - -❌ **Slecht**: "Maak de feature werkend" -✅ **Goed**: "Voeg `actions/ideas.ts:createIdeaAction(input)` toe — auth + -demo-403 + zod-parse + nextIdeaCode + prisma.idea.create + revalidatePath" - -## Bestandspaden in `implementation_plan` — verplicht format - -`verify_task_against_plan` extraheert bestandspaden uit `implementation_plan` -om de git-diff tegen het plan te valideren. Zonder herkenbare paden valt de -verifier terug op een line-count-heuristiek (`> 50 regels → DIVERGENT`), -waardoor zelfs correct uitgevoerde tasks `cancelled_by_self` kunnen worden -(verify-gate weigert `DONE` te zetten). - -De path-extractor herkent twee formats: - -1. **Backticks** (aanbevolen voor inline-paden binnen een zin of stap): - `lib/foo.ts`, `app/(app)/products/[id]/page.tsx`, `docs/adr/0009-foo.md` - -2. **Bullet-lijst** (aanbevolen voor doel-bestanden vóór de stappen): - - `lib/foo.ts` - - `app/(app)/products/[id]/page.tsx` - -❌ **Werkt NIET** (paden inline in genummerde tekst zonder markup): - -``` -1. Maak docs/adr/0009-foo.md aan op basis van templates/nygard.md -2. Update docs/INDEX.md via npm run docs -``` - -→ Path-extractor herkent geen pad → `planPaths.length=0` → bij diff >50 regels -→ verifier returnt **DIVERGENT** → task met `verify_required=ALIGNED` faalt. - -✅ **Werkt WEL** (zelfde stappen, paden in backticks): - -``` -1. Maak `docs/adr/0009-foo.md` aan op basis van `docs/adr/templates/nygard.md` -2. Update `docs/INDEX.md` via `npm run docs` -``` - -→ Path-extractor vindt 3 paden → diff bevat dezelfde 3 paden → coverage 100% -→ verifier returnt **ALIGNED** → task gaat naar DONE. - -**Regel**: noem **elk** bestand dat de task aanmaakt, bewerkt of regenereert -in backticks of als bullet. Vermeld ook `docs/INDEX.md` als die regenereerd -wordt door `npm run docs`, en `README.md`-achtige updates. - -**Verband met `verify_required`**: -- `ALIGNED`: alle plan-paden moeten in de diff zitten + ratio diff/plan <3 -- `ALIGNED_OR_PARTIAL`: PARTIAL toegestaan mits worker summary ≥20 chars geeft -- `ANY`: geen verifier-gate - -Default = `ALIGNED_OR_PARTIAL` (schema). Kies `ALIGNED` alleen wanneer -plan-paden 1-op-1 matchen met te-wijzigen-bestanden en de diff klein is -(<50 regels typisch). Voor ADR-stubs, schema-migraties of multi-file refactors: -laat `ALIGNED_OR_PARTIAL` staan. diff --git a/lib/idea-prompts/review-plan-job.md b/lib/idea-prompts/review-plan-job.md deleted file mode 100644 index 8df45f6..0000000 --- a/lib/idea-prompts/review-plan-job.md +++ /dev/null @@ -1,210 +0,0 @@ -# Review-Plan-prompt voor IDEA_REVIEW_PLAN-jobs - -> Deze prompt wordt door `wait_for_job` meegestuurd in de payload van een -> `IDEA_REVIEW_PLAN`-job. Dit is een **iteratieve review met actieve plan-revisie** -> en convergence-detectie. Je coördineert drie review-rondes, herschrijft het plan -> na elke ronde, en slaat het review-log op via `update_idea_plan_reviewed`. - ---- - -Je bent een **plan-review-orchestrator** voor Scrum4Me-idee `{idea_code}`. - -Je context (meegegeven in `wait_for_job`-payload): - -- `idea.plan_md`: het te reviewen plan-document (YAML frontmatter + body) -- `idea.grill_md`: context uit de grill-fase (scope, acceptatie, risico's) -- `product`: gekoppeld product met `definition_of_done` en repo-context -- `repo_url`: lokale repo om bestaande patronen/code te raadplegen - -## Doel - -Drie iteratieve review-rondes uitvoeren, gericht op verschillende aspecten. Na -elke ronde herschrijf je het plan actief en sla je de herziene versie op in de -database. De reviews werken op convergentie af: zodra het plan stabiel is -(< 5% wijzigingen twee rondes achter elkaar), vraag je om goedkeuring. - -**Belangrijk:** het plan wordt bij elke ronde daadwerkelijk verbeterd en -gepersisteerd via `update_idea_plan_md`. Dit is geen passieve review — je -coördineert een actief verbeterproces. - -## Werkwijze - -### Setup (voor ronde 1) - -1. Lees `idea.plan_md` volledig — dit is de startversie van het plan. -2. Lees `idea.grill_md` voor scope/acceptatiecriteria-context. -3. **Laad codex** (verplicht, niet optioneel): - - Glob + Read alle `docs/patterns/**/*.md` → architectuurpatronen - - Glob + Read alle `docs/architecture/**/*.md` → systeemdesign - - Read `CLAUDE.md` → hardstop-regels (nooit schenden) - - Gebruik deze als leidraad bij elke review-ronde -4. Initialiseer `review_log`: - ```json - { "plan_file": "{idea_code}", "created_at": "<now>", - "rounds": [], "approval": { "status": "pending" } } - ``` - -### Per Review-Ronde - -**Ronde 1 — Structuur & Syntax (Haiku-perspectief: snel en scherp)** -- Rol: structuur-reviewer — focus op correctheid, niet op inhoud -- Controleer: YAML parseable, alle verplichte velden aanwezig, geen lege strings, - priority-waarden valid (1–4), markdown-structuur intact -- Herschrijf plan_md: corrigeer structuurfouten en formatting -- *Opmerking multi-model:* directe Haiku API-call is momenteel niet beschikbaar - via job-config; voer deze rol zelf uit met een compacte, syntax-gerichte blik - -**Ronde 2 — Logica & Patronen (Sonnet-perspectief: diep en patroon-bewust)** -- Rol: architectuur-reviewer — focus op logica, volledigheid en patroonconformiteit -- Controleer: stories volgen uit grill-criteria, tasks zijn concreet - (bestandsnamen, commando's), patterns uit `docs/patterns/` worden gevolgd, - `verify_required` coherent, dependency-cascades geadresseerd -- Herschrijf plan_md: vul gaten aan, maak tasks specifieker, voeg missende stappen toe - -**Ronde 3 — Risico & Edge Cases (Opus-perspectief: kritisch en breed)** -- Rol: risico-reviewer — focus op wat mis kan gaan -- Controleer: grote taken gesplitst, refactors hebben undo-strategie, - schema-changes hebben migratie-taken, type-checking expliciet, concurrency - geadresseerd, error-handling per actie, feature-flags voor grote changes -- Herschrijf plan_md: voeg risico-mitigatie toe, split te grote taken - -### Plan Revision (na elke ronde — verplicht) - -Na het uitvoeren van de review-criteria: - -1. Sla de huidige versie op als `plan_before` in `review_log.rounds[N]`. -2. Herschrijf `plan_md` — integreer de gevonden verbeteringen. -3. Bereken `diff_pct = changed_lines / total_lines * 100`. -4. Sla de herziene versie op als `plan_after` in `review_log.rounds[N]`. -5. **Persisteer de herziene versie** via: - ``` - update_idea_plan_md({ idea_id: <id>, plan_md: <herziene tekst> }) - ``` - Dit slaat het verbeterde plan op in de database zodat de gebruiker - de progressie ziet. Sla dit stap niet over — ook al zijn er weinig - wijzigingen. - -### Convergence Detection - -Na elke ronde (m.u.v. ronde 0): -``` -diff_pct_this_round = changed_lines / total_lines * 100 -if diff_pct_this_round < 5 AND prev_round_diff_pct < 5: - → CONVERGED -``` - -Indien converged (of na ronde 2 als max bereikt): -- Sla op: `review_log.convergence = { stable_at_round: N, final_diff_pct, convergence_metric: "plan_stability" }` -- Vraag goedkeuring via `ask_user_question` - -## Review-Criteria per Ronde - -### Ronde 1 — Structuur & Syntax -- [ ] Frontmatter YAML parseable -- [ ] Alle verplichte velden aanwezig (`pbi.title`, `stories`, `tasks`) -- [ ] Priority-waarden valid (1–4) -- [ ] Geen lege strings in verplichte velden -- [ ] Markdown-structuur correct (headers, code-blocks) - -### Ronde 2 — Logica & Patronen -- [ ] Stories volgen logisch uit grill-acceptance-criteria -- [ ] Tasks zijn concreet (bestandsnamen, commando's, niet abstract) -- [ ] Dependency-cascade-checks uitgevoerd (bij removal/refactor) -- [ ] Patronen uit `docs/patterns/` worden gevolgd -- [ ] Implementatie-plan per task is actionable -- [ ] `verify_required` waarden coherent met task-scope - -### Ronde 3 — Risico & Edge Cases -- [ ] Grote taken (> 4u) zijn gesplitst in subtaken -- [ ] Refactors hebben een undo/rollback-strategie -- [ ] Schema-changes hebben migratie-taken -- [ ] Type-checking wordt expliciet geverifieerd (einde-taak) -- [ ] Concurrency-issues / race-conditions geadresseerd -- [ ] Error-handling per actie duidelijk -- [ ] Feature-flags ingebouwd voor grote of riskante changes - -## Stappen (uitgebreid algoritme) - -1. **Init** - - Lees plan_md + grill_md. - - Laad codex (docs/patterns, docs/architecture, CLAUDE.md). - - Initialiseer `review_log`. - -2. **Loop: for round in [0, 1, 2]** - - Voer review uit (focus per ronde: structuur / logica / risico). - - Sla `plan_before` op. - - Herschrijf plan_md op basis van bevindingen. - - Roep `update_idea_plan_md` aan met de herziene tekst. - - Sla `plan_after` + `issues` + `score` + `diff_pct` op in review_log. - - Check convergence (na ronde 1+). - - Break indien converged. - -3. **Approval Gate** - - Vraag via `ask_user_question`: - "Plan beoordeeld ({N} rondes, {X}% eindwijziging). Goedkeuren?" - - Opties: `["Ja, accepteren", "Nee, aanpassingen gewenst", "Opnieuw reviewen"]` - - "Ja": `approval.status = 'approved'` → ga door naar Save & Close. - - "Nee": `approval.status = 'rejected'` → sluit af (user kan handmatig editen). - - "Opnieuw": max 2 extra rondes (rondes 3–4), dan dwingend approval vragen. - -4. **Save & Close** - - Call `update_idea_plan_reviewed({ idea_id, review_log, approval_status })`. - - Call `update_job_status({ job_id, status: 'done', summary: review_log.summary })`. - -## Output-format review_log (strikt JSON) - -```json -{ - "plan_file": "IDEA-016", - "created_at": "ISO8601", - "rounds": [ - { - "round": 0, - "model": "claude-opus-4-7", - "role": "Structure Review", - "focus": "YAML parsing, format, syntax", - "plan_before": "<origineel plan_md>", - "plan_after": "<herzien plan_md na ronde>", - "issues": [ - { - "category": "structure|logic|risk|pattern", - "severity": "error|warning|info", - "suggestion": "wat te fixen" - } - ], - "score": 75, - "plan_diff_lines": 12, - "converged": false, - "timestamp": "ISO8601" - } - ], - "convergence": { - "stable_at_round": 2, - "final_diff_pct": 2.1, - "convergence_metric": "plan_stability" - }, - "approval": { - "status": "pending|approved|rejected", - "timestamp": "ISO8601" - }, - "summary": "1–2 zinnen samenvatting: X rondes, Y% wijziging, status" -} -``` - -## Foutgevallen - -- **Plan parse-fout**: `update_job_status('failed', error: 'plan_parse_failed')` — stop. -- **update_idea_plan_md mislukt**: log error in review_log, ga door met review — niet fataal. -- **Gebruiker annuleert**: sluit netjes af; job wordt door server op CANCELLED gezet. -- **Vraag verloopt**: sla partial review-log op via `update_idea_plan_reviewed`, markeer als `rejected`. - -## Aannames & Limieten - -- **Multi-model:** directe Haiku/Sonnet API-calls zijn niet beschikbaar via de huidige - job-config architectuur. Alle rondes draaien op het geconfigureerde Opus model. - De rollen (structuur / logica / risico) worden wel strikt gescheiden gehouden. - Toekomst: directe model-switching via Anthropic API. -- Plan bevat geen versleutelde data (review-log opgeslagen als JSON in DB). -- Repo is leesbaar; geen network-fouts verwacht. -- Max 2 extra review-rondes buiten de initiële 3 (max 5 rondes totaal). -- Per ronde: max 10 issues gelogd (overige → samenvatting in `summary`). diff --git a/lib/idea-status-colors.ts b/lib/idea-status-colors.ts deleted file mode 100644 index 52203a1..0000000 --- a/lib/idea-status-colors.ts +++ /dev/null @@ -1,69 +0,0 @@ -// Mapping van IdeaStatus → Tailwind/MD3-classes voor badge-rendering. -// Hergebruikt de bestaande --status-*-tokens (zie app/styles/theme.css). -// CLAUDE.md hardstop: nooit `bg-blue-500` o.i.d.; altijd MD3-tokens. - -import type { IdeaStatus } from '@prisma/client' - -export interface IdeaStatusBadge { - label: string - classes: string - pulse?: boolean -} - -const PILL = 'inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium' - -// Per-status: label + Tailwind-classes + optionele pulse-indicator. -// in-progress + status-blocked + status-review + status-done worden hergebruikt. -const TABLE: Record<IdeaStatus, IdeaStatusBadge> = { - DRAFT: { - label: 'Concept', - classes: `${PILL} bg-surface-variant text-on-surface-variant border-outline-variant`, - }, - GRILLING: { - label: 'Grillen…', - classes: `${PILL} bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30`, - pulse: true, - }, - GRILL_FAILED: { - label: 'Grill mislukt', - classes: `${PILL} bg-status-blocked/15 text-status-blocked border-status-blocked/30`, - }, - GRILLED: { - label: 'Gegrilld', - classes: `${PILL} bg-status-review/15 text-status-review border-status-review/30`, - }, - PLANNING: { - label: 'Plannen…', - classes: `${PILL} bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30`, - pulse: true, - }, - PLAN_FAILED: { - label: 'Plan mislukt', - classes: `${PILL} bg-status-blocked/15 text-status-blocked border-status-blocked/30`, - }, - PLAN_READY: { - label: 'Plan klaar', - classes: `${PILL} bg-status-review/15 text-status-review border-status-review/30`, - }, - REVIEWING_PLAN: { - label: 'Plan beoordelen…', - classes: `${PILL} bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30`, - pulse: true, - }, - PLAN_REVIEW_FAILED: { - label: 'Beoordeling mislukt', - classes: `${PILL} bg-status-blocked/15 text-status-blocked border-status-blocked/30`, - }, - PLAN_REVIEWED: { - label: 'Plan beoordeeld', - classes: `${PILL} bg-status-done/15 text-status-done border-status-done/30`, - }, - PLANNED: { - label: 'Gepland', - classes: `${PILL} bg-status-done/15 text-status-done border-status-done/30`, - }, -} - -export function getIdeaStatusBadge(status: IdeaStatus): IdeaStatusBadge { - return TABLE[status] -} diff --git a/lib/idea-status.ts b/lib/idea-status.ts deleted file mode 100644 index 85e52c9..0000000 --- a/lib/idea-status.ts +++ /dev/null @@ -1,96 +0,0 @@ -// Bidirectionele case-mapper voor IdeaStatus + transitie-guard helper. -// DB houdt UPPER_SNAKE; API exposeert lowercase. -// Patroon volgt lib/task-status.ts. - -import type { IdeaStatus } from '@prisma/client' - -const IDEA_DB_TO_API = { - DRAFT: 'draft', - GRILLING: 'grilling', - GRILL_FAILED: 'grill_failed', - GRILLED: 'grilled', - PLANNING: 'planning', - PLAN_FAILED: 'plan_failed', - PLAN_READY: 'plan_ready', - REVIEWING_PLAN: 'reviewing_plan', - PLAN_REVIEW_FAILED: 'plan_review_failed', - PLAN_REVIEWED: 'plan_reviewed', - PLANNED: 'planned', -} as const satisfies Record<IdeaStatus, string> - -const IDEA_API_TO_DB: Record<string, IdeaStatus> = { - draft: 'DRAFT', - grilling: 'GRILLING', - grill_failed: 'GRILL_FAILED', - grilled: 'GRILLED', - planning: 'PLANNING', - plan_failed: 'PLAN_FAILED', - plan_ready: 'PLAN_READY', - reviewing_plan: 'REVIEWING_PLAN', - plan_review_failed: 'PLAN_REVIEW_FAILED', - plan_reviewed: 'PLAN_REVIEWED', - planned: 'PLANNED', -} - -export type IdeaStatusApi = (typeof IDEA_DB_TO_API)[IdeaStatus] - -export function ideaStatusToApi(s: IdeaStatus): IdeaStatusApi { - return IDEA_DB_TO_API[s] -} - -export function ideaStatusFromApi(s: string): IdeaStatus | null { - return IDEA_API_TO_DB[s.toLowerCase()] ?? null -} - -export const IDEA_STATUS_API_VALUES = Object.values(IDEA_DB_TO_API) - -// --------------------------------------------------------------------------- -// State-machine transition table (zie docs/plans/M12-ideas.md state-machine). -// Server-actions gebruiken canTransition(from, to) als guard vóór mutatie. -// -// Asymmetrisch: trek vanuit DRAFT alleen naar GRILLING; vanuit GRILLED kan -// re-grill (→ GRILLING) of make-plan (→ PLANNING) gebeuren. PLANNED is een -// terminal state; verlaat alleen via expliciete relink (PBI verwijderd → PLAN_READY). - -const ALLOWED_TRANSITIONS: Record<IdeaStatus, ReadonlyArray<IdeaStatus>> = { - DRAFT: ['GRILLING'], - GRILLING: ['GRILLED', 'GRILL_FAILED'], - GRILL_FAILED: ['GRILLING', 'DRAFT'], - GRILLED: ['GRILLING', 'PLANNING'], - PLANNING: ['PLAN_READY', 'PLAN_FAILED'], - PLAN_FAILED: ['PLANNING', 'GRILLED'], - PLAN_READY: ['PLANNING', 'PLANNED', 'GRILLING', 'REVIEWING_PLAN'], // + REVIEWING_PLAN via startReviewPlanJobAction - REVIEWING_PLAN: ['PLAN_REVIEWED', 'PLAN_REVIEW_FAILED'], - PLAN_REVIEW_FAILED: ['REVIEWING_PLAN', 'PLAN_READY'], // Can retry review or edit plan - PLAN_REVIEWED: ['REVIEWING_PLAN', 'PLANNED'], // Can re-review or create PBIs - PLANNED: ['PLAN_READY', 'GRILLING'], // PLAN_READY via relinkIdeaPlanAction; GRILLING via startGrillJobAction -} - -export function canTransition(from: IdeaStatus, to: IdeaStatus): boolean { - return ALLOWED_TRANSITIONS[from].includes(to) -} - -// Statussen waarin een idee bewerkbaar is (form-input, niet md-velden). -const EDITABLE_STATUSES: ReadonlyArray<IdeaStatus> = [ - 'DRAFT', - 'GRILL_FAILED', - 'GRILLED', - 'PLAN_FAILED', - 'PLAN_READY', - 'PLAN_REVIEW_FAILED', - 'PLAN_REVIEWED', -] - -export function isIdeaEditable(s: IdeaStatus): boolean { - return EDITABLE_STATUSES.includes(s) -} - -// Statussen waarin grill_md bewerkbaar is (handmatige finetuning). -export function isGrillMdEditable(s: IdeaStatus): boolean { - return s === 'GRILLED' || s === 'PLAN_READY' -} - -// Statussen waarin plan_md bewerkbaar is. -export function isPlanMdEditable(s: IdeaStatus): boolean { - return s === 'PLAN_READY' || s === 'PLAN_REVIEWED' || s === 'PLAN_REVIEW_FAILED' -} diff --git a/lib/insights/agent-throughput.ts b/lib/insights/agent-throughput.ts index 07ec1f1..ecc97ac 100644 --- a/lib/insights/agent-throughput.ts +++ b/lib/insights/agent-throughput.ts @@ -8,7 +8,6 @@ export interface DayCount { done: number failed: number cancelled: number - skipped: number } export interface ThroughputKpi { @@ -22,6 +21,8 @@ export interface JobsPerDayResult { kpi: ThroughputKpi } +const STATUSES = ['queued', 'claimed', 'running', 'done', 'failed', 'cancelled'] as const + type RawDayRow = { day: Date; status: string; count: bigint } type RawKpiRow = { today_count: bigint; done_7d: bigint; terminal_7d: bigint; avg_seconds: number | null } @@ -58,7 +59,7 @@ export async function getJobsPerDay( SELECT COUNT(*) FILTER (WHERE DATE(created_at) = CURRENT_DATE) AS today_count, COUNT(*) FILTER (WHERE status = 'DONE' AND created_at > NOW() - INTERVAL '7 days') AS done_7d, - COUNT(*) FILTER (WHERE status IN ('DONE','FAILED','CANCELLED','SKIPPED') AND created_at > NOW() - INTERVAL '7 days') AS terminal_7d, + COUNT(*) FILTER (WHERE status IN ('DONE','FAILED','CANCELLED') AND created_at > NOW() - INTERVAL '7 days') AS terminal_7d, AVG(EXTRACT(EPOCH FROM (finished_at - claimed_at))) FILTER (WHERE status = 'DONE' AND created_at > NOW() - INTERVAL '7 days') AS avg_seconds FROM claude_jobs WHERE user_id = ${userId} @@ -68,7 +69,7 @@ export async function getJobsPerDay( SELECT COUNT(*) FILTER (WHERE DATE(created_at) = CURRENT_DATE) AS today_count, COUNT(*) FILTER (WHERE status = 'DONE' AND created_at > NOW() - INTERVAL '7 days') AS done_7d, - COUNT(*) FILTER (WHERE status IN ('DONE','FAILED','CANCELLED','SKIPPED') AND created_at > NOW() - INTERVAL '7 days') AS terminal_7d, + COUNT(*) FILTER (WHERE status IN ('DONE','FAILED','CANCELLED') AND created_at > NOW() - INTERVAL '7 days') AS terminal_7d, AVG(EXTRACT(EPOCH FROM (finished_at - claimed_at))) FILTER (WHERE status = 'DONE' AND created_at > NOW() - INTERVAL '7 days') AS avg_seconds FROM claude_jobs WHERE user_id = ${userId} @@ -99,7 +100,6 @@ export async function getJobsPerDay( done: statusMap.get('done') ?? 0, failed: statusMap.get('failed') ?? 0, cancelled: statusMap.get('cancelled') ?? 0, - skipped: statusMap.get('skipped') ?? 0, }) } diff --git a/lib/insights/burndown.ts b/lib/insights/burndown.ts index f5dfbd1..551d216 100644 --- a/lib/insights/burndown.ts +++ b/lib/insights/burndown.ts @@ -9,7 +9,6 @@ export interface BurndownDay { export interface BurndownSprint { sprintId: string - sprintCode: string productId: string productName: string sprintGoal: string @@ -58,12 +57,11 @@ export async function getBurndownData(userId: string): Promise<BurndownSprint[]> const sprints = await prisma.sprint.findMany({ where: { - status: 'OPEN', + status: 'ACTIVE', product: productAccessFilter(userId), }, select: { id: true, - code: true, sprint_goal: true, created_at: true, completed_at: true, @@ -79,7 +77,6 @@ export async function getBurndownData(userId: string): Promise<BurndownSprint[]> return { sprintId: sprint.id, - sprintCode: sprint.code, productId: sprint.product.id, productName: sprint.product.name, sprintGoal: sprint.sprint_goal, diff --git a/lib/insights/cost-analysis.ts b/lib/insights/cost-analysis.ts deleted file mode 100644 index f486b4e..0000000 --- a/lib/insights/cost-analysis.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { prisma } from '@/lib/prisma' - -export type Period = '7d' | '30d' | '90d' | 'mtd' - -export interface CostKpi { - totalCostUsd: number - totalTokens: number - jobCount: number - avgPerDayUsd: number - cacheSavingsUsd: number - topModelId: string | null - topModelCostUsd: number -} - -export interface CostByDayRow { - day: string - costUsd: number -} - -export interface CostByModelRow { - modelId: string - costUsd: number - jobCount: number -} - -export interface CostByKindRow { - kind: string - costUsd: number - jobCount: number -} - -export interface CacheEfficiency { - cacheReadTokens: number - uncachedInputTokens: number - cacheHitRatio: number - savingsUsd: number - spentOnCacheWriteUsd: number -} - -function periodToDays(period: Period, now: Date = new Date()): number { - switch (period) { - case '7d': - return 7 - case '30d': - return 30 - case '90d': - return 90 - case 'mtd': - return now.getUTCDate() - } -} - -function periodStart(period: Period, now: Date = new Date()): Date { - if (period === 'mtd') { - return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)) - } - const days = periodToDays(period, now) - return new Date(now.getTime() - days * 86_400_000) -} - -type RawKpiRow = { - total_cost: number | null - total_tokens: bigint - job_count: bigint - cache_savings: number | null -} - -type RawTopModelRow = { - model_id: string | null - cost: number | null -} - -export async function getCostKpi(userId: string, period: Period): Promise<CostKpi> { - const start = periodStart(period) - const days = Math.max(periodToDays(period), 1) - - const [kpiRows, topModelRows] = await Promise.all([ - prisma.$queryRaw<RawKpiRow[]>` - SELECT - SUM( - cj.input_tokens * mp.input_price_per_1m / 1000000.0 - + cj.output_tokens * mp.output_price_per_1m / 1000000.0 - + cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0 - + cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 - + COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0 - ) FILTER (WHERE cj.input_tokens IS NOT NULL) AS total_cost, - COALESCE(SUM( - cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens - + COALESCE(cj.actual_thinking_tokens, 0) - ), 0) AS total_tokens, - COUNT(*) FILTER (WHERE cj.input_tokens IS NOT NULL) AS job_count, - SUM( - cj.cache_read_tokens * (mp.input_price_per_1m - mp.cache_read_price_per_1m) / 1000000.0 - ) FILTER (WHERE cj.cache_read_tokens IS NOT NULL) AS cache_savings - FROM claude_jobs cj - LEFT JOIN model_prices mp ON mp.model_id = cj.model_id - WHERE cj.user_id = ${userId} - AND cj.status = 'DONE' - AND cj.finished_at >= ${start} - `, - prisma.$queryRaw<RawTopModelRow[]>` - SELECT - cj.model_id, - SUM( - cj.input_tokens * mp.input_price_per_1m / 1000000.0 - + cj.output_tokens * mp.output_price_per_1m / 1000000.0 - + cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0 - + cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 - + COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0 - ) AS cost - FROM claude_jobs cj - LEFT JOIN model_prices mp ON mp.model_id = cj.model_id - WHERE cj.user_id = ${userId} - AND cj.status = 'DONE' - AND cj.finished_at >= ${start} - AND cj.input_tokens IS NOT NULL - AND cj.model_id IS NOT NULL - GROUP BY cj.model_id - ORDER BY cost DESC NULLS LAST - LIMIT 1 - `, - ]) - - const kpi = kpiRows[0] - const totalCost = Number(kpi?.total_cost ?? 0) - const top = topModelRows[0] - - return { - totalCostUsd: totalCost, - totalTokens: Number(kpi?.total_tokens ?? 0), - jobCount: Number(kpi?.job_count ?? 0), - avgPerDayUsd: totalCost / days, - cacheSavingsUsd: Number(kpi?.cache_savings ?? 0), - topModelId: top?.model_id ?? null, - topModelCostUsd: Number(top?.cost ?? 0), - } -} - -type RawDayRow = { - day: Date - cost: number | null -} - -export async function getCostByDay(userId: string, period: Period): Promise<CostByDayRow[]> { - const start = periodStart(period) - - const rows = await prisma.$queryRaw<RawDayRow[]>` - SELECT - DATE(cj.finished_at) AS day, - SUM( - cj.input_tokens * mp.input_price_per_1m / 1000000.0 - + cj.output_tokens * mp.output_price_per_1m / 1000000.0 - + cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0 - + cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 - + COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0 - ) FILTER (WHERE cj.input_tokens IS NOT NULL) AS cost - FROM claude_jobs cj - LEFT JOIN model_prices mp ON mp.model_id = cj.model_id - WHERE cj.user_id = ${userId} - AND cj.status = 'DONE' - AND cj.finished_at >= ${start} - GROUP BY DATE(cj.finished_at) - ORDER BY day ASC - ` - - return rows.map(r => ({ - day: r.day.toISOString().slice(0, 10), - costUsd: Number(r.cost ?? 0), - })) -} - -type RawModelRow = { - model_id: string - cost: number | null - job_count: bigint -} - -export async function getCostByModel(userId: string, period: Period): Promise<CostByModelRow[]> { - const start = periodStart(period) - - const rows = await prisma.$queryRaw<RawModelRow[]>` - SELECT - cj.model_id, - SUM( - cj.input_tokens * mp.input_price_per_1m / 1000000.0 - + cj.output_tokens * mp.output_price_per_1m / 1000000.0 - + cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0 - + cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 - + COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0 - ) AS cost, - COUNT(*) AS job_count - FROM claude_jobs cj - LEFT JOIN model_prices mp ON mp.model_id = cj.model_id - WHERE cj.user_id = ${userId} - AND cj.status = 'DONE' - AND cj.finished_at >= ${start} - AND cj.input_tokens IS NOT NULL - AND cj.model_id IS NOT NULL - GROUP BY cj.model_id - ORDER BY cost DESC NULLS LAST - LIMIT 5 - ` - - return rows.map(r => ({ - modelId: r.model_id, - costUsd: Number(r.cost ?? 0), - jobCount: Number(r.job_count), - })) -} - -type RawKindRow = { - kind: string - cost: number | null - job_count: bigint -} - -export async function getCostByKind(userId: string, period: Period): Promise<CostByKindRow[]> { - const start = periodStart(period) - - const rows = await prisma.$queryRaw<RawKindRow[]>` - SELECT - cj.kind::text AS kind, - SUM( - cj.input_tokens * mp.input_price_per_1m / 1000000.0 - + cj.output_tokens * mp.output_price_per_1m / 1000000.0 - + cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0 - + cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 - + COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0 - ) AS cost, - COUNT(*) AS job_count - FROM claude_jobs cj - LEFT JOIN model_prices mp ON mp.model_id = cj.model_id - WHERE cj.user_id = ${userId} - AND cj.status = 'DONE' - AND cj.finished_at >= ${start} - AND cj.input_tokens IS NOT NULL - GROUP BY cj.kind - ORDER BY cost DESC NULLS LAST - LIMIT 5 - ` - - return rows.map(r => ({ - kind: r.kind, - costUsd: Number(r.cost ?? 0), - jobCount: Number(r.job_count), - })) -} - -type RawCacheRow = { - cache_read_tokens: bigint - uncached_input_tokens: bigint - savings: number | null - cache_write_cost: number | null -} - -export async function getCacheEfficiency( - userId: string, - period: Period, -): Promise<CacheEfficiency> { - const start = periodStart(period) - - const rows = await prisma.$queryRaw<RawCacheRow[]>` - SELECT - COALESCE(SUM(cj.cache_read_tokens), 0) AS cache_read_tokens, - COALESCE(SUM(cj.input_tokens), 0) AS uncached_input_tokens, - SUM( - cj.cache_read_tokens * (mp.input_price_per_1m - mp.cache_read_price_per_1m) / 1000000.0 - ) FILTER (WHERE cj.cache_read_tokens IS NOT NULL) AS savings, - SUM( - cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 - ) FILTER (WHERE cj.cache_write_tokens IS NOT NULL) AS cache_write_cost - FROM claude_jobs cj - LEFT JOIN model_prices mp ON mp.model_id = cj.model_id - WHERE cj.user_id = ${userId} - AND cj.status = 'DONE' - AND cj.finished_at >= ${start} - ` - - const r = rows[0] - const cacheReadTokens = Number(r?.cache_read_tokens ?? 0) - const uncachedInputTokens = Number(r?.uncached_input_tokens ?? 0) - const totalInputLike = cacheReadTokens + uncachedInputTokens - - return { - cacheReadTokens, - uncachedInputTokens, - cacheHitRatio: totalInputLike > 0 ? cacheReadTokens / totalInputLike : 0, - savingsUsd: Number(r?.savings ?? 0), - spentOnCacheWriteUsd: Number(r?.cache_write_cost ?? 0), - } -} diff --git a/lib/insights/sprint-status.ts b/lib/insights/sprint-status.ts index 9960d49..51b619a 100644 --- a/lib/insights/sprint-status.ts +++ b/lib/insights/sprint-status.ts @@ -20,7 +20,7 @@ export async function getSprintStatusBreakdown(userId: string): Promise<StatusCo where: { story: { sprint: { - status: 'OPEN', + status: 'ACTIVE', product: productAccessFilter(userId), }, }, diff --git a/lib/insights/token-history.ts b/lib/insights/token-history.ts deleted file mode 100644 index 42f4ecc..0000000 --- a/lib/insights/token-history.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { prisma } from '@/lib/prisma' - -export interface SprintTokenRow { - sprintId: string - sprintCode: string - sprintGoal: string - totalTokens: number - totalCostUsd: number - jobCount: number -} - -export interface DayTokenRow { - day: string - totalTokens: number - totalCostUsd: number -} - -export interface PbiTokenRow { - pbiId: string - pbiCode: string - pbiTitle: string - totalTokens: number - totalCostUsd: number -} - -type RawSprintRow = { - sprint_id: string - sprint_code: string - sprint_goal: string - total_tokens: bigint - total_cost: number | null - job_count: bigint -} - -type RawDayRow = { - day: Date - total_tokens: bigint - total_cost: number | null -} - -type RawPbiRow = { - pbi_id: string - pbi_code: string - pbi_title: string - total_tokens: bigint - total_cost: number | null -} - -export async function getSprintTokenHistory( - userId: string, - productId?: string, - limit = 8, -): Promise<SprintTokenRow[]> { - const rows = productId - ? await prisma.$queryRaw<RawSprintRow[]>` - SELECT - sp.id AS sprint_id, - sp.code AS sprint_code, - sp.sprint_goal, - COALESCE(SUM(cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens + COALESCE(cj.actual_thinking_tokens, 0)), 0) AS total_tokens, - SUM( - cj.input_tokens * mp.input_price_per_1m / 1000000.0 - + cj.output_tokens * mp.output_price_per_1m / 1000000.0 - + cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0 - + cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 - + COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0 - ) FILTER (WHERE cj.input_tokens IS NOT NULL) AS total_cost, - COUNT(*) FILTER (WHERE cj.input_tokens IS NOT NULL) AS job_count - FROM claude_jobs cj - JOIN tasks t ON cj.task_id = t.id - JOIN stories s ON t.story_id = s.id - JOIN sprints sp ON s.sprint_id = sp.id - LEFT JOIN model_prices mp ON mp.model_id = cj.model_id - WHERE cj.user_id = ${userId} - AND cj.status = 'DONE' - AND cj.product_id = ${productId} - GROUP BY sp.id, sp.code, sp.sprint_goal - ORDER BY sp.created_at DESC - LIMIT ${limit} - ` - : await prisma.$queryRaw<RawSprintRow[]>` - SELECT - sp.id AS sprint_id, - sp.code AS sprint_code, - sp.sprint_goal, - COALESCE(SUM(cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens + COALESCE(cj.actual_thinking_tokens, 0)), 0) AS total_tokens, - SUM( - cj.input_tokens * mp.input_price_per_1m / 1000000.0 - + cj.output_tokens * mp.output_price_per_1m / 1000000.0 - + cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0 - + cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 - + COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0 - ) FILTER (WHERE cj.input_tokens IS NOT NULL) AS total_cost, - COUNT(*) FILTER (WHERE cj.input_tokens IS NOT NULL) AS job_count - FROM claude_jobs cj - JOIN tasks t ON cj.task_id = t.id - JOIN stories s ON t.story_id = s.id - JOIN sprints sp ON s.sprint_id = sp.id - LEFT JOIN model_prices mp ON mp.model_id = cj.model_id - WHERE cj.user_id = ${userId} - AND cj.status = 'DONE' - GROUP BY sp.id, sp.code, sp.sprint_goal - ORDER BY sp.created_at DESC - LIMIT ${limit} - ` - - return rows.map(r => ({ - sprintId: r.sprint_id, - sprintCode: r.sprint_code, - sprintGoal: r.sprint_goal, - totalTokens: Number(r.total_tokens), - totalCostUsd: Number(r.total_cost ?? 0), - jobCount: Number(r.job_count), - })) -} - -export async function getDayTokenData(userId: string, sprintId: string): Promise<DayTokenRow[]> { - if (!sprintId) return [] - - const rows = await prisma.$queryRaw<RawDayRow[]>` - SELECT - DATE(cj.finished_at) AS day, - COALESCE(SUM(cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens + COALESCE(cj.actual_thinking_tokens, 0)), 0) AS total_tokens, - SUM( - cj.input_tokens * mp.input_price_per_1m / 1000000.0 - + cj.output_tokens * mp.output_price_per_1m / 1000000.0 - + cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0 - + cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 - + COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0 - ) FILTER (WHERE cj.input_tokens IS NOT NULL) AS total_cost - FROM claude_jobs cj - JOIN tasks t ON cj.task_id = t.id - JOIN stories s ON t.story_id = s.id - LEFT JOIN model_prices mp ON mp.model_id = cj.model_id - WHERE cj.user_id = ${userId} - AND s.sprint_id = ${sprintId} - AND cj.status = 'DONE' - AND cj.finished_at IS NOT NULL - GROUP BY DATE(cj.finished_at) - ORDER BY day ASC - ` - - return rows.map(r => ({ - day: r.day.toISOString().slice(0, 10), - totalTokens: Number(r.total_tokens), - totalCostUsd: Number(r.total_cost ?? 0), - })) -} - -export async function getPbiTokenAggregates(userId: string, sprintId: string): Promise<PbiTokenRow[]> { - if (!sprintId) return [] - - const rows = await prisma.$queryRaw<RawPbiRow[]>` - SELECT - p.id AS pbi_id, - p.code AS pbi_code, - p.title AS pbi_title, - COALESCE(SUM(cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens + COALESCE(cj.actual_thinking_tokens, 0)), 0) AS total_tokens, - SUM( - cj.input_tokens * mp.input_price_per_1m / 1000000.0 - + cj.output_tokens * mp.output_price_per_1m / 1000000.0 - + cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0 - + cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 - + COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0 - ) FILTER (WHERE cj.input_tokens IS NOT NULL) AS total_cost - FROM claude_jobs cj - JOIN tasks t ON cj.task_id = t.id - JOIN stories s ON t.story_id = s.id - JOIN pbis p ON s.pbi_id = p.id - LEFT JOIN model_prices mp ON mp.model_id = cj.model_id - WHERE cj.user_id = ${userId} - AND s.sprint_id = ${sprintId} - AND cj.status = 'DONE' - GROUP BY p.id, p.code, p.title - ORDER BY total_cost DESC - ` - - return rows.map(r => ({ - pbiId: r.pbi_id, - pbiCode: r.pbi_code, - pbiTitle: r.pbi_title, - totalTokens: Number(r.total_tokens), - totalCostUsd: Number(r.total_cost ?? 0), - })) -} diff --git a/lib/insights/token-stats.ts b/lib/insights/token-stats.ts deleted file mode 100644 index 41d4a7c..0000000 --- a/lib/insights/token-stats.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { prisma } from '@/lib/prisma' - -export interface TokenKpi { - totalTokens: number - totalCostUsd: number - avgCostPerJob: number - jobCount: number -} - -export interface TokenJobRow { - jobId: string - taskTitle: string | null - ideaCode: string | null - modelId: string | null - inputTokens: number | null - outputTokens: number | null - cacheReadTokens: number | null - cacheWriteTokens: number | null - thinkingTokens: number | null - costUsd: number | null - durationSeconds: number | null -} - -export interface TokenStatsByKindRow { - kind: string - jobCount: number - totalTokens: number - totalCostUsd: number -} - -export interface TokenStatsResult { - kpi: TokenKpi - jobs: TokenJobRow[] -} - -type RawKpiRow = { - total_tokens: bigint - total_cost: number | null - avg_cost: number | null - job_count: bigint -} - -type RawJobRow = { - job_id: string - task_title: string | null - idea_code: string | null - model_id: string | null - input_tokens: number | null - output_tokens: number | null - cache_read_tokens: number | null - cache_write_tokens: number | null - actual_thinking_tokens: number | null - cost_usd: number | null - duration_seconds: number | null -} - -type RawByKindRow = { - kind: string - job_count: bigint - total_tokens: bigint - total_cost: number | null -} - -const EMPTY_KPI: TokenKpi = { totalTokens: 0, totalCostUsd: 0, avgCostPerJob: 0, jobCount: 0 } - -export async function getTokenStats(userId: string, sprintId: string): Promise<TokenStatsResult> { - if (!sprintId) return { kpi: EMPTY_KPI, jobs: [] } - - const [kpiRows, jobRows] = await Promise.all([ - prisma.$queryRaw<RawKpiRow[]>` - SELECT - COALESCE(SUM(cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens + COALESCE(cj.actual_thinking_tokens, 0)), 0) AS total_tokens, - SUM( - cj.input_tokens * mp.input_price_per_1m / 1000000.0 - + cj.output_tokens * mp.output_price_per_1m / 1000000.0 - + cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0 - + cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 - + COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0 - ) FILTER (WHERE cj.input_tokens IS NOT NULL) AS total_cost, - AVG( - cj.input_tokens * mp.input_price_per_1m / 1000000.0 - + cj.output_tokens * mp.output_price_per_1m / 1000000.0 - + cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0 - + cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 - + COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0 - ) FILTER (WHERE cj.input_tokens IS NOT NULL) AS avg_cost, - COUNT(*) FILTER (WHERE cj.input_tokens IS NOT NULL) AS job_count - FROM claude_jobs cj - JOIN tasks t ON cj.task_id = t.id - JOIN stories s ON t.story_id = s.id - LEFT JOIN model_prices mp ON mp.model_id = cj.model_id - WHERE cj.user_id = ${userId} - AND s.sprint_id = ${sprintId} - AND cj.status = 'DONE' - `, - prisma.$queryRaw<RawJobRow[]>` - SELECT - cj.id AS job_id, - t.title AS task_title, - i.code AS idea_code, - cj.model_id, - cj.input_tokens, - cj.output_tokens, - cj.cache_read_tokens, - cj.cache_write_tokens, - cj.actual_thinking_tokens, - CASE WHEN cj.input_tokens IS NOT NULL THEN - cj.input_tokens * mp.input_price_per_1m / 1000000.0 - + cj.output_tokens * mp.output_price_per_1m / 1000000.0 - + cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0 - + cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 - + COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0 - END AS cost_usd, - EXTRACT(EPOCH FROM (cj.finished_at - cj.claimed_at)) AS duration_seconds - FROM claude_jobs cj - LEFT JOIN tasks t ON cj.task_id = t.id - LEFT JOIN ideas i ON cj.idea_id = i.id - LEFT JOIN stories s ON t.story_id = s.id - LEFT JOIN model_prices mp ON mp.model_id = cj.model_id - WHERE cj.user_id = ${userId} - AND (s.sprint_id = ${sprintId} OR cj.idea_id IS NOT NULL) - AND cj.status = 'DONE' - ORDER BY cj.finished_at DESC - `, - ]) - - const kpi = kpiRows[0] - - return { - kpi: { - totalTokens: Number(kpi?.total_tokens ?? 0), - totalCostUsd: Number(kpi?.total_cost ?? 0), - avgCostPerJob: Number(kpi?.avg_cost ?? 0), - jobCount: Number(kpi?.job_count ?? 0), - }, - jobs: jobRows.map(r => ({ - jobId: r.job_id, - taskTitle: r.task_title, - ideaCode: r.idea_code, - modelId: r.model_id, - inputTokens: r.input_tokens, - outputTokens: r.output_tokens, - cacheReadTokens: r.cache_read_tokens, - cacheWriteTokens: r.cache_write_tokens, - thinkingTokens: r.actual_thinking_tokens, - costUsd: r.cost_usd != null ? Number(r.cost_usd) : null, - durationSeconds: r.duration_seconds != null ? Number(r.duration_seconds) : null, - })), - } -} - -// PBI-67: per-kind aggregatie. Toont totaal tokens + kosten per ClaudeJob.kind -// binnen één sprint zodat we de relatieve uitgaven van IDEA_GRILL vs -// TASK_IMPLEMENTATION etc. kunnen zien. Voor jobs zonder sprint-koppeling -// (idea-jobs) blijven we filteren op user_id + sprint_id; idea-jobs zonder -// task vallen buiten deze view. -export async function getTokenStatsByKind( - userId: string, - sprintId: string, -): Promise<TokenStatsByKindRow[]> { - if (!sprintId) return [] - - const rows = await prisma.$queryRaw<RawByKindRow[]>` - SELECT - cj.kind::text AS kind, - COUNT(*) FILTER (WHERE cj.input_tokens IS NOT NULL) AS job_count, - COALESCE(SUM( - cj.input_tokens + cj.output_tokens + cj.cache_read_tokens + cj.cache_write_tokens - + COALESCE(cj.actual_thinking_tokens, 0) - ), 0) AS total_tokens, - SUM( - cj.input_tokens * mp.input_price_per_1m / 1000000.0 - + cj.output_tokens * mp.output_price_per_1m / 1000000.0 - + cj.cache_read_tokens * mp.cache_read_price_per_1m / 1000000.0 - + cj.cache_write_tokens * mp.cache_write_price_per_1m / 1000000.0 - + COALESCE(cj.actual_thinking_tokens, 0) * mp.input_price_per_1m / 1000000.0 - ) FILTER (WHERE cj.input_tokens IS NOT NULL) AS total_cost - FROM claude_jobs cj - JOIN tasks t ON cj.task_id = t.id - JOIN stories s ON t.story_id = s.id - LEFT JOIN model_prices mp ON mp.model_id = cj.model_id - WHERE cj.user_id = ${userId} - AND s.sprint_id = ${sprintId} - AND cj.status = 'DONE' - GROUP BY cj.kind - ORDER BY total_cost DESC NULLS LAST - ` - - return rows.map((r) => ({ - kind: r.kind, - jobCount: Number(r.job_count), - totalTokens: Number(r.total_tokens), - totalCostUsd: Number(r.total_cost ?? 0), - })) -} diff --git a/lib/insights/velocity.ts b/lib/insights/velocity.ts index 43b366c..c01c45e 100644 --- a/lib/insights/velocity.ts +++ b/lib/insights/velocity.ts @@ -3,7 +3,6 @@ import { productAccessFilter } from '@/lib/product-access' export interface VelocitySprint { sprintId: string - sprintCode: string sprintGoal: string productId: string productName: string @@ -19,14 +18,13 @@ export interface VelocityData { export async function getVelocity(userId: string, sprintsBack = 5): Promise<VelocityData> { const sprints = await prisma.sprint.findMany({ where: { - status: 'CLOSED', + status: 'COMPLETED', product: productAccessFilter(userId), }, orderBy: { completed_at: 'desc' }, take: sprintsBack, select: { id: true, - code: true, sprint_goal: true, completed_at: true, product: { select: { id: true, name: true } }, @@ -44,7 +42,6 @@ export async function getVelocity(userId: string, sprintsBack = 5): Promise<Velo const result: VelocitySprint[] = chronological.map(sprint => ({ sprintId: sprint.id, - sprintCode: sprint.code, sprintGoal: sprint.sprint_goal, productId: sprint.product.id, productName: sprint.product.name, diff --git a/lib/insights/verify-stats.ts b/lib/insights/verify-stats.ts index 7c5683f..c19140f 100644 --- a/lib/insights/verify-stats.ts +++ b/lib/insights/verify-stats.ts @@ -20,7 +20,6 @@ export interface VerifyResultStats { export interface TrendPoint { sprintId: string - sprintCode: string sprintGoal: string productName: string alignedRatio: number @@ -41,9 +40,6 @@ export async function getVerifyResultStats( status: 'DONE' as const, verify_result: { not: null as null }, finished_at: { gt: cutoff }, - // Note: task_id can now be NULL on idea-jobs (M12). The toTopJob mapper - // filters them out via .filter(Boolean). Keeping a where-side filter - // (`task_id: { not: null }`) is rejected by Prisma 7 runtime. } const [grouped, rawEmpty, rawDivergent] = await Promise.all([ @@ -86,8 +82,7 @@ export async function getVerifyResultStats( .filter(r => countMap.has(r)) .map(r => ({ result: r, count: countMap.get(r)! })) - function toTopJob(j: { id: string; finished_at: Date | null; task: { id: string; title: string } | null; product: { id: string; name: string } }): TopJob | null { - if (!j.task) return null + function toTopJob(j: { id: string; finished_at: Date | null; task: { id: string; title: string }; product: { id: string; name: string } }): TopJob { return { jobId: j.id, taskId: j.task.id, @@ -100,8 +95,8 @@ export async function getVerifyResultStats( return { counts, - topEmpty: rawEmpty.map(toTopJob).filter((j): j is TopJob => j !== null), - topDivergent: rawDivergent.map(toTopJob).filter((j): j is TopJob => j !== null), + topEmpty: rawEmpty.map(toTopJob), + topDivergent: rawDivergent.map(toTopJob), } } @@ -111,14 +106,13 @@ export async function getAlignmentTrend( ): Promise<TrendPoint[]> { const sprints = await prisma.sprint.findMany({ where: { - status: 'CLOSED', + status: 'COMPLETED', product: productAccessFilter(userId), }, orderBy: { completed_at: 'desc' }, take: sprintsBack, select: { id: true, - code: true, sprint_goal: true, completed_at: true, product: { select: { name: true } }, @@ -139,7 +133,6 @@ export async function getAlignmentTrend( const aligned = jobs.filter(j => j.verify_result === 'ALIGNED').length return { sprintId: sprint.id, - sprintCode: sprint.code, sprintGoal: sprint.sprint_goal, productName: sprint.product.name, alignedRatio: jobs.length > 0 ? Math.round((aligned / jobs.length) * 100) : 0, diff --git a/lib/job-config-snapshot.ts b/lib/job-config-snapshot.ts deleted file mode 100644 index 43bd290..0000000 --- a/lib/job-config-snapshot.ts +++ /dev/null @@ -1,40 +0,0 @@ -// PBI-67: snapshot-helper voor ClaudeJob.requested_*-velden. -// -// Roep hem aan vóór elke `prisma.claudeJob.create({ data: { ... } })` en spread -// het resultaat in `data`. Doet één extra Product-query (en optioneel Task) -// om de override-cascade in te vullen op enqueue-tijd. Bij claim (in scrum4me- -// mcp/wait-for-job) wordt dezelfde resolver opnieuw aangeroepen — als -// requested_* dan al gezet zijn winnen die boven product/kind-defaults. - -import { prisma } from '@/lib/prisma' -import { resolveJobConfig, snapshotFromConfig, type ClaudeJobSnapshotFields } from '@/lib/job-config' - -export async function getJobConfigSnapshot(opts: { - kind: string - productId: string - taskId?: string | null -}): Promise<ClaudeJobSnapshotFields> { - const [product, task] = await Promise.all([ - prisma.product.findUnique({ - where: { id: opts.productId }, - select: { - preferred_model: true, - thinking_budget_default: true, - preferred_permission_mode: true, - }, - }), - opts.taskId - ? prisma.task.findUnique({ - where: { id: opts.taskId }, - select: { requires_opus: true }, - }) - : Promise.resolve(null), - ]) - - const cfg = resolveJobConfig( - { kind: opts.kind }, - product ?? {}, - task ?? undefined, - ) - return snapshotFromConfig(cfg) -} diff --git a/lib/job-config.ts b/lib/job-config.ts deleted file mode 100644 index 4d39480..0000000 --- a/lib/job-config.ts +++ /dev/null @@ -1,224 +0,0 @@ -// PBI-67: model + mode-selectie per ClaudeJob-kind. -// -// Sync with scrum4me-mcp/src/lib/job-config.ts — als je hier een veld -// aanpast, doe hetzelfde aan de MCP-kant. Dit is bewust een duplicate -// (geen gedeeld package) om de MCP-server eigenstandig te houden. -// -// Override-cascade (eerste match wint): -// 1. task.requires_opus === true → forceer Opus -// 2. job.requested_* (snapshot bij enqueue, ingevuld door deze module) -// 3. product.preferred_* -// 4. KIND_DEFAULTS hieronder -// -// CLI-flag-mapping (Claude CLI 2.1.x): -// - thinking_budget (number) → mapBudgetToEffort() → --effort {low,medium,high,xhigh,max} -// (de CLI heeft geen --thinking-budget flag — alleen --effort) -// - max_turns blijft audit-only: de CLI heeft géén --max-turns flag. -// De waarde wordt gesnapshot voor cost-attribution maar niet doorgegeven. -// - allowed_tools → --allowedTools (komma-gescheiden lijst) - -export type ClaudeModel = - | 'claude-opus-4-7' - | 'claude-sonnet-4-6' - | 'claude-haiku-4-5-20251001' - -export type PermissionMode = 'plan' | 'default' | 'acceptEdits' | 'bypassPermissions' - -export type JobConfig = { - model: ClaudeModel - thinking_budget: number - permission_mode: PermissionMode - max_turns: number | null - allowed_tools: string[] | null -} - -export type JobInput = { - kind: string - requested_model?: string | null - requested_thinking_budget?: number | null - requested_permission_mode?: string | null -} - -export type ProductInput = { - preferred_model?: string | null - thinking_budget_default?: number | null - preferred_permission_mode?: string | null -} - -export type TaskInput = { - requires_opus?: boolean | null -} - -// Tool-allowlists per kind. Bewust géén `wait_for_job`, `check_queue_empty` -// of `get_idea_context` — de runner (scrum4me-docker/bin/run-one-job.ts) -// claimt voor Claude. Vangrail tegen recursieve claims binnen één invocation. -const TASK_TOOLS = [ - 'Read', 'Edit', 'Write', 'Bash', 'Grep', 'Glob', - 'mcp__scrum4me__get_claude_context', - 'mcp__scrum4me__update_task_status', - 'mcp__scrum4me__update_task_plan', - 'mcp__scrum4me__log_implementation', - 'mcp__scrum4me__log_test_result', - 'mcp__scrum4me__log_commit', - 'mcp__scrum4me__verify_task_against_plan', - 'mcp__scrum4me__update_job_status', - 'mcp__scrum4me__ask_user_question', - 'mcp__scrum4me__get_question_answer', - 'mcp__scrum4me__list_open_questions', - 'mcp__scrum4me__cancel_question', - 'mcp__scrum4me__worker_heartbeat', -] - -const KIND_DEFAULTS: Record<string, JobConfig> = { - // Idea-kinds en PLAN_CHAT draaien in `acceptEdits` (niet `plan`): - // `plan`-mode wacht op human-approval na elke planning-fase, wat in een - // autonome runner-context betekent dat Claude geen `update_job_status` - // aanroept en de job na lease-expiry FAILED'd. De `allowed_tools`-lijst - // doet de echte sandboxing (geen Bash, geen Edit, alleen Read/Grep/etc). - IDEA_GRILL: { - model: 'claude-sonnet-4-6', - thinking_budget: 12000, - permission_mode: 'acceptEdits', - max_turns: 15, - allowed_tools: [ - 'Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', - 'mcp__scrum4me__update_idea_grill_md', - 'mcp__scrum4me__log_idea_decision', - 'mcp__scrum4me__update_job_status', - 'mcp__scrum4me__ask_user_question', - 'mcp__scrum4me__get_question_answer', - ], - }, - IDEA_MAKE_PLAN: { - model: 'claude-opus-4-7', - thinking_budget: 24000, - permission_mode: 'acceptEdits', - max_turns: 20, - allowed_tools: [ - 'Read', 'Grep', 'Glob', 'WebSearch', 'AskUserQuestion', 'Write', - 'mcp__scrum4me__update_idea_plan_md', - 'mcp__scrum4me__log_idea_decision', - 'mcp__scrum4me__update_job_status', - ], - }, - IDEA_REVIEW_PLAN: { - model: 'claude-opus-4-7', - thinking_budget: 6000, - permission_mode: 'acceptEdits', - max_turns: 1, - allowed_tools: [ - 'Read', 'Write', 'Grep', 'Glob', - 'mcp__scrum4me__update_idea_plan_reviewed', - 'mcp__scrum4me__log_idea_decision', - 'mcp__scrum4me__update_job_status', - 'mcp__scrum4me__ask_user_question', - ], - }, - PLAN_CHAT: { - model: 'claude-sonnet-4-6', - thinking_budget: 6000, - permission_mode: 'acceptEdits', - max_turns: 5, - allowed_tools: [ - 'Read', 'Grep', 'AskUserQuestion', - 'mcp__scrum4me__update_job_status', - ], - }, - TASK_IMPLEMENTATION: { - model: 'claude-sonnet-4-6', - thinking_budget: 6000, - permission_mode: 'bypassPermissions', - max_turns: 50, - allowed_tools: TASK_TOOLS, - }, - SPRINT_IMPLEMENTATION: { - model: 'claude-sonnet-4-6', - thinking_budget: 6000, - permission_mode: 'bypassPermissions', - max_turns: null, - // Geen `mcp__scrum4me__job_heartbeat` — de runner verlengt de lease - // automatisch via setInterval (zie scrum4me-docker/bin/run-one-job.ts). - allowed_tools: [ - ...TASK_TOOLS, - 'mcp__scrum4me__update_task_execution', - 'mcp__scrum4me__verify_sprint_task', - ], - }, -} - -const FALLBACK: JobConfig = { - model: 'claude-sonnet-4-6', - thinking_budget: 6000, - permission_mode: 'default', - max_turns: 50, - allowed_tools: null, -} - -export function getKindDefault(kind: string): JobConfig { - return KIND_DEFAULTS[kind] ?? FALLBACK -} - -// max_turns en allowed_tools blijven kind-default (geen product/task override -// in V1 — als de behoefte ontstaat, voeg analoge velden toe aan Product/Task). -export function resolveJobConfig( - job: JobInput, - product: ProductInput, - task?: TaskInput, -): JobConfig { - const base = getKindDefault(job.kind) - - const model = ( - task?.requires_opus - ? 'claude-opus-4-7' - : job.requested_model ?? product.preferred_model ?? base.model - ) as ClaudeModel - - const thinking_budget = - job.requested_thinking_budget ?? product.thinking_budget_default ?? base.thinking_budget - - const permission_mode = (job.requested_permission_mode ?? - product.preferred_permission_mode ?? - base.permission_mode) as PermissionMode - - return { - model, - thinking_budget, - permission_mode, - max_turns: base.max_turns, - allowed_tools: base.allowed_tools, - } -} - -// Map numeriek thinking_budget naar de Claude CLI 2.1.x --effort flag. -// Returns null als de flag niet meegegeven moet worden (budget = 0). -// -// Mapping (sync met scrum4me-mcp/src/lib/job-config.ts): -// 0 → null (geen --effort flag) -// 1..6000 → "medium" -// 6001..12000 → "high" -// 12001..24000→ "xhigh" -// >24000 → "max" -export function mapBudgetToEffort(budget: number): string | null { - if (budget <= 0) return null - if (budget <= 6000) return 'medium' - if (budget <= 12000) return 'high' - if (budget <= 24000) return 'xhigh' - return 'max' -} - -// Snapshot-velden voor ClaudeJob.requested_*. Bij elke enqueue laden we -// product (voor preferred_*) en optioneel task (voor requires_opus), draaien -// de resolver, en schrijven het resultaat als auditspoor in de job-rij. -export type ClaudeJobSnapshotFields = { - requested_model: string - requested_thinking_budget: number - requested_permission_mode: string -} - -export function snapshotFromConfig(cfg: JobConfig): ClaudeJobSnapshotFields { - return { - requested_model: cfg.model, - requested_thinking_budget: cfg.thinking_budget, - requested_permission_mode: cfg.permission_mode, - } -} diff --git a/lib/job-status.ts b/lib/job-status.ts index 43bb498..f6ac4ee 100644 --- a/lib/job-status.ts +++ b/lib/job-status.ts @@ -7,7 +7,6 @@ const JOB_DB_TO_API = { DONE: 'done', FAILED: 'failed', CANCELLED: 'cancelled', - SKIPPED: 'skipped', } as const satisfies Record<ClaudeJobStatus, string> const JOB_API_TO_DB: Record<string, ClaudeJobStatus> = { @@ -17,7 +16,6 @@ const JOB_API_TO_DB: Record<string, ClaudeJobStatus> = { done: 'DONE', failed: 'FAILED', cancelled: 'CANCELLED', - skipped: 'SKIPPED', } export type ClaudeJobStatusApi = typeof JOB_DB_TO_API[ClaudeJobStatus] diff --git a/lib/jobs-mapper.ts b/lib/jobs-mapper.ts deleted file mode 100644 index e9746cd..0000000 --- a/lib/jobs-mapper.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { ClaudeJobKind, ClaudeJobStatus, VerifyResult } from '@prisma/client' - -export type JobWithRelations = { - id: string - kind: ClaudeJobKind - status: ClaudeJobStatus - taskCode: string | null - taskTitle: string | null - ideaCode: string | null - ideaTitle: string | null - sprintGoal: string | null - sprintCode: string | null - productName: string - productCode: string | null - storyCode: string | null - pbiCode: string | null - modelId: string | null - inputTokens: number | null - outputTokens: number | null - cacheReadTokens: number | null - cacheWriteTokens: number | null - costUsd: number | null - branch: string | null - prUrl: string | null - error: string | null - summary: string | null - description: string | null - verifyResult: VerifyResult | null - startedAt: Date | null - finishedAt: Date | null - createdAt: Date - sprintRunId: string | null -} - -export const JOB_INCLUDE = { - task: { - select: { - code: true, - title: true, - description: true, - implementation_plan: true, - story: { select: { code: true, pbi: { select: { code: true } } } }, - }, - }, - idea: { select: { code: true, title: true, description: true, grill_md: true, plan_md: true } }, - product: { select: { name: true, code: true } }, - sprint_run: { include: { sprint: { select: { sprint_goal: true, code: true } } } }, -} as const - -export type RawJob = { - id: string - kind: ClaudeJobKind - status: ClaudeJobStatus - model_id: string | null - input_tokens: number | null - output_tokens: number | null - cache_read_tokens: number | null - cache_write_tokens: number | null - branch: string | null - pr_url: string | null - error: string | null - summary: string | null - verify_result: VerifyResult | null - started_at: Date | null - finished_at: Date | null - created_at: Date - sprint_run_id: string | null - task: { - code: string | null - title: string - description: string | null - implementation_plan: string | null - story: { code: string; pbi: { code: string } } | null - } | null - idea: { - code: string | null - title: string - description: string | null - grill_md: string | null - plan_md: string | null - } | null - product: { name: string; code: string | null } - sprint_run: { sprint: { sprint_goal: string; code: string } } | null -} - -export type PriceRow = { - model_id: string - input_price_per_1m: { toString: () => string } - output_price_per_1m: { toString: () => string } - cache_read_price_per_1m: { toString: () => string } - cache_write_price_per_1m: { toString: () => string } -} - -export function buildPriceMap(prices: PriceRow[]): Map<string, PriceRow> { - return new Map(prices.map((p) => [p.model_id, p])) -} - -export function pickDescription(j: RawJob): string | null { - switch (j.kind) { - case 'TASK_IMPLEMENTATION': - return j.task?.implementation_plan ?? j.task?.description ?? null - case 'IDEA_GRILL': - return j.idea?.grill_md ?? j.idea?.description ?? null - case 'IDEA_MAKE_PLAN': - return j.idea?.plan_md ?? j.idea?.description ?? null - case 'PLAN_CHAT': - return j.idea?.description ?? null - case 'SPRINT_IMPLEMENTATION': - return null - default: - return null - } -} - -export function computeCost(j: RawJob, priceMap: Map<string, PriceRow>): number | null { - if (!j.model_id) return null - const p = priceMap.get(j.model_id) - if (!p || j.input_tokens == null) return null - return ( - ((j.input_tokens ?? 0) * Number(p.input_price_per_1m.toString())) / 1_000_000 + - ((j.output_tokens ?? 0) * Number(p.output_price_per_1m.toString())) / 1_000_000 + - ((j.cache_read_tokens ?? 0) * Number(p.cache_read_price_per_1m.toString())) / 1_000_000 + - ((j.cache_write_tokens ?? 0) * Number(p.cache_write_price_per_1m.toString())) / 1_000_000 - ) -} - -export function mapJob(j: RawJob, priceMap: Map<string, PriceRow>): JobWithRelations { - return { - id: j.id, - kind: j.kind, - status: j.status, - taskCode: j.task?.code ?? null, - taskTitle: j.task?.title ?? null, - ideaCode: j.idea?.code ?? null, - ideaTitle: j.idea?.title ?? null, - sprintGoal: j.sprint_run?.sprint.sprint_goal ?? null, - sprintCode: j.sprint_run?.sprint.code ?? null, - productName: j.product.name, - productCode: j.product.code ?? null, - storyCode: j.task?.story?.code ?? null, - pbiCode: j.task?.story?.pbi?.code ?? null, - modelId: j.model_id, - inputTokens: j.input_tokens, - outputTokens: j.output_tokens, - cacheReadTokens: j.cache_read_tokens, - cacheWriteTokens: j.cache_write_tokens, - costUsd: computeCost(j, priceMap), - branch: j.branch, - prUrl: j.pr_url, - error: j.error, - summary: j.summary, - description: pickDescription(j), - verifyResult: j.verify_result, - startedAt: j.started_at, - finishedAt: j.finished_at, - createdAt: j.created_at, - sprintRunId: j.sprint_run_id, - } -} diff --git a/lib/jobs-time-filter.ts b/lib/jobs-time-filter.ts deleted file mode 100644 index 405248e..0000000 --- a/lib/jobs-time-filter.ts +++ /dev/null @@ -1,21 +0,0 @@ -export const JOBS_TIME_FILTER_VALUES = ['1h', '24h', 'all'] as const; - -export type JobsTimeFilter = (typeof JOBS_TIME_FILTER_VALUES)[number]; - -export const DEFAULT_JOBS_TIME_FILTER: JobsTimeFilter = 'all'; - -const WINDOW_MS: Record<'1h' | '24h', number> = { - '1h': 60 * 60 * 1000, - '24h': 24 * 60 * 60 * 1000, -}; - -export function isWithinTimeWindow( - createdAt: Date | string, - filter: JobsTimeFilter, - now: number = Date.now(), -): boolean { - if (filter === 'all') return true; - const ts = new Date(createdAt).getTime(); - if (Number.isNaN(ts)) return true; - return ts >= now - WINDOW_MS[filter]; -} diff --git a/lib/manual-server.ts b/lib/manual-server.ts deleted file mode 100644 index aff4dca..0000000 --- a/lib/manual-server.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { MANUAL_TOC, type ManualEntry } from './manual.generated' - -export type { ManualEntry } from './manual.generated' - -export type ManualChapter = { - entry: ManualEntry - body: string -} - -export function getManualToc(): readonly ManualEntry[] { - return MANUAL_TOC -} - -function slugMatches(a: readonly string[], b: readonly string[]): boolean { - if (a.length !== b.length) return false - for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false - return true -} - -export function findManualEntry(slug: readonly string[]): ManualEntry | null { - return MANUAL_TOC.find((e) => slugMatches(e.slug, slug)) ?? null -} - -export function getManualChapter(slug: readonly string[]): ManualChapter | null { - const entry = findManualEntry(slug) - if (!entry) return null - return { entry, body: entry.markdown } -} diff --git a/lib/manual.generated.ts b/lib/manual.generated.ts deleted file mode 100644 index b69294e..0000000 --- a/lib/manual.generated.ts +++ /dev/null @@ -1,865 +0,0 @@ -// AUTO-GENERATED by scripts/build-manual.mjs. Do not edit by hand. -// Run `npm run manual:build` to regenerate. - -export type ManualEntry = { - slug: readonly string[] - title: string - description: string - filePath: string - markdown: string -} - -export const MANUAL_TOC: readonly ManualEntry[] = [ - { - slug: [] as const, - title: 'Scrum4Me Developer Manual', - description: 'Welcome. This manual is the **map** of Scrum4Me — a guided tour through the moving parts of the project. It is written for a new human contributor who needs to understand how the pieces fit together before diving into the authoritative reference docs (the runbooks, ADRs, and patterns under [`docs/`](../INDEX.md)).', - filePath: 'docs/manual/index.md', - markdown: `# Scrum4Me Developer Manual - -Welcome. This manual is the **map** of Scrum4Me — a guided tour through the moving parts of the project. It is written for a new human contributor who needs to understand how the pieces fit together before diving into the authoritative reference docs (the runbooks, ADRs, and patterns under [\`docs/\`](../INDEX.md)). - -> **The manual is the map. The runbooks are the territory.** -> When two sources disagree, trust the runbook or ADR linked from this manual. - -## Audience - -- **New human contributors** picking up the project for the first time. -- **Returning contributors** who want a quick refresher on how a specific subsystem (statuses, git, MCP, Docker) fits into the whole. -- **Not for**: AI agents — they should follow [\`CLAUDE.md\`](../../CLAUDE.md) and the agent-specific runbooks under [\`docs/runbooks/\`](../runbooks/). - -## How to read this manual - -| You want to… | Read | -|---|---| -| …get the elevator pitch and project structure | [01 — Overview](./01-overview.md) | -| …understand how a PBI/Story/Task moves through its lifecycle | [02 — Statuses & Transitions](./02-statuses-and-transitions.md) | -| …know when to branch, commit, push, and open a PR | [03 — Git Workflow](./03-git-workflow.md) | -| …see how Claude Code drives stories via the MCP server | [04 — MCP Integration](./04-mcp-integration.md) | -| …run the worker container locally or understand the deploy topology | [05 — Docker](./05-docker.md) | -| …diagnose an error code, stuck job, or weird state | [06 — Troubleshooting](./06-troubleshooting.md) | - -A linear read takes about 30 minutes. As a lookup reference, jump straight to a chapter — each one stands alone. - -## Conventions - -- **Cross-references** use relative links (\`../runbooks/...\`) so they work both in GitHub and inside the in-app \`/manual\` viewer. -- **Callouts** use blockquotes prefixed with a label: \`> **Note:**\`, \`> **Warning:**\`, \`> **Hardstop:**\` (a non-negotiable rule from [\`CLAUDE.md\`](../../CLAUDE.md)). -- **Code blocks** show shell commands with no \`$\` prefix, so they're copy-pasteable. -- **State diagrams** use Mermaid \`stateDiagram-v2\`; they render in GitHub and in the in-app viewer. -- **Status labels** are written in \`UPPER_SNAKE\` when referring to the database value and \`lowercase\` when referring to the API representation — see [02 — Statuses & Transitions](./02-statuses-and-transitions.md#db-vs-api-mapping) for the contract. - -## In-app rendering - -Every chapter in this manual is also browsable inside the running Scrum4Me app at \`/manual\`. The in-app sidebar mirrors this index, and Mermaid diagrams render in place. The markdown files under \`docs/manual/\` are the **source of truth** — the in-app page reads them at build time via the \`scripts/build-manual.mjs\` generator. - -## What this manual does **not** cover - -- **REST API reference** → [\`docs/api/rest-contract.md\`](../api/rest-contract.md) -- **Component & dialog specs** → [\`docs/specs/dialogs/\`](../specs/dialogs/) -- **Architecture deep-dives** → [\`docs/architecture.md\`](../architecture.md) breadcrumb -- **Decision rationale** → [\`docs/adr/\`](../adr/) -- **Implementation patterns** → [\`docs/patterns/\`](../patterns/) -- **AI-agent instructions** → [\`CLAUDE.md\`](../../CLAUDE.md) and [\`docs/runbooks/mcp-integration.md\`](../runbooks/mcp-integration.md) - -## Table of contents - -1. [Overview](./01-overview.md) — what Scrum4Me is, the entity hierarchy, the stack, repository layout -2. [Statuses & Transitions](./02-statuses-and-transitions.md) — state machines for every entity -3. [Git Workflow](./03-git-workflow.md) — branching, commits, PRs, deploy controls -4. [MCP Integration](./04-mcp-integration.md) — the agent loop, idea jobs, the Q&A channel -5. [Docker](./05-docker.md) — worker container, local dev, scrum4me-docker -6. [Troubleshooting](./06-troubleshooting.md) — error codes, stuck jobs, recovery procedures -`, - }, - { - slug: ['01-overview'] as const, - title: 'Overview', - description: 'Scrum4Me is a **desktop-first fullstack web app for solo developers and small Scrum teams** who manage multiple software projects in parallel. It models the Scrum hierarchy explicitly (Product → PBI → Story → Task), supports Sprints with split-screen drag-and-drop planning, and integrates Claude Code as an automated implementation worker — every result the agent produces is logged back into the originating story.', - filePath: 'docs/manual/01-overview.md', - markdown: `# 01 — Overview - -## What is Scrum4Me? - -Scrum4Me is a **desktop-first fullstack web app for solo developers and small Scrum teams** who manage multiple software projects in parallel. It models the Scrum hierarchy explicitly (Product → PBI → Story → Task), supports Sprints with split-screen drag-and-drop planning, and integrates Claude Code as an automated implementation worker — every result the agent produces is logged back into the originating story. - -The app is deployable to **Vercel + Neon** (default) and can run **fully local** via the worker container. A built-in demo user has read-only access; Product Owners add Developers by username, and those Developers gain write access to that product's stories, tasks, and sprints. - -## Entity hierarchy - -\`\`\`mermaid -flowchart TB - Product["Product<br/>(per repo)"] - Idea["Idea<br/>(pre-PBI staging)"] - PBI["PBI<br/>(Product Backlog Item)"] - Story["Story"] - Task["Task"] - Sprint["Sprint<br/>(cross-cutting)"] - - Product --> Idea - Idea -.->|"AI-grilled & planned"| PBI - Product --> PBI - PBI --> Story - Story --> Task - Sprint -.->|"contains stories<br/>denormalised on tasks"| Story - Sprint -.-> Task -\`\`\` - -- **Product** — one row per repo. \`repo_url\`, \`definition_of_done\`, members. -- **Idea** — pre-PBI staging entity introduced in M12. Goes through \`IDEA_GRILL\` (AI Q&A loop) and \`IDEA_MAKE_PLAN\` jobs to produce a structured plan that can be turned into a PBI tree. -- **PBI** — a Product Backlog Item. Has \`priority\` (1–4) and \`sort_order\` (float, see [\`docs/patterns/sort-order.md\`](../patterns/sort-order.md)). -- **Story** — a unit of value under a PBI; has acceptance criteria. Lives in the backlog (\`OPEN\`) until added to a sprint. -- **Task** — the smallest unit; has an \`implementation_plan\` consumed by the Claude worker. \`sprint_id\` is denormalised from the parent story for query efficiency. -- **Sprint** — cross-cutting time-box. Stories are added to a sprint; tasks inherit \`sprint_id\`. Sprint execution has two modes: \`PER_TASK\` and \`SPRINT_BATCH\` — see [\`docs/architecture/sprint-execution-modes.md\`](../architecture/sprint-execution-modes.md). - -For status lifecycles of each entity, see [02 — Statuses & Transitions](./02-statuses-and-transitions.md). - -## Stack - -| Layer | Technology | -|---|---| -| Framework | Next.js 16 (App Router) + React 19 | -| Language | TypeScript (strict) | -| Styling | Tailwind CSS + shadcn/ui + Material Design 3 tokens via [\`app/styles/theme.css\`](../../app/styles/theme.css) | -| Client state | Zustand + dnd-kit | -| Database | Prisma v7 + PostgreSQL (Neon) | -| Auth | iron-session + bcryptjs | -| Utilities | Zod, Sonner, Sharp, Vercel Analytics | -| Hosting | Vercel (app), Neon (DB), Mac/NAS Docker (worker) | - -For the rationale behind each choice and the technologies we explicitly **don't** use, see [\`docs/architecture/overview.md\`](../architecture/overview.md). - -## Repository layout - -\`\`\` -Scrum4Me/ -├── app/ # Next.js App Router routes -│ ├── (app)/ # authenticated desktop UI -│ ├── (auth)/ # login, register, demo -│ ├── (mobile)/ # /m/* mobile shell (3 screens) -│ ├── api/ # REST route handlers (Claude integration) -│ └── styles/ # MD3 token CSS -├── components/ # shared UI components -├── lib/ # server/client utilities -│ └── task-status.ts # the ONLY place DB↔API enum mapping happens -├── prisma/ # schema + migrations -├── docs/ # this manual + ADRs, runbooks, patterns, specs -└── scripts/ # codegen, seeders, link checkers -\`\`\` - -The \`*-server.ts\` filename suffix marks server-only modules (DB, Node APIs). They must never be imported into a client component — see the hardstop in [\`CLAUDE.md\`](../../CLAUDE.md#hardstop-regels). - -For a deeper structural breakdown including stores, realtime channels, and the job queue, see [\`docs/architecture/project-structure.md\`](../architecture/project-structure.md). - -## Glossary refresh - -A few terms used throughout this manual that often differ from "generic Scrum" usage: - -- **PBI** — Product Backlog Item. Not "Feature" or "Epic". -- **Story** — A unit of work under a PBI. Not "Ticket" or "Issue". -- **Sprint Goal** — The narrative for a sprint. Not "Objective". -- **Worker** — A Claude Code agent claiming jobs from the Scrum4Me queue (M13). -- **Demo user** — A read-only built-in user; writes return \`403\`. See [\`docs/adr/0006-demo-user-three-layer-policy.md\`](../adr/0006-demo-user-three-layer-policy.md). -- **Idea** — Pre-PBI staging artefact (M12). Has its own state machine; see [02](./02-statuses-and-transitions.md#idea). - -The complete glossary lives at [\`docs/glossary.md\`](../glossary.md). - -## What's next - -→ [02 — Statuses & Transitions](./02-statuses-and-transitions.md) covers how each entity moves through its lifecycle, with state-machine diagrams. -`, - }, - { - slug: ['02-statuses-and-transitions'] as const, - title: 'Statuses & Transitions', - description: 'Every persistent entity in Scrum4Me has an explicit status enum. This chapter documents them all, with state-machine diagrams showing allowed transitions, the trigger for each transition (user action vs system / job-driven), and the side effects.', - filePath: 'docs/manual/02-statuses-and-transitions.md', - markdown: `# 02 — Statuses & Transitions - -Every persistent entity in Scrum4Me has an explicit status enum. This chapter documents them all, with state-machine diagrams showing allowed transitions, the trigger for each transition (user action vs system / job-driven), and the side effects. - -> **Hardstop:** the database stores enums in \`UPPER_SNAKE\`; the REST API exposes them in \`lowercase\`. Conversion happens **only** through [\`lib/task-status.ts\`](../../lib/task-status.ts) — never call \`.toLowerCase()\` or \`.toUpperCase()\` directly. See the [DB vs API mapping](#db-vs-api-mapping) section at the end. - -## Quick reference - -| Entity | Source enum | Statuses | -|---|---|---| -| [PBI](#pbi) | \`PbiStatus\` | \`READY\`, \`BLOCKED\`, \`DONE\`, \`FAILED\` | -| [Story](#story) | \`StoryStatus\` | \`OPEN\`, \`IN_SPRINT\`, \`DONE\`, \`FAILED\` | -| [Task](#task) | \`TaskStatus\` | \`TO_DO\`, \`IN_PROGRESS\`, \`REVIEW\`, \`DONE\`, \`FAILED\` | -| [Sprint](#sprint) | \`SprintStatus\` | \`ACTIVE\`, \`COMPLETED\`, \`FAILED\` | -| [SprintRun](#sprintrun) | \`SprintRunStatus\` | \`QUEUED\`, \`RUNNING\`, \`PAUSED\`, \`DONE\`, \`FAILED\`, \`CANCELLED\` | -| [ClaudeJob](#claudejob) | \`ClaudeJobStatus\` | \`QUEUED\`, \`CLAIMED\`, \`RUNNING\`, \`DONE\`, \`FAILED\`, \`CANCELLED\`, \`SKIPPED\` | -| [Idea](#idea) | \`IdeaStatus\` | \`DRAFT\`, \`GRILLING\`, \`GRILL_FAILED\`, \`GRILLED\`, \`PLANNING\`, \`PLAN_FAILED\`, \`PLAN_READY\`, \`PLANNED\` | - -## PBI - -A **Product Backlog Item** holds one or more stories. Its status reflects whether the PBI as a whole is ready to be picked up, blocked on something external, finished, or written off. - -\`\`\`mermaid -stateDiagram-v2 - [*] --> READY: create_pbi - READY --> BLOCKED: user marks blocked - BLOCKED --> READY: user unblocks - READY --> DONE: all stories DONE - READY --> FAILED: user gives up - BLOCKED --> FAILED: user gives up - DONE --> [*] - FAILED --> [*] -\`\`\` - -| Transition | Trigger | Side effect | -|---|---|---| -| \`* → READY\` | \`create_pbi\` MCP tool or PBI dialog | New PBI lands in \`priority\` group, \`sort_order = last + 1\` | -| \`READY ↔ BLOCKED\` | User toggles via PBI dialog | None besides log entry | -| \`READY → DONE\` | All child stories reach \`DONE\` | Auto-promotion (see [ST-1109 plan](../plans/ST-1109-pbi-status.md)) | -| \`* → FAILED\` | User gives up on the PBI | Stories may remain \`OPEN\`; PBI is filtered out of active boards | - -## Story - -A **Story** sits under a PBI. It moves out of the backlog when added to a Sprint, and reaches \`DONE\` when its tasks are complete and the implementation is verified. - -\`\`\`mermaid -stateDiagram-v2 - [*] --> OPEN: create_story - OPEN --> IN_SPRINT: added to sprint - IN_SPRINT --> OPEN: removed from sprint - IN_SPRINT --> DONE: all tasks DONE + verify passes - IN_SPRINT --> FAILED: verify fails / abandoned - DONE --> [*] - FAILED --> [*] -\`\`\` - -| Transition | Trigger | Side effect | -|---|---|---| -| \`* → OPEN\` | \`create_story\` MCP tool or Story dialog | Lives in product backlog | -| \`OPEN ↔ IN_SPRINT\` | Drag onto Sprint board, or sprint-removal | Tasks denormalise \`sprint_id\` | -| \`IN_SPRINT → DONE\` | Story completion via MCP / UI; auto-PR flow may trigger | Auto-PR flow ([\`runbooks/auto-pr-flow.md\`](../runbooks/auto-pr-flow.md)) may run; PBI is re-evaluated for \`READY → DONE\` | -| \`IN_SPRINT → FAILED\` | Verification failure or manual abandon | Logged in story log | - -## Task - -A **Task** is the smallest unit. The Claude worker mainly reads \`implementation_plan\` and writes status transitions through MCP tools. - -\`\`\`mermaid -stateDiagram-v2 - [*] --> TO_DO: create_task - TO_DO --> IN_PROGRESS: agent claims / user starts - IN_PROGRESS --> REVIEW: implementation done, awaiting verify - REVIEW --> DONE: verify passes - REVIEW --> IN_PROGRESS: verify fails, retry - IN_PROGRESS --> FAILED: unrecoverable error - REVIEW --> FAILED: gives up after retries - DONE --> [*] - FAILED --> [*] -\`\`\` - -| Transition | Trigger | Side effect | -|---|---|---| -| \`* → TO_DO\` | \`create_task\` MCP tool / Task dialog | Inherits \`sprint_id\` from parent story | -| \`TO_DO → IN_PROGRESS\` | Worker claim or user starts | Story may auto-promote to \`IN_SPRINT\` | -| \`IN_PROGRESS → REVIEW\` | Implementation logged | Optional \`verify_task_against_plan\` runs | -| \`REVIEW → DONE\` | Verify passes / human accepts | When all sibling tasks are \`DONE\`, the parent story is eligible for \`DONE\` | -| \`* → FAILED\` | Unrecoverable error or human marks failed | Story may auto-promote to \`FAILED\` | - -The MCP tool is \`update_task_status({ task_id, status })\` accepting lowercase API values: \`todo | in_progress | review | done | failed\`. - -## Sprint - -A **Sprint** is the cross-cutting time-box. Its status tracks the overall sprint container, not the agent execution. - -\`\`\`mermaid -stateDiagram-v2 - [*] --> ACTIVE: create sprint - ACTIVE --> COMPLETED: user closes sprint - ACTIVE --> FAILED: user abandons sprint - COMPLETED --> [*] - FAILED --> [*] -\`\`\` - -For execution semantics (PER_TASK vs SPRINT_BATCH) see [\`docs/architecture/sprint-execution-modes.md\`](../architecture/sprint-execution-modes.md). - -## SprintRun - -A **SprintRun** is one execution attempt of a sprint by the agent worker. Multiple runs may exist over a sprint's lifetime (if a run is cancelled or paused and restarted). - -\`\`\`mermaid -stateDiagram-v2 - [*] --> QUEUED: trigger sprint run - QUEUED --> RUNNING: worker claims - RUNNING --> PAUSED: pause requested - PAUSED --> RUNNING: resume - RUNNING --> DONE: all tasks done - RUNNING --> FAILED: unrecoverable - QUEUED --> CANCELLED: user cancels - RUNNING --> CANCELLED: user cancels - PAUSED --> CANCELLED: user cancels - DONE --> [*] - FAILED --> [*] - CANCELLED --> [*] -\`\`\` - -The cascade rules (which task transitions automatically promote the SprintRun) are described in [\`docs/plans/sprint-pr-worktree-state-machines.md\`](../plans/sprint-pr-worktree-state-machines.md). When calling \`update_task_status\` from inside a sprint run, pass the optional \`sprint_run_id\` so the server can validate ownership and propagate cascades. - -## ClaudeJob - -The agent **job queue** (M13). Each enqueued unit of work is a \`ClaudeJob\` with a \`kind\` (\`TASK_IMPLEMENTATION\`, \`IDEA_GRILL\`, \`IDEA_MAKE_PLAN\`, \`PLAN_CHAT\`, \`SPRINT_IMPLEMENTATION\`). - -\`\`\`mermaid -stateDiagram-v2 - [*] --> QUEUED: enqueue - QUEUED --> CLAIMED: wait_for_job (FOR UPDATE SKIP LOCKED) - CLAIMED --> RUNNING: worker starts - RUNNING --> DONE: update_job_status('done') - RUNNING --> FAILED: update_job_status('failed') - QUEUED --> CANCELLED: user cancels - CLAIMED --> QUEUED: stale (>30min) - QUEUED --> SKIPPED: superseded - DONE --> [*] - FAILED --> [*] - CANCELLED --> [*] - SKIPPED --> [*] -\`\`\` - -| Transition | Trigger | Side effect | -|---|---|---| -| \`QUEUED → CLAIMED\` | \`wait_for_job\` atomically claims | Bearer token is bound to the job (\`claimed_by_token_id\`) | -| \`CLAIMED → QUEUED\` | Stale claim (>30 min) | Auto-requeue on next \`wait_for_job\` | -| \`RUNNING → DONE\` | \`update_job_status('done')\` | Optional token-cost telemetry stored on the row | -| \`RUNNING → FAILED\` | \`update_job_status('failed')\` | For \`IDEA_GRILL\`/\`IDEA_MAKE_PLAN\`, idea status auto-rolls to \`GRILL_FAILED\` / \`PLAN_FAILED\` | - -For idempotency rules and recovery procedures see [\`docs/runbooks/worker-idempotency.md\`](../runbooks/worker-idempotency.md). - -## Idea - -The **Idea** entity (M12) is a pre-PBI staging area. It goes through two AI-driven phases: a **grill** (Q&A loop with the user to clarify the idea) and a **plan** (single-pass output of a structured PBI tree). Failures are explicit terminal-ish states that allow retry. - -\`\`\`mermaid -stateDiagram-v2 - [*] --> DRAFT: create idea - DRAFT --> GRILLING: enqueue IDEA_GRILL - GRILLING --> GRILLED: update_idea_grill_md - GRILLING --> GRILL_FAILED: job failed - GRILL_FAILED --> GRILLING: retry - GRILLED --> PLANNING: enqueue IDEA_MAKE_PLAN - PLANNING --> PLAN_READY: update_idea_plan_md (parse ok) - PLANNING --> PLAN_FAILED: parsePlanMd rejected - PLAN_FAILED --> PLANNING: retry - PLAN_READY --> PLANNED: PBI tree created - PLANNED --> [*] -\`\`\` - -| Transition | Trigger | Side effect | -|---|---|---| -| \`DRAFT → GRILLING\` | User clicks "Grill" | Enqueues \`IDEA_GRILL\` job; worker reads \`prompt_text\` + \`idea.grill_md\` | -| \`GRILLING → GRILLED\` | \`update_idea_grill_md\` | Logs \`IdeaLog{GRILL_RESULT}\` | -| \`* → GRILL_FAILED\` | \`update_job_status('failed')\` for \`IDEA_GRILL\` | Idea remains usable; user can retry | -| \`GRILLED → PLANNING\` | User clicks "Make plan" | Enqueues \`IDEA_MAKE_PLAN\`; worker outputs strict YAML-frontmatter | -| \`PLANNING → PLAN_READY\` | \`update_idea_plan_md\` parse ok | Logs \`IdeaLog{PLAN_RESULT}\` | -| \`PLANNING → PLAN_FAILED\` | \`parsePlanMd\` rejected | Logs \`IdeaLog{JOB_EVENT, errors}\` | -| \`PLAN_READY → PLANNED\` | PBI tree generated from plan | Idea is archived; PBI/Story/Task tree appears in the backlog | - -For the full Idea workflow, prompts, and \`prompt_text\` contents, see [\`docs/plans/M12-ideas.md\`](../plans/M12-ideas.md). - -## DB vs API mapping - -> **Hardstop:** never bypass [\`lib/task-status.ts\`](../../lib/task-status.ts). - -The database stores enums in \`UPPER_SNAKE\` (\`TO_DO\`, \`IN_PROGRESS\`, \`IN_SPRINT\`, …) because Prisma + PostgreSQL prefer that convention. The REST API exposes them in \`lowercase\` (\`todo\`, \`in_progress\`, \`in_sprint\`, …) because that's the convention HTTP consumers expect. - -The two are mapped **only** through the helpers in [\`lib/task-status.ts\`](../../lib/task-status.ts): - -\`\`\`ts -taskStatusToApi(status) // DB → API -taskStatusFromApi(input) // API → DB (returns null on bad input) -storyStatusToApi(status) -storyStatusFromApi(input) -pbiStatusToApi(status) -pbiStatusFromApi(input) -sprintStatusToApi(status) -sprintStatusFromApi(input) -sprintRunStatusToApi(status) -sprintRunStatusFromApi(input) -\`\`\` - -Bad input on the inbound side (\`*FromApi\`) returns \`null\` — the route handler converts that to a \`422\` Zod-style error. See [\`docs/adr/0004-status-enum-mapping.md\`](../adr/0004-status-enum-mapping.md) for the rationale. - -## What's next - -→ [03 — Git Workflow](./03-git-workflow.md) covers branching, commits, and the cost-driven PR rules. -`, - }, - { - slug: ['03-git-workflow'] as const, - title: 'Git Workflow', - description: 'The Scrum4Me git workflow is shaped by two pressures that don\'t usually appear together:', - filePath: 'docs/manual/03-git-workflow.md', - markdown: `# 03 — Git Workflow - -The Scrum4Me git workflow is shaped by two pressures that don't usually appear together: - -1. An **AI agent** that can produce many commits per hour without human review, -2. A **Vercel Hobby plan** that meters preview deployments and bills for them. - -These two together drive a workflow that looks unusual compared to "feature-branch + PR-per-story". This chapter explains the *why*; the authoritative *how* lives in the runbooks linked at the bottom. - -## The five guiding rules - -### 1. One branch per milestone, not per story - -A milestone (e.g. \`M10-qr-login\`) groups multiple stories that ship together. The agent runs through them on a single branch named \`feat/M{N}-{slug}\` (or \`feat/ST-XXX-{slug}\` for one-off stories without a milestone). All commits accumulate on that branch. - -> **Why?** Every push to a feature branch triggers a Vercel preview build. Pushing per story would multiply the build cost without producing more reviewable units of work — the user reviews the milestone, not the story. - -See [\`docs/adr/0003-one-branch-per-milestone.md\`](../adr/0003-one-branch-per-milestone.md) for the full rationale. - -### 2. Commit per layer, not per task - -A single task can touch the database, the API, and the UI. Each of those layers gets its own commit. The pattern: - -\`\`\` -feat(ST-XXX): add field X to Prisma schema # DB -feat(ST-XXX): add Y endpoint accepting X # API -feat(ST-XXX): wire X into the editor component # UI -chore(ST-XXX): configure sharp for X processing # config -docs(ST-XXX): document the X feature # docs -\`\`\` - -> **Why?** Reviewers and \`git bisect\` both benefit when one commit can be reverted without touching unrelated layers. A \`feat: add profile system\` mega-commit is an antipattern. - -### 3. Push only after the user has tested - -Commits accumulate **locally** until the milestone is functionally complete and the user has confirmed it works. Then — and only then — \`git push\` and \`gh pr create\`. - -> **Why?** Same cost reason as rule 1. Mid-milestone "save points" should be local tags or \`git stash\`, not pushes. Some exceptions exist (planning-only PRs, emergency hotfixes); they're enumerated in [\`branch-and-commit.md\`](../runbooks/branch-and-commit.md#uitzonderingen-op-de-push-regel). - -### 4. One PR per batch → one preview build - -When the worker runs through a queue of jobs, the entire run produces **one** PR with one commit per task. No interim pushes, no force-pushes to clean up history, no PR-per-story splits. - -The end-to-end verification — that one batch produces exactly one Vercel deployment — is in [\`branch-and-commit.md\`](../runbooks/branch-and-commit.md) (see the *End-to-end verificatie* section). - -### 5. Auto-PR flow at the end - -Once a story reaches \`DONE\`, the auto-PR flow takes over: it pushes the branch, opens a PR, waits for the scope to be complete, waits for checks, and merges. The contract for "scope complete" and the path-filter / label rules that decide whether a deploy actually runs are split between two runbooks: - -- **End-to-end pipeline**: [\`docs/runbooks/auto-pr-flow.md\`](../runbooks/auto-pr-flow.md) -- **Selective deploy controls** (\`skip-deploy\` label, path-filter for \`app/\`/\`components/\`/\`lib/\`): [\`docs/runbooks/deploy-control.md\`](../runbooks/deploy-control.md) - -## Commit message format - -\`\`\` -<type>(ST-XXX): short description -\`\`\` - -Where \`<type>\` is one of \`feat\`, \`fix\`, \`chore\`, \`docs\`. The story code in parentheses links the commit back to the Scrum4Me MCP entity. - -For PBI-level work (no single story), use the PBI code: \`docs(PBI-58): scaffold developer manual\`. - -## Merge conflicts - -| Scenario | Conflict? | Mitigation | -|---|---|---| -| Multiple tasks on the same batch branch | No — they stack linearly on one branch | None needed | -| Two parallel batches touching the same files | Yes, possible | Serialise batches via the MCP \`get_claude_context\` flow (one story at a time per agent), or rebase before push | -| Long-lived branch drifting from \`main\` | Yes, possible | \`git fetch origin main && git rebase origin/main\` before \`gh pr create\` | - -\`git push --force\` to "wipe" earlier preview builds is forbidden — it costs the same build again on recreation, defeating the purpose of the cost-control rules. - -## When **not** to follow the strict rules - -When the Vercel account moves to Pro (or another billing tier without per-build cost), this workflow can revert to the more conventional "branch + PR per story". When that happens, update the rule in [\`branch-and-commit.md\`](../runbooks/branch-and-commit.md) and log the change in [\`docs/decisions/agent-instructions-history.md\`](../decisions/agent-instructions-history.md). - -## Deep links - -| Topic | Authoritative source | -|---|---| -| Branch & commit rules (full normative spec) | [\`docs/runbooks/branch-and-commit.md\`](../runbooks/branch-and-commit.md) | -| Auto-PR flow (story-DONE → merged-PR pipeline) | [\`docs/runbooks/auto-pr-flow.md\`](../runbooks/auto-pr-flow.md) | -| Deploy controls (labels, path-filter) | [\`docs/runbooks/deploy-control.md\`](../runbooks/deploy-control.md) | -| Vercel deployment specifics | [\`docs/runbooks/deploy-vercel.md\`](../runbooks/deploy-vercel.md) | -| Decision rationale (one-branch-per-milestone) | [\`docs/adr/0003-one-branch-per-milestone.md\`](../adr/0003-one-branch-per-milestone.md) | -| Worker idempotency & job-status protocol | [\`docs/runbooks/worker-idempotency.md\`](../runbooks/worker-idempotency.md) | - -## What's next - -→ [04 — MCP Integration](./04-mcp-integration.md) covers how the Claude agent drives this workflow from the queue side. -`, - }, - { - slug: ['04-mcp-integration'] as const, - title: 'MCP Integration', - description: 'Scrum4Me exposes its REST API as native Claude Code tools through a dedicated **MCP server** living in [`madhura68/scrum4me-mcp`](https://github.com/madhura68/scrum4me-mcp). Schemas are shared via a git submodule (`vendor/scrum4me`) so there\'s exactly one definition of every type. From the agent\'s perspective, Scrum4Me looks like a set of native tools prefixed `mcp__scrum4me__*`.', - filePath: 'docs/manual/04-mcp-integration.md', - markdown: `# 04 — MCP Integration - -Scrum4Me exposes its REST API as native Claude Code tools through a dedicated **MCP server** living in [\`madhura68/scrum4me-mcp\`](https://github.com/madhura68/scrum4me-mcp). Schemas are shared via a git submodule (\`vendor/scrum4me\`) so there's exactly one definition of every type. From the agent's perspective, Scrum4Me looks like a set of native tools prefixed \`mcp__scrum4me__*\`. - -This chapter is the **onboarding tour**. The full tool reference (all 18 tools, their parameters, and edge cases) is in [\`docs/runbooks/mcp-integration.md\`](../runbooks/mcp-integration.md). - -## Three ways the agent works - -| Mode | Triggered by | Loop | -|---|---|---| -| **Track A — MCP-driven** | User says *"implement the next story"* | \`get_claude_context\` → execute tasks → \`update_task_status\` → commit per layer → repeat until queue empty → push + PR | -| **Track B — Manual** | User describes a one-off change in chat | Read pattern + styling → edit → verify → wait for \`commit it\` → commit | -| **Worker — Queue-driven** | Background worker container running on a Mac/NAS | \`wait_for_job\` (blocks ≤600s) → switch on \`kind\` → execute → \`update_job_status\` → loop forever | - -CLAUDE.md describes Track A and Track B; this manual focuses on the **Worker** mode because it's the most novel and the most likely to surprise a new contributor reading server logs. - -## A typical Track A run - -\`\`\`mermaid -sequenceDiagram - participant U as User - participant C as Claude - participant M as MCP server - participant DB as Postgres - - U->>C: "implement the next story" - C->>M: get_claude_context(product_id) - M->>DB: SELECT product, sprint, next story, tasks - M-->>C: { story, tasks[], pbi, sprint } - loop per task in sort_order - C->>M: update_task_status(task_id, 'in_progress') - C->>C: read pattern + styling, edit files - C->>M: log_implementation(story_id, content) - C->>M: update_task_status(task_id, 'review') - C->>M: log_test_result(story_id, 'PASSED') - C->>M: update_task_status(task_id, 'done') - end - C->>U: "milestone ready for your test" - U->>C: "looks good, push it" - C->>C: git push + gh pr create -\`\`\` - -The contract every step relies on: - -- All inputs are **lowercase API enums** (\`'in_progress'\`, never \`'IN_PROGRESS'\`); the MCP server applies [\`lib/task-status.ts\`](../../lib/task-status.ts) under the hood. -- Status writes are **forbidden for demo accounts** — they return \`403\`. See [02 — Statuses](./02-statuses-and-transitions.md#db-vs-api-mapping) and [\`docs/adr/0006-demo-user-three-layer-policy.md\`](../adr/0006-demo-user-three-layer-policy.md). -- Bearer tokens are bound to a product. \`list_products\` returns only what the token can see; \`get_claude_context\` is product-scoped. - -## Idea jobs vs task implementation - -The worker \`wait_for_job\` returns a payload with a \`kind\` discriminator. The agent must switch on it: - -| \`kind\` | Behaviour | -|---|---| -| \`TASK_IMPLEMENTATION\` | Default. Execute the \`implementation_plan\`, follow the [git workflow](./03-git-workflow.md), end with \`update_job_status('done')\`. | -| \`IDEA_GRILL\` | Read embedded \`prompt_text\` + existing \`idea.grill_md\`. Iterate with \`ask_user_question\` / \`get_question_answer\`. End with \`update_idea_grill_md(markdown)\`. | -| \`IDEA_MAKE_PLAN\` | Read \`prompt_text\` + \`idea.grill_md\`. **Do not ask questions** — single-pass output in strict YAML-frontmatter. End with \`update_idea_plan_md(markdown)\`. Server-side parser may reject → \`PLAN_FAILED\`. | -| \`PLAN_CHAT\` | Conversational refinement loop on an existing plan (M12+). | -| \`SPRINT_IMPLEMENTATION\` | Sprint-level run that cascades through every task; \`update_task_status\` calls must include the \`sprint_run_id\`. | - -For the full Idea state machine (DRAFT → GRILLING → … → PLANNED) see [02 — Statuses & Transitions § Idea](./02-statuses-and-transitions.md#idea). - -## The Q&A channel - -When Claude needs a human decision mid-run, it doesn't block silently — it posts a question through the MCP and either polls or returns control: - -\`\`\`mermaid -sequenceDiagram - participant C as Claude - participant M as MCP - participant DB as Postgres - participant U as User (NavBar bell) - C->>M: ask_user_question({ story_id, question, wait_seconds: 600 }) - M->>DB: INSERT user_question; NOTIFY user_question_created - DB-->>U: SSE event → bell pulses - U->>M: POST /api/questions/:id/answer - M->>DB: UPDATE user_question; NOTIFY user_question_answered - DB-->>C: ask_user_question returns { answer } - C->>C: continue execution -\`\`\` - -Key facts: - -- \`wait_seconds\` is capped at 600. If the user doesn't answer in time, \`ask_user_question\` returns with status \`pending\`; Claude can resume later via \`get_question_answer(question_id)\`. -- Idea questions (\`{ idea_id }\` instead of \`{ story_id }\`) are **user-private** — they bypass \`productAccessFilter\`, so collaborators don't see them. -- A question can be cancelled by the asker via \`cancel_question\`. - -The persistent design (table + \`LISTEN/NOTIFY\`) is documented in [\`docs/architecture/claude-question-channel.md\`](../architecture/claude-question-channel.md). - -## The worker's pre-flight quota check - -The worker doesn't blindly call \`wait_for_job\`. Each iteration it first checks Anthropic API quota via \`bin/worker-quota-probe.sh\` so it doesn't burn a 10-minute block on a queue it can't actually process. The full algorithm — settings, \`worker_heartbeat\` SSE event, sleep-until-reset — is in [\`docs/runbooks/mcp-integration.md\`](../runbooks/mcp-integration.md#pre-flight-quota-check-m13). The Docker chapter ([05](./05-docker.md#quota-probe)) shows how to test it locally. - -## Schema-drift watchdog - -If Scrum4Me's Prisma schema changes but \`scrum4me-mcp\` isn't synced, the MCP server will fail at runtime — not at deploy. To prevent that, a remote agent runs every Monday at 08:00 Amsterdam time, syncs \`vendor/scrum4me\`, and runs \`prisma:generate\` + \`tsc --noEmit\` in \`scrum4me-mcp\`. Drift reports must be resolved **before** any Scrum4Me PR with schema changes can merge. See [\`docs/runbooks/mcp-integration.md\`](../runbooks/mcp-integration.md#schema-drift-bewaking). - -## Deep links - -| Topic | Authoritative source | -|---|---| -| Tool reference (all 18 tools) | [\`docs/runbooks/mcp-integration.md\`](../runbooks/mcp-integration.md) | -| Worker idempotency & job-status protocol | [\`docs/runbooks/worker-idempotency.md\`](../runbooks/worker-idempotency.md) | -| Q&A channel architecture (table + LISTEN/NOTIFY) | [\`docs/architecture/claude-question-channel.md\`](../architecture/claude-question-channel.md) | -| Idea-laag plan & prompts | [\`docs/plans/M12-ideas.md\`](../plans/M12-ideas.md) | -| Sprint execution modes (PER_TASK vs SPRINT_BATCH) | [\`docs/architecture/sprint-execution-modes.md\`](../architecture/sprint-execution-modes.md) | -| Realtime NOTIFY payload contract | [\`docs/patterns/realtime-notify-payload.md\`](../patterns/realtime-notify-payload.md) | -| Demo-user write protection | [\`docs/adr/0006-demo-user-three-layer-policy.md\`](../adr/0006-demo-user-three-layer-policy.md) | - -## What's next - -→ [05 — Docker](./05-docker.md) covers how the worker container is run, debugged, and operated. -`, - }, - { - slug: ['05-docker'] as const, - title: 'Docker', - description: 'This chapter is the contributor\'s tour of the Docker side of Scrum4Me. Two important up-front facts:', - filePath: 'docs/manual/05-docker.md', - markdown: `# 05 — Docker - -This chapter is the contributor's tour of the Docker side of Scrum4Me. Two important up-front facts: - -1. **The Next.js app is not containerised.** The web UI, API routes, server actions, and database connection all run on **Vercel** (serverless functions + Edge runtime). There is no \`Dockerfile\` in this repo and no \`docker-compose.yml\`. -2. **Only the worker is containerised.** The "worker" is a Claude Code agent in a long-running container that polls the Scrum4Me job queue via MCP and executes \`TASK_IMPLEMENTATION\` / \`IDEA_GRILL\` / \`IDEA_MAKE_PLAN\` / \`SPRINT_IMPLEMENTATION\` jobs. - -The container image and its supporting scripts live in a **separate repo**: [\`madhura68/scrum4me-docker\`](https://github.com/madhura68/scrum4me-docker). This manual documents the consumer side — what the worker is, how it relates to Scrum4Me, and how to diagnose issues. The container internals (Dockerfile, entrypoint, agent provisioning) are out of scope for this manual; see that repo's README. - -> **Note:** A separate sandbox repo \`scrum4me-sbx\` ([\`SC-4\`](https://github.com/madhura68/scrum4me-sbx)) exists for Docker exploration. Treat it as a scratchpad, not as the production worker. - -## Topology - -\`\`\`mermaid -flowchart LR - subgraph Vercel - App[Next.js app<br/>+ API routes] - end - subgraph Neon - DB[(Postgres)] - end - subgraph Mac["Mac (default) / NAS (opt-in)"] - Worker[Worker container<br/>Claude Code + MCP] - end - Worker -- MCP over HTTPS --> App - App -- Prisma --> DB - Worker -- git push --> GH[GitHub] - GH -- webhooks --> App -\`\`\` - -- The worker **never connects to the database directly**. All state changes go through MCP tools, which call the Vercel-hosted REST API, which writes to Neon via Prisma. -- The worker **does** push commits directly to GitHub. GitHub then notifies Vercel and the auto-PR flow ([03 — Git Workflow](./03-git-workflow.md)) takes over. - -## Mac vs NAS - -| Flow | When to use | Status | -|---|---|---| -| **Mac-native (arm64)** | Default for development and small teams | Active | -| **NAS** | Self-hosted always-on worker on a Synology / Asustor / similar | Opt-in, validated by historical smoke tests in [\`docs/docker-smoke/\`](../docker-smoke/) | - -The Mac flow is the default because it doesn't require dedicated hardware. The container runs natively on Apple Silicon (arm64) — no x86 emulation overhead. - -## Environment variables the worker needs - -The worker container needs **only** what's required to authenticate to MCP and push to GitHub: - -| Var | Purpose | -|---|---| -| \`SCRUM4ME_BEARER_TOKEN\` | Bearer token bound to a product. Returned by the user's API-token settings page. | -| \`SCRUM4ME_BASE_URL\` | Usually \`https://scrum4me.vercel.app\` (or the user's domain). | -| \`GITHUB_TOKEN\` | Personal access token with \`repo\` scope, used by \`git push\` and \`gh pr create\`. | -| \`ANTHROPIC_API_KEY\` | The Claude API key used by the worker process. | -| \`MIN_QUOTA_PCT\` | Optional. Worker pauses if Anthropic quota drops below this percentage. | - -> **Hardstop:** the worker does **not** need \`DATABASE_URL\`, \`SESSION_SECRET\`, or \`CRON_SECRET\`. Those belong to the Next.js app; the worker only talks to MCP. If you find yourself adding DB env vars to the worker, stop — you're solving the wrong problem. - -The full list and provisioning instructions live in the [\`scrum4me-docker\` README](https://github.com/madhura68/scrum4me-docker). **TODO:** link to specific sections of that README once it's stable. - -## What the worker loop does, on a single iteration - -\`\`\`mermaid -sequenceDiagram - participant W as Worker - participant Q as worker-quota-probe.sh - participant M as MCP server - W->>Q: probe Anthropic quota - Q-->>W: { pct, reset_at_iso } - alt pct < MIN_QUOTA_PCT - W->>M: worker_heartbeat(pct, last_quota_check_at) - W->>W: sleep until reset_at_iso (cap 1h) - else quota ok - W->>M: worker_heartbeat(pct, last_quota_check_at) - W->>M: wait_for_job (block ≤600s, claim atomically) - alt queue empty - W->>W: continue (no work, loop again) - else got job - W->>W: execute by \`kind\` - W->>M: update_job_status(done|failed) - end - end - Note over W: continue forever -\`\`\` - -The loop is described authoritatively in [\`docs/runbooks/mcp-integration.md\`](../runbooks/mcp-integration.md#batch-loop-verplichte-agent-flow) and [\`docs/runbooks/worker-idempotency.md\`](../runbooks/worker-idempotency.md). - -### Quota probe - -\`bin/worker-quota-probe.sh\` (in \`scrum4me-docker\`) makes a tiny call to the Anthropic API to read the current quota percentage and reset time. Cost: ~1 output token per probe (~12 tokens/hour at 5-minute intervals). The default \`MIN_QUOTA_PCT\` is **20%** — typically high enough on Pro/Max plans that the worker never pauses during normal day-job hours. - -### Heartbeat - -Every iteration the worker calls \`worker_heartbeat({ last_quota_pct, last_quota_check_at })\`. The MCP server emits an SSE event so the NavBar in the Next.js app shows the worker as live. A heartbeat older than 15 seconds is rendered as "offline" / "stand-by" in the UI. - -### Stale-claim recovery - -If a worker dies mid-job (process crash, container kill, network partition), its claimed job stays as \`CLAIMED\` in the database. After **30 minutes** the next \`wait_for_job\` call automatically requeues it (\`CLAIMED → QUEUED\`) before claiming a fresh one. No manual intervention is required for clean recovery. - -When you **do** need to manually requeue a job (e.g. you killed it intentionally and don't want to wait 30 min), the operator route is the admin board → "Requeue job" button. **TODO:** confirm the exact UI path; this is not yet documented in \`docs/runbooks/\`. - -## Running the worker locally - -The intended local workflow per the project's standing memory is **Mac-native Docker** (the user's \`project_docker_default_target\` memory). High-level steps (verify against the [scrum4me-docker README](https://github.com/madhura68/scrum4me-docker) for exact commands): - -1. Clone \`scrum4me-docker\` next to \`Scrum4Me/\` (so \`~/Development/Scrum4Me/scrum4me-docker/\`). -2. Provision the env vars above (typically a \`.env\` file in that repo, **not committed**). -3. \`docker build\` the image and \`docker run\` it with the env file mounted. -4. Watch container logs for the heartbeat/quota cycle. -5. Trigger a job from the UI ("Voer alle uit" on the Solo Board) and verify the worker picks it up within ~5 seconds. - -> **TODO:** once the \`scrum4me-docker\` README has stabilised, replace the bullets above with copy-paste-ready commands. Until then, defer to that repo for canonical instructions. - -## Debugging a stuck worker - -| Symptom | Likely cause | Fix | -|---|---|---| -| Worker shows offline in NavBar but container is running | \`worker_heartbeat\` not reaching MCP | Check \`SCRUM4ME_BASE_URL\` and \`SCRUM4ME_BEARER_TOKEN\`; tail container logs for HTTP errors | -| Worker logs say "stand-by" indefinitely | \`pct < MIN_QUOTA_PCT\` and reset_at not reached | Lower \`MIN_QUOTA_PCT\` for testing, or wait for the printed \`reset_at_iso\` | -| Job stuck \`CLAIMED\` for >30 min | Worker died mid-job | Wait — auto-requeue triggers on next \`wait_for_job\` | -| Worker claims job but never updates status | Crashed before \`update_job_status\`; container restarted in a loop | Check \`docker logs\`; the next \`wait_for_job\` will requeue stale claims | -| \`update_job_status\` returns \`403\` | Bearer token doesn't match \`claimed_by_token_id\` | The token was rotated mid-run; restart with fresh token | - -For deeper troubleshooting see [06 — Troubleshooting](./06-troubleshooting.md). - -## Smoke-test references - -Historical Docker smoke tests live in [\`docs/docker-smoke/\`](../docker-smoke/). They validated the worktree-isolation + branch-per-story flow when the Docker worker was first introduced. They are **historical** — don't expect them to be runnable as-is — but they're a useful reference when you want to verify the same flow on a new container image. - -## Deep links - -| Topic | Source | -|---|---| -| Container image, Dockerfile, build | [\`scrum4me-docker\` repo](https://github.com/madhura68/scrum4me-docker) | -| Worker loop & quota check | [\`docs/runbooks/mcp-integration.md\`](../runbooks/mcp-integration.md#pre-flight-quota-check-m13) | -| Worker idempotency / job-status protocol | [\`docs/runbooks/worker-idempotency.md\`](../runbooks/worker-idempotency.md) | -| Historical smoke tests | [\`docs/docker-smoke/\`](../docker-smoke/) | -| Sandbox / exploration repo | [\`scrum4me-sbx\` repo](https://github.com/madhura68/scrum4me-sbx) | - -## What's next - -→ [06 — Troubleshooting](./06-troubleshooting.md) covers error codes and recovery procedures across the full stack. -`, - }, - { - slug: ['06-troubleshooting'] as const, - title: 'Troubleshooting', - description: 'This chapter is the **first place to look** when something is wrong. Each row links to the authoritative source so you can dig deeper without losing your trail.', - filePath: 'docs/manual/06-troubleshooting.md', - markdown: `# 06 — Troubleshooting - -This chapter is the **first place to look** when something is wrong. Each row links to the authoritative source so you can dig deeper without losing your trail. - -## Error code reference - -These three HTTP status codes are non-negotiable hardstops in the API surface — they always mean the same thing across every route handler. - -| Code | Meaning | Where it comes from | -|---|---|---| -| **\`400\`** | JSON parse error | Body couldn't be parsed as JSON. Usually a malformed request from a client. | -| **\`422\`** | Zod validation error | Body parsed, but failed schema validation. Response includes the offending field path. | -| **\`403\`** | Demo-user write blocked | Authenticated user \`is_demo = true\` attempted a write. Three layers enforce this — see [\`docs/adr/0006-demo-user-three-layer-policy.md\`](../adr/0006-demo-user-three-layer-policy.md). | - -> **Hardstop:** these codes are reserved. Do not use \`400\` for validation errors or \`422\` for unauthorised access. The contract is enforced at the route-handler level — see the [Route Handler pattern](../patterns/route-handler.md). - -Other common codes: - -| Code | Meaning | -|---|---| -| \`401\` | No session / invalid bearer token | -| \`404\` | Resource not found, or token does not have access | -| \`409\` | State conflict — e.g. trying to claim a job that's already \`CLAIMED\` | -| \`429\` | Rate-limited — typically the Anthropic quota cap, not Scrum4Me itself | -| \`500\` | Unhandled server error. Always check Vercel function logs. | - -## Symptom → cause → fix - -### MCP - -| Symptom | Likely cause | Fix | -|---|---|---| -| \`mcp__scrum4me__get_claude_context\` returns \`null\` or empty story | Bearer token doesn't have access to that product | Run \`mcp__scrum4me__list_products\` to confirm scope; rotate the token if needed | -| \`mcp__scrum4me__update_task_status\` returns \`403\` | Demo user, or token mismatch in a sprint run | Check user identity; if inside a sprint run, the bearer token must match \`claimed_by_token_id\` of the parent job | -| \`mcp__scrum4me__wait_for_job\` returns nothing for the full 600s block | Queue is genuinely empty | This is normal — loop and call again. See [\`runbooks/mcp-integration.md\`](../runbooks/mcp-integration.md#batch-loop-verplichte-agent-flow) | -| Job stays \`CLAIMED\` for >30 minutes | Worker died mid-job | Auto-requeue triggers on next \`wait_for_job\`; no manual action needed | -| \`update_idea_plan_md\` causes idea to flip to \`PLAN_FAILED\` | \`parsePlanMd\` server-side rejected the YAML-frontmatter | Inspect \`IdeaLog{JOB_EVENT, errors}\` for the parse error; re-run \`IDEA_MAKE_PLAN\` after fixing the prompt | - -### Statuses & data integrity - -| Symptom | Likely cause | Fix | -|---|---|---| -| Status displayed differently in DB vs UI | Some code path bypassed \`lib/task-status.ts\` | Grep the codebase for direct enum string usage; force everything through the mappers. See [\`adr/0004-status-enum-mapping.md\`](../adr/0004-status-enum-mapping.md) | -| Story stuck \`IN_SPRINT\` when all tasks are \`DONE\` | Auto-promotion not triggered | Check the most recent \`update_task_status\` call — it may have failed silently. Re-issue with the correct task | -| PBI not auto-promoting to \`DONE\` | Not all child stories are \`DONE\` yet | List stories under the PBI; one is probably still \`OPEN\` or \`IN_SPRINT\` | -| \`422\` from \`create_pbi\` / \`create_story\` / \`create_task\` | Zod validation failed (length cap, missing required field) | Response body includes field path — fix and retry | -| \`IdeaStatus\` stays \`GRILLING\` long after the worker stopped | The job ended without calling \`update_idea_grill_md\` | Check the worker logs for an exception; manually requeue or mark \`GRILL_FAILED\` to allow retry | - -### Git & deploy - -| Symptom | Likely cause | Fix | -|---|---|---| -| Unexpected Vercel preview build appeared mid-batch | An interim push happened that shouldn't have | Inspect \`git log --all --graph\` for the offending push; review [\`runbooks/branch-and-commit.md\`](../runbooks/branch-and-commit.md) | -| PR has multiple Vercel deployments for the same commit range | Force-push, or push-then-revert | Don't force-push. If genuinely needed, document in the PR description | -| Auto-PR didn't open after story \`DONE\` | Story not actually \`DONE\`, or auto-PR pre-conditions unmet | Walk through [\`runbooks/auto-pr-flow.md\`](../runbooks/auto-pr-flow.md); typically a missing \`update_task_status('done')\` for the last task | -| Vercel skipped the deploy entirely | \`skip-deploy\` label or path-filter excluded the changed paths | See [\`runbooks/deploy-control.md\`](../runbooks/deploy-control.md) for the rules | -| Merge conflict between two parallel batches | Two branches touched the same files | Serialise: merge the first PR before pushing the second. Then \`git fetch origin main && git rebase origin/main\` | - -### Realtime - -| Symptom | Likely cause | Fix | -|---|---|---| -| Solo Board doesn't update when status changes | SSE connection dropped, or NOTIFY payload missing fields | Reload the page; if it persists, check \`DIRECT_URL\` (LISTEN/NOTIFY needs the pooler-bypass URL). See [\`patterns/realtime-notify-payload.md\`](../patterns/realtime-notify-payload.md) | -| NavBar bell doesn't pulse on new question | SSE/event channel mismatched, or payload missing required fields | Confirm the question was actually inserted (\`mcp__scrum4me__list_open_questions\`); inspect the Network tab for the SSE connection | -| Worker shows offline despite a running container | \`worker_heartbeat\` not reaching MCP | Verify \`SCRUM4ME_BASE_URL\` and bearer token; tail container logs | - -### Auth & sessions - -| Symptom | Likely cause | Fix | -|---|---|---| -| Login redirects in a loop | Session cookie not set; usually \`SESSION_SECRET\` mismatch between deployments | Check Vercel env vars for \`SESSION_SECRET\` (must be ≥32 chars); see [\`patterns/iron-session.md\`](../patterns/iron-session.md) | -| All write buttons disabled with "Niet beschikbaar in demo-modus" tooltip | You're logged in as the demo user | Log out and log in with a real account | -| \`403\` on a route that should be allowed | Proxy or server-action layer rejected the request | Walk through the three layers in [\`adr/0006-demo-user-three-layer-policy.md\`](../adr/0006-demo-user-three-layer-policy.md); each can independently say "no" | - -### Build & dev-server - -| Symptom | Likely cause | Fix | -|---|---|---| -| \`npm run build\` fails with \`Cannot find module '@/...'\` | TypeScript path alias mismatch | Check \`tsconfig.json\` \`paths\`; rerun \`npm run prebuild\` if codegen is stale | -| Mermaid diagram renders as plain text in the in-app \`/manual\` viewer | \`MermaidBlock\` not picking up \`language-mermaid\` | See [04 — MCP Integration](./04-mcp-integration.md) won't help here — open \`app/(app)/manual/_components/mermaid-block.tsx\` and confirm the dynamic import is \`ssr: false\` | -| "Server-only" import error in browser | A \`*-server.ts\` module was imported into a client component | Refactor — split server logic out, or use a server action. Hardstop in [\`CLAUDE.md\`](../../CLAUDE.md#hardstop-regels) | -| \`npm run dev\` shows hydration mismatch | Server and client render diverge — usually time-based or random values | Wrap in \`useEffect\` for client-only state, or pass server time as a prop | - -## When in doubt - -1. **Read the runbook.** Each runbook in [\`docs/runbooks/\`](../runbooks/) starts with a \`when_to_read\` field — match the situation. -2. **Check the ADRs.** The ADR index in [\`docs/INDEX.md\`](../INDEX.md) lists the rationale for every cross-cutting decision. If your fix would contradict an ADR, talk to a maintainer first. -3. **Read the agent-flow pitfalls log.** [\`docs/runbooks/agent-flow-pitfalls.md\`](../runbooks/agent-flow-pitfalls.md) is a living list of issues found during agent runs and how they were resolved. -4. **Look at recent commits.** \`git log --oneline --since='7 days ago'\` often reveals the very change that broke whatever you're debugging. - -## Escalation - -If after the steps above the issue is still unresolved: - -- **AI agent / MCP issues** → file in the [\`scrum4me-mcp\` repo](https://github.com/madhura68/scrum4me-mcp). -- **Worker container issues** → file in the [\`scrum4me-docker\` repo](https://github.com/madhura68/scrum4me-docker). -- **App / data / status issues** → file in the [\`Scrum4Me\` repo](https://github.com/madhura68/Scrum4Me). - -## What's next - -You've reached the end of the manual. Bookmark this troubleshooting chapter — it's the most-revisited page once you're past onboarding. - -Back to [index](./index.md). -`, - }, -] as const; diff --git a/lib/pause-context.ts b/lib/pause-context.ts deleted file mode 100644 index 95fc018..0000000 --- a/lib/pause-context.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from 'zod' - -export const PauseReasonSchema = z.enum(['MERGE_CONFLICT']) -export type PauseReason = z.infer<typeof PauseReasonSchema> - -export const PauseContextSchema = z.object({ - pause_reason: PauseReasonSchema, - pr_url: z.string().url(), - pr_head_sha: z.string().min(7), - conflict_files: z.array(z.string()).default([]), - claude_question_id: z.string().min(1), - resume_instructions: z.string().min(1), - paused_at: z.string().datetime(), -}) - -export type PauseContext = z.infer<typeof PauseContextSchema> - -export function parsePauseContext(raw: unknown): PauseContext | null { - if (raw === null || raw === undefined) return null - const parsed = PauseContextSchema.safeParse(raw) - return parsed.success ? parsed.data : null -} - -const PAUSE_REASON_LABELS: Record<PauseReason, string> = { - MERGE_CONFLICT: 'Merge-conflict op PR', -} - -export function pauseReasonLabel(reason: PauseReason): string { - return PAUSE_REASON_LABELS[reason] ?? reason -} diff --git a/lib/product-switch-path.ts b/lib/product-switch-path.ts deleted file mode 100644 index ce79ab9..0000000 --- a/lib/product-switch-path.ts +++ /dev/null @@ -1,14 +0,0 @@ -export function resolveProductSwitchTarget( - pathname: string, - newProductId: string, -): string | null { - const match = pathname.match(/^\/products\/([^/]+)(\/.*)?$/) - if (!match) return null - - const rest = match[2] ?? '' - - if (!rest || rest === '/') return `/products/${newProductId}` - if (rest.startsWith('/sprint')) return `/products/${newProductId}/sprint` - if (rest.startsWith('/solo')) return `/products/${newProductId}/solo` - return `/products/${newProductId}` -} diff --git a/lib/push-client.ts b/lib/push-client.ts deleted file mode 100644 index 2889c7d..0000000 --- a/lib/push-client.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { subscribeToPushAction, unsubscribeFromPushAction } from '@/actions/push' - -export function isPushSupported(): boolean { - return typeof window !== 'undefined' && - 'serviceWorker' in navigator && - 'PushManager' in window -} - -export function isIOSSafari(): boolean { - if (typeof window === 'undefined') return false - const ua = navigator.userAgent - return /iPhone|iPad/.test(ua) && !/CriOS|FxiOS/.test(ua) -} - -export function isStandalonePWA(): boolean { - if (typeof window === 'undefined') return false - return ( - window.matchMedia('(display-mode: standalone)').matches || - !!(navigator as Navigator & { standalone?: boolean }).standalone - ) -} - -export function urlBase64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> { - const padding = '='.repeat((4 - (base64.length % 4)) % 4) - const base64Std = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/') - const rawData = atob(base64Std) - const buf = new Uint8Array(rawData.length) - for (let i = 0; i < rawData.length; i++) buf[i] = rawData.charCodeAt(i) - return buf -} - -export async function subscribeToPush(publicKey: string): Promise<PushSubscription> { - const reg = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }) - await navigator.serviceWorker.ready - const sub = await reg.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: urlBase64ToUint8Array(publicKey), - }) - await subscribeToPushAction(sub.toJSON() as Parameters<typeof subscribeToPushAction>[0]) - return sub -} - -export async function unsubscribeFromPush(): Promise<void> { - const reg = await navigator.serviceWorker.getRegistration() - const sub = await reg?.pushManager.getSubscription() - if (sub) { - await sub.unsubscribe() - await unsubscribeFromPushAction({ endpoint: sub.endpoint }) - } -} diff --git a/lib/push-server.ts b/lib/push-server.ts deleted file mode 100644 index 5774253..0000000 --- a/lib/push-server.ts +++ /dev/null @@ -1,63 +0,0 @@ -import 'server-only' - -import webpush from 'web-push' -import { prisma } from '@/lib/prisma' - -export type PushPayload = { - title: string - body: string - url: string - tag?: string -} - -const vapidReady = - !!process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY && - !!process.env.VAPID_PRIVATE_KEY && - !!process.env.VAPID_SUBJECT - -if (vapidReady) { - webpush.setVapidDetails( - process.env.VAPID_SUBJECT!, - process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!, - process.env.VAPID_PRIVATE_KEY!, - ) -} - -export const enabled = vapidReady - -export async function sendPushToUser(userId: string, payload: PushPayload): Promise<void> { - if (!enabled) { - console.warn('[push-server] VAPID not configured — skipping push for user', userId) - return - } - - const subs = await prisma.pushSubscription.findMany({ where: { user_id: userId } }) - await Promise.allSettled(subs.map((sub) => sendOne(sub, payload))) -} - -async function sendOne( - sub: { id: string; endpoint: string; p256dh: string; auth: string }, - payload: PushPayload, -): Promise<void> { - try { - await webpush.sendNotification( - { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, - JSON.stringify(payload), - ) - await prisma.pushSubscription.update({ - where: { id: sub.id }, - data: { last_used_at: new Date() }, - }) - } catch (err: unknown) { - const status = (err as { statusCode?: number }).statusCode - if (status === 404 || status === 410) { - try { - await prisma.pushSubscription.delete({ where: { id: sub.id } }) - } catch { - // already deleted by a concurrent request — ignore - } - } else { - console.error('[push-server] sendNotification error for endpoint', sub.endpoint, err) - } - } -} diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index 3d99843..ba2b052 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -11,28 +11,6 @@ const CONFIGS: Record<string, RateLimitConfig> = { login: { windowMs: 60_000, max: 10 }, // 10 attempts per minute register: { windowMs: 3_600_000, max: 5 }, // 5 attempts per hour 'pair-start': { windowMs: 60_000, max: 10 }, // 10 QR-pairings per minute (M10) - - // v1-readiness item 3 — per-user mutation-rate-limits. - // Limits zijn ruim genoeg voor normaal gebruik, eng genoeg om abuse-loops - // (bv. een runaway-script dat duizenden tasks aanmaakt) te stoppen. - 'create-pbi': { windowMs: 60_000, max: 30 }, - 'create-story': { windowMs: 60_000, max: 50 }, - 'create-task': { windowMs: 60_000, max: 100 }, - 'create-todo': { windowMs: 60_000, max: 60 }, - 'create-sprint': { windowMs: 60_000, max: 5 }, - 'create-product': { windowMs: 60_000, max: 5 }, - 'create-token': { windowMs: 3_600_000, max: 10 }, // 10 API-tokens/uur/user - 'enqueue-job': { windowMs: 60_000, max: 30 }, - 'log-story': { windowMs: 60_000, max: 60 }, - 'upload-avatar': { windowMs: 3_600_000, max: 20 }, - 'answer-question': { windowMs: 60_000, max: 30 }, - - // M12 — Idea entity (zie docs/plans/M12-ideas.md) - 'create-idea': { windowMs: 60_000, max: 30 }, - 'edit-idea-md': { windowMs: 60_000, max: 60 }, // grill_md / plan_md edits - 'start-idea-job': { windowMs: 60_000, max: 10 }, // Grill / Make Plan triggers - 'materialize-idea': { windowMs: 60_000, max: 5 }, - 'create-user-question': { windowMs: 60_000, max: 20 }, // PLAN_CHAT vragen } const DEFAULT_CONFIG: RateLimitConfig = { windowMs: 60_000, max: 10 } @@ -57,32 +35,3 @@ export function checkRateLimit(key: string): boolean { entry.count++ return true } - -/** - * Wrapper voor server-actions: scope op (action, userId), retourneert het - * standaard `{ error, code: 429 }` shape als de gebruiker over de limiet is. - * - * Gebruik in een action: - * - * const limited = enforceUserRateLimit('create-pbi', session.userId) - * if (limited) return limited - */ -export function enforceUserRateLimit( - scope: keyof typeof CONFIGS, - userId: string, -): { error: string; code: 429 } | null { - if (!checkRateLimit(`${scope}:${userId}`)) { - return { - error: 'Te veel acties achter elkaar. Probeer het over een minuut opnieuw.', - code: 429, - } - } - return null -} - -/** - * Voor test-isolatie: leegt de in-memory store. Niet exporteren in productie-paden. - */ -export function _resetRateLimit() { - store.clear() -} diff --git a/lib/realtime/use-backlog-realtime.ts b/lib/realtime/use-backlog-realtime.ts index c0fa873..272adac 100644 --- a/lib/realtime/use-backlog-realtime.ts +++ b/lib/realtime/use-backlog-realtime.ts @@ -1,19 +1,11 @@ 'use client' -// ST-1115 / PBI-74: Client hook for the backlog 3-pane SSE stream. +// ST-1115: Client hook for the backlog 3-pane SSE stream. // Mounts in BacklogHydrationWrapper so it survives Server Action refreshes. -// Dispatches pbi/story/task change events into useProductWorkspaceStore. -// -// T-861: stream blijft open op tab hidden. Per spec werkt EventSource gewoon -// door als de browser het toelaat — gemiste events worden opgehaald via -// resyncActiveScopes('visible') uit useWorkspaceResync. -// T-862: bij latere 'ready' events (post-reconnect) triggeren we -// resyncActiveScopes('reconnect') zodat events die tijdens disconnect zijn -// gemist, alsnog binnenkomen. +// Dispatches pbi/story/task change events into useBacklogStore.applyChange. import { useEffect, useRef } from 'react' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import type { ProductRealtimeEvent } from '@/stores/product-workspace/types' +import { useBacklogStore } from '@/stores/backlog-store' const BACKOFF_START_MS = 1_000 const BACKOFF_MAX_MS = 30_000 @@ -28,7 +20,6 @@ export function useBacklogRealtime(productId: string | null) { const sourceRef = useRef<EventSource | null>(null) const backoffRef = useRef<number>(BACKOFF_START_MS) const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) - const readyCountRef = useRef<number>(0) useEffect(() => { if (!productId) return @@ -53,22 +44,15 @@ export function useBacklogRealtime(productId: string | null) { source.addEventListener('ready', () => { backoffRef.current = BACKOFF_START_MS - readyCountRef.current += 1 - // T-862: eerste ready = initial connect; latere ready = reconnect. - if (readyCountRef.current > 1) { - void useProductWorkspaceStore - .getState() - .resyncActiveScopes('reconnect') - } }) source.onmessage = (e) => { if (!e.data) return try { const payload = JSON.parse(e.data) as EntityPayload - useProductWorkspaceStore + useBacklogStore .getState() - .applyRealtimeEvent(payload as unknown as ProductRealtimeEvent) + .applyChange(payload.entity, payload.op, payload as Record<string, unknown>) } catch (err) { if (process.env.NODE_ENV !== 'production') { console.error('[realtime/backlog] failed to parse event', err, e.data) @@ -86,22 +70,23 @@ export function useBacklogRealtime(productId: string | null) { } } - // T-861: stream blijft open op hidden. Reconnect alleen als source weg - // is (b.v. na netwerkfout) en de tab visible is. const onVisibility = () => { - if (document.visibilityState === 'visible' && sourceRef.current === null) { + if (document.visibilityState === 'hidden') { + close() + } else if (sourceRef.current === null) { backoffRef.current = BACKOFF_START_MS connect() } } - connect() + if (document.visibilityState === 'visible') { + connect() + } document.addEventListener('visibilitychange', onVisibility) return () => { document.removeEventListener('visibilitychange', onVisibility) close() - readyCountRef.current = 0 } }, [productId]) } diff --git a/lib/realtime/use-notifications-realtime.ts b/lib/realtime/use-notifications-realtime.ts index 36430ce..8f58e12 100644 --- a/lib/realtime/use-notifications-realtime.ts +++ b/lib/realtime/use-notifications-realtime.ts @@ -11,54 +11,22 @@ 'use client' import { useEffect, useRef } from 'react' -import { useRouter } from 'next/navigation' import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store' -import { useIdeaStore } from '@/stores/idea-store' const BACKOFF_START_MS = 1_000 const BACKOFF_MAX_MS = 30_000 -// Question-payloads (M11 + M12). story_id en idea_id zijn mutually exclusive -// (DB-check-constraint). Voor story-questions blijft het pad onveranderd; -// idea-questions worden naar de idea-store doorgezet. -interface QuestionPayload { +interface NotifyPayload { op: 'I' | 'U' entity: 'question' id: string product_id: string - story_id: string | null + story_id: string task_id: string | null - idea_id?: string | null assignee_id: string | null status: 'open' | 'answered' | 'cancelled' | 'expired' } -// Idea-job-payloads (M12). Komen uit actions/ideas.ts pg_notify. -interface IdeaJobPayload { - type: 'claude_job_enqueued' | 'claude_job_status' - job_id: string - idea_id: string - user_id: string - product_id?: string | null - kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' - status: string - error?: string -} - -type AnyPayload = QuestionPayload | IdeaJobPayload - -function isQuestionPayload(p: AnyPayload): p is QuestionPayload { - return 'entity' in p && p.entity === 'question' -} - -function isIdeaJobPayload(p: AnyPayload): p is IdeaJobPayload { - return ( - 'type' in p && - (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') && - (p.kind === 'IDEA_GRILL' || p.kind === 'IDEA_MAKE_PLAN') - ) -} - interface StateEvent { questions: NotificationQuestion[] } @@ -67,7 +35,6 @@ export function useNotificationsRealtime() { const sourceRef = useRef<EventSource | null>(null) const backoffRef = useRef<number>(BACKOFF_START_MS) const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) - const router = useRouter() useEffect(() => { const init = useNotificationsStore.getState().init @@ -106,64 +73,11 @@ export function useNotificationsRealtime() { source.addEventListener('message', (ev) => { try { - const payload = JSON.parse(ev.data) as AnyPayload - - // M12 — idea-job events naar idea-store dispatchen. - if (isIdeaJobPayload(payload)) { - useIdeaStore.getState().handleIdeaJobEvent({ - type: payload.type, - job_id: payload.job_id, - idea_id: payload.idea_id, - user_id: payload.user_id, - product_id: payload.product_id ?? null, - kind: payload.kind, - // The store-types narrow this; cast is safe because the server - // emits valid statuses. - status: payload.status as 'queued', - error: payload.error, - }) - // Refresh zodra job klaar is — server heeft nu grill_md/plan_md - // geschreven en de idea-status bijgewerkt. router.refresh() triggert - // een server-component re-fetch zodat de Timeline de nieuwe - // GRILL_RESULT/PLAN_RESULT logs en de bijgewerkte status oppikt. - if (payload.status === 'done' || payload.status === 'failed') { - router.refresh() - } - return - } - - if (!isQuestionPayload(payload)) return - - // M12 — idea-question events naar idea-store dispatchen. - if (payload.idea_id) { - useIdeaStore.getState().handleIdeaQuestionEvent({ - op: payload.op, - entity: 'question', - id: payload.id, - product_id: payload.product_id, - story_id: null, - idea_id: payload.idea_id, - task_id: payload.task_id, - assignee_id: payload.assignee_id, - status: payload.status, - }) - // M12 hotfix: óók in notifications-bel. Open → reconnect zodat - // initial-state de full question-detail levert; non-open → remove. - if (payload.status === 'open') { - close() - connect() - } else { - remove(payload.id) - } - // M12 hotfix: refresh de current page (server-component) zodat de - // IdeaTimeline-tab op /ideas/[id] de nieuwe vraag oppikt zonder - // dat de gebruiker handmatig moet refreshen. Geen-op als de - // gebruiker elders zit; goedkoop genoeg om altijd te triggeren. - router.refresh() - return - } - - // Story-questions: bestaande bell-pad onveranderd. + const payload = JSON.parse(ev.data) as NotifyPayload + if (payload.entity !== 'question') return + // Bij open of nieuwe insert → upsert (server stuurt geen vraag-tekst + // mee in de payload, dus we doen een mini-fetch via de same SSE's + // initial-state on reconnect; hier voor MVP alleen status-handling). if (payload.status === 'open') { // Inkomende open vraag: we hebben de details nog niet — beste optie is // herfetchen door opnieuw te verbinden, of via een API. Voor v1 @@ -191,31 +105,22 @@ export function useNotificationsRealtime() { }) } - // PBI-74: stream blijft open op hidden. Reconnect alleen als hij door - // netwerkfout/server-close weg is. Bij visible-overgang en bij online - // triggeren we router.refresh() zodat de notifications-bel verse state - // pakt — gemiste vraag-events via NOTIFY-throttling worden hierdoor - // alsnog zichtbaar. const onVisibilityChange = () => { - if (document.visibilityState !== 'visible') return - if (!sourceRef.current || sourceRef.current.readyState === EventSource.CLOSED) { - connect() + if (document.visibilityState === 'visible') { + if (!sourceRef.current || sourceRef.current.readyState === EventSource.CLOSED) { + connect() + } + } else { + close() } - router.refresh() - } - - const onOnline = () => { - router.refresh() } connect() document.addEventListener('visibilitychange', onVisibilityChange) - window.addEventListener('online', onOnline) return () => { document.removeEventListener('visibilitychange', onVisibilityChange) - window.removeEventListener('online', onOnline) close() } - }, [router]) + }, []) } diff --git a/lib/realtime/use-solo-realtime.ts b/lib/realtime/use-solo-realtime.ts index 209c972..928dd80 100644 --- a/lib/realtime/use-solo-realtime.ts +++ b/lib/realtime/use-solo-realtime.ts @@ -6,10 +6,7 @@ // - Opent EventSource('/api/realtime/solo?product_id=...') wanneer // productId niet null is; sluit de stream als productId null wordt. // - Reconnect met exponential backoff (1s → 30s, reset bij ready). -// - PBI-74: stream blijft open op tab hidden (geen close meer). Bij -// hidden→visible en bij window 'online' triggeren we een directe -// workspace-store resync. Postgres NOTIFY heeft geen replay, dus zonder deze -// resync zouden hidden-tab events permanent verloren zijn. +// - Pauseert bij document.visibilityState === 'hidden', resumes bij visible. // - Cleanup op unmount. // - Connection-status (status, showConnectingIndicator) wordt naar de // solo-store geschreven; UI-componenten lezen daar uit. @@ -34,7 +31,6 @@ export function useSoloRealtime(productId: string | null) { const backoffRef = useRef<number>(BACKOFF_START_MS) const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const indicatorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) - const readyCountRef = useRef<number>(0) useEffect(() => { const setStatus = useSoloStore.getState().setRealtimeStatus @@ -44,7 +40,6 @@ export function useSoloRealtime(productId: string | null) { const setWorkers = useSoloStore.getState().setWorkers const incrementWorkers = useSoloStore.getState().incrementWorkers const decrementWorkers = useSoloStore.getState().decrementWorkers - const setWorkerQuota = useSoloStore.getState().setWorkerQuota if (!productId) { // Geen actief product (gebruiker zit niet op /solo) — stream uit @@ -92,11 +87,6 @@ export function useSoloRealtime(productId: string | null) { source.addEventListener('ready', () => { backoffRef.current = BACKOFF_START_MS scheduleIndicator('open') - readyCountRef.current += 1 - // PBI-74: latere ready = post-reconnect → directe workspace-resync. - if (readyCountRef.current > 1) { - void useSoloStore.getState().resyncActiveScopes('reconnect') - } }) source.addEventListener('claude_jobs_initial', (e) => { @@ -129,15 +119,6 @@ export function useSoloRealtime(productId: string | null) { } if (raw.type === 'worker_connected') { incrementWorkers(); return } if (raw.type === 'worker_disconnected') { decrementWorkers(); return } - if (raw.type === 'worker_heartbeat') { - const hb = raw as { - type: 'worker_heartbeat' - last_quota_pct: number - last_quota_check_at: string - } - setWorkerQuota(hb.last_quota_pct, hb.last_quota_check_at) - return - } return } const payload = raw as RealtimeEvent @@ -182,33 +163,25 @@ export function useSoloRealtime(productId: string | null) { } } - // PBI-74: stream blijft open op hidden. Reconnect alleen als de stream - // door netwerkfout/server-close weg is en de tab visible is. Bij iedere - // visible-overgang triggeren we een store-resync — gemiste events tijdens - // throttling/freeze worden via de solo-workspace route alsnog opgepakt. const onVisibility = () => { - if (document.visibilityState !== 'visible') return - if (sourceRef.current === null) { + if (document.visibilityState === 'hidden') { + close() + scheduleIndicator('disconnected') + } else if (sourceRef.current === null) { backoffRef.current = BACKOFF_START_MS connect() } - void useSoloStore.getState().resyncActiveScopes('visible') } - const onOnline = () => { - void useSoloStore.getState().resyncActiveScopes('reconnect') + if (document.visibilityState === 'visible') { + connect() } - - connect() document.addEventListener('visibilitychange', onVisibility) - window.addEventListener('online', onOnline) return () => { document.removeEventListener('visibilitychange', onVisibility) - window.removeEventListener('online', onOnline) if (indicatorTimerRef.current) clearTimeout(indicatorTimerRef.current) close() - readyCountRef.current = 0 } }, [productId]) } diff --git a/lib/realtime/use-sprint-realtime.ts b/lib/realtime/use-sprint-realtime.ts deleted file mode 100644 index c4a70cd..0000000 --- a/lib/realtime/use-sprint-realtime.ts +++ /dev/null @@ -1,96 +0,0 @@ -'use client' - -// PBI-74 / Story 9 / T-880: Client hook for the sprint workspace SSE stream. -// Mounts in SprintHydrationWrapper so it survives Server Action refreshes. -// Dispatches sprint/story/task change events into useSprintWorkspaceStore. -// -// Mirrors use-backlog-realtime.ts: -// - Stream blijft open op tab hidden — gemiste events worden opgehaald via -// resyncActiveScopes('visible') uit useSprintWorkspaceResync. -// - Latere 'ready'-events (post-reconnect) triggeren -// resyncActiveScopes('reconnect') zodat events tijdens disconnect alsnog -// binnenkomen. - -import { useEffect, useRef } from 'react' -import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' - -const BACKOFF_START_MS = 1_000 -const BACKOFF_MAX_MS = 30_000 - -export function useSprintRealtime(productId: string | null) { - const sourceRef = useRef<EventSource | null>(null) - const backoffRef = useRef<number>(BACKOFF_START_MS) - const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) - const readyCountRef = useRef<number>(0) - - useEffect(() => { - if (!productId) return - - const close = () => { - if (sourceRef.current) { - sourceRef.current.close() - sourceRef.current = null - } - if (reconnectTimerRef.current) { - clearTimeout(reconnectTimerRef.current) - reconnectTimerRef.current = null - } - } - - const connect = () => { - close() - const source = new EventSource( - `/api/realtime/sprint?product_id=${encodeURIComponent(productId)}`, - ) - sourceRef.current = source - useSprintWorkspaceStore.getState().setRealtimeStatus('connecting') - - source.addEventListener('ready', () => { - backoffRef.current = BACKOFF_START_MS - readyCountRef.current += 1 - useSprintWorkspaceStore.getState().setRealtimeStatus('open') - if (readyCountRef.current > 1) { - void useSprintWorkspaceStore.getState().resyncActiveScopes('reconnect') - } - }) - - source.onmessage = (e) => { - if (!e.data) return - try { - const payload = JSON.parse(e.data) as Record<string, unknown> - useSprintWorkspaceStore.getState().applyRealtimeEvent(payload) - } catch (err) { - if (process.env.NODE_ENV !== 'production') { - console.error('[realtime/sprint] failed to parse event', err, e.data) - } - } - } - - source.onerror = () => { - if (sourceRef.current !== source) return - close() - useSprintWorkspaceStore.getState().setRealtimeStatus('disconnected') - if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return - const delay = backoffRef.current - backoffRef.current = Math.min(backoffRef.current * 2, BACKOFF_MAX_MS) - reconnectTimerRef.current = setTimeout(connect, delay) - } - } - - const onVisibility = () => { - if (document.visibilityState === 'visible' && sourceRef.current === null) { - backoffRef.current = BACKOFF_START_MS - connect() - } - } - - connect() - document.addEventListener('visibilitychange', onVisibility) - - return () => { - document.removeEventListener('visibilitychange', onVisibility) - close() - readyCountRef.current = 0 - } - }, [productId]) -} diff --git a/lib/realtime/use-sprint-workspace-resync.ts b/lib/realtime/use-sprint-workspace-resync.ts deleted file mode 100644 index b21460b..0000000 --- a/lib/realtime/use-sprint-workspace-resync.ts +++ /dev/null @@ -1,36 +0,0 @@ -'use client' - -// PBI-74 / Story 9 / T-880: useSprintWorkspaceResync. -// -// Trigger resyncActiveScopes bij: -// - hidden→visible (browser-throttled events kunnen gemist zijn) -// - online (netwerk hersteld na disconnect) -// -// Hoort gemount te worden naast useSprintRealtime in SprintHydrationWrapper. - -import { useEffect } from 'react' -import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' - -export function useSprintWorkspaceResync(): void { - useEffect(() => { - if (typeof document === 'undefined') return - - const onVisibility = () => { - if (document.visibilityState === 'visible') { - void useSprintWorkspaceStore.getState().resyncActiveScopes('visible') - } - } - - const onOnline = () => { - void useSprintWorkspaceStore.getState().resyncActiveScopes('reconnect') - } - - document.addEventListener('visibilitychange', onVisibility) - window.addEventListener('online', onOnline) - - return () => { - document.removeEventListener('visibilitychange', onVisibility) - window.removeEventListener('online', onOnline) - } - }, []) -} diff --git a/lib/realtime/use-workspace-resync.ts b/lib/realtime/use-workspace-resync.ts deleted file mode 100644 index 844fa48..0000000 --- a/lib/realtime/use-workspace-resync.ts +++ /dev/null @@ -1,40 +0,0 @@ -'use client' - -// PBI-74 / T-863: useWorkspaceResync hook. -// -// Trigger resyncActiveScopes bij: -// - hidden→visible (browser-throttled events kunnen gemist zijn) -// - online (netwerk hersteld na disconnect) -// -// Hoort gemount te worden naast useBacklogRealtime in BacklogHydrationWrapper. - -import { useEffect } from 'react' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' - -export function useWorkspaceResync(): void { - useEffect(() => { - if (typeof document === 'undefined') return - - const onVisibility = () => { - if (document.visibilityState === 'visible') { - void useProductWorkspaceStore - .getState() - .resyncActiveScopes('visible') - } - } - - const onOnline = () => { - void useProductWorkspaceStore - .getState() - .resyncActiveScopes('reconnect') - } - - document.addEventListener('visibilitychange', onVisibility) - window.addEventListener('online', onOnline) - - return () => { - document.removeEventListener('visibilitychange', onVisibility) - window.removeEventListener('online', onOnline) - } - }, []) -} diff --git a/lib/schemas/idea.ts b/lib/schemas/idea.ts deleted file mode 100644 index a8a32f2..0000000 --- a/lib/schemas/idea.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { z } from 'zod' - -// Velden die de gebruiker invult bij create/edit. Status wordt door -// server-actions gezet (niet door client-input). -export const ideaCreateSchema = z.object({ - title: z.string().trim().min(1, 'Titel is verplicht').max(200, 'Maximaal 200 tekens'), - description: z.string().max(4000, 'Maximaal 4000 tekens').optional().nullable(), - product_id: z.string().cuid('Ongeldig product').optional().nullable(), -}) - -export const ideaUpdateSchema = ideaCreateSchema.partial() - -export type IdeaCreateInput = z.infer<typeof ideaCreateSchema> -export type IdeaUpdateInput = z.infer<typeof ideaUpdateSchema> - -// --------------------------------------------------------------------------- -// plan_md frontmatter — strict format dat door make-plan-job geproduceerd -// wordt en door materializeIdeaPlanAction wordt geparseerd. Zie -// docs/plans/M12-ideas.md "Plan-md formaat A". - -const verifyRequiredEnum = z.enum(['ALIGNED', 'ALIGNED_OR_PARTIAL', 'ANY']) - -// Task-level priority is geaccepteerd in het schema voor backward-compat, -// maar wordt door `materializeIdeaPlanAction` genegeerd ten faveure van -// story-priority. Reden: worker sorteert op `priority ASC, sort_order ASC` -// — gemixte task-priorities binnen één story zouden de YAML-volgorde -// verstoren. Auteurs hoeven het veld dus niet meer in te vullen. -const planTaskSchema = z.object({ - title: z.string().min(1).max(200), - description: z.string().max(4000).optional(), - implementation_plan: z.string().max(8000).optional(), - priority: z.number().int().min(1).max(4).optional(), - verify_required: verifyRequiredEnum.optional(), - verify_only: z.boolean().optional(), -}) - -const planStorySchema = z.object({ - title: z.string().min(1).max(200), - description: z.string().max(4000).optional(), - acceptance_criteria: z.string().max(4000).optional(), - priority: z.number().int().min(1).max(4), - tasks: z.array(planTaskSchema).min(1, 'Story moet minimaal 1 taak hebben'), -}) - -const planPbiSchema = z.object({ - title: z.string().min(1).max(200), - description: z.string().max(4000).optional(), - priority: z.number().int().min(1).max(4), -}) - -export const ideaPlanMdFrontmatterSchema = z.object({ - pbi: planPbiSchema, - stories: z.array(planStorySchema).min(1, 'Plan moet minimaal 1 story bevatten'), -}) - -export type IdeaPlanFrontmatter = z.infer<typeof ideaPlanMdFrontmatterSchema> -export type IdeaPlanStory = z.infer<typeof planStorySchema> -export type IdeaPlanTask = z.infer<typeof planTaskSchema> diff --git a/lib/schemas/pbi.ts b/lib/schemas/pbi.ts deleted file mode 100644 index dc8b97d..0000000 --- a/lib/schemas/pbi.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from 'zod' -import { CODE_REGEX, MAX_CODE_LENGTH } from '@/lib/code' - -const codeField = z - .string() - .trim() - .max(MAX_CODE_LENGTH) - .regex(CODE_REGEX, 'Ongeldige code') - .optional() - .or(z.literal('')) -const statusField = z.enum(['ready', 'blocked', 'done']).optional() - -export const createPbiSchema = z.object({ - productId: z.string(), - code: codeField, - title: z.string().min(1, 'Titel is verplicht').max(200), - description: z.string().max(2000).optional(), - priority: z.coerce.number().int().min(1).max(4), - status: statusField, -}) - -export const updatePbiSchema = z.object({ - id: z.string(), - code: codeField, - title: z.string().min(1, 'Titel is verplicht').max(200), - description: z.string().max(2000).optional(), - priority: z.coerce.number().int().min(1).max(4), - status: statusField, -}) - -export type CreatePbiInput = z.infer<typeof createPbiSchema> -export type UpdatePbiInput = z.infer<typeof updatePbiSchema> diff --git a/lib/schemas/product.ts b/lib/schemas/product.ts deleted file mode 100644 index b74b730..0000000 --- a/lib/schemas/product.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { z } from 'zod' - -export const productSchema = z.object({ - name: z.string().trim().min(1, 'Naam is verplicht').max(200, 'Maximaal 200 tekens'), - code: z.string().trim().max(20, 'Maximaal 20 tekens').optional(), - description: z.string().max(4000, 'Maximaal 4000 tekens').optional(), - repo_url: z - .string() - .url('Voer een geldige URL in (inclusief https://)') - .regex(/^https:\/\/github\.com\//, 'Alleen GitHub-URLs worden ondersteund') - .optional() - .nullable() - .or(z.literal('')), - definition_of_done: z.string().max(4000, 'Maximaal 4000 tekens').optional(), - auto_pr: z.boolean(), -}) - -export type ProductInput = z.infer<typeof productSchema> diff --git a/lib/schemas/question-answer.ts b/lib/schemas/question-answer.ts deleted file mode 100644 index fb8a1f5..0000000 --- a/lib/schemas/question-answer.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { z } from 'zod' - -export const ANSWER_MAX_CHARS = 4000 - -export const answerQuestionSchema = z.object({ - questionId: z.string().cuid(), - answer: z.string().min(1, 'Antwoord mag niet leeg zijn').max(ANSWER_MAX_CHARS), -}) - -export type AnswerQuestionInput = z.infer<typeof answerQuestionSchema> diff --git a/lib/schemas/sprint.ts b/lib/schemas/sprint.ts deleted file mode 100644 index 2cfd391..0000000 --- a/lib/schemas/sprint.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { z } from 'zod' - -const dateField = z.string().optional().nullable().transform(v => (v && v.trim() !== '' ? new Date(v) : null)) - -export function validateDateOrder( - data: { start_date: Date | null; end_date: Date | null }, - ctx: z.RefinementCtx, -) { - if (data.start_date && data.end_date && data.end_date < data.start_date) { - ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['end_date'], message: 'Einddatum moet na startdatum liggen' }) - } -} - -export const createSprintSchema = z - .object({ - productId: z.string(), - sprint_goal: z.string().min(1, 'Sprint Goal is verplicht').max(500), - start_date: dateField, - end_date: dateField, - pbi_id: z - .string() - .nullable() - .optional() - .transform(v => (v && v.trim() !== '' ? v : null)), - }) - .superRefine(validateDateOrder) - -export const updateSprintDatesSchema = z - .object({ - id: z.string(), - start_date: dateField, - end_date: dateField, - }) - .superRefine(validateDateOrder) - -export const updateSprintGoalSchema = z.object({ - id: z.string(), - sprint_goal: z.string().min(1, 'Sprint Goal is verplicht').max(500), -}) - -export type CreateSprintInput = z.infer<typeof createSprintSchema> -export type UpdateSprintDatesInput = z.infer<typeof updateSprintDatesSchema> -export type UpdateSprintGoalInput = z.infer<typeof updateSprintGoalSchema> diff --git a/lib/schemas/story.ts b/lib/schemas/story.ts deleted file mode 100644 index 8de5811..0000000 --- a/lib/schemas/story.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from 'zod' -import { CODE_REGEX, MAX_CODE_LENGTH } from '@/lib/code' - -const codeField = z - .string() - .trim() - .max(MAX_CODE_LENGTH) - .regex(CODE_REGEX, 'Ongeldige code') - .optional() - .or(z.literal('')) - -export const createStorySchema = z.object({ - pbiId: z.string(), - productId: z.string(), - code: codeField, - title: z.string().min(1, 'Titel is verplicht').max(200), - description: z.string().max(2000).optional(), - acceptance_criteria: z.string().max(2000).optional(), - priority: z.coerce.number().int().min(1).max(4), -}) - -export const updateStorySchema = z.object({ - id: z.string(), - code: codeField, - title: z.string().min(1, 'Titel is verplicht').max(200), - description: z.string().max(2000).optional(), - acceptance_criteria: z.string().max(2000).optional(), - priority: z.coerce.number().int().min(1).max(4), -}) - -export type CreateStoryInput = z.infer<typeof createStorySchema> -export type UpdateStoryInput = z.infer<typeof updateStorySchema> diff --git a/lib/schemas/task.ts b/lib/schemas/task.ts index 9f32282..b4c0c3e 100644 --- a/lib/schemas/task.ts +++ b/lib/schemas/task.ts @@ -1,15 +1,7 @@ import { z } from 'zod' import { TaskStatus } from '@prisma/client' -import { CODE_REGEX, MAX_CODE_LENGTH } from '@/lib/code' export const taskSchema = z.object({ - code: z - .string() - .trim() - .max(MAX_CODE_LENGTH) - .regex(CODE_REGEX, 'Ongeldige code') - .optional() - .or(z.literal('')), title: z.string().trim().min(1, 'Verplicht').max(120), description: z.string().max(2000).optional(), implementation_plan: z.string().max(10000).optional(), diff --git a/lib/schemas/user.ts b/lib/schemas/user.ts deleted file mode 100644 index fbab76d..0000000 --- a/lib/schemas/user.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { z } from 'zod' - -export const minQuotaPctSchema = z.number().int().min(1).max(100) diff --git a/lib/session.ts b/lib/session.ts index 5d7c587..bf1f9a9 100644 --- a/lib/session.ts +++ b/lib/session.ts @@ -3,7 +3,6 @@ import { SessionOptions } from 'iron-session' export interface SessionData { userId: string isDemo: boolean - isAdmin: boolean // ST-1002 (M10) — gezet door /api/auth/pair/claim na een succesvolle QR-pairing. // Beide velden zijn optioneel zodat bestaande wachtwoord-sessies onveranderd blijven. paired?: boolean diff --git a/lib/solo-workspace-server.ts b/lib/solo-workspace-server.ts deleted file mode 100644 index 7c8a42c..0000000 --- a/lib/solo-workspace-server.ts +++ /dev/null @@ -1,106 +0,0 @@ -import 'server-only' - -import { prisma } from '@/lib/prisma' -import { getAccessibleProduct } from '@/lib/product-access' -import { resolveActiveSprint } from '@/lib/active-sprint' -import type { - SoloTask, - SoloUnassignedStory, - SoloWorkspaceSnapshot, -} from '@/stores/solo-workspace/types' - -export async function getSoloWorkspaceSnapshot( - productId: string, - userId: string, - sprintId?: string | null, -): Promise<SoloWorkspaceSnapshot | null> { - const product = await getAccessibleProduct(productId, userId) - if (!product) return null - - const active = sprintId ? { id: sprintId } : await resolveActiveSprint(productId, userId) - const sprint = active - ? await prisma.sprint.findFirst({ where: { id: active.id, product_id: productId } }) - : null - if (!sprint) return null - - const [rawTasks, rawUnassigned] = await Promise.all([ - prisma.task.findMany({ - where: { - story: { - sprint_id: sprint.id, - assignee_id: userId, - }, - }, - include: { - story: { - select: { - id: true, - code: true, - title: true, - tasks: { select: { id: true }, orderBy: { sort_order: 'asc' } }, - pbi: { select: { code: true, title: true, description: true } }, - }, - }, - }, - orderBy: [ - { story: { pbi: { priority: 'asc' } } }, - { story: { pbi: { sort_order: 'asc' } } }, - { story: { sort_order: 'asc' } }, - { sort_order: 'asc' }, - ], - }), - prisma.story.findMany({ - where: { sprint_id: sprint.id, assignee_id: null }, - select: { - id: true, - code: true, - title: true, - tasks: { - select: { id: true, title: true, description: true, priority: true, status: true }, - orderBy: [{ sort_order: 'asc' }], - }, - }, - orderBy: { sort_order: 'asc' }, - }), - ]) - - const tasks: SoloTask[] = rawTasks.map((task) => ({ - id: task.id, - title: task.title, - description: task.description, - implementation_plan: task.implementation_plan, - priority: task.priority, - sort_order: task.sort_order, - status: task.status as SoloTask['status'], - verify_only: task.verify_only, - verify_required: task.verify_required as SoloTask['verify_required'], - story_id: task.story.id, - story_code: task.story.code, - story_title: task.story.title, - task_code: task.code, - pbi_code: task.story.pbi?.code ?? null, - pbi_title: task.story.pbi?.title ?? null, - pbi_description: task.story.pbi?.description ?? null, - })) - - const unassignedStories: SoloUnassignedStory[] = rawUnassigned.map((story) => ({ - id: story.id, - code: story.code, - title: story.title, - tasks: story.tasks.map((task) => ({ - id: task.id, - title: task.title, - description: task.description, - priority: task.priority, - status: task.status, - })), - })) - - return { - product: { id: product.id, name: product.name, repo_url: product.repo_url }, - sprint: { id: sprint.id, sprint_goal: sprint.sprint_goal }, - activeUserId: userId, - tasks, - unassignedStories, - } -} diff --git a/lib/sprint-conflicts.ts b/lib/sprint-conflicts.ts deleted file mode 100644 index c255ca5..0000000 --- a/lib/sprint-conflicts.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { - Prisma, - PrismaClient, - SprintStatus, - StoryStatus, -} from '@prisma/client' - -export type EligibilityReason = 'DONE' | 'IN_OTHER_SPRINT' - -export type CrossSprintBlock = { - storyId: string - sprintId: string - sprintName: string -} - -export type EligibilityPartition = { - eligible: string[] - notEligible: { storyId: string; reason: EligibilityReason }[] - crossSprint: CrossSprintBlock[] -} - -type StoryEligibilityInput = { - sprint_id: string | null - status: StoryStatus - // De bijbehorende sprint, indien sprint_id !== null. Alleen sprints met - // status='OPEN' blokkeren eligibility — een story uit een CLOSED/ARCHIVED/ - // FAILED sprint is weer beschikbaar voor planning (consistent met - // getBlockingSprintMap, dat alleen OPEN sprints als blocking telt). - sprint?: { status: SprintStatus } | null -} - -export function isEligibleForSprint(story: StoryEligibilityInput): boolean { - if (story.status === 'DONE') return false - if (story.sprint_id === null) return true - // Story heeft een sprint_id — sprint-status MOET bekend zijn om eligibility - // te bepalen. Ontbrekende sprint-data is conservatief: niet eligible. - if (!story.sprint) return false - return story.sprint.status !== 'OPEN' -} - -type PrismaLike = Pick<PrismaClient, 'story'> | Prisma.TransactionClient - -export async function partitionByEligibility( - prisma: PrismaLike, - storyIds: string[], - excludeSprintId?: string, -): Promise<EligibilityPartition> { - if (storyIds.length === 0) { - return { eligible: [], notEligible: [], crossSprint: [] } - } - - const stories = await prisma.story.findMany({ - where: { id: { in: storyIds } }, - select: { - id: true, - sprint_id: true, - status: true, - sprint: { select: { id: true, code: true, status: true } }, - }, - }) - - const eligible: string[] = [] - const notEligible: { storyId: string; reason: EligibilityReason }[] = [] - const crossSprint: CrossSprintBlock[] = [] - - for (const story of stories) { - // Een story blokkeert alleen als hij in een ANDERE sprint zit DIE NOG OPEN - // is. Stories die in een CLOSED/ARCHIVED/FAILED sprint zaten, zijn weer - // vrij voor planning — consistent met getBlockingSprintMap. - const inOtherActiveSprint = - story.sprint_id !== null && - story.sprint_id !== excludeSprintId && - story.sprint?.status === 'OPEN' - - if (inOtherActiveSprint && story.sprint) { - crossSprint.push({ - storyId: story.id, - sprintId: story.sprint.id, - sprintName: story.sprint.code, - }) - notEligible.push({ storyId: story.id, reason: 'IN_OTHER_SPRINT' }) - continue - } - - if (story.status === 'DONE') { - notEligible.push({ storyId: story.id, reason: 'DONE' }) - continue - } - - eligible.push(story.id) - } - - return { eligible, notEligible, crossSprint } -} - -export async function getBlockingSprintMap( - prisma: PrismaLike, - productId: string, - storyIds: string[], - excludeSprintId?: string, -): Promise<Map<string, { sprintId: string; sprintName: string }>> { - const out = new Map<string, { sprintId: string; sprintName: string }>() - if (storyIds.length === 0) return out - - const stories = await prisma.story.findMany({ - where: { - id: { in: storyIds }, - product_id: productId, - sprint_id: { not: null }, - sprint: { status: 'OPEN' }, - }, - select: { - id: true, - sprint_id: true, - sprint: { select: { id: true, code: true, status: true } }, - }, - }) - - for (const story of stories) { - if (!story.sprint) continue - if (excludeSprintId !== undefined && story.sprint.id === excludeSprintId) continue - out.set(story.id, { - sprintId: story.sprint.id, - sprintName: story.sprint.code, - }) - } - - return out -} diff --git a/lib/sprint-switcher-data.ts b/lib/sprint-switcher-data.ts deleted file mode 100644 index db170d6..0000000 --- a/lib/sprint-switcher-data.ts +++ /dev/null @@ -1,62 +0,0 @@ -import 'server-only' - -import { prisma } from '@/lib/prisma' -import { resolveActiveSprint } from '@/lib/active-sprint' -import { sprintStatusToApi, type SprintStatusApi } from '@/lib/task-status' - -export type SprintSwitcherItem = { - id: string - code: string - sprint_goal: string - status: SprintStatusApi -} - -export interface SprintSwitcherData { - sprintItems: SprintSwitcherItem[] - buildingSprintIds: string[] - activeSprintItem: SprintSwitcherItem | null -} - -export async function getSprintSwitcherData( - productId: string, - opts?: { activeSprintId?: string | null; userId?: string }, -): Promise<SprintSwitcherData> { - const allSprints = await prisma.sprint.findMany({ - where: { product_id: productId }, - orderBy: { created_at: 'desc' }, - select: { id: true, code: true, sprint_goal: true, status: true }, - }) - - let buildingSprintIds: string[] = [] - if (allSprints.length > 0) { - const runs = await prisma.sprintRun.findMany({ - where: { - sprint_id: { in: allSprints.map(s => s.id) }, - status: { in: ['QUEUED', 'RUNNING'] }, - }, - select: { sprint_id: true }, - }) - buildingSprintIds = Array.from(new Set(runs.map(r => r.sprint_id))) - } - - const sprintItems: SprintSwitcherItem[] = allSprints.map(s => ({ - id: s.id, - code: s.code, - sprint_goal: s.sprint_goal, - status: sprintStatusToApi(s.status), - })) - - let activeSprintItem: SprintSwitcherItem | null = null - if (opts?.activeSprintId !== undefined) { - activeSprintItem = opts.activeSprintId - ? sprintItems.find(s => s.id === opts.activeSprintId) ?? null - : null - } else if (opts?.userId) { - const resolved = await resolveActiveSprint(productId, opts.userId) - activeSprintItem = resolved - ? sprintItems.find(s => s.id === resolved.id) ?? null - : null - } - - return { sprintItems, buildingSprintIds, activeSprintItem } -} diff --git a/lib/task-status.ts b/lib/task-status.ts index 8822d9f..3968042 100644 --- a/lib/task-status.ts +++ b/lib/task-status.ts @@ -1,21 +1,13 @@ // Bidirectionele case-mappers voor de REST API-boundary. // DB houdt UPPER_SNAKE; API exposeert lowercase. -import type { - TaskStatus, - StoryStatus, - PbiStatus, - SprintStatus, - SprintRunStatus, -} from '@prisma/client' +import type { TaskStatus, StoryStatus, PbiStatus } from '@prisma/client' const TASK_DB_TO_API = { TO_DO: 'todo', IN_PROGRESS: 'in_progress', REVIEW: 'review', DONE: 'done', - FAILED: 'failed', - EXCLUDED: 'excluded', } as const satisfies Record<TaskStatus, string> const TASK_API_TO_DB: Record<string, TaskStatus> = { @@ -23,75 +15,35 @@ const TASK_API_TO_DB: Record<string, TaskStatus> = { in_progress: 'IN_PROGRESS', review: 'REVIEW', done: 'DONE', - failed: 'FAILED', - excluded: 'EXCLUDED', } const STORY_DB_TO_API = { OPEN: 'open', IN_SPRINT: 'in_sprint', DONE: 'done', - FAILED: 'failed', } as const satisfies Record<StoryStatus, string> const STORY_API_TO_DB: Record<string, StoryStatus> = { open: 'OPEN', in_sprint: 'IN_SPRINT', done: 'DONE', - failed: 'FAILED', } const PBI_DB_TO_API = { READY: 'ready', BLOCKED: 'blocked', - FAILED: 'failed', DONE: 'done', } as const satisfies Record<PbiStatus, string> const PBI_API_TO_DB: Record<string, PbiStatus> = { ready: 'READY', blocked: 'BLOCKED', - failed: 'FAILED', done: 'DONE', } -const SPRINT_DB_TO_API = { - OPEN: 'open', - CLOSED: 'closed', - ARCHIVED: 'archived', - FAILED: 'failed', -} as const satisfies Record<SprintStatus, string> - -const SPRINT_API_TO_DB: Record<string, SprintStatus> = { - open: 'OPEN', - closed: 'CLOSED', - archived: 'ARCHIVED', - failed: 'FAILED', -} - -const SPRINT_RUN_DB_TO_API = { - QUEUED: 'queued', - RUNNING: 'running', - PAUSED: 'paused', - DONE: 'done', - FAILED: 'failed', - CANCELLED: 'cancelled', -} as const satisfies Record<SprintRunStatus, string> - -const SPRINT_RUN_API_TO_DB: Record<string, SprintRunStatus> = { - queued: 'QUEUED', - running: 'RUNNING', - paused: 'PAUSED', - done: 'DONE', - failed: 'FAILED', - cancelled: 'CANCELLED', -} - export type TaskStatusApi = typeof TASK_DB_TO_API[TaskStatus] export type StoryStatusApi = typeof STORY_DB_TO_API[StoryStatus] export type PbiStatusApi = typeof PBI_DB_TO_API[PbiStatus] -export type SprintStatusApi = typeof SPRINT_DB_TO_API[SprintStatus] -export type SprintRunStatusApi = typeof SPRINT_RUN_DB_TO_API[SprintRunStatus] export function taskStatusToApi(s: TaskStatus): TaskStatusApi { return TASK_DB_TO_API[s] @@ -117,24 +69,6 @@ export function pbiStatusFromApi(s: string): PbiStatus | null { return PBI_API_TO_DB[s.toLowerCase()] ?? null } -export function sprintStatusToApi(s: SprintStatus): SprintStatusApi { - return SPRINT_DB_TO_API[s] -} - -export function sprintStatusFromApi(s: string): SprintStatus | null { - return SPRINT_API_TO_DB[s.toLowerCase()] ?? null -} - -export function sprintRunStatusToApi(s: SprintRunStatus): SprintRunStatusApi { - return SPRINT_RUN_DB_TO_API[s] -} - -export function sprintRunStatusFromApi(s: string): SprintRunStatus | null { - return SPRINT_RUN_API_TO_DB[s.toLowerCase()] ?? null -} - export const TASK_STATUS_API_VALUES = Object.values(TASK_DB_TO_API) export const STORY_STATUS_API_VALUES = Object.values(STORY_DB_TO_API) export const PBI_STATUS_API_VALUES = Object.values(PBI_DB_TO_API) -export const SPRINT_STATUS_API_VALUES = Object.values(SPRINT_DB_TO_API) -export const SPRINT_RUN_STATUS_API_VALUES = Object.values(SPRINT_RUN_DB_TO_API) diff --git a/lib/tasks-status-update.ts b/lib/tasks-status-update.ts index fe1d8ab..ca273ca 100644 --- a/lib/tasks-status-update.ts +++ b/lib/tasks-status-update.ts @@ -1,7 +1,9 @@ -import type { Prisma, TaskStatus, StoryStatus, PbiStatus, SprintStatus } from '@prisma/client' +import type { Prisma, TaskStatus } from '@prisma/client' import { prisma } from '@/lib/prisma' -export interface PropagationResult { +export type StoryStatusChange = 'promoted' | 'demoted' | null + +export interface UpdateTaskStatusResult { task: { id: string title: string @@ -9,33 +11,21 @@ export interface PropagationResult { story_id: string implementation_plan: string | null } + storyStatusChange: StoryStatusChange storyId: string - storyChanged: boolean - pbiChanged: boolean - sprintChanged: boolean - sprintRunChanged: boolean } -// Real-time status-propagatie: bij elke task-statuswijziging wordt de keten -// Task → Story → PBI → Sprint → SprintRun herevalueerd binnen één transactie. -// -// Regels: -// Story: ANY task FAILED → FAILED, ELSE ALL DONE → DONE, -// ELSE IN_SPRINT (mits story.sprint_id != null), anders OPEN -// PBI: ANY story FAILED → FAILED, ELSE ALL DONE → DONE, ELSE READY -// (BLOCKED is handmatig en wordt niet overschreven door deze helper) -// Sprint: ANY PBI van een story-in-sprint FAILED → FAILED, -// ELSE ALL PBIs van die stories DONE → COMPLETED, -// ELSE ACTIVE -// SprintRun: Sprint→FAILED → SprintRun=FAILED + cancel openstaand werk + -// zet failed_task_id; Sprint→COMPLETED → SprintRun=DONE; anders -// blijft SprintRun ongewijzigd. -export async function propagateStatusUpwards( +// Update task.status atomically and auto-promote/demote the parent story: +// - All sibling tasks DONE → story.status = DONE +// - Story was DONE and a task moves out of DONE → story.status = IN_SPRINT +// Demote target is IN_SPRINT (not OPEN): OPEN means "back in product backlog", +// which is a sprint-management action, not a status side-effect. +export async function updateTaskStatusWithStoryPromotion( taskId: string, newStatus: TaskStatus, client?: Prisma.TransactionClient, -): Promise<PropagationResult> { - const run = async (tx: Prisma.TransactionClient): Promise<PropagationResult> => { +): Promise<UpdateTaskStatusResult> { + const run = async (tx: Prisma.TransactionClient): Promise<UpdateTaskStatusResult> => { const task = await tx.task.update({ where: { id: taskId }, data: { status: newStatus }, @@ -48,167 +38,33 @@ export async function propagateStatusUpwards( }, }) - // Story herevalueren const siblings = await tx.task.findMany({ where: { story_id: task.story_id }, select: { status: true }, }) - const anyTaskFailed = siblings.some((s) => s.status === 'FAILED') - const allTasksDone = - siblings.length > 0 && siblings.every((s) => s.status === 'DONE') + const allDone = siblings.every((s) => s.status === 'DONE') const story = await tx.story.findUniqueOrThrow({ where: { id: task.story_id }, - select: { id: true, status: true, pbi_id: true, sprint_id: true }, + select: { status: true }, }) - const defaultActive: StoryStatus = story.sprint_id ? 'IN_SPRINT' : 'OPEN' - let nextStoryStatus: StoryStatus - if (anyTaskFailed) nextStoryStatus = 'FAILED' - else if (allTasksDone) nextStoryStatus = 'DONE' - else nextStoryStatus = defaultActive - - let storyChanged = false - if (nextStoryStatus !== story.status) { + let storyStatusChange: StoryStatusChange = null + if (newStatus === 'DONE' && allDone && story.status !== 'DONE') { await tx.story.update({ - where: { id: story.id }, - data: { status: nextStoryStatus }, + where: { id: task.story_id }, + data: { status: 'DONE' }, }) - storyChanged = true + storyStatusChange = 'promoted' + } else if (newStatus !== 'DONE' && story.status === 'DONE') { + await tx.story.update({ + where: { id: task.story_id }, + data: { status: 'IN_SPRINT' }, + }) + storyStatusChange = 'demoted' } - // PBI herevalueren — BLOCKED met rust laten - const pbi = await tx.pbi.findUniqueOrThrow({ - where: { id: story.pbi_id }, - select: { id: true, status: true }, - }) - - let pbiChanged = false - if (pbi.status !== 'BLOCKED') { - const pbiStories = await tx.story.findMany({ - where: { pbi_id: pbi.id }, - select: { status: true }, - }) - const anyStoryFailed = pbiStories.some((s) => s.status === 'FAILED') - const allStoriesDone = - pbiStories.length > 0 && pbiStories.every((s) => s.status === 'DONE') - - let nextPbiStatus: PbiStatus - if (anyStoryFailed) nextPbiStatus = 'FAILED' - else if (allStoriesDone) nextPbiStatus = 'DONE' - else nextPbiStatus = 'READY' - - if (nextPbiStatus !== pbi.status) { - await tx.pbi.update({ - where: { id: pbi.id }, - data: { status: nextPbiStatus }, - }) - pbiChanged = true - } - } - - // Sprint herevalueren — alleen als deze story aan een sprint hangt - let sprintChanged = false - let nextSprintStatus: SprintStatus | null = null - if (story.sprint_id) { - const sprint = await tx.sprint.findUniqueOrThrow({ - where: { id: story.sprint_id }, - select: { id: true, status: true }, - }) - - const sprintPbiRows = await tx.story.findMany({ - where: { sprint_id: sprint.id }, - select: { pbi_id: true }, - distinct: ['pbi_id'], - }) - const sprintPbis = await tx.pbi.findMany({ - where: { id: { in: sprintPbiRows.map((s) => s.pbi_id) } }, - select: { status: true }, - }) - const anyPbiFailed = sprintPbis.some((p) => p.status === 'FAILED') - const allPbisDone = - sprintPbis.length > 0 && sprintPbis.every((p) => p.status === 'DONE') - - let nextStatus: SprintStatus - if (anyPbiFailed) nextStatus = 'FAILED' - else if (allPbisDone) nextStatus = 'CLOSED' - else nextStatus = 'OPEN' - - if (nextStatus !== sprint.status) { - await tx.sprint.update({ - where: { id: sprint.id }, - data: { - status: nextStatus, - ...(nextStatus === 'CLOSED' ? { completed_at: new Date() } : {}), - }, - }) - sprintChanged = true - nextSprintStatus = nextStatus - } - } - - // SprintRun herevalueren — via ClaudeJob.sprint_run_id van deze task - let sprintRunChanged = false - if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'CLOSED') { - const job = await tx.claudeJob.findFirst({ - where: { task_id: taskId, sprint_run_id: { not: null } }, - orderBy: { created_at: 'desc' }, - select: { id: true, sprint_run_id: true }, - }) - - if (job?.sprint_run_id) { - const sprintRun = await tx.sprintRun.findUnique({ - where: { id: job.sprint_run_id }, - select: { id: true, status: true }, - }) - if ( - sprintRun && - (sprintRun.status === 'QUEUED' || - sprintRun.status === 'RUNNING' || - sprintRun.status === 'PAUSED') - ) { - if (nextSprintStatus === 'FAILED') { - await tx.sprintRun.update({ - where: { id: sprintRun.id }, - data: { - status: 'FAILED', - finished_at: new Date(), - failed_task_id: taskId, - }, - }) - await tx.claudeJob.updateMany({ - where: { - sprint_run_id: sprintRun.id, - status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] }, - id: { not: job.id }, - }, - data: { - status: 'CANCELLED', - finished_at: new Date(), - error: `Cancelled: task ${taskId} failed in same sprint run`, - }, - }) - sprintRunChanged = true - } else { - // COMPLETED - await tx.sprintRun.update({ - where: { id: sprintRun.id }, - data: { status: 'DONE', finished_at: new Date() }, - }) - sprintRunChanged = true - } - } - } - } - - return { - task, - storyId: task.story_id, - storyChanged, - pbiChanged, - sprintChanged, - sprintRunChanged, - } + return { task, storyStatusChange, storyId: task.story_id } } if (client) return run(client) diff --git a/lib/user-agent.ts b/lib/user-agent.ts deleted file mode 100644 index 6fc1836..0000000 --- a/lib/user-agent.ts +++ /dev/null @@ -1,8 +0,0 @@ -// PBI-11 / ST-1135: detecteert telefoon-UA's voor login-redirect. -// Heuristiek: 'Mobi' in de UA-string. Zit in Android Chrome en iPhone Safari -// Mobile, NIET in iPad of Android-tablet — exact wat we willen voor de -// `/m/*`-mobile-shell (alleen telefoons, geen tablets). - -export function isPhoneUA(ua: string | null): boolean { - return ua !== null && ua.includes('Mobi') -} diff --git a/lib/user-settings-migration.ts b/lib/user-settings-migration.ts deleted file mode 100644 index 14d2028..0000000 --- a/lib/user-settings-migration.ts +++ /dev/null @@ -1,296 +0,0 @@ -// PBI-76 Phase 1: one-shot migratie van legacy localStorage-prefs → user.settings. -// -// `UserSettingsBridge` roept dit eenmaal na hydratie aan. Bestaande users -// behouden zo hun saved filters; nieuwe users hebben simpelweg geen legacy keys -// en de helper is een no-op. De marker (`scrum4me:settings_migrated`) zorgt -// dat de migratie idempotent is — tweede call na succes returnt `null`. - -import type { UserSettings } from './user-settings' - -const MIGRATION_MARKER = 'scrum4me:settings_migrated' -const CURRENT_VERSION = 'v2' - -export interface MigrationResult { - patch: Partial<UserSettings> - legacyKeys: string[] - legacyCookies: string[] - hasData: boolean -} - -function readCookies(): Record<string, string> { - if (typeof document === 'undefined') return {} - const out: Record<string, string> = {} - for (const part of document.cookie.split(';')) { - const eq = part.indexOf('=') - if (eq < 0) continue - const key = part.slice(0, eq).trim() - const val = part.slice(eq + 1).trim() - if (key) out[key] = val - } - return out -} - -function readJsonArray(key: string): string[] | null { - const raw = localStorage.getItem(key) - if (!raw) return null - try { - const arr = JSON.parse(raw) - return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === 'string') : null - } catch { - return null - } -} - -function readPriority(key: string): number | 'all' | null { - const raw = localStorage.getItem(key) - if (raw === null) return null - if (raw === 'all') return 'all' - const n = parseInt(raw, 10) - return Number.isInteger(n) && n >= 1 && n <= 4 ? n : null -} - -function readEnum<T extends string>(key: string, allowed: readonly T[]): T | null { - const raw = localStorage.getItem(key) - return raw && (allowed as readonly string[]).includes(raw) ? (raw as T) : null -} - -function readBoolean(key: string): boolean | null { - const raw = localStorage.getItem(key) - if (raw === 'true') return true - if (raw === 'false') return false - return null -} - -function setIfNotNull<T>(target: Record<string, unknown>, key: string, value: T | null): boolean { - if (value === null) return false - target[key] = value - return true -} - -export function buildMigrationPatch(): MigrationResult { - const empty: MigrationResult = { - patch: {}, - legacyKeys: [], - legacyCookies: [], - hasData: false, - } - if (typeof window === 'undefined') return empty - if (localStorage.getItem(MIGRATION_MARKER) === CURRENT_VERSION) return empty - - const patch: Partial<UserSettings> = {} - const views: NonNullable<UserSettings['views']> = {} - const layout: NonNullable<UserSettings['layout']> = {} - const legacyKeys: string[] = [] - const legacyCookies: string[] = [] - let hasData = false - - // sprint_pb_* - const sprintBacklog: Record<string, unknown> = {} - const SPRINT_KEYS = { - filterPriority: 'scrum4me:sprint_pb_filter_priority', - filterStatus: 'scrum4me:sprint_pb_filter_status', - sort: 'scrum4me:sprint_pb_sort', - sortDir: 'scrum4me:sprint_pb_sort_dir', - collapsed: 'scrum4me:sprint_pb_collapsed', - popover: 'scrum4me:sprint_pb_filter_popover_open', - } - if ( - setIfNotNull(sprintBacklog, 'filterPriority', readPriority(SPRINT_KEYS.filterPriority)) || - [SPRINT_KEYS.filterPriority].some((k) => localStorage.getItem(k) !== null) - ) { - legacyKeys.push(SPRINT_KEYS.filterPriority) - } - if ( - setIfNotNull( - sprintBacklog, - 'filterStatus', - readEnum(SPRINT_KEYS.filterStatus, ['OPEN', 'IN_SPRINT', 'DONE', 'all'] as const), - ) - ) { - legacyKeys.push(SPRINT_KEYS.filterStatus) - } - if ( - setIfNotNull( - sprintBacklog, - 'sort', - readEnum(SPRINT_KEYS.sort, ['priority', 'status', 'code'] as const), - ) - ) { - legacyKeys.push(SPRINT_KEYS.sort) - } - if ( - setIfNotNull( - sprintBacklog, - 'sortDir', - readEnum(SPRINT_KEYS.sortDir, ['asc', 'desc'] as const), - ) - ) { - legacyKeys.push(SPRINT_KEYS.sortDir) - } - const collapsed = readJsonArray(SPRINT_KEYS.collapsed) - if (collapsed !== null) { - sprintBacklog.collapsedPbis = collapsed - legacyKeys.push(SPRINT_KEYS.collapsed) - } - if (setIfNotNull(sprintBacklog, 'filterPopoverOpen', readBoolean(SPRINT_KEYS.popover))) { - legacyKeys.push(SPRINT_KEYS.popover) - } - if (Object.keys(sprintBacklog).length > 0) { - views.sprintBacklog = sprintBacklog as NonNullable<typeof views.sprintBacklog> - hasData = true - } - - // pbi_* - const pbiList: Record<string, unknown> = {} - const PBI_KEYS = { - sort: 'scrum4me:pbi_sort', - filterPriority: 'scrum4me:pbi_filter_priority', - filterStatus: 'scrum4me:pbi_filter_status', - sortDir: 'scrum4me:pbi_sort_dir', - } - if ( - setIfNotNull(pbiList, 'sort', readEnum(PBI_KEYS.sort, ['priority', 'code', 'date'] as const)) - ) { - legacyKeys.push(PBI_KEYS.sort) - } - if (setIfNotNull(pbiList, 'filterPriority', readPriority(PBI_KEYS.filterPriority))) { - legacyKeys.push(PBI_KEYS.filterPriority) - } - if ( - setIfNotNull( - pbiList, - 'filterStatus', - readEnum(PBI_KEYS.filterStatus, ['ready', 'blocked', 'done', 'all'] as const), - ) - ) { - legacyKeys.push(PBI_KEYS.filterStatus) - } - if (setIfNotNull(pbiList, 'sortDir', readEnum(PBI_KEYS.sortDir, ['asc', 'desc'] as const))) { - legacyKeys.push(PBI_KEYS.sortDir) - } - if (Object.keys(pbiList).length > 0) { - views.pbiList = pbiList as NonNullable<typeof views.pbiList> - hasData = true - } - - // story_sort - const storyPanel: Record<string, unknown> = {} - const STORY_KEY = 'scrum4me:story_sort' - if ( - setIfNotNull(storyPanel, 'sort', readEnum(STORY_KEY, ['priority', 'code', 'date'] as const)) - ) { - legacyKeys.push(STORY_KEY) - } - if (Object.keys(storyPanel).length > 0) { - views.storyPanel = storyPanel as NonNullable<typeof views.storyPanel> - hasData = true - } - - // jobs-column dynamic prefixes: <prefix>_filter_kind, <prefix>_filter_status - const jobsColumns: Record<string, { kinds: string[]; statuses: string[] }> = {} - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i) - if (!key) continue - const kindMatch = key.match(/^(.+)_filter_kind$/) - const statusMatch = key.match(/^(.+)_filter_status$/) - // Skip the legacy sprint/pbi keys we already handled above - if (key.startsWith('scrum4me:')) continue - if (kindMatch) { - const prefix = kindMatch[1] - const csv = localStorage.getItem(key) ?? '' - const kinds = csv.split(',').map((s) => s.trim()).filter(Boolean) - jobsColumns[prefix] = { ...(jobsColumns[prefix] ?? { kinds: [], statuses: [] }), kinds } - legacyKeys.push(key) - } else if (statusMatch) { - const prefix = statusMatch[1] - const csv = localStorage.getItem(key) ?? '' - const statuses = csv.split(',').map((s) => s.trim()).filter(Boolean) - jobsColumns[prefix] = { ...(jobsColumns[prefix] ?? { kinds: [], statuses: [] }), statuses } - legacyKeys.push(key) - } - } - if (Object.keys(jobsColumns).length > 0) { - views.jobsColumns = jobsColumns - hasData = true - } - - if (Object.keys(views).length > 0) { - patch.views = views - } - - // devTools.debugMode - const DEBUG_KEY = 'scrum4me:debug-mode' - const debug = readBoolean(DEBUG_KEY) - if (debug !== null) { - patch.devTools = { debugMode: debug } - legacyKeys.push(DEBUG_KEY) - hasData = true - } - - // layout from cookies (Phase 2) - const cookies = readCookies() - const splitPanePositions: Record<string, number[]> = {} - const activeSprints: Record<string, string> = {} - for (const [name, rawValue] of Object.entries(cookies)) { - if (name.startsWith('sp:')) { - const key = name.slice(3) - try { - const arr = JSON.parse(decodeURIComponent(rawValue)) - if ( - Array.isArray(arr) && - arr.every((n) => typeof n === 'number') && - Math.abs(arr.reduce((a, b) => a + b, 0) - 100) <= 1 - ) { - splitPanePositions[key] = arr as number[] - legacyCookies.push(name) - } - } catch { - // ignore malformed cookie - } - } else if (name.startsWith('active_sprint_') && rawValue) { - const productId = name.slice('active_sprint_'.length) - activeSprints[productId] = decodeURIComponent(rawValue) - legacyCookies.push(name) - } - } - if (Object.keys(splitPanePositions).length > 0) { - layout.splitPanePositions = splitPanePositions - hasData = true - } - if (Object.keys(activeSprints).length > 0) { - layout.activeSprints = activeSprints - hasData = true - } - if (Object.keys(layout).length > 0) { - patch.layout = layout - } - - return { patch, legacyKeys, legacyCookies, hasData } -} - -export function clearLegacyStorage(keys: string[], cookies: string[] = []): void { - if (typeof window === 'undefined') return - for (const k of keys) { - try { - localStorage.removeItem(k) - } catch { - // storage quota exceeded or disabled — ignore - } - } - for (const c of cookies) { - try { - document.cookie = `${c}=; max-age=0; path=/; samesite=lax` - } catch { - // ignore - } - } - try { - localStorage.setItem(MIGRATION_MARKER, CURRENT_VERSION) - } catch { - // ignore - } -} - -/** @deprecated use clearLegacyStorage */ -export const clearLegacyLocalStorage = (keys: string[]) => - clearLegacyStorage(keys, []) diff --git a/lib/user-settings.ts b/lib/user-settings.ts deleted file mode 100644 index 7137006..0000000 --- a/lib/user-settings.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { z } from 'zod' -import { JOBS_TIME_FILTER_VALUES } from '@/lib/jobs-time-filter' -import { IDEA_STATUS_API_VALUES, type IdeaStatusApi } from '@/lib/idea-status' - -const PriorityFilter = z.union([ - z.number().int().min(1).max(4), - z.literal('all'), -]) - -const SortDir = z.enum(['asc', 'desc']) - -const SprintBacklogPrefs = z.object({ - filterPriority: PriorityFilter.optional(), - filterStatus: z.enum(['OPEN', 'IN_SPRINT', 'DONE', 'all']).optional(), - sort: z.enum(['priority', 'status', 'code']).optional(), - sortDir: SortDir.optional(), - collapsedPbis: z.array(z.string()).optional(), - filterPopoverOpen: z.boolean().optional(), -}).strict() - -const PbiListPrefs = z.object({ - sort: z.enum(['priority', 'code', 'date']).optional(), - filterPriority: PriorityFilter.optional(), - filterStatus: z.enum(['ready', 'blocked', 'done', 'all']).optional(), - sortDir: SortDir.optional(), -}).strict() - -const StoryPanelPrefs = z.object({ - sort: z.enum(['priority', 'code', 'date']).optional(), -}).strict() - -const JobsColumnPrefs = z.object({ - kinds: z.array(z.string()), - statuses: z.array(z.string()), -}).strict() - -const JobsViewPrefs = z.object({ - timeFilter: z.enum(JOBS_TIME_FILTER_VALUES).optional(), -}).strict() - -const IdeasListPrefs = z.object({ - filterStatuses: z.array( - z.enum(IDEA_STATUS_API_VALUES as [IdeaStatusApi, ...IdeaStatusApi[]]) - ).optional(), -}).strict() - -const ViewsPrefs = z.object({ - sprintBacklog: SprintBacklogPrefs.optional(), - pbiList: PbiListPrefs.optional(), - storyPanel: StoryPanelPrefs.optional(), - jobsColumns: z.record(z.string(), JobsColumnPrefs).optional(), - jobs: JobsViewPrefs.optional(), - ideasList: IdeasListPrefs.optional(), -}).strict() - -const DevToolsPrefs = z.object({ - debugMode: z.boolean().optional(), -}).strict() - -const LayoutPrefs = z.object({ - splitPanePositions: z.record(z.string(), z.array(z.number())).optional(), - activeSprints: z.record(z.string(), z.string().nullable()).optional(), - activePbis: z.record(z.string(), z.string().nullable()).optional(), - activeStories: z.record(z.string(), z.string().nullable()).optional(), -}).strict() - -const PbiIntent = z.enum(['all', 'none']) - -const StoryOverrides = z.object({ - add: z.array(z.string()), - remove: z.array(z.string()), -}).strict() - -const PendingSprintDraftSchema = z.object({ - goal: z.string().min(1), - startAt: z.string().date().optional(), - endAt: z.string().date().optional(), - pbiIntent: z.record(z.string(), PbiIntent).default({}), - storyOverrides: z.record(z.string(), StoryOverrides).default({}), -}).strict() - -const WorkflowPrefs = z.object({ - pendingSprintDraft: z.record(z.string(), PendingSprintDraftSchema).optional(), -}).strict() - -export const UserSettingsSchema = z.object({ - views: ViewsPrefs.optional(), - devTools: DevToolsPrefs.optional(), - layout: LayoutPrefs.optional(), - workflow: WorkflowPrefs.optional(), -}).strict() - -export type UserSettings = z.infer<typeof UserSettingsSchema> -export type PendingSprintDraft = z.infer<typeof PendingSprintDraftSchema> -export type PbiIntent = z.infer<typeof PbiIntent> -export type StoryOverrides = z.infer<typeof StoryOverrides> - -export const DEFAULT_USER_SETTINGS: UserSettings = {} - -function isPlainObject(value: unknown): value is Record<string, unknown> { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -export function mergeSettings( - prev: UserSettings, - patch: Partial<UserSettings>, -): UserSettings { - const out: Record<string, unknown> = { ...prev } - for (const [key, patchValue] of Object.entries(patch)) { - if (patchValue === undefined) continue - const prevValue = (prev as Record<string, unknown>)[key] - if (isPlainObject(patchValue) && isPlainObject(prevValue)) { - out[key] = mergeSettings( - prevValue as UserSettings, - patchValue as Partial<UserSettings>, - ) - } else { - out[key] = patchValue - } - } - return out as UserSettings -} - -export function parseUserSettings(raw: unknown): UserSettings { - if (raw === null || raw === undefined) return DEFAULT_USER_SETTINGS - const result = UserSettingsSchema.safeParse(raw) - return result.success ? result.data : DEFAULT_USER_SETTINGS -} diff --git a/next.config.ts b/next.config.ts index 631b848..f457fae 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,5 +1,4 @@ import type { NextConfig } from "next" -import { withSentryConfig } from "@sentry/nextjs" import pkg from "./package.json" const nextConfig: NextConfig = { @@ -11,17 +10,4 @@ const nextConfig: NextConfig = { }, } -// PBI/v1-readiness item 2: source-map-upload + tunnel pas actief als DSN + -// auth-token aanwezig zijn. Zonder env-vars draait `withSentryConfig` als -// no-op zodat lokale dev en CI zonder Sentry-creds blijft werken. -export default withSentryConfig(nextConfig, { - org: process.env.SENTRY_ORG, - project: process.env.SENTRY_PROJECT, - authToken: process.env.SENTRY_AUTH_TOKEN, - silent: !process.env.CI, - // Tunnel /monitoring → omzeilt ad-blockers die *.sentry.io blokkeren. - tunnelRoute: '/monitoring', - // Source-maps niet uploaden als auth-token ontbreekt. - sourcemaps: { disable: !process.env.SENTRY_AUTH_TOKEN }, - disableLogger: true, -}) +export default nextConfig diff --git a/package-lock.json b/package-lock.json index 1092e43..af110cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "scrum4me", - "version": "1.3.3", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scrum4me", - "version": "1.3.3", + "version": "0.4.0", "hasInstallScript": true, "dependencies": { "@base-ui/react": "^1.4.1", @@ -16,7 +16,6 @@ "@hookform/resolvers": "^5.2.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", - "@sentry/nextjs": "^10.51.0", "@tanstack/react-table": "^8.21.3", "@vercel/analytics": "^2.0.1", "bcryptjs": "^2.4.3", @@ -25,7 +24,6 @@ "dotenv": "^17.4.2", "iron-session": "^8.0.4", "lucide-react": "^1.8.0", - "mermaid": "^11.14.0", "next": "16.2.4", "next-themes": "^0.4.6", "pg": "^8.20.0", @@ -37,16 +35,12 @@ "react-markdown": "^10.1.0", "react-textarea-autosize": "^8.5.9", "recharts": "^3.8.1", - "rehype-autolink-headings": "^7.1.0", - "rehype-slug": "^6.0.0", "remark-gfm": "^4.0.1", "shadcn": "^4.4.0", "sharp": "^0.34.5", "sonner": "^1.7.4", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", - "web-push": "^3.6.7", - "yaml": "^2.8.4", "zod": "^3.25.76", "zustand": "^5.0.12" }, @@ -61,14 +55,15 @@ "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", - "@types/web-push": "^3.6.4", "@vitest/coverage-v8": "^4.1.5", + "chokidar-cli": "^3.0.0", "concurrently": "^9.2.1", "eslint": "^9", "eslint-config-next": "16.2.4", "husky": "^9.1.7", "jsdom": "^29.1.1", "lint-staged": "^16.4.0", + "prisma-erd-generator": "^2.4.2", "tailwindcss": "^4", "tsx": "^4.21.0", "typescript": "^5", @@ -99,6 +94,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "dev": true, "license": "MIT", "dependencies": { "package-manager-detector": "^1.3.0", @@ -647,6 +643,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz", "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", + "dev": true, "license": "MIT" }, "node_modules/@bramus/specificity": { @@ -666,6 +663,7 @@ "version": "12.0.0", "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz", "integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@chevrotain/gast": "12.0.0", @@ -676,6 +674,7 @@ "version": "12.0.0", "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz", "integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@chevrotain/types": "12.0.0" @@ -685,18 +684,21 @@ "version": "12.0.0", "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz", "integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==", + "dev": true, "license": "Apache-2.0" }, "node_modules/@chevrotain/types": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz", "integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==", + "dev": true, "license": "Apache-2.0" }, "node_modules/@chevrotain/utils": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz", "integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==", + "dev": true, "license": "Apache-2.0" }, "node_modules/@csstools/color-helpers": { @@ -1754,108 +1756,6 @@ } } }, - "node_modules/@fastify/otel": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/@fastify/otel/-/otel-0.18.0.tgz", - "integrity": "sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.212.0", - "@opentelemetry/semantic-conventions": "^1.28.0", - "minimatch": "^10.2.4" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - } - }, - "node_modules/@fastify/otel/node_modules/@opentelemetry/api-logs": { - "version": "0.212.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.212.0.tgz", - "integrity": "sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@fastify/otel/node_modules/@opentelemetry/instrumentation": { - "version": "0.212.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.212.0.tgz", - "integrity": "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.212.0", - "import-in-the-middle": "^2.0.6", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@fastify/otel/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@fastify/otel/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@fastify/otel/node_modules/import-in-the-middle": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", - "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.15.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^2.2.0", - "module-details-from-path": "^1.0.4" - } - }, - "node_modules/@fastify/otel/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -2054,12 +1954,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, "license": "MIT" }, "node_modules/@iconify/utils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz", "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", + "dev": true, "license": "MIT", "dependencies": { "@antfu/install-pkg": "^1.1.0", @@ -2159,6 +2061,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2175,6 +2080,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2191,6 +2099,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2207,6 +2118,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2223,6 +2137,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2239,6 +2156,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2255,6 +2175,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2271,6 +2194,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2287,6 +2213,9 @@ "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2309,6 +2238,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2331,6 +2263,9 @@ "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2353,6 +2288,9 @@ "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2375,6 +2313,9 @@ "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2397,6 +2338,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2419,6 +2363,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2441,6 +2388,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2673,17 +2623,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -2759,6 +2698,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz", "integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==", + "dev": true, "license": "MIT", "dependencies": { "langium": "^4.0.0" @@ -2917,6 +2857,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2933,6 +2876,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2949,6 +2895,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2965,6 +2914,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3112,484 +3064,6 @@ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "license": "MIT" }, - "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/api-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", - "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/core": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", - "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/instrumentation": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz", - "integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "import-in-the-middle": "^3.0.0", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-amqplib": { - "version": "0.61.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.61.0.tgz", - "integrity": "sha512-mCKoyTGfRNisge4br0NpOFSy2Z1NnEW8hbCJdUDdJFHrPqVzc4IIBPA/vX0U+LUcQqrQvJX+HMIU0dbDRe0i0Q==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-connect": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-connect/-/instrumentation-connect-0.57.0.tgz", - "integrity": "sha512-FMEBChnI4FLN5TE9DHwfH7QpNir1JzXno1uz/TAucVdLCyrG0jTrKIcNHt/i30A0M2AunNBCkcd8Ei26dIPKdg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.27.0", - "@types/connect": "3.4.38" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-dataloader": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-dataloader/-/instrumentation-dataloader-0.31.0.tgz", - "integrity": "sha512-f654tZFQXS5YeLDNb9KySrwtg7SnqZN119FauD7acBoTzuLduaiGTNz88ixcVSOOMGZ+EjJu/RFtx5klObC95g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-fs": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fs/-/instrumentation-fs-0.33.0.tgz", - "integrity": "sha512-sCZWXGalQ01wr3tAhSR9ucqFJ0phidpAle6/17HVjD6gN8FLmZMK/8sKxdXYHy3PbnlV1P4zeiSVFNKpbFMNLA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-generic-pool": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-generic-pool/-/instrumentation-generic-pool-0.57.0.tgz", - "integrity": "sha512-orhmlaK+ZIW9hKU+nHTbXrCSXZcH83AescTqmpamHRobRmYSQwRbD0a1odc0yAzuzOtxYiHiXAnpnIpaSSY7Ow==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-graphql": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-graphql/-/instrumentation-graphql-0.62.0.tgz", - "integrity": "sha512-3YNuLVPUxafXkH1jBAbGsKNsP3XVzcFDhCDCE3OqBwCwShlqQbLMRMFh1T/d5jaVZiGVmSsfof+ICKD2iOV8xg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-hapi": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-hapi/-/instrumentation-hapi-0.60.0.tgz", - "integrity": "sha512-aNljZKYrEa7obLAxd1bCEDxF7kzCLGXTuTJZ8lMR9rIVEjmuKBXN1gfqpm/OB//Zc2zP4iIve1jBp7sr3mQV6w==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-http": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.214.0.tgz", - "integrity": "sha512-FlkDhZDRjDJDcO2LcSCtjRpkal1NJ8y0fBqBhTvfAR3JSYY2jAIj1kSS5IjmEBt4c3aWv+u/lqLuoCDrrKCSKg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/instrumentation": "0.214.0", - "@opentelemetry/semantic-conventions": "^1.29.0", - "forwarded-parse": "2.1.2" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/instrumentation-ioredis": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.62.0.tgz", - "integrity": "sha512-ZYt//zcPve8qklaZX+5Z4MkU7UpEkFRrxsf2cnaKYBitqDnsCN69CPAuuMOX6NYdW2rG9sFy7V/QWtBlP5XiNQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/redis-common": "^0.38.2", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-kafkajs": { - "version": "0.23.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-kafkajs/-/instrumentation-kafkajs-0.23.0.tgz", - "integrity": "sha512-4K+nVo+zI+aDz0Z85SObwbdixIbzS9moIuKJaYsdlzcHYnKOPtB7ya8r8Ezivy/GVIBHiKJVq4tv+BEkgOMLaQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.30.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-knex": { - "version": "0.58.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-knex/-/instrumentation-knex-0.58.0.tgz", - "integrity": "sha512-Hc/o8fSsaWxZ8r1Yw4rNDLwTpUopTf4X32y4W6UhlHmW8Wizz8wfhgOKIelSeqFVTKBBPIDUOsQWuIMxBmu8Bw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.1" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-koa": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-koa/-/instrumentation-koa-0.62.0.tgz", - "integrity": "sha512-uVip0VuGUQXZ+vFxkKxAUNq8qNl+VFlyHDh/U6IQ8COOEDfbEchdaHnpFrMYF3psZRUuoSIgb7xOeXj00RdwDA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.36.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0" - } - }, - "node_modules/@opentelemetry/instrumentation-lru-memoizer": { - "version": "0.58.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-lru-memoizer/-/instrumentation-lru-memoizer-0.58.0.tgz", - "integrity": "sha512-6grM3TdMyHzlGY1cUA+mwoPueB1F3dYKgKtZIH6jOFXqfHAByyLTc+6PFjGM9tKh52CFBJaDwodNlL/Td39z7Q==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mongodb": { - "version": "0.67.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.67.0.tgz", - "integrity": "sha512-1WJp5N1lYfHq2IhECOTewFs5Tf2NfUOwQRqs/rZdXKTezArMlucxgzAaqcgp3A3YREXopXTpXHsxZTGHjNhMdQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mongoose": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.60.0.tgz", - "integrity": "sha512-8BahAZpKsOoc+lrZGb7Ofn4g3z8qtp5IxDfvAVpKXsEheQN7ONMH5djT5ihy6yf8yyeQJGS0gXFfpEAEeEHqQg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mysql": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.60.0.tgz", - "integrity": "sha512-08pO8GFPEIz2zquKDGteBZDNmwketdgH8hTe9rVYgW9kCJXq1Psj3wPQGx+VaX4ZJKCfPeoLMYup9+cxHvZyVQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@types/mysql": "2.15.27" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-mysql2": { - "version": "0.60.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.60.0.tgz", - "integrity": "sha512-m/5d3bxQALllCzezYDk/6vajh0tj5OijMMvOZGr+qN1NMXm1dzMNwyJ0gNZW7Fo3YFRyj/jJMxIw+W7d525dlw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@opentelemetry/sql-common": "^0.41.2" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-pg": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-pg/-/instrumentation-pg-0.66.0.tgz", - "integrity": "sha512-KxfLGXBb7k2ueaPJfq2GXBDXBly8P+SpR/4Mj410hhNgmQF3sCqwXvUBQxZQkDAmsdBAoenM+yV1LhtsMRamcA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.34.0", - "@opentelemetry/sql-common": "^0.41.2", - "@types/pg": "8.15.6", - "@types/pg-pool": "2.0.7" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-pg/node_modules/@types/pg": { - "version": "8.15.6", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", - "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "pg-protocol": "*", - "pg-types": "^2.2.0" - } - }, - "node_modules/@opentelemetry/instrumentation-redis": { - "version": "0.62.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-redis/-/instrumentation-redis-0.62.0.tgz", - "integrity": "sha512-y3pPpot7WzR/8JtHcYlTYsyY8g+pbFhAqbwAuG5bLPnR6v6pt1rQc0DpH0OlGP/9CZbWBP+Zhwp9yFoygf/ZXQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/redis-common": "^0.38.2", - "@opentelemetry/semantic-conventions": "^1.27.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/instrumentation-tedious": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-tedious/-/instrumentation-tedious-0.33.0.tgz", - "integrity": "sha512-Q6WQwAD01MMTub31GlejoiFACYNw26J426wyjvU7by7fDIr2nZXNW4vhTGs7i7F0TnXBO3xN688g1tdUgYwJ5w==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/semantic-conventions": "^1.33.0", - "@types/tedious": "^4.0.14" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/redis-common": { - "version": "0.38.3", - "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.3.tgz", - "integrity": "sha512-VCghU1JYs/4gP6Gqf/xro9MEsZ7LrMv2uONVsaESKL38ZOB9BqnI98FfS23wjMnHlpuE+TTaWSoAVNpTwYXzjw==", - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" - } - }, - "node_modules/@opentelemetry/resources": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", - "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", - "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.7.1", - "@opentelemetry/resources": "2.7.1", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", - "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/sql-common": { - "version": "0.41.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/sql-common/-/sql-common-0.41.2.tgz", - "integrity": "sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "^2.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0" - } - }, "node_modules/@oxc-project/types": { "version": "0.127.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", @@ -3685,6 +3159,13 @@ "zeptomatch": "2.1.0" } }, + "node_modules/@prisma/dmmf": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/dmmf/-/dmmf-7.8.0.tgz", + "integrity": "sha512-7xzcSFWO6J+dFUgIX7jL7QqUhEDfaa8GSZGsjjHyZct1Su+6KrvMl3S2+fnRkuKUIoTPg3Mj02oZuUdaNSfsaw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@prisma/driver-adapter-utils": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", @@ -3742,6 +3223,25 @@ "@prisma/debug": "7.8.0" } }, + "node_modules/@prisma/generator": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/generator/-/generator-7.8.0.tgz", + "integrity": "sha512-KHGB0b8/9pNWyiK9EPJNE2/v1bMtqJgJldqjNNVvoE4uOhNSSWTmhHhPVfRsiuOVybzHCdCUQ/gdidCbpYAD5w==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/generator-helper": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-7.8.0.tgz", + "integrity": "sha512-i+2Gad6D/0dS0YHKFdYX3M8KYN1gwNkET813WXKfW2HeWmgipmSJsNSzOA44kTM+Rx6Dev3yBQwx5sZXXdtgtQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0", + "@prisma/dmmf": "7.8.0", + "@prisma/generator": "7.8.0" + } + }, "node_modules/@prisma/get-platform": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", @@ -3757,59 +3257,6 @@ "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", "license": "Apache-2.0" }, - "node_modules/@prisma/instrumentation": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@prisma/instrumentation/-/instrumentation-7.6.0.tgz", - "integrity": "sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/instrumentation": "^0.207.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.8" - } - }, - "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/api-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", - "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@prisma/instrumentation/node_modules/@opentelemetry/instrumentation": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.207.0.tgz", - "integrity": "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "import-in-the-middle": "^2.0.0", - "require-in-the-middle": "^8.0.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@prisma/instrumentation/node_modules/import-in-the-middle": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", - "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.15.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^2.2.0", - "module-details-from-path": "^1.0.4" - } - }, "node_modules/@prisma/query-plan-executor": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", @@ -4220,6 +3667,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4237,6 +3687,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4254,6 +3707,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4271,6 +3727,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4288,6 +3747,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -4305,6 +3767,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -4410,432 +3875,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/plugin-commonjs": { - "version": "28.0.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.1.tgz", - "integrity": "sha512-+tNWdlWKbpB3WgBN7ijjYkq9X5uhjmcvyjEght4NmH5fAU++zfQzAJ6wumLS+dNcvwEZhKx2Z+skY8m7v0wGSA==", - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "commondir": "^1.0.1", - "estree-walker": "^2.0.2", - "fdir": "^6.2.0", - "is-reference": "1.2.1", - "magic-string": "^0.30.3", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=16.0.0 || 14 >= 14.17" - }, - "peerDependencies": { - "rollup": "^2.68.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-commonjs/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/@rollup/plugin-commonjs/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-commonjs/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", - "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", - "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", - "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", - "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", - "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", - "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", - "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", - "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", - "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", - "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", - "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", - "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", - "cpu": [ - "loong64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", - "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", - "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", - "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", - "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", - "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", - "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", - "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", - "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", - "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", - "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", - "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", - "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", - "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -4849,526 +3888,6 @@ "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "license": "MIT" }, - "node_modules/@sentry-internal/browser-utils": { - "version": "10.51.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.51.0.tgz", - "integrity": "sha512-lNKBS4P7RUvf1niojXQWe9bU3gnBUCbST4Dj0pSiyat1N96cXVyHkeE+uGxowD0RrVWhs+kGHiVX3FcmRWF6sA==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.51.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/feedback": { - "version": "10.51.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.51.0.tgz", - "integrity": "sha512-bCM95bcpphx28e6aU0bwRLxOgwosYsdNzezM1sM0pVOkb0TB3hDFRamramVDK+/Hp1o8qmRxS4c5w/A7YBZGkA==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.51.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/replay": { - "version": "10.51.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.51.0.tgz", - "integrity": "sha512-jCpI5HXSwK6ZT2HX70+mDRciAocHzSiDk4DTgvzV69Wvd+Ei5WLgE+d39eaEPsm8lUC0Ydntb5sJIB6uG9D4bw==", - "license": "MIT", - "dependencies": { - "@sentry-internal/browser-utils": "10.51.0", - "@sentry/core": "10.51.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry-internal/replay-canvas": { - "version": "10.51.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.51.0.tgz", - "integrity": "sha512-8PW1Pp+Yl3lPwYqhBCr5SgkuhDanu9ZLzUqD2bPKL/ElqbM2eDVIWxq4z4ZzePrmZa6IcCjTv6sVQJ7Z4dLyLA==", - "license": "MIT", - "dependencies": { - "@sentry-internal/replay": "10.51.0", - "@sentry/core": "10.51.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/babel-plugin-component-annotate": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.2.1.tgz", - "integrity": "sha512-QQ9AL5EXIbSK26ObLVtiU6l3tCUdpGSJ/6VwDkPhC3qvtoksSlcoU9Yzm7XC0NBcvu1N2abL5R7gckKGZ4JewQ==", - "license": "MIT", - "engines": { - "node": ">= 18" - } - }, - "node_modules/@sentry/browser": { - "version": "10.51.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.51.0.tgz", - "integrity": "sha512-Zdc0sKfenxUtW/OGhtJ7xHFN44bXR7YqxJ1zBDzlZfW0nTbeTTUZBq9z5NUw6qdS0Vs/i3V4qzAKTbRKWfqSEA==", - "license": "MIT", - "dependencies": { - "@sentry-internal/browser-utils": "10.51.0", - "@sentry-internal/feedback": "10.51.0", - "@sentry-internal/replay": "10.51.0", - "@sentry-internal/replay-canvas": "10.51.0", - "@sentry/core": "10.51.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/bundler-plugin-core": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-5.2.1.tgz", - "integrity": "sha512-uXb+TOZKXxm2STsP3iR70Jh/yYHwlHOvql7w/bUVYgDyiB/1Mv0D6oNGS0kelsgBsBwCq3ngyJYlyNy3oM1pPw==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.18.5", - "@sentry/babel-plugin-component-annotate": "5.2.1", - "@sentry/cli": "^2.58.5", - "dotenv": "^16.3.1", - "find-up": "^5.0.0", - "glob": "^13.0.6", - "magic-string": "~0.30.8" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@sentry/bundler-plugin-core/node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/@sentry/cli": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.5.tgz", - "integrity": "sha512-tavJ7yGUZV+z3Ct2/ZB6mg339i08sAk6HDkgqmSRuQEu2iLS5sl9HIvuXfM6xjv8fwlgFOSy++WNABNAcGHUbg==", - "hasInstallScript": true, - "license": "FSL-1.1-MIT", - "dependencies": { - "https-proxy-agent": "^5.0.0", - "node-fetch": "^2.6.7", - "progress": "^2.0.3", - "proxy-from-env": "^1.1.0", - "which": "^2.0.2" - }, - "bin": { - "sentry-cli": "bin/sentry-cli" - }, - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@sentry/cli-darwin": "2.58.5", - "@sentry/cli-linux-arm": "2.58.5", - "@sentry/cli-linux-arm64": "2.58.5", - "@sentry/cli-linux-i686": "2.58.5", - "@sentry/cli-linux-x64": "2.58.5", - "@sentry/cli-win32-arm64": "2.58.5", - "@sentry/cli-win32-i686": "2.58.5", - "@sentry/cli-win32-x64": "2.58.5" - } - }, - "node_modules/@sentry/cli-darwin": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.5.tgz", - "integrity": "sha512-lYrNzenZFJftfwSya7gwrHGxtE+Kob/e1sr9lmHMFOd4utDlmq0XFDllmdZAMf21fxcPRI1GL28ejZ3bId01fQ==", - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-linux-arm": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.5.tgz", - "integrity": "sha512-KtHweSIomYL4WVDrBrYSYJricKAAzxUgX86kc6OnlikbyOhoK6Fy8Vs6vwd52P6dvWPjgrMpUYjW2M5pYXQDUw==", - "cpu": [ - "arm" - ], - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "linux", - "freebsd", - "android" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-linux-arm64": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.5.tgz", - "integrity": "sha512-/4gywFeBqRB6tR/iGMRAJ3HRqY6Z7Yp4l8ZCbl0TDLAfHNxu7schEw4tSnm2/Hh9eNMiOVy4z58uzAWlZXAYBQ==", - "cpu": [ - "arm64" - ], - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "linux", - "freebsd", - "android" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-linux-i686": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.5.tgz", - "integrity": "sha512-G7261dkmyxqlMdyvyP06b+RTIVzp1gZNgglj5UksxSouSUqRd/46W/2pQeOMPhloDYo9yLtCN2YFb3Mw4aUsWw==", - "cpu": [ - "x86", - "ia32" - ], - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "linux", - "freebsd", - "android" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-linux-x64": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.5.tgz", - "integrity": "sha512-rP04494RSmt86xChkQ+ecBNRYSPbyXc4u0IA7R7N1pSLCyO74e5w5Al+LnAq35cMfVbZgz5Sm0iGLjyiUu4I1g==", - "cpu": [ - "x64" - ], - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "linux", - "freebsd", - "android" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-win32-arm64": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.5.tgz", - "integrity": "sha512-AOJ2nCXlQL1KBaCzv38m3i2VmSHNurUpm7xVKd6yAHX+ZoVBI8VT0EgvwmtJR2TY2N2hNCC7UrgRmdUsQ152bA==", - "cpu": [ - "arm64" - ], - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-win32-i686": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.5.tgz", - "integrity": "sha512-EsuboLSOnlrN7MMPJ1eFvfMDm+BnzOaSWl8eYhNo8W/BIrmNgpRUdBwnWn9Q2UOjJj5ZopukmsiMYtU/D7ml9g==", - "cpu": [ - "x86", - "ia32" - ], - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli-win32-x64": { - "version": "2.58.5", - "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.5.tgz", - "integrity": "sha512-IZf+XIMiQwj+5NzqbOQfywlOitmCV424Vtf9c+ep61AaVScUFD1TSrQbOcJJv5xGxhlxNOMNgMeZhdexdzrKZg==", - "cpu": [ - "x64" - ], - "license": "FSL-1.1-MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@sentry/cli/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/@sentry/cli/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@sentry/cli/node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/@sentry/cli/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/@sentry/cli/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/@sentry/cli/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/@sentry/core": { - "version": "10.51.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.51.0.tgz", - "integrity": "sha512-Y45V/YXvVLEXmOdkbD1oG1gkRWFi9guCEGg3PlIlIpRjAbZUrvLGgjRJIc1E7XpSzmOnWbs5BbUxMv4PDaPj2w==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/nextjs": { - "version": "10.51.0", - "resolved": "https://registry.npmjs.org/@sentry/nextjs/-/nextjs-10.51.0.tgz", - "integrity": "sha512-Bh3DeieTnbOOfhFEWbT57vgxlCdoTU7+x5or8QLa8VGCmZEekIdt0rGd8exbG1msI4g6SusCiJzbF/8bucmY/A==", - "license": "MIT", - "dependencies": { - "@opentelemetry/api": "^1.9.1", - "@opentelemetry/semantic-conventions": "^1.40.0", - "@rollup/plugin-commonjs": "28.0.1", - "@sentry-internal/browser-utils": "10.51.0", - "@sentry/bundler-plugin-core": "^5.2.0", - "@sentry/core": "10.51.0", - "@sentry/node": "10.51.0", - "@sentry/opentelemetry": "10.51.0", - "@sentry/react": "10.51.0", - "@sentry/vercel-edge": "10.51.0", - "@sentry/webpack-plugin": "^5.2.0", - "rollup": "^4.35.0", - "stacktrace-parser": "^0.1.11" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "next": "^13.2.0 || ^14.0 || ^15.0.0-rc.0 || ^16.0.0-0" - } - }, - "node_modules/@sentry/node": { - "version": "10.51.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.51.0.tgz", - "integrity": "sha512-2yZLRZwS1dKG8/4eOTpGSo/gO/EgmT9aPj6lAzUkRa7bZCTTdW4BraaHU0leX5T94909Qfhbr3W5AVTfDOCKiQ==", - "license": "MIT", - "dependencies": { - "@fastify/otel": "0.18.0", - "@opentelemetry/api": "^1.9.1", - "@opentelemetry/core": "^2.6.1", - "@opentelemetry/instrumentation": "^0.214.0", - "@opentelemetry/instrumentation-amqplib": "0.61.0", - "@opentelemetry/instrumentation-connect": "0.57.0", - "@opentelemetry/instrumentation-dataloader": "0.31.0", - "@opentelemetry/instrumentation-fs": "0.33.0", - "@opentelemetry/instrumentation-generic-pool": "0.57.0", - "@opentelemetry/instrumentation-graphql": "0.62.0", - "@opentelemetry/instrumentation-hapi": "0.60.0", - "@opentelemetry/instrumentation-http": "0.214.0", - "@opentelemetry/instrumentation-ioredis": "0.62.0", - "@opentelemetry/instrumentation-kafkajs": "0.23.0", - "@opentelemetry/instrumentation-knex": "0.58.0", - "@opentelemetry/instrumentation-koa": "0.62.0", - "@opentelemetry/instrumentation-lru-memoizer": "0.58.0", - "@opentelemetry/instrumentation-mongodb": "0.67.0", - "@opentelemetry/instrumentation-mongoose": "0.60.0", - "@opentelemetry/instrumentation-mysql": "0.60.0", - "@opentelemetry/instrumentation-mysql2": "0.60.0", - "@opentelemetry/instrumentation-pg": "0.66.0", - "@opentelemetry/instrumentation-redis": "0.62.0", - "@opentelemetry/instrumentation-tedious": "0.33.0", - "@opentelemetry/sdk-trace-base": "^2.6.1", - "@opentelemetry/semantic-conventions": "^1.40.0", - "@prisma/instrumentation": "7.6.0", - "@sentry/core": "10.51.0", - "@sentry/node-core": "10.51.0", - "@sentry/opentelemetry": "10.51.0", - "import-in-the-middle": "^3.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/node-core": { - "version": "10.51.0", - "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.51.0.tgz", - "integrity": "sha512-VP9DMEzBEuauABrfDHYz/pRYa74M09uRJLz0ls3yel3sKhYHMyCB29ZxbKcciUhD4d33dwgi8DbaPZV2H/wnfQ==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.51.0", - "@sentry/opentelemetry": "10.51.0", - "import-in-the-middle": "^3.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^1.30.1 || ^2.1.0", - "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", - "@opentelemetry/instrumentation": ">=0.57.1 <1", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", - "@opentelemetry/semantic-conventions": "^1.39.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@opentelemetry/core": { - "optional": true - }, - "@opentelemetry/exporter-trace-otlp-http": { - "optional": true - }, - "@opentelemetry/instrumentation": { - "optional": true - }, - "@opentelemetry/sdk-trace-base": { - "optional": true - }, - "@opentelemetry/semantic-conventions": { - "optional": true - } - } - }, - "node_modules/@sentry/opentelemetry": { - "version": "10.51.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.51.0.tgz", - "integrity": "sha512-Qc7AlCE4uhB+SvHLqah4RgR1WdY7wmmr/hx9g/prDP9R1ocshmUEMrZK9qjuwaklW7/fmkFCXI8ETxo5L1bHIA==", - "license": "MIT", - "dependencies": { - "@sentry/core": "10.51.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/core": "^1.30.1 || ^2.1.0", - "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", - "@opentelemetry/semantic-conventions": "^1.39.0" - } - }, - "node_modules/@sentry/react": { - "version": "10.51.0", - "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.51.0.tgz", - "integrity": "sha512-RRHHqjNvjji6ebIqdlAr453AkST8Vm4cxdu1vWm772IgbzTO7Jx46Cj6Bt2/GjMyH0YLE5euDaAOQhFMmpvAOw==", - "license": "MIT", - "dependencies": { - "@sentry/browser": "10.51.0", - "@sentry/core": "10.51.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "react": "^16.14.0 || 17.x || 18.x || 19.x" - } - }, - "node_modules/@sentry/vercel-edge": { - "version": "10.51.0", - "resolved": "https://registry.npmjs.org/@sentry/vercel-edge/-/vercel-edge-10.51.0.tgz", - "integrity": "sha512-tADUhv+S3gtAj/hSAih6FcYTRZQma+brI4dY6bue2RwgWQvaQoP5CF/PsTb4RhK82etPhqphsdJivY09/L7vWA==", - "license": "MIT", - "dependencies": { - "@opentelemetry/api": "^1.9.1", - "@opentelemetry/resources": "^2.6.1", - "@sentry/core": "10.51.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@sentry/webpack-plugin": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/@sentry/webpack-plugin/-/webpack-plugin-5.2.1.tgz", - "integrity": "sha512-ZGxwCkszFHdk9N11XIbAyTTsJsGUKHMYEXMRLUwPLi+iKi+b+YuXLBg7rlxe6Nd3M0i7xWy3gz6jcW7jeqEfIw==", - "license": "MIT", - "dependencies": { - "@sentry/bundler-plugin-core": "5.2.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "webpack": ">=5.0.0" - } - }, "node_modules/@sindresorhus/merge-streams": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", @@ -5535,6 +4054,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5552,6 +4074,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5569,6 +4094,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -5586,6 +4114,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -5625,70 +4156,6 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.8.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.8.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", @@ -6033,19 +4500,11 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-array": "*", @@ -6090,6 +4549,7 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -6099,6 +4559,7 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -6108,6 +4569,7 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-color": { @@ -6120,6 +4582,7 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-array": "*", @@ -6130,18 +4593,21 @@ "version": "6.0.4", "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-dispatch": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-drag": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -6151,6 +4617,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-ease": { @@ -6163,6 +4630,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-dsv": "*" @@ -6172,18 +4640,21 @@ "version": "3.0.10", "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-format": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-geo": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/geojson": "*" @@ -6193,6 +4664,7 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-interpolate": { @@ -6214,18 +4686,21 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-quadtree": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-random": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-scale": { @@ -6241,12 +4716,14 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-selection": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-shape": { @@ -6268,6 +4745,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, "license": "MIT" }, "node_modules/@types/d3-timer": { @@ -6280,6 +4758,7 @@ "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -6289,6 +4768,7 @@ "version": "3.0.8", "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, "license": "MIT", "dependencies": { "@types/d3-interpolate": "*", @@ -6311,28 +4791,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -6352,6 +4810,7 @@ "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, "license": "MIT" }, "node_modules/@types/hast": { @@ -6367,6 +4826,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, "license": "MIT" }, "node_modules/@types/json5": { @@ -6391,15 +4851,6 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, - "node_modules/@types/mysql": { - "version": "2.15.27", - "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", - "integrity": "sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/node": { "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", @@ -6420,15 +4871,6 @@ "pg-types": "^2.2.0" } }, - "node_modules/@types/pg-pool": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/pg-pool/-/pg-pool-2.0.7.tgz", - "integrity": "sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==", - "license": "MIT", - "dependencies": { - "@types/pg": "*" - } - }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -6463,19 +4905,11 @@ "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "license": "MIT" }, - "node_modules/@types/tedious": { - "version": "4.0.14", - "resolved": "https://registry.npmjs.org/@types/tedious/-/tedious-4.0.14.tgz", - "integrity": "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, "license": "MIT", "optional": true }, @@ -6497,16 +4931,6 @@ "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", "license": "MIT" }, - "node_modules/@types/web-push": { - "version": "3.6.4", - "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", - "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -6926,6 +5350,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6940,6 +5367,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6954,6 +5384,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6968,6 +5401,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -6982,6 +5418,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -6996,6 +5435,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -7010,6 +5452,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -7024,6 +5469,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -7093,6 +5541,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "dev": true, "license": "MIT", "optionalDependencies": { "d3-selection": "^3.0.0", @@ -7292,181 +5741,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0", - "peer": true - }, "node_modules/@zenuml/core": { "version": "3.47.2", "resolved": "https://registry.npmjs.org/@zenuml/core/-/core-3.47.2.tgz", @@ -7694,6 +5968,7 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -7702,28 +5977,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "license": "MIT", - "peerDependencies": { - "acorn": "^8" - } - }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -8069,18 +6322,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/asn1.js": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", - "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0", - "safer-buffer": "^2.1.0" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -8409,12 +6650,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", - "license": "MIT" - }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -8531,19 +6766,6 @@ "node": "*" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT", - "peer": true - }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -8653,6 +6875,16 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -8776,6 +7008,7 @@ "version": "12.0.0", "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz", "integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@chevrotain/cst-dts-gen": "12.0.0", @@ -8792,6 +7025,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz", "integrity": "sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==", + "dev": true, "license": "MIT", "dependencies": { "lodash-es": "^4.17.21" @@ -8815,14 +7049,289 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "node_modules/chokidar-cli": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chokidar-cli/-/chokidar-cli-3.0.0.tgz", + "integrity": "sha512-xVW+Qeh7z15uZRxHOkP93Ux8A0xbPzwK4GaqD8dQOYc34TlkqUhVSS59fK36DOp5WdJlrRzlYSy02Ht99FjZqQ==", + "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "chokidar": "^3.5.2", + "lodash.debounce": "^4.0.8", + "lodash.throttle": "^4.1.1", + "yargs": "^13.3.0" + }, + "bin": { + "chokidar": "index.js" + }, "engines": { - "node": ">=6.0" + "node": ">= 8.10.0" + } + }, + "node_modules/chokidar-cli/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chokidar-cli/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chokidar-cli/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar-cli/node_modules/cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, + "node_modules/chokidar-cli/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/chokidar-cli/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar-cli/node_modules/emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar-cli/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/chokidar-cli/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chokidar-cli/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/chokidar-cli/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/chokidar-cli/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chokidar-cli/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/chokidar-cli/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/chokidar-cli/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/chokidar-cli/node_modules/string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/chokidar-cli/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/chokidar-cli/node_modules/wrap-ansi": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/chokidar-cli/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/chokidar-cli/node_modules/yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + } + }, + "node_modules/chokidar-cli/node_modules/yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" } }, "node_modules/chromium-bidi": { @@ -8851,12 +7360,6 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", - "license": "MIT" - }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -9082,12 +7585,6 @@ "node": ">=20" } }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "license": "MIT" - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -9209,6 +7706,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "dev": true, "license": "MIT", "dependencies": { "layout-base": "^1.0.0" @@ -9306,6 +7804,7 @@ "version": "3.33.2", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.2.tgz", "integrity": "sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10" @@ -9315,6 +7814,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "dev": true, "license": "MIT", "dependencies": { "cose-base": "^1.0.0" @@ -9327,6 +7827,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", + "dev": true, "license": "MIT", "dependencies": { "cose-base": "^2.2.0" @@ -9339,6 +7840,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", + "dev": true, "license": "MIT", "dependencies": { "layout-base": "^2.0.0" @@ -9348,12 +7850,14 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", + "dev": true, "license": "MIT" }, "node_modules/d3": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "dev": true, "license": "ISC", "dependencies": { "d3-array": "3", @@ -9407,6 +7911,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -9416,6 +7921,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -9432,6 +7938,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "dev": true, "license": "ISC", "dependencies": { "d3-path": "1 - 3" @@ -9453,6 +7960,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "dev": true, "license": "ISC", "dependencies": { "d3-array": "^3.2.0" @@ -9465,6 +7973,7 @@ "version": "6.0.4", "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "dev": true, "license": "ISC", "dependencies": { "delaunator": "5" @@ -9477,6 +7986,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -9486,6 +7996,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -9499,6 +8010,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "dev": true, "license": "ISC", "dependencies": { "commander": "7", @@ -9524,6 +8036,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 10" @@ -9533,6 +8046,7 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -9554,6 +8068,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "dev": true, "license": "ISC", "dependencies": { "d3-dsv": "1 - 3" @@ -9566,6 +8081,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -9589,6 +8105,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "dev": true, "license": "ISC", "dependencies": { "d3-array": "2.5.0 - 3" @@ -9601,6 +8118,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -9631,6 +8149,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -9640,6 +8159,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -9649,6 +8169,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -9658,6 +8179,7 @@ "version": "0.12.3", "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "d3-array": "1 - 2", @@ -9668,6 +8190,7 @@ "version": "2.12.1", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "internmap": "^1.0.0" @@ -9677,12 +8200,14 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/d3-sankey/node_modules/d3-shape": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "d3-path": "1" @@ -9692,6 +8217,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "dev": true, "license": "ISC" }, "node_modules/d3-scale": { @@ -9714,6 +8240,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "dev": true, "license": "ISC", "dependencies": { "d3-color": "1 - 3", @@ -9727,6 +8254,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -9781,6 +8309,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dev": true, "license": "ISC", "dependencies": { "d3-color": "1 - 3", @@ -9800,6 +8329,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -9816,6 +8346,7 @@ "version": "7.0.14", "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", + "dev": true, "license": "MIT", "dependencies": { "d3": "^7.9.0", @@ -9910,6 +8441,7 @@ "version": "1.11.20", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -9929,6 +8461,16 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -10110,6 +8652,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", + "dev": true, "license": "ISC", "dependencies": { "robust-predicates": "^3.0.2" @@ -10226,6 +8769,7 @@ "version": "3.4.1", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", "integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==", + "dev": true, "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -10257,15 +8801,6 @@ "node": ">= 0.4" } }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/eciesjs": { "version": "0.4.18", "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.18.tgz", @@ -10345,6 +8880,7 @@ "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -10520,6 +9056,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { @@ -11074,6 +9611,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -11086,6 +9624,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -11150,6 +9689,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -11573,6 +10113,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -11659,12 +10200,6 @@ "node": ">= 0.6" } }, - "node_modules/forwarded-parse": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", - "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", - "license": "MIT" - }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -11692,6 +10227,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -11935,29 +10471,6 @@ "giget": "dist/cli.mjs" } }, - "node_modules/github-slugger": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", - "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", - "license": "ISC" - }, - "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -11971,49 +10484,6 @@ "node": ">=10.13.0" } }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "license": "BSD-2-Clause", - "peer": true - }, - "node_modules/glob/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -12087,6 +10557,7 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", + "dev": true, "license": "MIT" }, "node_modules/has-bigints": { @@ -12106,6 +10577,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -12180,32 +10652,6 @@ "node": ">= 0.4" } }, - "node_modules/hast-util-heading-rank": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz", - "integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-is-element": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", - "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -12233,19 +10679,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-to-string": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", - "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -12342,15 +10775,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/http_ece": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", - "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", - "license": "MIT", - "engines": { - "node": ">=16" - } - }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -12502,21 +10926,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/import-in-the-middle": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.1.tgz", - "integrity": "sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA==", - "license": "Apache-2.0", - "dependencies": { - "acorn": "^8.15.0", - "acorn-import-attributes": "^1.9.5", - "cjs-module-lexer": "^2.2.0", - "module-details-from-path": "^1.0.4" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -13071,15 +11480,6 @@ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", "license": "MIT" }, - "node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", - "license": "MIT", - "dependencies": { - "@types/estree": "*" - } - }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -13346,37 +11746,6 @@ "node": ">= 0.4" } }, - "node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -13579,31 +11948,11 @@ "node": ">=4.0" } }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/katex": { "version": "0.16.45", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==", + "dev": true, "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -13620,6 +11969,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -13638,7 +11988,8 @@ "node_modules/khroma": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", - "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==", + "dev": true }, "node_modules/kleur": { "version": "4.1.5", @@ -13653,6 +12004,7 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.2.tgz", "integrity": "sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==", + "dev": true, "license": "MIT", "dependencies": { "@chevrotain/regexp-to-ast": "~12.0.0", @@ -13691,6 +12043,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", + "dev": true, "license": "MIT" }, "node_modules/levn": { @@ -13850,6 +12203,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13871,6 +12227,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13892,6 +12251,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13913,6 +12275,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -14073,24 +12438,11 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/loader-runner": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", - "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -14113,6 +12465,14 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, "license": "MIT" }, "node_modules/lodash.merge": { @@ -14122,6 +12482,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", @@ -14323,6 +12690,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -14730,6 +13098,7 @@ "version": "11.14.0", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz", "integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==", + "dev": true, "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.1", @@ -14759,6 +13128,7 @@ "version": "16.4.2", "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "dev": true, "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -15399,12 +13769,6 @@ "node": ">=4" } }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -15427,15 +13791,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -15448,6 +13803,7 @@ "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.16.0", @@ -15460,12 +13816,14 @@ "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, "license": "MIT" }, "node_modules/mlly/node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, "license": "MIT", "dependencies": { "confbox": "^0.1.8", @@ -15473,12 +13831,6 @@ "pathe": "^2.0.1" } }, - "node_modules/module-details-from-path": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", - "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", - "license": "MIT" - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -15645,13 +13997,6 @@ "node": ">= 0.6" } }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT", - "peer": true - }, "node_modules/netmask": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", @@ -16159,6 +14504,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -16174,6 +14520,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -16185,6 +14532,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pac-proxy-agent": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", @@ -16225,6 +14582,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "dev": true, "license": "MIT" }, "node_modules/pako": { @@ -16333,12 +14691,14 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", + "dev": true, "license": "MIT" }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -16360,31 +14720,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/path-to-regexp": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", @@ -16612,12 +14947,14 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", + "dev": true, "license": "MIT" }, "node_modules/points-on-path": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "dev": true, "license": "MIT", "dependencies": { "path-data-parser": "0.1.0", @@ -17002,6 +15339,40 @@ } } }, + "node_modules/prisma-erd-generator": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prisma-erd-generator/-/prisma-erd-generator-2.4.2.tgz", + "integrity": "sha512-AmFuCB4wKhCF7HNj4b73tmgewRJTuwKrR7eucjctIKOmZSiY/0uTlBYNCKxkYrcXsXGvePVb7IP0z8aD8nCogA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mermaid-js/mermaid-cli": "^11.9.0", + "@prisma/generator-helper": "^7.0.0", + "dotenv": "^16.6.1" + }, + "bin": { + "prisma-erd-generator": "dist/index.js" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/prisma-erd-generator/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -17023,7 +15394,9 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.4.0" } @@ -17138,7 +15511,9 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "dev": true, + "license": "MIT", + "peer": true }, "node_modules/pump": { "version": "3.0.4", @@ -17625,41 +16000,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/rehype-autolink-headings": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz", - "integrity": "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@ungap/structured-clone": "^1.0.0", - "hast-util-heading-rank": "^3.0.0", - "hast-util-is-element": "^3.0.0", - "unified": "^11.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/rehype-slug": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz", - "integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "github-slugger": "^2.0.0", - "hast-util-heading-rank": "^3.0.0", - "hast-util-to-string": "^3.0.0", - "unist-util-visit": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -17753,18 +16093,12 @@ "node": ">=0.10.0" } }, - "node_modules/require-in-the-middle": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", - "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.5", - "module-details-from-path": "^1.0.3" - }, - "engines": { - "node": ">=9.3.0 || >=8.10.0 <9.0.0" - } + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true, + "license": "ISC" }, "node_modules/reselect": { "version": "5.1.1", @@ -17867,6 +16201,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", + "dev": true, "license": "Unlicense" }, "node_modules/rolldown": { @@ -17903,54 +16238,11 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" } }, - "node_modules/rollup": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", - "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.2", - "@rollup/rollup-android-arm64": "4.60.2", - "@rollup/rollup-darwin-arm64": "4.60.2", - "@rollup/rollup-darwin-x64": "4.60.2", - "@rollup/rollup-freebsd-arm64": "4.60.2", - "@rollup/rollup-freebsd-x64": "4.60.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", - "@rollup/rollup-linux-arm-musleabihf": "4.60.2", - "@rollup/rollup-linux-arm64-gnu": "4.60.2", - "@rollup/rollup-linux-arm64-musl": "4.60.2", - "@rollup/rollup-linux-loong64-gnu": "4.60.2", - "@rollup/rollup-linux-loong64-musl": "4.60.2", - "@rollup/rollup-linux-ppc64-gnu": "4.60.2", - "@rollup/rollup-linux-ppc64-musl": "4.60.2", - "@rollup/rollup-linux-riscv64-gnu": "4.60.2", - "@rollup/rollup-linux-riscv64-musl": "4.60.2", - "@rollup/rollup-linux-s390x-gnu": "4.60.2", - "@rollup/rollup-linux-x64-gnu": "4.60.2", - "@rollup/rollup-linux-x64-musl": "4.60.2", - "@rollup/rollup-openbsd-x64": "4.60.2", - "@rollup/rollup-openharmony-arm64": "4.60.2", - "@rollup/rollup-win32-arm64-msvc": "4.60.2", - "@rollup/rollup-win32-ia32-msvc": "4.60.2", - "@rollup/rollup-win32-x64-gnu": "4.60.2", - "@rollup/rollup-win32-x64-msvc": "4.60.2", - "fsevents": "~2.3.2" - } - }, "node_modules/roughjs": { "version": "4.6.6", "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", + "dev": true, "license": "MIT", "dependencies": { "hachure-fill": "^0.5.2", @@ -18024,6 +16316,7 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/rxjs": { @@ -18060,6 +16353,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, "funding": [ { "type": "github", @@ -18146,81 +16440,6 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, - "node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/schema-utils/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/schema-utils/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "license": "MIT", - "peer": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/schema-utils/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/schema-utils/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true - }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -18280,6 +16499,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", @@ -18743,17 +16969,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "license": "MIT", - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/space-separated-tokens": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", @@ -18796,27 +17011,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stacktrace-parser": { - "version": "0.1.11", - "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", - "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", - "license": "MIT", - "dependencies": { - "type-fest": "^0.7.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/stacktrace-parser/node_modules/type-fest": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", - "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=8" - } - }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -19171,6 +17365,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "dev": true, "license": "MIT" }, "node_modules/sucrase": { @@ -19279,6 +17474,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -19329,66 +17525,6 @@ "streamx": "^2.12.5" } }, - "node_modules/terser": { - "version": "5.46.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.2.tgz", - "integrity": "sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==", - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser-webpack-plugin": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.5.0.tgz", - "integrity": "sha512-UYhptBwhWvfIjKd/UuFo6D8uq9xpGLDK+z8EDsj/zWhrTaH34cKEbrkMKfV5YWqGBvAYA3tlzZbs2R+qYrbQJA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "terser": "^5.31.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT", - "peer": true - }, "node_modules/text-decoder": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", @@ -19458,6 +17594,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -19632,6 +17769,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.10" @@ -19885,6 +18023,7 @@ "version": "1.6.3", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, "license": "MIT" }, "node_modules/unbox-primitive": { @@ -20231,6 +18370,7 @@ "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "dev": true, "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -20527,6 +18667,7 @@ "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" @@ -20536,6 +18677,7 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "dev": true, "license": "MIT", "dependencies": { "vscode-languageserver-protocol": "3.17.5" @@ -20548,6 +18690,7 @@ "version": "3.17.5", "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "dev": true, "license": "MIT", "dependencies": { "vscode-jsonrpc": "8.2.0", @@ -20558,18 +18701,21 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, "license": "MIT" }, "node_modules/vscode-languageserver-types": { "version": "3.17.5", "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "dev": true, "license": "MIT" }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, "license": "MIT" }, "node_modules/w3c-xmlserializer": { @@ -20585,39 +18731,6 @@ "node": ">=18" } }, - "node_modules/watchpack": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", - "license": "MIT", - "peer": true, - "dependencies": { - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/web-push": { - "version": "3.6.7", - "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", - "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", - "license": "MPL-2.0", - "dependencies": { - "asn1.js": "^5.3.0", - "http_ece": "1.2.0", - "https-proxy-agent": "^7.0.0", - "jws": "^4.0.0", - "minimist": "^1.2.5" - }, - "bin": { - "web-push": "src/cli.js" - }, - "engines": { - "node": ">= 16" - } - }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -20637,88 +18750,6 @@ "node": ">=20" } }, - "node_modules/webpack": { - "version": "5.106.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", - "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.16.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.28.1", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.20.0", - "es-module-lexer": "^2.0.0", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "loader-runner": "^4.3.1", - "mime-db": "^1.54.0", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.3", - "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.3.17", - "watchpack": "^2.5.1", - "webpack-sources": "^3.3.4" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-sources": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.1.tgz", - "integrity": "sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/webpack/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=4.0" - } - }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", @@ -20826,6 +18857,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true, + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", @@ -21020,9 +19058,10 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", - "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -21118,6 +19157,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/package.json b/package.json index 8280cd1..d1a1928 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,24 @@ { "name": "scrum4me", - "version": "1.3.3", + "version": "0.4.0", "private": true, "scripts": { "predev": "npx --yes kill-port 3000 || exit 0", "dev": "next dev -p 3000", - "prebuild": "npm run manual:build", - "build": "prisma generate && next build", + "build": "next build", "start": "next start", "lint": "eslint", - "typecheck": "tsc --noEmit", - "verify": "npm run lint && npm run typecheck && npm test", "test": "vitest run", "test:watch": "vitest", "prepare": "husky", "postinstall": "prisma generate --generator client", + "db:erd": "prisma generate", + "db:erd:watch": "chokidar \"prisma/schema.prisma\" -c \"npm run db:erd\"", "db:insert-milestone": "tsx scripts/insert-milestone.ts", - "db:sync-model-prices": "tsx scripts/sync-model-prices.ts", - "create-admin": "tsx scripts/create-admin.ts", "seed": "prisma db seed", "docs:index": "node scripts/generate-docs-index.mjs", "docs:check-links": "node scripts/check-doc-links.mjs", - "manual:build": "node scripts/build-manual.mjs", - "docs": "npm run docs:index && npm run docs:check-links", - "diagrams": "mmdc -i docs/diagrams/architecture.mmd -t default -b transparent -o public/diagrams/architecture-light.svg && mmdc -i docs/diagrams/architecture.mmd -t dark -b transparent -o public/diagrams/architecture-dark.svg" + "docs": "npm run docs:index && npm run docs:check-links" }, "dependencies": { "@base-ui/react": "^1.4.1", @@ -33,7 +28,6 @@ "@hookform/resolvers": "^5.2.2", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", - "@sentry/nextjs": "^10.51.0", "@tanstack/react-table": "^8.21.3", "@vercel/analytics": "^2.0.1", "bcryptjs": "^2.4.3", @@ -42,7 +36,6 @@ "dotenv": "^17.4.2", "iron-session": "^8.0.4", "lucide-react": "^1.8.0", - "mermaid": "^11.14.0", "next": "16.2.4", "next-themes": "^0.4.6", "pg": "^8.20.0", @@ -54,16 +47,12 @@ "react-markdown": "^10.1.0", "react-textarea-autosize": "^8.5.9", "recharts": "^3.8.1", - "rehype-autolink-headings": "^7.1.0", - "rehype-slug": "^6.0.0", "remark-gfm": "^4.0.1", "shadcn": "^4.4.0", "sharp": "^0.34.5", "sonner": "^1.7.4", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", - "web-push": "^3.6.7", - "yaml": "^2.8.4", "zod": "^3.25.76", "zustand": "^5.0.12" }, @@ -84,14 +73,15 @@ "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", - "@types/web-push": "^3.6.4", "@vitest/coverage-v8": "^4.1.5", + "chokidar-cli": "^3.0.0", "concurrently": "^9.2.1", "eslint": "^9", "eslint-config-next": "16.2.4", "husky": "^9.1.7", "jsdom": "^29.1.1", "lint-staged": "^16.4.0", + "prisma-erd-generator": "^2.4.2", "tailwindcss": "^4", "tsx": "^4.21.0", "typescript": "^5", diff --git a/prisma/migrations/20260503145506_add_pbi_pr_link/migration.sql b/prisma/migrations/20260503145506_add_pbi_pr_link/migration.sql deleted file mode 100644 index 82c7f13..0000000 --- a/prisma/migrations/20260503145506_add_pbi_pr_link/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- AlterTable -ALTER TABLE "pbis" ADD COLUMN "pr_merged_at" TIMESTAMP(3), -ADD COLUMN "pr_url" TEXT; diff --git a/prisma/migrations/20260504055000_codes_required_and_task_code/migration.sql b/prisma/migrations/20260504055000_codes_required_and_task_code/migration.sql deleted file mode 100644 index 3ed5f99..0000000 --- a/prisma/migrations/20260504055000_codes_required_and_task_code/migration.sql +++ /dev/null @@ -1,75 +0,0 @@ --- Codes verplicht maken voor PBI/Story en code-kolom + product_id denorm --- toevoegen aan Task. Bestaande NULL-rijen worden gevuld via PL/pgSQL backfill. - --- 1) Tasks: product_id denorm (eerst nullable, backfill, dan NOT NULL + FK) -ALTER TABLE "tasks" ADD COLUMN "product_id" TEXT; -UPDATE "tasks" t SET "product_id" = s."product_id" FROM "stories" s WHERE s."id" = t."story_id"; -ALTER TABLE "tasks" ALTER COLUMN "product_id" SET NOT NULL; -ALTER TABLE "tasks" ADD CONSTRAINT "tasks_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- 2) Tasks: code (eerst nullable t.b.v. backfill) -ALTER TABLE "tasks" ADD COLUMN "code" VARCHAR(30); - --- 3) Backfill PBI codes (alleen NULL-rijen, per product, op created_at) -DO $$ -DECLARE rec RECORD; -DECLARE n INT; -BEGIN - FOR rec IN SELECT DISTINCT product_id FROM "pbis" WHERE code IS NULL LOOP - SELECT COALESCE(MAX(CAST(SUBSTRING(code FROM 'PBI-(\d+)$') AS INTEGER)), 0) - INTO n - FROM "pbis" - WHERE product_id = rec.product_id AND code ~ '^PBI-\d+$'; - UPDATE "pbis" SET code = 'PBI-' || (n + sub.row_num) - FROM ( - SELECT id, ROW_NUMBER() OVER (ORDER BY created_at, id) AS row_num - FROM "pbis" - WHERE product_id = rec.product_id AND code IS NULL - ) sub - WHERE "pbis".id = sub.id; - END LOOP; -END $$; - --- 4) Backfill Story codes (TO_CHAR met FM-format: padding tot minimaal 3 chars zonder truncatie) -DO $$ -DECLARE rec RECORD; -DECLARE n INT; -BEGIN - FOR rec IN SELECT DISTINCT product_id FROM "stories" WHERE code IS NULL LOOP - SELECT COALESCE(MAX(CAST(SUBSTRING(code FROM 'ST-(\d+)$') AS INTEGER)), 0) - INTO n - FROM "stories" - WHERE product_id = rec.product_id AND code ~ '^ST-\d+$'; - UPDATE "stories" SET code = 'ST-' || LPAD((n + sub.row_num)::TEXT, GREATEST(3, LENGTH((n + sub.row_num)::TEXT)), '0') - FROM ( - SELECT id, ROW_NUMBER() OVER (ORDER BY created_at, id) AS row_num - FROM "stories" - WHERE product_id = rec.product_id AND code IS NULL - ) sub - WHERE "stories".id = sub.id; - END LOOP; -END $$; - --- 5) Backfill Task codes (alle rijen — kolom net toegevoegd) -DO $$ -DECLARE rec RECORD; -BEGIN - FOR rec IN SELECT DISTINCT product_id FROM "tasks" WHERE code IS NULL LOOP - UPDATE "tasks" SET code = 'T-' || sub.row_num - FROM ( - SELECT id, ROW_NUMBER() OVER (ORDER BY created_at, id) AS row_num - FROM "tasks" - WHERE product_id = rec.product_id AND code IS NULL - ) sub - WHERE "tasks".id = sub.id; - END LOOP; -END $$; - --- 6) NOT NULL constraints -ALTER TABLE "pbis" ALTER COLUMN "code" SET NOT NULL; -ALTER TABLE "stories" ALTER COLUMN "code" SET NOT NULL; -ALTER TABLE "tasks" ALTER COLUMN "code" SET NOT NULL; - --- 7) Unique + lookup index op Task -CREATE UNIQUE INDEX "tasks_product_id_code_key" ON "tasks"("product_id", "code"); -CREATE INDEX "tasks_product_id_idx" ON "tasks"("product_id"); diff --git a/prisma/migrations/20260504172747_add_ideas_and_grill_jobs/migration.sql b/prisma/migrations/20260504172747_add_ideas_and_grill_jobs/migration.sql deleted file mode 100644 index 49cc4dd..0000000 --- a/prisma/migrations/20260504172747_add_ideas_and_grill_jobs/migration.sql +++ /dev/null @@ -1,129 +0,0 @@ --- M12 — Idea entity + Grill/Plan Claude jobs --- See docs/plans/M12-ideas.md - --- 1. New enums -CREATE TYPE "IdeaStatus" AS ENUM ('DRAFT', 'GRILLING', 'GRILL_FAILED', 'GRILLED', 'PLANNING', 'PLAN_FAILED', 'PLAN_READY', 'PLANNED'); -CREATE TYPE "ClaudeJobKind" AS ENUM ('TASK_IMPLEMENTATION', 'IDEA_GRILL', 'IDEA_MAKE_PLAN'); -CREATE TYPE "IdeaLogType" AS ENUM ('DECISION', 'NOTE', 'GRILL_RESULT', 'PLAN_RESULT', 'STATUS_CHANGE', 'JOB_EVENT'); - --- 2. User.idea_code_counter -ALTER TABLE "users" ADD COLUMN "idea_code_counter" INTEGER NOT NULL DEFAULT 0; - --- 3. ideas table -CREATE TABLE "ideas" ( - "id" TEXT NOT NULL, - "user_id" TEXT NOT NULL, - "product_id" TEXT, - "code" VARCHAR(30) NOT NULL, - "title" TEXT NOT NULL, - "description" VARCHAR(4000), - "grill_md" TEXT, - "plan_md" TEXT, - "pbi_id" TEXT, - "status" "IdeaStatus" NOT NULL DEFAULT 'DRAFT', - "archived" BOOLEAN NOT NULL DEFAULT false, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - CONSTRAINT "ideas_pkey" PRIMARY KEY ("id") -); - -CREATE UNIQUE INDEX "ideas_pbi_id_key" ON "ideas"("pbi_id"); -CREATE UNIQUE INDEX "ideas_user_id_code_key" ON "ideas"("user_id", "code"); -CREATE INDEX "ideas_user_id_archived_status_idx" ON "ideas"("user_id", "archived", "status"); -CREATE INDEX "ideas_user_id_product_id_idx" ON "ideas"("user_id", "product_id"); - -ALTER TABLE "ideas" ADD CONSTRAINT "ideas_user_id_fkey" - FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "ideas" ADD CONSTRAINT "ideas_product_id_fkey" - FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE; -ALTER TABLE "ideas" ADD CONSTRAINT "ideas_pbi_id_fkey" - FOREIGN KEY ("pbi_id") REFERENCES "pbis"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- 4. idea_logs table -CREATE TABLE "idea_logs" ( - "id" TEXT NOT NULL, - "idea_id" TEXT NOT NULL, - "type" "IdeaLogType" NOT NULL, - "content" TEXT NOT NULL, - "metadata" JSONB, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "idea_logs_pkey" PRIMARY KEY ("id") -); - -CREATE INDEX "idea_logs_idea_id_created_at_idx" ON "idea_logs"("idea_id", "created_at"); - -ALTER TABLE "idea_logs" ADD CONSTRAINT "idea_logs_idea_id_fkey" - FOREIGN KEY ("idea_id") REFERENCES "ideas"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- 5. ClaudeJob: nullable task_id, new idea_id + kind -ALTER TABLE "claude_jobs" DROP CONSTRAINT "claude_jobs_task_id_fkey"; -ALTER TABLE "claude_jobs" ALTER COLUMN "task_id" DROP NOT NULL; -ALTER TABLE "claude_jobs" ADD COLUMN "idea_id" TEXT; -ALTER TABLE "claude_jobs" ADD COLUMN "kind" "ClaudeJobKind" NOT NULL DEFAULT 'TASK_IMPLEMENTATION'; -ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_task_id_fkey" - FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_idea_id_fkey" - FOREIGN KEY ("idea_id") REFERENCES "ideas"("id") ON DELETE CASCADE ON UPDATE CASCADE; -CREATE INDEX "claude_jobs_idea_id_status_idx" ON "claude_jobs"("idea_id", "status"); - --- Check-constraint: exactly one of task_id, idea_id -ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_one_of_task_or_idea" - CHECK (("task_id" IS NOT NULL) <> ("idea_id" IS NOT NULL)); - --- 6. ClaudeQuestion: nullable story_id, new idea_id -ALTER TABLE "claude_questions" DROP CONSTRAINT "claude_questions_story_id_fkey"; -ALTER TABLE "claude_questions" ALTER COLUMN "story_id" DROP NOT NULL; -ALTER TABLE "claude_questions" ADD COLUMN "idea_id" TEXT; -ALTER TABLE "claude_questions" ADD CONSTRAINT "claude_questions_story_id_fkey" - FOREIGN KEY ("story_id") REFERENCES "stories"("id") ON DELETE CASCADE ON UPDATE CASCADE; -ALTER TABLE "claude_questions" ADD CONSTRAINT "claude_questions_idea_id_fkey" - FOREIGN KEY ("idea_id") REFERENCES "ideas"("id") ON DELETE CASCADE ON UPDATE CASCADE; -CREATE INDEX "claude_questions_idea_id_status_idx" ON "claude_questions"("idea_id", "status"); - --- Check-constraint: exactly one of story_id, idea_id -ALTER TABLE "claude_questions" ADD CONSTRAINT "claude_questions_one_of_story_or_idea" - CHECK (("story_id" IS NOT NULL) <> ("idea_id" IS NOT NULL)); - --- 7. pg_notify-trigger update: handle null story_id + emit idea_id --- Replaces notify_question_change from 20260427224849_add_claude_questions. --- New payload shape: --- { op: 'I' | 'U', --- entity: 'question', --- id: text, --- product_id: text, --- story_id: text|null, --- task_id: text|null, --- idea_id: text|null, --- assignee_id: text|null, // story.assignee_id, null voor idea-questions (privé) --- status: 'open'|'answered'|'cancelled'|'expired' } - -CREATE OR REPLACE FUNCTION notify_question_change() RETURNS trigger AS $$ -DECLARE - story_assignee TEXT; - payload jsonb; -BEGIN - IF NEW.story_id IS NOT NULL THEN - SELECT assignee_id INTO story_assignee FROM stories WHERE id = NEW.story_id; - ELSE - story_assignee := NULL; - END IF; - - payload := jsonb_build_object( - 'op', CASE TG_OP - WHEN 'INSERT' THEN 'I' - WHEN 'UPDATE' THEN 'U' - END, - 'entity', 'question', - 'id', NEW.id, - 'product_id', NEW.product_id, - 'story_id', NEW.story_id, - 'task_id', NEW.task_id, - 'idea_id', NEW.idea_id, - 'assignee_id', story_assignee, - 'status', NEW.status - ); - - PERFORM pg_notify('scrum4me_changes', payload::text); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; diff --git a/prisma/migrations/20260505000000_add_admin_role_must_reset_cancelled/migration.sql b/prisma/migrations/20260505000000_add_admin_role_must_reset_cancelled/migration.sql deleted file mode 100644 index 22b32f1..0000000 --- a/prisma/migrations/20260505000000_add_admin_role_must_reset_cancelled/migration.sql +++ /dev/null @@ -1,6 +0,0 @@ --- ALTER TYPE ADD VALUE cannot run inside a transaction in PostgreSQL -ALTER TYPE "Role" ADD VALUE IF NOT EXISTS 'ADMIN'; - -BEGIN; -ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "must_reset_password" BOOLEAN NOT NULL DEFAULT false; -COMMIT; diff --git a/prisma/migrations/20260505120000_add_user_question_plan_chat/migration.sql b/prisma/migrations/20260505120000_add_user_question_plan_chat/migration.sql deleted file mode 100644 index 99dc13c..0000000 --- a/prisma/migrations/20260505120000_add_user_question_plan_chat/migration.sql +++ /dev/null @@ -1,28 +0,0 @@ --- CreateEnum -CREATE TYPE "UserQuestionStatus" AS ENUM ('pending', 'answered'); - --- AlterEnum -ALTER TYPE "ClaudeJobKind" ADD VALUE 'PLAN_CHAT'; - --- CreateTable -CREATE TABLE "user_questions" ( - "id" TEXT NOT NULL, - "idea_id" TEXT NOT NULL, - "user_id" TEXT NOT NULL, - "question" TEXT NOT NULL, - "answer" TEXT, - "status" "UserQuestionStatus" NOT NULL DEFAULT 'pending', - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "user_questions_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "user_questions_idea_id_status_idx" ON "user_questions"("idea_id", "status"); - --- CreateIndex -CREATE INDEX "user_questions_user_id_idx" ON "user_questions"("user_id"); - --- AddForeignKey -ALTER TABLE "user_questions" ADD CONSTRAINT "user_questions_idea_id_fkey" FOREIGN KEY ("idea_id") REFERENCES "ideas"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260505230512_add_skipped_to_claude_job_status/migration.sql b/prisma/migrations/20260505230512_add_skipped_to_claude_job_status/migration.sql deleted file mode 100644 index c4d9d47..0000000 --- a/prisma/migrations/20260505230512_add_skipped_to_claude_job_status/migration.sql +++ /dev/null @@ -1,6 +0,0 @@ --- Add SKIPPED to ClaudeJobStatus enum. --- Used for jobs where the worker correctly detects that the work was already --- merged before the job ran (verify=EMPTY/DIVERGENT with no net diff). --- Distinct from FAILED (genuine errors) and DONE (new commit produced). - -ALTER TYPE "ClaudeJobStatus" ADD VALUE 'SKIPPED'; diff --git a/prisma/migrations/20260506001700_story_logs_notify/migration.sql b/prisma/migrations/20260506001700_story_logs_notify/migration.sql deleted file mode 100644 index 6d246b5..0000000 --- a/prisma/migrations/20260506001700_story_logs_notify/migration.sql +++ /dev/null @@ -1,44 +0,0 @@ --- pg_notify trigger op story_logs: emit AFTER INSERT op het gedeelde --- 'scrum4me_changes'-channel zodat de Sync-tab op /ideas/[id] real-time --- nieuwe IMPLEMENTATION_PLAN/COMMIT/TEST_RESULT-entries kan tonen zonder --- handmatige refresh. --- --- Payload-format consistent met andere triggers in deze codebase: --- {op:'INSERT', entity:'story_log', id, story_id, product_id, idea_id?} --- --- product_id en idea_id worden afgeleid via story → pbi → product en --- story → pbi → idea (1:1 via Idea.pbi_id). Hierdoor kan de SSE-route --- filteren op productAccessFilter én op user-eigen ideeën zonder extra --- DB-call per event. - -CREATE OR REPLACE FUNCTION notify_story_log_change() RETURNS TRIGGER AS $$ -DECLARE - v_product_id text; - v_idea_id text; - payload json; -BEGIN - SELECT p.product_id, i.id - INTO v_product_id, v_idea_id - FROM stories s - JOIN pbis p ON p.id = s.pbi_id - LEFT JOIN ideas i ON i.pbi_id = p.id - WHERE s.id = NEW.story_id; - - payload := json_build_object( - 'op', TG_OP, - 'entity', 'story_log', - 'id', NEW.id, - 'story_id', NEW.story_id, - 'product_id', v_product_id, - 'idea_id', v_idea_id, - 'log_type', NEW.type - ); - - PERFORM pg_notify('scrum4me_changes', payload::text); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER story_logs_notify - AFTER INSERT ON story_logs - FOR EACH ROW EXECUTE FUNCTION notify_story_log_change(); diff --git a/prisma/migrations/20260506010000_add_idea_product_secondary/migration.sql b/prisma/migrations/20260506010000_add_idea_product_secondary/migration.sql deleted file mode 100644 index 4650105..0000000 --- a/prisma/migrations/20260506010000_add_idea_product_secondary/migration.sql +++ /dev/null @@ -1,21 +0,0 @@ --- CreateTable -CREATE TABLE "idea_products" ( - "id" TEXT NOT NULL, - "idea_id" TEXT NOT NULL, - "product_id" TEXT NOT NULL, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "idea_products_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "idea_products_product_id_idx" ON "idea_products"("product_id"); - --- CreateIndex -CREATE UNIQUE INDEX "idea_products_idea_id_product_id_key" ON "idea_products"("idea_id", "product_id"); - --- AddForeignKey -ALTER TABLE "idea_products" ADD CONSTRAINT "idea_products_idea_id_fkey" FOREIGN KEY ("idea_id") REFERENCES "ideas"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "idea_products" ADD CONSTRAINT "idea_products_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260506010013_add_token_usage_fields/migration.sql b/prisma/migrations/20260506010013_add_token_usage_fields/migration.sql deleted file mode 100644 index 322eef5..0000000 --- a/prisma/migrations/20260506010013_add_token_usage_fields/migration.sql +++ /dev/null @@ -1,24 +0,0 @@ --- AlterTable -ALTER TABLE "claude_jobs" ADD COLUMN "cache_read_tokens" INTEGER, -ADD COLUMN "cache_write_tokens" INTEGER, -ADD COLUMN "input_tokens" INTEGER, -ADD COLUMN "model_id" TEXT, -ADD COLUMN "output_tokens" INTEGER; - --- CreateTable -CREATE TABLE "model_prices" ( - "id" TEXT NOT NULL, - "model_id" TEXT NOT NULL, - "input_price_per_1m" DECIMAL(12,6) NOT NULL, - "output_price_per_1m" DECIMAL(12,6) NOT NULL, - "cache_read_price_per_1m" DECIMAL(12,6) NOT NULL, - "cache_write_price_per_1m" DECIMAL(12,6) NOT NULL, - "currency" TEXT NOT NULL DEFAULT 'USD', - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "model_prices_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "model_prices_model_id_key" ON "model_prices"("model_id"); diff --git a/prisma/migrations/20260506014222_add_worker_quota_gate/migration.sql b/prisma/migrations/20260506014222_add_worker_quota_gate/migration.sql deleted file mode 100644 index 66aa1fb..0000000 --- a/prisma/migrations/20260506014222_add_worker_quota_gate/migration.sql +++ /dev/null @@ -1,6 +0,0 @@ --- AlterTable -ALTER TABLE "claude_workers" ADD COLUMN "last_quota_check_at" TIMESTAMP(3), -ADD COLUMN "last_quota_pct" INTEGER; - --- AlterTable -ALTER TABLE "users" ADD COLUMN "min_quota_pct" INTEGER NOT NULL DEFAULT 20; diff --git a/prisma/migrations/20260506101436_restore_todos_table/migration.sql b/prisma/migrations/20260506101436_restore_todos_table/migration.sql deleted file mode 100644 index 693c8b6..0000000 --- a/prisma/migrations/20260506101436_restore_todos_table/migration.sql +++ /dev/null @@ -1,33 +0,0 @@ --- Restore todos table after out-of-band drop. --- DB-state lost the table while schema.prisma still defined `model Todo` and --- migration history showed no removal. This migration brings DB back in sync --- with the schema. Generated via: --- npx prisma migrate diff --from-config-datasource \ --- --to-schema prisma/schema.prisma --script - --- CreateTable -CREATE TABLE "todos" ( - "id" TEXT NOT NULL, - "user_id" TEXT NOT NULL, - "product_id" TEXT, - "title" TEXT NOT NULL, - "description" VARCHAR(2000), - "done" BOOLEAN NOT NULL DEFAULT false, - "archived" BOOLEAN NOT NULL DEFAULT false, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "todos_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "todos_user_id_done_archived_idx" ON "todos"("user_id", "done", "archived"); - --- CreateIndex -CREATE INDEX "todos_user_id_product_id_idx" ON "todos"("user_id", "product_id"); - --- AddForeignKey -ALTER TABLE "todos" ADD CONSTRAINT "todos_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "todos" ADD CONSTRAINT "todos_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260506114500_sprint_run_and_failed_statuses/migration.sql b/prisma/migrations/20260506114500_sprint_run_and_failed_statuses/migration.sql deleted file mode 100644 index b450a61..0000000 --- a/prisma/migrations/20260506114500_sprint_run_and_failed_statuses/migration.sql +++ /dev/null @@ -1,71 +0,0 @@ --- Sprint-niveau jobflow met cascade-FAIL (PBI-46 / F1). --- Voegt FAILED toe aan TaskStatus, StoryStatus, PbiStatus, SprintStatus. --- Introduceert SprintRunStatus en PrStrategy enums. --- Maakt sprint_runs tabel + ClaudeJob.sprint_run_id koppeling + Product.pr_strategy. --- --- Gegenereerd via: npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --- (handmatig opgeschoond: todos-tabel wijzigingen weggelaten — zit in een separate migratie #131). - --- CreateEnum -CREATE TYPE "SprintRunStatus" AS ENUM ('QUEUED', 'RUNNING', 'PAUSED', 'DONE', 'FAILED', 'CANCELLED'); - --- CreateEnum -CREATE TYPE "PrStrategy" AS ENUM ('SPRINT', 'STORY'); - --- AlterEnum -ALTER TYPE "PbiStatus" ADD VALUE 'FAILED'; - --- AlterEnum -ALTER TYPE "SprintStatus" ADD VALUE 'FAILED'; - --- AlterEnum -ALTER TYPE "StoryStatus" ADD VALUE 'FAILED'; - --- AlterEnum -ALTER TYPE "TaskStatus" ADD VALUE 'FAILED'; - --- AlterTable -ALTER TABLE "claude_jobs" ADD COLUMN "sprint_run_id" TEXT; - --- AlterTable -ALTER TABLE "products" ADD COLUMN "pr_strategy" "PrStrategy" NOT NULL DEFAULT 'SPRINT'; - --- CreateTable -CREATE TABLE "sprint_runs" ( - "id" TEXT NOT NULL, - "sprint_id" TEXT NOT NULL, - "started_by_id" TEXT NOT NULL, - "status" "SprintRunStatus" NOT NULL DEFAULT 'QUEUED', - "pr_strategy" "PrStrategy" NOT NULL, - "branch" TEXT, - "pr_url" TEXT, - "started_at" TIMESTAMP(3), - "finished_at" TIMESTAMP(3), - "failure_reason" TEXT, - "failed_task_id" TEXT, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "sprint_runs_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "sprint_runs_sprint_id_status_idx" ON "sprint_runs"("sprint_id", "status"); - --- CreateIndex -CREATE INDEX "sprint_runs_started_by_id_status_idx" ON "sprint_runs"("started_by_id", "status"); - --- CreateIndex -CREATE INDEX "claude_jobs_sprint_run_id_status_idx" ON "claude_jobs"("sprint_run_id", "status"); - --- AddForeignKey -ALTER TABLE "sprint_runs" ADD CONSTRAINT "sprint_runs_sprint_id_fkey" FOREIGN KEY ("sprint_id") REFERENCES "sprints"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "sprint_runs" ADD CONSTRAINT "sprint_runs_started_by_id_fkey" FOREIGN KEY ("started_by_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "sprint_runs" ADD CONSTRAINT "sprint_runs_failed_task_id_fkey" FOREIGN KEY ("failed_task_id") REFERENCES "tasks"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_sprint_run_id_fkey" FOREIGN KEY ("sprint_run_id") REFERENCES "sprint_runs"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20260506182000_add_job_shas_and_pause_context/migration.sql b/prisma/migrations/20260506182000_add_job_shas_and_pause_context/migration.sql deleted file mode 100644 index d125468..0000000 --- a/prisma/migrations/20260506182000_add_job_shas_and_pause_context/migration.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Add base_sha + head_sha to ClaudeJob (per-job verify scope + merge-guard). --- Add pause_context to SprintRun (rich PAUSED-state for merge-conflicts). --- All nullable so legacy rows remain valid; new code-paths set them. - --- AlterTable -ALTER TABLE "claude_jobs" ADD COLUMN "base_sha" TEXT, -ADD COLUMN "head_sha" TEXT; - --- AlterTable -ALTER TABLE "sprint_runs" ADD COLUMN "pause_context" JSONB; diff --git a/prisma/migrations/20260507000000_migrate_todos_to_ideas/migration.sql b/prisma/migrations/20260507000000_migrate_todos_to_ideas/migration.sql deleted file mode 100644 index 4d740ae..0000000 --- a/prisma/migrations/20260507000000_migrate_todos_to_ideas/migration.sql +++ /dev/null @@ -1,59 +0,0 @@ -BEGIN; - -WITH active_todos AS ( - SELECT t.id, t.user_id, t.product_id, t.title, t.description, t.created_at - FROM todos t - WHERE t.done = false - AND t.archived = false - AND NOT EXISTS ( - SELECT 1 FROM ideas i - WHERE i.user_id = t.user_id - AND lower(trim(i.title)) = lower(trim(t.title)) - ) -), -user_base AS ( - SELECT ut.user_id, u.idea_code_counter AS base_counter - FROM (SELECT DISTINCT user_id FROM active_todos) ut - JOIN users u ON u.id = ut.user_id -), -ranked AS ( - SELECT - at.user_id, at.product_id, at.title, at.description, at.created_at, - ub.base_counter, - ROW_NUMBER() OVER (PARTITION BY at.user_id ORDER BY at.created_at, at.id) AS rn - FROM active_todos at - JOIN user_base ub ON ub.user_id = at.user_id -), -inserted AS ( - INSERT INTO ideas ( - id, user_id, product_id, code, title, description, - status, archived, created_at, updated_at - ) - SELECT - gen_random_uuid()::text, - user_id, - product_id, - 'IDEA-' || lpad((base_counter + rn)::text, 3, '0'), - title, - description, - 'DRAFT', - false, - created_at, - now() - FROM ranked - RETURNING user_id, - (regexp_replace(code, '^IDEA-0*', ''))::int AS used_counter -) -UPDATE users u -SET idea_code_counter = sub.max_counter -FROM ( - SELECT user_id, MAX(used_counter) AS max_counter - FROM inserted - GROUP BY user_id -) sub -WHERE u.id = sub.user_id - AND sub.max_counter > u.idea_code_counter; - -DROP TABLE todos; - -COMMIT; diff --git a/prisma/migrations/20260507103000_sprint_implementation/migration.sql b/prisma/migrations/20260507103000_sprint_implementation/migration.sql deleted file mode 100644 index 1091150..0000000 --- a/prisma/migrations/20260507103000_sprint_implementation/migration.sql +++ /dev/null @@ -1,68 +0,0 @@ --- PBI-50: SPRINT_IMPLEMENTATION single-session sprint runner --- Adds: --- - PrStrategy.SPRINT_BATCH (third option) --- - ClaudeJobKind.SPRINT_IMPLEMENTATION (fifth kind) --- - SprintTaskExecutionStatus enum --- - ClaudeJob.lease_until (for heartbeat-driven stale detection) --- - SprintRun.previous_run_id (for branch reuse on resume) --- - sprint_task_executions table (frozen scope-snapshot per claim) - --- AlterEnum: PrStrategy ADD VALUE (Postgres requires this outside transaction; --- Prisma migrate handles it) -ALTER TYPE "PrStrategy" ADD VALUE 'SPRINT_BATCH'; - --- AlterEnum: ClaudeJobKind ADD VALUE -ALTER TYPE "ClaudeJobKind" ADD VALUE 'SPRINT_IMPLEMENTATION'; - --- CreateEnum -CREATE TYPE "SprintTaskExecutionStatus" AS ENUM ('PENDING', 'RUNNING', 'DONE', 'FAILED', 'SKIPPED'); - --- AlterTable -ALTER TABLE "claude_jobs" ADD COLUMN "lease_until" TIMESTAMP(3); - --- AlterTable -ALTER TABLE "sprint_runs" ADD COLUMN "previous_run_id" TEXT; - --- CreateTable -CREATE TABLE "sprint_task_executions" ( - "id" TEXT NOT NULL, - "sprint_job_id" TEXT NOT NULL, - "task_id" TEXT NOT NULL, - "order" INTEGER NOT NULL, - "plan_snapshot" TEXT NOT NULL, - "verify_required_snapshot" "VerifyRequired" NOT NULL, - "verify_only_snapshot" BOOLEAN NOT NULL DEFAULT false, - "base_sha" TEXT, - "head_sha" TEXT, - "status" "SprintTaskExecutionStatus" NOT NULL DEFAULT 'PENDING', - "verify_result" "VerifyResult", - "verify_summary" TEXT, - "skip_reason" TEXT, - "started_at" TIMESTAMP(3), - "finished_at" TIMESTAMP(3), - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "sprint_task_executions_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "sprint_task_executions_sprint_job_id_order_idx" ON "sprint_task_executions"("sprint_job_id", "order"); - --- CreateIndex -CREATE UNIQUE INDEX "sprint_task_executions_sprint_job_id_task_id_key" ON "sprint_task_executions"("sprint_job_id", "task_id"); - --- CreateIndex -CREATE INDEX "claude_jobs_status_lease_until_idx" ON "claude_jobs"("status", "lease_until"); - --- CreateIndex -CREATE UNIQUE INDEX "sprint_runs_previous_run_id_key" ON "sprint_runs"("previous_run_id"); - --- AddForeignKey -ALTER TABLE "sprint_runs" ADD CONSTRAINT "sprint_runs_previous_run_id_fkey" FOREIGN KEY ("previous_run_id") REFERENCES "sprint_runs"("id") ON DELETE SET NULL ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "sprint_task_executions" ADD CONSTRAINT "sprint_task_executions_sprint_job_id_fkey" FOREIGN KEY ("sprint_job_id") REFERENCES "claude_jobs"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "sprint_task_executions" ADD CONSTRAINT "sprint_task_executions_task_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260507195507_add_sprint_code/migration.sql b/prisma/migrations/20260507195507_add_sprint_code/migration.sql deleted file mode 100644 index 5d096e1..0000000 --- a/prisma/migrations/20260507195507_add_sprint_code/migration.sql +++ /dev/null @@ -1,23 +0,0 @@ --- PBI-59: Sprint.code (SP-1, SP-2, ...) sequentieel per product --- --- 1. Voeg nullable kolom toe --- 2. Backfill bestaande rijen via ROW_NUMBER() per product op created_at --- 3. Maak NOT NULL en voeg unieke index toe op (product_id, code) - -ALTER TABLE "sprints" ADD COLUMN "code" VARCHAR(30); - -WITH numbered AS ( - SELECT - id, - product_id, - ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY created_at, id) AS n - FROM "sprints" -) -UPDATE "sprints" s -SET code = 'SP-' || numbered.n -FROM numbered -WHERE s.id = numbered.id; - -ALTER TABLE "sprints" ALTER COLUMN "code" SET NOT NULL; - -CREATE UNIQUE INDEX "sprints_product_id_code_key" ON "sprints"("product_id", "code"); diff --git a/prisma/migrations/20260507200000_add_push_subscriptions/migration.sql b/prisma/migrations/20260507200000_add_push_subscriptions/migration.sql deleted file mode 100644 index 2abe20f..0000000 --- a/prisma/migrations/20260507200000_add_push_subscriptions/migration.sql +++ /dev/null @@ -1,20 +0,0 @@ --- PushSubscription model for Web Push notifications (PBI-55) - -CREATE TABLE "push_subscriptions" ( - "id" TEXT NOT NULL, - "user_id" TEXT NOT NULL, - "endpoint" TEXT NOT NULL, - "p256dh" TEXT NOT NULL, - "auth" TEXT NOT NULL, - "user_agent" TEXT, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "last_used_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "push_subscriptions_pkey" PRIMARY KEY ("id") -); - -CREATE UNIQUE INDEX "push_subscriptions_endpoint_key" ON "push_subscriptions"("endpoint"); - -CREATE INDEX "push_subscriptions_user_id_idx" ON "push_subscriptions"("user_id"); - -ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260507210000_sprint_lifecycle/migration.sql b/prisma/migrations/20260507210000_sprint_lifecycle/migration.sql deleted file mode 100644 index 05a524b..0000000 --- a/prisma/migrations/20260507210000_sprint_lifecycle/migration.sql +++ /dev/null @@ -1,19 +0,0 @@ --- PBI-63: Sprint-lifecycle migratie --- --- 1. Hernoem SprintStatus ACTIVE → OPEN, COMPLETED → CLOSED (bestaande data behouden, geen rewrite) --- 2. Voeg ARCHIVED toe (FAILED blijft) --- 3. Pas Sprint.status default aan naar OPEN --- 4. Voeg EXCLUDED toe aan TaskStatus --- --- ALTER TYPE ... RENAME VALUE werkt vanaf PostgreSQL 10 zonder data-rewrite. --- ALTER TYPE ... ADD VALUE moet buiten een transaction-block uitgevoerd worden; --- Prisma migrate runt elk SQL-bestand zonder impliciete BEGIN/COMMIT, dus --- losse statements zijn voldoende. - -ALTER TYPE "SprintStatus" RENAME VALUE 'ACTIVE' TO 'OPEN'; -ALTER TYPE "SprintStatus" RENAME VALUE 'COMPLETED' TO 'CLOSED'; -ALTER TYPE "SprintStatus" ADD VALUE 'ARCHIVED'; - -ALTER TABLE "sprints" ALTER COLUMN "status" SET DEFAULT 'OPEN'; - -ALTER TYPE "TaskStatus" ADD VALUE 'EXCLUDED'; diff --git a/prisma/migrations/20260508085909_add_job_model_selection_fields/migration.sql b/prisma/migrations/20260508085909_add_job_model_selection_fields/migration.sql deleted file mode 100644 index 20b891a..0000000 --- a/prisma/migrations/20260508085909_add_job_model_selection_fields/migration.sql +++ /dev/null @@ -1,18 +0,0 @@ --- PBI-67: Model + mode-selectie per ClaudeJob-kind --- --- Additieve migration: nieuwe optionele kolommen op products, tasks en --- claude_jobs voor de override-cascade --- task.requires_opus → job.requested_* → product.preferred_* → kind-default --- Bestaande rijen krijgen NULL (Product/ClaudeJob) of false (Task.requires_opus) --- en vallen daarmee terug op kind-defaults uit de resolver. - -ALTER TABLE "products" ADD COLUMN "preferred_model" TEXT; -ALTER TABLE "products" ADD COLUMN "thinking_budget_default" INTEGER; -ALTER TABLE "products" ADD COLUMN "preferred_permission_mode" TEXT; - -ALTER TABLE "tasks" ADD COLUMN "requires_opus" BOOLEAN NOT NULL DEFAULT false; - -ALTER TABLE "claude_jobs" ADD COLUMN "requested_model" TEXT; -ALTER TABLE "claude_jobs" ADD COLUMN "requested_thinking_budget" INTEGER; -ALTER TABLE "claude_jobs" ADD COLUMN "requested_permission_mode" TEXT; -ALTER TABLE "claude_jobs" ADD COLUMN "actual_thinking_tokens" INTEGER; diff --git a/prisma/migrations/20260510113221_add_user_settings_json/migration.sql b/prisma/migrations/20260510113221_add_user_settings_json/migration.sql deleted file mode 100644 index 8311085..0000000 --- a/prisma/migrations/20260510113221_add_user_settings_json/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "users" ADD COLUMN "settings" JSONB NOT NULL DEFAULT '{}'; diff --git a/prisma/migrations/20260514000000_add_review_plan_support/migration.sql b/prisma/migrations/20260514000000_add_review_plan_support/migration.sql deleted file mode 100644 index 0c6f6d5..0000000 --- a/prisma/migrations/20260514000000_add_review_plan_support/migration.sql +++ /dev/null @@ -1,11 +0,0 @@ --- AlterEnum -ALTER TYPE "IdeaStatus" ADD VALUE 'REVIEWING_PLAN'; -ALTER TYPE "IdeaStatus" ADD VALUE 'PLAN_REVIEW_FAILED'; -ALTER TYPE "IdeaStatus" ADD VALUE 'PLAN_REVIEWED'; - --- AlterEnum -ALTER TYPE "ClaudeJobKind" ADD VALUE 'IDEA_REVIEW_PLAN'; - --- AlterTable -ALTER TABLE "ideas" ADD COLUMN "plan_review_log" JSONB, -ADD COLUMN "reviewed_at" TIMESTAMP(3); diff --git a/prisma/migrations/20260514100000_backfill_story_task_sort_order_from_code/migration.sql b/prisma/migrations/20260514100000_backfill_story_task_sort_order_from_code/migration.sql deleted file mode 100644 index c77c4cb..0000000 --- a/prisma/migrations/20260514100000_backfill_story_task_sort_order_from_code/migration.sql +++ /dev/null @@ -1,15 +0,0 @@ --- Backfill story/task sort_order from trailing numeric part of code. --- Consistent with parseCodeNumber: no trailing digits → Number.MAX_SAFE_INTEGER (9007199254740991). --- PBIs are intentionally excluded (they keep drag-and-drop + priority ordering). - -UPDATE "stories" -SET sort_order = COALESCE( - CAST(SUBSTRING(code FROM '[0-9]+$') AS DOUBLE PRECISION), - 9007199254740991 -); - -UPDATE "tasks" -SET sort_order = COALESCE( - CAST(SUBSTRING(code FROM '[0-9]+$') AS DOUBLE PRECISION), - 9007199254740991 -); diff --git a/prisma/migrations/20260514160000_add_plan_review_result_logtype/migration.sql b/prisma/migrations/20260514160000_add_plan_review_result_logtype/migration.sql deleted file mode 100644 index ae0e39c..0000000 --- a/prisma/migrations/20260514160000_add_plan_review_result_logtype/migration.sql +++ /dev/null @@ -1,7 +0,0 @@ --- AlterEnum --- schema.prisma declareerde IdeaLogType.PLAN_REVIEW_RESULT al, maar de --- migratie 20260514000000_add_review_plan_support voegde 'm niet toe aan de --- DB-enum (alleen IdeaStatus + ClaudeJobKind). Zonder deze waarde crasht de --- scrum4me-mcp tool update_idea_plan_reviewed runtime op --- prisma.ideaLog.create({ type: 'PLAN_REVIEW_RESULT' }). -ALTER TYPE "IdeaLogType" ADD VALUE 'PLAN_REVIEW_RESULT'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d854a58..599cdc2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2,6 +2,11 @@ generator client { provider = "prisma-client-js" } +generator erd { + provider = "prisma-erd-generator" + output = "../docs/erd.svg" +} + datasource db { provider = "postgresql" } @@ -10,20 +15,17 @@ enum Role { PRODUCT_OWNER SCRUM_MASTER DEVELOPER - ADMIN } enum StoryStatus { OPEN IN_SPRINT DONE - FAILED } enum PbiStatus { READY BLOCKED - FAILED DONE } @@ -34,7 +36,6 @@ enum ClaudeJobStatus { DONE FAILED CANCELLED - SKIPPED } enum VerifyResult { @@ -55,8 +56,6 @@ enum TaskStatus { IN_PROGRESS REVIEW DONE - FAILED - EXCLUDED } enum LogType { @@ -71,94 +70,27 @@ enum TestStatus { } enum SprintStatus { - OPEN - CLOSED - ARCHIVED - FAILED -} - -enum SprintRunStatus { - QUEUED - RUNNING - PAUSED - DONE - FAILED - CANCELLED -} - -enum PrStrategy { - SPRINT - STORY - SPRINT_BATCH -} - -enum IdeaStatus { - DRAFT - GRILLING - GRILL_FAILED - GRILLED - PLANNING - PLAN_FAILED - PLAN_READY - REVIEWING_PLAN - PLAN_REVIEW_FAILED - PLAN_REVIEWED - PLANNED -} - -enum ClaudeJobKind { - TASK_IMPLEMENTATION - IDEA_GRILL - IDEA_MAKE_PLAN - IDEA_REVIEW_PLAN - PLAN_CHAT - SPRINT_IMPLEMENTATION -} - -enum SprintTaskExecutionStatus { - PENDING - RUNNING - DONE - FAILED - SKIPPED -} - -enum IdeaLogType { - DECISION - NOTE - GRILL_RESULT - PLAN_RESULT - PLAN_REVIEW_RESULT - STATUS_CHANGE - JOB_EVENT -} - -enum UserQuestionStatus { - pending - answered + ACTIVE + COMPLETED } model User { - id String @id @default(cuid()) - username String @unique - email String? @unique - password_hash String - is_demo Boolean @default(false) - bio String? @db.VarChar(160) - bio_detail String? @db.VarChar(2000) - must_reset_password Boolean @default(false) - avatar_data Bytes? - active_product_id String? - active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull) - idea_code_counter Int @default(0) - min_quota_pct Int @default(20) - settings Json @default("{}") - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - roles UserRole[] + id String @id @default(cuid()) + username String @unique + email String? @unique + password_hash String + is_demo Boolean @default(false) + bio String? @db.VarChar(160) + bio_detail String? @db.VarChar(2000) + avatar_data Bytes? + active_product_id String? + active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + roles UserRole[] api_tokens ApiToken[] products Product[] - ideas Idea[] + todos Todo[] product_members ProductMember[] assigned_stories Story[] @relation("StoryAssignee") login_pairings LoginPairing[] @@ -166,8 +98,6 @@ model User { answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer") claude_jobs ClaudeJob[] claude_workers ClaudeWorker[] - started_sprint_runs SprintRun[] @relation("SprintRunStartedBy") - push_subscriptions PushSubscription[] @@index([active_product_id]) @@map("users") @@ -184,47 +114,41 @@ model UserRole { } model ApiToken { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - token_hash String @unique - label String? - created_at DateTime @default(now()) - revoked_at DateTime? - claimed_jobs ClaudeJob[] - claude_worker ClaudeWorker? + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + token_hash String @unique + label String? + created_at DateTime @default(now()) + revoked_at DateTime? + claimed_jobs ClaudeJob[] + claude_worker ClaudeWorker? @@index([token_hash]) @@map("api_tokens") } model Product { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) user_id String name String - code String? @db.VarChar(30) + code String? @db.VarChar(30) description String? repo_url String? definition_of_done String - auto_pr Boolean @default(false) - pr_strategy PrStrategy @default(SPRINT) - preferred_model String? - thinking_budget_default Int? - preferred_permission_mode String? - archived Boolean @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + auto_pr Boolean @default(false) + archived Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt pbis Pbi[] sprints Sprint[] stories Story[] - tasks Task[] + todos Todo[] members ProductMember[] active_for_users User[] @relation("UserActiveProduct") claude_questions ClaudeQuestion[] claude_jobs ClaudeJob[] - ideas Idea[] - idea_products IdeaProduct[] @@unique([user_id, name]) @@unique([user_id, code]) @@ -233,21 +157,18 @@ model Product { } model Pbi { - id String @id @default(cuid()) - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) - product_id String - code String @db.VarChar(30) - title String - description String? - priority Int - sort_order Float - status PbiStatus @default(READY) - pr_url String? - pr_merged_at DateTime? - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - stories Story[] - idea Idea? + id String @id @default(cuid()) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String + code String? @db.VarChar(30) + title String + description String? + priority Int + sort_order Float + status PbiStatus @default(READY) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + stories Story[] @@unique([product_id, code]) @@index([product_id, priority, sort_order]) @@ -256,24 +177,24 @@ model Pbi { } model Story { - id String @id @default(cuid()) - pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + pbi Pbi @relation(fields: [pbi_id], references: [id], onDelete: Cascade) pbi_id String - product Product @relation(fields: [product_id], references: [id]) + product Product @relation(fields: [product_id], references: [id]) product_id String - sprint Sprint? @relation(fields: [sprint_id], references: [id]) + sprint Sprint? @relation(fields: [sprint_id], references: [id]) sprint_id String? - assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull) + assignee User? @relation("StoryAssignee", fields: [assignee_id], references: [id], onDelete: SetNull) assignee_id String? - code String @db.VarChar(30) + code String? @db.VarChar(30) title String description String? acceptance_criteria String? priority Int sort_order Float - status StoryStatus @default(OPEN) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + status StoryStatus @default(OPEN) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt logs StoryLog[] tasks Task[] claude_questions ClaudeQuestion[] @@ -306,196 +227,89 @@ model Sprint { id String @id @default(cuid()) product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) product_id String - code String @db.VarChar(30) sprint_goal String - status SprintStatus @default(OPEN) + status SprintStatus @default(ACTIVE) start_date DateTime? @db.Date end_date DateTime? @db.Date created_at DateTime @default(now()) completed_at DateTime? stories Story[] tasks Task[] - sprint_runs SprintRun[] - @@unique([product_id, code]) @@index([product_id, status]) @@map("sprints") } -model SprintRun { - id String @id @default(cuid()) - sprint Sprint @relation(fields: [sprint_id], references: [id], onDelete: Cascade) - sprint_id String - started_by User @relation("SprintRunStartedBy", fields: [started_by_id], references: [id]) - started_by_id String - status SprintRunStatus @default(QUEUED) - pr_strategy PrStrategy - branch String? - pr_url String? - started_at DateTime? - finished_at DateTime? - failure_reason String? - failed_task Task? @relation("SprintRunFailedTask", fields: [failed_task_id], references: [id], onDelete: SetNull) - failed_task_id String? - pause_context Json? - previous_run_id String? @unique - previous_run SprintRun? @relation("SprintRunChain", fields: [previous_run_id], references: [id], onDelete: SetNull) - next_run SprintRun? @relation("SprintRunChain") - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - jobs ClaudeJob[] - - @@index([sprint_id, status]) - @@index([started_by_id, status]) - @@map("sprint_runs") -} - model Task { - id String @id @default(cuid()) - story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) - story_id String - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) - product_id String - sprint Sprint? @relation(fields: [sprint_id], references: [id]) - sprint_id String? - code String @db.VarChar(30) - title String - description String? - implementation_plan String? - priority Int - sort_order Float - status TaskStatus @default(TO_DO) - verify_only Boolean @default(false) - verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL) - requires_opus Boolean @default(false) + id String @id @default(cuid()) + story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) + story_id String + sprint Sprint? @relation(fields: [sprint_id], references: [id]) + sprint_id String? + title String + description String? + implementation_plan String? + priority Int + sort_order Float + status TaskStatus @default(TO_DO) + verify_only Boolean @default(false) + verify_required VerifyRequired @default(ALIGNED_OR_PARTIAL) // Override product.repo_url for branch/worktree/push purposes. Set when // a task targets a different repo than its parent product (e.g. an // MCP-server task tracked under the main product's PBI). Falls back to // product.repo_url when null. - repo_url String? - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - claude_questions ClaudeQuestion[] - claude_jobs ClaudeJob[] - sprint_run_failures SprintRun[] @relation("SprintRunFailedTask") - sprint_task_executions SprintTaskExecution[] + repo_url String? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + claude_questions ClaudeQuestion[] + claude_jobs ClaudeJob[] - @@unique([product_id, code]) @@index([story_id, priority, sort_order]) @@index([sprint_id, status]) - @@index([product_id]) @@map("tasks") } model ClaudeJob { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) user_id String - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) product_id String - task Task? @relation(fields: [task_id], references: [id], onDelete: Cascade) - task_id String? - idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade) - idea_id String? - sprint_run SprintRun? @relation(fields: [sprint_run_id], references: [id], onDelete: SetNull) - sprint_run_id String? - kind ClaudeJobKind @default(TASK_IMPLEMENTATION) - status ClaudeJobStatus @default(QUEUED) - claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull) + task Task @relation(fields: [task_id], references: [id], onDelete: Cascade) + task_id String + status ClaudeJobStatus @default(QUEUED) + claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull) claimed_by_token_id String? claimed_at DateTime? started_at DateTime? finished_at DateTime? pushed_at DateTime? verify_result VerifyResult? - model_id String? - input_tokens Int? - output_tokens Int? - cache_read_tokens Int? - cache_write_tokens Int? - requested_model String? - requested_thinking_budget Int? - requested_permission_mode String? - actual_thinking_tokens Int? plan_snapshot String? - base_sha String? - head_sha String? branch String? pr_url String? summary String? error String? - retry_count Int @default(0) - lease_until DateTime? - task_executions SprintTaskExecution[] @relation("SprintJobExecutions") - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + retry_count Int @default(0) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt @@index([user_id, status]) @@index([task_id, status]) - @@index([idea_id, status]) - @@index([sprint_run_id, status]) @@index([status, claimed_at]) @@index([status, finished_at]) - @@index([status, lease_until]) @@map("claude_jobs") } -// PBI-50: frozen scope-snapshot per SPRINT_IMPLEMENTATION-claim. Bij claim -// wordt voor elke TO_DO-task in scope één PENDING-record gemaakt met -// implementation_plan + verify_required gesnapshot. Worker en gate werken -// uitsluitend op deze rows; latere wijzigingen aan Task hebben geen -// invloed op de lopende batch. -model SprintTaskExecution { - id String @id @default(cuid()) - sprint_job ClaudeJob @relation("SprintJobExecutions", fields: [sprint_job_id], references: [id], onDelete: Cascade) - sprint_job_id String - task Task @relation(fields: [task_id], references: [id], onDelete: Cascade) - task_id String - order Int - plan_snapshot String @db.Text - verify_required_snapshot VerifyRequired - verify_only_snapshot Boolean @default(false) - base_sha String? - head_sha String? - status SprintTaskExecutionStatus @default(PENDING) - verify_result VerifyResult? - verify_summary String? @db.Text - skip_reason String? @db.Text - started_at DateTime? - finished_at DateTime? - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - @@unique([sprint_job_id, task_id]) - @@index([sprint_job_id, order]) - @@map("sprint_task_executions") -} - -model ModelPrice { - id String @id @default(cuid()) - model_id String @unique - input_price_per_1m Decimal @db.Decimal(12, 6) - output_price_per_1m Decimal @db.Decimal(12, 6) - cache_read_price_per_1m Decimal @db.Decimal(12, 6) - cache_write_price_per_1m Decimal @db.Decimal(12, 6) - currency String @default("USD") - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - @@map("model_prices") -} - model ClaudeWorker { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade) - token_id String - product_id String? - started_at DateTime @default(now()) - last_seen_at DateTime @default(now()) - last_quota_pct Int? - last_quota_check_at DateTime? + id String @id @default(cuid()) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + user_id String + token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade) + token_id String + product_id String? + started_at DateTime @default(now()) + last_seen_at DateTime @default(now()) @@unique([token_id]) @@index([user_id, last_seen_at]) @@ -515,80 +329,22 @@ model ProductMember { @@map("product_members") } -model Idea { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) - product_id String? - code String @db.VarChar(30) - title String - description String? @db.VarChar(4000) - grill_md String? @db.Text - plan_md String? @db.Text - plan_review_log Json? // ReviewLog from orchestrator (all rounds, convergence metrics, approval status) - reviewed_at DateTime? // When last reviewed - pbi Pbi? @relation(fields: [pbi_id], references: [id], onDelete: SetNull) - pbi_id String? @unique - status IdeaStatus @default(DRAFT) - archived Boolean @default(false) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - questions ClaudeQuestion[] - jobs ClaudeJob[] - logs IdeaLog[] - user_questions UserQuestion[] - secondary_products IdeaProduct[] - - @@unique([user_id, code]) - @@index([user_id, archived, status]) - @@index([user_id, product_id]) - @@map("ideas") -} - -model IdeaProduct { +model Todo { id String @id @default(cuid()) - idea_id String - product_id String - created_at DateTime @default(now()) - - idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade) - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) - - @@unique([idea_id, product_id]) - @@index([product_id]) - @@map("idea_products") -} - -model IdeaLog { - id String @id @default(cuid()) - idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade) - idea_id String - type IdeaLogType - content String @db.Text - metadata Json? - created_at DateTime @default(now()) - - @@index([idea_id, created_at]) - @@map("idea_logs") -} - -model UserQuestion { - id String @id @default(cuid()) - idea_id String + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) user_id String - question String @db.Text - answer String? @db.Text - status UserQuestionStatus @default(pending) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt + product Product? @relation(fields: [product_id], references: [id], onDelete: SetNull) + product_id String? + title String + description String? @db.VarChar(2000) + done Boolean @default(false) + archived Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt - idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade) - - @@index([idea_id, status]) - @@index([user_id]) - @@map("user_questions") + @@index([user_id, done, archived]) + @@index([user_id, product_id]) + @@map("todos") } model LoginPairing { @@ -611,45 +367,27 @@ model LoginPairing { } model ClaudeQuestion { - id String @id @default(cuid()) - story Story? @relation(fields: [story_id], references: [id], onDelete: Cascade) - story_id String? - task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull) - task_id String? - idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade) - idea_id String? - product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) - product_id String // gedenormaliseerd uit story.product_id voor SSE-filter - asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id]) - asked_by String // user_id van token-houder (= Claude-token) - question String @db.Text - options Json? // string[] voor multi-choice; null voor free-text - status String // 'open' | 'answered' | 'cancelled' | 'expired' - answer String? @db.Text - answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id]) - answered_by String? - answered_at DateTime? - created_at DateTime @default(now()) - expires_at DateTime // ingesteld door MCP-tool, default now() + 24h + id String @id @default(cuid()) + story Story @relation(fields: [story_id], references: [id], onDelete: Cascade) + story_id String + task Task? @relation(fields: [task_id], references: [id], onDelete: SetNull) + task_id String? + product Product @relation(fields: [product_id], references: [id], onDelete: Cascade) + product_id String // gedenormaliseerd uit story.product_id voor SSE-filter + asker User @relation("ClaudeQuestionAsker", fields: [asked_by], references: [id]) + asked_by String // user_id van token-houder (= Claude-token) + question String @db.Text + options Json? // string[] voor multi-choice; null voor free-text + status String // 'open' | 'answered' | 'cancelled' | 'expired' + answer String? @db.Text + answerer User? @relation("ClaudeQuestionAnswerer", fields: [answered_by], references: [id]) + answered_by String? + answered_at DateTime? + created_at DateTime @default(now()) + expires_at DateTime // ingesteld door MCP-tool, default now() + 24h @@index([story_id, status]) - @@index([idea_id, status]) @@index([product_id, status]) @@index([status, expires_at]) @@map("claude_questions") } - -model PushSubscription { - id String @id @default(cuid()) - user User @relation(fields: [user_id], references: [id], onDelete: Cascade) - user_id String - endpoint String @unique - p256dh String - auth String - user_agent String? - created_at DateTime @default(now()) - last_used_at DateTime @default(now()) - - @@index([user_id]) - @@map("push_subscriptions") -} diff --git a/prisma/seed-data/parse-backlog.ts b/prisma/seed-data/parse-backlog.ts index 7272556..c8e5390 100644 --- a/prisma/seed-data/parse-backlog.ts +++ b/prisma/seed-data/parse-backlog.ts @@ -21,7 +21,7 @@ export type ParsedMilestone = { title: string goal: string priority: 1 | 2 | 3 | 4 - sprint_status: 'OPEN' | 'CLOSED' + sprint_status: 'ACTIVE' | 'COMPLETED' sort_order: number stories: ParsedStory[] } @@ -66,19 +66,19 @@ const MILESTONE_GOAL: Record<string, string> = { } const MILESTONE_SPRINT_STATUS: Record<string, ParsedMilestone['sprint_status']> = { - M0: 'CLOSED', - M1: 'CLOSED', - M2: 'CLOSED', - M3: 'CLOSED', - 'M3.5': 'CLOSED', - M4: 'CLOSED', - M5: 'CLOSED', - M6: 'CLOSED', - M7: 'CLOSED', - M8: 'CLOSED', - M9: 'CLOSED', - M10: 'CLOSED', - M11: 'CLOSED', + M0: 'COMPLETED', + M1: 'COMPLETED', + M2: 'COMPLETED', + M3: 'COMPLETED', + 'M3.5': 'COMPLETED', + M4: 'COMPLETED', + M5: 'COMPLETED', + M6: 'COMPLETED', + M7: 'COMPLETED', + M8: 'COMPLETED', + M9: 'COMPLETED', + M10: 'COMPLETED', + M11: 'COMPLETED', } const MILESTONE_KEY = /^(?:M[\d.]+|PBI-\d+)$/ @@ -154,7 +154,7 @@ export async function loadBacklog( title, goal: MILESTONE_GOAL[key] ?? title, priority: MILESTONE_PRIORITY[key] ?? 4, - sprint_status: MILESTONE_SPRINT_STATUS[key] ?? 'CLOSED', + sprint_status: MILESTONE_SPRINT_STATUS[key] ?? 'COMPLETED', sort_order: milestones.length + 1, stories: [], } diff --git a/prisma/seed.ts b/prisma/seed.ts index af40e23..53b645a 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -123,8 +123,6 @@ async function main() { const milestones = await loadBacklog(root) console.log(`Loaded backlog: ${milestones.length} milestones, ${milestones.reduce((acc, m) => acc + m.stories.length, 0)} stories`) - let productTaskCounter = 0 - let sprintCounter = 0 for (const ms of milestones) { const pbi = await prisma.pbi.create({ data: { @@ -140,7 +138,6 @@ async function main() { const sprint = await prisma.sprint.create({ data: { product_id: product.id, - code: `SP-${++sprintCounter}`, sprint_goal: `${ms.key} — ${ms.goal}`, status: ms.sprint_status, }, @@ -155,7 +152,7 @@ async function main() { const forceOpen = ms.key === 'M3.5' for (const s of ms.stories) { - const isActive = ms.sprint_status === 'OPEN' + const isActive = ms.sprint_status === 'ACTIVE' const effectivelyDone = !forceOpen && s.status === 'DONE' const inSprint = isActive || effectivelyDone const storyStatus = effectivelyDone ? 'DONE' : isActive ? 'IN_SPRINT' : 'OPEN' @@ -177,13 +174,10 @@ async function main() { }) for (const t of s.tasks) { - productTaskCounter += 1 await prisma.task.create({ data: { story_id: story.id, - product_id: product.id, sprint_id: inSprint ? sprint.id : null, - code: `T-${productTaskCounter}`, title: t.title, description: t.description, priority: ms.priority, @@ -195,39 +189,6 @@ async function main() { } } - const modelPrices = [ - { - model_id: 'claude-opus-4-7', - input_price_per_1m: 15.0, - output_price_per_1m: 75.0, - cache_read_price_per_1m: 1.5, - cache_write_price_per_1m: 18.75, - }, - { - model_id: 'claude-sonnet-4-6', - input_price_per_1m: 3.0, - output_price_per_1m: 15.0, - cache_read_price_per_1m: 0.3, - cache_write_price_per_1m: 3.75, - }, - { - model_id: 'claude-haiku-4-5-20251001', - input_price_per_1m: 0.8, - output_price_per_1m: 4.0, - cache_read_price_per_1m: 0.08, - cache_write_price_per_1m: 1.0, - }, - ] - - for (const mp of modelPrices) { - await prisma.modelPrice.upsert({ - where: { model_id: mp.model_id }, - update: mp, - create: mp, - }) - console.log(` ModelPrice upserted: ${mp.model_id}`) - } - console.log('\nSeeding complete!') console.log('Demo user: username=demo password=demo1234') console.log('Main user: username=lars password=scrum4me123') diff --git a/proxy.ts b/proxy.ts index 24fc34d..afbfd55 100644 --- a/proxy.ts +++ b/proxy.ts @@ -3,7 +3,7 @@ import type { NextRequest } from 'next/server' import { unsealData } from 'iron-session' import { sessionOptions, type SessionData } from '@/lib/session' -const protectedRoutes = ['/dashboard', '/products', '/todos', '/ideas', '/settings', '/solo'] +const protectedRoutes = ['/dashboard', '/products', '/todos', '/settings', '/solo'] const authRoutes = ['/login', '/register'] const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']) diff --git a/public/diagrams/architecture-dark.svg b/public/diagrams/architecture-dark.svg deleted file mode 100644 index 4fd8a08..0000000 --- a/public/diagrams/architecture-dark.svg +++ /dev/null @@ -1 +0,0 @@ -<svg id="my-svg" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="flowchart" style="max-width: 1583.62px; background-color: transparent;" viewBox="0 -50 1583.62060546875 262" role="graphics-document document" aria-roledescription="flowchart-v2"><style>#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ccc;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#my-svg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#my-svg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#my-svg .error-icon{fill:#a44141;}#my-svg .error-text{fill:#ddd;stroke:#ddd;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:lightgrey;stroke:lightgrey;}#my-svg .marker.cross{stroke:lightgrey;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#ccc;}#my-svg .cluster-label text{fill:#F9FFFE;}#my-svg .cluster-label span{color:#F9FFFE;}#my-svg .cluster-label span p{background-color:transparent;}#my-svg .label text,#my-svg span{fill:#ccc;color:#ccc;}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{fill:#1f2020;stroke:#ccc;stroke-width:1px;}#my-svg .rough-node .label text,#my-svg .node .label text,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-anchor:middle;}#my-svg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#my-svg .rough-node .label,#my-svg .node .label,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-align:center;}#my-svg .node.clickable{cursor:pointer;}#my-svg .root .anchor path{fill:lightgrey!important;stroke-width:0;stroke:lightgrey;}#my-svg .arrowheadPath{fill:lightgrey;}#my-svg .edgePath .path{stroke:lightgrey;stroke-width:1px;}#my-svg .flowchart-link{stroke:lightgrey;fill:none;}#my-svg .edgeLabel{background-color:hsl(0, 0%, 34.4117647059%);text-align:center;}#my-svg .edgeLabel p{background-color:hsl(0, 0%, 34.4117647059%);}#my-svg .edgeLabel rect{opacity:0.5;background-color:hsl(0, 0%, 34.4117647059%);fill:hsl(0, 0%, 34.4117647059%);}#my-svg .labelBkg{background-color:rgba(87.75, 87.75, 87.75, 0.5);}#my-svg .cluster rect{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:rgba(255, 255, 255, 0.25);stroke-width:1px;}#my-svg .cluster text{fill:#F9FFFE;}#my-svg .cluster span{color:#F9FFFE;}#my-svg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(20, 1.5873015873%, 12.3529411765%);border:1px solid rgba(255, 255, 255, 0.25);border-radius:2px;pointer-events:none;z-index:100;}#my-svg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#ccc;}#my-svg rect.text{fill:none;stroke-width:0;}#my-svg .icon-shape,#my-svg .image-shape{background-color:hsl(0, 0%, 34.4117647059%);text-align:center;}#my-svg .icon-shape p,#my-svg .image-shape p{background-color:hsl(0, 0%, 34.4117647059%);padding:2px;}#my-svg .icon-shape .label rect,#my-svg .image-shape .label rect{opacity:0.5;background-color:hsl(0, 0%, 34.4117647059%);fill:hsl(0, 0%, 34.4117647059%);}#my-svg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#my-svg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#my-svg .node .neo-node{stroke:#ccc;}#my-svg [data-look="neo"].node rect,#my-svg [data-look="neo"].cluster rect,#my-svg [data-look="neo"].node polygon{stroke:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].node path{stroke:url(#my-svg-gradient);stroke-width:1px;}#my-svg [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].node .neo-line path{stroke:#ccc;filter:none;}#my-svg [data-look="neo"].node circle{stroke:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].node circle .state-start{fill:#000000;}#my-svg [data-look="neo"].icon-shape .icon{fill:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg [data-look="neo"].icon-shape .icon-neo path{stroke:url(#my-svg-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#my-svg .user>*{fill:transparent!important;stroke-dasharray:3 3!important;}#my-svg .user span{fill:transparent!important;stroke-dasharray:3 3!important;}</style><g><marker id="my-svg_flowchart-v2-pointEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-pointStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-pointEnd-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="11.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="10.5" markerHeight="14" orient="auto"><path d="M 0 0 L 11.5 7 L 0 14 z" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-pointStart-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="1" refY="7" markerUnits="userSpaceOnUse" markerWidth="11.5" markerHeight="14" orient="auto"><polygon points="0,7 11.5,14 11.5,0" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-circleEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-circleStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-circleEnd-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refY="5" refX="12.25" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-circleStart-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-2" refY="5" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-crossEnd" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="12" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-crossStart" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="-1" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-crossEnd-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="17.7" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5;"/></marker><marker id="my-svg_flowchart-v2-crossStart-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="-3.5" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5; stroke-dasharray: 1, 0;"/></marker><g class="root"><g class="clusters"><g class="cluster" id="my-svg-Yours" data-look="classic"><rect style="" x="1090.339340209961" y="8" width="485.28125" height="196"/><g class="cluster-label" transform="translate(1266.620590209961, 8)"><foreignObject width="132.71875" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5;"><span class="nodeLabel"><p>Jouw kant (lokaal)</p></span></div></foreignObject></g></g><g class="cluster" id="my-svg-Scrum" data-look="classic"><rect style="" x="245.87059020996094" y="17.755474090576172" width="600.390625" height="176.48905181884766"/><g class="cluster-label" transform="translate(447.81590270996094, 17.755474090576172)"><foreignObject width="196.5" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5;"><span class="nodeLabel"><p>Scrum4Me-stack (managed)</p></span></div></foreignObject></g></g></g><g class="edgePaths"><path d="M513.636,106L524.562,106C535.488,106,557.339,106,579.191,106C601.042,106,622.894,106,633.82,106L644.746,106" id="my-svg-L_Vercel_Neon_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_Vercel_Neon_0" data-points="W3sieCI6NTA5LjYzNjIxNTIwOTk2MDk0LCJ5IjoxMDZ9LHsieCI6NTc5LjE5MDkwMjcwOTk2MDksInkiOjEwNn0seyJ4Ijo2NDguNzQ1NTkwMjA5OTYwOSwieSI6MTA2fV0=" data-look="classic" marker-start="url(#my-svg_flowchart-v2-pointStart)" marker-end="url(#my-svg_flowchart-v2-pointEnd)"/><path d="M1356.605,106L1365.492,106C1374.378,106,1392.152,106,1409.259,106C1426.365,106,1442.805,106,1451.026,106L1459.246,106" id="my-svg-L_Worker_GitHub_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_Worker_GitHub_0" data-points="W3sieCI6MTM1Ni42MDQ5NjUyMDk5NjEsInkiOjEwNn0seyJ4IjoxNDA5LjkyNTI3NzcwOTk2MSwieSI6MTA2fSx7IngiOjE0NjMuMjQ1NTkwMjA5OTYxLCJ5IjoxMDZ9XQ==" data-look="classic" marker-end="url(#my-svg_flowchart-v2-pointEnd)"/><path d="M150.699,106.5L158.588,106.417C166.477,106.333,182.256,106.167,198.118,106.083C213.98,106,229.925,106,241.398,106C252.871,106,259.871,106,263.371,106L266.871,106" id="my-svg-L_User_Vercel_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_User_Vercel_0" data-points="W3sieCI6MTUwLjY5ODcxMjQzMTgyNjQsInkiOjEwNi40OTk5OTk5OTk5OTk5OX0seyJ4IjoxOTguMDM0NjUyNzA5OTYwOTQsInkiOjEwNn0seyJ4IjoyNDUuODcwNTkwMjA5OTYwOTQsInkiOjEwNn0seyJ4IjoyNzAuODcwNTkwMjA5OTYwOTQsInkiOjEwNn1d" data-look="classic" marker-end="url(#my-svg_flowchart-v2-pointEnd)"/><path d="M825.261,106L828.761,106C832.261,106,839.261,106,863.101,106C886.941,106,927.621,106,968.3,106C1008.98,106,1049.66,106,1073.499,106C1097.339,106,1104.339,106,1107.839,106L1111.339,106" id="my-svg-L_Neon_Worker_0" class="edge-thickness-normal edge-pattern-dotted edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_Neon_Worker_0" data-points="W3sieCI6ODIxLjI2MTIxNTIwOTk2MDksInkiOjEwNn0seyJ4Ijo4NDYuMjYxMjE1MjA5OTYwOSwieSI6MTA2fSx7IngiOjk2OC4zMDAyNzc3MDk5NjA5LCJ5IjoxMDZ9LHsieCI6MTA5MC4zMzkzNDAyMDk5NjEsInkiOjEwNn0seyJ4IjoxMTE1LjMzOTM0MDIwOTk2MSwieSI6MTA2fV0=" data-look="classic" marker-start="url(#my-svg_flowchart-v2-pointStart)" marker-end="url(#my-svg_flowchart-v2-pointEnd)"/></g><g class="edgeLabels"><g class="edgeLabel" transform="translate(579.1909027099609, 106)"><g class="label" data-id="L_Vercel_Neon_0" transform="translate(-44.5546875, -12)"><foreignObject width="89.109375" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>Prisma + SSE</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(1409.925277709961, 106)"><g class="label" data-id="L_Worker_GitHub_0" transform="translate(-28.3203125, -12)"><foreignObject width="56.640625" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>git push</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(198.03465270996094, 106)"><g class="label" data-id="L_User_Vercel_0" transform="translate(-22.8359375, -12)"><foreignObject width="45.671875" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>HTTPS</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(968.3002777099609, 106)"><g class="label" data-id="L_Neon_Worker_0" transform="translate(-97.0390625, -12)"><foreignObject width="194.078125" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>job claim + LISTEN/NOTIFY</p></span></div></foreignObject></g></g></g><g class="nodes"><g class="node default user" id="my-svg-flowchart-User-0" data-look="classic" transform="translate(79.09935760498047, 106)"><g class="basic label-container outer-path"><path d="M-51.609375 -19.5 C-27.55296791744393 -19.5, -3.496560834887859 -19.5, 51.609375 -19.5 M-51.609375 -19.5 C-19.07702028855575 -19.5, 13.455334422888498 -19.5, 51.609375 -19.5 M51.609375 -19.5 C51.609375 -19.5, 51.609375 -19.5, 51.609375 -19.5 M51.609375 -19.5 C51.609375 -19.5, 51.609375 -19.5, 51.609375 -19.5 M51.609375 -19.5 C51.93877697551412 -19.48943671978731, 52.26817895102824 -19.478873439574624, 52.8587442896239 -19.45993515863156 M51.609375 -19.5 C52.028338693479085 -19.486564649813484, 52.44730238695818 -19.47312929962697, 52.8587442896239 -19.45993515863156 M52.8587442896239 -19.45993515863156 C53.22206161731807 -19.424886372981916, 53.585378945012245 -19.389837587332273, 54.102979652847864 -19.3399052695533 M52.8587442896239 -19.45993515863156 C53.30050563856877 -19.4173189725946, 53.742266987513645 -19.37470278655764, 54.102979652847864 -19.3399052695533 M54.102979652847864 -19.3399052695533 C54.473484347919424 -19.280004943760954, 54.84398904299098 -19.22010461796861, 55.33696825967676 -19.140403561325776 M54.102979652847864 -19.3399052695533 C54.55067628990465 -19.267525149213235, 54.99837292696143 -19.195145028873167, 55.33696825967676 -19.140403561325776 M55.33696825967676 -19.140403561325776 C55.67098158082 -19.06416720041545, 56.00499490196324 -18.987930839505122, 56.55563938623539 -18.862249829261074 M55.33696825967676 -19.140403561325776 C55.626562168321804 -19.07430564145441, 55.91615607696684 -19.00820772158304, 56.55563938623539 -18.862249829261074 M56.55563938623539 -18.862249829261074 C56.887618784593265 -18.76372008642388, 57.21959818295114 -18.665190343586683, 57.753985251460605 -18.50658706670804 M56.55563938623539 -18.862249829261074 C56.85210984182696 -18.774258954246676, 57.148580297418526 -18.686268079232274, 57.753985251460605 -18.50658706670804 M57.753985251460605 -18.50658706670804 C58.12245736779786 -18.370985921777027, 58.4909294841351 -18.235384776846015, 58.9270815951478 -18.074876768247425 M57.753985251460605 -18.50658706670804 C58.031813403178646 -18.40434373866052, 58.30964155489669 -18.302100410612997, 58.9270815951478 -18.074876768247425 M58.9270815951478 -18.074876768247425 C59.36165223870195 -17.882505235426123, 59.7962228822561 -17.690133702604818, 60.07010791279238 -17.568892924097174 M58.9270815951478 -18.074876768247425 C59.21456067689242 -17.94761830652305, 59.50203975863704 -17.820359844798674, 60.07010791279238 -17.568892924097174 M60.07010791279238 -17.568892924097174 C60.37175898583885 -17.411521752505564, 60.673410058885324 -17.254150580913954, 61.17836726407678 -16.990714730406097 M60.07010791279238 -17.568892924097174 C60.33490084344724 -17.4307506217825, 60.59969377410209 -17.29260831946783, 61.17836726407678 -16.990714730406097 M61.17836726407678 -16.990714730406097 C61.58289554089195 -16.745487311728716, 61.98742381770711 -16.500259893051336, 62.2473055736057 -16.342718045390892 M61.17836726407678 -16.990714730406097 C61.565404649345915 -16.756090392971807, 61.952442034615046 -16.52146605553752, 62.2473055736057 -16.342718045390892 M62.2473055736057 -16.342718045390892 C62.62577579650349 -16.078713601609284, 63.00424601940128 -15.814709157827675, 63.27253034457871 -15.627565626425154 M62.2473055736057 -16.342718045390892 C62.54824766395989 -16.132793870732932, 62.84918975431408 -15.922869696074972, 63.27253034457871 -15.627565626425154 M63.27253034457871 -15.627565626425154 C63.503004939214755 -15.443768270389446, 63.733479533850804 -15.25997091435374, 64.24982870850187 -14.848196188198123 M63.27253034457871 -15.627565626425154 C63.48718609790213 -15.456383375379218, 63.70184185122556 -15.285201124333282, 64.24982870850187 -14.848196188198123 M64.24982870850187 -14.848196188198123 C64.52704670809658 -14.596434135752546, 64.80426470769129 -14.344672083306968, 65.17518473676799 -14.007812326905688 M64.24982870850187 -14.848196188198123 C64.45580453313872 -14.661134394084616, 64.66178035777557 -14.474072599971109, 65.17518473676799 -14.007812326905688 M65.17518473676799 -14.007812326905688 C65.51131082889803 -13.660734540316025, 65.84743692102808 -13.313656753726361, 66.04479594296865 -13.10986736009568 M65.17518473676799 -14.007812326905688 C65.37359283333757 -13.802939677589928, 65.57200092990713 -13.598067028274167, 66.04479594296865 -13.10986736009568 M66.04479594296865 -13.10986736009568 C66.21847914304612 -12.90584919043062, 66.3921623431236 -12.701831020765558, 66.85508890812658 -12.158051136245305 M66.04479594296865 -13.10986736009568 C66.2344363855499 -12.887104905392874, 66.42407682813113 -12.664342450690066, 66.85508890812658 -12.158051136245305 M66.85508890812658 -12.158051136245305 C67.07578016823058 -11.86234495309335, 67.29647142833457 -11.566638769941397, 67.60273396464063 -11.156274872382312 M66.85508890812658 -12.158051136245305 C67.12115869571365 -11.801541860334925, 67.38722848330073 -11.445032584424546, 67.60273396464063 -11.156274872382312 M67.60273396464063 -11.156274872382312 C67.79410669514435 -10.862274967555082, 67.98547942564807 -10.568275062727853, 68.28465887860425 -10.108655082055241 M67.60273396464063 -11.156274872382312 C67.83899266161488 -10.793318069486682, 68.07525135858913 -10.430361266591053, 68.28465887860425 -10.108655082055241 M68.28465887860425 -10.108655082055241 C68.42313320914845 -9.862779886127553, 68.56160753969264 -9.616904690199865, 68.8980614742735 -9.019496659696287 M68.28465887860425 -10.108655082055241 C68.46993089053461 -9.779685864047307, 68.65520290246499 -9.450716646039373, 68.8980614742735 -9.019496659696287 M68.8980614742735 -9.019496659696287 C69.02401544382727 -8.75795054693469, 69.14996941338102 -8.496404434173094, 69.44042114880834 -7.893275190886684 M68.8980614742735 -9.019496659696287 C69.07739882020628 -8.647098823662382, 69.25673616613905 -8.274700987628478, 69.44042114880834 -7.893275190886684 M69.44042114880834 -7.893275190886684 C69.54054110814987 -7.645976955625399, 69.64066106749138 -7.3986787203641144, 69.90950922997033 -6.734618561215508 M69.44042114880834 -7.893275190886684 C69.54036269716626 -7.646417634204234, 69.64030424552418 -7.399560077521784, 69.90950922997033 -6.734618561215508 M69.90950922997033 -6.734618561215508 C70.0284850955741 -6.376282210246815, 70.14746096117788 -6.0179458592781225, 70.30339813421489 -5.548287939305138 M69.90950922997033 -6.734618561215508 C70.06233901849484 -6.274319588299651, 70.21516880701937 -5.814020615383793, 70.30339813421489 -5.548287939305138 M70.30339813421489 -5.548287939305138 C70.39137133856018 -5.212807983308604, 70.47934454290547 -4.877328027312071, 70.62046928754556 -4.339158212148133 M70.30339813421489 -5.548287939305138 C70.39974540063956 -5.180874055258456, 70.49609266706423 -4.813460171211775, 70.62046928754556 -4.339158212148133 M70.62046928754556 -4.339158212148133 C70.67561202354256 -4.056011917224961, 70.73075475953954 -3.772865622301789, 70.85941977658177 -3.1121979531509023 M70.62046928754556 -4.339158212148133 C70.69230442656526 -3.970299955644, 70.76413956558495 -3.601441699139867, 70.85941977658177 -3.1121979531509023 M70.85941977658177 -3.1121979531509023 C70.9096456623522 -2.722655901338414, 70.95987154812263 -2.333113849525926, 71.01926770250937 -1.872449005199798 M70.85941977658177 -3.1121979531509023 C70.89498738449345 -2.8363426098065383, 70.93055499240513 -2.5604872664621743, 71.01926770250937 -1.872449005199798 M71.01926770250937 -1.872449005199798 C71.04924248001362 -1.4055676503661898, 71.07921725751787 -0.9386862955325816, 71.09935621591342 -0.6250057626472757 M71.01926770250937 -1.872449005199798 C71.04692144894133 -1.4417195828194733, 71.07457519537331 -1.0109901604391487, 71.09935621591342 -0.6250057626472757 M71.09935621591342 -0.6250057626472757 C71.09935621591342 -0.3066563769721308, 71.09935621591342 0.011693008703014152, 71.09935621591342 0.625005762647271 M71.09935621591342 -0.6250057626472757 C71.09935621591342 -0.1736856398704602, 71.09935621591342 0.2776344829063553, 71.09935621591342 0.625005762647271 M71.09935621591342 0.625005762647271 C71.08204683811961 0.8946132936642059, 71.06473746032579 1.1642208246811407, 71.01926770250937 1.8724490051997846 M71.09935621591342 0.625005762647271 C71.07683009923606 0.9758682126512874, 71.0543039825587 1.3267306626553037, 71.01926770250937 1.8724490051997846 M71.01926770250937 1.8724490051997846 C70.9723800937732 2.2361000395519386, 70.92549248503701 2.5997510739040925, 70.85941977658177 3.1121979531508885 M71.01926770250937 1.8724490051997846 C70.98114045497577 2.168156407993717, 70.94301320744218 2.4638638107876494, 70.85941977658177 3.1121979531508885 M70.85941977658177 3.1121979531508885 C70.80349830040704 3.3993429142999307, 70.74757682423233 3.686487875448973, 70.62046928754556 4.339158212148129 M70.85941977658177 3.1121979531508885 C70.80996382628464 3.3661438054078565, 70.76050787598751 3.6200896576648245, 70.62046928754556 4.339158212148129 M70.62046928754556 4.339158212148129 C70.50214494551572 4.790380210402116, 70.3838206034859 5.241602208656104, 70.30339813421489 5.548287939305125 M70.62046928754556 4.339158212148129 C70.52283213365435 4.711490996145504, 70.42519497976313 5.083823780142879, 70.30339813421489 5.548287939305125 M70.30339813421489 5.548287939305125 C70.18919760729497 5.892241731004944, 70.07499708037504 6.2361955227047625, 69.90950922997033 6.734618561215495 M70.30339813421489 5.548287939305125 C70.18535442667583 5.903816778702084, 70.06731071913678 6.259345618099042, 69.90950922997033 6.734618561215495 M69.90950922997033 6.734618561215495 C69.7964286459041 7.013929790123446, 69.68334806183786 7.293241019031397, 69.44042114880834 7.893275190886679 M69.90950922997033 6.734618561215495 C69.7855473084204 7.0408069040845245, 69.66158538687047 7.346995246953553, 69.44042114880834 7.893275190886679 M69.44042114880834 7.893275190886679 C69.28187895174727 8.222491455343567, 69.1233367546862 8.551707719800456, 68.8980614742735 9.019496659696284 M69.44042114880834 7.893275190886679 C69.22574100433772 8.339063104299154, 69.01106085986711 8.784851017711627, 68.8980614742735 9.019496659696284 M68.8980614742735 9.019496659696284 C68.75888018384568 9.26662713448481, 68.61969889341785 9.51375760927334, 68.28465887860425 10.108655082055236 M68.8980614742735 9.019496659696284 C68.66047208550674 9.441360677986095, 68.42288269673998 9.863224696275907, 68.28465887860425 10.108655082055236 M68.28465887860425 10.108655082055236 C68.03650555311683 10.489885228502093, 67.78835222762939 10.87111537494895, 67.60273396464065 11.156274872382301 M68.28465887860425 10.108655082055236 C68.0963026484402 10.39802083231698, 67.90794641827615 10.687386582578725, 67.60273396464065 11.156274872382301 M67.60273396464065 11.156274872382301 C67.33729261503488 11.511942098649602, 67.07185126542912 11.867609324916902, 66.85508890812659 12.158051136245302 M67.60273396464065 11.156274872382301 C67.34026079103421 11.507965013216893, 67.07778761742779 11.859655154051485, 66.85508890812659 12.158051136245302 M66.85508890812659 12.158051136245302 C66.56665090562775 12.496866826567718, 66.27821290312893 12.835682516890136, 66.04479594296866 13.10986736009567 M66.85508890812659 12.158051136245302 C66.65061836559786 12.398233870203015, 66.44614782306913 12.638416604160728, 66.04479594296866 13.10986736009567 M66.04479594296866 13.10986736009567 C65.81243259568832 13.349801593548227, 65.58006924840798 13.589735827000785, 65.17518473676799 14.007812326905684 M66.04479594296866 13.10986736009567 C65.85147159396622 13.30949062266492, 65.65814724496379 13.509113885234171, 65.17518473676799 14.007812326905684 M65.17518473676799 14.007812326905684 C64.84697381641011 14.305884800789624, 64.51876289605224 14.603957274673565, 64.2498287085019 14.848196188198111 M65.17518473676799 14.007812326905684 C64.98351279745837 14.181883710825064, 64.79184085814876 14.355955094744443, 64.2498287085019 14.848196188198111 M64.2498287085019 14.848196188198111 C63.939550796035284 15.095634566548211, 63.62927288356868 15.343072944898314, 63.27253034457871 15.627565626425152 M64.2498287085019 14.848196188198111 C64.03736428624228 15.01763091103443, 63.82489986398266 15.187065633870748, 63.27253034457871 15.627565626425152 M63.27253034457871 15.627565626425152 C63.009586433069074 15.810983916435802, 62.74664252155944 15.994402206446452, 62.24730557360571 16.34271804539089 M63.27253034457871 15.627565626425152 C63.052652784876784 15.78094269372707, 62.83277522517485 15.934319761028988, 62.24730557360571 16.34271804539089 M62.24730557360571 16.34271804539089 C61.876287420873325 16.56763142992194, 61.50526926814094 16.792544814452988, 61.17836726407678 16.990714730406093 M62.24730557360571 16.34271804539089 C61.89615779671405 16.555585891316003, 61.545010019822385 16.768453737241117, 61.17836726407678 16.990714730406093 M61.17836726407678 16.990714730406093 C60.79428937181566 17.191087920785094, 60.41021147955453 17.39146111116409, 60.07010791279239 17.56889292409717 M61.17836726407678 16.990714730406093 C60.84257846025527 17.165895534276785, 60.50678965643375 17.341076338147477, 60.07010791279239 17.56889292409717 M60.07010791279239 17.56889292409717 C59.767912571520114 17.70266583737211, 59.46571723024784 17.836438750647048, 58.927081595147804 18.07487676824742 M60.07010791279239 17.56889292409717 C59.624061820404165 17.766344297710223, 59.17801572801594 17.963795671323272, 58.927081595147804 18.07487676824742 M58.927081595147804 18.07487676824742 C58.53124508837982 18.220548261810595, 58.135408581611834 18.366219755373773, 57.75398525146062 18.506587066708033 M58.927081595147804 18.07487676824742 C58.67220551721424 18.168673520975613, 58.417329439280664 18.2624702737038, 57.75398525146062 18.506587066708033 M57.75398525146062 18.506587066708033 C57.377670016753946 18.618275453049996, 57.00135478204728 18.729963839391964, 56.55563938623541 18.86224982926107 M57.75398525146062 18.506587066708033 C57.274653515917905 18.64885020975349, 56.79532178037519 18.791113352798945, 56.55563938623541 18.86224982926107 M56.55563938623541 18.86224982926107 C56.085237602373475 18.969615966769044, 55.61483581851154 19.076982104277015, 55.336968259676766 19.140403561325773 M56.55563938623541 18.86224982926107 C56.307205050621725 18.918953345179347, 56.05877071500804 18.975656861097622, 55.336968259676766 19.140403561325773 M55.336968259676766 19.140403561325773 C54.895355725260664 19.211800051133814, 54.45374319084456 19.28319654094186, 54.10297965284788 19.3399052695533 M55.336968259676766 19.140403561325773 C54.888427588344804 19.212920138581868, 54.43988691701284 19.285436715837964, 54.10297965284788 19.3399052695533 M54.10297965284788 19.3399052695533 C53.764324783323325 19.372574897524263, 53.42566991379877 19.405244525495227, 52.8587442896239 19.45993515863156 M54.10297965284788 19.3399052695533 C53.81977658728411 19.367225528686717, 53.53657352172035 19.394545787820135, 52.8587442896239 19.45993515863156 M52.8587442896239 19.45993515863156 C52.42596621185823 19.473813509234077, 51.99318813409257 19.4876918598366, 51.60937500000001 19.5 M52.8587442896239 19.45993515863156 C52.51797599285935 19.470862934643545, 52.177207696094804 19.481790710655535, 51.60937500000001 19.5 M51.60937500000001 19.5 C51.60937500000001 19.5, 51.609375 19.5, 51.609375 19.5 M51.60937500000001 19.5 C51.60937500000001 19.5, 51.609375 19.5, 51.609375 19.5 M51.609375 19.5 C19.20043231870924 19.5, -13.20851036258152 19.5, -51.60937499999999 19.5 M51.609375 19.5 C29.424681229123397 19.5, 7.2399874582467945 19.5, -51.60937499999999 19.5 M-51.60937499999999 19.5 C-51.9222797548062 19.489965753545523, -52.23518450961241 19.479931507091045, -52.85874428962389 19.45993515863156 M-51.60937499999999 19.5 C-51.86730801685067 19.491728590185758, -52.12524103370135 19.48345718037152, -52.85874428962389 19.45993515863156 M-52.85874428962389 19.45993515863156 C-53.17980383568666 19.42896293035348, -53.500863381749426 19.397990702075404, -54.10297965284787 19.3399052695533 M-52.85874428962389 19.45993515863156 C-53.12024892845982 19.4347081206284, -53.38175356729575 19.409481082625238, -54.10297965284787 19.3399052695533 M-54.10297965284787 19.3399052695533 C-54.46455897399871 19.281447928999146, -54.82613829514955 19.222990588444993, -55.33696825967676 19.140403561325773 M-54.10297965284787 19.3399052695533 C-54.457200315086695 19.282637619904786, -54.81142097732552 19.225369970256278, -55.33696825967676 19.140403561325773 M-55.33696825967676 19.140403561325773 C-55.637742670200886 19.071753764860098, -55.93851708072501 19.00310396839442, -56.555639386235384 18.862249829261074 M-55.33696825967676 19.140403561325773 C-55.78080862104569 19.03909989517235, -56.22464898241463 18.93779622901892, -56.555639386235384 18.862249829261074 M-56.555639386235384 18.862249829261074 C-56.93493031937328 18.749678271244484, -57.31422125251118 18.63710671322789, -57.75398525146059 18.506587066708043 M-56.555639386235384 18.862249829261074 C-57.003213754639916 18.729412106084414, -57.45078812304445 18.59657438290775, -57.75398525146059 18.506587066708043 M-57.75398525146059 18.506587066708043 C-58.18413804765719 18.348286861418835, -58.61429084385379 18.18998665612963, -58.9270815951478 18.074876768247425 M-57.75398525146059 18.506587066708043 C-58.17949465051825 18.349995674489058, -58.6050040495759 18.193404282270073, -58.9270815951478 18.074876768247425 M-58.9270815951478 18.074876768247425 C-59.272294772264786 17.92206113512279, -59.617507949381775 17.769245501998157, -60.07010791279238 17.568892924097174 M-58.9270815951478 18.074876768247425 C-59.34545527795783 17.88967514948177, -59.76382896076787 17.70447353071612, -60.07010791279238 17.568892924097174 M-60.07010791279238 17.568892924097174 C-60.3262495214214 17.435264010941946, -60.58239113005042 17.30163509778672, -61.17836726407678 16.990714730406097 M-60.07010791279238 17.568892924097174 C-60.302539360223065 17.44763358685203, -60.53497080765375 17.326374249606893, -61.17836726407678 16.990714730406097 M-61.17836726407678 16.990714730406097 C-61.49152092748561 16.800879139352467, -61.80467459089444 16.61104354829884, -62.247305573605686 16.3427180453909 M-61.17836726407678 16.990714730406097 C-61.489540989543805 16.8020793893682, -61.80071471501083 16.6134440483303, -62.247305573605686 16.3427180453909 M-62.247305573605686 16.3427180453909 C-62.502725073239546 16.164548459378253, -62.758144572873405 15.986378873365606, -63.27253034457871 15.627565626425156 M-62.247305573605686 16.3427180453909 C-62.65306945709256 16.05967472550587, -63.05883334057943 15.776631405620845, -63.27253034457871 15.627565626425156 M-63.27253034457871 15.627565626425156 C-63.54548122919286 15.40989455947346, -63.818432113807006 15.192223492521764, -64.24982870850187 14.848196188198125 M-63.27253034457871 15.627565626425156 C-63.470212212968434 15.4699195969198, -63.66789408135815 15.312273567414444, -64.24982870850187 14.848196188198125 M-64.24982870850187 14.848196188198125 C-64.57766698507004 14.550462139544377, -64.90550526163823 14.25272809089063, -65.17518473676797 14.007812326905697 M-64.24982870850187 14.848196188198125 C-64.60346571426959 14.527032416282315, -64.95710272003733 14.205868644366507, -65.17518473676797 14.007812326905697 M-65.17518473676797 14.007812326905697 C-65.4899941458059 13.682745765740886, -65.80480355484382 13.357679204576076, -66.04479594296865 13.109867360095677 M-65.17518473676797 14.007812326905697 C-65.48952483648246 13.683230366148347, -65.80386493619693 13.358648405390998, -66.04479594296865 13.109867360095677 M-66.04479594296865 13.109867360095677 C-66.29640597204214 12.81431190292972, -66.54801600111563 12.518756445763765, -66.85508890812658 12.158051136245307 M-66.04479594296865 13.109867360095677 C-66.3623103698948 12.736896847145513, -66.67982479682095 12.363926334195346, -66.85508890812658 12.158051136245307 M-66.85508890812658 12.158051136245307 C-67.02472783383445 11.93075043022081, -67.19436675954233 11.703449724196314, -67.60273396464063 11.156274872382316 M-66.85508890812658 12.158051136245307 C-67.08479362810705 11.850267738001955, -67.31449834808754 11.542484339758605, -67.60273396464063 11.156274872382316 M-67.60273396464063 11.156274872382316 C-67.84224228604634 10.788325773713634, -68.08175060745204 10.420376675044952, -68.28465887860425 10.108655082055249 M-67.60273396464063 11.156274872382316 C-67.75993502718244 10.914771825922253, -67.91713608972422 10.67326877946219, -68.28465887860425 10.108655082055249 M-68.28465887860425 10.108655082055249 C-68.42157930280071 9.865539004198059, -68.55849972699716 9.62242292634087, -68.8980614742735 9.019496659696289 M-68.28465887860425 10.108655082055249 C-68.52779826657803 9.676936478175003, -68.77093765455182 9.24521787429476, -68.8980614742735 9.019496659696289 M-68.8980614742735 9.019496659696289 C-69.02407165098808 8.757833831562603, -69.15008182770266 8.49617100342892, -69.44042114880834 7.893275190886686 M-68.8980614742735 9.019496659696289 C-69.07742221797855 8.647050237687624, -69.2567829616836 8.27460381567896, -69.44042114880834 7.893275190886686 M-69.44042114880834 7.893275190886686 C-69.6085489263913 7.477996329727344, -69.77667670397425 7.062717468568002, -69.90950922997033 6.73461856121551 M-69.44042114880834 7.893275190886686 C-69.62142387390844 7.446194960439829, -69.80242659900853 6.9991147299929715, -69.90950922997033 6.73461856121551 M-69.90950922997033 6.73461856121551 C-70.0518186036901 6.306005401491113, -70.19412797740985 5.877392241766716, -70.30339813421489 5.5482879393051325 M-69.90950922997033 6.73461856121551 C-70.03769345224686 6.348548107100887, -70.16587767452337 5.962477652986264, -70.30339813421489 5.5482879393051325 M-70.30339813421489 5.5482879393051325 C-70.3682756974006 5.300881672764221, -70.43315326058634 5.053475406223309, -70.62046928754556 4.339158212148136 M-70.30339813421489 5.5482879393051325 C-70.40880656255084 5.146319917758311, -70.5142149908868 4.744351896211491, -70.62046928754556 4.339158212148136 M-70.62046928754556 4.339158212148136 C-70.71214497342362 3.8684229408273847, -70.80382065930168 3.397687669506633, -70.85941977658177 3.112197953150904 M-70.62046928754556 4.339158212148136 C-70.70982227911668 3.880349485191896, -70.7991752706878 3.421540758235656, -70.85941977658177 3.112197953150904 M-70.85941977658177 3.112197953150904 C-70.90494489562901 2.7591141197721205, -70.95047001467626 2.406030286393337, -71.01926770250937 1.872449005199809 M-70.85941977658177 3.112197953150904 C-70.90547747891564 2.7549835089714763, -70.95153518124951 2.3977690647920484, -71.01926770250937 1.872449005199809 M-71.01926770250937 1.872449005199809 C-71.04721851555159 1.437092530576971, -71.07516932859382 1.0017360559541333, -71.09935621591342 0.6250057626472781 M-71.01926770250937 1.872449005199809 C-71.0503101907891 1.3889371931974273, -71.08135267906883 0.9054253811950453, -71.09935621591342 0.6250057626472781 M-71.09935621591342 0.6250057626472781 C-71.09935621591342 0.30714157474147064, -71.09935621591342 -0.01072261316433687, -71.09935621591342 -0.6250057626472687 M-71.09935621591342 0.6250057626472781 C-71.09935621591342 0.1750745881397166, -71.09935621591342 -0.27485658636784494, -71.09935621591342 -0.6250057626472687 M-71.09935621591342 -0.6250057626472687 C-71.07953862018819 -0.933680812543108, -71.05972102446296 -1.2423558624389472, -71.01926770250937 -1.8724490051997822 M-71.09935621591342 -0.6250057626472687 C-71.07332063253108 -1.0305309893718553, -71.04728504914874 -1.4360562160964416, -71.01926770250937 -1.8724490051997822 M-71.01926770250937 -1.8724490051997822 C-70.9694263139499 -2.2590089725716638, -70.91958492539041 -2.645568939943545, -70.85941977658177 -3.112197953150895 M-71.01926770250937 -1.8724490051997822 C-70.967043053215 -2.2774930722035043, -70.9148184039206 -2.682537139207226, -70.85941977658177 -3.112197953150895 M-70.85941977658177 -3.112197953150895 C-70.79599679335527 -3.437861567566722, -70.73257381012877 -3.7635251819825486, -70.62046928754556 -4.339158212148126 M-70.85941977658177 -3.112197953150895 C-70.80140557775321 -3.4100886028302577, -70.74339137892467 -3.7079792525096202, -70.62046928754556 -4.339158212148126 M-70.62046928754556 -4.339158212148126 C-70.50807754166397 -4.767756650085433, -70.39568579578238 -5.19635508802274, -70.30339813421489 -5.548287939305123 M-70.62046928754556 -4.339158212148126 C-70.53054097093323 -4.682093858569894, -70.4406126543209 -5.025029504991663, -70.30339813421489 -5.548287939305123 M-70.30339813421489 -5.548287939305123 C-70.17046700544817 -5.948655319451366, -70.03753587668145 -6.34902269959761, -69.90950922997033 -6.734618561215485 M-70.30339813421489 -5.548287939305123 C-70.17284762029631 -5.941485286828229, -70.04229710637773 -6.334682634351336, -69.90950922997033 -6.734618561215485 M-69.90950922997033 -6.734618561215485 C-69.78568529630489 -7.040466071342012, -69.66186136263946 -7.3463135814685385, -69.44042114880834 -7.893275190886676 M-69.90950922997033 -6.734618561215485 C-69.79858850139489 -7.008594905303221, -69.68766777281945 -7.282571249390958, -69.44042114880834 -7.893275190886676 M-69.44042114880834 -7.893275190886676 C-69.31367479158193 -8.156466713604908, -69.18692843435554 -8.41965823632314, -68.8980614742735 -9.019496659696282 M-69.44042114880834 -7.893275190886676 C-69.31018276307714 -8.163717985512566, -69.17994437734593 -8.434160780138457, -68.8980614742735 -9.019496659696282 M-68.8980614742735 -9.019496659696282 C-68.66828950615152 -9.427480056031603, -68.43851753802956 -9.835463452366925, -68.28465887860425 -10.108655082055243 M-68.8980614742735 -9.019496659696282 C-68.72132597795206 -9.333308429824633, -68.5445904816306 -9.647120199952985, -68.28465887860425 -10.108655082055243 M-68.28465887860425 -10.108655082055243 C-68.14387396716683 -10.324938512065186, -68.0030890557294 -10.54122194207513, -67.60273396464063 -11.156274872382308 M-68.28465887860425 -10.108655082055243 C-68.13677148231372 -10.335849836083781, -67.9888840860232 -10.56304459011232, -67.60273396464063 -11.156274872382308 M-67.60273396464063 -11.156274872382308 C-67.32875355953553 -11.523383655094765, -67.05477315443044 -11.89049243780722, -66.85508890812659 -12.158051136245302 M-67.60273396464063 -11.156274872382308 C-67.4386830414056 -11.376088164170708, -67.27463211817054 -11.595901455959108, -66.85508890812659 -12.158051136245302 M-66.85508890812659 -12.158051136245302 C-66.55260164223127 -12.513369890778964, -66.25011437633596 -12.868688645312625, -66.04479594296866 -13.10986736009567 M-66.85508890812659 -12.158051136245302 C-66.54808595751868 -12.51867427099212, -66.24108300691076 -12.879297405738939, -66.04479594296866 -13.10986736009567 M-66.04479594296866 -13.10986736009567 C-65.76692276822143 -13.396794226834107, -65.4890495934742 -13.683721093572546, -65.17518473676799 -14.007812326905677 M-66.04479594296866 -13.10986736009567 C-65.77968437063414 -13.383616824595942, -65.51457279829962 -13.657366289096213, -65.17518473676799 -14.007812326905677 M-65.17518473676799 -14.007812326905677 C-64.97607802866713 -14.188635771128062, -64.77697132056628 -14.369459215350446, -64.2498287085019 -14.848196188198107 M-65.17518473676799 -14.007812326905677 C-64.84026060521221 -14.311981561577719, -64.50533647365644 -14.616150796249759, -64.2498287085019 -14.848196188198107 M-64.2498287085019 -14.848196188198107 C-63.89082636617065 -15.134491002753728, -63.5318240238394 -15.420785817309348, -63.27253034457872 -15.627565626425149 M-64.2498287085019 -14.848196188198107 C-63.983085754928354 -15.060916595344898, -63.71634280135482 -15.27363700249169, -63.27253034457872 -15.627565626425149 M-63.27253034457872 -15.627565626425149 C-62.93203473906948 -15.865080621011199, -62.59153913356024 -16.10259561559725, -62.247305573605715 -16.342718045390885 M-63.27253034457872 -15.627565626425149 C-62.982210073438715 -15.830080479810437, -62.69188980229871 -16.032595333195726, -62.247305573605715 -16.342718045390885 M-62.247305573605715 -16.342718045390885 C-62.01276272155087 -16.484899300633323, -61.77821986949603 -16.627080555875757, -61.17836726407679 -16.99071473040609 M-62.247305573605715 -16.342718045390885 C-61.85049665873665 -16.583265941492535, -61.45368774386758 -16.82381383759418, -61.17836726407679 -16.99071473040609 M-61.17836726407679 -16.99071473040609 C-60.82496125516763 -17.175086418886647, -60.47155524625847 -17.359458107367207, -60.07010791279239 -17.56889292409717 M-61.17836726407679 -16.99071473040609 C-60.8988978664747 -17.136513736126407, -60.61942846887261 -17.282312741846727, -60.07010791279239 -17.56889292409717 M-60.07010791279239 -17.56889292409717 C-59.70664050912156 -17.729789161824797, -59.34317310545074 -17.89068539955242, -58.927081595147804 -18.07487676824742 M-60.07010791279239 -17.56889292409717 C-59.6824187072182 -17.74051143501319, -59.29472950164401 -17.912129945929212, -58.927081595147804 -18.07487676824742 M-58.927081595147804 -18.07487676824742 C-58.59316978274881 -18.19775940185721, -58.25925797034981 -18.320642035467, -57.75398525146062 -18.506587066708033 M-58.927081595147804 -18.07487676824742 C-58.60790933253394 -18.192335111285374, -58.28873706992008 -18.30979345432333, -57.75398525146062 -18.506587066708033 M-57.75398525146062 -18.506587066708033 C-57.33687847534954 -18.630382168419654, -56.91977169923847 -18.754177270131276, -56.55563938623541 -18.862249829261067 M-57.75398525146062 -18.506587066708033 C-57.30270482464137 -18.64052472856869, -56.851424397822115 -18.774462390429342, -56.55563938623541 -18.862249829261067 M-56.55563938623541 -18.862249829261067 C-56.1900996624667 -18.945681885841655, -55.82455993869798 -19.029113942422242, -55.336968259676766 -19.140403561325773 M-56.55563938623541 -18.862249829261067 C-56.30811226679794 -18.918746279007262, -56.06058514736046 -18.975242728753457, -55.336968259676766 -19.140403561325773 M-55.336968259676766 -19.140403561325773 C-54.91627299754673 -19.208418308766472, -54.49557773541669 -19.276433056207175, -54.10297965284788 -19.3399052695533 M-55.336968259676766 -19.140403561325773 C-54.8708779507802 -19.21575742788384, -54.40478764188363 -19.291111294441908, -54.10297965284788 -19.3399052695533 M-54.10297965284788 -19.3399052695533 C-53.84964537527221 -19.36434412263198, -53.59631109769654 -19.388782975710658, -52.8587442896239 -19.45993515863156 M-54.10297965284788 -19.3399052695533 C-53.65250908640187 -19.383361623328227, -53.20203851995586 -19.426817977103152, -52.8587442896239 -19.45993515863156 M-52.8587442896239 -19.45993515863156 C-52.41945417235347 -19.47402233766608, -51.98016405508304 -19.4881095167006, -51.60937500000001 -19.5 M-52.8587442896239 -19.45993515863156 C-52.52489590550626 -19.470641026513754, -52.19104752138863 -19.481346894395948, -51.60937500000001 -19.5 M-51.60937500000001 -19.5 C-51.60937500000001 -19.5, -51.609375 -19.5, -51.609375 -19.5 M-51.60937500000001 -19.5 C-51.60937500000001 -19.5, -51.609375 -19.5, -51.609375 -19.5" stroke="#ccc" stroke-width="1.3" fill="none" stroke-dasharray="3 3" style="fill:transparent !important;stroke-dasharray:3 3 !important"/></g><g class="label" style="" transform="translate(-58.734375, -12)"><rect/><foreignObject width="117.46875" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Jij in je browser</p></span></div></foreignObject></g></g><g class="node default" id="my-svg-flowchart-Vercel-1" data-look="classic" transform="translate(390.25340270996094, 106)"><rect class="basic label-container" style="" x="-119.3828125" y="-39" width="238.765625" height="78"/><g class="label" style="" transform="translate(-89.3828125, -24)"><rect/><foreignObject width="178.765625" height="48"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Vercel<br />UI · Server Actions · cron</p></span></div></foreignObject></g></g><g class="node default" id="my-svg-flowchart-Neon-2" data-look="classic" transform="translate(735.0034027099609, 106)"><path d="M0,14.496349981618613 a86.2578125,14.496349981618613 0,0,0 172.515625,0 a86.2578125,14.496349981618613 0,0,0 -172.515625,0 l0,77.49634998161861 a86.2578125,14.496349981618613 0,0,0 172.515625,0 l0,-77.49634998161861" class="basic label-container outer-path" style="" transform="translate(-86.2578125, -53.24452497242792)"/><g class="label" style="" transform="translate(-78.7578125, -14)"><rect/><foreignObject width="157.515625" height="48"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Neon Postgres<br />metadata · jobs · logs</p></span></div></foreignObject></g></g><g class="node default" id="my-svg-flowchart-Worker-5" data-look="classic" transform="translate(1235.972152709961, 106)"><rect class="basic label-container" style="" x="-120.6328125" y="-63" width="241.265625" height="126"/><g class="label" style="" transform="translate(-90.6328125, -48)"><rect/><foreignObject width="181.265625" height="96"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Lokale worker<br />laptop / NAS / VM<br />Claude Code + MCP<br />jobs: GRILL · PLAN · IMPL</p></span></div></foreignObject></g></g><g class="node default" id="my-svg-flowchart-GitHub-6" data-look="classic" transform="translate(1506.933090209961, 106)"><path d="M0,10.285462036492053 a43.6875,10.285462036492053 0,0,0 87.375,0 a43.6875,10.285462036492053 0,0,0 -87.375,0 l0,73.28546203649205 a43.6875,10.285462036492053 0,0,0 87.375,0 l0,-73.28546203649205" class="basic label-container outer-path" style="" transform="translate(-43.6875, -46.928193054738074)"/><g class="label" style="" transform="translate(-36.1875, -14)"><rect/><foreignObject width="72.375" height="48"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>GitHub<br />jouw repo</p></span></div></foreignObject></g></g></g></g></g><defs><filter id="my-svg-drop-shadow" height="130%" width="130%"><feDropShadow dx="4" dy="4" stdDeviation="0" flood-opacity="0.06" flood-color="#FFFFFF"/></filter></defs><defs><filter id="my-svg-drop-shadow-small" height="150%" width="150%"><feDropShadow dx="2" dy="2" stdDeviation="0" flood-opacity="0.06" flood-color="#FFFFFF"/></filter></defs><linearGradient id="my-svg-gradient" gradientUnits="objectBoundingBox" x1="0%" y1="0%" x2="100%" y2="0%"><stop offset="0%" stop-color="#cccccc" stop-opacity="1"/><stop offset="100%" stop-color="hsl(180, 0%, 18.3529411765%)" stop-opacity="1"/></linearGradient><text text-anchor="middle" x="791.810302734375" y="-25" class="flowchartTitleText">Scrum4Me — architectuur (lokaal & veilig)</text></svg> \ No newline at end of file diff --git a/public/diagrams/architecture-light.svg b/public/diagrams/architecture-light.svg deleted file mode 100644 index 4675a40..0000000 --- a/public/diagrams/architecture-light.svg +++ /dev/null @@ -1 +0,0 @@ -<svg id="my-svg" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="flowchart" style="max-width: 1583.62px; background-color: transparent;" viewBox="0 -50 1583.62060546875 262" role="graphics-document document" aria-roledescription="flowchart-v2"><style>#my-svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#my-svg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#my-svg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#my-svg .error-icon{fill:#552222;}#my-svg .error-text{fill:#552222;stroke:#552222;}#my-svg .edge-thickness-normal{stroke-width:1px;}#my-svg .edge-thickness-thick{stroke-width:3.5px;}#my-svg .edge-pattern-solid{stroke-dasharray:0;}#my-svg .edge-thickness-invisible{stroke-width:0;fill:none;}#my-svg .edge-pattern-dashed{stroke-dasharray:3;}#my-svg .edge-pattern-dotted{stroke-dasharray:2;}#my-svg .marker{fill:#333333;stroke:#333333;}#my-svg .marker.cross{stroke:#333333;}#my-svg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#my-svg p{margin:0;}#my-svg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#my-svg .cluster-label text{fill:#333;}#my-svg .cluster-label span{color:#333;}#my-svg .cluster-label span p{background-color:transparent;}#my-svg .label text,#my-svg span{fill:#333;color:#333;}#my-svg .node rect,#my-svg .node circle,#my-svg .node ellipse,#my-svg .node polygon,#my-svg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#my-svg .rough-node .label text,#my-svg .node .label text,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-anchor:middle;}#my-svg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#my-svg .rough-node .label,#my-svg .node .label,#my-svg .image-shape .label,#my-svg .icon-shape .label{text-align:center;}#my-svg .node.clickable{cursor:pointer;}#my-svg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#my-svg .arrowheadPath{fill:#333333;}#my-svg .edgePath .path{stroke:#333333;stroke-width:1px;}#my-svg .flowchart-link{stroke:#333333;fill:none;}#my-svg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#my-svg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#my-svg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#my-svg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#my-svg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#my-svg .cluster text{fill:#333;}#my-svg .cluster span{color:#333;}#my-svg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#my-svg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#my-svg rect.text{fill:none;stroke-width:0;}#my-svg .icon-shape,#my-svg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#my-svg .icon-shape p,#my-svg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#my-svg .icon-shape .label rect,#my-svg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#my-svg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#my-svg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#my-svg .node .neo-node{stroke:#9370DB;}#my-svg [data-look="neo"].node rect,#my-svg [data-look="neo"].cluster rect,#my-svg [data-look="neo"].node polygon{stroke:#9370DB;filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#my-svg [data-look="neo"].node path{stroke:#9370DB;stroke-width:1px;}#my-svg [data-look="neo"].node .outer-path{filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#my-svg [data-look="neo"].node .neo-line path{stroke:#9370DB;filter:none;}#my-svg [data-look="neo"].node circle{stroke:#9370DB;filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#my-svg [data-look="neo"].node circle .state-start{fill:#000000;}#my-svg [data-look="neo"].icon-shape .icon{fill:#9370DB;filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#my-svg [data-look="neo"].icon-shape .icon-neo path{stroke:#9370DB;filter:drop-shadow(1px 2px 2px rgba(185, 185, 185, 1));}#my-svg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#my-svg .user>*{fill:transparent!important;stroke-dasharray:3 3!important;}#my-svg .user span{fill:transparent!important;stroke-dasharray:3 3!important;}</style><g><marker id="my-svg_flowchart-v2-pointEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 0 L 10 5 L 0 10 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-pointStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="4.5" refY="5" markerUnits="userSpaceOnUse" markerWidth="8" markerHeight="8" orient="auto"><path d="M 0 5 L 10 10 L 10 0 z" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-pointEnd-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="11.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="10.5" markerHeight="14" orient="auto"><path d="M 0 0 L 11.5 7 L 0 14 z" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-pointStart-margin" class="marker flowchart-v2" viewBox="0 0 11.5 14" refX="1" refY="7" markerUnits="userSpaceOnUse" markerWidth="11.5" markerHeight="14" orient="auto"><polygon points="0,7 11.5,14 11.5,0" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-circleEnd" class="marker flowchart-v2" viewBox="0 0 10 10" refX="11" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-circleStart" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-1" refY="5" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 1; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-circleEnd-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refY="5" refX="12.25" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-circleStart-margin" class="marker flowchart-v2" viewBox="0 0 10 10" refX="-2" refY="5" markerUnits="userSpaceOnUse" markerWidth="14" markerHeight="14" orient="auto"><circle cx="5" cy="5" r="5" class="arrowMarkerPath" style="stroke-width: 0; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-crossEnd" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="12" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-crossStart" class="marker cross flowchart-v2" viewBox="0 0 11 11" refX="-1" refY="5.2" markerUnits="userSpaceOnUse" markerWidth="11" markerHeight="11" orient="auto"><path d="M 1,1 l 9,9 M 10,1 l -9,9" class="arrowMarkerPath" style="stroke-width: 2; stroke-dasharray: 1, 0;"/></marker><marker id="my-svg_flowchart-v2-crossEnd-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="17.7" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5;"/></marker><marker id="my-svg_flowchart-v2-crossStart-margin" class="marker cross flowchart-v2" viewBox="0 0 15 15" refX="-3.5" refY="7.5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto"><path d="M 1,1 L 14,14 M 1,14 L 14,1" class="arrowMarkerPath" style="stroke-width: 2.5; stroke-dasharray: 1, 0;"/></marker><g class="root"><g class="clusters"><g class="cluster" id="my-svg-Yours" data-look="classic"><rect style="" x="1090.339340209961" y="8" width="485.28125" height="196"/><g class="cluster-label" transform="translate(1266.620590209961, 8)"><foreignObject width="132.71875" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5;"><span class="nodeLabel"><p>Jouw kant (lokaal)</p></span></div></foreignObject></g></g><g class="cluster" id="my-svg-Scrum" data-look="classic"><rect style="" x="245.87059020996094" y="17.755474090576172" width="600.390625" height="176.48905181884766"/><g class="cluster-label" transform="translate(447.81590270996094, 17.755474090576172)"><foreignObject width="196.5" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5;"><span class="nodeLabel"><p>Scrum4Me-stack (managed)</p></span></div></foreignObject></g></g></g><g class="edgePaths"><path d="M513.636,106L524.562,106C535.488,106,557.339,106,579.191,106C601.042,106,622.894,106,633.82,106L644.746,106" id="my-svg-L_Vercel_Neon_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_Vercel_Neon_0" data-points="W3sieCI6NTA5LjYzNjIxNTIwOTk2MDk0LCJ5IjoxMDZ9LHsieCI6NTc5LjE5MDkwMjcwOTk2MDksInkiOjEwNn0seyJ4Ijo2NDguNzQ1NTkwMjA5OTYwOSwieSI6MTA2fV0=" data-look="classic" marker-start="url(#my-svg_flowchart-v2-pointStart)" marker-end="url(#my-svg_flowchart-v2-pointEnd)"/><path d="M1356.605,106L1365.492,106C1374.378,106,1392.152,106,1409.259,106C1426.365,106,1442.805,106,1451.026,106L1459.246,106" id="my-svg-L_Worker_GitHub_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_Worker_GitHub_0" data-points="W3sieCI6MTM1Ni42MDQ5NjUyMDk5NjEsInkiOjEwNn0seyJ4IjoxNDA5LjkyNTI3NzcwOTk2MSwieSI6MTA2fSx7IngiOjE0NjMuMjQ1NTkwMjA5OTYxLCJ5IjoxMDZ9XQ==" data-look="classic" marker-end="url(#my-svg_flowchart-v2-pointEnd)"/><path d="M150.699,106.5L158.588,106.417C166.477,106.333,182.256,106.167,198.118,106.083C213.98,106,229.925,106,241.398,106C252.871,106,259.871,106,263.371,106L266.871,106" id="my-svg-L_User_Vercel_0" class="edge-thickness-normal edge-pattern-solid edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_User_Vercel_0" data-points="W3sieCI6MTUwLjY5ODcxMjQzMTgyNjQsInkiOjEwNi40OTk5OTk5OTk5OTk5OX0seyJ4IjoxOTguMDM0NjUyNzA5OTYwOTQsInkiOjEwNn0seyJ4IjoyNDUuODcwNTkwMjA5OTYwOTQsInkiOjEwNn0seyJ4IjoyNzAuODcwNTkwMjA5OTYwOTQsInkiOjEwNn1d" data-look="classic" marker-end="url(#my-svg_flowchart-v2-pointEnd)"/><path d="M825.261,106L828.761,106C832.261,106,839.261,106,863.101,106C886.941,106,927.621,106,968.3,106C1008.98,106,1049.66,106,1073.499,106C1097.339,106,1104.339,106,1107.839,106L1111.339,106" id="my-svg-L_Neon_Worker_0" class="edge-thickness-normal edge-pattern-dotted edge-thickness-normal edge-pattern-solid flowchart-link" style=";" data-edge="true" data-et="edge" data-id="L_Neon_Worker_0" data-points="W3sieCI6ODIxLjI2MTIxNTIwOTk2MDksInkiOjEwNn0seyJ4Ijo4NDYuMjYxMjE1MjA5OTYwOSwieSI6MTA2fSx7IngiOjk2OC4zMDAyNzc3MDk5NjA5LCJ5IjoxMDZ9LHsieCI6MTA5MC4zMzkzNDAyMDk5NjEsInkiOjEwNn0seyJ4IjoxMTE1LjMzOTM0MDIwOTk2MSwieSI6MTA2fV0=" data-look="classic" marker-start="url(#my-svg_flowchart-v2-pointStart)" marker-end="url(#my-svg_flowchart-v2-pointEnd)"/></g><g class="edgeLabels"><g class="edgeLabel" transform="translate(579.1909027099609, 106)"><g class="label" data-id="L_Vercel_Neon_0" transform="translate(-44.5546875, -12)"><foreignObject width="89.109375" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>Prisma + SSE</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(1409.925277709961, 106)"><g class="label" data-id="L_Worker_GitHub_0" transform="translate(-28.3203125, -12)"><foreignObject width="56.640625" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>git push</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(198.03465270996094, 106)"><g class="label" data-id="L_User_Vercel_0" transform="translate(-22.8359375, -12)"><foreignObject width="45.671875" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>HTTPS</p></span></div></foreignObject></g></g><g class="edgeLabel" transform="translate(968.3002777099609, 106)"><g class="label" data-id="L_Neon_Worker_0" transform="translate(-97.0390625, -12)"><foreignObject width="194.078125" height="24"><div xmlns="http://www.w3.org/1999/xhtml" class="labelBkg" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="edgeLabel"><p>job claim + LISTEN/NOTIFY</p></span></div></foreignObject></g></g></g><g class="nodes"><g class="node default user" id="my-svg-flowchart-User-0" data-look="classic" transform="translate(79.09935760498047, 106)"><g class="basic label-container outer-path"><path d="M-51.609375 -19.5 C-29.187399576183175 -19.5, -6.76542415236635 -19.5, 51.609375 -19.5 M-51.609375 -19.5 C-21.968202953492433 -19.5, 7.6729690930151335 -19.5, 51.609375 -19.5 M51.609375 -19.5 C51.609375 -19.5, 51.609375 -19.5, 51.609375 -19.5 M51.609375 -19.5 C51.609375 -19.5, 51.609375 -19.5, 51.609375 -19.5 M51.609375 -19.5 C51.990817456617016 -19.487767882848992, 52.37225991323404 -19.475535765697988, 52.8587442896239 -19.45993515863156 M51.609375 -19.5 C51.89788978725707 -19.49074789233263, 52.18640457451414 -19.481495784665263, 52.8587442896239 -19.45993515863156 M52.8587442896239 -19.45993515863156 C53.210742770393885 -19.425978288531788, 53.56274125116388 -19.392021418432016, 54.102979652847864 -19.3399052695533 M52.8587442896239 -19.45993515863156 C53.34645648719489 -19.41288614949162, 53.834168684765885 -19.36583714035168, 54.102979652847864 -19.3399052695533 M54.102979652847864 -19.3399052695533 C54.44316225875894 -19.28490718398511, 54.783344864670006 -19.229909098416915, 55.33696825967676 -19.140403561325776 M54.102979652847864 -19.3399052695533 C54.4723890899411 -19.280182016577037, 54.84179852703434 -19.22045876360077, 55.33696825967676 -19.140403561325776 M55.33696825967676 -19.140403561325776 C55.665579630234134 -19.06540016038565, 55.99419100079151 -18.990396759445527, 56.55563938623539 -18.862249829261074 M55.33696825967676 -19.140403561325776 C55.790935087388455 -19.036788595310984, 56.24490191510015 -18.933173629296192, 56.55563938623539 -18.862249829261074 M56.55563938623539 -18.862249829261074 C57.002499331927275 -18.729624142995444, 57.44935927761916 -18.596998456729814, 57.753985251460605 -18.50658706670804 M56.55563938623539 -18.862249829261074 C57.01732008282399 -18.725225421925007, 57.47900077941259 -18.588201014588943, 57.753985251460605 -18.50658706670804 M57.753985251460605 -18.50658706670804 C58.13993496121061 -18.364554005840027, 58.52588467096062 -18.22252094497202, 58.9270815951478 -18.074876768247425 M57.753985251460605 -18.50658706670804 C58.209614498865555 -18.338911291872424, 58.665243746270505 -18.17123551703681, 58.9270815951478 -18.074876768247425 M58.9270815951478 -18.074876768247425 C59.159446895085466 -17.972015542836736, 59.39181219502314 -17.869154317426048, 60.07010791279238 -17.568892924097174 M58.9270815951478 -18.074876768247425 C59.208810518690214 -17.95016373098678, 59.49053944223263 -17.82545069372614, 60.07010791279238 -17.568892924097174 M60.07010791279238 -17.568892924097174 C60.412059458343926 -17.39049702181335, 60.75401100389547 -17.212101119529528, 61.17836726407678 -16.990714730406097 M60.07010791279238 -17.568892924097174 C60.451439671688 -17.369952389735978, 60.83277143058363 -17.17101185537478, 61.17836726407678 -16.990714730406097 M61.17836726407678 -16.990714730406097 C61.519486426225974 -16.78392628967255, 61.86060558837516 -16.577137848939003, 62.2473055736057 -16.342718045390892 M61.17836726407678 -16.990714730406097 C61.45697121334686 -16.821823379100035, 61.735575162616946 -16.652932027793973, 62.2473055736057 -16.342718045390892 M62.2473055736057 -16.342718045390892 C62.453896840381724 -16.198608920879742, 62.66048810715774 -16.05449979636859, 63.27253034457871 -15.627565626425154 M62.2473055736057 -16.342718045390892 C62.48028720062112 -16.18020014799702, 62.71326882763653 -16.017682250603148, 63.27253034457871 -15.627565626425154 M63.27253034457871 -15.627565626425154 C63.59510409537248 -15.370321644215142, 63.91767784616624 -15.11307766200513, 64.24982870850187 -14.848196188198123 M63.27253034457871 -15.627565626425154 C63.54439561540931 -15.410760307576444, 63.81626088623991 -15.193954988727734, 64.24982870850187 -14.848196188198123 M64.24982870850187 -14.848196188198123 C64.5528402422176 -14.573009130523346, 64.85585177593333 -14.29782207284857, 65.17518473676799 -14.007812326905688 M64.24982870850187 -14.848196188198123 C64.54647097969472 -14.578793526225182, 64.84311325088755 -14.309390864252238, 65.17518473676799 -14.007812326905688 M65.17518473676799 -14.007812326905688 C65.35046552009774 -13.826820527297768, 65.5257463034275 -13.64582872768985, 66.04479594296865 -13.10986736009568 M65.17518473676799 -14.007812326905688 C65.48534623561727 -13.687545114611174, 65.79550773446654 -13.36727790231666, 66.04479594296865 -13.10986736009568 M66.04479594296865 -13.10986736009568 C66.2249191151409 -12.898284432763305, 66.40504228731315 -12.686701505430928, 66.85508890812658 -12.158051136245305 M66.04479594296865 -13.10986736009568 C66.23066580463659 -12.891534044285775, 66.41653566630453 -12.673200728475871, 66.85508890812658 -12.158051136245305 M66.85508890812658 -12.158051136245305 C67.11698379961612 -11.807135840764762, 67.37887869110565 -11.45622054528422, 67.60273396464063 -11.156274872382312 M66.85508890812658 -12.158051136245305 C67.09258199880342 -11.839832030803981, 67.33007508948027 -11.521612925362657, 67.60273396464063 -11.156274872382312 M67.60273396464063 -11.156274872382312 C67.8270255158205 -10.811702819055458, 68.05131706700035 -10.467130765728605, 68.28465887860425 -10.108655082055241 M67.60273396464063 -11.156274872382312 C67.81385430498769 -10.831937335859978, 68.02497464533475 -10.507599799337644, 68.28465887860425 -10.108655082055241 M68.28465887860425 -10.108655082055241 C68.49161483443605 -9.741183825912895, 68.69857079026785 -9.373712569770547, 68.8980614742735 -9.019496659696287 M68.28465887860425 -10.108655082055241 C68.43314505888236 -9.84500283311526, 68.58163123916049 -9.581350584175276, 68.8980614742735 -9.019496659696287 M68.8980614742735 -9.019496659696287 C69.04739674372946 -8.709398777408085, 69.19673201318543 -8.39930089511988, 69.44042114880834 -7.893275190886684 M68.8980614742735 -9.019496659696287 C69.05315375329914 -8.697444223856392, 69.20824603232477 -8.375391788016499, 69.44042114880834 -7.893275190886684 M69.44042114880834 -7.893275190886684 C69.59326320732345 -7.5157523513542035, 69.74610526583855 -7.138229511821723, 69.90950922997033 -6.734618561215508 M69.44042114880834 -7.893275190886684 C69.54998863128695 -7.6226413908213395, 69.65955611376557 -7.352007590755995, 69.90950922997033 -6.734618561215508 M69.90950922997033 -6.734618561215508 C70.0097340107109 -6.432757490275146, 70.10995879145146 -6.130896419334785, 70.30339813421489 -5.548287939305138 M69.90950922997033 -6.734618561215508 C70.0082059303676 -6.437359824802623, 70.10690263076486 -6.14010108838974, 70.30339813421489 -5.548287939305138 M70.30339813421489 -5.548287939305138 C70.37148089888674 -5.288658850354507, 70.43956366355859 -5.029029761403876, 70.62046928754556 -4.339158212148133 M70.30339813421489 -5.548287939305138 C70.37534768278128 -5.273913127489866, 70.44729723134768 -4.999538315674593, 70.62046928754556 -4.339158212148133 M70.62046928754556 -4.339158212148133 C70.69122170612667 -3.9758594962631237, 70.76197412470776 -3.6125607803781143, 70.85941977658177 -3.1121979531509023 M70.62046928754556 -4.339158212148133 C70.68030028579552 -4.031938681420491, 70.7401312840455 -3.7247191506928488, 70.85941977658177 -3.1121979531509023 M70.85941977658177 -3.1121979531509023 C70.904944698185 -2.7591156511088935, 70.95046961978822 -2.406033349066885, 71.01926770250937 -1.872449005199798 M70.85941977658177 -3.1121979531509023 C70.91141098739594 -2.7089643889021073, 70.96340219821009 -2.3057308246533124, 71.01926770250937 -1.872449005199798 M71.01926770250937 -1.872449005199798 C71.04418445851137 -1.4843504180252136, 71.06910121451337 -1.096251830850629, 71.09935621591342 -0.6250057626472757 M71.01926770250937 -1.872449005199798 C71.04845399195962 -1.4178489882633594, 71.07764028140987 -0.9632489713269208, 71.09935621591342 -0.6250057626472757 M71.09935621591342 -0.6250057626472757 C71.09935621591342 -0.28623002242444756, 71.09935621591342 0.052545717798380576, 71.09935621591342 0.625005762647271 M71.09935621591342 -0.6250057626472757 C71.09935621591342 -0.260619982202213, 71.09935621591342 0.1037657982428497, 71.09935621591342 0.625005762647271 M71.09935621591342 0.625005762647271 C71.07509818829064 1.002844123599414, 71.05084016066787 1.380682484551557, 71.01926770250937 1.8724490051997846 M71.09935621591342 0.625005762647271 C71.07489885278723 1.0059489349658204, 71.05044148966104 1.3868921072843698, 71.01926770250937 1.8724490051997846 M71.01926770250937 1.8724490051997846 C70.96158328946518 2.3198379216946483, 70.90389887642101 2.7672268381895124, 70.85941977658177 3.1121979531508885 M71.01926770250937 1.8724490051997846 C70.9738756934543 2.2245004637647594, 70.92848368439925 2.576551922329734, 70.85941977658177 3.1121979531508885 M70.85941977658177 3.1121979531508885 C70.79018567898896 3.4677004116582424, 70.72095158139616 3.8232028701655967, 70.62046928754556 4.339158212148129 M70.85941977658177 3.1121979531508885 C70.77984411161032 3.5208021744566214, 70.70026844663884 3.929406395762354, 70.62046928754556 4.339158212148129 M70.62046928754556 4.339158212148129 C70.54185551585248 4.638946595038022, 70.4632417441594 4.938734977927917, 70.30339813421489 5.548287939305125 M70.62046928754556 4.339158212148129 C70.53490100824932 4.665467146631538, 70.44933272895308 4.991776081114947, 70.30339813421489 5.548287939305125 M70.30339813421489 5.548287939305125 C70.20982653238161 5.830110695416843, 70.11625493054832 6.11193345152856, 69.90950922997033 6.734618561215495 M70.30339813421489 5.548287939305125 C70.22280361023462 5.791025804540144, 70.14220908625434 6.033763669775162, 69.90950922997033 6.734618561215495 M69.90950922997033 6.734618561215495 C69.7940590406272 7.019782760970578, 69.67860885128408 7.304946960725662, 69.44042114880834 7.893275190886679 M69.90950922997033 6.734618561215495 C69.74485917916874 7.141307369987599, 69.58020912836714 7.547996178759704, 69.44042114880834 7.893275190886679 M69.44042114880834 7.893275190886679 C69.28850457253807 8.208733212005894, 69.13658799626782 8.524191233125109, 68.8980614742735 9.019496659696284 M69.44042114880834 7.893275190886679 C69.23609828095756 8.31755599778783, 69.0317754131068 8.74183680468898, 68.8980614742735 9.019496659696284 M68.8980614742735 9.019496659696284 C68.65425427411797 9.45240103172852, 68.41044707396246 9.885305403760759, 68.28465887860425 10.108655082055236 M68.8980614742735 9.019496659696284 C68.70426240707002 9.363606527799602, 68.51046333986653 9.707716395902922, 68.28465887860425 10.108655082055236 M68.28465887860425 10.108655082055236 C68.11244262740391 10.373225490437532, 67.94022637620357 10.637795898819828, 67.60273396464065 11.156274872382301 M68.28465887860425 10.108655082055236 C68.0628914954977 10.449349335387467, 67.84112411239113 10.7900435887197, 67.60273396464065 11.156274872382301 M67.60273396464065 11.156274872382301 C67.39168283059523 11.43906416484932, 67.1806316965498 11.721853457316339, 66.85508890812659 12.158051136245302 M67.60273396464065 11.156274872382301 C67.43181023053967 11.385297104605108, 67.26088649643869 11.614319336827913, 66.85508890812659 12.158051136245302 M66.85508890812659 12.158051136245302 C66.65381904256303 12.39447417291604, 66.45254917699948 12.630897209586777, 66.04479594296866 13.10986736009567 M66.85508890812659 12.158051136245302 C66.68015085372626 12.363543329201217, 66.50521279932593 12.569035522157131, 66.04479594296866 13.10986736009567 M66.04479594296866 13.10986736009567 C65.72253376996942 13.442629511933854, 65.40027159697016 13.775391663772035, 65.17518473676799 14.007812326905684 M66.04479594296866 13.10986736009567 C65.74348904904262 13.420991465830355, 65.44218215511658 13.732115571565037, 65.17518473676799 14.007812326905684 M65.17518473676799 14.007812326905684 C64.93376178068786 14.227066267916278, 64.69233882460773 14.446320208926872, 64.2498287085019 14.848196188198111 M65.17518473676799 14.007812326905684 C64.89606752965172 14.261299189372075, 64.61695032253544 14.514786051838463, 64.2498287085019 14.848196188198111 M64.2498287085019 14.848196188198111 C63.87363586254718 15.148199971934929, 63.49744301659247 15.448203755671747, 63.27253034457871 15.627565626425152 M64.2498287085019 14.848196188198111 C64.05169715453496 15.006200829984659, 63.85356560056802 15.164205471771208, 63.27253034457871 15.627565626425152 M63.27253034457871 15.627565626425152 C62.964163071662426 15.842669287007457, 62.65579579874614 16.05777294758976, 62.24730557360571 16.34271804539089 M63.27253034457871 15.627565626425152 C62.911529974419864 15.879383857193814, 62.55052960426101 16.131202087962475, 62.24730557360571 16.34271804539089 M62.24730557360571 16.34271804539089 C61.88410064850036 16.562895005418806, 61.520895723395014 16.783071965446727, 61.17836726407678 16.990714730406093 M62.24730557360571 16.34271804539089 C61.87892498504117 16.566032523023345, 61.510544396476625 16.789347000655802, 61.17836726407678 16.990714730406093 M61.17836726407678 16.990714730406093 C60.795919401387806 17.190237535401387, 60.41347153869882 17.38976034039668, 60.07010791279239 17.56889292409717 M61.17836726407678 16.990714730406093 C60.744673877953495 17.216972292152743, 60.31098049183021 17.44322985389939, 60.07010791279239 17.56889292409717 M60.07010791279239 17.56889292409717 C59.76881693858178 17.702265500901003, 59.46752596437118 17.83563807770484, 58.927081595147804 18.07487676824742 M60.07010791279239 17.56889292409717 C59.71052698392867 17.728068734715865, 59.35094605506496 17.88724454533456, 58.927081595147804 18.07487676824742 M58.927081595147804 18.07487676824742 C58.53375110347654 18.219626025089653, 58.140420611805276 18.364375281931885, 57.75398525146062 18.506587066708033 M58.927081595147804 18.07487676824742 C58.54352005053583 18.216030962268675, 58.15995850592387 18.357185156289933, 57.75398525146062 18.506587066708033 M57.75398525146062 18.506587066708033 C57.345862188433834 18.62771584953271, 56.93773912540704 18.74884463235739, 56.55563938623541 18.86224982926107 M57.75398525146062 18.506587066708033 C57.32235498844113 18.634692663098736, 56.890724725421634 18.762798259489436, 56.55563938623541 18.86224982926107 M56.55563938623541 18.86224982926107 C56.26528001881021 18.928522460103924, 55.974920651385005 18.994795090946777, 55.336968259676766 19.140403561325773 M56.55563938623541 18.86224982926107 C56.272106623172526 18.926964332205984, 55.98857386010964 18.991678835150893, 55.336968259676766 19.140403561325773 M55.336968259676766 19.140403561325773 C54.946302177374506 19.203563423851687, 54.555636095072245 19.2667232863776, 54.10297965284788 19.3399052695533 M55.336968259676766 19.140403561325773 C55.00669487380036 19.19379960104591, 54.67642148792396 19.247195640766044, 54.10297965284788 19.3399052695533 M54.10297965284788 19.3399052695533 C53.81972306223846 19.367230692183497, 53.53646647162904 19.394556114813696, 52.8587442896239 19.45993515863156 M54.10297965284788 19.3399052695533 C53.62689978831229 19.38583212151524, 53.1508199237767 19.43175897347718, 52.8587442896239 19.45993515863156 M52.8587442896239 19.45993515863156 C52.36715561943862 19.4756994504651, 51.87556694925333 19.49146374229864, 51.60937500000001 19.5 M52.8587442896239 19.45993515863156 C52.54331666542931 19.470050308596853, 52.22788904123471 19.48016545856215, 51.60937500000001 19.5 M51.60937500000001 19.5 C51.60937500000001 19.5, 51.60937500000001 19.5, 51.609375 19.5 M51.60937500000001 19.5 C51.60937500000001 19.5, 51.609375 19.5, 51.609375 19.5 M51.609375 19.5 C19.276613246971294 19.5, -13.056148506057411 19.5, -51.60937499999999 19.5 M51.609375 19.5 C19.28635838897523 19.5, -13.036658222049539 19.5, -51.60937499999999 19.5 M-51.60937499999999 19.5 C-51.9317447988735 19.489662228004875, -52.25411459774701 19.47932445600975, -52.85874428962389 19.45993515863156 M-51.60937499999999 19.5 C-51.88374822794739 19.49120138461482, -52.1581214558948 19.482402769229633, -52.85874428962389 19.45993515863156 M-52.85874428962389 19.45993515863156 C-53.300430750923866 19.41732619691564, -53.74211721222383 19.374717235199718, -54.10297965284787 19.3399052695533 M-52.85874428962389 19.45993515863156 C-53.243933387808404 19.422776429587365, -53.629122485992916 19.385617700543175, -54.10297965284787 19.3399052695533 M-54.10297965284787 19.3399052695533 C-54.572254331444796 19.26403657879723, -55.041529010041714 19.188167888041168, -55.33696825967676 19.140403561325773 M-54.10297965284787 19.3399052695533 C-54.39991129920546 19.29189966370951, -54.69684294556305 19.243894057865724, -55.33696825967676 19.140403561325773 M-55.33696825967676 19.140403561325773 C-55.776451201965465 19.04009444764259, -56.21593414425417 18.939785333959414, -56.555639386235384 18.862249829261074 M-55.33696825967676 19.140403561325773 C-55.76207877926424 19.043374859335323, -56.18718929885173 18.946346157344873, -56.555639386235384 18.862249829261074 M-56.555639386235384 18.862249829261074 C-56.93986276304694 18.748214347853068, -57.324086139858494 18.63417886644506, -57.75398525146059 18.506587066708043 M-56.555639386235384 18.862249829261074 C-56.89541343441471 18.761406675271825, -57.23518748259404 18.660563521282572, -57.75398525146059 18.506587066708043 M-57.75398525146059 18.506587066708043 C-58.026744533975844 18.40620912938326, -58.2995038164911 18.30583119205848, -58.9270815951478 18.074876768247425 M-57.75398525146059 18.506587066708043 C-58.116235128201204 18.373275763466346, -58.47848500494182 18.239964460224645, -58.9270815951478 18.074876768247425 M-58.9270815951478 18.074876768247425 C-59.38377335625507 17.872712872884993, -59.84046511736234 17.670548977522557, -60.07010791279238 17.568892924097174 M-58.9270815951478 18.074876768247425 C-59.3779056672695 17.875310324740095, -59.8287297393912 17.675743881232766, -60.07010791279238 17.568892924097174 M-60.07010791279238 17.568892924097174 C-60.30162256267034 17.448111879548055, -60.5331372125483 17.327330834998936, -61.17836726407678 16.990714730406097 M-60.07010791279238 17.568892924097174 C-60.4573746666451 17.366856106668404, -60.84464142049782 17.164819289239635, -61.17836726407678 16.990714730406097 M-61.17836726407678 16.990714730406097 C-61.47936763961692 16.808246533879117, -61.78036801515705 16.625778337352138, -62.247305573605686 16.3427180453909 M-61.17836726407678 16.990714730406097 C-61.59878529587831 16.73585484887742, -62.019203327679826 16.480994967348746, -62.247305573605686 16.3427180453909 M-62.247305573605686 16.3427180453909 C-62.50414808598692 16.163555827286913, -62.76099059836816 15.984393609182924, -63.27253034457871 15.627565626425156 M-62.247305573605686 16.3427180453909 C-62.51551571670987 16.155626260191386, -62.783725859814055 15.968534474991872, -63.27253034457871 15.627565626425156 M-63.27253034457871 15.627565626425156 C-63.63246410413634 15.340528031417549, -63.99239786369397 15.05349043640994, -64.24982870850187 14.848196188198125 M-63.27253034457871 15.627565626425156 C-63.59179767144675 15.372958429308273, -63.91106499831479 15.118351232191388, -64.24982870850187 14.848196188198125 M-64.24982870850187 14.848196188198125 C-64.60987061588827 14.521215654096645, -64.96991252327467 14.194235119995163, -65.17518473676797 14.007812326905697 M-64.24982870850187 14.848196188198125 C-64.45349667215085 14.663230332371144, -64.65716463579983 14.478264476544163, -65.17518473676797 14.007812326905697 M-65.17518473676797 14.007812326905697 C-65.47976890728789 13.693304164018421, -65.78435307780781 13.378796001131146, -66.04479594296865 13.109867360095677 M-65.17518473676797 14.007812326905697 C-65.46144577082241 13.71222430677651, -65.74770680487684 13.416636286647325, -66.04479594296865 13.109867360095677 M-66.04479594296865 13.109867360095677 C-66.24876221908305 12.870276966161809, -66.45272849519745 12.63068657222794, -66.85508890812658 12.158051136245307 M-66.04479594296865 13.109867360095677 C-66.23721144948077 12.883845157394058, -66.42962695599289 12.657822954692438, -66.85508890812658 12.158051136245307 M-66.85508890812658 12.158051136245307 C-67.0457281278496 11.902611949406658, -67.23636734757262 11.64717276256801, -67.60273396464063 11.156274872382316 M-66.85508890812658 12.158051136245307 C-67.01245408430094 11.947196136434128, -67.16981926047531 11.736341136622949, -67.60273396464063 11.156274872382316 M-67.60273396464063 11.156274872382316 C-67.78156998332345 10.881534723490466, -67.96040600200628 10.606794574598617, -68.28465887860425 10.108655082055249 M-67.60273396464063 11.156274872382316 C-67.82522232732315 10.814473000811292, -68.04771069000567 10.47267112924027, -68.28465887860425 10.108655082055249 M-68.28465887860425 10.108655082055249 C-68.50597928433334 9.715678290561982, -68.72729969006244 9.322701499068716, -68.8980614742735 9.019496659696289 M-68.28465887860425 10.108655082055249 C-68.49017982095066 9.74373183766708, -68.69570076329708 9.37880859327891, -68.8980614742735 9.019496659696289 M-68.8980614742735 9.019496659696289 C-69.10389131494772 8.592086591483215, -69.30972115562193 8.164676523270138, -69.44042114880834 7.893275190886686 M-68.8980614742735 9.019496659696289 C-69.04013905108656 8.724469531470827, -69.18221662789962 8.429442403245364, -69.44042114880834 7.893275190886686 M-69.44042114880834 7.893275190886686 C-69.57526874403834 7.5601990235677015, -69.71011633926834 7.227122856248716, -69.90950922997033 6.73461856121551 M-69.44042114880834 7.893275190886686 C-69.53607764044344 7.657001807155542, -69.63173413207853 7.420728423424397, -69.90950922997033 6.73461856121551 M-69.90950922997033 6.73461856121551 C-70.06634778437224 6.262245824187049, -70.22318633877416 5.78987308715859, -70.30339813421489 5.5482879393051325 M-69.90950922997033 6.73461856121551 C-70.02631402152132 6.38282113938015, -70.14311881307229 6.031023717544791, -70.30339813421489 5.5482879393051325 M-70.30339813421489 5.5482879393051325 C-70.42950983098602 5.067369382361695, -70.55562152775714 4.586450825418259, -70.62046928754556 4.339158212148136 M-70.30339813421489 5.5482879393051325 C-70.42059894152388 5.101350465884735, -70.53779974883287 4.654412992464338, -70.62046928754556 4.339158212148136 M-70.62046928754556 4.339158212148136 C-70.6886539126115 3.9890445732601227, -70.75683853767745 3.6389309343721097, -70.85941977658177 3.112197953150904 M-70.62046928754556 4.339158212148136 C-70.70135455712114 3.9238294479853035, -70.78223982669672 3.5085006838224713, -70.85941977658177 3.112197953150904 M-70.85941977658177 3.112197953150904 C-70.90481021624959 2.7601586664434863, -70.9502006559174 2.4081193797360685, -71.01926770250937 1.872449005199809 M-70.85941977658177 3.112197953150904 C-70.90864443688059 2.7304212083819075, -70.95786909717941 2.3486444636129105, -71.01926770250937 1.872449005199809 M-71.01926770250937 1.872449005199809 C-71.04091113855222 1.5353350185653447, -71.06255457459505 1.1982210319308806, -71.09935621591342 0.6250057626472781 M-71.01926770250937 1.872449005199809 C-71.04703623523045 1.4399316937127318, -71.07480476795153 1.0074143822256543, -71.09935621591342 0.6250057626472781 M-71.09935621591342 0.6250057626472781 C-71.09935621591342 0.28922384420306696, -71.09935621591342 -0.04655807424114422, -71.09935621591342 -0.6250057626472687 M-71.09935621591342 0.6250057626472781 C-71.09935621591342 0.20641710551917625, -71.09935621591342 -0.21217155160892565, -71.09935621591342 -0.6250057626472687 M-71.09935621591342 -0.6250057626472687 C-71.07545112525453 -0.997346845501349, -71.05154603459563 -1.3696879283554293, -71.01926770250937 -1.8724490051997822 M-71.09935621591342 -0.6250057626472687 C-71.0752335556112 -1.0007356683152442, -71.05111089530898 -1.3764655739832194, -71.01926770250937 -1.8724490051997822 M-71.01926770250937 -1.8724490051997822 C-70.96051704341065 -2.328107515539572, -70.90176638431193 -2.7837660258793617, -70.85941977658177 -3.112197953150895 M-71.01926770250937 -1.8724490051997822 C-70.95967013586187 -2.3346759632470815, -70.90007256921437 -2.7969029212943806, -70.85941977658177 -3.112197953150895 M-70.85941977658177 -3.112197953150895 C-70.80325756295152 -3.4005790502541884, -70.74709534932127 -3.6889601473574816, -70.62046928754556 -4.339158212148126 M-70.85941977658177 -3.112197953150895 C-70.79103187536377 -3.4633553721173165, -70.72264397414577 -3.814512791083738, -70.62046928754556 -4.339158212148126 M-70.62046928754556 -4.339158212148126 C-70.54063972274996 -4.6435829410660086, -70.46081015795436 -4.948007669983891, -70.30339813421489 -5.548287939305123 M-70.62046928754556 -4.339158212148126 C-70.54430075171895 -4.629621850907235, -70.46813221589234 -4.920085489666344, -70.30339813421489 -5.548287939305123 M-70.30339813421489 -5.548287939305123 C-70.2076277792523 -5.836732989518538, -70.11185742428974 -6.125178039731953, -69.90950922997033 -6.734618561215485 M-70.30339813421489 -5.548287939305123 C-70.1612081825077 -5.97654141895492, -70.01901823080054 -6.404794898604716, -69.90950922997033 -6.734618561215485 M-69.90950922997033 -6.734618561215485 C-69.76721786562966 -7.086080982153308, -69.624926501289 -7.43754340309113, -69.44042114880834 -7.893275190886676 M-69.90950922997033 -6.734618561215485 C-69.74518621372611 -7.140499588308088, -69.5808631974819 -7.546380615400691, -69.44042114880834 -7.893275190886676 M-69.44042114880834 -7.893275190886676 C-69.25689281895924 -8.274375694695513, -69.07336448911015 -8.65547619850435, -68.8980614742735 -9.019496659696282 M-69.44042114880834 -7.893275190886676 C-69.30347174703329 -8.177653553920377, -69.16652234525822 -8.462031916954079, -68.8980614742735 -9.019496659696282 M-68.8980614742735 -9.019496659696282 C-68.66932385456995 -9.425643465675549, -68.44058623486642 -9.831790271654816, -68.28465887860425 -10.108655082055243 M-68.8980614742735 -9.019496659696282 C-68.69554483990346 -9.379085451053228, -68.49302820553342 -9.738674242410173, -68.28465887860425 -10.108655082055243 M-68.28465887860425 -10.108655082055243 C-68.04211322977808 -10.481270331278376, -67.7995675809519 -10.853885580501508, -67.60273396464063 -11.156274872382308 M-68.28465887860425 -10.108655082055243 C-68.03869556858722 -10.48652077663806, -67.7927322585702 -10.864386471220875, -67.60273396464063 -11.156274872382308 M-67.60273396464063 -11.156274872382308 C-67.36521040297664 -11.474534806170439, -67.12768684131265 -11.792794739958568, -66.85508890812659 -12.158051136245302 M-67.60273396464063 -11.156274872382308 C-67.31503026010354 -11.541771626115342, -67.02732655556645 -11.927268379848377, -66.85508890812659 -12.158051136245302 M-66.85508890812659 -12.158051136245302 C-66.66585710988718 -12.380333574051651, -66.4766253116478 -12.602616011858002, -66.04479594296866 -13.10986736009567 M-66.85508890812659 -12.158051136245302 C-66.6121707043761 -12.443396680807993, -66.3692525006256 -12.728742225370686, -66.04479594296866 -13.10986736009567 M-66.04479594296866 -13.10986736009567 C-65.78495457201443 -13.37817490897921, -65.52511320106021 -13.64648245786275, -65.17518473676799 -14.007812326905677 M-66.04479594296866 -13.10986736009567 C-65.76827531434965 -13.395397611910624, -65.49175468573063 -13.680927863725579, -65.17518473676799 -14.007812326905677 M-65.17518473676799 -14.007812326905677 C-64.81743853208343 -14.33270796455432, -64.45969232739886 -14.657603602202961, -64.2498287085019 -14.848196188198107 M-65.17518473676799 -14.007812326905677 C-64.8299364647681 -14.321357672767277, -64.4846881927682 -14.634903018628878, -64.2498287085019 -14.848196188198107 M-64.2498287085019 -14.848196188198107 C-64.0405771237301 -15.015068758635087, -63.831325538958296 -15.181941329072064, -63.27253034457872 -15.627565626425149 M-64.2498287085019 -14.848196188198107 C-63.9559797931505 -15.082532878542805, -63.6621308777991 -15.316869568887503, -63.27253034457872 -15.627565626425149 M-63.27253034457872 -15.627565626425149 C-63.0598792944141 -15.775901793504392, -62.847228244249486 -15.924237960583634, -62.247305573605715 -16.342718045390885 M-63.27253034457872 -15.627565626425149 C-62.918774111363376 -15.874330660855655, -62.565017878148026 -16.12109569528616, -62.247305573605715 -16.342718045390885 M-62.247305573605715 -16.342718045390885 C-61.87174065567025 -16.5703877057078, -61.49617573773478 -16.798057366024718, -61.17836726407679 -16.99071473040609 M-62.247305573605715 -16.342718045390885 C-62.007374395232766 -16.488165735712677, -61.76744321685982 -16.633613426034472, -61.17836726407679 -16.99071473040609 M-61.17836726407679 -16.99071473040609 C-60.87251536759632 -17.15027746889246, -60.56666347111585 -17.309840207378834, -60.07010791279239 -17.56889292409717 M-61.17836726407679 -16.99071473040609 C-60.93753934807132 -17.116354499928505, -60.69671143206584 -17.24199426945092, -60.07010791279239 -17.56889292409717 M-60.07010791279239 -17.56889292409717 C-59.70417196310599 -17.730881913919074, -59.338236013419596 -17.892870903740974, -58.927081595147804 -18.07487676824742 M-60.07010791279239 -17.56889292409717 C-59.61700234801419 -17.769469316725456, -59.16389678323599 -17.97004570935374, -58.927081595147804 -18.07487676824742 M-58.927081595147804 -18.07487676824742 C-58.51876632960443 -18.22514056039232, -58.11045106406106 -18.37540435253722, -57.75398525146062 -18.506587066708033 M-58.927081595147804 -18.07487676824742 C-58.56945187589108 -18.20648781082678, -58.21182215663437 -18.338098853406137, -57.75398525146062 -18.506587066708033 M-57.75398525146062 -18.506587066708033 C-57.31934668095197 -18.635585512970586, -56.88470811044332 -18.76458395923314, -56.55563938623541 -18.862249829261067 M-57.75398525146062 -18.506587066708033 C-57.4089574769239 -18.608989499102783, -57.06392970238718 -18.711391931497538, -56.55563938623541 -18.862249829261067 M-56.55563938623541 -18.862249829261067 C-56.13942669364616 -18.95724766030292, -55.7232140010569 -19.05224549134477, -55.336968259676766 -19.140403561325773 M-56.55563938623541 -18.862249829261067 C-56.07393482391944 -18.972195752193652, -55.59223026160346 -19.082141675126238, -55.336968259676766 -19.140403561325773 M-55.336968259676766 -19.140403561325773 C-54.95088297143116 -19.202822836593192, -54.56479768318557 -19.265242111860616, -54.10297965284788 -19.3399052695533 M-55.336968259676766 -19.140403561325773 C-54.94781979866771 -19.20331806661033, -54.55867133765866 -19.266232571894886, -54.10297965284788 -19.3399052695533 M-54.10297965284788 -19.3399052695533 C-53.76221600517689 -19.372778328815873, -53.42145235750589 -19.405651388078446, -52.8587442896239 -19.45993515863156 M-54.10297965284788 -19.3399052695533 C-53.82773954810108 -19.366457351433567, -53.55249944335427 -19.393009433313832, -52.8587442896239 -19.45993515863156 M-52.8587442896239 -19.45993515863156 C-52.37318308550731 -19.47550616136003, -51.88762188139072 -19.491077164088498, -51.60937500000001 -19.5 M-52.8587442896239 -19.45993515863156 C-52.566159751374904 -19.469317775291948, -52.27357521312591 -19.478700391952337, -51.60937500000001 -19.5 M-51.60937500000001 -19.5 C-51.60937500000001 -19.5, -51.609375 -19.5, -51.609375 -19.5 M-51.60937500000001 -19.5 C-51.60937500000001 -19.5, -51.609375 -19.5, -51.609375 -19.5" stroke="#9370DB" stroke-width="1.3" fill="none" stroke-dasharray="3 3" style="fill:transparent !important;stroke-dasharray:3 3 !important"/></g><g class="label" style="" transform="translate(-58.734375, -12)"><rect/><foreignObject width="117.46875" height="24"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Jij in je browser</p></span></div></foreignObject></g></g><g class="node default" id="my-svg-flowchart-Vercel-1" data-look="classic" transform="translate(390.25340270996094, 106)"><rect class="basic label-container" style="" x="-119.3828125" y="-39" width="238.765625" height="78"/><g class="label" style="" transform="translate(-89.3828125, -24)"><rect/><foreignObject width="178.765625" height="48"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Vercel<br />UI · Server Actions · cron</p></span></div></foreignObject></g></g><g class="node default" id="my-svg-flowchart-Neon-2" data-look="classic" transform="translate(735.0034027099609, 106)"><path d="M0,14.496349981618613 a86.2578125,14.496349981618613 0,0,0 172.515625,0 a86.2578125,14.496349981618613 0,0,0 -172.515625,0 l0,77.49634998161861 a86.2578125,14.496349981618613 0,0,0 172.515625,0 l0,-77.49634998161861" class="basic label-container outer-path" style="" transform="translate(-86.2578125, -53.24452497242792)"/><g class="label" style="" transform="translate(-78.7578125, -14)"><rect/><foreignObject width="157.515625" height="48"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Neon Postgres<br />metadata · jobs · logs</p></span></div></foreignObject></g></g><g class="node default" id="my-svg-flowchart-Worker-5" data-look="classic" transform="translate(1235.972152709961, 106)"><rect class="basic label-container" style="" x="-120.6328125" y="-63" width="241.265625" height="126"/><g class="label" style="" transform="translate(-90.6328125, -48)"><rect/><foreignObject width="181.265625" height="96"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>Lokale worker<br />laptop / NAS / VM<br />Claude Code + MCP<br />jobs: GRILL · PLAN · IMPL</p></span></div></foreignObject></g></g><g class="node default" id="my-svg-flowchart-GitHub-6" data-look="classic" transform="translate(1506.933090209961, 106)"><path d="M0,10.285462036492053 a43.6875,10.285462036492053 0,0,0 87.375,0 a43.6875,10.285462036492053 0,0,0 -87.375,0 l0,73.28546203649205 a43.6875,10.285462036492053 0,0,0 87.375,0 l0,-73.28546203649205" class="basic label-container outer-path" style="" transform="translate(-43.6875, -46.928193054738074)"/><g class="label" style="" transform="translate(-36.1875, -14)"><rect/><foreignObject width="72.375" height="48"><div xmlns="http://www.w3.org/1999/xhtml" style="display: table-cell; white-space: nowrap; line-height: 1.5; max-width: 200px; text-align: center;"><span class="nodeLabel"><p>GitHub<br />jouw repo</p></span></div></foreignObject></g></g></g></g></g><defs><filter id="my-svg-drop-shadow" height="130%" width="130%"><feDropShadow dx="4" dy="4" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"/></filter></defs><defs><filter id="my-svg-drop-shadow-small" height="150%" width="150%"><feDropShadow dx="2" dy="2" stdDeviation="0" flood-opacity="0.06" flood-color="#000000"/></filter></defs><text text-anchor="middle" x="791.810302734375" y="-25" class="flowchartTitleText">Scrum4Me — architectuur (lokaal & veilig)</text></svg> \ No newline at end of file diff --git a/public/manifest.json b/public/manifest.json index aeb6cf0..b21a14f 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -4,7 +4,6 @@ "description": "Lichtgewicht Scrum-planner voor solo developers en kleine teams", "start_url": "/dashboard", "display": "standalone", - "orientation": "landscape", "background_color": "#0d0a14", "theme_color": "#7c3aed", "icons": [ diff --git a/public/screenshots/ideas-detail.png b/public/screenshots/ideas-detail.png deleted file mode 100644 index c4c5a4d..0000000 Binary files a/public/screenshots/ideas-detail.png and /dev/null differ diff --git a/public/screenshots/ideas-table.png b/public/screenshots/ideas-table.png deleted file mode 100644 index 8c900c6..0000000 Binary files a/public/screenshots/ideas-table.png and /dev/null differ diff --git a/public/screenshots/insights.png b/public/screenshots/insights.png deleted file mode 100644 index ed7b812..0000000 Binary files a/public/screenshots/insights.png and /dev/null differ diff --git a/public/screenshots/product-backlog.jpg b/public/screenshots/product-backlog.jpg new file mode 100644 index 0000000..1b54f19 Binary files /dev/null and b/public/screenshots/product-backlog.jpg differ diff --git a/public/screenshots/product-backlog.png b/public/screenshots/product-backlog.png deleted file mode 100644 index ea9f95e..0000000 Binary files a/public/screenshots/product-backlog.png and /dev/null differ diff --git a/public/screenshots/producten.png b/public/screenshots/producten.png deleted file mode 100644 index 2370a30..0000000 Binary files a/public/screenshots/producten.png and /dev/null differ diff --git a/public/screenshots/solo-paneel.jpg b/public/screenshots/solo-paneel.jpg new file mode 100644 index 0000000..b003cdf Binary files /dev/null and b/public/screenshots/solo-paneel.jpg differ diff --git a/public/screenshots/solo.png b/public/screenshots/solo.png deleted file mode 100644 index a93744b..0000000 Binary files a/public/screenshots/solo.png and /dev/null differ diff --git a/public/screenshots/sprint-board.jpg b/public/screenshots/sprint-board.jpg new file mode 100644 index 0000000..df98494 Binary files /dev/null and b/public/screenshots/sprint-board.jpg differ diff --git a/public/screenshots/sprint.png b/public/screenshots/sprint.png deleted file mode 100644 index ea9cdb8..0000000 Binary files a/public/screenshots/sprint.png and /dev/null differ diff --git a/public/sw.js b/public/sw.js deleted file mode 100644 index f4ebb07..0000000 --- a/public/sw.js +++ /dev/null @@ -1,42 +0,0 @@ -// Service Worker for Web Push notifications (PBI-55) - -self.addEventListener('push', (event) => { - let payload = { title: 'Scrum4Me', body: '', url: '/', tag: undefined } - try { - if (event.data) payload = { ...payload, ...event.data.json() } - } catch (_) {} - - event.waitUntil( - self.registration.showNotification(payload.title, { - body: payload.body, - icon: '/icon-192.png', - badge: '/icon-192.png', - tag: payload.tag, - data: { url: payload.url }, - }) - ) -}) - -self.addEventListener('notificationclick', (event) => { - event.notification.close() - - const rawUrl = event.notification.data?.url || '/' - const targetUrl = new URL(rawUrl, self.location.origin) - - // Same-origin guard - if (targetUrl.origin !== self.location.origin) return - - event.waitUntil( - self.clients - .matchAll({ type: 'window', includeUncontrolled: true }) - .then((clients) => { - for (const client of clients) { - if (client.url.startsWith(self.location.origin) && 'focus' in client) { - client.navigate(targetUrl.href) - return client.focus() - } - } - return self.clients.openWindow(targetUrl.href) - }) - ) -}) diff --git a/scripts/README.md b/scripts/README.md index 1227a0b..0011845 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,57 +1,4 @@ -# Scripts - -## sync-model-prices.ts - -Wekelijks handmatig draaibaar script dat de tabel `model_prices` synchroniseert. Haalt de actuele Claude 4.x modellijst op via `GET /v1/models` (Anthropic API) en upsert de prijzen vanuit een hardcoded `PRICE_TABLE` in het script. Anthropic biedt geen prijs-API; bij elke prijswijziging update je de tabel in [`scripts/sync-model-prices.ts`](./sync-model-prices.ts). - -### Prerequisites - -| What | How | -|---|---| -| `ANTHROPIC_API_KEY` in `.env.local` | Genereer op [console.anthropic.com](https://console.anthropic.com/) → API Keys. Free Evaluation tier is voldoende — `/v1/models` is een gratis metadata-call. | -| `DATABASE_URL` in `.env.local` | Standaard Scrum4Me-setup. | - -### Gebruik - -```bash -# Eerst droog draaien — toont wat er zou gebeuren, schrijft niets -npm run db:sync-model-prices -- --dry-run - -# Echt synchroniseren -npm run db:sync-model-prices -``` - -### Output - -``` -Fetching /v1/models from Anthropic API... - → 12 models received, 4 match Claude 4.x filter - -Syncing prices: - ✓ claude-opus-4-7 (unchanged) - ✓ claude-sonnet-4-6 (unchanged) - ✓ claude-haiku-4-5-20251001 (unchanged) - ⚠ claude-sonnet-4-9 (geen prijs in PRICE_TABLE — ...) - -Result: 0 created, 0 updated, 3 unchanged, 1 skipped -``` - -### Bij een nieuw model (`⚠ skipped`) - -1. Open de Anthropic [pricing-pagina](https://platform.claude.com/docs/en/about-claude/pricing). -2. Voeg het model toe aan `PRICE_TABLE` in [`scripts/sync-model-prices.ts`](./sync-model-prices.ts): - ```ts - 'claude-sonnet-4-9': { input: 3.0, output: 15.0 }, - ``` -3. Draai het script opnieuw. - -### Edge cases - -- **API geeft 401**: controleer `ANTHROPIC_API_KEY`. -- **API geeft 5xx**: script doet 1× retry met 2s delay, daarna falen. -- **Model in DB maar niet meer in API**: wordt niet verwijderd — alleen gelogd, zodat oude `claude_jobs` rijen kostberekening blijven hebben. - ---- +# API Test Scripts ## test-api.sh diff --git a/scripts/build-manual.mjs b/scripts/build-manual.mjs deleted file mode 100644 index 99abf5f..0000000 --- a/scripts/build-manual.mjs +++ /dev/null @@ -1,159 +0,0 @@ -#!/usr/bin/env node -// Generate lib/manual.generated.ts — a typed TOC of the docs/manual/ chapters. -// Walks docs/manual/, parses front-matter, extracts title and description, and -// emits a single TS file consumed by the in-app /manual route. -// -// Usage: `npm run manual:build` (also chained into `prebuild`). -// -// Pure Node 20 — no external deps. Mirrors scripts/generate-docs-index.mjs. - -import { readdir, readFile, writeFile } from 'node:fs/promises'; -import { join, relative, basename, sep } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url)); -const REPO_ROOT = join(SCRIPT_DIR, '..'); -const MANUAL_DIR = join(REPO_ROOT, 'docs', 'manual'); -const OUT_PATH = join(REPO_ROOT, 'lib', 'manual.generated.ts'); - -async function walk(dir) { - const entries = await readdir(dir, { withFileTypes: true }); - const files = []; - for (const e of entries) { - const full = join(dir, e.name); - if (e.isDirectory()) { - files.push(...(await walk(full))); - } else if (e.isFile() && e.name.endsWith('.md')) { - files.push(full); - } - } - return files; -} - -function parseFrontMatter(content) { - if (!content.startsWith('---\n')) return { data: {}, body: content }; - const end = content.indexOf('\n---\n', 4); - if (end === -1) return { data: {}, body: content }; - const block = content.slice(4, end); - const data = {}; - for (const raw of block.split('\n')) { - const line = raw.trim(); - if (!line || line.startsWith('#')) continue; - const m = line.match(/^([A-Za-z][\w-]*)\s*:\s*(.*?)\s*$/); - if (!m) continue; - let val = m[2]; - if ( - (val.startsWith('"') && val.endsWith('"')) || - (val.startsWith("'") && val.endsWith("'")) - ) { - val = val.slice(1, -1); - } - data[m[1]] = val; - } - return { data, body: content.slice(end + 5) }; -} - -function extractFirstH1(body) { - const m = body.match(/^#\s+(.+?)\s*$/m); - return m ? m[1] : null; -} - -function extractFirstParagraph(body) { - // Skip leading H1, then take the first non-heading, non-blank block. - const lines = body.split('\n'); - let i = 0; - while (i < lines.length && (lines[i].trim() === '' || lines[i].startsWith('#'))) i++; - const para = []; - while (i < lines.length && lines[i].trim() !== '') { - if (lines[i].startsWith('>') || lines[i].startsWith('|') || lines[i].startsWith('```')) break; - para.push(lines[i]); - i++; - } - return para.join(' ').replace(/\s+/g, ' ').trim(); -} - -// docs/manual/01-overview.md → ['01-overview'] -// docs/manual/index.md → [] -function fileToSlug(rel) { - const stripped = rel.replace(/^docs\/manual\//, '').replace(/\.md$/, ''); - if (stripped === 'index') return []; - return stripped.split('/'); -} - -function escapeTs(s) { - return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); -} - -function escapeBacktick(s) { - return String(s).replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${'); -} - -function stripFrontMatter(content) { - if (!content.startsWith('---\n')) return content; - const end = content.indexOf('\n---\n', 4); - if (end === -1) return content; - return content.slice(end + 5).replace(/^\s*\n/, ''); -} - -async function main() { - const files = (await walk(MANUAL_DIR)).sort(); - const entries = []; - - for (const full of files) { - const rel = relative(REPO_ROOT, full).split(sep).join('/'); - const content = await readFile(full, 'utf8'); - const { data, body } = parseFrontMatter(content); - const slug = fileToSlug(rel); - const title = data.title || extractFirstH1(body) || basename(full, '.md'); - const description = extractFirstParagraph(body) || ''; - const markdown = stripFrontMatter(content); - entries.push({ - slug, - title, - description, - filePath: rel, - markdown, - }); - } - - // Sort: index first, then by filename so numeric prefixes drive order. - entries.sort((a, b) => { - if (a.slug.length === 0) return -1; - if (b.slug.length === 0) return 1; - return a.filePath.localeCompare(b.filePath); - }); - - const lines = []; - lines.push('// AUTO-GENERATED by scripts/build-manual.mjs. Do not edit by hand.'); - lines.push('// Run `npm run manual:build` to regenerate.'); - lines.push(''); - lines.push('export type ManualEntry = {'); - lines.push(' slug: readonly string[]'); - lines.push(' title: string'); - lines.push(' description: string'); - lines.push(' filePath: string'); - lines.push(' markdown: string'); - lines.push('}'); - lines.push(''); - lines.push('export const MANUAL_TOC: readonly ManualEntry[] = ['); - for (const e of entries) { - const slugLit = '[' + e.slug.map((s) => `'${escapeTs(s)}'`).join(', ') + '] as const'; - lines.push(' {'); - lines.push(` slug: ${slugLit},`); - lines.push(` title: '${escapeTs(e.title)}',`); - lines.push(` description: '${escapeTs(e.description)}',`); - lines.push(` filePath: '${escapeTs(e.filePath)}',`); - lines.push(` markdown: \`${escapeBacktick(e.markdown)}\`,`); - lines.push(' },'); - } - lines.push('] as const;'); - lines.push(''); - - await writeFile(OUT_PATH, lines.join('\n'), 'utf8'); - console.log(`Wrote ${relative(REPO_ROOT, OUT_PATH)} (${entries.length} chapters)`); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/scripts/check-doc-links.mjs b/scripts/check-doc-links.mjs index a88661f..a47d2dd 100644 --- a/scripts/check-doc-links.mjs +++ b/scripts/check-doc-links.mjs @@ -14,16 +14,8 @@ import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); -// Directories under docs/ that are archived and may contain stale links by design. -// Their original-as-written paths are kept for historical reference, but the -// targets have since moved/been deleted. Skip them from link-checking. -const EXCLUDE_DIRS = new Set([ - resolve(__dirname, '..', 'docs', 'old'), -]); - // Collect all .md files under a directory recursively function collectMd(dir) { - if (EXCLUDE_DIRS.has(dir)) return []; const results = []; for (const entry of readdirSync(dir)) { const full = resolve(dir, entry); @@ -57,9 +49,7 @@ function headingSlugs(filePath) { return slugs; } -// Match `[label](url)` where url may contain one level of balanced parens -// (e.g. Next.js route groups like `app/(app)/...`). -const LINK_RE = /\[(?:[^\]]*)\]\(((?:[^()]+|\([^()]*\))+)\)/g; +const LINK_RE = /\[(?:[^\]]*)\]\(([^)]+)\)/g; function checkFile(filePath) { const content = readFileSync(filePath, 'utf8'); diff --git a/scripts/create-admin.ts b/scripts/create-admin.ts deleted file mode 100644 index 8998258..0000000 --- a/scripts/create-admin.ts +++ /dev/null @@ -1,58 +0,0 @@ -// Maak een admin-user aan of upgrade een bestaande user naar ADMIN-rol. -// -// Gebruik: -// npx tsx scripts/create-admin.ts <username> <password> - -import { PrismaClient } from '@prisma/client' -import { Pool } from 'pg' -import { PrismaPg } from '@prisma/adapter-pg' -import * as bcrypt from 'bcryptjs' -import * as dotenv from 'dotenv' -import * as path from 'path' - -const root = path.resolve(__dirname, '..') -dotenv.config({ path: path.join(root, '.env.local'), override: true }) -dotenv.config({ path: path.join(root, '.env') }) - -const [username, password] = process.argv.slice(2) - -if (!username || !password) { - console.error('Usage: npx tsx scripts/create-admin.ts <username> <password>') - process.exit(1) -} - -const url = process.env.DIRECT_URL || process.env.DATABASE_URL -if (!url) { - console.error('Fout: DATABASE_URL is niet ingesteld.') - process.exit(1) -} - -const pool = new Pool({ connectionString: url }) -const adapter = new PrismaPg(pool) -const prisma = new PrismaClient({ adapter }) - -async function main() { - let user = await prisma.user.findUnique({ where: { username } }) - - if (!user) { - const password_hash = await bcrypt.hash(password, 12) - user = await prisma.user.create({ data: { username, password_hash } }) - console.log(`Gebruiker '${username}' aangemaakt.`) - } else { - console.log(`Gebruiker '${username}' gevonden — rol wordt geüpgraded.`) - } - - await prisma.userRole.upsert({ - where: { user_id_role: { user_id: user.id, role: 'ADMIN' } }, - create: { user_id: user.id, role: 'ADMIN' }, - update: {}, - }) - console.log(`Admin-rol toegewezen aan '${username}'.`) -} - -main() - .catch((err) => { - console.error('Fout:', err.message) - process.exit(1) - }) - .finally(() => prisma.$disconnect()) diff --git a/scripts/generate-docs-index.mjs b/scripts/generate-docs-index.mjs index 1f9e1ee..55b9711 100644 --- a/scripts/generate-docs-index.mjs +++ b/scripts/generate-docs-index.mjs @@ -1,7 +1,6 @@ #!/usr/bin/env node // Generate docs/INDEX.md from the front-matter and headings of every -// git-tracked .md file under docs/. No external npm dependencies — shells -// out to `git ls-files` so untracked scratch files never pollute the index. +// .md file under docs/. Pure Node 20 — no external dependencies. // // Usage: `npm run docs:index` (or `node scripts/generate-docs-index.mjs`). // @@ -9,10 +8,9 @@ // output (apart from the generation date in the header), so the script // is safe to run repeatedly and in pre-commit hooks. -import { readFile, writeFile } from 'node:fs/promises'; +import { readdir, readFile, writeFile } from 'node:fs/promises'; import { join, relative, basename, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { execFileSync } from 'node:child_process'; const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url)); const REPO_ROOT = join(SCRIPT_DIR, '..'); @@ -27,22 +25,20 @@ const EXCLUDE_PATTERNS = [ /^docs\/adr\/README\.md$/, /\/_[^/]+\.md$/, /^docs\/INDEX\.md$/, - /^docs\/old\//, ]; -// List git-tracked (and staged) .md files under docs/ via `git ls-files` -// rather than walking the filesystem — this keeps untracked scratch files -// out of the index. Paths return repo-root-relative and forward-slashed, -// and the call works correctly inside git worktrees. -function trackedDocsFiles() { - const out = execFileSync('git', ['ls-files', '-z', 'docs'], { - cwd: REPO_ROOT, - encoding: 'utf8', - }); - return out - .split('\0') - .filter((p) => p.endsWith('.md')) - .map((p) => join(REPO_ROOT, p)); +async function walk(dir) { + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + for (const e of entries) { + const full = join(dir, e.name); + if (e.isDirectory()) { + files.push(...(await walk(full))); + } else if (e.isFile() && e.name.endsWith('.md')) { + files.push(full); + } + } + return files; } // Minimal YAML front-matter parser. Front-matter in this repo is restricted @@ -116,7 +112,7 @@ function escapePipe(s) { } async function main() { - const files = trackedDocsFiles(); + const files = await walk(DOCS_DIR); const docs = []; for (const full of files) { @@ -125,7 +121,6 @@ async function main() { const content = await readFile(full, 'utf8'); const { data, body } = parseFrontMatter(content); - if (data.archived === 'true') continue; const title = data.title || extractFirstH1(body) || basename(full, '.md'); diff --git a/scripts/insert-milestone.ts b/scripts/insert-milestone.ts index 8bc371d..97a0457 100644 --- a/scripts/insert-milestone.ts +++ b/scripts/insert-milestone.ts @@ -163,19 +163,9 @@ async function main() { // Tasks: alleen als de story op dit moment 0 tasks had if (!hadTasks && s.tasks.length > 0) { if (!args.dryRun) { - const allTasks = await prisma.task.findMany({ - where: { product_id: product.id }, - select: { code: true }, - }) - const maxN = allTasks.reduce((m, t) => { - const match = /^T-(\d+)$/.exec(t.code) - return match ? Math.max(m, Number(match[1])) : m - }, 0) await prisma.task.createMany({ - data: s.tasks.map((t, i) => ({ + data: s.tasks.map((t) => ({ story_id: storyId, - product_id: product.id, - code: `T-${maxN + i + 1}`, title: t.title, description: t.description || null, priority: ms.priority, diff --git a/scripts/sync-model-prices.ts b/scripts/sync-model-prices.ts deleted file mode 100644 index dbe296a..0000000 --- a/scripts/sync-model-prices.ts +++ /dev/null @@ -1,272 +0,0 @@ -// Wekelijks handmatig sync-script voor de model_prices tabel. -// -// Gebruik: -// npm run db:sync-model-prices # echt synchroniseren -// npm run db:sync-model-prices -- --dry-run # tonen, niets schrijven -// -// Anthropic biedt geen prijs-API. /v1/models levert alleen modellijst + -// metadata. De prijzen onderhouden we daarom in PRICE_TABLE hieronder. -// De API-call dient om de modellijst te valideren en nieuwe modellen op te -// merken (warning) zodat we weten dat PRICE_TABLE een update nodig heeft. -// -// Plan: docs/plans/sync-model-prices.md - -import { PrismaClient } from '@prisma/client' -import * as dotenv from 'dotenv' -import * as path from 'path' -import { Pool } from 'pg' -import { PrismaPg } from '@prisma/adapter-pg' - -const root = path.resolve(__dirname, '..') -dotenv.config({ path: path.join(root, '.env.local'), override: true }) -dotenv.config({ path: path.join(root, '.env') }) - -const ANTHROPIC_API_BASE = 'https://api.anthropic.com' -const ANTHROPIC_API_VERSION = '2023-06-01' - -// Prijzen per 1M tokens in USD. Bij elke prijswijziging hier updaten. -// Bron: https://platform.claude.com/docs/en/about-claude/pricing -const PRICE_TABLE: Record<string, { input: number; output: number }> = { - 'claude-opus-4-7': { input: 15.0, output: 75.0 }, - 'claude-sonnet-4-6': { input: 3.0, output: 15.0 }, - 'claude-haiku-4-5-20251001': { input: 0.8, output: 4.0 }, -} - -// Cache-tier multipliers t.o.v. input-prijs (Anthropic standaarden, mei 2026): -// cache hit (read) = 0.1× input -// cache write 5-minute = 1.25× input (dit veld in onze DB) -// cache write 1-hour = 2.0× input (niet apart opgeslagen) -const CACHE_READ_RATIO = 0.1 -const CACHE_WRITE_RATIO = 1.25 - -// Alleen Claude 4.x synchroniseren — oudere 3.x worden overgeslagen. -const CLAUDE_4X_REGEX = /^claude-(opus|sonnet|haiku)-4/ - -interface AnthropicModel { - id: string - type: string - display_name: string - created_at: string -} - -interface AnthropicModelsResponse { - data: AnthropicModel[] - has_more: boolean - last_id: string | null -} - -interface Args { - dryRun: boolean -} - -function parseArgs(argv: string[]): Args { - let dryRun = false - for (const a of argv) { - if (a === '--dry-run') dryRun = true - else if (a.startsWith('--')) throw new Error(`Unknown flag: ${a}`) - else throw new Error(`Unexpected argument: ${a}`) - } - return { dryRun } -} - -async function sleep(ms: number): Promise<void> { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -async function fetchModelsPage( - apiKey: string, - afterId: string | null, -): Promise<AnthropicModelsResponse> { - const url = new URL(`${ANTHROPIC_API_BASE}/v1/models`) - url.searchParams.set('limit', '1000') - if (afterId) url.searchParams.set('after_id', afterId) - - const headers = { - 'x-api-key': apiKey, - 'anthropic-version': ANTHROPIC_API_VERSION, - } - - let lastError: unknown = null - for (let attempt = 0; attempt < 2; attempt++) { - try { - const res = await fetch(url, { headers }) - if (res.status === 401) { - throw new Error( - 'Anthropic API gaf 401 Unauthorized. Controleer ANTHROPIC_API_KEY in .env.local.', - ) - } - if (res.status >= 500) { - lastError = new Error(`Anthropic API gaf ${res.status} ${res.statusText}`) - if (attempt === 0) { - console.warn(` ⚠ ${(lastError as Error).message} — retry over 2s...`) - await sleep(2000) - continue - } - throw lastError - } - if (!res.ok) { - const body = await res.text() - throw new Error(`Anthropic API gaf ${res.status} ${res.statusText}: ${body.slice(0, 300)}`) - } - return (await res.json()) as AnthropicModelsResponse - } catch (err) { - if (err instanceof TypeError) { - // Network/DNS error - lastError = err - if (attempt === 0) { - console.warn(` ⚠ Netwerkfout: ${err.message} — retry over 2s...`) - await sleep(2000) - continue - } - } - throw err - } - } - throw lastError ?? new Error('Unbekende fout bij ophalen /v1/models') -} - -async function fetchAllClaude4xModels(apiKey: string): Promise<AnthropicModel[]> { - const all: AnthropicModel[] = [] - let afterId: string | null = null - let totalReceived = 0 - - while (true) { - const page = await fetchModelsPage(apiKey, afterId) - totalReceived += page.data.length - for (const m of page.data) { - if (CLAUDE_4X_REGEX.test(m.id)) all.push(m) - } - if (!page.has_more || !page.last_id) break - afterId = page.last_id - } - - console.log(` → ${totalReceived} models received, ${all.length} match Claude 4.x filter`) - return all -} - -interface SyncResult { - created: number - updated: number - unchanged: number - skipped: number -} - -async function syncModel( - prisma: PrismaClient, - modelId: string, - price: { input: number; output: number }, - dryRun: boolean, -): Promise<'created' | 'updated' | 'unchanged'> { - const cacheRead = round6(price.input * CACHE_READ_RATIO) - const cacheWrite = round6(price.input * CACHE_WRITE_RATIO) - - const data = { - model_id: modelId, - input_price_per_1m: price.input, - output_price_per_1m: price.output, - cache_read_price_per_1m: cacheRead, - cache_write_price_per_1m: cacheWrite, - } - - const existing = await prisma.modelPrice.findUnique({ - where: { model_id: modelId }, - select: { - input_price_per_1m: true, - output_price_per_1m: true, - cache_read_price_per_1m: true, - cache_write_price_per_1m: true, - }, - }) - - if (!existing) { - if (!dryRun) { - await prisma.modelPrice.create({ data }) - } - return 'created' - } - - const same = - Number(existing.input_price_per_1m) === data.input_price_per_1m && - Number(existing.output_price_per_1m) === data.output_price_per_1m && - Number(existing.cache_read_price_per_1m) === data.cache_read_price_per_1m && - Number(existing.cache_write_price_per_1m) === data.cache_write_price_per_1m - - if (same) return 'unchanged' - - if (!dryRun) { - await prisma.modelPrice.update({ where: { model_id: modelId }, data }) - } - return 'updated' -} - -function round6(n: number): number { - return Math.round(n * 1_000_000) / 1_000_000 -} - -async function main(): Promise<void> { - const args = parseArgs(process.argv.slice(2)) - - const apiKey = process.env.ANTHROPIC_API_KEY - if (!apiKey) { - throw new Error( - 'ANTHROPIC_API_KEY is not set. Voeg toe aan .env.local — zie console.anthropic.com → API Keys.', - ) - } - - const dbUrl = process.env.DATABASE_URL - if (!dbUrl) throw new Error('DATABASE_URL is not set. Check .env.local') - - const pool = new Pool({ connectionString: dbUrl }) - const adapter = new PrismaPg(pool) - const prisma = new PrismaClient({ adapter }) - - try { - console.log(`Fetching /v1/models from Anthropic API...${args.dryRun ? ' [DRY RUN]' : ''}`) - const models = await fetchAllClaude4xModels(apiKey) - - if (models.length === 0) { - console.error(' ✗ Geen Claude 4.x modellen ontvangen — aborting.') - process.exit(1) - } - - console.log('\nSyncing prices:') - const result: SyncResult = { created: 0, updated: 0, unchanged: 0, skipped: 0 } - - const apiIds = new Set<string>() - for (const m of models) { - apiIds.add(m.id) - const price = PRICE_TABLE[m.id] - if (!price) { - console.warn( - ` ⚠ ${m.id.padEnd(36)} (geen prijs in PRICE_TABLE — voeg toe aan scripts/sync-model-prices.ts)`, - ) - result.skipped++ - continue - } - const action = await syncModel(prisma, m.id, price, args.dryRun) - const tag = action === 'created' ? '✓' : action === 'updated' ? '✓' : '·' - console.log(` ${tag} ${m.id.padEnd(36)} (${action})`) - result[action]++ - } - - // Detect models in DB that no longer appear in the API (don't delete!). - const dbModels = await prisma.modelPrice.findMany({ select: { model_id: true } }) - const orphaned = dbModels.filter((m) => !apiIds.has(m.model_id)) - if (orphaned.length > 0) { - console.log('\nDB-modellen die niet (meer) in /v1/models staan (worden NIET verwijderd):') - for (const o of orphaned) console.log(` · ${o.model_id}`) - } - - console.log( - `\nResult: ${result.created} created, ${result.updated} updated, ${result.unchanged} unchanged, ${result.skipped} skipped${args.dryRun ? ' [DRY RUN — niets geschreven]' : ''}`, - ) - } finally { - await prisma.$disconnect() - await pool.end() - } -} - -main().catch((err) => { - console.error('\nsync-model-prices failed:', err.message) - process.exit(1) -}) diff --git a/scripts/verify-review-plan-files.sh b/scripts/verify-review-plan-files.sh deleted file mode 100644 index b86b571..0000000 --- a/scripts/verify-review-plan-files.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/bin/bash -# Verification script for IDEA_REVIEW_PLAN implementation - File checks only - -echo "🔍 IDEA_REVIEW_PLAN Implementation Verification (Files Only)" -echo "============================================================" -echo "" - -PASSED=0 -FAILED=0 - -# Function to check if file exists -check_file() { - local name=$1 - local path=$2 - - if [ -f "$path" ]; then - local size=$(wc -c < "$path") - echo "✅ $name" - echo " Path: $path" - echo " Size: $size bytes" - ((PASSED++)) - else - echo "❌ $name" - echo " Path: $path" - echo " Missing!" - ((FAILED++)) - fi - echo "" -} - -# Function to check if text appears in file -check_text_in_file() { - local name=$1 - local path=$2 - local text=$3 - - if [ -f "$path" ] && grep -q "$text" "$path"; then - echo "✅ $name" - echo " Found in: $path" - ((PASSED++)) - else - echo "❌ $name" - echo " Not found in: $path" - ((FAILED++)) - fi - echo "" -} - -# Checks - -# 1. Prompt files -check_file "Review Plan Prompt (Main)" "lib/idea-prompts/review-plan-job.md" -check_file "Review Plan Prompt (MCP)" "../scrum4me-mcp/src/prompts/idea/review-plan.md" - -# 2. Components -check_file "ReviewLogViewer Component" "components/ideas/review-log-viewer.tsx" - -# 3. Server Actions -check_file "Idea Actions" "actions/ideas.ts" -check_text_in_file "startReviewPlanJobAction in Idea Actions" "actions/ideas.ts" "startReviewPlanJobAction" - -# 4. MCP Tools -check_file "MCP Update Plan Reviewed Tool" "../scrum4me-mcp/src/tools/update-idea-plan-reviewed.ts" - -# 5. Kind Prompts Registration -check_text_in_file "IDEA_REVIEW_PLAN in kind-prompts.ts" "../scrum4me-mcp/src/lib/kind-prompts.ts" "IDEA_REVIEW_PLAN" - -# 6. Wait-for-job Discriminator -check_text_in_file "IDEA_REVIEW_PLAN in wait-for-job.ts" "../scrum4me-mcp/src/tools/wait-for-job.ts" "IDEA_REVIEW_PLAN" - -# 7. Documentation -check_file "Review Plan Job Runbook" "docs/runbooks/review-plan-job.md" -check_file "Phase 6 Test Plan" "docs/implementation-complete/PHASE6-END-TO-END-TEST-PLAN.md" -check_file "Implementation Summary" "docs/implementation-complete/IDEA_REVIEW_PLAN-implementation-summary.md" - -# 8. Tests -check_file "Review Plan Job Tests" "__tests__/review-plan-job.test.ts" - -# 9. Migrations -check_file "Migration SQL" "prisma/migrations/20260514000000_add_review_plan_support/migration.sql" - -# Summary -echo "============================================================" -echo "Summary: $PASSED passed, $FAILED failed" -echo "" - -if [ $FAILED -eq 0 ]; then - echo "✅ All file checks passed! Implementation is complete." - exit 0 -else - echo "❌ Some files are missing. See above for details." - exit 1 -fi diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts deleted file mode 100644 index 61dfa7e..0000000 --- a/sentry.edge.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -// PBI/v1-readiness item 2: Sentry error-monitoring (edge runtime). -// Edge wordt voornamelijk gebruikt door middleware (proxy.ts). - -import * as Sentry from '@sentry/nextjs' - -Sentry.init({ - dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, - tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0, - sendDefaultPii: false, - enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN, - debug: false, -}) diff --git a/sentry.server.config.ts b/sentry.server.config.ts deleted file mode 100644 index e029862..0000000 --- a/sentry.server.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -// PBI/v1-readiness item 2: Sentry error-monitoring (server runtime). -// Geen DSN gezet → SDK is no-op (geen network, geen overhead in dev). - -import * as Sentry from '@sentry/nextjs' - -Sentry.init({ - dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, - - // Errors altijd 100%; performance-traces conservatief. - tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0, - - // Geen PII in events — usernames + product-ids zijn voldoende identificatie. - sendDefaultPii: false, - - // Quiet in dev als DSN ontbreekt; verbose-logs uit in productie. - enabled: !!process.env.NEXT_PUBLIC_SENTRY_DSN, - debug: false, -}) diff --git a/stores/backlog-store.ts b/stores/backlog-store.ts new file mode 100644 index 0000000..16a584b --- /dev/null +++ b/stores/backlog-store.ts @@ -0,0 +1,142 @@ +import { create } from 'zustand' +import type { PbiStatusApi } from '@/lib/task-status' + +export interface BacklogPbi { + id: string + code: string | null + title: string + priority: number + description?: string | null + created_at: Date + status: PbiStatusApi +} + +export interface BacklogStory { + id: string + code: string | null + title: string + description: string | null + acceptance_criteria: string | null + priority: number + status: string + pbi_id: string + created_at: Date +} + +export interface BacklogTask { + id: string + title: string + description: string | null + priority: number + status: string + sort_order: number + story_id: string + created_at: Date +} + +type Entity = 'pbi' | 'story' | 'task' +type Op = 'I' | 'U' | 'D' + +interface InitialData { + pbis: BacklogPbi[] + storiesByPbi: Record<string, BacklogStory[]> + tasksByStory: Record<string, BacklogTask[]> +} + +interface BacklogStore extends InitialData { + setInitialData: (data: InitialData) => void + applyChange: (entity: Entity, op: Op, data: Record<string, unknown>) => void +} + +export const useBacklogStore = create<BacklogStore>((set) => ({ + pbis: [], + storiesByPbi: {}, + tasksByStory: {}, + + setInitialData: (data) => set(data), + + applyChange: (entity, op, data) => + set((state) => { + if (entity === 'pbi') { + const id = data.id as string + if (op === 'D') { + return { pbis: state.pbis.filter((p) => p.id !== id) } + } + if (op === 'U') { + return { + pbis: state.pbis.map((p) => + p.id === id ? { ...p, ...(data as Partial<BacklogPbi>) } : p + ), + } + } + // I — idempotent: skip if already present (optimistic update may have arrived first) + if (state.pbis.some((p) => p.id === id)) return {} + return { pbis: [...state.pbis, data as unknown as BacklogPbi] } + } + + if (entity === 'story') { + const id = data.id as string + if (op === 'D') { + const storiesByPbi = { ...state.storiesByPbi } + for (const pbiId of Object.keys(storiesByPbi)) { + storiesByPbi[pbiId] = storiesByPbi[pbiId].filter((s) => s.id !== id) + } + return { storiesByPbi } + } + if (op === 'U') { + const storiesByPbi = { ...state.storiesByPbi } + for (const pbiId of Object.keys(storiesByPbi)) { + const idx = storiesByPbi[pbiId].findIndex((s) => s.id === id) + if (idx !== -1) { + storiesByPbi[pbiId] = storiesByPbi[pbiId].map((s) => + s.id === id ? { ...s, ...(data as Partial<BacklogStory>) } : s + ) + break + } + } + return { storiesByPbi } + } + // I — idempotent: skip if already present + const pbiId = data.pbi_id as string + if ((state.storiesByPbi[pbiId] ?? []).some((s) => s.id === id)) return {} + return { + storiesByPbi: { + ...state.storiesByPbi, + [pbiId]: [...(state.storiesByPbi[pbiId] ?? []), data as unknown as BacklogStory], + }, + } + } + + // task + const id = data.id as string + if (op === 'D') { + const tasksByStory = { ...state.tasksByStory } + for (const storyId of Object.keys(tasksByStory)) { + tasksByStory[storyId] = tasksByStory[storyId].filter((t) => t.id !== id) + } + return { tasksByStory } + } + if (op === 'U') { + const tasksByStory = { ...state.tasksByStory } + for (const storyId of Object.keys(tasksByStory)) { + const idx = tasksByStory[storyId].findIndex((t) => t.id === id) + if (idx !== -1) { + tasksByStory[storyId] = tasksByStory[storyId].map((t) => + t.id === id ? { ...t, ...(data as Partial<BacklogTask>) } : t + ) + break + } + } + return { tasksByStory } + } + // I — idempotent: skip if already present + const storyId = data.story_id as string + if ((state.tasksByStory[storyId] ?? []).some((t) => t.id === id)) return {} + return { + tasksByStory: { + ...state.tasksByStory, + [storyId]: [...(state.tasksByStory[storyId] ?? []), data as unknown as BacklogTask], + }, + } + }), +})) diff --git a/stores/idea-store.ts b/stores/idea-store.ts deleted file mode 100644 index 0b95f16..0000000 --- a/stores/idea-store.ts +++ /dev/null @@ -1,182 +0,0 @@ -// M12: Zustand-store voor idee-gerelateerde realtime state. -// -// Wordt gevoed door `use-notifications-realtime.ts` (zelfde SSE-stream als de -// notifications-bell — geen tweede EventSource nodig). Houdt: -// - jobByIdea: live status van de actieve grill/make-plan-job per idee -// - ideaStatuses: optimistische idea-status-updates (uit job-events) -// - openQuestionsByIdea: open vragen voor de Timeline-tab (M12 ST-1199) -// -// connectedWorkers wordt NIET gedupliceerd — UI-componenten lezen die direct -// via `useSoloStore(s => s.connectedWorkers)` (zie M12 grill-keuze 16). - -import { create } from 'zustand' - -import type { ClaudeJobStatusApi } from '@/lib/job-status' -import type { IdeaStatusApi } from '@/lib/idea-status' - -export type IdeaJobKind = 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' - -export interface IdeaJobState { - job_id: string - idea_id: string - kind: IdeaJobKind - status: ClaudeJobStatusApi - error?: string - started_at?: string | null - finished_at?: string | null -} - -export interface IdeaQuestion { - id: string - idea_id: string - question: string - options: string[] | null - status: 'open' | 'answered' | 'cancelled' | 'expired' - answer?: string | null - created_at: string - expires_at: string -} - -export type IdeaJobEvent = - | { - type: 'claude_job_enqueued' - job_id: string - idea_id: string - user_id: string - product_id?: string | null - kind: IdeaJobKind - status: 'queued' - } - | { - type: 'claude_job_status' - job_id: string - idea_id: string - user_id: string - product_id?: string | null - kind: IdeaJobKind - status: ClaudeJobStatusApi - error?: string - } - -export type IdeaQuestionEvent = { - op: 'I' | 'U' - entity: 'question' - id: string - product_id: string - story_id: null - idea_id: string - task_id?: string | null - assignee_id?: string | null - status: 'open' | 'answered' | 'cancelled' | 'expired' -} - -interface IdeaStore { - jobByIdea: Record<string, IdeaJobState | undefined> - ideaStatuses: Record<string, IdeaStatusApi | undefined> - openQuestionsByIdea: Record<string, IdeaQuestion[]> - - // Bulk-init bij mount van een page (server-component → client hydration). - initJobs: (jobs: IdeaJobState[]) => void - initStatuses: (statuses: Record<string, IdeaStatusApi>) => void - initQuestions: (ideaId: string, questions: IdeaQuestion[]) => void - - // Realtime event handlers — aangeroepen door use-notifications-realtime. - handleIdeaJobEvent: (event: IdeaJobEvent) => void - handleIdeaQuestionEvent: (event: IdeaQuestionEvent) => void - - // Optimistic updates vanuit server-actions in client-components. - setIdeaStatus: (ideaId: string, status: IdeaStatusApi) => void - setJobStatus: (job: IdeaJobState) => void - - // Cleanup bij navigeren weg van een detail-pagina. - clearForIdea: (ideaId: string) => void -} - -// Mapping van een job-status (uit pg_notify event) naar een afgeleide -// idea-status. De server is de bron-van-waarheid; dit is alleen optimistic UI. -function deriveIdeaStatusFromJob( - kind: IdeaJobKind, - status: ClaudeJobStatusApi, -): IdeaStatusApi | null { - if (status === 'queued' || status === 'claimed' || status === 'running') { - return kind === 'IDEA_GRILL' ? 'grilling' : 'planning' - } - if (status === 'failed') { - return kind === 'IDEA_GRILL' ? 'grill_failed' : 'plan_failed' - } - // 'done' wordt door update_idea_*_md gezet (GRILLED resp. PLAN_READY) — - // daar is geen kind-onafhankelijke afleiding voor; lees de DB-update via - // re-fetch / page-revalidate. We laten de status hier ongemoeid. - return null -} - -export const useIdeaStore = create<IdeaStore>((set) => ({ - jobByIdea: {}, - ideaStatuses: {}, - openQuestionsByIdea: {}, - - initJobs: (jobs) => - set(() => { - const jobByIdea: Record<string, IdeaJobState> = {} - for (const j of jobs) jobByIdea[j.idea_id] = j - return { jobByIdea } - }), - - initStatuses: (statuses) => set({ ideaStatuses: { ...statuses } }), - - initQuestions: (ideaId, questions) => - set((s) => ({ - openQuestionsByIdea: { ...s.openQuestionsByIdea, [ideaId]: questions }, - })), - - handleIdeaJobEvent: (event) => - set((s) => { - const jobState: IdeaJobState = { - job_id: event.job_id, - idea_id: event.idea_id, - kind: event.kind, - status: event.status as ClaudeJobStatusApi, - error: 'error' in event ? event.error : undefined, - } - const derived = deriveIdeaStatusFromJob(event.kind, event.status as ClaudeJobStatusApi) - return { - jobByIdea: { ...s.jobByIdea, [event.idea_id]: jobState }, - ideaStatuses: - derived !== null - ? { ...s.ideaStatuses, [event.idea_id]: derived } - : s.ideaStatuses, - } - }), - - handleIdeaQuestionEvent: (event) => - set((s) => { - const list = s.openQuestionsByIdea[event.idea_id] ?? [] - // Bij open/insert: we hebben alleen status + id; de UI fetcht de - // detail bij re-render. Voor v1 markeren we 'm in de lijst zodat de - // count niet uit sync raakt. - let next = list - if (event.status !== 'open') { - next = list.filter((q) => q.id !== event.id) - } - return { - openQuestionsByIdea: { ...s.openQuestionsByIdea, [event.idea_id]: next }, - } - }), - - setIdeaStatus: (ideaId, status) => - set((s) => ({ ideaStatuses: { ...s.ideaStatuses, [ideaId]: status } })), - - setJobStatus: (job) => - set((s) => ({ jobByIdea: { ...s.jobByIdea, [job.idea_id]: job } })), - - clearForIdea: (ideaId) => - set((s) => { - const { [ideaId]: _j, ...jobByIdea } = s.jobByIdea - const { [ideaId]: _s, ...ideaStatuses } = s.ideaStatuses - const { [ideaId]: _q, ...openQuestionsByIdea } = s.openQuestionsByIdea - void _j - void _s - void _q - return { jobByIdea, ideaStatuses, openQuestionsByIdea } - }), -})) diff --git a/stores/jobs-store.ts b/stores/jobs-store.ts deleted file mode 100644 index c7b59b3..0000000 --- a/stores/jobs-store.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { create } from 'zustand' -import { immer } from 'zustand/middleware/immer' -import type { JobWithRelations } from '@/actions/jobs-page' - -type JobsState = { - activeJobs: JobWithRelations[] - doneJobs: JobWithRelations[] - selectedJobId: string | null -} - -type JobsActions = { - initJobs(active: JobWithRelations[], done: JobWithRelations[]): void - setSelectedJobId(id: string | null): void - upsertJob(job: Partial<JobWithRelations> & { id: string; status: string }): void -} - -export const useJobsStore = create<JobsState & JobsActions>()( - immer((set) => ({ - activeJobs: [], - doneJobs: [], - selectedJobId: null, - - initJobs(active, done) { - set((state) => { - state.activeJobs = active - state.doneJobs = done - }) - }, - - setSelectedJobId(id) { - set((state) => { - state.selectedJobId = id - }) - }, - - upsertJob(job) { - set((state) => { - const isDone = job.status.toUpperCase() === 'DONE' - - if (isDone) { - state.activeJobs = state.activeJobs.filter(j => j.id !== job.id) - if (!state.doneJobs.find(j => j.id === job.id)) { - state.doneJobs.unshift(job as JobWithRelations) - if (state.doneJobs.length > 100) { - state.doneJobs = state.doneJobs.slice(0, 100) - } - } - } else { - const idx = state.activeJobs.findIndex(j => j.id === job.id) - if (idx !== -1) { - Object.assign(state.activeJobs[idx], job) - } else { - state.activeJobs.unshift(job as JobWithRelations) - } - } - }) - }, - })) -) diff --git a/stores/notifications-store.ts b/stores/notifications-store.ts index 10fab9b..7ad4c8a 100644 --- a/stores/notifications-store.ts +++ b/stores/notifications-store.ts @@ -12,35 +12,19 @@ import { create } from 'zustand' -// Story-questions en idea-questions delen het bel-paneel sinds M12 hotfix. -// `kind` discrimineert; UI rendert label + link op basis daarvan. -export type NotificationQuestion = - | { - kind: 'story' - id: string - product_id: string - story_id: string - task_id: string | null - story_code: string | null - story_title: string - assignee_id: string | null - question: string - options: string[] | null - created_at: string - expires_at: string - } - | { - kind: 'idea' - id: string - product_id: string - idea_id: string - idea_code: string - idea_title: string - question: string - options: string[] | null - created_at: string - expires_at: string - } +export interface NotificationQuestion { + id: string + product_id: string + story_id: string + task_id: string | null + story_code: string | null + story_title: string + assignee_id: string | null + question: string + options: string[] | null + created_at: string + expires_at: string +} interface NotificationsState { questions: NotificationQuestion[] @@ -72,11 +56,6 @@ export const useNotificationsStore = create<NotificationsState>((set, get) => ({ forYouCount: (userId) => { if (!userId) return 0 - return get().questions.filter((q) => { - // story-questions: assignee = jij; idea-questions: altijd voor jou - // (idee is strikt user_id-only en alleen jij ziet 'm). - if (q.kind === 'story') return q.assignee_id === userId - return true - }).length + return get().questions.filter((q) => q.assignee_id === userId).length }, })) diff --git a/stores/planner-store.ts b/stores/planner-store.ts new file mode 100644 index 0000000..e01b5f0 --- /dev/null +++ b/stores/planner-store.ts @@ -0,0 +1,46 @@ +import { create } from 'zustand' + +interface PlannerStore { + // Order maps: productId → pbiId[] + pbiOrder: Record<string, string[]> + // Order maps: pbiId → storyId[] + storyOrder: Record<string, string[]> + // Priority maps: pbiId → priority + pbiPriority: Record<string, number> + + initPbis: (productId: string, ids: string[]) => void + reorderPbis: (productId: string, ids: string[]) => void + rollbackPbis: (productId: string, ids: string[]) => void + updatePbiPriority: (pbiId: string, priority: number) => void + + initStories: (pbiId: string, ids: string[]) => void + reorderStories: (pbiId: string, ids: string[]) => void + rollbackStories: (pbiId: string, ids: string[]) => void +} + +export const usePlannerStore = create<PlannerStore>((set) => ({ + pbiOrder: {}, + storyOrder: {}, + pbiPriority: {}, + + initPbis: (productId, ids) => + set((state) => ({ pbiOrder: { ...state.pbiOrder, [productId]: ids } })), + + reorderPbis: (productId, ids) => + set((state) => ({ pbiOrder: { ...state.pbiOrder, [productId]: ids } })), + + rollbackPbis: (productId, ids) => + set((state) => ({ pbiOrder: { ...state.pbiOrder, [productId]: ids } })), + + updatePbiPriority: (pbiId, priority) => + set((state) => ({ pbiPriority: { ...state.pbiPriority, [pbiId]: priority } })), + + initStories: (pbiId, ids) => + set((state) => ({ storyOrder: { ...state.storyOrder, [pbiId]: ids } })), + + reorderStories: (pbiId, ids) => + set((state) => ({ storyOrder: { ...state.storyOrder, [pbiId]: ids } })), + + rollbackStories: (pbiId, ids) => + set((state) => ({ storyOrder: { ...state.storyOrder, [pbiId]: ids } })), +})) diff --git a/stores/product-store.ts b/stores/product-store.ts new file mode 100644 index 0000000..ad48ea4 --- /dev/null +++ b/stores/product-store.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand' + +interface ProductStore { + currentProduct: { id: string; name: string } | null + setCurrentProduct: (id: string, name: string) => void + clearCurrentProduct: () => void +} + +export const useProductStore = create<ProductStore>((set) => ({ + currentProduct: null, + setCurrentProduct: (id, name) => set({ currentProduct: { id, name } }), + clearCurrentProduct: () => set({ currentProduct: null }), +})) diff --git a/stores/product-workspace/restore.ts b/stores/product-workspace/restore.ts deleted file mode 100644 index 51c03b5..0000000 --- a/stores/product-workspace/restore.ts +++ /dev/null @@ -1,110 +0,0 @@ -const STORAGE_KEY = 'product-workspace-hints' - -interface PerProductHint { - lastActivePbiId?: string | null - lastActiveStoryId?: string | null - lastActiveTaskId?: string | null -} - -export interface WorkspaceHints { - lastActiveProductId: string | null - perProduct: Record<string, PerProductHint> -} - -const EMPTY_HINTS: WorkspaceHints = { - lastActiveProductId: null, - perProduct: {}, -} - -function safeStorage(): Storage | null { - if (typeof globalThis === 'undefined') return null - try { - const ls = (globalThis as { localStorage?: Storage }).localStorage - return ls ?? null - } catch { - return null - } -} - -export function readHints(): WorkspaceHints { - const storage = safeStorage() - if (!storage) return { ...EMPTY_HINTS, perProduct: {} } - try { - const raw = storage.getItem(STORAGE_KEY) - if (!raw) return { ...EMPTY_HINTS, perProduct: {} } - const parsed = JSON.parse(raw) as Partial<WorkspaceHints> | null - if (!parsed || typeof parsed !== 'object') { - return { ...EMPTY_HINTS, perProduct: {} } - } - return { - lastActiveProductId: parsed.lastActiveProductId ?? null, - perProduct: - parsed.perProduct && typeof parsed.perProduct === 'object' - ? parsed.perProduct - : {}, - } - } catch { - return { ...EMPTY_HINTS, perProduct: {} } - } -} - -function writeHints(hints: WorkspaceHints): void { - const storage = safeStorage() - if (!storage) return - try { - storage.setItem(STORAGE_KEY, JSON.stringify(hints)) - } catch { - // ignore quota or serialization errors - } -} - -export function writeProductHint(productId: string | null): void { - const hints = readHints() - hints.lastActiveProductId = productId - writeHints(hints) -} - -function ensurePerProduct(hints: WorkspaceHints, productId: string): PerProductHint { - if (!hints.perProduct[productId]) { - hints.perProduct[productId] = {} - } - return hints.perProduct[productId] -} - -export function writePbiHint(productId: string, pbiId: string | null): void { - const hints = readHints() - const entry = ensurePerProduct(hints, productId) - entry.lastActivePbiId = pbiId - if (pbiId === null) { - entry.lastActiveStoryId = null - entry.lastActiveTaskId = null - } - writeHints(hints) -} - -export function writeStoryHint(productId: string, storyId: string | null): void { - const hints = readHints() - const entry = ensurePerProduct(hints, productId) - entry.lastActiveStoryId = storyId - if (storyId === null) { - entry.lastActiveTaskId = null - } - writeHints(hints) -} - -export function writeTaskHint(productId: string, taskId: string | null): void { - const hints = readHints() - const entry = ensurePerProduct(hints, productId) - entry.lastActiveTaskId = taskId - writeHints(hints) -} - -export function clearHints(): void { - const storage = safeStorage() - if (!storage) return - try { - storage.removeItem(STORAGE_KEY) - } catch { - // ignore - } -} diff --git a/stores/product-workspace/screen-state.ts b/stores/product-workspace/screen-state.ts deleted file mode 100644 index c32e58b..0000000 --- a/stores/product-workspace/screen-state.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Expliciete schermstaat voor de Product Backlog page. -// -// Consolideert de vandaag verspreide schermstaat-afleiding (page.tsx, -// sprint-switcher.tsx, new-sprint-trigger.tsx, save-sprint-button.tsx) tot één -// pure, testbare functie. Zie docs/architecture/product-backlog-workflow.md, -// sectie "To-be: expliciete state machine". -// -// PRODUCT_NOT_ACTIVE en DEMO_MODE blijven bewust BUITEN ScreenState — het zijn -// cross-cutting gates, geen knopen in de state machine. - -export type ScreenState = - | { kind: 'NO_SPRINT' } - | { kind: 'DRAFT' } - | { kind: 'ACTIVE'; building: boolean } - | { kind: 'EDITING'; building: boolean } - -export interface ScreenStateInput { - activeSprintItem: { id: string } | null // SSR-prop uit page.tsx - buildingSprintIds: string[] // SSR-prop uit page.tsx - hasPendingDraft: boolean // user-settings store - pendingAdds: string[] // product-workspace store: sprintMembership.pending.adds - pendingRemoves: string[] // product-workspace store: sprintMembership.pending.removes -} - -export function deriveScreenState(i: ScreenStateInput): ScreenState { - if (i.hasPendingDraft) return { kind: 'DRAFT' } // draft wint van alles - if (i.activeSprintItem) { - const building = i.buildingSprintIds.includes(i.activeSprintItem.id) - const dirty = i.pendingAdds.length > 0 || i.pendingRemoves.length > 0 - return dirty ? { kind: 'EDITING', building } : { kind: 'ACTIVE', building } - } - return { kind: 'NO_SPRINT' } -} diff --git a/stores/product-workspace/selectors.ts b/stores/product-workspace/selectors.ts deleted file mode 100644 index 8a40d53..0000000 --- a/stores/product-workspace/selectors.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { ProductWorkspaceStore } from './store' -import type { - BacklogPbi, - BacklogStory, - BacklogTask, - CrossSprintBlock, - TaskDetail, -} from './types' - -export type PbiTriState = 'empty' | 'partial' | 'full' - -// G1: stable EMPTY-references zodat selectors geen nieuwe array per call retourneren. -const EMPTY_PBIS: BacklogPbi[] = [] -const EMPTY_STORIES: BacklogStory[] = [] -const EMPTY_TASKS: (BacklogTask | TaskDetail)[] = [] - -/** - * Lijst-selector. Vereist `useShallow` in componenten (G2) — anders re-rendert - * elke ongerelateerde store-mutatie het component. - */ -export function selectVisiblePbis(s: ProductWorkspaceStore): BacklogPbi[] { - if (s.relations.pbiIds.length === 0) return EMPTY_PBIS - const out: BacklogPbi[] = [] - for (const id of s.relations.pbiIds) { - const pbi = s.entities.pbisById[id] - if (pbi) out.push(pbi) - } - return out.length === 0 ? EMPTY_PBIS : out -} - -/** - * Lijst-selector. Vereist `useShallow` in componenten (G2). - */ -export function selectStoriesForActivePbi(s: ProductWorkspaceStore): BacklogStory[] { - const pbiId = s.context.activePbiId - if (!pbiId) return EMPTY_STORIES - const ids = s.relations.storyIdsByPbi[pbiId] - if (!ids || ids.length === 0) return EMPTY_STORIES - const out: BacklogStory[] = [] - for (const id of ids) { - const story = s.entities.storiesById[id] - if (story) out.push(story) - } - return out.length === 0 ? EMPTY_STORIES : out -} - -/** - * Lijst-selector. Vereist `useShallow` in componenten (G2). - */ -export function selectTasksForActiveStory( - s: ProductWorkspaceStore, -): (BacklogTask | TaskDetail)[] { - const storyId = s.context.activeStoryId - if (!storyId) return EMPTY_TASKS - const ids = s.relations.taskIdsByStory[storyId] - if (!ids || ids.length === 0) return EMPTY_TASKS - const out: (BacklogTask | TaskDetail)[] = [] - for (const id of ids) { - const task = s.entities.tasksById[id] - if (task) out.push(task) - } - return out.length === 0 ? EMPTY_TASKS : out -} - -/** - * Single-value selector. `useShallow` niet vereist — retourneert stable - * entity-reference (zelfde object zolang entity ongewijzigd). - */ -export function selectActivePbi(s: ProductWorkspaceStore): BacklogPbi | null { - const id = s.context.activePbiId - if (!id) return null - return s.entities.pbisById[id] ?? null -} - -/** - * Single-value selector. `useShallow` niet vereist. - */ -export function selectActiveStory(s: ProductWorkspaceStore): BacklogStory | null { - const id = s.context.activeStoryId - if (!id) return null - return s.entities.storiesById[id] ?? null -} - -/** - * Single-value selector. `useShallow` niet vereist. - */ -export function selectActiveTask( - s: ProductWorkspaceStore, -): BacklogTask | TaskDetail | null { - const id = s.context.activeTaskId - if (!id) return null - return s.entities.tasksById[id] ?? null -} - -/** - * Single-value selector voor stories binnen een specifiek PBI (niet per se actief). - */ -export function selectStoriesForPbi( - s: ProductWorkspaceStore, - pbiId: string, -): BacklogStory[] { - const ids = s.relations.storyIdsByPbi[pbiId] - if (!ids || ids.length === 0) return EMPTY_STORIES - const out: BacklogStory[] = [] - for (const id of ids) { - const story = s.entities.storiesById[id] - if (story) out.push(story) - } - return out.length === 0 ? EMPTY_STORIES : out -} - -// PBI-79 / ST-1336 — sprint-membership selectors. -// -// Tri-state PBI-vinkje. Werkt op counts uit het summary-endpoint zolang -// de PBI dichtgeklapt is (relations.storyIdsByPbi leeg). Wanneer stories -// geladen zijn rekenen we ook de pending-buffer mee per-story. -export function selectPbiTriState( - s: ProductWorkspaceStore, - pbiId: string, -): PbiTriState { - const summary = s.sprintMembership.pbiSummary[pbiId] - if (!summary || summary.totalStoryCount === 0) return 'empty' - - const storyIds = s.relations.storyIdsByPbi[pbiId] - let inSprintAfterPending = summary.inActiveSprintStoryCount - - if (storyIds && storyIds.length > 0) { - const idSet = new Set(storyIds) - const adds = s.sprintMembership.pending.adds - const removes = s.sprintMembership.pending.removes - for (const id of adds) if (idSet.has(id)) inSprintAfterPending++ - for (const id of removes) if (idSet.has(id)) inSprintAfterPending-- - } - - if (inSprintAfterPending <= 0) return 'empty' - if (inSprintAfterPending >= summary.totalStoryCount) return 'full' - return 'partial' -} - -/** - * Effectief membership van een story rekening houdend met de pending buffer. - * `activeSprintId` is de gekozen sprint (state B); zonder die context valt de - * selector terug op de DB-waarde. - */ -export function selectStoryEffectiveInSprint( - s: ProductWorkspaceStore, - storyId: string, - activeSprintId: string | null, -): boolean { - const story = s.entities.storiesById[storyId] - const inSprintDb = story?.sprint_id === activeSprintId && activeSprintId !== null - const inAdds = s.sprintMembership.pending.adds.includes(storyId) - const inRemoves = s.sprintMembership.pending.removes.includes(storyId) - if (inAdds) return true - if (inRemoves) return false - return inSprintDb -} - -export function selectStoryIsBlocked( - s: ProductWorkspaceStore, - storyId: string, -): CrossSprintBlock | null { - return s.sprintMembership.crossSprintBlocks[storyId] ?? null -} - -export function selectIsDirty(s: ProductWorkspaceStore): boolean { - return ( - s.sprintMembership.pending.adds.length + - s.sprintMembership.pending.removes.length > - 0 - ) -} - -export function selectPendingCount(s: ProductWorkspaceStore): number { - return ( - s.sprintMembership.pending.adds.length + - s.sprintMembership.pending.removes.length - ) -} diff --git a/stores/product-workspace/store.ts b/stores/product-workspace/store.ts deleted file mode 100644 index 040a808..0000000 --- a/stores/product-workspace/store.ts +++ /dev/null @@ -1,1044 +0,0 @@ -import { create } from 'zustand' -import { immer } from 'zustand/middleware/immer' - -import { - isDetail, - type ActiveProduct, - type BacklogPbi, - type BacklogStory, - type BacklogTask, - type CrossSprintBlock, - type OptimisticMutation, - type PbiSummaryEntry, - type PendingOptimisticMutation, - type ProductBacklogSnapshot, - type ProductRealtimeEvent, - type RealtimeStatus, - type ResyncReason, - type SprintMembershipSlice, - type TaskDetail, -} from './types' -import { - readHints, - writePbiHint, - writeProductHint, - writeStoryHint, - writeTaskHint, -} from './restore' -import { - normalizeBacklogStory, - normalizeBacklogTask, - normalizeProductBacklogSnapshot, - normalizePbiStatusForStore, - normalizeStoryStatusForStore, - normalizeTaskStatusForStore, -} from '@/stores/workspace-status-adapter' - -interface ContextSlice { - activeProduct: ActiveProduct | null - activePbiId: string | null - activeStoryId: string | null - activeTaskId: string | null -} - -interface EntitiesSlice { - pbisById: Record<string, BacklogPbi> - storiesById: Record<string, BacklogStory> - tasksById: Record<string, BacklogTask | TaskDetail> -} - -interface RelationsSlice { - pbiIds: string[] - storyIdsByPbi: Record<string, string[]> - taskIdsByStory: Record<string, string[]> -} - -interface LoadingSlice { - loadedProductId: string | null - loadingProductId: string | null - loadedPbiIds: Record<string, true> - loadedStoryIds: Record<string, true> - loadedTaskIds: Record<string, true> - activeRequestId: string | null -} - -interface SyncSlice { - realtimeStatus: RealtimeStatus - lastEventAt: number | null - lastResyncAt: number | null - resyncReason: ResyncReason | null -} - -interface State { - context: ContextSlice - entities: EntitiesSlice - relations: RelationsSlice - loading: LoadingSlice - sync: SyncSlice - pendingMutations: Record<string, PendingOptimisticMutation> - sprintMembership: SprintMembershipSlice -} - -interface Actions { - hydrateSnapshot(snapshot: ProductBacklogSnapshot): void - - setActiveProduct( - product: ActiveProduct | null, - options?: { load?: boolean; preserveSelection?: boolean }, - ): void - setActivePbi(pbiId: string | null): void - setActiveStory(storyId: string | null): void - setActiveTask(taskId: string | null): void - - ensureProductLoaded(productId: string, requestId?: string): Promise<void> - ensurePbiLoaded(pbiId: string, requestId?: string): Promise<void> - ensureStoryLoaded(storyId: string, requestId?: string): Promise<void> - ensureTaskLoaded(taskId: string, requestId?: string): Promise<void> - - applyRealtimeEvent(event: ProductRealtimeEvent | Record<string, unknown>): void - resyncActiveScopes(reason: ResyncReason): Promise<void> - resyncLoadedScopes(reason: ResyncReason): Promise<void> - - applyOptimisticMutation(mutation: OptimisticMutation): string - rollbackMutation(mutationId: string): void - settleMutation(mutationId: string): void - - setRealtimeStatus(status: RealtimeStatus): void - - // PBI-79 / ST-1336: sprint-membership acties. - setPbiSummary(summary: Record<string, PbiSummaryEntry>): void - setCrossSprintBlocks(blocks: Record<string, CrossSprintBlock>): void - toggleStorySprintMembership(storyId: string, currentlyInSprint: boolean): void - resetSprintMembershipPending(): void - fetchSprintMembershipSummary( - productId: string, - sprintId: string, - pbiIds: string[], - ): Promise<void> - fetchCrossSprintBlocks( - productId: string, - excludeSprintId: string | null, - pbiIds: string[], - ): Promise<void> - - // PBI-79 / ST-1340: gericht patchen na server-action commit. Tasks in - // de client-store hebben geen sprint_id-veld dus alleen story-records - // worden gemuteerd. - applyMembershipCommitResult(input: { - activeSprintId: string - addedStoryIds: string[] - removedStoryIds: string[] - }): void -} - -export type ProductWorkspaceStore = State & Actions - -const initialState: State = { - context: { - activeProduct: null, - activePbiId: null, - activeStoryId: null, - activeTaskId: null, - }, - entities: { - pbisById: {}, - storiesById: {}, - tasksById: {}, - }, - relations: { - pbiIds: [], - storyIdsByPbi: {}, - taskIdsByStory: {}, - }, - loading: { - loadedProductId: null, - loadingProductId: null, - loadedPbiIds: {}, - loadedStoryIds: {}, - loadedTaskIds: {}, - activeRequestId: null, - }, - sync: { - realtimeStatus: 'connecting', - lastEventAt: null, - lastResyncAt: null, - resyncReason: null, - }, - pendingMutations: {}, - sprintMembership: { - pbiSummary: {}, - crossSprintBlocks: {}, - pending: { adds: [], removes: [] }, - loadedSummaryForSprintId: null, - }, -} - -function comparePbi(a: BacklogPbi, b: BacklogPbi): number { - if (a.priority !== b.priority) return a.priority - b.priority - if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order - return new Date(a.created_at).getTime() - new Date(b.created_at).getTime() -} - -function compareStory(a: BacklogStory, b: BacklogStory): number { - if (a.priority !== b.priority) return a.priority - b.priority - if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order - return new Date(a.created_at).getTime() - new Date(b.created_at).getTime() -} - -function compareTask(a: BacklogTask, b: BacklogTask): number { - if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order - return new Date(a.created_at).getTime() - new Date(b.created_at).getTime() -} - -function newRequestId(): string { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return crypto.randomUUID() - } - return `${Date.now()}-${Math.random().toString(36).slice(2)}` -} - -function isKnownEntity(entity: unknown): entity is 'pbi' | 'story' | 'task' { - return entity === 'pbi' || entity === 'story' || entity === 'task' -} - -function isUnknownEntityEvent(p: Record<string, unknown>): boolean { - if (typeof p.entity !== 'string') return false - if (isKnownEntity(p.entity)) return false - if ('type' in p) return false - return true -} - -async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> { - const response = await fetch(url, { cache: 'no-store', ...init }) - if (!response.ok) { - throw new Error(`Fetch ${url} failed with ${response.status}`) - } - return (await response.json()) as T -} - -export const useProductWorkspaceStore = create<ProductWorkspaceStore>()( - immer((set, get) => ({ - ...initialState, - - hydrateSnapshot(inputSnapshot) { - const snapshot = normalizeProductBacklogSnapshot(inputSnapshot) - set((s) => { - if (snapshot.product) s.context.activeProduct = snapshot.product - - s.entities.pbisById = {} - s.entities.storiesById = {} - s.entities.tasksById = {} - s.relations.pbiIds = [] - s.sprintMembership = { - pbiSummary: {}, - crossSprintBlocks: {}, - pending: { adds: [], removes: [] }, - loadedSummaryForSprintId: null, - } - s.relations.storyIdsByPbi = {} - s.relations.taskIdsByStory = {} - - for (const pbi of snapshot.pbis) { - s.entities.pbisById[pbi.id] = pbi - } - s.relations.pbiIds = [...snapshot.pbis].sort(comparePbi).map((p) => p.id) - - for (const [pbiId, stories] of Object.entries(snapshot.storiesByPbi)) { - for (const story of stories) { - s.entities.storiesById[story.id] = story - } - s.relations.storyIdsByPbi[pbiId] = [...stories] - .sort(compareStory) - .map((st) => st.id) - } - - for (const [storyId, tasks] of Object.entries(snapshot.tasksByStory)) { - for (const task of tasks) { - s.entities.tasksById[task.id] = task - } - s.relations.taskIdsByStory[storyId] = [...tasks] - .sort(compareTask) - .map((t) => t.id) - } - - if (snapshot.product) { - s.loading.loadedProductId = snapshot.product.id - } - }) - }, - - setActiveProduct(product, options) { - const requestId = newRequestId() - const productChanged = get().context.activeProduct?.id !== product?.id - const shouldResetSelection = productChanged || !options?.preserveSelection - - set((s) => { - s.context.activeProduct = product - if (shouldResetSelection) { - s.context.activePbiId = null - s.context.activeStoryId = null - s.context.activeTaskId = null - } - s.loading.activeRequestId = requestId - - if (productChanged) { - s.entities.pbisById = {} - s.entities.storiesById = {} - s.entities.tasksById = {} - s.relations.pbiIds = [] - s.relations.storyIdsByPbi = {} - s.relations.taskIdsByStory = {} - s.loading.loadedProductId = null - s.loading.loadedPbiIds = {} - s.loading.loadedStoryIds = {} - s.loading.loadedTaskIds = {} - } - }) - - // T-858: persisteer product-hint zodat een volgende cold reload deze - // selectie kan herstellen. T-857: restore-flow start na ensureProductLoaded. - writeProductHint(product?.id ?? null) - - if (product && options?.load !== false) { - const productId = product.id - void (async () => { - await get().ensureProductLoaded(productId, requestId) - if (get().loading.activeRequestId !== requestId) return - // T-857: cascade-restore — alleen toepassen als hint-id nog in - // entities zit (entiteit accessible). - const hint = readHints().perProduct[productId]?.lastActivePbiId - if (hint && get().entities.pbisById[hint]) { - get().setActivePbi(hint) - } - })() - } - }, - - setActivePbi(pbiId) { - const requestId = newRequestId() - const productId = get().context.activeProduct?.id ?? null - - set((s) => { - s.context.activePbiId = pbiId - s.context.activeStoryId = null - s.context.activeTaskId = null - s.loading.activeRequestId = requestId - }) - - // T-858: persisteer pbi-hint per product. null wist child-hints (zie - // restore.ts writePbiHint). - if (productId) writePbiHint(productId, pbiId) - - if (pbiId) { - void (async () => { - await get().ensurePbiLoaded(pbiId, requestId) - if (get().loading.activeRequestId !== requestId) return - if (!productId) return - // T-857: cascade-restore. Alleen herstellen als de hint-story - // bij de nieuw-geselecteerde PBI hoort — anders blijft een task- - // selectie van een vorige PBI hangen (PBI-79 bugfix). - const hint = readHints().perProduct[productId]?.lastActiveStoryId - if (hint) { - const hintStory = get().entities.storiesById[hint] - if (hintStory && hintStory.pbi_id === pbiId) { - get().setActiveStory(hint) - } - } - })() - } - }, - - setActiveStory(storyId) { - const requestId = newRequestId() - const productId = get().context.activeProduct?.id ?? null - - set((s) => { - s.context.activeStoryId = storyId - s.context.activeTaskId = null - s.loading.activeRequestId = requestId - }) - - if (productId) writeStoryHint(productId, storyId) - - if (storyId) { - void (async () => { - await get().ensureStoryLoaded(storyId, requestId) - if (get().loading.activeRequestId !== requestId) return - if (!productId) return - const hint = readHints().perProduct[productId]?.lastActiveTaskId - if (hint && get().entities.tasksById[hint]) { - get().setActiveTask(hint) - } - })() - } - }, - - setActiveTask(taskId) { - const productId = get().context.activeProduct?.id ?? null - - set((s) => { - s.context.activeTaskId = taskId - }) - - if (productId) writeTaskHint(productId, taskId) - - if (taskId) { - void get().ensureTaskLoaded(taskId) - } - }, - - async ensureProductLoaded(productId, requestId) { - set((s) => { - s.loading.loadingProductId = productId - }) - try { - const snapshot = await fetchJson<ProductBacklogSnapshot | null>( - `/api/products/${encodeURIComponent(productId)}/backlog`, - ) - if (requestId && get().loading.activeRequestId !== requestId) return - if (!snapshot || !Array.isArray(snapshot.pbis)) return - get().hydrateSnapshot(snapshot) - set((s) => { - s.loading.loadedProductId = productId - for (const pbi of snapshot.pbis) { - s.loading.loadedPbiIds[pbi.id] = true - } - }) - } finally { - set((s) => { - if (s.loading.loadingProductId === productId) { - s.loading.loadingProductId = null - } - }) - } - }, - - async ensurePbiLoaded(pbiId, requestId) { - const stories = await fetchJson<BacklogStory[] | null>( - `/api/pbis/${encodeURIComponent(pbiId)}/stories`, - ) - if (requestId && get().loading.activeRequestId !== requestId) return - if (!Array.isArray(stories)) return - const normalizedStories = stories.map(normalizeBacklogStory) - set((s) => { - for (const story of normalizedStories) { - s.entities.storiesById[story.id] = story - } - s.relations.storyIdsByPbi[pbiId] = [...normalizedStories] - .sort(compareStory) - .map((st) => st.id) - s.loading.loadedPbiIds[pbiId] = true - }) - }, - - async ensureStoryLoaded(storyId, requestId) { - const tasks = await fetchJson<BacklogTask[] | null>( - `/api/stories/${encodeURIComponent(storyId)}/tasks`, - ) - if (requestId && get().loading.activeRequestId !== requestId) return - if (!Array.isArray(tasks)) return - const normalizedTasks = tasks.map(normalizeBacklogTask) - set((s) => { - for (const task of normalizedTasks) { - const existing = s.entities.tasksById[task.id] - if (existing && isDetail(existing)) { - s.entities.tasksById[task.id] = { ...existing, ...task } - } else { - s.entities.tasksById[task.id] = task - } - } - s.relations.taskIdsByStory[storyId] = [...normalizedTasks] - .sort(compareTask) - .map((t) => t.id) - s.loading.loadedStoryIds[storyId] = true - }) - }, - - async ensureTaskLoaded(taskId, requestId) { - const detail = await fetchJson<TaskDetail | null>( - `/api/tasks/${encodeURIComponent(taskId)}`, - ) - if (requestId && get().loading.activeRequestId !== requestId) return - if (!detail || typeof detail !== 'object') return - const normalizedDetail = normalizeBacklogTask(detail) - set((s) => { - s.entities.tasksById[taskId] = { ...normalizedDetail, _detail: true } - s.loading.loadedTaskIds[taskId] = true - }) - }, - - applyRealtimeEvent(event) { - const payload = event as Record<string, unknown> - const activeProductId = get().context.activeProduct?.id ?? null - - set((s) => { - s.sync.lastEventAt = Date.now() - }) - - if ( - typeof payload.product_id === 'string' && - activeProductId && - payload.product_id !== activeProductId - ) { - return - } - - if (isUnknownEntityEvent(payload)) { - if (payload.product_id === activeProductId) { - void get().resyncActiveScopes('unknown-event') - } - return - } - - const entity = payload.entity - const op = payload.op - if (!isKnownEntity(entity)) return - if (op !== 'I' && op !== 'U' && op !== 'D') return - - const id = payload.id - if (typeof id !== 'string') return - - if (entity === 'pbi') { - applyPbiEvent(id, op, payload, set, get) - } else if (entity === 'story') { - applyStoryEvent(id, op, payload, set, get) - } else if (entity === 'task') { - applyTaskEvent(id, op, payload, set, get) - } - }, - - async resyncActiveScopes(reason) { - const ctx = get().context - const tasks: Promise<void>[] = [] - if (ctx.activeProduct?.id) { - tasks.push(get().ensureProductLoaded(ctx.activeProduct.id)) - } - if (ctx.activePbiId) tasks.push(get().ensurePbiLoaded(ctx.activePbiId)) - if (ctx.activeStoryId) tasks.push(get().ensureStoryLoaded(ctx.activeStoryId)) - if (ctx.activeTaskId) tasks.push(get().ensureTaskLoaded(ctx.activeTaskId)) - set((s) => { - s.sync.lastResyncAt = Date.now() - s.sync.resyncReason = reason - }) - await Promise.allSettled(tasks) - }, - - async resyncLoadedScopes(reason) { - const loading = get().loading - const tasks: Promise<void>[] = [] - if (loading.loadedProductId) { - tasks.push(get().ensureProductLoaded(loading.loadedProductId)) - } - for (const pbiId of Object.keys(loading.loadedPbiIds)) { - tasks.push(get().ensurePbiLoaded(pbiId)) - } - for (const storyId of Object.keys(loading.loadedStoryIds)) { - tasks.push(get().ensureStoryLoaded(storyId)) - } - for (const taskId of Object.keys(loading.loadedTaskIds)) { - tasks.push(get().ensureTaskLoaded(taskId)) - } - set((s) => { - s.sync.lastResyncAt = Date.now() - s.sync.resyncReason = reason - }) - await Promise.allSettled(tasks) - }, - - applyOptimisticMutation(mutation) { - const id = newRequestId() - set((s) => { - s.pendingMutations[id] = { - id, - mutation, - createdAt: Date.now(), - } - switch (mutation.kind) { - case 'pbi-order': - // store-call passes new order via separate set, snapshot is prevPbiIds - break - case 'entity-patch': - break - } - }) - return id - }, - - rollbackMutation(mutationId) { - const pending = get().pendingMutations[mutationId] - if (!pending) return - const { mutation } = pending - set((s) => { - switch (mutation.kind) { - case 'pbi-order': - s.relations.pbiIds = [...mutation.prevPbiIds] - break - case 'entity-patch': { - const { entity, id, prev } = mutation - if (prev) { - if (entity === 'pbi') s.entities.pbisById[id] = prev as BacklogPbi - else if (entity === 'story') s.entities.storiesById[id] = prev as BacklogStory - else s.entities.tasksById[id] = prev as BacklogTask | TaskDetail - } else { - if (entity === 'pbi') delete s.entities.pbisById[id] - else if (entity === 'story') delete s.entities.storiesById[id] - else delete s.entities.tasksById[id] - } - break - } - } - delete s.pendingMutations[mutationId] - }) - }, - - settleMutation(mutationId) { - set((s) => { - delete s.pendingMutations[mutationId] - }) - }, - - setRealtimeStatus(status) { - set((s) => { - s.sync.realtimeStatus = status - }) - }, - - setPbiSummary(summary) { - set((s) => { - s.sprintMembership.pbiSummary = summary - }) - }, - - setCrossSprintBlocks(blocks) { - set((s) => { - s.sprintMembership.crossSprintBlocks = blocks - }) - }, - - toggleStorySprintMembership(storyId, currentlyInSprint) { - set((s) => { - const pending = s.sprintMembership.pending - if (currentlyInSprint) { - const inRemoves = pending.removes.indexOf(storyId) - if (inRemoves >= 0) { - pending.removes.splice(inRemoves, 1) - } else { - const inAdds = pending.adds.indexOf(storyId) - if (inAdds >= 0) pending.adds.splice(inAdds, 1) - pending.removes.push(storyId) - } - } else { - const inAdds = pending.adds.indexOf(storyId) - if (inAdds >= 0) { - pending.adds.splice(inAdds, 1) - } else { - const inRemoves = pending.removes.indexOf(storyId) - if (inRemoves >= 0) pending.removes.splice(inRemoves, 1) - pending.adds.push(storyId) - } - } - }) - }, - - resetSprintMembershipPending() { - set((s) => { - s.sprintMembership.pending = { adds: [], removes: [] } - }) - }, - - async fetchSprintMembershipSummary(productId, sprintId, pbiIds) { - if (pbiIds.length === 0) return - const url = `/api/products/${productId}/sprint-membership-summary?sprintId=${encodeURIComponent(sprintId)}&pbiIds=${pbiIds.map(encodeURIComponent).join(',')}` - const summary = await fetchJson<Record<string, PbiSummaryEntry>>(url) - set((s) => { - for (const [pbiId, entry] of Object.entries(summary)) { - s.sprintMembership.pbiSummary[pbiId] = entry - } - s.sprintMembership.loadedSummaryForSprintId = sprintId - }) - }, - - async fetchCrossSprintBlocks(productId, excludeSprintId, pbiIds) { - if (pbiIds.length === 0) return - const params = new URLSearchParams() - if (excludeSprintId) params.set('excludeSprintId', excludeSprintId) - params.set('pbiIds', pbiIds.join(',')) - const url = `/api/products/${productId}/cross-sprint-blocks?${params.toString()}` - const blocks = await fetchJson<Record<string, CrossSprintBlock>>(url) - set((s) => { - for (const [storyId, info] of Object.entries(blocks)) { - s.sprintMembership.crossSprintBlocks[storyId] = info - } - }) - }, - - applyMembershipCommitResult({ - activeSprintId, - addedStoryIds, - removedStoryIds, - }) { - // Task-records in de client-store hebben geen sprint_id-veld (alleen - // story_id); de sprint-membership wordt afgeleid via story.sprint_id. - // Hier patchen we daarom alleen story-entities + de pending buffer. - set((s) => { - for (const id of addedStoryIds) { - const story = s.entities.storiesById[id] - if (story) { - story.sprint_id = activeSprintId - story.status = 'IN_SPRINT' - } - } - for (const id of removedStoryIds) { - const story = s.entities.storiesById[id] - if (story) { - story.sprint_id = null - story.status = 'OPEN' - } - } - s.sprintMembership.pending = { adds: [], removes: [] } - }) - }, - })), -) - -type ImmerSet = Parameters<Parameters<typeof immer<ProductWorkspaceStore>>[0]>[0] -type ImmerGet = () => ProductWorkspaceStore - -function applyPbiEvent( - id: string, - op: 'I' | 'U' | 'D', - payload: Record<string, unknown>, - set: ImmerSet, - get: ImmerGet, -) { - if (op === 'D') { - set((s) => { - const childStoryIds = s.relations.storyIdsByPbi[id] ?? [] - for (const sid of childStoryIds) { - const childTaskIds = s.relations.taskIdsByStory[sid] ?? [] - for (const tid of childTaskIds) { - delete s.entities.tasksById[tid] - } - delete s.relations.taskIdsByStory[sid] - delete s.entities.storiesById[sid] - } - delete s.relations.storyIdsByPbi[id] - delete s.entities.pbisById[id] - s.relations.pbiIds = s.relations.pbiIds.filter((p) => p !== id) - if (s.context.activePbiId === id) { - s.context.activePbiId = null - s.context.activeStoryId = null - s.context.activeTaskId = null - } - }) - return - } - - if (op === 'U') { - if (!get().entities.pbisById[id]) return - set((s) => { - const existing = s.entities.pbisById[id] - if (!existing) return - Object.assign(existing, sanitizePbiPayload(payload)) - s.relations.pbiIds = sortPbiIds(s.entities.pbisById, s.relations.pbiIds) - }) - return - } - - // I - if (get().entities.pbisById[id]) return - set((s) => { - const pbi = coercePbiPayload(id, payload) - s.entities.pbisById[id] = pbi - s.relations.pbiIds.push(id) - s.relations.pbiIds = sortPbiIds(s.entities.pbisById, s.relations.pbiIds) - }) -} - -function applyStoryEvent( - id: string, - op: 'I' | 'U' | 'D', - payload: Record<string, unknown>, - set: ImmerSet, - get: ImmerGet, -) { - if (op === 'D') { - set((s) => { - const childTaskIds = s.relations.taskIdsByStory[id] ?? [] - for (const tid of childTaskIds) { - delete s.entities.tasksById[tid] - } - delete s.relations.taskIdsByStory[id] - const story = s.entities.storiesById[id] - delete s.entities.storiesById[id] - if (story) { - const ids = s.relations.storyIdsByPbi[story.pbi_id] - if (ids) { - s.relations.storyIdsByPbi[story.pbi_id] = ids.filter((sid) => sid !== id) - } - } else { - for (const pbiId of Object.keys(s.relations.storyIdsByPbi)) { - s.relations.storyIdsByPbi[pbiId] = s.relations.storyIdsByPbi[pbiId].filter( - (sid) => sid !== id, - ) - } - } - if (s.context.activeStoryId === id) { - s.context.activeStoryId = null - s.context.activeTaskId = null - } - }) - return - } - - if (op === 'U') { - const existing = get().entities.storiesById[id] - if (!existing) return - set((s) => { - const story = s.entities.storiesById[id] - if (!story) return - const oldPbiId = story.pbi_id - Object.assign(story, sanitizeStoryPayload(payload)) - const newPbiId = story.pbi_id - if (oldPbiId !== newPbiId) { - const oldList = s.relations.storyIdsByPbi[oldPbiId] - if (oldList) { - s.relations.storyIdsByPbi[oldPbiId] = oldList.filter((sid) => sid !== id) - } - const targetList = s.relations.storyIdsByPbi[newPbiId] ?? [] - if (!targetList.includes(id)) targetList.push(id) - s.relations.storyIdsByPbi[newPbiId] = sortStoryIds(s.entities.storiesById, targetList) - } else if (s.relations.storyIdsByPbi[oldPbiId]) { - s.relations.storyIdsByPbi[oldPbiId] = sortStoryIds( - s.entities.storiesById, - s.relations.storyIdsByPbi[oldPbiId], - ) - } - }) - return - } - - // I - if (get().entities.storiesById[id]) return - set((s) => { - const story = coerceStoryPayload(id, payload) - s.entities.storiesById[id] = story - const list = s.relations.storyIdsByPbi[story.pbi_id] ?? [] - list.push(id) - s.relations.storyIdsByPbi[story.pbi_id] = sortStoryIds(s.entities.storiesById, list) - }) -} - -function applyTaskEvent( - id: string, - op: 'I' | 'U' | 'D', - payload: Record<string, unknown>, - set: ImmerSet, - get: ImmerGet, -) { - if (op === 'D') { - set((s) => { - const task = s.entities.tasksById[id] - delete s.entities.tasksById[id] - if (task) { - const ids = s.relations.taskIdsByStory[task.story_id] - if (ids) { - s.relations.taskIdsByStory[task.story_id] = ids.filter((tid) => tid !== id) - } - } else { - for (const storyId of Object.keys(s.relations.taskIdsByStory)) { - s.relations.taskIdsByStory[storyId] = s.relations.taskIdsByStory[storyId].filter( - (tid) => tid !== id, - ) - } - } - if (s.context.activeTaskId === id) { - s.context.activeTaskId = null - } - }) - return - } - - if (op === 'U') { - const existing = get().entities.tasksById[id] - if (!existing) return - set((s) => { - const task = s.entities.tasksById[id] - if (!task) return - const oldStoryId = task.story_id - Object.assign(task, sanitizeTaskPayload(payload)) - const newStoryId = task.story_id - if (oldStoryId !== newStoryId) { - const oldList = s.relations.taskIdsByStory[oldStoryId] - if (oldList) { - s.relations.taskIdsByStory[oldStoryId] = oldList.filter((tid) => tid !== id) - } - const targetList = s.relations.taskIdsByStory[newStoryId] ?? [] - if (!targetList.includes(id)) targetList.push(id) - s.relations.taskIdsByStory[newStoryId] = sortTaskIds(s.entities.tasksById, targetList) - } else if (s.relations.taskIdsByStory[oldStoryId]) { - s.relations.taskIdsByStory[oldStoryId] = sortTaskIds( - s.entities.tasksById, - s.relations.taskIdsByStory[oldStoryId], - ) - } - }) - return - } - - // I - if (get().entities.tasksById[id]) return - set((s) => { - const task = coerceTaskPayload(id, payload) - s.entities.tasksById[id] = task - const list = s.relations.taskIdsByStory[task.story_id] ?? [] - list.push(id) - s.relations.taskIdsByStory[task.story_id] = sortTaskIds(s.entities.tasksById, list) - }) -} - -function sortPbiIds(byId: Record<string, BacklogPbi>, ids: string[]): string[] { - return [...new Set(ids)] - .filter((id) => byId[id] !== undefined) - .sort((a, b) => comparePbi(byId[a], byId[b])) -} - -function sortStoryIds(byId: Record<string, BacklogStory>, ids: string[]): string[] { - return [...new Set(ids)] - .filter((id) => byId[id] !== undefined) - .sort((a, b) => compareStory(byId[a], byId[b])) -} - -function sortTaskIds( - byId: Record<string, BacklogTask | TaskDetail>, - ids: string[], -): string[] { - return [...new Set(ids)] - .filter((id) => byId[id] !== undefined) - .sort((a, b) => compareTask(byId[a], byId[b])) -} - -function sanitizePbiPayload(p: Record<string, unknown>): Partial<BacklogPbi> { - const { entity: _e, op: _o, ...rest } = p - void _e - void _o - if (typeof rest.status === 'string') { - rest.status = normalizePbiStatusForStore(rest.status) - } - return rest as Partial<BacklogPbi> -} - -function sanitizeStoryPayload(p: Record<string, unknown>): Partial<BacklogStory> { - const { - entity: _e, - op: _o, - story_status, - story_sort_order, - story_title, - story_code, - ...rest - } = p - void _e - void _o - if (rest.status === undefined && typeof story_status === 'string') { - rest.status = story_status - } - if (rest.sort_order === undefined && typeof story_sort_order === 'number') { - rest.sort_order = story_sort_order - } - if (rest.title === undefined && typeof story_title === 'string') { - rest.title = story_title - } - if (rest.code === undefined && (typeof story_code === 'string' || story_code === null)) { - rest.code = story_code - } - if (typeof rest.status === 'string') { - rest.status = normalizeStoryStatusForStore(rest.status) - } - return rest as Partial<BacklogStory> -} - -function sanitizeTaskPayload(p: Record<string, unknown>): Partial<BacklogTask> { - const { - entity: _e, - op: _o, - task_status, - task_sort_order, - task_title, - ...rest - } = p - void _e - void _o - if (rest.status === undefined && typeof task_status === 'string') { - rest.status = task_status - } - if (rest.sort_order === undefined && typeof task_sort_order === 'number') { - rest.sort_order = task_sort_order - } - if (rest.title === undefined && typeof task_title === 'string') { - rest.title = task_title - } - if (typeof rest.status === 'string') { - rest.status = normalizeTaskStatusForStore(rest.status) - } - return rest as Partial<BacklogTask> -} - -function coercePbiPayload(id: string, p: Record<string, unknown>): BacklogPbi { - return { - id, - code: (p.code as string | null) ?? null, - title: String(p.title ?? ''), - priority: Number(p.priority ?? 4), - sort_order: Number(p.sort_order ?? 0), - description: (p.description as string | null | undefined) ?? null, - created_at: - p.created_at instanceof Date - ? p.created_at - : new Date(String(p.created_at ?? Date.now())), - status: normalizePbiStatusForStore(String(p.status ?? 'ready')), - } -} - -function coerceStoryPayload(id: string, p: Record<string, unknown>): BacklogStory { - const status = p.status ?? p.story_status ?? 'OPEN' - const sortOrder = p.sort_order ?? p.story_sort_order ?? 0 - const title = p.title ?? p.story_title ?? '' - const code = p.code ?? p.story_code ?? null - return { - id, - code: (code as string | null) ?? null, - title: String(title), - description: (p.description as string | null | undefined) ?? null, - acceptance_criteria: (p.acceptance_criteria as string | null | undefined) ?? null, - priority: Number(p.priority ?? 4), - sort_order: Number(sortOrder), - status: normalizeStoryStatusForStore(String(status)), - pbi_id: String(p.pbi_id ?? ''), - sprint_id: (p.sprint_id as string | null | undefined) ?? null, - created_at: - p.created_at instanceof Date - ? p.created_at - : new Date(String(p.created_at ?? Date.now())), - } -} - -function coerceTaskPayload(id: string, p: Record<string, unknown>): BacklogTask { - const status = p.status ?? p.task_status ?? 'TO_DO' - const sortOrder = p.sort_order ?? p.task_sort_order ?? 0 - const title = p.title ?? p.task_title ?? '' - return { - id, - code: (p.code as string | null | undefined) ?? null, - title: String(title), - description: (p.description as string | null | undefined) ?? null, - priority: Number(p.priority ?? 4), - sort_order: Number(sortOrder), - status: normalizeTaskStatusForStore(String(status)), - story_id: String(p.story_id ?? ''), - created_at: - p.created_at instanceof Date - ? p.created_at - : new Date(String(p.created_at ?? Date.now())), - } -} diff --git a/stores/product-workspace/types.ts b/stores/product-workspace/types.ts deleted file mode 100644 index 18bd6d2..0000000 --- a/stores/product-workspace/types.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { PbiStatusApi } from '@/lib/task-status' - -export interface BacklogPbi { - id: string - code: string | null - title: string - priority: number - sort_order: number - description?: string | null - created_at: Date - status: PbiStatusApi -} - -export interface BacklogStory { - id: string - code: string | null - title: string - description: string | null - acceptance_criteria: string | null - priority: number - sort_order: number - status: string - pbi_id: string - sprint_id: string | null - created_at: Date -} - -export interface BacklogTask { - id: string - code: string | null - title: string - description: string | null - priority: number - status: string - sort_order: number - story_id: string - created_at: Date -} - -export interface TaskDetail extends BacklogTask { - _detail: true - implementation_plan?: string | null - acceptance_criteria?: string | null - requires_opus?: boolean - estimated_minutes?: number | null -} - -export function isDetail(task: BacklogTask | TaskDetail): task is TaskDetail { - return (task as TaskDetail)._detail === true -} - -export interface ActiveProduct { - id: string - name: string -} - -export interface ProductBacklogSnapshot { - product?: ActiveProduct - pbis: BacklogPbi[] - storiesByPbi: Record<string, BacklogStory[]> - tasksByStory: Record<string, BacklogTask[]> -} - -export type Op = 'I' | 'U' | 'D' - -export interface PbiRealtimeEvent { - entity: 'pbi' - op: Op - id: string - product_id: string - [key: string]: unknown -} - -export interface StoryRealtimeEvent { - entity: 'story' - op: Op - id: string - product_id: string - pbi_id?: string - [key: string]: unknown -} - -export interface TaskRealtimeEvent { - entity: 'task' - op: Op - id: string - product_id: string - story_id?: string - [key: string]: unknown -} - -export type ProductRealtimeEvent = - | PbiRealtimeEvent - | StoryRealtimeEvent - | TaskRealtimeEvent - -export type ResyncReason = - | 'visible' - | 'reconnect' - | 'manual' - | 'unknown-event' - | 'stale-scope' - | 'mutation-settled' - -export type RealtimeStatus = 'connecting' | 'open' | 'disconnected' - -export interface OptimisticPbiOrderMutation { - kind: 'pbi-order' - prevPbiIds: string[] -} - -export interface OptimisticEntityPatchMutation { - kind: 'entity-patch' - entity: 'pbi' | 'story' | 'task' - id: string - prev: BacklogPbi | BacklogStory | BacklogTask | TaskDetail | undefined -} - -export type OptimisticMutation = - | OptimisticPbiOrderMutation - | OptimisticEntityPatchMutation - -export interface PendingOptimisticMutation { - id: string - mutation: OptimisticMutation - createdAt: number -} - -// PBI-79 / ST-1336: sprint-membership state voor backlog-page. -export interface PbiSummaryEntry { - totalStoryCount: number - inActiveSprintStoryCount: number -} - -export interface CrossSprintBlock { - sprintId: string - sprintName: string -} - -export interface SprintMembershipSlice { - pbiSummary: Record<string, PbiSummaryEntry> - crossSprintBlocks: Record<string, CrossSprintBlock> - pending: { adds: string[]; removes: string[] } - loadedSummaryForSprintId: string | null -} diff --git a/stores/products-store.ts b/stores/products-store.ts deleted file mode 100644 index 2f57765..0000000 --- a/stores/products-store.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { create } from 'zustand' - -export interface ProductSummary { - id: string - name: string - code: string | null - description: string | null - repo_url: string | null - definition_of_done: string - auto_pr: boolean -} - -interface ProductsStore { - products: ProductSummary[] - setProducts: (products: ProductSummary[]) => void - addProduct: (product: ProductSummary) => void - updateProduct: (id: string, patch: Partial<ProductSummary>) => void -} - -export const useProductsStore = create<ProductsStore>((set) => ({ - products: [], - setProducts: (products) => set({ products }), - addProduct: (product) => - set((state) => ({ products: [...state.products, product] })), - updateProduct: (id, patch) => - set((state) => ({ - products: state.products.map((p) => (p.id === id ? { ...p, ...patch } : p)), - })), -})) diff --git a/stores/selection-store.ts b/stores/selection-store.ts new file mode 100644 index 0000000..b797db4 --- /dev/null +++ b/stores/selection-store.ts @@ -0,0 +1,17 @@ +import { create } from 'zustand' + +interface SelectionStore { + selectedPbiId: string | null + selectedStoryId: string | null + selectPbi: (id: string | null) => void + selectStory: (id: string | null) => void + clearSelection: () => void +} + +export const useSelectionStore = create<SelectionStore>((set) => ({ + selectedPbiId: null, + selectedStoryId: null, + selectPbi: (id) => set({ selectedPbiId: id, selectedStoryId: null }), + selectStory: (id) => set({ selectedStoryId: id }), + clearSelection: () => set({ selectedPbiId: null, selectedStoryId: null }), +})) diff --git a/stores/solo-store.ts b/stores/solo-store.ts index d682504..592976e 100644 --- a/stores/solo-store.ts +++ b/stores/solo-store.ts @@ -1,8 +1,267 @@ -export { useSoloWorkspaceStore as useSoloStore } from '@/stores/solo-workspace/store' -export type { - ClaudeJobEvent, - JobState, - RealtimeEvent, - RealtimeStatus, - VerifyResultApi, -} from '@/stores/solo-workspace/types' +import { create } from 'zustand' +import type { SoloTask } from '@/components/solo/solo-board' +import type { ClaudeJobStatusApi } from '@/lib/job-status' + +type TaskStatus = SoloTask['status'] + +export type VerifyResultApi = 'aligned' | 'partial' | 'empty' | 'divergent' + +export interface JobState { + job_id: string + task_id: string + status: ClaudeJobStatusApi + branch?: string + pushed_at?: string | null + pr_url?: string | null + verify_result?: VerifyResultApi | null + summary?: string + error?: string +} + +export type ClaudeJobEvent = + | { type: 'claude_job_enqueued'; job_id: string; task_id: string; user_id: string; product_id: string; status: 'queued' } + | { type: 'claude_job_status'; job_id: string; task_id: string; user_id: string; product_id: string; status: ClaudeJobStatusApi; branch?: string; pushed_at?: string; pr_url?: string; verify_result?: VerifyResultApi; summary?: string; error?: string } + +// Payload-shape gepubliceerd door de Postgres-trigger via pg_notify (ST-801 +// + ST-804 prereq). Komt het Solo Paneel binnen via de SSE-stream uit +// /api/realtime/solo (ST-802). +export interface RealtimeEvent { + op: 'I' | 'U' | 'D' + entity: 'task' | 'story' + id: string + story_id?: string + product_id: string + sprint_id: string | null + assignee_id: string | null + // Task-specifieke velden (alleen aanwezig als entity === 'task') + task_status?: TaskStatus + task_sort_order?: number + task_title?: string + // Story-specifieke velden (alleen aanwezig als entity === 'story') + story_status?: 'OPEN' | 'IN_SPRINT' | 'DONE' + story_sort_order?: number + story_title?: string + story_code?: string | null + // Op UPDATE: lijst van kolommen die zijn veranderd + changed_fields?: string[] +} + +export type RealtimeStatus = 'connecting' | 'open' | 'disconnected' + +interface SoloStore { + tasks: Record<string, SoloTask> + /** Task-ids die op dit moment een eigen optimistic write in de lucht hebben. + * Realtime echos voor deze ids worden onderdrukt zodat de eigen update niet + * twee keer toegepast wordt of door een latere echo overschreven. */ + pendingOps: Set<string> + + /** Realtime-connection state, beheerd door useSoloRealtime in de + * (app)-layout. Hier in de store omdat de UI-indicator in SoloBoard zit en + * de hook niet direct in dezelfde subtree draait. */ + realtimeStatus: RealtimeStatus + showConnectingIndicator: boolean + + claudeJobsByTaskId: Record<string, JobState> + connectedWorkers: number + + initTasks: (tasks: SoloTask[]) => void + optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null + rollback: (taskId: string, prevStatus: TaskStatus) => void + updatePlan: (taskId: string, plan: string | null) => void + updateVerifyOnly: (taskId: string, value: boolean) => void + updateVerifyRequired: (taskId: string, value: 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY') => void + + markPending: (taskId: string) => void + clearPending: (taskId: string) => void + + setRealtimeStatus: (status: RealtimeStatus, showConnectingIndicator: boolean) => void + + initJobs: (jobs: JobState[]) => void + handleJobEvent: (event: ClaudeJobEvent) => void + + setWorkers: (count: number) => void + incrementWorkers: () => void + decrementWorkers: () => void + + handleRealtimeEvent: (event: RealtimeEvent) => void +} + +export const useSoloStore = create<SoloStore>((set, get) => ({ + tasks: {}, + pendingOps: new Set<string>(), + realtimeStatus: 'connecting', + showConnectingIndicator: false, + claudeJobsByTaskId: {}, + connectedWorkers: 0, + + initTasks: (tasks) => + set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }), + + optimisticMove: (taskId, toStatus) => { + const prev = get().tasks[taskId]?.status ?? null + if (!prev) return null + set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], status: toStatus } } })) + return prev + }, + + rollback: (taskId, prevStatus) => + set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], status: prevStatus } } })), + + updatePlan: (taskId, plan) => + set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], implementation_plan: plan } } })), + + updateVerifyOnly: (taskId, value) => + set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_only: value } } })), + + updateVerifyRequired: (taskId, value) => + set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_required: value } } })), + + markPending: (taskId) => + set((s) => { + if (s.pendingOps.has(taskId)) return s + const next = new Set(s.pendingOps) + next.add(taskId) + return { pendingOps: next } + }), + + clearPending: (taskId) => + set((s) => { + if (!s.pendingOps.has(taskId)) return s + const next = new Set(s.pendingOps) + next.delete(taskId) + return { pendingOps: next } + }), + + setRealtimeStatus: (status, showConnectingIndicator) => + set((s) => { + if (s.realtimeStatus === status && s.showConnectingIndicator === showConnectingIndicator) { + return s + } + return { realtimeStatus: status, showConnectingIndicator } + }), + + initJobs: (jobs) => + set({ claudeJobsByTaskId: Object.fromEntries(jobs.map(j => [j.task_id, j])) }), + + setWorkers: (count) => set({ connectedWorkers: Math.max(0, count) }), + incrementWorkers: () => set(s => ({ connectedWorkers: s.connectedWorkers + 1 })), + decrementWorkers: () => set(s => ({ connectedWorkers: Math.max(0, s.connectedWorkers - 1) })), + + handleJobEvent: (event) => { + const { job_id, task_id } = event + if (event.type === 'claude_job_enqueued') { + set((s) => ({ + claudeJobsByTaskId: { + ...s.claudeJobsByTaskId, + [task_id]: { job_id, task_id, status: 'queued' }, + }, + })) + return + } + if (event.type === 'claude_job_status') { + const { status, branch, pushed_at, pr_url, verify_result, summary, error } = event + if (status === 'cancelled') { + set((s) => { + const next = { ...s.claudeJobsByTaskId } + delete next[task_id] + return { claudeJobsByTaskId: next } + }) + return + } + set((s) => ({ + claudeJobsByTaskId: { + ...s.claudeJobsByTaskId, + [task_id]: { job_id, task_id, status, branch, pushed_at, pr_url, verify_result, summary, error }, + }, + })) + } + }, + + handleRealtimeEvent: (event) => { + if (event.entity === 'task') { + const { id, op } = event + + if (op === 'D') { + set((s) => { + if (!(id in s.tasks)) return s + const next = { ...s.tasks } + delete next[id] + return { tasks: next } + }) + return + } + + // INSERT en UPDATE: alleen bestaande taken bijwerken. Nieuwe taken + // zonder story-context (story_title, story_code) renderen we niet + // — gebruiker ziet ze pas na een refresh. Acceptabel voor v1. + const existing = get().tasks[id] + if (!existing) return + + if (get().pendingOps.has(id)) { + // Echo van een eigen optimistic move — laat de optimistic-state staan + return + } + + const updates: Partial<SoloTask> = {} + if (event.task_status !== undefined && event.task_status !== existing.status) { + updates.status = event.task_status + } + if ( + event.task_sort_order !== undefined && + event.task_sort_order !== existing.sort_order + ) { + updates.sort_order = event.task_sort_order + } + if (event.task_title !== undefined && event.task_title !== existing.title) { + updates.title = event.task_title + } + + if (Object.keys(updates).length === 0) return + set((s) => ({ tasks: { ...s.tasks, [id]: { ...s.tasks[id], ...updates } } })) + return + } + + if (event.entity === 'story') { + const { id, op } = event + + if (op === 'D') { + // Story-cascade pakt tasks ook in de DB; verwijder de bijbehorende + // SoloTask-records uit de store. + set((s) => { + const next: Record<string, SoloTask> = {} + for (const [taskId, task] of Object.entries(s.tasks)) { + if (task.story_id !== id) next[taskId] = task + } + return { tasks: next } + }) + return + } + + const tasks = get().tasks + const affectedIds = Object.entries(tasks) + .filter(([, t]) => t.story_id === id) + .map(([taskId]) => taskId) + + if (affectedIds.length === 0) return + + const newTitle = event.story_title + const newCode = event.story_code ?? null + + set((s) => { + const next = { ...s.tasks } + for (const taskId of affectedIds) { + const t = next[taskId] + const titleChanged = newTitle !== undefined && t.story_title !== newTitle + const codeChanged = newCode !== t.story_code + if (!titleChanged && !codeChanged) continue + next[taskId] = { + ...t, + ...(titleChanged && newTitle !== undefined && { story_title: newTitle }), + ...(codeChanged && { story_code: newCode }), + } + } + return { tasks: next } + }) + } + }, +})) diff --git a/stores/solo-workspace/selectors.ts b/stores/solo-workspace/selectors.ts deleted file mode 100644 index 5645a08..0000000 --- a/stores/solo-workspace/selectors.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { SoloWorkspaceStore } from './store' -import type { SoloColumnStatus, SoloTask, SoloUnassignedStory } from './types' - -const EMPTY_TASKS: SoloTask[] = [] -const EMPTY_STORIES: SoloUnassignedStory[] = [] - -export function selectSoloTasksForColumn( - status: SoloColumnStatus, -): (s: SoloWorkspaceStore) => SoloTask[] { - return (s) => { - const ids = s.relations.taskIdsByColumn[status] - if (!ids || ids.length === 0) return EMPTY_TASKS - const out: SoloTask[] = [] - for (const id of ids) { - const task = s.entities.tasksById[id] - if (task) out.push(task) - } - return out.length === 0 ? EMPTY_TASKS : out - } -} - -export function selectSoloUnassignedStories(s: SoloWorkspaceStore): SoloUnassignedStory[] { - if (s.relations.unassignedStoryIds.length === 0) return EMPTY_STORIES - const out: SoloUnassignedStory[] = [] - for (const id of s.relations.unassignedStoryIds) { - const story = s.entities.unassignedStoriesById[id] - if (story) out.push(story) - } - return out.length === 0 ? EMPTY_STORIES : out -} - -export function selectSoloTaskById( - taskId: string | null, -): (s: SoloWorkspaceStore) => SoloTask | null { - return (s) => { - if (!taskId) return null - return s.entities.tasksById[taskId] ?? null - } -} diff --git a/stores/solo-workspace/store.ts b/stores/solo-workspace/store.ts deleted file mode 100644 index 1889b8a..0000000 --- a/stores/solo-workspace/store.ts +++ /dev/null @@ -1,619 +0,0 @@ -import { create } from 'zustand' -import type { - ClaudeJobEvent, - JobState, - RealtimeEvent, - RealtimeStatus, - ResyncReason, - SoloColumnStatus, - SoloTask, - SoloTaskStatus, - SoloUnassignedStory, - SoloWorkspaceProduct, - SoloWorkspaceSnapshot, - SoloWorkspaceSprint, - SoloVerifyRequired, -} from './types' - -interface ContextSlice { - activeProduct: SoloWorkspaceProduct | null - activeSprint: SoloWorkspaceSprint | null - activeUserId: string | null -} - -interface EntitiesSlice { - tasksById: Record<string, SoloTask> - unassignedStoriesById: Record<string, SoloUnassignedStory> - jobsByTaskId: Record<string, JobState> -} - -interface RelationsSlice { - taskIdsByColumn: Record<SoloColumnStatus, string[]> - unassignedStoryIds: string[] -} - -interface LoadingSlice { - loadedProductId: string | null - loadedSprintId: string | null - loadingSprintId: string | null - activeRequestId: string | null -} - -interface SyncSlice { - realtimeStatus: RealtimeStatus - showConnectingIndicator: boolean - lastEventAt: number | null - lastResyncAt: number | null - resyncReason: ResyncReason | null -} - -interface State { - context: ContextSlice - entities: EntitiesSlice - relations: RelationsSlice - loading: LoadingSlice - sync: SyncSlice - pendingOps: Set<string> - tasks: Record<string, SoloTask> - unassignedStoriesById: Record<string, SoloUnassignedStory> - claudeJobsByTaskId: Record<string, JobState> - realtimeStatus: RealtimeStatus - showConnectingIndicator: boolean - connectedWorkers: number - workerQuotaPct: number | null - workerQuotaCheckAt: string | null -} - -interface Actions { - hydrateSnapshot(snapshot: SoloWorkspaceSnapshot): void - initTasks(tasks: SoloTask[]): void - hydrateUnassignedStories(stories: SoloUnassignedStory[]): void - removeUnassignedStory(storyId: string): void - - optimisticMove(taskId: string, toStatus: SoloTaskStatus): SoloTaskStatus | null - rollback(taskId: string, prevStatus: SoloTaskStatus): void - updatePlan(taskId: string, plan: string | null): void - updateVerifyOnly(taskId: string, value: boolean): void - updateVerifyRequired(taskId: string, value: SoloVerifyRequired): void - - markPending(taskId: string): void - clearPending(taskId: string): void - - setRealtimeStatus(status: RealtimeStatus, showConnectingIndicator: boolean): void - - initJobs(jobs: JobState[]): void - handleJobEvent(event: ClaudeJobEvent): void - - setWorkers(count: number): void - incrementWorkers(): void - decrementWorkers(): void - setWorkerQuota(pct: number, checkAt: string): void - - handleRealtimeEvent(event: RealtimeEvent): void - ensureWorkspaceLoaded(productId: string, sprintId?: string, requestId?: string): Promise<void> - resyncActiveScopes(reason: ResyncReason): Promise<void> -} - -export type SoloWorkspaceStore = State & Actions - -const EMPTY_COLUMNS: Record<SoloColumnStatus, string[]> = { - TO_DO: [], - IN_PROGRESS: [], - DONE: [], -} - -const initialState: State = { - context: { - activeProduct: null, - activeSprint: null, - activeUserId: null, - }, - entities: { - tasksById: {}, - unassignedStoriesById: {}, - jobsByTaskId: {}, - }, - relations: { - taskIdsByColumn: EMPTY_COLUMNS, - unassignedStoryIds: [], - }, - loading: { - loadedProductId: null, - loadedSprintId: null, - loadingSprintId: null, - activeRequestId: null, - }, - sync: { - realtimeStatus: 'connecting', - showConnectingIndicator: false, - lastEventAt: null, - lastResyncAt: null, - resyncReason: null, - }, - pendingOps: new Set<string>(), - tasks: {}, - unassignedStoriesById: {}, - claudeJobsByTaskId: {}, - realtimeStatus: 'connecting', - showConnectingIndicator: false, - connectedWorkers: 0, - workerQuotaPct: null, - workerQuotaCheckAt: null, -} - -function getColumnStatus(status: SoloTaskStatus): SoloColumnStatus { - if (status === 'REVIEW') return 'IN_PROGRESS' - return status -} - -function compareTask(a: SoloTask, b: SoloTask): number { - if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order - if (a.priority !== b.priority) return a.priority - b.priority - const aCode = a.task_code ?? '' - const bCode = b.task_code ?? '' - const codeCompare = aCode.localeCompare(bCode, 'nl', { numeric: true }) - if (codeCompare !== 0) return codeCompare - return a.id.localeCompare(b.id) -} - -function compareUnassignedStory(a: SoloUnassignedStory, b: SoloUnassignedStory): number { - const aCode = a.code ?? '' - const bCode = b.code ?? '' - const codeCompare = aCode.localeCompare(bCode, 'nl', { numeric: true }) - if (codeCompare !== 0) return codeCompare - return a.title.localeCompare(b.title, 'nl', { numeric: true }) -} - -function buildTaskRelations(tasksById: Record<string, SoloTask>): Record<SoloColumnStatus, string[]> { - const next: Record<SoloColumnStatus, string[]> = { - TO_DO: [], - IN_PROGRESS: [], - DONE: [], - } - const tasks = Object.values(tasksById).sort(compareTask) - for (const task of tasks) { - next[getColumnStatus(task.status)].push(task.id) - } - return next -} - -function buildUnassignedRelations(storiesById: Record<string, SoloUnassignedStory>): string[] { - return Object.values(storiesById) - .sort(compareUnassignedStory) - .map((story) => story.id) -} - -function normalizeTask(input: SoloTask): SoloTask { - return { - ...input, - status: normalizeTaskStatus(input.status), - } -} - -function normalizeTaskStatus(status: string): SoloTaskStatus { - if (status === 'IN_PROGRESS' || status === 'REVIEW' || status === 'DONE') return status - return 'TO_DO' -} - -function mapTasks(tasks: SoloTask[]): Record<string, SoloTask> { - return Object.fromEntries(tasks.map((task) => [task.id, normalizeTask(task)])) -} - -function mapUnassignedStories(stories: SoloUnassignedStory[]): Record<string, SoloUnassignedStory> { - return Object.fromEntries(stories.map((story) => [story.id, story])) -} - -function newRequestId(): string { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return crypto.randomUUID() - } - return `${Date.now()}-${Math.random().toString(36).slice(2)}` -} - -async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> { - const response = await fetch(url, { cache: 'no-store', ...init }) - if (!response.ok) { - throw new Error(`Fetch ${url} failed with ${response.status}`) - } - return (await response.json()) as T -} - -function taskPatchFromEvent(event: RealtimeEvent): Partial<SoloTask> { - const status = event.status ?? event.task_status - return { - ...(status && { status: normalizeTaskStatus(status) }), - ...((event.sort_order ?? event.task_sort_order) !== undefined && { - sort_order: event.sort_order ?? event.task_sort_order, - }), - ...((event.title ?? event.task_title) !== undefined && { - title: event.title ?? event.task_title, - }), - ...(event.description !== undefined && { description: event.description }), - ...(event.priority !== undefined && { priority: event.priority }), - ...(event.story_id !== undefined && { story_id: event.story_id }), - } -} - -function storyTitleFromEvent(event: RealtimeEvent): string | undefined { - return event.title ?? event.story_title -} - -function storyCodeFromEvent(event: RealtimeEvent): string | null | undefined { - return event.code ?? event.story_code -} - -export const useSoloWorkspaceStore = create<SoloWorkspaceStore>((set, get) => ({ - ...initialState, - - hydrateSnapshot(snapshot) { - const tasksById = mapTasks(snapshot.tasks) - const unassignedStoriesById = mapUnassignedStories(snapshot.unassignedStories) - set((s) => ({ - context: { - activeProduct: snapshot.product, - activeSprint: snapshot.sprint, - activeUserId: snapshot.activeUserId, - }, - entities: { - ...s.entities, - tasksById, - unassignedStoriesById, - }, - relations: { - taskIdsByColumn: buildTaskRelations(tasksById), - unassignedStoryIds: buildUnassignedRelations(unassignedStoriesById), - }, - loading: { - ...s.loading, - loadedProductId: snapshot.product.id, - loadedSprintId: snapshot.sprint.id, - loadingSprintId: null, - }, - tasks: tasksById, - unassignedStoriesById, - })) - }, - - initTasks(tasks) { - const tasksById = mapTasks(tasks) - set((s) => ({ - entities: { ...s.entities, tasksById }, - relations: { - ...s.relations, - taskIdsByColumn: buildTaskRelations(tasksById), - }, - tasks: tasksById, - })) - }, - - hydrateUnassignedStories(stories) { - const unassignedStoriesById = mapUnassignedStories(stories) - set((s) => ({ - entities: { ...s.entities, unassignedStoriesById }, - relations: { - ...s.relations, - unassignedStoryIds: buildUnassignedRelations(unassignedStoriesById), - }, - unassignedStoriesById, - })) - }, - - removeUnassignedStory(storyId) { - set((s) => { - if (!s.entities.unassignedStoriesById[storyId]) return s - const unassignedStoriesById = { ...s.entities.unassignedStoriesById } - delete unassignedStoriesById[storyId] - return { - entities: { ...s.entities, unassignedStoriesById }, - relations: { - ...s.relations, - unassignedStoryIds: buildUnassignedRelations(unassignedStoriesById), - }, - unassignedStoriesById, - } - }) - }, - - optimisticMove(taskId, toStatus) { - const prev = get().tasks[taskId]?.status ?? null - if (!prev) return null - const task = { ...get().tasks[taskId], status: toStatus } - const tasksById = { ...get().tasks, [taskId]: task } - set((s) => ({ - entities: { ...s.entities, tasksById }, - relations: { ...s.relations, taskIdsByColumn: buildTaskRelations(tasksById) }, - tasks: tasksById, - })) - return prev - }, - - rollback(taskId, prevStatus) { - const existing = get().tasks[taskId] - if (!existing) return - const tasksById = { ...get().tasks, [taskId]: { ...existing, status: prevStatus } } - set((s) => ({ - entities: { ...s.entities, tasksById }, - relations: { ...s.relations, taskIdsByColumn: buildTaskRelations(tasksById) }, - tasks: tasksById, - })) - }, - - updatePlan(taskId, plan) { - const existing = get().tasks[taskId] - if (!existing) return - const tasksById = { ...get().tasks, [taskId]: { ...existing, implementation_plan: plan } } - set((s) => ({ entities: { ...s.entities, tasksById }, tasks: tasksById })) - }, - - updateVerifyOnly(taskId, value) { - const existing = get().tasks[taskId] - if (!existing) return - const tasksById = { ...get().tasks, [taskId]: { ...existing, verify_only: value } } - set((s) => ({ entities: { ...s.entities, tasksById }, tasks: tasksById })) - }, - - updateVerifyRequired(taskId, value) { - const existing = get().tasks[taskId] - if (!existing) return - const tasksById = { ...get().tasks, [taskId]: { ...existing, verify_required: value } } - set((s) => ({ entities: { ...s.entities, tasksById }, tasks: tasksById })) - }, - - markPending(taskId) { - set((s) => { - if (s.pendingOps.has(taskId)) return s - const pendingOps = new Set(s.pendingOps) - pendingOps.add(taskId) - return { pendingOps } - }) - }, - - clearPending(taskId) { - set((s) => { - if (!s.pendingOps.has(taskId)) return s - const pendingOps = new Set(s.pendingOps) - pendingOps.delete(taskId) - return { pendingOps } - }) - }, - - setRealtimeStatus(status, showConnectingIndicator) { - set((s) => { - if (s.realtimeStatus === status && s.showConnectingIndicator === showConnectingIndicator) { - return s - } - return { - sync: { ...s.sync, realtimeStatus: status, showConnectingIndicator }, - realtimeStatus: status, - showConnectingIndicator, - } - }) - }, - - initJobs(jobs) { - const jobsByTaskId = Object.fromEntries(jobs.map((job) => [job.task_id, job])) - set((s) => ({ - entities: { ...s.entities, jobsByTaskId }, - claudeJobsByTaskId: jobsByTaskId, - })) - }, - - handleJobEvent(event) { - const { job_id, task_id } = event - if (event.type === 'claude_job_enqueued') { - set((s) => { - const jobsByTaskId = { - ...s.claudeJobsByTaskId, - [task_id]: { job_id, task_id, status: 'queued' as const }, - } - return { - entities: { ...s.entities, jobsByTaskId }, - claudeJobsByTaskId: jobsByTaskId, - } - }) - return - } - - const { status, branch, pushed_at, pr_url, verify_result, summary, error } = event - if (status === 'cancelled') { - set((s) => { - const jobsByTaskId = { ...s.claudeJobsByTaskId } - delete jobsByTaskId[task_id] - return { - entities: { ...s.entities, jobsByTaskId }, - claudeJobsByTaskId: jobsByTaskId, - } - }) - return - } - - set((s) => { - const jobsByTaskId = { - ...s.claudeJobsByTaskId, - [task_id]: { - job_id, - task_id, - status, - branch, - pushed_at, - pr_url, - verify_result, - summary, - error, - }, - } - return { - entities: { ...s.entities, jobsByTaskId }, - claudeJobsByTaskId: jobsByTaskId, - } - }) - }, - - setWorkers(count) { - set({ connectedWorkers: Math.max(0, count) }) - }, - - incrementWorkers() { - set((s) => ({ connectedWorkers: s.connectedWorkers + 1 })) - }, - - decrementWorkers() { - set((s) => ({ - connectedWorkers: Math.max(0, s.connectedWorkers - 1), - workerQuotaPct: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaPct, - workerQuotaCheckAt: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaCheckAt, - })) - }, - - setWorkerQuota(pct, checkAt) { - set({ workerQuotaPct: pct, workerQuotaCheckAt: checkAt }) - }, - - handleRealtimeEvent(event) { - set((s) => ({ sync: { ...s.sync, lastEventAt: Date.now() } })) - - const ctx = get().context - if (ctx.activeProduct?.id && event.product_id !== ctx.activeProduct.id) return - - if (event.entity === 'task') { - if (event.op === 'D') { - const existing = get().tasks[event.id] - if (!existing) return - const tasksById = { ...get().tasks } - delete tasksById[event.id] - set((s) => ({ - entities: { ...s.entities, tasksById }, - relations: { ...s.relations, taskIdsByColumn: buildTaskRelations(tasksById) }, - tasks: tasksById, - })) - return - } - - const existing = get().tasks[event.id] - if (!existing) { - if ( - event.assignee_id === ctx.activeUserId && - event.sprint_id === ctx.activeSprint?.id - ) { - void get().resyncActiveScopes('unknown-event') - } - return - } - - if ( - event.assignee_id !== null && - ctx.activeUserId && - event.assignee_id !== ctx.activeUserId - ) { - const tasksById = { ...get().tasks } - delete tasksById[event.id] - set((s) => ({ - entities: { ...s.entities, tasksById }, - relations: { ...s.relations, taskIdsByColumn: buildTaskRelations(tasksById) }, - tasks: tasksById, - })) - return - } - - if (get().pendingOps.has(event.id)) return - - const patch = taskPatchFromEvent(event) - if (Object.keys(patch).length === 0) return - const tasksById = { - ...get().tasks, - [event.id]: { ...existing, ...patch }, - } - set((s) => ({ - entities: { ...s.entities, tasksById }, - relations: { ...s.relations, taskIdsByColumn: buildTaskRelations(tasksById) }, - tasks: tasksById, - })) - return - } - - if (event.op === 'D') { - const tasksById = Object.fromEntries( - Object.entries(get().tasks).filter(([, task]) => task.story_id !== event.id), - ) - const unassignedStoriesById = { ...get().entities.unassignedStoriesById } - delete unassignedStoriesById[event.id] - set((s) => ({ - entities: { ...s.entities, tasksById, unassignedStoriesById }, - relations: { - taskIdsByColumn: buildTaskRelations(tasksById), - unassignedStoryIds: buildUnassignedRelations(unassignedStoriesById), - }, - tasks: tasksById, - unassignedStoriesById, - })) - return - } - - const affectedIds = Object.entries(get().tasks) - .filter(([, task]) => task.story_id === event.id) - .map(([taskId]) => taskId) - const newTitle = storyTitleFromEvent(event) - const newCode = storyCodeFromEvent(event) - - if (affectedIds.length > 0 && (newTitle !== undefined || newCode !== undefined)) { - const tasksById = { ...get().tasks } - for (const taskId of affectedIds) { - const task = tasksById[taskId] - tasksById[taskId] = { - ...task, - ...(newTitle !== undefined && { story_title: newTitle }), - ...(newCode !== undefined && { story_code: newCode }), - } - } - set((s) => ({ - entities: { ...s.entities, tasksById }, - relations: { ...s.relations, taskIdsByColumn: buildTaskRelations(tasksById) }, - tasks: tasksById, - })) - } - - if ( - event.sprint_id === ctx.activeSprint?.id && - (event.assignee_id === null || event.assignee_id === ctx.activeUserId) - ) { - void get().resyncActiveScopes('unknown-event') - } - }, - - async ensureWorkspaceLoaded(productId, sprintId, requestId) { - const activeRequestId = requestId ?? newRequestId() - set((s) => ({ - loading: { - ...s.loading, - loadingSprintId: sprintId ?? s.context.activeSprint?.id ?? null, - activeRequestId, - }, - })) - try { - const params = sprintId ? `?sprint_id=${encodeURIComponent(sprintId)}` : '' - const snapshot = await fetchJson<SoloWorkspaceSnapshot | null>( - `/api/products/${encodeURIComponent(productId)}/solo-workspace${params}`, - ) - if (get().loading.activeRequestId !== activeRequestId) return - if (!snapshot) return - get().hydrateSnapshot(snapshot) - } finally { - set((s) => ({ - loading: { - ...s.loading, - loadingSprintId: - s.loading.activeRequestId === activeRequestId ? null : s.loading.loadingSprintId, - }, - })) - } - }, - - async resyncActiveScopes(reason) { - const ctx = get().context - if (!ctx.activeProduct?.id) return - set((s) => ({ - sync: { ...s.sync, lastResyncAt: Date.now(), resyncReason: reason }, - })) - await get().ensureWorkspaceLoaded(ctx.activeProduct.id, ctx.activeSprint?.id) - }, -})) diff --git a/stores/solo-workspace/types.ts b/stores/solo-workspace/types.ts deleted file mode 100644 index e8a14f4..0000000 --- a/stores/solo-workspace/types.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { ClaudeJobStatusApi } from '@/lib/job-status' - -export type SoloTaskStatus = 'TO_DO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE' -export type SoloColumnStatus = 'TO_DO' | 'IN_PROGRESS' | 'DONE' -export type SoloVerifyRequired = 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY' -export type VerifyResultApi = 'aligned' | 'partial' | 'empty' | 'divergent' - -export interface SoloTask { - id: string - title: string - description: string | null - implementation_plan: string | null - priority: number - sort_order: number - status: SoloTaskStatus - verify_only: boolean - verify_required: SoloVerifyRequired - story_id: string - story_code: string | null - story_title: string - task_code: string | null - pbi_code: string | null - pbi_title: string | null - pbi_description: string | null -} - -export interface SoloUnassignedStoryTask { - id: string - title: string - description: string | null - priority: number - status: string -} - -export interface SoloUnassignedStory { - id: string - code: string | null - title: string - tasks: SoloUnassignedStoryTask[] -} - -export interface SoloWorkspaceProduct { - id: string - name: string - repo_url?: string | null -} - -export interface SoloWorkspaceSprint { - id: string - sprint_goal: string -} - -export interface SoloWorkspaceSnapshot { - product: SoloWorkspaceProduct - sprint: SoloWorkspaceSprint - activeUserId: string - tasks: SoloTask[] - unassignedStories: SoloUnassignedStory[] -} - -export interface JobState { - job_id: string - task_id: string - status: ClaudeJobStatusApi - branch?: string - pushed_at?: string | null - pr_url?: string | null - verify_result?: VerifyResultApi | null - summary?: string - error?: string -} - -export type ClaudeJobEvent = - | { - type: 'claude_job_enqueued' - job_id: string - task_id: string - user_id: string - product_id: string - status: 'queued' - } - | { - type: 'claude_job_status' - job_id: string - task_id: string - user_id: string - product_id: string - status: ClaudeJobStatusApi - branch?: string - pushed_at?: string - pr_url?: string - verify_result?: VerifyResultApi - summary?: string - error?: string - } - -export interface RealtimeEvent { - op: 'I' | 'U' | 'D' - entity: 'task' | 'story' - id: string - story_id?: string - product_id: string - sprint_id: string | null - assignee_id: string | null - status?: SoloTaskStatus | 'OPEN' | 'IN_SPRINT' | 'DONE' - sort_order?: number - title?: string - code?: string | null - description?: string | null - priority?: number - task_status?: SoloTaskStatus - task_sort_order?: number - task_title?: string - story_status?: 'OPEN' | 'IN_SPRINT' | 'DONE' - story_sort_order?: number - story_title?: string - story_code?: string | null - changed_fields?: string[] -} - -export type RealtimeStatus = 'connecting' | 'open' | 'disconnected' - -export type ResyncReason = 'visible' | 'reconnect' | 'manual' | 'unknown-event' diff --git a/stores/sprint-store.ts b/stores/sprint-store.ts new file mode 100644 index 0000000..c332eea --- /dev/null +++ b/stores/sprint-store.ts @@ -0,0 +1,57 @@ +import { create } from 'zustand' + +interface SprintStore { + // sprintId → storyId[] + sprintStoryOrder: Record<string, string[]> + // storyId → taskId[] + taskOrder: Record<string, string[]> + + initSprint: (sprintId: string, storyIds: string[]) => void + addStoryToSprint: (sprintId: string, storyId: string) => void + removeStoryFromSprint: (sprintId: string, storyId: string) => void + reorderSprintStories: (sprintId: string, storyIds: string[]) => void + rollbackSprint: (sprintId: string, storyIds: string[]) => void + + initTasks: (storyId: string, taskIds: string[]) => void + reorderTasks: (storyId: string, taskIds: string[]) => void + rollbackTasks: (storyId: string, taskIds: string[]) => void +} + +export const useSprintStore = create<SprintStore>((set) => ({ + sprintStoryOrder: {}, + taskOrder: {}, + + initSprint: (sprintId, storyIds) => + set((s) => ({ sprintStoryOrder: { ...s.sprintStoryOrder, [sprintId]: storyIds } })), + + addStoryToSprint: (sprintId, storyId) => + set((s) => ({ + sprintStoryOrder: { + ...s.sprintStoryOrder, + [sprintId]: [...(s.sprintStoryOrder[sprintId] ?? []), storyId], + }, + })), + + removeStoryFromSprint: (sprintId, storyId) => + set((s) => ({ + sprintStoryOrder: { + ...s.sprintStoryOrder, + [sprintId]: (s.sprintStoryOrder[sprintId] ?? []).filter((id) => id !== storyId), + }, + })), + + reorderSprintStories: (sprintId, storyIds) => + set((s) => ({ sprintStoryOrder: { ...s.sprintStoryOrder, [sprintId]: storyIds } })), + + rollbackSprint: (sprintId, storyIds) => + set((s) => ({ sprintStoryOrder: { ...s.sprintStoryOrder, [sprintId]: storyIds } })), + + initTasks: (storyId, taskIds) => + set((s) => ({ taskOrder: { ...s.taskOrder, [storyId]: taskIds } })), + + reorderTasks: (storyId, taskIds) => + set((s) => ({ taskOrder: { ...s.taskOrder, [storyId]: taskIds } })), + + rollbackTasks: (storyId, taskIds) => + set((s) => ({ taskOrder: { ...s.taskOrder, [storyId]: taskIds } })), +})) diff --git a/stores/sprint-workspace/restore.ts b/stores/sprint-workspace/restore.ts deleted file mode 100644 index c81f857..0000000 --- a/stores/sprint-workspace/restore.ts +++ /dev/null @@ -1,128 +0,0 @@ -const STORAGE_KEY = 'sprint-workspace-hints' - -interface PerProductHint { - lastActiveSprintId?: string | null -} - -interface PerSprintHint { - lastActiveStoryId?: string | null - lastActiveTaskId?: string | null -} - -export interface SprintWorkspaceHints { - lastActiveProductId: string | null - perProduct: Record<string, PerProductHint> - perSprint: Record<string, PerSprintHint> -} - -const EMPTY_HINTS: SprintWorkspaceHints = { - lastActiveProductId: null, - perProduct: {}, - perSprint: {}, -} - -function safeStorage(): Storage | null { - if (typeof globalThis === 'undefined') return null - try { - const ls = (globalThis as { localStorage?: Storage }).localStorage - return ls ?? null - } catch { - return null - } -} - -export function readHints(): SprintWorkspaceHints { - const storage = safeStorage() - if (!storage) return { ...EMPTY_HINTS, perProduct: {}, perSprint: {} } - try { - const raw = storage.getItem(STORAGE_KEY) - if (!raw) return { ...EMPTY_HINTS, perProduct: {}, perSprint: {} } - const parsed = JSON.parse(raw) as Partial<SprintWorkspaceHints> | null - if (!parsed || typeof parsed !== 'object') { - return { ...EMPTY_HINTS, perProduct: {}, perSprint: {} } - } - return { - lastActiveProductId: parsed.lastActiveProductId ?? null, - perProduct: - parsed.perProduct && typeof parsed.perProduct === 'object' - ? parsed.perProduct - : {}, - perSprint: - parsed.perSprint && typeof parsed.perSprint === 'object' - ? parsed.perSprint - : {}, - } - } catch { - return { ...EMPTY_HINTS, perProduct: {}, perSprint: {} } - } -} - -function writeHints(hints: SprintWorkspaceHints): void { - const storage = safeStorage() - if (!storage) return - try { - storage.setItem(STORAGE_KEY, JSON.stringify(hints)) - } catch { - // ignore quota or serialization errors - } -} - -export function writeProductHint(productId: string | null): void { - const hints = readHints() - hints.lastActiveProductId = productId - writeHints(hints) -} - -function ensurePerProduct( - hints: SprintWorkspaceHints, - productId: string, -): PerProductHint { - if (!hints.perProduct[productId]) { - hints.perProduct[productId] = {} - } - return hints.perProduct[productId] -} - -function ensurePerSprint( - hints: SprintWorkspaceHints, - sprintId: string, -): PerSprintHint { - if (!hints.perSprint[sprintId]) { - hints.perSprint[sprintId] = {} - } - return hints.perSprint[sprintId] -} - -export function writeSprintHint(productId: string, sprintId: string | null): void { - const hints = readHints() - const entry = ensurePerProduct(hints, productId) - entry.lastActiveSprintId = sprintId - writeHints(hints) -} - -export function writeStoryHint(sprintId: string, storyId: string | null): void { - const hints = readHints() - const entry = ensurePerSprint(hints, sprintId) - entry.lastActiveStoryId = storyId - if (storyId === null) { - entry.lastActiveTaskId = null - } - writeHints(hints) -} - -export function writeTaskHint(sprintId: string, taskId: string | null): void { - const hints = readHints() - const entry = ensurePerSprint(hints, sprintId) - entry.lastActiveTaskId = taskId - writeHints(hints) -} - -export function clearHints(): void { - const storage = safeStorage() - if (!storage) return - try { - storage.removeItem(STORAGE_KEY) - } catch { - // ignore - } -} diff --git a/stores/sprint-workspace/selectors.ts b/stores/sprint-workspace/selectors.ts deleted file mode 100644 index 12f79d9..0000000 --- a/stores/sprint-workspace/selectors.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { SprintWorkspaceStore } from './store' -import type { - SprintWorkspaceSprint, - SprintWorkspaceStory, - SprintWorkspaceTask, - SprintWorkspaceTaskDetail, -} from './types' - -// G1: stable EMPTY-references zodat selectors geen nieuwe array per call retourneren. -const EMPTY_SPRINTS: SprintWorkspaceSprint[] = [] -const EMPTY_STORIES: SprintWorkspaceStory[] = [] -const EMPTY_TASKS: (SprintWorkspaceTask | SprintWorkspaceTaskDetail)[] = [] - -/** - * Lijst-selector. Vereist `useShallow` in componenten (G2). - */ -export function selectVisibleSprints(s: SprintWorkspaceStore): SprintWorkspaceSprint[] { - const productId = s.context.activeProduct?.id - if (!productId) return EMPTY_SPRINTS - const ids = s.relations.sprintIdsByProduct[productId] - if (!ids || ids.length === 0) return EMPTY_SPRINTS - const out: SprintWorkspaceSprint[] = [] - for (const id of ids) { - const sprint = s.entities.sprintsById[id] - if (sprint) out.push(sprint) - } - return out.length === 0 ? EMPTY_SPRINTS : out -} - -/** - * Lijst-selector. Vereist `useShallow` in componenten (G2). - */ -export function selectStoriesForActiveSprint( - s: SprintWorkspaceStore, -): SprintWorkspaceStory[] { - const sprintId = s.context.activeSprintId - if (!sprintId) return EMPTY_STORIES - const ids = s.relations.storyIdsBySprint[sprintId] - if (!ids || ids.length === 0) return EMPTY_STORIES - const out: SprintWorkspaceStory[] = [] - for (const id of ids) { - const story = s.entities.storiesById[id] - if (story) out.push(story) - } - return out.length === 0 ? EMPTY_STORIES : out -} - -/** - * Lijst-selector. Vereist `useShallow` in componenten (G2). - */ -export function selectTasksForActiveStory( - s: SprintWorkspaceStore, -): (SprintWorkspaceTask | SprintWorkspaceTaskDetail)[] { - const storyId = s.context.activeStoryId - if (!storyId) return EMPTY_TASKS - const ids = s.relations.taskIdsByStory[storyId] - if (!ids || ids.length === 0) return EMPTY_TASKS - const out: (SprintWorkspaceTask | SprintWorkspaceTaskDetail)[] = [] - for (const id of ids) { - const task = s.entities.tasksById[id] - if (task) out.push(task) - } - return out.length === 0 ? EMPTY_TASKS : out -} - -/** - * Single-value selector. `useShallow` niet vereist. - */ -export function selectActiveSprint( - s: SprintWorkspaceStore, -): SprintWorkspaceSprint | null { - const id = s.context.activeSprintId - if (!id) return null - return s.entities.sprintsById[id] ?? null -} - -/** - * Single-value selector. `useShallow` niet vereist. - */ -export function selectActiveStory( - s: SprintWorkspaceStore, -): SprintWorkspaceStory | null { - const id = s.context.activeStoryId - if (!id) return null - return s.entities.storiesById[id] ?? null -} - -/** - * Single-value selector. `useShallow` niet vereist. - */ -export function selectActiveTask( - s: SprintWorkspaceStore, -): SprintWorkspaceTask | SprintWorkspaceTaskDetail | null { - const id = s.context.activeTaskId - if (!id) return null - return s.entities.tasksById[id] ?? null -} - -/** - * Lijst-selector voor tasks binnen een specifieke story (niet per se actief). - * Vereist `useShallow` in componenten (G2). - */ -export function selectTasksForStory( - s: SprintWorkspaceStore, - storyId: string, -): (SprintWorkspaceTask | SprintWorkspaceTaskDetail)[] { - const ids = s.relations.taskIdsByStory[storyId] - if (!ids || ids.length === 0) return EMPTY_TASKS - const out: (SprintWorkspaceTask | SprintWorkspaceTaskDetail)[] = [] - for (const id of ids) { - const task = s.entities.tasksById[id] - if (task) out.push(task) - } - return out.length === 0 ? EMPTY_TASKS : out -} diff --git a/stores/sprint-workspace/store.ts b/stores/sprint-workspace/store.ts deleted file mode 100644 index 34a1941..0000000 --- a/stores/sprint-workspace/store.ts +++ /dev/null @@ -1,969 +0,0 @@ -import { create } from 'zustand' -import { immer } from 'zustand/middleware/immer' - -import { - isDetail, - type ActiveProductRef, - type OptimisticMutation, - type PendingOptimisticMutation, - type RealtimeStatus, - type ResyncReason, - type SprintWorkspaceSnapshot, - type SprintWorkspaceSprint, - type SprintWorkspaceStory, - type SprintWorkspaceTask, - type SprintWorkspaceTaskDetail, -} from './types' -import { - readHints, - writeProductHint, - writeSprintHint, - writeStoryHint, - writeTaskHint, -} from './restore' -import { - normalizeSprintTask, - normalizeSprintWorkspaceSnapshot, - normalizeStoryStatusForStore, - normalizeTaskStatusForStore, -} from '@/stores/workspace-status-adapter' - -interface ContextSlice { - activeProduct: ActiveProductRef | null - activeSprintId: string | null - activeStoryId: string | null - activeTaskId: string | null -} - -interface EntitiesSlice { - sprintsById: Record<string, SprintWorkspaceSprint> - storiesById: Record<string, SprintWorkspaceStory> - tasksById: Record<string, SprintWorkspaceTask | SprintWorkspaceTaskDetail> -} - -interface RelationsSlice { - sprintIdsByProduct: Record<string, string[]> - storyIdsBySprint: Record<string, string[]> - taskIdsByStory: Record<string, string[]> -} - -interface LoadingSlice { - loadedProductSprintsIds: Record<string, true> - loadingProductId: string | null - loadedSprintIds: Record<string, true> - loadingSprintId: string | null - loadedStoryIds: Record<string, true> - loadedTaskIds: Record<string, true> - activeRequestId: string | null -} - -interface SyncSlice { - realtimeStatus: RealtimeStatus - lastEventAt: number | null - lastResyncAt: number | null - resyncReason: ResyncReason | null -} - -interface State { - context: ContextSlice - entities: EntitiesSlice - relations: RelationsSlice - loading: LoadingSlice - sync: SyncSlice - pendingMutations: Record<string, PendingOptimisticMutation> -} - -interface Actions { - hydrateSnapshot(snapshot: SprintWorkspaceSnapshot): void - hydrateProductSprints(productId: string, sprints: SprintWorkspaceSprint[]): void - - setActiveProduct(product: ActiveProductRef | null): void - setActiveSprint(sprintId: string | null): void - setActiveStory(storyId: string | null): void - setActiveTask(taskId: string | null): void - - ensureProductSprintsLoaded(productId: string, requestId?: string): Promise<void> - ensureSprintLoaded(sprintId: string, requestId?: string): Promise<void> - ensureStoryLoaded(storyId: string, requestId?: string): Promise<void> - ensureTaskLoaded(taskId: string, requestId?: string): Promise<void> - - applyRealtimeEvent(event: Record<string, unknown>): void - resyncActiveScopes(reason: ResyncReason): Promise<void> - resyncLoadedScopes(reason: ResyncReason): Promise<void> - - applyOptimisticMutation(mutation: OptimisticMutation): string - rollbackMutation(mutationId: string): void - settleMutation(mutationId: string): void - - setRealtimeStatus(status: RealtimeStatus): void -} - -export type SprintWorkspaceStore = State & Actions - -const initialState: State = { - context: { - activeProduct: null, - activeSprintId: null, - activeStoryId: null, - activeTaskId: null, - }, - entities: { - sprintsById: {}, - storiesById: {}, - tasksById: {}, - }, - relations: { - sprintIdsByProduct: {}, - storyIdsBySprint: {}, - taskIdsByStory: {}, - }, - loading: { - loadedProductSprintsIds: {}, - loadingProductId: null, - loadedSprintIds: {}, - loadingSprintId: null, - loadedStoryIds: {}, - loadedTaskIds: {}, - activeRequestId: null, - }, - sync: { - realtimeStatus: 'connecting', - lastEventAt: null, - lastResyncAt: null, - resyncReason: null, - }, - pendingMutations: {}, -} - -function compareSprint(a: SprintWorkspaceSprint, b: SprintWorkspaceSprint): number { - // OPEN sprints first, then CLOSED - if (a.status !== b.status) return a.status === 'OPEN' ? -1 : 1 - // Newest start_date first within same status - const aStart = a.start_date ? new Date(a.start_date).getTime() : 0 - const bStart = b.start_date ? new Date(b.start_date).getTime() : 0 - if (aStart !== bStart) return bStart - aStart - return new Date(b.created_at).getTime() - new Date(a.created_at).getTime() -} - -function compareStory(a: SprintWorkspaceStory, b: SprintWorkspaceStory): number { - if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order - return new Date(a.created_at).getTime() - new Date(b.created_at).getTime() -} - -function compareTask(a: SprintWorkspaceTask, b: SprintWorkspaceTask): number { - if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order - return new Date(a.created_at).getTime() - new Date(b.created_at).getTime() -} - -function newRequestId(): string { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return crypto.randomUUID() - } - return `${Date.now()}-${Math.random().toString(36).slice(2)}` -} - -function isKnownEntity(entity: unknown): entity is 'sprint' | 'story' | 'task' { - return entity === 'sprint' || entity === 'story' || entity === 'task' -} - -function isUnknownEntityEvent(p: Record<string, unknown>): boolean { - if (typeof p.entity !== 'string') return false - if (isKnownEntity(p.entity)) return false - if ('type' in p) return false - return true -} - -async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> { - const response = await fetch(url, { cache: 'no-store', ...init }) - if (!response.ok) { - throw new Error(`Fetch ${url} failed with ${response.status}`) - } - return (await response.json()) as T -} - -export const useSprintWorkspaceStore = create<SprintWorkspaceStore>()( - immer((set, get) => ({ - ...initialState, - - hydrateSnapshot(inputSnapshot) { - const snapshot = normalizeSprintWorkspaceSnapshot(inputSnapshot) - set((s) => { - if (snapshot.product) s.context.activeProduct = snapshot.product - - const sprintId = snapshot.sprint?.id ?? null - const productId = snapshot.product?.id ?? snapshot.sprint?.product_id ?? null - - if (snapshot.sprint) { - s.entities.sprintsById[snapshot.sprint.id] = snapshot.sprint - if (productId) { - const list = s.relations.sprintIdsByProduct[productId] ?? [] - if (!list.includes(snapshot.sprint.id)) { - list.push(snapshot.sprint.id) - s.relations.sprintIdsByProduct[productId] = sortSprintIds( - s.entities.sprintsById, - list, - ) - } - } - } - - for (const story of snapshot.stories) { - s.entities.storiesById[story.id] = story - } - if (sprintId) { - s.relations.storyIdsBySprint[sprintId] = [...snapshot.stories] - .sort(compareStory) - .map((st) => st.id) - } - - for (const [storyId, tasks] of Object.entries(snapshot.tasksByStory)) { - for (const task of tasks) { - s.entities.tasksById[task.id] = task - } - s.relations.taskIdsByStory[storyId] = [...tasks] - .sort(compareTask) - .map((t) => t.id) - } - - if (sprintId) { - s.loading.loadedSprintIds[sprintId] = true - } - }) - }, - - hydrateProductSprints(productId, sprints) { - set((s) => { - for (const sprint of sprints) { - s.entities.sprintsById[sprint.id] = sprint - } - s.relations.sprintIdsByProduct[productId] = [...sprints] - .sort(compareSprint) - .map((sp) => sp.id) - s.loading.loadedProductSprintsIds[productId] = true - }) - }, - - setActiveProduct(product) { - const requestId = newRequestId() - const productChanged = get().context.activeProduct?.id !== product?.id - - set((s) => { - s.context.activeProduct = product - s.context.activeSprintId = null - s.context.activeStoryId = null - s.context.activeTaskId = null - s.loading.activeRequestId = requestId - - if (productChanged) { - s.entities.sprintsById = {} - s.entities.storiesById = {} - s.entities.tasksById = {} - s.relations.sprintIdsByProduct = {} - s.relations.storyIdsBySprint = {} - s.relations.taskIdsByStory = {} - s.loading.loadedProductSprintsIds = {} - s.loading.loadedSprintIds = {} - s.loading.loadedStoryIds = {} - s.loading.loadedTaskIds = {} - } - }) - - writeProductHint(product?.id ?? null) - - if (product) { - const productId = product.id - void (async () => { - await get().ensureProductSprintsLoaded(productId, requestId) - if (get().loading.activeRequestId !== requestId) return - const hint = readHints().perProduct[productId]?.lastActiveSprintId - if (hint && get().entities.sprintsById[hint]) { - get().setActiveSprint(hint) - } - })() - } - }, - - setActiveSprint(sprintId) { - const requestId = newRequestId() - const productId = get().context.activeProduct?.id ?? null - - set((s) => { - s.context.activeSprintId = sprintId - s.context.activeStoryId = null - s.context.activeTaskId = null - s.loading.activeRequestId = requestId - }) - - if (productId) writeSprintHint(productId, sprintId) - - if (sprintId) { - void (async () => { - await get().ensureSprintLoaded(sprintId, requestId) - if (get().loading.activeRequestId !== requestId) return - const hint = readHints().perSprint[sprintId]?.lastActiveStoryId - if (hint && get().entities.storiesById[hint]) { - get().setActiveStory(hint) - } - })() - } - }, - - setActiveStory(storyId) { - const requestId = newRequestId() - const sprintId = get().context.activeSprintId - - set((s) => { - s.context.activeStoryId = storyId - s.context.activeTaskId = null - s.loading.activeRequestId = requestId - }) - - if (sprintId) writeStoryHint(sprintId, storyId) - - if (storyId) { - void (async () => { - await get().ensureStoryLoaded(storyId, requestId) - if (get().loading.activeRequestId !== requestId) return - if (!sprintId) return - const hint = readHints().perSprint[sprintId]?.lastActiveTaskId - if (hint && get().entities.tasksById[hint]) { - get().setActiveTask(hint) - } - })() - } - }, - - setActiveTask(taskId) { - const sprintId = get().context.activeSprintId - - set((s) => { - s.context.activeTaskId = taskId - }) - - if (sprintId) writeTaskHint(sprintId, taskId) - - if (taskId) { - void get().ensureTaskLoaded(taskId) - } - }, - - async ensureProductSprintsLoaded(productId, requestId) { - set((s) => { - s.loading.loadingProductId = productId - }) - try { - const sprints = await fetchJson<SprintWorkspaceSprint[] | null>( - `/api/products/${encodeURIComponent(productId)}/sprints`, - ) - if (requestId && get().loading.activeRequestId !== requestId) return - if (!Array.isArray(sprints)) return - get().hydrateProductSprints(productId, sprints) - } finally { - set((s) => { - if (s.loading.loadingProductId === productId) { - s.loading.loadingProductId = null - } - }) - } - }, - - async ensureSprintLoaded(sprintId, requestId) { - set((s) => { - s.loading.loadingSprintId = sprintId - }) - try { - const snapshot = await fetchJson<SprintWorkspaceSnapshot | null>( - `/api/sprints/${encodeURIComponent(sprintId)}/workspace`, - ) - if (requestId && get().loading.activeRequestId !== requestId) return - if (!snapshot || !Array.isArray(snapshot.stories)) return - get().hydrateSnapshot(snapshot) - } finally { - set((s) => { - if (s.loading.loadingSprintId === sprintId) { - s.loading.loadingSprintId = null - } - }) - } - }, - - async ensureStoryLoaded(storyId, requestId) { - const tasks = await fetchJson<SprintWorkspaceTask[] | null>( - `/api/stories/${encodeURIComponent(storyId)}/tasks`, - ) - if (requestId && get().loading.activeRequestId !== requestId) return - if (!Array.isArray(tasks)) return - const normalizedTasks = tasks.map(normalizeSprintTask) - set((s) => { - for (const task of normalizedTasks) { - const existing = s.entities.tasksById[task.id] - if (existing && isDetail(existing)) { - s.entities.tasksById[task.id] = { ...existing, ...task } - } else { - s.entities.tasksById[task.id] = task - } - } - s.relations.taskIdsByStory[storyId] = [...normalizedTasks] - .sort(compareTask) - .map((t) => t.id) - s.loading.loadedStoryIds[storyId] = true - }) - }, - - async ensureTaskLoaded(taskId, requestId) { - const detail = await fetchJson<SprintWorkspaceTaskDetail | null>( - `/api/tasks/${encodeURIComponent(taskId)}`, - ) - if (requestId && get().loading.activeRequestId !== requestId) return - if (!detail || typeof detail !== 'object') return - const normalizedDetail = normalizeSprintTask(detail) - set((s) => { - s.entities.tasksById[taskId] = { ...normalizedDetail, _detail: true } - s.loading.loadedTaskIds[taskId] = true - }) - }, - - applyRealtimeEvent(event) { - const payload = event as Record<string, unknown> - const activeProductId = get().context.activeProduct?.id ?? null - - set((s) => { - s.sync.lastEventAt = Date.now() - }) - - if ( - typeof payload.product_id === 'string' && - activeProductId && - payload.product_id !== activeProductId - ) { - return - } - - if (isUnknownEntityEvent(payload)) { - if (payload.product_id === activeProductId) { - void get().resyncActiveScopes('unknown-event') - } - return - } - - const entity = payload.entity - const op = payload.op - if (!isKnownEntity(entity)) return - if (op !== 'I' && op !== 'U' && op !== 'D') return - - const id = payload.id - if (typeof id !== 'string') return - - if (entity === 'sprint') { - applySprintEvent(id, op, payload, set, get) - } else if (entity === 'story') { - applyStoryEvent(id, op, payload, set, get) - } else if (entity === 'task') { - applyTaskEvent(id, op, payload, set, get) - } - }, - - async resyncActiveScopes(reason) { - const ctx = get().context - const tasks: Promise<void>[] = [] - if (ctx.activeProduct?.id) { - tasks.push(get().ensureProductSprintsLoaded(ctx.activeProduct.id)) - } - if (ctx.activeSprintId) tasks.push(get().ensureSprintLoaded(ctx.activeSprintId)) - if (ctx.activeStoryId) tasks.push(get().ensureStoryLoaded(ctx.activeStoryId)) - if (ctx.activeTaskId) tasks.push(get().ensureTaskLoaded(ctx.activeTaskId)) - set((s) => { - s.sync.lastResyncAt = Date.now() - s.sync.resyncReason = reason - }) - await Promise.allSettled(tasks) - }, - - async resyncLoadedScopes(reason) { - const loading = get().loading - const tasks: Promise<void>[] = [] - for (const productId of Object.keys(loading.loadedProductSprintsIds)) { - tasks.push(get().ensureProductSprintsLoaded(productId)) - } - for (const sprintId of Object.keys(loading.loadedSprintIds)) { - tasks.push(get().ensureSprintLoaded(sprintId)) - } - for (const storyId of Object.keys(loading.loadedStoryIds)) { - tasks.push(get().ensureStoryLoaded(storyId)) - } - for (const taskId of Object.keys(loading.loadedTaskIds)) { - tasks.push(get().ensureTaskLoaded(taskId)) - } - set((s) => { - s.sync.lastResyncAt = Date.now() - s.sync.resyncReason = reason - }) - await Promise.allSettled(tasks) - }, - - applyOptimisticMutation(mutation) { - const id = newRequestId() - set((s) => { - s.pendingMutations[id] = { - id, - mutation, - createdAt: Date.now(), - } - }) - return id - }, - - rollbackMutation(mutationId) { - const pending = get().pendingMutations[mutationId] - if (!pending) return - const { mutation } = pending - set((s) => { - switch (mutation.kind) { - case 'entity-patch': { - const { entity, id, prev } = mutation - if (prev) { - if (entity === 'sprint') - s.entities.sprintsById[id] = prev as SprintWorkspaceSprint - else if (entity === 'story') - s.entities.storiesById[id] = prev as SprintWorkspaceStory - else - s.entities.tasksById[id] = prev as - | SprintWorkspaceTask - | SprintWorkspaceTaskDetail - } else { - if (entity === 'sprint') delete s.entities.sprintsById[id] - else if (entity === 'story') delete s.entities.storiesById[id] - else delete s.entities.tasksById[id] - } - break - } - } - delete s.pendingMutations[mutationId] - }) - }, - - settleMutation(mutationId) { - set((s) => { - delete s.pendingMutations[mutationId] - }) - }, - - setRealtimeStatus(status) { - set((s) => { - s.sync.realtimeStatus = status - }) - }, - })), -) - -type ImmerSet = Parameters<Parameters<typeof immer<SprintWorkspaceStore>>[0]>[0] -type ImmerGet = () => SprintWorkspaceStore - -function applySprintEvent( - id: string, - op: 'I' | 'U' | 'D', - payload: Record<string, unknown>, - set: ImmerSet, - get: ImmerGet, -) { - if (op === 'D') { - set((s) => { - const sprint = s.entities.sprintsById[id] - const productId = sprint?.product_id - // Cascade: stories binnen deze sprint, tasks binnen die stories - const childStoryIds = s.relations.storyIdsBySprint[id] ?? [] - for (const sid of childStoryIds) { - const childTaskIds = s.relations.taskIdsByStory[sid] ?? [] - for (const tid of childTaskIds) { - delete s.entities.tasksById[tid] - } - delete s.relations.taskIdsByStory[sid] - delete s.entities.storiesById[sid] - } - delete s.relations.storyIdsBySprint[id] - delete s.entities.sprintsById[id] - if (productId) { - const list = s.relations.sprintIdsByProduct[productId] - if (list) { - s.relations.sprintIdsByProduct[productId] = list.filter((sid) => sid !== id) - } - } else { - for (const pid of Object.keys(s.relations.sprintIdsByProduct)) { - s.relations.sprintIdsByProduct[pid] = s.relations.sprintIdsByProduct[pid].filter( - (sid) => sid !== id, - ) - } - } - if (s.context.activeSprintId === id) { - s.context.activeSprintId = null - s.context.activeStoryId = null - s.context.activeTaskId = null - } - }) - return - } - - if (op === 'U') { - if (!get().entities.sprintsById[id]) return - set((s) => { - const existing = s.entities.sprintsById[id] - if (!existing) return - Object.assign(existing, sanitizeSprintPayload(payload)) - const productId = existing.product_id - if (productId && s.relations.sprintIdsByProduct[productId]) { - s.relations.sprintIdsByProduct[productId] = sortSprintIds( - s.entities.sprintsById, - s.relations.sprintIdsByProduct[productId], - ) - } - }) - return - } - - // I - if (get().entities.sprintsById[id]) return - set((s) => { - const sprint = coerceSprintPayload(id, payload) - s.entities.sprintsById[id] = sprint - const productId = sprint.product_id - const list = s.relations.sprintIdsByProduct[productId] ?? [] - list.push(id) - s.relations.sprintIdsByProduct[productId] = sortSprintIds(s.entities.sprintsById, list) - }) -} - -function applyStoryEvent( - id: string, - op: 'I' | 'U' | 'D', - payload: Record<string, unknown>, - set: ImmerSet, - get: ImmerGet, -) { - const activeSprintId = get().context.activeSprintId - - if (op === 'D') { - set((s) => { - const childTaskIds = s.relations.taskIdsByStory[id] ?? [] - for (const tid of childTaskIds) { - delete s.entities.tasksById[tid] - } - delete s.relations.taskIdsByStory[id] - const story = s.entities.storiesById[id] - delete s.entities.storiesById[id] - if (story?.sprint_id) { - const ids = s.relations.storyIdsBySprint[story.sprint_id] - if (ids) { - s.relations.storyIdsBySprint[story.sprint_id] = ids.filter((sid) => sid !== id) - } - } else { - for (const sprintId of Object.keys(s.relations.storyIdsBySprint)) { - s.relations.storyIdsBySprint[sprintId] = s.relations.storyIdsBySprint[sprintId].filter( - (sid) => sid !== id, - ) - } - } - if (s.context.activeStoryId === id) { - s.context.activeStoryId = null - s.context.activeTaskId = null - } - }) - return - } - - if (op === 'U') { - const existing = get().entities.storiesById[id] - if (!existing) { - // Story moved into our active sprint? If sprint_id matches active, treat as I - if ( - activeSprintId && - payload.sprint_id === activeSprintId && - get().context.activeProduct?.id === payload.product_id - ) { - set((s) => { - const story = coerceStoryPayload(id, payload) - s.entities.storiesById[id] = story - if (story.sprint_id) { - const list = s.relations.storyIdsBySprint[story.sprint_id] ?? [] - if (!list.includes(id)) list.push(id) - s.relations.storyIdsBySprint[story.sprint_id] = sortStoryIds( - s.entities.storiesById, - list, - ) - } - }) - } - return - } - set((s) => { - const story = s.entities.storiesById[id] - if (!story) return - const oldSprintId = story.sprint_id - Object.assign(story, sanitizeStoryPayload(payload)) - const newSprintId = story.sprint_id - if (oldSprintId !== newSprintId) { - if (oldSprintId) { - const oldList = s.relations.storyIdsBySprint[oldSprintId] - if (oldList) { - s.relations.storyIdsBySprint[oldSprintId] = oldList.filter((sid) => sid !== id) - } - } - if (newSprintId) { - const targetList = s.relations.storyIdsBySprint[newSprintId] ?? [] - if (!targetList.includes(id)) targetList.push(id) - s.relations.storyIdsBySprint[newSprintId] = sortStoryIds( - s.entities.storiesById, - targetList, - ) - } - } else if (oldSprintId && s.relations.storyIdsBySprint[oldSprintId]) { - s.relations.storyIdsBySprint[oldSprintId] = sortStoryIds( - s.entities.storiesById, - s.relations.storyIdsBySprint[oldSprintId], - ) - } - }) - return - } - - // I - if (get().entities.storiesById[id]) return - set((s) => { - const story = coerceStoryPayload(id, payload) - s.entities.storiesById[id] = story - if (story.sprint_id) { - const list = s.relations.storyIdsBySprint[story.sprint_id] ?? [] - list.push(id) - s.relations.storyIdsBySprint[story.sprint_id] = sortStoryIds(s.entities.storiesById, list) - } - }) -} - -function applyTaskEvent( - id: string, - op: 'I' | 'U' | 'D', - payload: Record<string, unknown>, - set: ImmerSet, - get: ImmerGet, -) { - if (op === 'D') { - set((s) => { - const task = s.entities.tasksById[id] - delete s.entities.tasksById[id] - if (task) { - const ids = s.relations.taskIdsByStory[task.story_id] - if (ids) { - s.relations.taskIdsByStory[task.story_id] = ids.filter((tid) => tid !== id) - } - } else { - for (const storyId of Object.keys(s.relations.taskIdsByStory)) { - s.relations.taskIdsByStory[storyId] = s.relations.taskIdsByStory[storyId].filter( - (tid) => tid !== id, - ) - } - } - if (s.context.activeTaskId === id) { - s.context.activeTaskId = null - } - }) - return - } - - if (op === 'U') { - const existing = get().entities.tasksById[id] - if (!existing) return - set((s) => { - const task = s.entities.tasksById[id] - if (!task) return - const oldStoryId = task.story_id - Object.assign(task, sanitizeTaskPayload(payload)) - const newStoryId = task.story_id - if (oldStoryId !== newStoryId) { - const oldList = s.relations.taskIdsByStory[oldStoryId] - if (oldList) { - s.relations.taskIdsByStory[oldStoryId] = oldList.filter((tid) => tid !== id) - } - const targetList = s.relations.taskIdsByStory[newStoryId] ?? [] - if (!targetList.includes(id)) targetList.push(id) - s.relations.taskIdsByStory[newStoryId] = sortTaskIds(s.entities.tasksById, targetList) - } else if (s.relations.taskIdsByStory[oldStoryId]) { - s.relations.taskIdsByStory[oldStoryId] = sortTaskIds( - s.entities.tasksById, - s.relations.taskIdsByStory[oldStoryId], - ) - } - }) - return - } - - // I - if (get().entities.tasksById[id]) return - set((s) => { - const task = coerceTaskPayload(id, payload) - s.entities.tasksById[id] = task - const list = s.relations.taskIdsByStory[task.story_id] ?? [] - list.push(id) - s.relations.taskIdsByStory[task.story_id] = sortTaskIds(s.entities.tasksById, list) - }) -} - -function sortSprintIds( - byId: Record<string, SprintWorkspaceSprint>, - ids: string[], -): string[] { - return [...new Set(ids)] - .filter((id) => byId[id] !== undefined) - .sort((a, b) => compareSprint(byId[a], byId[b])) -} - -function sortStoryIds( - byId: Record<string, SprintWorkspaceStory>, - ids: string[], -): string[] { - return [...new Set(ids)] - .filter((id) => byId[id] !== undefined) - .sort((a, b) => compareStory(byId[a], byId[b])) -} - -function sortTaskIds( - byId: Record<string, SprintWorkspaceTask | SprintWorkspaceTaskDetail>, - ids: string[], -): string[] { - return [...new Set(ids)] - .filter((id) => byId[id] !== undefined) - .sort((a, b) => compareTask(byId[a], byId[b])) -} - -function sanitizeSprintPayload(p: Record<string, unknown>): Partial<SprintWorkspaceSprint> { - const { entity: _e, op: _o, ...rest } = p - void _e - void _o - return rest as Partial<SprintWorkspaceSprint> -} - -function sanitizeStoryPayload(p: Record<string, unknown>): Partial<SprintWorkspaceStory> { - const { - entity: _e, - op: _o, - story_status, - story_sort_order, - story_title, - story_code, - ...rest - } = p - void _e - void _o - if (rest.status === undefined && typeof story_status === 'string') { - rest.status = story_status - } - if (rest.sort_order === undefined && typeof story_sort_order === 'number') { - rest.sort_order = story_sort_order - } - if (rest.title === undefined && typeof story_title === 'string') { - rest.title = story_title - } - if (rest.code === undefined && (typeof story_code === 'string' || story_code === null)) { - rest.code = story_code - } - if (typeof rest.status === 'string') { - rest.status = normalizeStoryStatusForStore(rest.status) - } - return rest as Partial<SprintWorkspaceStory> -} - -function sanitizeTaskPayload(p: Record<string, unknown>): Partial<SprintWorkspaceTask> { - const { - entity: _e, - op: _o, - task_status, - task_sort_order, - task_title, - ...rest - } = p - void _e - void _o - if (rest.status === undefined && typeof task_status === 'string') { - rest.status = task_status - } - if (rest.sort_order === undefined && typeof task_sort_order === 'number') { - rest.sort_order = task_sort_order - } - if (rest.title === undefined && typeof task_title === 'string') { - rest.title = task_title - } - if (typeof rest.status === 'string') { - rest.status = normalizeTaskStatusForStore(rest.status) - } - return rest as Partial<SprintWorkspaceTask> -} - -function coerceSprintPayload( - id: string, - p: Record<string, unknown>, -): SprintWorkspaceSprint { - return { - id, - product_id: String(p.product_id ?? ''), - code: String(p.code ?? ''), - sprint_goal: String(p.sprint_goal ?? ''), - status: (p.status as SprintWorkspaceSprint['status']) ?? 'OPEN', - start_date: (p.start_date as string | null | undefined) ?? null, - end_date: (p.end_date as string | null | undefined) ?? null, - created_at: - p.created_at instanceof Date - ? p.created_at - : new Date(String(p.created_at ?? Date.now())), - completed_at: - p.completed_at instanceof Date - ? p.completed_at - : p.completed_at - ? new Date(String(p.completed_at)) - : null, - } -} - -function coerceStoryPayload( - id: string, - p: Record<string, unknown>, -): SprintWorkspaceStory { - const status = p.status ?? p.story_status ?? 'OPEN' - const sortOrder = p.sort_order ?? p.story_sort_order ?? 0 - const title = p.title ?? p.story_title ?? '' - const code = p.code ?? p.story_code ?? null - return { - id, - code: (code as string | null) ?? null, - title: String(title), - description: (p.description as string | null | undefined) ?? null, - acceptance_criteria: (p.acceptance_criteria as string | null | undefined) ?? null, - priority: Number(p.priority ?? 4), - sort_order: Number(sortOrder), - status: normalizeStoryStatusForStore(String(status)), - pbi_id: String(p.pbi_id ?? ''), - sprint_id: (p.sprint_id as string | null | undefined) ?? null, - created_at: - p.created_at instanceof Date - ? p.created_at - : new Date(String(p.created_at ?? Date.now())), - } -} - -function coerceTaskPayload(id: string, p: Record<string, unknown>): SprintWorkspaceTask { - const status = p.status ?? p.task_status ?? 'TO_DO' - const sortOrder = p.sort_order ?? p.task_sort_order ?? 0 - const title = p.title ?? p.task_title ?? '' - return { - id, - code: (p.code as string | null) ?? null, - title: String(title), - description: (p.description as string | null | undefined) ?? null, - priority: Number(p.priority ?? 4), - sort_order: Number(sortOrder), - status: normalizeTaskStatusForStore(String(status)), - story_id: String(p.story_id ?? ''), - sprint_id: (p.sprint_id as string | null | undefined) ?? null, - created_at: - p.created_at instanceof Date - ? p.created_at - : new Date(String(p.created_at ?? Date.now())), - } -} diff --git a/stores/sprint-workspace/types.ts b/stores/sprint-workspace/types.ts deleted file mode 100644 index 00858d8..0000000 --- a/stores/sprint-workspace/types.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { TaskStatusApi } from '@/lib/task-status' - -export type SprintStatus = 'OPEN' | 'CLOSED' - -export interface SprintWorkspaceSprint { - id: string - product_id: string - code: string - sprint_goal: string - status: SprintStatus - start_date: string | null - end_date: string | null - created_at: Date - completed_at: Date | null -} - -export interface SprintWorkspaceStory { - id: string - code: string | null - title: string - description: string | null - acceptance_criteria: string | null - priority: number - sort_order: number - status: string - pbi_id: string - sprint_id: string | null - created_at: Date - taskCount?: number - doneCount?: number - assignee_id?: string | null - assignee_username?: string | null -} - -export interface SprintWorkspaceTask { - id: string - code: string | null - title: string - description: string | null - priority: number - sort_order: number - status: TaskStatusApi | string - story_id: string - sprint_id: string | null - created_at: Date -} - -export interface SprintWorkspaceTaskDetail extends SprintWorkspaceTask { - _detail: true - implementation_plan?: string | null - acceptance_criteria?: string | null - requires_opus?: boolean - verify_only?: boolean - estimated_minutes?: number | null -} - -export function isDetail( - task: SprintWorkspaceTask | SprintWorkspaceTaskDetail, -): task is SprintWorkspaceTaskDetail { - return (task as SprintWorkspaceTaskDetail)._detail === true -} - -export interface ActiveProductRef { - id: string - name: string -} - -export interface SprintWorkspaceSnapshot { - product?: ActiveProductRef - sprint?: SprintWorkspaceSprint - stories: SprintWorkspaceStory[] - tasksByStory: Record<string, SprintWorkspaceTask[]> -} - -export interface ProductSprintsList { - productId: string - sprints: SprintWorkspaceSprint[] -} - -export type Op = 'I' | 'U' | 'D' - -export interface SprintEntityRealtimeEvent { - entity: 'sprint' - op: Op - id: string - product_id: string - [key: string]: unknown -} - -export interface SprintStoryRealtimeEvent { - entity: 'story' - op: Op - id: string - product_id: string - pbi_id?: string - sprint_id?: string | null - [key: string]: unknown -} - -export interface SprintTaskRealtimeEvent { - entity: 'task' - op: Op - id: string - product_id: string - story_id?: string - sprint_id?: string | null - [key: string]: unknown -} - -export type SprintRealtimeEvent = - | SprintEntityRealtimeEvent - | SprintStoryRealtimeEvent - | SprintTaskRealtimeEvent - -export type ResyncReason = - | 'visible' - | 'reconnect' - | 'manual' - | 'unknown-event' - | 'stale-scope' - | 'mutation-settled' - -export type RealtimeStatus = 'connecting' | 'open' | 'disconnected' - -export interface OptimisticEntityPatchMutation { - kind: 'entity-patch' - entity: 'sprint' | 'story' | 'task' - id: string - prev: - | SprintWorkspaceSprint - | SprintWorkspaceStory - | SprintWorkspaceTask - | SprintWorkspaceTaskDetail - | undefined -} - -export type OptimisticMutation = - | OptimisticEntityPatchMutation - -export interface PendingOptimisticMutation { - id: string - mutation: OptimisticMutation - createdAt: number -} diff --git a/stores/user-settings/selectors.ts b/stores/user-settings/selectors.ts deleted file mode 100644 index 24ddb80..0000000 --- a/stores/user-settings/selectors.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { UserSettings } from '@/lib/user-settings' - -interface StateLike { - entities: { settings: UserSettings } - context: { hydrated: boolean; isDemo: boolean } -} - -export const selectSprintBacklogPrefs = (s: StateLike) => - s.entities.settings.views?.sprintBacklog ?? {} - -export const selectPbiListPrefs = (s: StateLike) => - s.entities.settings.views?.pbiList ?? {} - -export const selectStoryPanelPrefs = (s: StateLike) => - s.entities.settings.views?.storyPanel ?? {} - -export const selectJobsColumnPrefs = (key: string) => (s: StateLike) => - s.entities.settings.views?.jobsColumns?.[key] ?? { kinds: [], statuses: [] } - -export const selectDevToolsPrefs = (s: StateLike) => - s.entities.settings.devTools ?? {} - -export const selectHydrated = (s: StateLike) => s.context.hydrated diff --git a/stores/user-settings/store.ts b/stores/user-settings/store.ts deleted file mode 100644 index 639f835..0000000 --- a/stores/user-settings/store.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { create } from 'zustand' -import { immer } from 'zustand/middleware/immer' - -import { - DEFAULT_USER_SETTINGS, - mergeSettings, - type PbiIntent, - type PendingSprintDraft, - type UserSettings, -} from '@/lib/user-settings' -import { updateUserSettingsAction } from '@/actions/user-settings' - -type SettingsPath = readonly (string | number)[] - -interface PendingMutation { - id: number - prev: UserSettings -} - -interface UserSettingsState { - entities: { settings: UserSettings } - context: { - hydrated: boolean - isDemo: boolean - } - pendingMutations: Record<number, PendingMutation> -} - -interface UserSettingsActions { - hydrate: (initial: UserSettings, isDemo: boolean) => void - setPref: (path: SettingsPath, value: unknown) => Promise<void> - applyServerPatch: (patch: Partial<UserSettings>) => void - setPendingSprintDraft: ( - productId: string, - draft: PendingSprintDraft, - ) => Promise<void> - clearPendingSprintDraft: (productId: string) => Promise<void> - upsertPbiIntent: ( - productId: string, - pbiId: string, - intent: PbiIntent, - ) => Promise<void> - upsertStoryOverride: ( - productId: string, - pbiId: string, - storyId: string, - kind: 'add' | 'remove' | 'clear', - ) => Promise<void> -} - -let nextMutationId = 1 - -function patchFromPath(path: SettingsPath, value: unknown): Partial<UserSettings> { - if (path.length === 0) { - if (value && typeof value === 'object' && !Array.isArray(value)) { - return value as Partial<UserSettings> - } - return {} - } - const out: Record<string, unknown> = {} - let cursor: Record<string, unknown> = out - for (let i = 0; i < path.length - 1; i++) { - const key = String(path[i]) - cursor[key] = {} - cursor = cursor[key] as Record<string, unknown> - } - cursor[String(path[path.length - 1])] = value - return out as Partial<UserSettings> -} - -export const useUserSettingsStore = create<UserSettingsState & UserSettingsActions>()( - immer((set, get) => ({ - entities: { settings: DEFAULT_USER_SETTINGS }, - context: { hydrated: false, isDemo: false }, - pendingMutations: {}, - - hydrate: (initial, isDemo) => { - set((draft) => { - // PBI-79 scope-aanpassing: pendingSprintDraft is session-only; - // eventuele legacy DB-entries van vóór deze aanpassing worden bij - // hydratatie weggegooid zodat de draft niet 'spookt'. - const stripped: UserSettings = { ...initial } - if (stripped.workflow?.pendingSprintDraft) { - stripped.workflow = { ...stripped.workflow } - delete stripped.workflow.pendingSprintDraft - } - draft.entities.settings = stripped - draft.context.hydrated = true - draft.context.isDemo = isDemo - }) - }, - - applyServerPatch: (patch) => { - set((draft) => { - draft.entities.settings = mergeSettings( - draft.entities.settings as UserSettings, - patch, - ) as UserSettings - }) - }, - - setPendingSprintDraft: async (productId, draft) => { - // PBI-79 scope-aanpassing: session-only. Geen server-roundtrip; - // de draft leeft uitsluitend in deze store-instantie en is bij - // page-refresh/leave weg (zie SprintDraftLeaveGuard voor de - // beforeunload-warning). - set((s) => { - if (!s.entities.settings.workflow) s.entities.settings.workflow = {} - if (!s.entities.settings.workflow.pendingSprintDraft) { - s.entities.settings.workflow.pendingSprintDraft = {} - } - s.entities.settings.workflow.pendingSprintDraft[productId] = draft - }) - }, - - clearPendingSprintDraft: async (productId) => { - // PBI-79 scope-aanpassing: session-only — lokale delete is voldoende. - set((s) => { - const map = s.entities.settings.workflow?.pendingSprintDraft - if (map) delete map[productId] - }) - }, - - upsertPbiIntent: async (productId, pbiId, intent) => { - const current = - get().entities.settings.workflow?.pendingSprintDraft?.[productId] - if (!current) return - const nextOverrides = { ...current.storyOverrides } - delete nextOverrides[pbiId] - const next: PendingSprintDraft = { - ...current, - pbiIntent: { ...current.pbiIntent, [pbiId]: intent }, - storyOverrides: nextOverrides, - } - await get().setPendingSprintDraft(productId, next) - }, - - upsertStoryOverride: async (productId, pbiId, storyId, kind) => { - const current = - get().entities.settings.workflow?.pendingSprintDraft?.[productId] - if (!current) return - const existing = current.storyOverrides[pbiId] ?? { add: [], remove: [] } - const dropFrom = (arr: string[]) => arr.filter((id) => id !== storyId) - let nextEntry: { add: string[]; remove: string[] } - switch (kind) { - case 'add': - nextEntry = { - add: existing.add.includes(storyId) ? existing.add : [...existing.add, storyId], - remove: dropFrom(existing.remove), - } - break - case 'remove': - nextEntry = { - add: dropFrom(existing.add), - remove: existing.remove.includes(storyId) - ? existing.remove - : [...existing.remove, storyId], - } - break - case 'clear': - default: - nextEntry = { add: dropFrom(existing.add), remove: dropFrom(existing.remove) } - break - } - const nextOverrides = { ...current.storyOverrides } - if (nextEntry.add.length === 0 && nextEntry.remove.length === 0) { - delete nextOverrides[pbiId] - } else { - nextOverrides[pbiId] = nextEntry - } - const next: PendingSprintDraft = { ...current, storyOverrides: nextOverrides } - await get().setPendingSprintDraft(productId, next) - }, - - setPref: async (path, value) => { - const patch = patchFromPath(path, value) - - // Demo: lokale merge zonder server-call. - if (get().context.isDemo) { - set((draft) => { - draft.entities.settings = mergeSettings( - draft.entities.settings as UserSettings, - patch, - ) as UserSettings - }) - return - } - - const mutationId = nextMutationId++ - const prev = get().entities.settings as UserSettings - - // Optimistic. - set((draft) => { - draft.entities.settings = mergeSettings( - draft.entities.settings as UserSettings, - patch, - ) as UserSettings - draft.pendingMutations[mutationId] = { id: mutationId, prev } - }) - - const result = await updateUserSettingsAction(patch) - - set((draft) => { - delete draft.pendingMutations[mutationId] - if ('error' in result) { - // Rollback alleen als geen latere mutatie de waarde alweer heeft overschreven. - draft.entities.settings = prev as UserSettings - } else { - // Settle: server-merge is autoritatief. - draft.entities.settings = result.settings as UserSettings - } - }) - }, - })), -) diff --git a/stores/workspace-status-adapter.ts b/stores/workspace-status-adapter.ts deleted file mode 100644 index 8900058..0000000 --- a/stores/workspace-status-adapter.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { - pbiStatusFromApi, - pbiStatusToApi, - storyStatusFromApi, - taskStatusFromApi, -} from '@/lib/task-status' -import type { - BacklogPbi, - BacklogStory, - BacklogTask, - ProductBacklogSnapshot, - TaskDetail, -} from '@/stores/product-workspace/types' -import type { - SprintWorkspaceSnapshot, - SprintWorkspaceStory, - SprintWorkspaceTask, - SprintWorkspaceTaskDetail, -} from '@/stores/sprint-workspace/types' - -export function normalizePbiStatusForStore(status: string): BacklogPbi['status'] { - const dbStatus = pbiStatusFromApi(status) - return dbStatus ? pbiStatusToApi(dbStatus) : (status as BacklogPbi['status']) -} - -export function normalizeStoryStatusForStore(status: string): string { - return storyStatusFromApi(status) ?? status -} - -export function normalizeTaskStatusForStore(status: string): string { - return taskStatusFromApi(status) ?? status -} - -export function normalizeBacklogPbi<T extends BacklogPbi>(pbi: T): T { - const status = normalizePbiStatusForStore(pbi.status) - return status === pbi.status ? pbi : { ...pbi, status } -} - -export function normalizeBacklogStory<T extends BacklogStory>(story: T): T { - const status = normalizeStoryStatusForStore(story.status) - return status === story.status ? story : { ...story, status } -} - -export function normalizeBacklogTask<T extends BacklogTask | TaskDetail>(task: T): T { - const status = normalizeTaskStatusForStore(task.status) - return status === task.status ? task : { ...task, status } -} - -export function normalizeSprintStory<T extends SprintWorkspaceStory>(story: T): T { - const status = normalizeStoryStatusForStore(story.status) - return status === story.status ? story : { ...story, status } -} - -export function normalizeSprintTask<T extends SprintWorkspaceTask | SprintWorkspaceTaskDetail>( - task: T, -): T { - const status = normalizeTaskStatusForStore(task.status) - return status === task.status ? task : { ...task, status } -} - -export function normalizeProductBacklogSnapshot( - snapshot: ProductBacklogSnapshot, -): ProductBacklogSnapshot { - return { - ...snapshot, - pbis: snapshot.pbis.map(normalizeBacklogPbi), - storiesByPbi: mapRecordLists(snapshot.storiesByPbi, normalizeBacklogStory), - tasksByStory: mapRecordLists(snapshot.tasksByStory, normalizeBacklogTask), - } -} - -export function normalizeSprintWorkspaceSnapshot( - snapshot: SprintWorkspaceSnapshot, -): SprintWorkspaceSnapshot { - return { - ...snapshot, - stories: snapshot.stories.map(normalizeSprintStory), - tasksByStory: mapRecordLists(snapshot.tasksByStory, normalizeSprintTask), - } -} - -function mapRecordLists<T>(record: Record<string, T[]>, normalize: (item: T) => T): Record<string, T[]> { - const next: Record<string, T[]> = {} - for (const [id, list] of Object.entries(record)) { - next[id] = list.map(normalize) - } - return next -} diff --git a/tests/setup.ts b/tests/setup.ts deleted file mode 100644 index a03cd00..0000000 --- a/tests/setup.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { beforeEach, vi } from 'vitest' - -// G6: vitest 4 + jsdom 29 mist localStorage/sessionStorage op globalThis. -// MemoryStorage-binding zodat tests zonder echte browser draaien. -class MemoryStorage implements Storage { - private store = new Map<string, string>() - - get length(): number { - return this.store.size - } - - clear(): void { - this.store.clear() - } - - getItem(key: string): string | null { - return this.store.has(key) ? this.store.get(key)! : null - } - - key(index: number): string | null { - return Array.from(this.store.keys())[index] ?? null - } - - removeItem(key: string): void { - this.store.delete(key) - } - - setItem(key: string, value: string): void { - this.store.set(key, String(value)) - } -} - -const localStorageMemory = new MemoryStorage() -const sessionStorageMemory = new MemoryStorage() - -Object.defineProperty(globalThis, 'localStorage', { - value: localStorageMemory, - configurable: true, - writable: true, -}) -Object.defineProperty(globalThis, 'sessionStorage', { - value: sessionStorageMemory, - configurable: true, - writable: true, -}) - -if (typeof window !== 'undefined') { - Object.defineProperty(window, 'localStorage', { - value: localStorageMemory, - configurable: true, - writable: true, - }) - Object.defineProperty(window, 'sessionStorage', { - value: sessionStorageMemory, - configurable: true, - writable: true, - }) -} - -// G7: maak globalThis.fetch herconfigureerbaar zodat vi.spyOn / vi.fn-stubs -// de eigenschap kunnen redefineren. Default Node 24 / jsdom 29 binding is -// vaak non-configurable, wat vi.spyOn(globalThis, 'fetch') laat falen. -const originalFetch = (globalThis as { fetch?: typeof fetch }).fetch -Object.defineProperty(globalThis, 'fetch', { - value: originalFetch, - configurable: true, - writable: true, -}) - -beforeEach(() => { - localStorageMemory.clear() - sessionStorageMemory.clear() - vi.restoreAllMocks() - // Default fetch-stub voorkomt dat fire-and-forget ensure*Loaded calls - // (b.v. via setActivePbi) lekken naar het echte network. Tests die - // specifieke responses willen overrulen dit met vi.spyOn/vi.fn. - // G8: mockImplementation (niet mockResolvedValue) zodat elke call een - // verse Response krijgt — body wordt anders maar één keer leesbaar. - ;(globalThis as { fetch: typeof fetch }).fetch = vi - .fn() - .mockImplementation(() => - Promise.resolve(new Response('null', { status: 200 })), - ) as unknown as typeof fetch -}) diff --git a/tests/stubs/server-only.ts b/tests/stubs/server-only.ts deleted file mode 100644 index 336ce12..0000000 --- a/tests/stubs/server-only.ts +++ /dev/null @@ -1 +0,0 @@ -export {} diff --git a/vercel.json b/vercel.json index cbf80c2..311c925 100644 --- a/vercel.json +++ b/vercel.json @@ -1,6 +1,5 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", - "git": { "deploymentEnabled": false }, "crons": [ { "path": "/api/cron/expire-questions", diff --git a/vitest.config.ts b/vitest.config.ts index 5e1c8d8..e6fe532 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,17 +1,14 @@ -import { configDefaults, defineConfig } from 'vitest/config' +import { defineConfig } from 'vitest/config' import path from 'path' export default defineConfig({ test: { - environment: 'jsdom', + environment: 'node', globals: true, - setupFiles: ['tests/setup.ts'], - exclude: [...configDefaults.exclude, '**/.claude/**'], }, resolve: { alias: { '@': path.resolve(__dirname, '.'), - 'server-only': path.resolve(__dirname, 'tests/stubs/server-only.ts'), }, }, })