From d750676f5e810dfa63b628c47640995311cac2d6 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 17:36:44 +0200 Subject: [PATCH 01/73] =?UTF-8?q?PBI-56=20+=20ST-1275:=20PLAN=5FREADY=20?= =?UTF-8?q?=E2=86=92=20GRILLING=20re-grill=20+=20SKIPPED=20status=20render?= =?UTF-8?q?ing=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ST-1272): allow PLAN_READY → GRILLING re-grill transition actions/ideas.ts already lists PLAN_READY in GRILL_TRIGGERABLE_FROM, but lib/idea-status.ts ALLOWED_TRANSITIONS was missing the PLAN_READY → GRILLING edge. As a result, clicking Grill on a PLAN_READY idea returned 422 "Status-transitie ongeldig" while the UI button was enabled. Mirrors the existing PLANNED → GRILLING re-grill behaviour. - lib/idea-status.ts: PLAN_READY allows GRILLING in addition to PLANNING/PLANNED - __tests__/lib/idea-status.test.ts: explicit assert for PLAN_READY → GRILLING and PLAN_READY added to the regrill loop covering every GRILL_TRIGGERABLE_FROM status Co-Authored-By: Claude Opus 4.7 (1M context) * feat(ST-1275): render SKIPPED job status in chart-colors and insights Closing the gap left when ClaudeJobStatus.SKIPPED was added to the schema: the badge map and case-mapper already covered it, but the chart palette, the per-day insights aggregator and the stacked-bar chart did not. SKIPPED jobs (e.g. cmovkur8 manually flipped during the no-op-exit hotfix) now render with a muted style consistent with cancelled. - lib/chart-colors.ts: JOB_STATUS_COLORS gains a 'skipped' entry (var(--muted-foreground), same intensity as cancelled — neither rood/orange) - lib/insights/agent-throughput.ts: DayCount + STATUSES + perDay zero-fill now include 'skipped'; the SQL terminal_7d filter already counted SKIPPED - app/(app)/insights/components/agent-throughput.tsx: STACKED_STATUSES and the empty-state guard include 'skipped' - __tests__: chart-colors keys list, job-status round-trip ('all 7 statuses') and the insights non-zero filter all account for SKIPPED Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- __tests__/lib/chart-colors.test.ts | 2 +- __tests__/lib/idea-status.test.ts | 5 +++-- __tests__/lib/insights/agent-throughput.test.ts | 2 +- __tests__/lib/job-status.test.ts | 3 ++- app/(app)/insights/components/agent-throughput.tsx | 4 ++-- lib/chart-colors.ts | 1 + lib/idea-status.ts | 2 +- lib/insights/agent-throughput.ts | 4 ++++ 8 files changed, 15 insertions(+), 8 deletions(-) diff --git a/__tests__/lib/chart-colors.test.ts b/__tests__/lib/chart-colors.test.ts index b8d0be2..dc316bd 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', + 'queued', 'claimed', 'running', 'done', 'failed', 'cancelled', 'skipped', ] for (const key of keys) { expect(JOB_STATUS_COLORS[key]).toBeTruthy() diff --git a/__tests__/lib/idea-status.test.ts b/__tests__/lib/idea-status.test.ts index 9bedd32..b72692c 100644 --- a/__tests__/lib/idea-status.test.ts +++ b/__tests__/lib/idea-status.test.ts @@ -41,6 +41,7 @@ describe('canTransition', () => { 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', () => { @@ -60,8 +61,8 @@ describe('canTransition', () => { }) it('canTransition to GRILLING from all statuses that allow re-grill', () => { - // DRAFT, GRILLED, GRILL_FAILED, PLANNED are in GRILL_TRIGGERABLE_FROM and support the transition. - const regrill = ['DRAFT', 'GRILLED', 'GRILL_FAILED', 'PLANNED'] as const + // 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) } diff --git a/__tests__/lib/insights/agent-throughput.test.ts b/__tests__/lib/insights/agent-throughput.test.ts index 3465dd4..31bf46d 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 > 0, + d => d.done + d.failed + d.queued + d.claimed + d.running + d.cancelled + d.skipped > 0, ) expect(nonZero).toHaveLength(3) diff --git a/__tests__/lib/job-status.test.ts b/__tests__/lib/job-status.test.ts index db8d1ab..dee082e 100644 --- a/__tests__/lib/job-status.test.ts +++ b/__tests__/lib/job-status.test.ts @@ -27,13 +27,14 @@ describe('job-status mappers', () => { expect(jobStatusFromApi('QUEUED')).toBe('QUEUED') }) - it('maps all 6 DB statuses to API', () => { + it('maps all 7 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/app/(app)/insights/components/agent-throughput.tsx b/app/(app)/insights/components/agent-throughput.tsx index 820e64f..a43dd96 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'] as const +const STACKED_STATUSES = ['queued', 'claimed', 'running', 'done', 'failed', 'cancelled', 'skipped'] 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 === 0, + d => d.done + d.failed + d.queued + d.claimed + d.running + d.cancelled + d.skipped === 0, ) function handleProductChange(value: string | null) { diff --git a/lib/chart-colors.ts b/lib/chart-colors.ts index 561d4dc..e7b2d9f 100644 --- a/lib/chart-colors.ts +++ b/lib/chart-colors.ts @@ -28,6 +28,7 @@ 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/idea-status.ts b/lib/idea-status.ts index 1cc94b2..e513245 100644 --- a/lib/idea-status.ts +++ b/lib/idea-status.ts @@ -53,7 +53,7 @@ const ALLOWED_TRANSITIONS: Record> = { GRILLED: ['GRILLING', 'PLANNING'], PLANNING: ['PLAN_READY', 'PLAN_FAILED'], PLAN_FAILED: ['PLANNING', 'GRILLED'], - PLAN_READY: ['PLANNING', 'PLANNED'], + PLAN_READY: ['PLANNING', 'PLANNED', 'GRILLING'], // GRILLING via startGrillJobAction (re-grill) PLANNED: ['PLAN_READY', 'GRILLING'], // PLAN_READY via relinkIdeaPlanAction; GRILLING via startGrillJobAction } diff --git a/lib/insights/agent-throughput.ts b/lib/insights/agent-throughput.ts index b5017d2..3e172aa 100644 --- a/lib/insights/agent-throughput.ts +++ b/lib/insights/agent-throughput.ts @@ -8,6 +8,7 @@ export interface DayCount { done: number failed: number cancelled: number + skipped: number } export interface ThroughputKpi { @@ -21,6 +22,8 @@ export interface JobsPerDayResult { kpi: ThroughputKpi } +const STATUSES = ['queued', 'claimed', 'running', 'done', 'failed', 'cancelled', 'skipped'] 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 } @@ -98,6 +101,7 @@ export async function getJobsPerDay( done: statusMap.get('done') ?? 0, failed: statusMap.get('failed') ?? 0, cancelled: statusMap.get('cancelled') ?? 0, + skipped: statusMap.get('skipped') ?? 0, }) } From e75bac93758d6f797a1aa04643fe5e303a6a0115 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 7 May 2026 17:37:43 +0200 Subject: [PATCH 02/73] docs(PBI-58): add developer manual chapters under docs/manual/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 7-file English-language manual targeted at new human contributors: index, overview, statuses & transitions (with mermaid state diagrams), git workflow, MCP integration, docker, and troubleshooting. The manual is the *map* — it cross-references existing runbooks/ADRs/architecture docs rather than duplicating their content. Regenerates docs/INDEX.md and validates with check-doc-links.mjs. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/INDEX.md | 7 + docs/manual/01-overview.md | 99 +++++++++ docs/manual/02-statuses-and-transitions.md | 222 +++++++++++++++++++++ docs/manual/03-git-workflow.md | 99 +++++++++ docs/manual/04-mcp-integration.md | 121 +++++++++++ docs/manual/05-docker.md | 149 ++++++++++++++ docs/manual/06-troubleshooting.md | 112 +++++++++++ docs/manual/index.md | 64 ++++++ 8 files changed, 873 insertions(+) create mode 100644 docs/manual/01-overview.md create mode 100644 docs/manual/02-statuses-and-transitions.md create mode 100644 docs/manual/03-git-workflow.md create mode 100644 docs/manual/04-mcp-integration.md create mode 100644 docs/manual/05-docker.md create mode 100644 docs/manual/06-troubleshooting.md create mode 100644 docs/manual/index.md diff --git a/docs/INDEX.md b/docs/INDEX.md index 59962d6..83f72d4 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -105,6 +105,13 @@ Auto-generated on 2026-05-07 from front-matter and headings. | [Docker smoke test — task 2](./docker-smoke/2-mei-task-2.md) | `docker-smoke/2-mei-task-2.md` | done | 2026-05-03 | | [Scrum4Me — Functionele Specificatie](./functional.md) | `functional.md` | active | 2026-05-03 | | [Scrum4Me — Glossary](./glossary.md) | `glossary.md` | active | 2026-05-03 | +| [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 — 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 | diff --git a/docs/manual/01-overview.md b/docs/manual/01-overview.md new file mode 100644 index 0000000..bb99663 --- /dev/null +++ b/docs/manual/01-overview.md @@ -0,0 +1,99 @@ +--- +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
(per repo)"] + Idea["Idea
(pre-PBI staging)"] + PBI["PBI
(Product Backlog Item)"] + Story["Story"] + Task["Task"] + Sprint["Sprint
(cross-cutting)"] + + Product --> Idea + Idea -.->|"AI-grilled & planned"| PBI + Product --> PBI + PBI --> Story + Story --> Task + Sprint -.->|"contains stories
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 new file mode 100644 index 0000000..916f579 --- /dev/null +++ b/docs/manual/02-statuses-and-transitions.md @@ -0,0 +1,222 @@ +--- +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 new file mode 100644 index 0000000..888c7f1 --- /dev/null +++ b/docs/manual/03-git-workflow.md @@ -0,0 +1,99 @@ +--- +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 + +``` +(ST-XXX): short description +``` + +Where `` 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 new file mode 100644 index 0000000..5860621 --- /dev/null +++ b/docs/manual/04-mcp-integration.md @@ -0,0 +1,121 @@ +--- +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 new file mode 100644 index 0000000..4400160 --- /dev/null +++ b/docs/manual/05-docker.md @@ -0,0 +1,149 @@ +--- +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
+ API routes] + end + subgraph Neon + DB[(Postgres)] + end + subgraph Mac["Mac (default) / NAS (opt-in)"] + Worker[Worker container
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 new file mode 100644 index 0000000..05df556 --- /dev/null +++ b/docs/manual/06-troubleshooting.md @@ -0,0 +1,112 @@ +--- +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 new file mode 100644 index 0000000..5fe0fa7 --- /dev/null +++ b/docs/manual/index.md @@ -0,0 +1,64 @@ +--- +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 From 5025b78a816a893ec3e2bacf11350a1d03f088b3 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 7 May 2026 17:43:30 +0200 Subject: [PATCH 03/73] chore(PBI-58): add markdown rendering deps + manual:build script Adds mermaid, rehype-slug, rehype-autolink-headings for the in-app /manual page. Wires manual:build into prebuild so production builds always regenerate the chapter TOC. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 189 +++++++++++++++++++++------------------------- package.json | 5 ++ 2 files changed, 89 insertions(+), 105 deletions(-) diff --git a/package-lock.json b/package-lock.json index 15a386a..cfcb587 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "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", @@ -36,6 +37,8 @@ "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", @@ -96,7 +99,6 @@ "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", @@ -645,7 +647,6 @@ "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": { @@ -665,7 +666,6 @@ "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,7 +676,6 @@ "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" @@ -686,21 +685,18 @@ "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": { @@ -2058,14 +2054,12 @@ "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", @@ -2813,7 +2807,6 @@ "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" @@ -6208,7 +6201,6 @@ "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": "*", @@ -6253,7 +6245,6 @@ "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": "*" @@ -6263,7 +6254,6 @@ "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": "*" @@ -6273,7 +6263,6 @@ "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": { @@ -6286,7 +6275,6 @@ "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": "*", @@ -6297,21 +6285,18 @@ "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": "*" @@ -6321,7 +6306,6 @@ "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": { @@ -6334,7 +6318,6 @@ "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": "*" @@ -6344,21 +6327,18 @@ "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": "*" @@ -6368,7 +6348,6 @@ "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": { @@ -6390,21 +6369,18 @@ "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": { @@ -6420,14 +6396,12 @@ "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": { @@ -6449,7 +6423,6 @@ "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": { @@ -6462,7 +6435,6 @@ "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": "*" @@ -6472,7 +6444,6 @@ "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": "*", @@ -6536,7 +6507,6 @@ "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": { @@ -6661,7 +6631,6 @@ "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 }, @@ -7293,7 +7262,6 @@ "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", @@ -8963,7 +8931,6 @@ "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", @@ -8980,7 +8947,6 @@ "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" @@ -9683,7 +9649,6 @@ "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" @@ -9781,7 +9746,6 @@ "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" @@ -9791,7 +9755,6 @@ "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" @@ -9804,7 +9767,6 @@ "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" @@ -9817,7 +9779,6 @@ "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" @@ -9827,14 +9788,12 @@ "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", @@ -9888,7 +9847,6 @@ "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" @@ -9898,7 +9856,6 @@ "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", @@ -9915,7 +9872,6 @@ "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" @@ -9937,7 +9893,6 @@ "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" @@ -9950,7 +9905,6 @@ "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" @@ -9963,7 +9917,6 @@ "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" @@ -9973,7 +9926,6 @@ "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", @@ -9987,7 +9939,6 @@ "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", @@ -10013,7 +9964,6 @@ "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" @@ -10023,7 +9973,6 @@ "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" @@ -10045,7 +9994,6 @@ "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" @@ -10058,7 +10006,6 @@ "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", @@ -10082,7 +10029,6 @@ "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" @@ -10095,7 +10041,6 @@ "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" @@ -10126,7 +10071,6 @@ "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" @@ -10136,7 +10080,6 @@ "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" @@ -10146,7 +10089,6 @@ "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" @@ -10156,7 +10098,6 @@ "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", @@ -10167,7 +10108,6 @@ "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" @@ -10177,14 +10117,12 @@ "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" @@ -10194,7 +10132,6 @@ "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": { @@ -10217,7 +10154,6 @@ "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", @@ -10231,7 +10167,6 @@ "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" @@ -10286,7 +10221,6 @@ "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", @@ -10306,7 +10240,6 @@ "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", @@ -10323,7 +10256,6 @@ "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", @@ -10418,7 +10350,6 @@ "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": { @@ -10629,7 +10560,6 @@ "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" @@ -10746,7 +10676,6 @@ "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" @@ -12447,6 +12376,12 @@ "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", @@ -12593,7 +12528,6 @@ "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": { @@ -12687,6 +12621,32 @@ "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", @@ -12714,6 +12674,19 @@ "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", @@ -14042,7 +14015,6 @@ "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" @@ -14059,7 +14031,6 @@ "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" @@ -14078,8 +14049,7 @@ "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==", - "dev": true + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, "node_modules/kleur": { "version": "4.1.5", @@ -14094,7 +14064,6 @@ "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", @@ -14133,7 +14102,6 @@ "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": { @@ -14568,7 +14536,6 @@ "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": { @@ -15200,7 +15167,6 @@ "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", @@ -15230,7 +15196,6 @@ "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" @@ -15914,7 +15879,6 @@ "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", @@ -15927,14 +15891,12 @@ "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", @@ -16704,7 +16666,6 @@ "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": { @@ -16813,7 +16774,6 @@ "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": { @@ -17093,14 +17053,12 @@ "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", @@ -18142,6 +18100,41 @@ "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", @@ -18356,7 +18349,6 @@ "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": { @@ -18441,7 +18433,6 @@ "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", @@ -18515,7 +18506,6 @@ "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": { @@ -19671,7 +19661,6 @@ "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": { @@ -19959,7 +19948,6 @@ "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" @@ -20134,7 +20122,6 @@ "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" @@ -20388,7 +20375,6 @@ "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": { @@ -20735,7 +20721,6 @@ "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" @@ -21032,7 +21017,6 @@ "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" @@ -21042,7 +21026,6 @@ "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" @@ -21055,7 +21038,6 @@ "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", @@ -21066,21 +21048,18 @@ "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": { diff --git a/package.json b/package.json index 8796e0c..0f6d444 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "predev": "npx --yes kill-port 3000 || exit 0", "dev": "next dev -p 3000", + "prebuild": "npm run manual:build", "build": "next build", "start": "next start", "lint": "eslint", @@ -21,6 +22,7 @@ "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" }, @@ -41,6 +43,7 @@ "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", @@ -52,6 +55,8 @@ "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", From 948f75d087d1f3a9c39463387cb2e38ebcb817eb Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 7 May 2026 17:43:37 +0200 Subject: [PATCH 04/73] feat(PBI-58): codegen script for in-app manual TOC scripts/build-manual.mjs walks docs/manual/, parses YAML front-matter, strips it from the body, and emits lib/manual.generated.ts with a typed ManualEntry[] containing slug, title, description, filePath, and the embedded markdown body. Pure Node 20, mirrors generate-docs-index.mjs. Inlining the markdown at build time keeps runtime serverless functions free of filesystem reads, which avoids whole-project NFT tracing. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/manual.generated.ts | 865 +++++++++++++++++++++++++++++++++++++++ scripts/build-manual.mjs | 159 +++++++ 2 files changed, 1024 insertions(+) create mode 100644 lib/manual.generated.ts create mode 100644 scripts/build-manual.mjs diff --git a/lib/manual.generated.ts b/lib/manual.generated.ts new file mode 100644 index 0000000..b69294e --- /dev/null +++ b/lib/manual.generated.ts @@ -0,0 +1,865 @@ +// 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
(per repo)"] + Idea["Idea
(pre-PBI staging)"] + PBI["PBI
(Product Backlog Item)"] + Story["Story"] + Task["Task"] + Sprint["Sprint
(cross-cutting)"] + + Product --> Idea + Idea -.->|"AI-grilled & planned"| PBI + Product --> PBI + PBI --> Story + Story --> Task + Sprint -.->|"contains stories
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 + +\`\`\` +(ST-XXX): short description +\`\`\` + +Where \`\` 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
+ API routes] + end + subgraph Neon + DB[(Postgres)] + end + subgraph Mac["Mac (default) / NAS (opt-in)"] + Worker[Worker container
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/scripts/build-manual.mjs b/scripts/build-manual.mjs new file mode 100644 index 0000000..99abf5f --- /dev/null +++ b/scripts/build-manual.mjs @@ -0,0 +1,159 @@ +#!/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); +}); From 797c7d32b0450012933a95caa1a4535974661852 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 7 May 2026 17:43:47 +0200 Subject: [PATCH 05/73] feat(PBI-58): /manual route renders developer manual chapters in-app Catch-all route at app/(app)/manual/[[...slug]]/page.tsx with generateStaticParams covering every TOC entry. Server-side MarkdownView uses react-markdown with remark-gfm, rehype-slug, and rehype-autolink-headings; mermaid code blocks are routed to a client-only MermaidBlock that dynamic-imports mermaid on mount. ManualSidebar (client) reads the typed TOC and highlights the active chapter via usePathname. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/(app)/manual/[[...slug]]/page.tsx | 42 +++++++++++ .../manual/_components/manual-sidebar.tsx | 51 +++++++++++++ .../manual/_components/markdown-view.tsx | 42 +++++++++++ .../manual/_components/mermaid-block.tsx | 73 +++++++++++++++++++ app/(app)/manual/layout.tsx | 16 ++++ lib/manual-server.ts | 28 +++++++ 6 files changed, 252 insertions(+) create mode 100644 app/(app)/manual/[[...slug]]/page.tsx create mode 100644 app/(app)/manual/_components/manual-sidebar.tsx create mode 100644 app/(app)/manual/_components/markdown-view.tsx create mode 100644 app/(app)/manual/_components/mermaid-block.tsx create mode 100644 app/(app)/manual/layout.tsx create mode 100644 lib/manual-server.ts diff --git a/app/(app)/manual/[[...slug]]/page.tsx b/app/(app)/manual/[[...slug]]/page.tsx new file mode 100644 index 0000000..ccc330b --- /dev/null +++ b/app/(app)/manual/[[...slug]]/page.tsx @@ -0,0 +1,42 @@ +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 { + return getManualToc().map((entry) => ({ + slug: entry.slug.length > 0 ? [...entry.slug] : undefined, + })) +} + +export async function generateMetadata({ + params, +}: { + params: Promise +}): Promise { + 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 +}) { + const { slug = [] } = await params + const chapter = getManualChapter(slug) + if (!chapter) notFound() + + return ( +
+ +
+ ) +} diff --git a/app/(app)/manual/_components/manual-sidebar.tsx b/app/(app)/manual/_components/manual-sidebar.tsx new file mode 100644 index 0000000..9643ed3 --- /dev/null +++ b/app/(app)/manual/_components/manual-sidebar.tsx @@ -0,0 +1,51 @@ +'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 ( + + ) +} diff --git a/app/(app)/manual/_components/markdown-view.tsx b/app/(app)/manual/_components/markdown-view.tsx new file mode 100644 index 0000000..421477f --- /dev/null +++ b/app/(app)/manual/_components/markdown-view.tsx @@ -0,0 +1,42 @@ +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 ( +
+ + } + return ( + {children} + ) + }, + }} + > + {markdown} + +
+ ) +} diff --git a/app/(app)/manual/_components/mermaid-block.tsx b/app/(app)/manual/_components/mermaid-block.tsx new file mode 100644 index 0000000..66e52db --- /dev/null +++ b/app/(app)/manual/_components/mermaid-block.tsx @@ -0,0 +1,73 @@ +'use client' + +import { useEffect, useId, useRef, useState } from 'react' + +type Props = { + source: string +} + +let mermaidPromise: Promise | 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(null) + const [error, setError] = useState(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 ( +
+        
+          {`Mermaid render failed: ${error}\n\n${source}`}
+        
+      
+ ) + } + + return ( +
+ ) +} diff --git a/app/(app)/manual/layout.tsx b/app/(app)/manual/layout.tsx new file mode 100644 index 0000000..06ebc7e --- /dev/null +++ b/app/(app)/manual/layout.tsx @@ -0,0 +1,16 @@ +import { getManualToc } from '@/lib/manual-server' +import { ManualSidebar } from './_components/manual-sidebar' + +export default function ManualLayout({ + children, +}: { + children: React.ReactNode +}) { + const toc = getManualToc() + return ( +
+ +
{children}
+
+ ) +} diff --git a/lib/manual-server.ts b/lib/manual-server.ts new file mode 100644 index 0000000..aff4dca --- /dev/null +++ b/lib/manual-server.ts @@ -0,0 +1,28 @@ +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 } +} From 4a790f830feff8837d3055eb68a0b66f37e70197 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 7 May 2026 17:43:54 +0200 Subject: [PATCH 06/73] feat(PBI-58): add Manual link to main nav bar Co-Authored-By: Claude Opus 4.7 (1M context) --- components/shared/nav-bar.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/components/shared/nav-bar.tsx b/components/shared/nav-bar.tsx index 7a4aaec..2ff3a81 100644 --- a/components/shared/nav-bar.tsx +++ b/components/shared/nav-bar.tsx @@ -143,6 +143,7 @@ export function NavBar({ : disabledSpan('Solo')} {navLink('/insights', 'Insights', pathname.startsWith('/insights'))} {navLink('/ideas', 'Ideas', pathname.startsWith('/ideas'))} + {navLink('/manual', 'Manual', pathname.startsWith('/manual'))} {roles.includes('ADMIN') && navLink('/admin', 'Admin', pathname.startsWith('/admin'))}
From bd7478861bed12411f3a2fca0cf47559e1c30aca Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 18:00:10 +0200 Subject: [PATCH 07/73] PBI-58: Developer manual + in-app /manual page (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(PBI-58): add developer manual chapters under docs/manual/ Adds a 7-file English-language manual targeted at new human contributors: index, overview, statuses & transitions (with mermaid state diagrams), git workflow, MCP integration, docker, and troubleshooting. The manual is the *map* — it cross-references existing runbooks/ADRs/architecture docs rather than duplicating their content. Regenerates docs/INDEX.md and validates with check-doc-links.mjs. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(PBI-58): add markdown rendering deps + manual:build script Adds mermaid, rehype-slug, rehype-autolink-headings for the in-app /manual page. Wires manual:build into prebuild so production builds always regenerate the chapter TOC. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(PBI-58): codegen script for in-app manual TOC scripts/build-manual.mjs walks docs/manual/, parses YAML front-matter, strips it from the body, and emits lib/manual.generated.ts with a typed ManualEntry[] containing slug, title, description, filePath, and the embedded markdown body. Pure Node 20, mirrors generate-docs-index.mjs. Inlining the markdown at build time keeps runtime serverless functions free of filesystem reads, which avoids whole-project NFT tracing. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(PBI-58): /manual route renders developer manual chapters in-app Catch-all route at app/(app)/manual/[[...slug]]/page.tsx with generateStaticParams covering every TOC entry. Server-side MarkdownView uses react-markdown with remark-gfm, rehype-slug, and rehype-autolink-headings; mermaid code blocks are routed to a client-only MermaidBlock that dynamic-imports mermaid on mount. ManualSidebar (client) reads the typed TOC and highlights the active chapter via usePathname. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(PBI-58): add Manual link to main nav bar Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- app/(app)/manual/[[...slug]]/page.tsx | 42 + .../manual/_components/manual-sidebar.tsx | 51 ++ .../manual/_components/markdown-view.tsx | 42 + .../manual/_components/mermaid-block.tsx | 73 ++ app/(app)/manual/layout.tsx | 16 + components/shared/nav-bar.tsx | 1 + docs/INDEX.md | 7 + docs/manual/01-overview.md | 99 ++ docs/manual/02-statuses-and-transitions.md | 222 +++++ docs/manual/03-git-workflow.md | 99 ++ docs/manual/04-mcp-integration.md | 121 +++ docs/manual/05-docker.md | 149 +++ docs/manual/06-troubleshooting.md | 112 +++ docs/manual/index.md | 64 ++ lib/manual-server.ts | 28 + lib/manual.generated.ts | 865 ++++++++++++++++++ package-lock.json | 189 ++-- package.json | 5 + scripts/build-manual.mjs | 159 ++++ 19 files changed, 2239 insertions(+), 105 deletions(-) create mode 100644 app/(app)/manual/[[...slug]]/page.tsx create mode 100644 app/(app)/manual/_components/manual-sidebar.tsx create mode 100644 app/(app)/manual/_components/markdown-view.tsx create mode 100644 app/(app)/manual/_components/mermaid-block.tsx create mode 100644 app/(app)/manual/layout.tsx create mode 100644 docs/manual/01-overview.md create mode 100644 docs/manual/02-statuses-and-transitions.md create mode 100644 docs/manual/03-git-workflow.md create mode 100644 docs/manual/04-mcp-integration.md create mode 100644 docs/manual/05-docker.md create mode 100644 docs/manual/06-troubleshooting.md create mode 100644 docs/manual/index.md create mode 100644 lib/manual-server.ts create mode 100644 lib/manual.generated.ts create mode 100644 scripts/build-manual.mjs diff --git a/app/(app)/manual/[[...slug]]/page.tsx b/app/(app)/manual/[[...slug]]/page.tsx new file mode 100644 index 0000000..ccc330b --- /dev/null +++ b/app/(app)/manual/[[...slug]]/page.tsx @@ -0,0 +1,42 @@ +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 { + return getManualToc().map((entry) => ({ + slug: entry.slug.length > 0 ? [...entry.slug] : undefined, + })) +} + +export async function generateMetadata({ + params, +}: { + params: Promise +}): Promise { + 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 +}) { + const { slug = [] } = await params + const chapter = getManualChapter(slug) + if (!chapter) notFound() + + return ( +
+ +
+ ) +} diff --git a/app/(app)/manual/_components/manual-sidebar.tsx b/app/(app)/manual/_components/manual-sidebar.tsx new file mode 100644 index 0000000..9643ed3 --- /dev/null +++ b/app/(app)/manual/_components/manual-sidebar.tsx @@ -0,0 +1,51 @@ +'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 ( + + ) +} diff --git a/app/(app)/manual/_components/markdown-view.tsx b/app/(app)/manual/_components/markdown-view.tsx new file mode 100644 index 0000000..421477f --- /dev/null +++ b/app/(app)/manual/_components/markdown-view.tsx @@ -0,0 +1,42 @@ +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 ( +
+ + } + return ( + {children} + ) + }, + }} + > + {markdown} + +
+ ) +} diff --git a/app/(app)/manual/_components/mermaid-block.tsx b/app/(app)/manual/_components/mermaid-block.tsx new file mode 100644 index 0000000..66e52db --- /dev/null +++ b/app/(app)/manual/_components/mermaid-block.tsx @@ -0,0 +1,73 @@ +'use client' + +import { useEffect, useId, useRef, useState } from 'react' + +type Props = { + source: string +} + +let mermaidPromise: Promise | 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(null) + const [error, setError] = useState(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 ( +
+        
+          {`Mermaid render failed: ${error}\n\n${source}`}
+        
+      
+ ) + } + + return ( +
+ ) +} diff --git a/app/(app)/manual/layout.tsx b/app/(app)/manual/layout.tsx new file mode 100644 index 0000000..06ebc7e --- /dev/null +++ b/app/(app)/manual/layout.tsx @@ -0,0 +1,16 @@ +import { getManualToc } from '@/lib/manual-server' +import { ManualSidebar } from './_components/manual-sidebar' + +export default function ManualLayout({ + children, +}: { + children: React.ReactNode +}) { + const toc = getManualToc() + return ( +
+ +
{children}
+
+ ) +} diff --git a/components/shared/nav-bar.tsx b/components/shared/nav-bar.tsx index 7a4aaec..2ff3a81 100644 --- a/components/shared/nav-bar.tsx +++ b/components/shared/nav-bar.tsx @@ -143,6 +143,7 @@ export function NavBar({ : disabledSpan('Solo')} {navLink('/insights', 'Insights', pathname.startsWith('/insights'))} {navLink('/ideas', 'Ideas', pathname.startsWith('/ideas'))} + {navLink('/manual', 'Manual', pathname.startsWith('/manual'))} {roles.includes('ADMIN') && navLink('/admin', 'Admin', pathname.startsWith('/admin'))}
diff --git a/docs/INDEX.md b/docs/INDEX.md index 59962d6..83f72d4 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -105,6 +105,13 @@ Auto-generated on 2026-05-07 from front-matter and headings. | [Docker smoke test — task 2](./docker-smoke/2-mei-task-2.md) | `docker-smoke/2-mei-task-2.md` | done | 2026-05-03 | | [Scrum4Me — Functionele Specificatie](./functional.md) | `functional.md` | active | 2026-05-03 | | [Scrum4Me — Glossary](./glossary.md) | `glossary.md` | active | 2026-05-03 | +| [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 — 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 | diff --git a/docs/manual/01-overview.md b/docs/manual/01-overview.md new file mode 100644 index 0000000..bb99663 --- /dev/null +++ b/docs/manual/01-overview.md @@ -0,0 +1,99 @@ +--- +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
(per repo)"] + Idea["Idea
(pre-PBI staging)"] + PBI["PBI
(Product Backlog Item)"] + Story["Story"] + Task["Task"] + Sprint["Sprint
(cross-cutting)"] + + Product --> Idea + Idea -.->|"AI-grilled & planned"| PBI + Product --> PBI + PBI --> Story + Story --> Task + Sprint -.->|"contains stories
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 new file mode 100644 index 0000000..916f579 --- /dev/null +++ b/docs/manual/02-statuses-and-transitions.md @@ -0,0 +1,222 @@ +--- +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 new file mode 100644 index 0000000..888c7f1 --- /dev/null +++ b/docs/manual/03-git-workflow.md @@ -0,0 +1,99 @@ +--- +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 + +``` +(ST-XXX): short description +``` + +Where `` 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 new file mode 100644 index 0000000..5860621 --- /dev/null +++ b/docs/manual/04-mcp-integration.md @@ -0,0 +1,121 @@ +--- +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 new file mode 100644 index 0000000..4400160 --- /dev/null +++ b/docs/manual/05-docker.md @@ -0,0 +1,149 @@ +--- +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
+ API routes] + end + subgraph Neon + DB[(Postgres)] + end + subgraph Mac["Mac (default) / NAS (opt-in)"] + Worker[Worker container
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 new file mode 100644 index 0000000..05df556 --- /dev/null +++ b/docs/manual/06-troubleshooting.md @@ -0,0 +1,112 @@ +--- +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 new file mode 100644 index 0000000..5fe0fa7 --- /dev/null +++ b/docs/manual/index.md @@ -0,0 +1,64 @@ +--- +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/lib/manual-server.ts b/lib/manual-server.ts new file mode 100644 index 0000000..aff4dca --- /dev/null +++ b/lib/manual-server.ts @@ -0,0 +1,28 @@ +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 new file mode 100644 index 0000000..b69294e --- /dev/null +++ b/lib/manual.generated.ts @@ -0,0 +1,865 @@ +// 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
(per repo)"] + Idea["Idea
(pre-PBI staging)"] + PBI["PBI
(Product Backlog Item)"] + Story["Story"] + Task["Task"] + Sprint["Sprint
(cross-cutting)"] + + Product --> Idea + Idea -.->|"AI-grilled & planned"| PBI + Product --> PBI + PBI --> Story + Story --> Task + Sprint -.->|"contains stories
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 + +\`\`\` +(ST-XXX): short description +\`\`\` + +Where \`\` 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
+ API routes] + end + subgraph Neon + DB[(Postgres)] + end + subgraph Mac["Mac (default) / NAS (opt-in)"] + Worker[Worker container
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/package-lock.json b/package-lock.json index 15a386a..cfcb587 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "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", @@ -36,6 +37,8 @@ "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", @@ -96,7 +99,6 @@ "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", @@ -645,7 +647,6 @@ "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": { @@ -665,7 +666,6 @@ "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,7 +676,6 @@ "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" @@ -686,21 +685,18 @@ "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": { @@ -2058,14 +2054,12 @@ "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", @@ -2813,7 +2807,6 @@ "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" @@ -6208,7 +6201,6 @@ "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": "*", @@ -6253,7 +6245,6 @@ "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": "*" @@ -6263,7 +6254,6 @@ "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": "*" @@ -6273,7 +6263,6 @@ "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": { @@ -6286,7 +6275,6 @@ "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": "*", @@ -6297,21 +6285,18 @@ "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": "*" @@ -6321,7 +6306,6 @@ "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": { @@ -6334,7 +6318,6 @@ "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": "*" @@ -6344,21 +6327,18 @@ "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": "*" @@ -6368,7 +6348,6 @@ "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": { @@ -6390,21 +6369,18 @@ "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": { @@ -6420,14 +6396,12 @@ "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": { @@ -6449,7 +6423,6 @@ "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": { @@ -6462,7 +6435,6 @@ "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": "*" @@ -6472,7 +6444,6 @@ "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": "*", @@ -6536,7 +6507,6 @@ "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": { @@ -6661,7 +6631,6 @@ "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 }, @@ -7293,7 +7262,6 @@ "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", @@ -8963,7 +8931,6 @@ "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", @@ -8980,7 +8947,6 @@ "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" @@ -9683,7 +9649,6 @@ "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" @@ -9781,7 +9746,6 @@ "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" @@ -9791,7 +9755,6 @@ "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" @@ -9804,7 +9767,6 @@ "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" @@ -9817,7 +9779,6 @@ "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" @@ -9827,14 +9788,12 @@ "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", @@ -9888,7 +9847,6 @@ "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" @@ -9898,7 +9856,6 @@ "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", @@ -9915,7 +9872,6 @@ "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" @@ -9937,7 +9893,6 @@ "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" @@ -9950,7 +9905,6 @@ "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" @@ -9963,7 +9917,6 @@ "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" @@ -9973,7 +9926,6 @@ "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", @@ -9987,7 +9939,6 @@ "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", @@ -10013,7 +9964,6 @@ "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" @@ -10023,7 +9973,6 @@ "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" @@ -10045,7 +9994,6 @@ "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" @@ -10058,7 +10006,6 @@ "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", @@ -10082,7 +10029,6 @@ "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" @@ -10095,7 +10041,6 @@ "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" @@ -10126,7 +10071,6 @@ "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" @@ -10136,7 +10080,6 @@ "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" @@ -10146,7 +10089,6 @@ "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" @@ -10156,7 +10098,6 @@ "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", @@ -10167,7 +10108,6 @@ "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" @@ -10177,14 +10117,12 @@ "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" @@ -10194,7 +10132,6 @@ "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": { @@ -10217,7 +10154,6 @@ "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", @@ -10231,7 +10167,6 @@ "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" @@ -10286,7 +10221,6 @@ "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", @@ -10306,7 +10240,6 @@ "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", @@ -10323,7 +10256,6 @@ "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", @@ -10418,7 +10350,6 @@ "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": { @@ -10629,7 +10560,6 @@ "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" @@ -10746,7 +10676,6 @@ "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" @@ -12447,6 +12376,12 @@ "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", @@ -12593,7 +12528,6 @@ "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": { @@ -12687,6 +12621,32 @@ "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", @@ -12714,6 +12674,19 @@ "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", @@ -14042,7 +14015,6 @@ "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" @@ -14059,7 +14031,6 @@ "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" @@ -14078,8 +14049,7 @@ "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==", - "dev": true + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, "node_modules/kleur": { "version": "4.1.5", @@ -14094,7 +14064,6 @@ "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", @@ -14133,7 +14102,6 @@ "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": { @@ -14568,7 +14536,6 @@ "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": { @@ -15200,7 +15167,6 @@ "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", @@ -15230,7 +15196,6 @@ "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" @@ -15914,7 +15879,6 @@ "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", @@ -15927,14 +15891,12 @@ "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", @@ -16704,7 +16666,6 @@ "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": { @@ -16813,7 +16774,6 @@ "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": { @@ -17093,14 +17053,12 @@ "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", @@ -18142,6 +18100,41 @@ "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", @@ -18356,7 +18349,6 @@ "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": { @@ -18441,7 +18433,6 @@ "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", @@ -18515,7 +18506,6 @@ "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": { @@ -19671,7 +19661,6 @@ "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": { @@ -19959,7 +19948,6 @@ "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" @@ -20134,7 +20122,6 @@ "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" @@ -20388,7 +20375,6 @@ "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": { @@ -20735,7 +20721,6 @@ "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" @@ -21032,7 +21017,6 @@ "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" @@ -21042,7 +21026,6 @@ "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" @@ -21055,7 +21038,6 @@ "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", @@ -21066,21 +21048,18 @@ "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": { diff --git a/package.json b/package.json index 8796e0c..0f6d444 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "predev": "npx --yes kill-port 3000 || exit 0", "dev": "next dev -p 3000", + "prebuild": "npm run manual:build", "build": "next build", "start": "next start", "lint": "eslint", @@ -21,6 +22,7 @@ "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" }, @@ -41,6 +43,7 @@ "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", @@ -52,6 +55,8 @@ "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", diff --git a/scripts/build-manual.mjs b/scripts/build-manual.mjs new file mode 100644 index 0000000..99abf5f --- /dev/null +++ b/scripts/build-manual.mjs @@ -0,0 +1,159 @@ +#!/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); +}); From bdd0398eb67e377d33e37b06301d006f5f3dbce7 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Thu, 7 May 2026 18:07:01 +0200 Subject: [PATCH 08/73] fix(PBI-58): pin turbopack root to cwd so worktrees resolve own node_modules Multi-lockfile setups (e.g. running dev/build from inside a git worktree under .claude/worktrees/) caused Next.js to climb up to the parent repo's package-lock.json and resolve modules from the parent's node_modules, which doesn't have the new manual-rendering deps installed. Pinning turbopack.root to process.cwd() keeps each invocation scoped to the directory it was launched from. Co-Authored-By: Claude Opus 4.7 (1M context) --- next.config.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/next.config.ts b/next.config.ts index 631b848..ad4d0ef 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,11 @@ import pkg from "./package.json" const nextConfig: NextConfig = { reactStrictMode: true, serverExternalPackages: ['sharp'], + // Pin Turbopack workspace root to the invocation directory so worktrees + // resolve their own node_modules instead of climbing into an outer lockfile. + turbopack: { + root: process.cwd(), + }, env: { NEXT_PUBLIC_APP_VERSION: pkg.version, NEXT_PUBLIC_BUILD_DATE: new Date().toISOString(), From 4a63b4b01fec339040c84f3f01a953f786fc5ff1 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 18:41:19 +0200 Subject: [PATCH 09/73] Sprint: UI taken/ (#149) * feat(PBI-58): Vitest-tests voor SoloTaskCard veldmapping en 4-regels layout Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): server action fetchJobsPageData voor jobs-pagina Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): SSE-route /api/realtime/jobs voor user-scoped job-events Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): JobCard component voor jobs-pagina Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): JobDetailPane component voor jobs-pagina Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): API route GET /api/jobs/[id]/sub-tasks voor sprint task executions Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .../components/solo/solo-task-card.test.tsx | 84 +++++++++ actions/jobs-page.ts | 114 ++++++++++++ app/api/jobs/[id]/sub-tasks/route.ts | 39 ++++ app/api/realtime/jobs/route.ts | 170 ++++++++++++++++++ components/jobs/job-card.tsx | 75 ++++++++ components/jobs/job-detail-pane.tsx | 76 ++++++++ 6 files changed, 558 insertions(+) create mode 100644 __tests__/components/solo/solo-task-card.test.tsx create mode 100644 actions/jobs-page.ts create mode 100644 app/api/jobs/[id]/sub-tasks/route.ts create mode 100644 app/api/realtime/jobs/route.ts create mode 100644 components/jobs/job-card.tsx create mode 100644 components/jobs/job-detail-pane.tsx diff --git a/__tests__/components/solo/solo-task-card.test.tsx b/__tests__/components/solo/solo-task-card.test.tsx new file mode 100644 index 0000000..f7a8493 --- /dev/null +++ b/__tests__/components/solo/solo-task-card.test.tsx @@ -0,0 +1,84 @@ +// @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 }) => {children}, +})) +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 }) => {code}, +})) + +import { SoloTaskCard, SoloTaskCardOverlay } from '@/components/solo/solo-task-card' + +function makeSoloTask(overrides: Partial = {}): 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() + 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() + 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() + expect(screen.queryByText(/Omschrijving/)).toBeNull() + }) + + it('toont description als tekst', () => { + render() + 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() + 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/actions/jobs-page.ts b/actions/jobs-page.ts new file mode 100644 index 0000000..ae58804 --- /dev/null +++ b/actions/jobs-page.ts @@ -0,0 +1,114 @@ +'use server' + +import { prisma } from '@/lib/prisma' +import { getSession } from '@/lib/auth' +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 + modelId: string | null + inputTokens: number | null + outputTokens: number | null + cacheReadTokens: number | null + cacheWriteTokens: number | null + branch: string | null + prUrl: string | null + error: string | null + summary: string | null + verifyResult: VerifyResult | null + startedAt: Date | null + finishedAt: Date | null + createdAt: Date + sprintRunId: string | null +} + +const JOB_INCLUDE = { + task: { select: { code: true, title: true } }, + idea: { select: { code: true, title: true } }, + product: { select: { name: true } }, + sprint_run: { include: { sprint: { select: { sprint_goal: true, code: true } } } }, +} as const + +function mapJob(j: { + 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 } | null + idea: { code: string | null; title: string } | null + product: { name: string } + sprint_run: { sprint: { sprint_goal: string; code: string | null } } | null +}): 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, + modelId: j.model_id, + inputTokens: j.input_tokens, + outputTokens: j.output_tokens, + cacheReadTokens: j.cache_read_tokens, + cacheWriteTokens: j.cache_write_tokens, + branch: j.branch, + prUrl: j.pr_url, + error: j.error, + summary: j.summary, + verifyResult: j.verify_result, + startedAt: j.started_at, + finishedAt: j.finished_at, + createdAt: j.created_at, + sprintRunId: j.sprint_run_id, + } +} + +export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelations[]; doneJobs: JobWithRelations[] } | null> { + const session = await getSession() + if (!session.userId) return null + + const [active, done] = await Promise.all([ + prisma.claudeJob.findMany({ + where: { user_id: session.userId, status: { notIn: ['DONE'] } }, + include: JOB_INCLUDE, + orderBy: { created_at: 'asc' }, + }), + prisma.claudeJob.findMany({ + where: { user_id: session.userId, status: 'DONE' }, + include: JOB_INCLUDE, + orderBy: { finished_at: 'desc' }, + take: 100, + }), + ]) + + return { + activeJobs: active.map(mapJob), + doneJobs: done.map(mapJob), + } +} diff --git a/app/api/jobs/[id]/sub-tasks/route.ts b/app/api/jobs/[id]/sub-tasks/route.ts new file mode 100644 index 0000000..7e90822 --- /dev/null +++ b/app/api/jobs/[id]/sub-tasks/route.ts @@ -0,0 +1,39 @@ +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/realtime/jobs/route.ts b/app/api/realtime/jobs/route.ts new file mode 100644 index 0000000..67edefd --- /dev/null +++ b/app/api/realtime/jobs/route.ts @@ -0,0 +1,170 @@ +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 + 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 | null = null + let hardCloseTimer: ReturnType | 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 { + 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/components/jobs/job-card.tsx b/components/jobs/job-card.tsx new file mode 100644 index 0000000..590e743 --- /dev/null +++ b/components/jobs/job-card.tsx @@ -0,0 +1,75 @@ +'use client' + +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 { 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 + branch?: string | null + error?: string | null + summary?: string | null + isSelected?: boolean + onClick?: () => void +} + +const KIND_LABELS: Record = { + TASK_IMPLEMENTATION: 'TAAK', + SPRINT_IMPLEMENTATION: 'SPRINT', + IDEA_GRILL: 'GRILL', + IDEA_MAKE_PLAN: 'PLAN', + PLAN_CHAT: 'CHAT', +} + +export default function JobCard({ + kind, status, taskCode, taskTitle, ideaCode, ideaTitle, + sprintGoal, sprintCode, productName, branch, error, isSelected, onClick, +}: JobCardProps) { + let titleText: string + if (kind === 'TASK_IMPLEMENTATION') { + titleText = taskCode && taskTitle ? `${taskCode} ${taskTitle}` : taskTitle || 'Taak' + } else if (kind === 'SPRINT_IMPLEMENTATION') { + titleText = sprintGoal || (sprintCode ? `Sprint ${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' + } + + const detailText = branch || (error ? error.slice(0, 80) : null) || productName + + const apiStatus = jobStatusToApi(status) + + return ( +
+
+ + {KIND_LABELS[kind]} + + + {JOB_STATUS_LABELS[apiStatus]} + +
+

{titleText}

+

{detailText}

+
+ ) +} diff --git a/components/jobs/job-detail-pane.tsx b/components/jobs/job-detail-pane.tsx new file mode 100644 index 0000000..5f7b2cd --- /dev/null +++ b/components/jobs/job-detail-pane.tsx @@ -0,0 +1,76 @@ +'use client' + +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' + +interface FieldRowProps { + label: string + children: React.ReactNode +} + +function FieldRow({ label, children }: FieldRowProps) { + return ( +
+ {label} + {children} +
+ ) +} + +interface JobDetailPaneProps { + job: JobWithRelations | null +} + +export default function JobDetailPane({ job }: JobDetailPaneProps) { + if (!job) { + return ( +
+ Selecteer een job om details te zien +
+ ) + } + + const apiStatus = jobStatusToApi(job.status) + + return ( +
+ + + {JOB_STATUS_LABELS[apiStatus]} + + + {job.kind} + {job.productName} + {job.modelId || '—'} + {job.inputTokens?.toLocaleString() || '—'} + {job.outputTokens?.toLocaleString() || '—'} + {job.cacheReadTokens?.toLocaleString() || '—'} + {job.cacheWriteTokens?.toLocaleString() || '—'} + + {job.branch || '—'} + + + {job.prUrl ? ( + + PR openen ↗ + + ) : '—'} + + + {job.error ? ( +
+            {job.error}
+          
+ ) : '—'} +
+ + {job.startedAt ? new Date(job.startedAt).toLocaleString('nl-NL') : '—'} + + + {job.finishedAt ? new Date(job.finishedAt).toLocaleString('nl-NL') : '—'} + +
+ ) +} From f166186374aefac70e166705f3681be19e0f3e11 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 19:16:20 +0200 Subject: [PATCH 10/73] feat(PBI-59): Jobs-pagina UI (vervolg na #149) (#150) * feat(PBI-58): Vitest-tests voor SoloTaskCard veldmapping en 4-regels layout Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): server action fetchJobsPageData voor jobs-pagina Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): SSE-route /api/realtime/jobs voor user-scoped job-events Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): JobCard component voor jobs-pagina Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): JobDetailPane component voor jobs-pagina Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): API route GET /api/jobs/[id]/sub-tasks voor sprint task executions Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): SprintSubTasksPane component voor jobs-pagina Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): Zustand store useJobsStore voor jobs-pagina Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): useJobsRealtime hook met SSE-verbinding en store-updates Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): JobsBoard 3-kolom SplitPane client component Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): /jobs server page met JobsBoard Co-Authored-By: Claude Sonnet 4.6 * feat(PBI-59): Jobs nav-link toevoegen aan NavBar Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- app/(app)/jobs/page.tsx | 25 ++++++ components/jobs/jobs-board.tsx | 96 +++++++++++++++++++++++ components/jobs/sprint-sub-tasks-pane.tsx | 67 ++++++++++++++++ components/shared/nav-bar.tsx | 1 + hooks/use-jobs-realtime.ts | 79 +++++++++++++++++++ stores/jobs-store.ts | 59 ++++++++++++++ 6 files changed, 327 insertions(+) create mode 100644 app/(app)/jobs/page.tsx create mode 100644 components/jobs/jobs-board.tsx create mode 100644 components/jobs/sprint-sub-tasks-pane.tsx create mode 100644 hooks/use-jobs-realtime.ts create mode 100644 stores/jobs-store.ts diff --git a/app/(app)/jobs/page.tsx b/app/(app)/jobs/page.tsx new file mode 100644 index 0000000..3982bff --- /dev/null +++ b/app/(app)/jobs/page.tsx @@ -0,0 +1,25 @@ +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { fetchJobsPageData } from '@/actions/jobs-page' +import JobsBoard from '@/components/jobs/jobs-board' + +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 ( +
+
+

Jobs

+
+
+ +
+
+ ) +} diff --git a/components/jobs/jobs-board.tsx b/components/jobs/jobs-board.tsx new file mode 100644 index 0000000..11c84ff --- /dev/null +++ b/components/jobs/jobs-board.tsx @@ -0,0 +1,96 @@ +'use client' + +import { useEffect } from 'react' +import { SplitPane } from '@/components/split-pane/split-pane' +import JobCard from './job-card' +import JobDetailPane from './job-detail-pane' +import SprintSubTasksPane from './sprint-sub-tasks-pane' +import { useJobsStore } from '@/stores/jobs-store' +import useJobsRealtime from '@/hooks/use-jobs-realtime' +import type { JobWithRelations } from '@/actions/jobs-page' + +interface JobsBoardProps { + initialActiveJobs: JobWithRelations[] + initialDoneJobs: JobWithRelations[] +} + +function jobToCardProps(j: JobWithRelations) { + return { + 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, + branch: j.branch, + error: j.error, + summary: j.summary, + } +} + +export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBoardProps) { + const { activeJobs, doneJobs, selectedJobId, initJobs, setSelectedJobId } = useJobsStore() + 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 = ( +
+ {activeJobs.map(j => ( + setSelectedJobId(j.id)} + /> + ))} + {activeJobs.length === 0 && ( +

Geen actieve jobs

+ )} +
+ ) + + const middlePane = ( +
+ +
+ +
+
+ ) + + const rightPane = ( +
+ {doneJobs.map(j => ( + setSelectedJobId(j.id)} + /> + ))} + {doneJobs.length === 0 && ( +

Nog geen afgeronde jobs

+ )} +
+ ) + + return ( + + ) +} diff --git a/components/jobs/sprint-sub-tasks-pane.tsx b/components/jobs/sprint-sub-tasks-pane.tsx new file mode 100644 index 0000000..7c91193 --- /dev/null +++ b/components/jobs/sprint-sub-tasks-pane.tsx @@ -0,0 +1,67 @@ +'use client' + +import { useEffect, useState } from 'react' +import { cn } from '@/lib/utils' +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([]) + 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
Laden…
+ } + + if (subTasks.length === 0) return null + + return ( +
+ {subTasks.map(t => { + const apiStatus = t.status.toLowerCase() as ClaudeJobStatusApi + return ( +
+ {t.taskCode} + {t.taskTitle} + + {JOB_STATUS_LABELS[apiStatus] ?? t.status} + +
+ ) + })} +
+ ) +} + +export default function SprintSubTasksPane({ jobId, isSprintJob }: SprintSubTasksPaneProps) { + if (!isSprintJob || !jobId) return null + return +} diff --git a/components/shared/nav-bar.tsx b/components/shared/nav-bar.tsx index 2ff3a81..61365b4 100644 --- a/components/shared/nav-bar.tsx +++ b/components/shared/nav-bar.tsx @@ -143,6 +143,7 @@ export function NavBar({ : disabledSpan('Solo')} {navLink('/insights', 'Insights', pathname.startsWith('/insights'))} {navLink('/ideas', 'Ideas', pathname.startsWith('/ideas'))} + {navLink('/jobs', 'Jobs', pathname.startsWith('/jobs'))} {navLink('/manual', 'Manual', pathname.startsWith('/manual'))} {roles.includes('ADMIN') && navLink('/admin', 'Admin', pathname.startsWith('/admin'))} diff --git a/hooks/use-jobs-realtime.ts b/hooks/use-jobs-realtime.ts new file mode 100644 index 0000000..f85b5c5 --- /dev/null +++ b/hooks/use-jobs-realtime.ts @@ -0,0 +1,79 @@ +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 initJobs = useJobsStore(s => s.initJobs) + const upsertJob = useJobsStore(s => s.upsertJob) + + useEffect(() => { + let es: EventSource | null = null + let reconnectTimer: ReturnType | null = null + let active = true + + function connect() { + if (!active) return + + es = new EventSource('/api/realtime/jobs') + + es.addEventListener('jobs_initial', (event) => { + try { + const jobs = JSON.parse(event.data) + if (Array.isArray(jobs)) { + initJobs(jobs, useJobsStore.getState().doneJobs) + } + } catch { + // malformed JSON + } + }) + + es.addEventListener('message', (event) => { + try { + const payload = JSON.parse(event.data) as JobStatusPayload + if (!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() + } + }, [initJobs, upsertJob]) +} diff --git a/stores/jobs-store.ts b/stores/jobs-store.ts new file mode 100644 index 0000000..fe0cc40 --- /dev/null +++ b/stores/jobs-store.ts @@ -0,0 +1,59 @@ +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 & { id: string; status: string }): void +} + +export const useJobsStore = create()( + 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.push(job as JobWithRelations) + } + } + }) + }, + })) +) From a7e9ca1c35a6b93f4be7d0b66e4819a6414c06fc Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 19:24:21 +0200 Subject: [PATCH 11/73] fix(PBI-59): drop invalid Sprint.code select in fetchJobsPageData (#151) Sprint heeft geen `code` veld; de query crashte met PrismaClientValidationError zodra /jobs werd geopend. sprintCode blijft in JobWithRelations als string|null voor UI-compat (JobCard.titleText fallback) maar is nu altijd null. Co-authored-by: Claude Opus 4.7 (1M context) --- actions/jobs-page.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/actions/jobs-page.ts b/actions/jobs-page.ts index ae58804..ebf811d 100644 --- a/actions/jobs-page.ts +++ b/actions/jobs-page.ts @@ -35,7 +35,7 @@ const JOB_INCLUDE = { task: { select: { code: true, title: true } }, idea: { select: { code: true, title: true } }, product: { select: { name: true } }, - sprint_run: { include: { sprint: { select: { sprint_goal: true, code: true } } } }, + sprint_run: { include: { sprint: { select: { sprint_goal: true } } } }, } as const function mapJob(j: { @@ -59,7 +59,7 @@ function mapJob(j: { task: { code: string | null; title: string } | null idea: { code: string | null; title: string } | null product: { name: string } - sprint_run: { sprint: { sprint_goal: string; code: string | null } } | null + sprint_run: { sprint: { sprint_goal: string } } | null }): JobWithRelations { return { id: j.id, @@ -70,7 +70,7 @@ function mapJob(j: { 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, + sprintCode: null, productName: j.product.name, modelId: j.model_id, inputTokens: j.input_tokens, From 16f01283efc21de27b9fbd3ed147b80ee5613fcd Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 19:51:53 +0200 Subject: [PATCH 12/73] feat(PBI-59): add Detail/Usage view-switch on /jobs (#152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits het middenpaneel van de jobs-pagina in twee views (zoals admin/jobs): - **Detail** — alle metadata (status, kind, product, branch, PR, dates, errors, summary, verify-result) plus een kind-aware beschrijving: TASK → implementation_plan, IDEA_GRILL → grill_md, IDEA_MAKE_PLAN → plan_md, PLAN_CHAT → idea.description. - **Usage** — model, tokens (in/uit/cache/totaal), berekende kosten in USD via ModelPrice-tabel, en duur (started→finished). SprintSubTasksPane blijft als sticky header boven beide views. Server action `fetchJobsPageData` haalt nu ook ModelPrices op en selecteert task.{description,implementation_plan} + idea.{description,grill_md,plan_md} zodat de description en costUsd in JobWithRelations gevuld kunnen worden. Co-authored-by: Claude Opus 4.7 (1M context) --- actions/jobs-page.ts | 75 +++++++++++++++++++++++++---- components/jobs/job-detail-pane.tsx | 58 +++++++++++++++++----- components/jobs/job-usage-pane.tsx | 71 +++++++++++++++++++++++++++ components/jobs/jobs-board.tsx | 25 +++++++++- 4 files changed, 207 insertions(+), 22 deletions(-) create mode 100644 components/jobs/job-usage-pane.tsx diff --git a/actions/jobs-page.ts b/actions/jobs-page.ts index ebf811d..6148439 100644 --- a/actions/jobs-page.ts +++ b/actions/jobs-page.ts @@ -20,10 +20,12 @@ export type JobWithRelations = { 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 @@ -32,13 +34,13 @@ export type JobWithRelations = { } const JOB_INCLUDE = { - task: { select: { code: true, title: true } }, - idea: { select: { code: true, title: true } }, + task: { select: { code: true, title: true, description: true, implementation_plan: true } }, + idea: { select: { code: true, title: true, description: true, grill_md: true, plan_md: true } }, product: { select: { name: true } }, sprint_run: { include: { sprint: { select: { sprint_goal: true } } } }, } as const -function mapJob(j: { +type RawJob = { id: string kind: ClaudeJobKind status: ClaudeJobStatus @@ -56,11 +58,61 @@ function mapJob(j: { finished_at: Date | null created_at: Date sprint_run_id: string | null - task: { code: string | null; title: string } | null - idea: { code: string | null; title: string } | null + task: { + code: string | null + title: string + description: string | null + implementation_plan: string | null + } | null + idea: { + code: string | null + title: string + description: string | null + grill_md: string | null + plan_md: string | null + } | null product: { name: string } sprint_run: { sprint: { sprint_goal: string } } | null -}): JobWithRelations { +} + +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 } +} + +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 + } +} + +function computeCost(j: RawJob, priceMap: Map): 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 + ) +} + +function mapJob(j: RawJob, priceMap: Map): JobWithRelations { return { id: j.id, kind: j.kind, @@ -77,10 +129,12 @@ function mapJob(j: { 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, @@ -93,7 +147,7 @@ export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelation const session = await getSession() if (!session.userId) return null - const [active, done] = await Promise.all([ + const [active, done, prices] = await Promise.all([ prisma.claudeJob.findMany({ where: { user_id: session.userId, status: { notIn: ['DONE'] } }, include: JOB_INCLUDE, @@ -105,10 +159,13 @@ export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelation orderBy: { finished_at: 'desc' }, take: 100, }), + prisma.modelPrice.findMany(), ]) + const priceMap = new Map(prices.map((p) => [p.model_id, p as unknown as PriceRow])) + return { - activeJobs: active.map(mapJob), - doneJobs: done.map(mapJob), + activeJobs: active.map((j) => mapJob(j as RawJob, priceMap)), + doneJobs: done.map((j) => mapJob(j as RawJob, priceMap)), } } diff --git a/components/jobs/job-detail-pane.tsx b/components/jobs/job-detail-pane.tsx index 5f7b2cd..c90f220 100644 --- a/components/jobs/job-detail-pane.tsx +++ b/components/jobs/job-detail-pane.tsx @@ -19,6 +19,24 @@ function FieldRow({ label, children }: FieldRowProps) { ) } +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) return null + return { label: 'Sprint', value: job.sprintGoal } + 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 } @@ -33,6 +51,7 @@ export default function JobDetailPane({ job }: JobDetailPaneProps) { } const apiStatus = jobStatusToApi(job.status) + const subject = subjectLabel(job) return (
@@ -43,11 +62,7 @@ export default function JobDetailPane({ job }: JobDetailPaneProps) { {job.kind} {job.productName} - {job.modelId || '—'} - {job.inputTokens?.toLocaleString() || '—'} - {job.outputTokens?.toLocaleString() || '—'} - {job.cacheReadTokens?.toLocaleString() || '—'} - {job.cacheWriteTokens?.toLocaleString() || '—'} + {subject && {subject.value}} {job.branch || '—'} @@ -58,12 +73,9 @@ export default function JobDetailPane({ job }: JobDetailPaneProps) { ) : '—'} - - {job.error ? ( -
-            {job.error}
-          
- ) : '—'} + {job.verifyResult ?? '—'} + + {new Date(job.createdAt).toLocaleString('nl-NL')} {job.startedAt ? new Date(job.startedAt).toLocaleString('nl-NL') : '—'} @@ -71,6 +83,30 @@ export default function JobDetailPane({ job }: JobDetailPaneProps) { {job.finishedAt ? new Date(job.finishedAt).toLocaleString('nl-NL') : '—'} + + {job.error ? ( +
+            {job.error}
+          
+ ) : '—'} +
+ + {job.summary ? ( +
+            {job.summary}
+          
+ ) : '—'} +
+
+

Beschrijving

+ {job.description ? ( +
+            {job.description}
+          
+ ) : ( +

Geen beschrijving.

+ )} +
) } diff --git a/components/jobs/job-usage-pane.tsx b/components/jobs/job-usage-pane.tsx new file mode 100644 index 0000000..2a5cd1e --- /dev/null +++ b/components/jobs/job-usage-pane.tsx @@ -0,0 +1,71 @@ +'use client' + +import type { JobWithRelations } from '@/actions/jobs-page' + +interface FieldRowProps { + label: string + children: React.ReactNode +} + +function FieldRow({ label, children }: FieldRowProps) { + return ( +
+ {label} + {children} +
+ ) +} + +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 ( +
+ Selecteer een job om gebruik te zien +
+ ) + } + + const totalTokens = + (job.inputTokens ?? 0) + + (job.outputTokens ?? 0) + + (job.cacheReadTokens ?? 0) + + (job.cacheWriteTokens ?? 0) + + const costLabel = job.costUsd != null ? `$${job.costUsd.toFixed(4)}` : '—' + + return ( +
+ {job.modelId ?? '—'} + {formatNumber(job.inputTokens)} + {formatNumber(job.outputTokens)} + {formatNumber(job.cacheReadTokens)} + {formatNumber(job.cacheWriteTokens)} + {formatNumber(totalTokens || null)} + {costLabel} + {formatDuration(job.startedAt, job.finishedAt)} +
+ ) +} diff --git a/components/jobs/jobs-board.tsx b/components/jobs/jobs-board.tsx index 11c84ff..498ba60 100644 --- a/components/jobs/jobs-board.tsx +++ b/components/jobs/jobs-board.tsx @@ -1,9 +1,11 @@ 'use client' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' +import { Button } from '@/components/ui/button' import { SplitPane } from '@/components/split-pane/split-pane' import JobCard from './job-card' import JobDetailPane from './job-detail-pane' +import JobUsagePane from './job-usage-pane' import SprintSubTasksPane from './sprint-sub-tasks-pane' import { useJobsStore } from '@/stores/jobs-store' import useJobsRealtime from '@/hooks/use-jobs-realtime' @@ -14,6 +16,8 @@ interface JobsBoardProps { initialDoneJobs: JobWithRelations[] } +type View = 'detail' | 'usage' + function jobToCardProps(j: JobWithRelations) { return { id: j.id, @@ -34,6 +38,7 @@ function jobToCardProps(j: JobWithRelations) { export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBoardProps) { const { activeJobs, doneJobs, selectedJobId, initJobs, setSelectedJobId } = useJobsStore() + const [view, setView] = useState('detail') useJobsRealtime() // eslint-disable-next-line react-hooks/exhaustive-deps @@ -63,8 +68,24 @@ export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBo jobId={selectedJobId} isSprintJob={selectedJob?.kind === 'SPRINT_IMPLEMENTATION'} /> +
+ + +
- + {view === 'detail' ? : }
) From a268df36805a7ca0f5a488b22119ed642cfea0a2 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 20:10:16 +0200 Subject: [PATCH 13/73] feat(PBI-59): Sprint.code (SP-N sequentieel per product) (#153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voegt een verplicht code-veld toe aan Sprint, sequentieel per product (consistent met PBI-N, ST-NNN, T-N). - **Schema** — `Sprint.code String @db.VarChar(30)` + `@@unique([product_id, code])` - **Migratie** — voegt kolom toe als nullable, backfillt bestaande sprints via `ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY created_at)` als `SP-N`, en zet daarna NOT NULL + UNIQUE. - **Generator** — `generateNextSprintCode(productId)` in lib/code-server.ts volgt het patroon van story/pbi/task; createSprintAction gebruikt `createWithCodeRetry` voor race-bescherming. - **Seed** — sprint-counter per product (`SP-1`, `SP-2`, ...). Zichtbaar in: - Sprint-header (`Product › Sprint actief · SP-3`) - JobCard + JobDetailPane voor SPRINT_IMPLEMENTATION jobs - Insights: VelocityChart x-axis (compacter dan goal-truncated), AlignmentTrend tooltip, SprintInfoStrip - actions/jobs-page.ts: `sprintCode` is weer een echte code i.p.v. null Co-authored-by: Claude Opus 4.7 (1M context) --- __tests__/actions/sprint-dates.test.ts | 4 +++- actions/jobs-page.ts | 6 ++--- actions/sprints.ts | 24 ++++++++++++------- .../insights/components/alignment-trend.tsx | 13 +++++----- .../insights/components/sprint-info-strip.tsx | 2 ++ .../insights/components/velocity-chart.tsx | 4 +--- app/(app)/insights/page.tsx | 2 ++ app/(app)/products/[id]/sprint/page.tsx | 1 + components/jobs/job-card.tsx | 3 ++- components/jobs/job-detail-pane.tsx | 7 ++++-- components/sprint/sprint-header.tsx | 3 +++ docs/erd.svg | 2 +- lib/code-server.ts | 10 ++++++++ lib/insights/burndown.ts | 3 +++ lib/insights/token-history.ts | 9 +++++-- lib/insights/velocity.ts | 3 +++ lib/insights/verify-stats.ts | 3 +++ .../migration.sql | 23 ++++++++++++++++++ prisma/schema.prisma | 2 ++ prisma/seed.ts | 2 ++ 20 files changed, 97 insertions(+), 29 deletions(-) create mode 100644 prisma/migrations/20260507195507_add_sprint_code/migration.sql diff --git a/__tests__/actions/sprint-dates.test.ts b/__tests__/actions/sprint-dates.test.ts index eaa05db..875ab1d 100644 --- a/__tests__/actions/sprint-dates.test.ts +++ b/__tests__/actions/sprint-dates.test.ts @@ -16,6 +16,7 @@ vi.mock('@/lib/prisma', () => ({ prisma: { sprint: { findFirst: vi.fn(), + findMany: vi.fn(), create: vi.fn(), update: vi.fn(), }, @@ -25,7 +26,7 @@ vi.mock('@/lib/prisma', () => ({ import { prisma } from '@/lib/prisma' import { createSprintAction, updateSprintDatesAction } from '@/actions/sprints' -const mockSprint = prisma as unknown as { sprint: { findFirst: ReturnType; create: ReturnType; update: ReturnType } } +const mockSprint = prisma as unknown as { sprint: { findFirst: ReturnType; findMany: ReturnType; create: ReturnType; update: ReturnType } } function makeFormData(data: Record) { const fd = new FormData() @@ -39,6 +40,7 @@ describe('createSprintAction — date validation', () => { beforeEach(() => { vi.clearAllMocks() mockSprint.sprint.findFirst.mockResolvedValue(null) + mockSprint.sprint.findMany.mockResolvedValue([]) mockSprint.sprint.create.mockResolvedValue({ id: 'sprint-1' }) }) diff --git a/actions/jobs-page.ts b/actions/jobs-page.ts index 6148439..de9b1b8 100644 --- a/actions/jobs-page.ts +++ b/actions/jobs-page.ts @@ -37,7 +37,7 @@ const JOB_INCLUDE = { task: { select: { code: true, title: true, description: true, implementation_plan: true } }, idea: { select: { code: true, title: true, description: true, grill_md: true, plan_md: true } }, product: { select: { name: true } }, - sprint_run: { include: { sprint: { select: { sprint_goal: true } } } }, + sprint_run: { include: { sprint: { select: { sprint_goal: true, code: true } } } }, } as const type RawJob = { @@ -72,7 +72,7 @@ type RawJob = { plan_md: string | null } | null product: { name: string } - sprint_run: { sprint: { sprint_goal: string } } | null + sprint_run: { sprint: { sprint_goal: string; code: string } } | null } type PriceRow = { @@ -122,7 +122,7 @@ function mapJob(j: RawJob, priceMap: Map): JobWithRelations { ideaCode: j.idea?.code ?? null, ideaTitle: j.idea?.title ?? null, sprintGoal: j.sprint_run?.sprint.sprint_goal ?? null, - sprintCode: null, + sprintCode: j.sprint_run?.sprint.code ?? null, productName: j.product.name, modelId: j.model_id, inputTokens: j.input_tokens, diff --git a/actions/sprints.ts b/actions/sprints.ts index 2784334..3da4eda 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -13,6 +13,7 @@ import { } from '@/lib/schemas/sprint' import { enforceUserRateLimit } from '@/lib/rate-limit' import { propagateStatusUpwards } from '@/lib/tasks-status-update' +import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -54,15 +55,20 @@ export async function createSprintAction(_prevState: unknown, formData: FormData }) if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id, code: 422 } - 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, - }, - }) + 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: 'ACTIVE', + start_date: parsed.data.start_date, + end_date: parsed.data.end_date, + }, + }), + ) revalidatePath(`/products/${parsed.data.productId}`) return { success: true, sprintId: sprint.id } diff --git a/app/(app)/insights/components/alignment-trend.tsx b/app/(app)/insights/components/alignment-trend.tsx index 45375d1..1718188 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; sprintGoal: string } + payload?: { total: number; alignedRatio: number; sprintCode: string; sprintGoal: string } } function CustomTooltip({ active, payload }: { active?: boolean; payload?: TooltipPayload[] }) { @@ -25,7 +25,10 @@ function CustomTooltip({ active, payload }: { active?: boolean; payload?: Toolti const aligned = Math.round((d.alignedRatio / 100) * d.total) return (
-

{d.sprintGoal}

+

+ {d.sprintCode} + {d.sprintGoal} +

{aligned} / {d.total} aligned ({d.alignedRatio}%)

@@ -33,10 +36,6 @@ 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 ( @@ -48,7 +47,7 @@ export function AlignmentTrend({ trend }: Props) { const data = trend.map(p => ({ ...p, - label: sprintLabel(p.sprintGoal), + label: p.sprintCode, })) return ( diff --git a/app/(app)/insights/components/sprint-info-strip.tsx b/app/(app)/insights/components/sprint-info-strip.tsx index 3d85a33..ed3c15b 100644 --- a/app/(app)/insights/components/sprint-info-strip.tsx +++ b/app/(app)/insights/components/sprint-info-strip.tsx @@ -2,6 +2,7 @@ interface SprintInfo { sprintId: string + sprintCode: string productName: string sprintGoal: string taskCount: number @@ -33,6 +34,7 @@ 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" > {s.productName} + {s.sprintCode} {truncate(s.sprintGoal, 60)} {s.daysLeft > 0 ? `${s.daysLeft}d over` : `${Math.abs(s.daysLeft)}d over tijd`} diff --git a/app/(app)/insights/components/velocity-chart.tsx b/app/(app)/insights/components/velocity-chart.tsx index 7cd2d9e..a05df7f 100644 --- a/app/(app)/insights/components/velocity-chart.tsx +++ b/app/(app)/insights/components/velocity-chart.tsx @@ -35,11 +35,9 @@ export function VelocityChart({ data }: Props) { type Row = { sprintLabel: string } & Record const grouped = new Map() 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: label }) + grouped.set(key, { sprintLabel: s.sprintCode }) } grouped.get(key)![s.productName] = s.doneCount } diff --git a/app/(app)/insights/page.tsx b/app/(app)/insights/page.tsx index 39244b7..4c646a1 100644 --- a/app/(app)/insights/page.tsx +++ b/app/(app)/insights/page.tsx @@ -60,6 +60,7 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps) where: { status: 'ACTIVE', product: productAccessFilter(userId) }, select: { id: true, + code: true, sprint_goal: true, created_at: true, product: { select: { id: true, name: true } }, @@ -88,6 +89,7 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps) 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, diff --git a/app/(app)/products/[id]/sprint/page.tsx b/app/(app)/products/[id]/sprint/page.tsx index e535758..7f09296 100644 --- a/app/(app)/products/[id]/sprint/page.tsx +++ b/app/(app)/products/[id]/sprint/page.tsx @@ -38,6 +38,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { where: { product_id: id, status: { in: ['ACTIVE', 'FAILED'] } }, select: { id: true, + code: true, sprint_goal: true, status: true, start_date: true, diff --git a/components/jobs/job-card.tsx b/components/jobs/job-card.tsx index 590e743..0d888a6 100644 --- a/components/jobs/job-card.tsx +++ b/components/jobs/job-card.tsx @@ -39,7 +39,8 @@ export default function JobCard({ if (kind === 'TASK_IMPLEMENTATION') { titleText = taskCode && taskTitle ? `${taskCode} ${taskTitle}` : taskTitle || 'Taak' } else if (kind === 'SPRINT_IMPLEMENTATION') { - titleText = sprintGoal || (sprintCode ? `Sprint ${sprintCode}` : 'Sprint') + 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') { diff --git a/components/jobs/job-detail-pane.tsx b/components/jobs/job-detail-pane.tsx index c90f220..7a691c1 100644 --- a/components/jobs/job-detail-pane.tsx +++ b/components/jobs/job-detail-pane.tsx @@ -25,8 +25,11 @@ function subjectLabel(job: JobWithRelations): { label: string; value: string } | if (!job.taskTitle) return null return { label: 'Taak', value: job.taskCode ? `${job.taskCode} ${job.taskTitle}` : job.taskTitle } case 'SPRINT_IMPLEMENTATION': - if (!job.sprintGoal) return null - return { label: 'Sprint', value: job.sprintGoal } + 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': diff --git a/components/sprint/sprint-header.tsx b/components/sprint/sprint-header.tsx index 893e21a..9c73c53 100644 --- a/components/sprint/sprint-header.tsx +++ b/components/sprint/sprint-header.tsx @@ -35,6 +35,7 @@ import type { SprintStory } from './sprint-backlog' interface Sprint { id: string + code: string sprint_goal: string status: string start_date: Date | null @@ -136,6 +137,8 @@ export function SprintHeader({ productId: _productId, productName, sprint, isDem {productName} Sprint actief + · + {sprint.code}
{editingGoal ? ( diff --git a/docs/erd.svg b/docs/erd.svg index 7b40b67..ef98c47 100644 --- a/docs/erd.svg +++ b/docs/erd.svg @@ -1 +1 @@ -

active_product

user

enum:role

user

user

product

enum:status

pbi

product

sprint

assignee

enum:status

story

enum:type

enum:status

product

enum:status

story

product

sprint

enum:status

enum:verify_required

user

product

task

idea

enum:kind

enum:status

claimed_by_token

enum:verify_result

user

token

product

user

user

product

pbi

enum:status

idea

product

idea

enum:type

enum:status

idea

user

story

task

idea

product

asker

answerer

Role

PRODUCT_OWNER

PRODUCT_OWNER

SCRUM_MASTER

SCRUM_MASTER

DEVELOPER

DEVELOPER

ADMIN

ADMIN

StoryStatus

OPEN

OPEN

IN_SPRINT

IN_SPRINT

DONE

DONE

PbiStatus

READY

READY

BLOCKED

BLOCKED

DONE

DONE

ClaudeJobStatus

QUEUED

QUEUED

CLAIMED

CLAIMED

RUNNING

RUNNING

DONE

DONE

FAILED

FAILED

CANCELLED

CANCELLED

SKIPPED

SKIPPED

VerifyResult

ALIGNED

ALIGNED

PARTIAL

PARTIAL

EMPTY

EMPTY

DIVERGENT

DIVERGENT

VerifyRequired

ALIGNED

ALIGNED

ALIGNED_OR_PARTIAL

ALIGNED_OR_PARTIAL

ANY

ANY

TaskStatus

TO_DO

TO_DO

IN_PROGRESS

IN_PROGRESS

REVIEW

REVIEW

DONE

DONE

LogType

IMPLEMENTATION_PLAN

IMPLEMENTATION_PLAN

TEST_RESULT

TEST_RESULT

COMMIT

COMMIT

TestStatus

PASSED

PASSED

FAILED

FAILED

SprintStatus

ACTIVE

ACTIVE

COMPLETED

COMPLETED

IdeaStatus

DRAFT

DRAFT

GRILLING

GRILLING

GRILL_FAILED

GRILL_FAILED

GRILLED

GRILLED

PLANNING

PLANNING

PLAN_FAILED

PLAN_FAILED

PLAN_READY

PLAN_READY

PLANNED

PLANNED

ClaudeJobKind

TASK_IMPLEMENTATION

TASK_IMPLEMENTATION

IDEA_GRILL

IDEA_GRILL

IDEA_MAKE_PLAN

IDEA_MAKE_PLAN

PLAN_CHAT

PLAN_CHAT

IdeaLogType

DECISION

DECISION

NOTE

NOTE

GRILL_RESULT

GRILL_RESULT

PLAN_RESULT

PLAN_RESULT

STATUS_CHANGE

STATUS_CHANGE

JOB_EVENT

JOB_EVENT

UserQuestionStatus

pending

pending

answered

answered

users

String

id

🗝️

String

username

String

email

String

password_hash

Boolean

is_demo

String

bio

String

bio_detail

Boolean

must_reset_password

Bytes

avatar_data

Int

idea_code_counter

Int

min_quota_pct

DateTime

created_at

DateTime

updated_at

user_roles

String

id

🗝️

Role

role

api_tokens

String

id

🗝️

String

token_hash

String

label

DateTime

created_at

DateTime

revoked_at

products

String

id

🗝️

String

name

String

code

String

description

String

repo_url

String

definition_of_done

Boolean

auto_pr

Boolean

archived

DateTime

created_at

DateTime

updated_at

pbis

String

id

🗝️

String

code

String

title

String

description

Int

priority

Float

sort_order

PbiStatus

status

String

pr_url

DateTime

pr_merged_at

DateTime

created_at

DateTime

updated_at

stories

String

id

🗝️

String

code

String

title

String

description

String

acceptance_criteria

Int

priority

Float

sort_order

StoryStatus

status

DateTime

created_at

DateTime

updated_at

story_logs

String

id

🗝️

LogType

type

String

content

TestStatus

status

String

commit_hash

String

commit_message

Json

metadata

DateTime

created_at

sprints

String

id

🗝️

String

sprint_goal

SprintStatus

status

DateTime

start_date

DateTime

end_date

DateTime

created_at

DateTime

completed_at

tasks

String

id

🗝️

String

code

String

title

String

description

String

implementation_plan

Int

priority

Float

sort_order

TaskStatus

status

Boolean

verify_only

VerifyRequired

verify_required

String

repo_url

DateTime

created_at

DateTime

updated_at

claude_jobs

String

id

🗝️

ClaudeJobKind

kind

ClaudeJobStatus

status

DateTime

claimed_at

DateTime

started_at

DateTime

finished_at

DateTime

pushed_at

VerifyResult

verify_result

String

model_id

Int

input_tokens

Int

output_tokens

Int

cache_read_tokens

Int

cache_write_tokens

String

plan_snapshot

String

branch

String

pr_url

String

summary

String

error

Int

retry_count

DateTime

created_at

DateTime

updated_at

model_prices

String

id

🗝️

String

model_id

Decimal

input_price_per_1m

Decimal

output_price_per_1m

Decimal

cache_read_price_per_1m

Decimal

cache_write_price_per_1m

String

currency

DateTime

created_at

DateTime

updated_at

claude_workers

String

id

🗝️

String

product_id

DateTime

started_at

DateTime

last_seen_at

Int

last_quota_pct

DateTime

last_quota_check_at

product_members

String

id

🗝️

DateTime

created_at

ideas

String

id

🗝️

String

code

String

title

String

description

String

grill_md

String

plan_md

IdeaStatus

status

Boolean

archived

DateTime

created_at

DateTime

updated_at

idea_products

String

id

🗝️

DateTime

created_at

idea_logs

String

id

🗝️

IdeaLogType

type

String

content

Json

metadata

DateTime

created_at

user_questions

String

id

🗝️

String

user_id

String

question

String

answer

UserQuestionStatus

status

DateTime

created_at

DateTime

updated_at

login_pairings

String

id

🗝️

String

secret_hash

String

desktop_token_hash

String

status

String

desktop_ua

String

desktop_ip

DateTime

created_at

DateTime

expires_at

DateTime

approved_at

DateTime

consumed_at

claude_questions

String

id

🗝️

String

question

Json

options

String

status

String

answer

DateTime

answered_at

DateTime

created_at

DateTime

expires_at

\ No newline at end of file +

active_product

user

enum:role

user

user

enum:pr_strategy

product

enum:status

pbi

product

sprint

assignee

enum:status

story

enum:type

enum:status

product

enum:status

sprint

started_by

enum:status

enum:pr_strategy

failed_task

previous_run

story

product

sprint

enum:status

enum:verify_required

user

product

task

idea

sprint_run

enum:kind

enum:status

claimed_by_token

enum:verify_result

sprint_job

task

enum:verify_required_snapshot

enum:status

enum:verify_result

user

token

product

user

user

product

pbi

enum:status

idea

product

idea

enum:type

enum:status

idea

user

story

task

idea

product

asker

answerer

Role

PRODUCT_OWNER

PRODUCT_OWNER

SCRUM_MASTER

SCRUM_MASTER

DEVELOPER

DEVELOPER

ADMIN

ADMIN

StoryStatus

OPEN

OPEN

IN_SPRINT

IN_SPRINT

DONE

DONE

FAILED

FAILED

PbiStatus

READY

READY

BLOCKED

BLOCKED

FAILED

FAILED

DONE

DONE

ClaudeJobStatus

QUEUED

QUEUED

CLAIMED

CLAIMED

RUNNING

RUNNING

DONE

DONE

FAILED

FAILED

CANCELLED

CANCELLED

SKIPPED

SKIPPED

VerifyResult

ALIGNED

ALIGNED

PARTIAL

PARTIAL

EMPTY

EMPTY

DIVERGENT

DIVERGENT

VerifyRequired

ALIGNED

ALIGNED

ALIGNED_OR_PARTIAL

ALIGNED_OR_PARTIAL

ANY

ANY

TaskStatus

TO_DO

TO_DO

IN_PROGRESS

IN_PROGRESS

REVIEW

REVIEW

DONE

DONE

FAILED

FAILED

LogType

IMPLEMENTATION_PLAN

IMPLEMENTATION_PLAN

TEST_RESULT

TEST_RESULT

COMMIT

COMMIT

TestStatus

PASSED

PASSED

FAILED

FAILED

SprintStatus

ACTIVE

ACTIVE

COMPLETED

COMPLETED

FAILED

FAILED

SprintRunStatus

QUEUED

QUEUED

RUNNING

RUNNING

PAUSED

PAUSED

DONE

DONE

FAILED

FAILED

CANCELLED

CANCELLED

PrStrategy

SPRINT

SPRINT

STORY

STORY

SPRINT_BATCH

SPRINT_BATCH

IdeaStatus

DRAFT

DRAFT

GRILLING

GRILLING

GRILL_FAILED

GRILL_FAILED

GRILLED

GRILLED

PLANNING

PLANNING

PLAN_FAILED

PLAN_FAILED

PLAN_READY

PLAN_READY

PLANNED

PLANNED

ClaudeJobKind

TASK_IMPLEMENTATION

TASK_IMPLEMENTATION

IDEA_GRILL

IDEA_GRILL

IDEA_MAKE_PLAN

IDEA_MAKE_PLAN

PLAN_CHAT

PLAN_CHAT

SPRINT_IMPLEMENTATION

SPRINT_IMPLEMENTATION

SprintTaskExecutionStatus

PENDING

PENDING

RUNNING

RUNNING

DONE

DONE

FAILED

FAILED

SKIPPED

SKIPPED

IdeaLogType

DECISION

DECISION

NOTE

NOTE

GRILL_RESULT

GRILL_RESULT

PLAN_RESULT

PLAN_RESULT

STATUS_CHANGE

STATUS_CHANGE

JOB_EVENT

JOB_EVENT

UserQuestionStatus

pending

pending

answered

answered

users

String

id

🗝️

String

username

String

email

String

password_hash

Boolean

is_demo

String

bio

String

bio_detail

Boolean

must_reset_password

Bytes

avatar_data

Int

idea_code_counter

Int

min_quota_pct

DateTime

created_at

DateTime

updated_at

user_roles

String

id

🗝️

Role

role

api_tokens

String

id

🗝️

String

token_hash

String

label

DateTime

created_at

DateTime

revoked_at

products

String

id

🗝️

String

name

String

code

String

description

String

repo_url

String

definition_of_done

Boolean

auto_pr

PrStrategy

pr_strategy

Boolean

archived

DateTime

created_at

DateTime

updated_at

pbis

String

id

🗝️

String

code

String

title

String

description

Int

priority

Float

sort_order

PbiStatus

status

String

pr_url

DateTime

pr_merged_at

DateTime

created_at

DateTime

updated_at

stories

String

id

🗝️

String

code

String

title

String

description

String

acceptance_criteria

Int

priority

Float

sort_order

StoryStatus

status

DateTime

created_at

DateTime

updated_at

story_logs

String

id

🗝️

LogType

type

String

content

TestStatus

status

String

commit_hash

String

commit_message

Json

metadata

DateTime

created_at

sprints

String

id

🗝️

String

code

String

sprint_goal

SprintStatus

status

DateTime

start_date

DateTime

end_date

DateTime

created_at

DateTime

completed_at

sprint_runs

String

id

🗝️

SprintRunStatus

status

PrStrategy

pr_strategy

String

branch

String

pr_url

DateTime

started_at

DateTime

finished_at

String

failure_reason

Json

pause_context

DateTime

created_at

DateTime

updated_at

tasks

String

id

🗝️

String

code

String

title

String

description

String

implementation_plan

Int

priority

Float

sort_order

TaskStatus

status

Boolean

verify_only

VerifyRequired

verify_required

String

repo_url

DateTime

created_at

DateTime

updated_at

claude_jobs

String

id

🗝️

ClaudeJobKind

kind

ClaudeJobStatus

status

DateTime

claimed_at

DateTime

started_at

DateTime

finished_at

DateTime

pushed_at

VerifyResult

verify_result

String

model_id

Int

input_tokens

Int

output_tokens

Int

cache_read_tokens

Int

cache_write_tokens

String

plan_snapshot

String

base_sha

String

head_sha

String

branch

String

pr_url

String

summary

String

error

Int

retry_count

DateTime

lease_until

DateTime

created_at

DateTime

updated_at

sprint_task_executions

String

id

🗝️

Int

order

String

plan_snapshot

VerifyRequired

verify_required_snapshot

Boolean

verify_only_snapshot

String

base_sha

String

head_sha

SprintTaskExecutionStatus

status

VerifyResult

verify_result

String

verify_summary

String

skip_reason

DateTime

started_at

DateTime

finished_at

DateTime

created_at

DateTime

updated_at

model_prices

String

id

🗝️

String

model_id

Decimal

input_price_per_1m

Decimal

output_price_per_1m

Decimal

cache_read_price_per_1m

Decimal

cache_write_price_per_1m

String

currency

DateTime

created_at

DateTime

updated_at

claude_workers

String

id

🗝️

String

product_id

DateTime

started_at

DateTime

last_seen_at

Int

last_quota_pct

DateTime

last_quota_check_at

product_members

String

id

🗝️

DateTime

created_at

ideas

String

id

🗝️

String

code

String

title

String

description

String

grill_md

String

plan_md

IdeaStatus

status

Boolean

archived

DateTime

created_at

DateTime

updated_at

idea_products

String

id

🗝️

DateTime

created_at

idea_logs

String

id

🗝️

IdeaLogType

type

String

content

Json

metadata

DateTime

created_at

user_questions

String

id

🗝️

String

user_id

String

question

String

answer

UserQuestionStatus

status

DateTime

created_at

DateTime

updated_at

login_pairings

String

id

🗝️

String

secret_hash

String

desktop_token_hash

String

status

String

desktop_ua

String

desktop_ip

DateTime

created_at

DateTime

expires_at

DateTime

approved_at

DateTime

consumed_at

claude_questions

String

id

🗝️

String

question

Json

options

String

status

String

answer

DateTime

answered_at

DateTime

created_at

DateTime

expires_at

\ No newline at end of file diff --git a/lib/code-server.ts b/lib/code-server.ts index 461c859..a74fc4b 100644 --- a/lib/code-server.ts +++ b/lib/code-server.ts @@ -41,6 +41,7 @@ export async function createWithCodeRetry( 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 @@ -82,3 +83,12 @@ export async function generateNextTaskCode(productId: string): Promise { return `T-${next}` } +export async function generateNextSprintCode(productId: string): Promise { + 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/insights/burndown.ts b/lib/insights/burndown.ts index 551d216..bc14b11 100644 --- a/lib/insights/burndown.ts +++ b/lib/insights/burndown.ts @@ -9,6 +9,7 @@ export interface BurndownDay { export interface BurndownSprint { sprintId: string + sprintCode: string productId: string productName: string sprintGoal: string @@ -62,6 +63,7 @@ export async function getBurndownData(userId: string): Promise }, select: { id: true, + code: true, sprint_goal: true, created_at: true, completed_at: true, @@ -77,6 +79,7 @@ export async function getBurndownData(userId: string): Promise return { sprintId: sprint.id, + sprintCode: sprint.code, productId: sprint.product.id, productName: sprint.product.name, sprintGoal: sprint.sprint_goal, diff --git a/lib/insights/token-history.ts b/lib/insights/token-history.ts index 33f1abd..75674b0 100644 --- a/lib/insights/token-history.ts +++ b/lib/insights/token-history.ts @@ -2,6 +2,7 @@ import { prisma } from '@/lib/prisma' export interface SprintTokenRow { sprintId: string + sprintCode: string sprintGoal: string totalTokens: number totalCostUsd: number @@ -24,6 +25,7 @@ export interface PbiTokenRow { type RawSprintRow = { sprint_id: string + sprint_code: string sprint_goal: string total_tokens: bigint total_cost: number | null @@ -53,6 +55,7 @@ export async function getSprintTokenHistory( ? await prisma.$queryRaw` 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), 0) AS total_tokens, SUM( @@ -70,13 +73,14 @@ export async function getSprintTokenHistory( WHERE cj.user_id = ${userId} AND cj.status = 'DONE' AND cj.product_id = ${productId} - GROUP BY sp.id, sp.sprint_goal + GROUP BY sp.id, sp.code, sp.sprint_goal ORDER BY sp.created_at DESC LIMIT ${limit} ` : await prisma.$queryRaw` 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), 0) AS total_tokens, SUM( @@ -93,13 +97,14 @@ export async function getSprintTokenHistory( 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.sprint_goal + 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), diff --git a/lib/insights/velocity.ts b/lib/insights/velocity.ts index c01c45e..05228f0 100644 --- a/lib/insights/velocity.ts +++ b/lib/insights/velocity.ts @@ -3,6 +3,7 @@ import { productAccessFilter } from '@/lib/product-access' export interface VelocitySprint { sprintId: string + sprintCode: string sprintGoal: string productId: string productName: string @@ -25,6 +26,7 @@ export async function getVelocity(userId: string, sprintsBack = 5): Promise ({ 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 0de209f..1c6c0e6 100644 --- a/lib/insights/verify-stats.ts +++ b/lib/insights/verify-stats.ts @@ -20,6 +20,7 @@ export interface VerifyResultStats { export interface TrendPoint { sprintId: string + sprintCode: string sprintGoal: string productName: string alignedRatio: number @@ -117,6 +118,7 @@ export async function getAlignmentTrend( take: sprintsBack, select: { id: true, + code: true, sprint_goal: true, completed_at: true, product: { select: { name: true } }, @@ -137,6 +139,7 @@ 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/prisma/migrations/20260507195507_add_sprint_code/migration.sql b/prisma/migrations/20260507195507_add_sprint_code/migration.sql new file mode 100644 index 0000000..5d096e1 --- /dev/null +++ b/prisma/migrations/20260507195507_add_sprint_code/migration.sql @@ -0,0 +1,23 @@ +-- 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/schema.prisma b/prisma/schema.prisma index bb37e55..548f8fc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -299,6 +299,7 @@ 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(ACTIVE) start_date DateTime? @db.Date @@ -309,6 +310,7 @@ model Sprint { tasks Task[] sprint_runs SprintRun[] + @@unique([product_id, code]) @@index([product_id, status]) @@map("sprints") } diff --git a/prisma/seed.ts b/prisma/seed.ts index 50b4158..7ed1c43 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -124,6 +124,7 @@ async function main() { 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: { @@ -139,6 +140,7 @@ 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, }, From 00dbbb4f94d5ce4264e9add26663496f4bc2c891 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 20:17:15 +0200 Subject: [PATCH 14/73] chore(ci): gate auto-deploy behind AUTO_DEPLOY_ENABLED repo-variable (#154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Voorkomt automatische Vercel-deploys op PR-preview en push-naar-main zolang \`vars.AUTO_DEPLOY_ENABLED == 'true'\` ontbreekt. Default-staat: auto-deploy UIT, scheelt Actions-minuten op het free-plan. Handmatig deployen blijft werken via workflow_dispatch (Actions tab → "Run workflow" → kies preview of production). Die job (\`deploy-manual\`) is niet aan de flag gebonden. Aanzetten van auto-deploy: Settings → Secrets and variables → Actions → Variables → New repository variable: \`AUTO_DEPLOY_ENABLED\` = \`true\`. \`changes\` job (path-filter) staat ook achter de flag — die wordt alleen gebruikt door de twee auto-deploy jobs. Runbook bijgewerkt met de nieuwe default + uitleg. Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 16 +++++++++++++--- docs/INDEX.md | 2 +- docs/runbooks/deploy-control.md | 20 ++++++++++++++------ 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aab01af..c8fda6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,8 @@ jobs: name: Detect deploy-relevant changes runs-on: ubuntu-latest needs: ci - if: github.event_name != 'workflow_dispatch' + # 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: @@ -95,8 +96,13 @@ jobs: 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: | - github.event_name == 'pull_request' && ( + 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') @@ -128,8 +134,12 @@ jobs: 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: | - github.ref == 'refs/heads/main' + vars.AUTO_DEPLOY_ENABLED == 'true' + && github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.changes.outputs.code == 'true' diff --git a/docs/INDEX.md b/docs/INDEX.md index 83f72d4..3c7670c 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -122,7 +122,7 @@ Auto-generated on 2026-05-07 from front-matter and headings. | [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-05 | +| [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 | | [MCP Integration — Scrum4Me Tools](./runbooks/mcp-integration.md) | `runbooks/mcp-integration.md` | active | 2026-05-03 | | [v1.0 Smoke Test Checklist](./runbooks/v1-smoke-test.md) | `runbooks/v1-smoke-test.md` | active | 2026-05-04 | diff --git a/docs/runbooks/deploy-control.md b/docs/runbooks/deploy-control.md index 8e59331..f0c9039 100644 --- a/docs/runbooks/deploy-control.md +++ b/docs/runbooks/deploy-control.md @@ -3,7 +3,7 @@ title: "Deploy-controle: triggers, labels, path-filter" status: active audience: [contributor, ai-agent] language: nl -last_updated: 2026-05-05 +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." --- @@ -14,19 +14,27 @@ 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" | -| `pull_request` naar `main` | Preview (`vercel deploy`) — alleen als path-filter zegt "code" en geen `skip-deploy` label | -| `workflow_dispatch` | Handmatig — kies `target: preview \| production` in Actions-tab | +| `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 -en labels. +voor doc-only changes. Alleen de deploy-jobs respecteren path-filter, +labels, en de `AUTO_DEPLOY_ENABLED` flag. --- From 883534a5216a14bea13dcf92bc9d6d9e09b44fc0 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 20:22:07 +0200 Subject: [PATCH 15/73] fix(PBI-59): map jobs_initial SSE payload by job_id, not id (#155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De server-route stuurt JobPayload[] (met `job_id`), maar de client deed `initJobs(jobs, ...)` waardoor alle entries in activeJobs `id: undefined` kregen — wat React-key warnings opleverde: Each child in a list should have a unique "key" prop. Fix: SSE jobs_initial niet meer als overwrite gebruiken; SSR-fetch heeft de volledige JobWithRelations al in de store gezet. We reconcileren nu per job met upsertJob (status/branch/error/summary updaten van bekende jobs, onbekende jobs als partials toevoegen — zelfde gedrag als gewone 'message' SSE events). Co-authored-by: Claude Opus 4.7 (1M context) --- hooks/use-jobs-realtime.ts | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/hooks/use-jobs-realtime.ts b/hooks/use-jobs-realtime.ts index f85b5c5..b15cd0a 100644 --- a/hooks/use-jobs-realtime.ts +++ b/hooks/use-jobs-realtime.ts @@ -18,7 +18,6 @@ interface JobStatusPayload { } export default function useJobsRealtime() { - const initJobs = useJobsStore(s => s.initJobs) const upsertJob = useJobsStore(s => s.upsertJob) useEffect(() => { @@ -32,10 +31,23 @@ export default function useJobsRealtime() { 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 pushen onbekende jobs (nieuw aangemaakt tussen + // SSR en SSE-connect) als partials. try { - const jobs = JSON.parse(event.data) - if (Array.isArray(jobs)) { - initJobs(jobs, useJobsStore.getState().doneJobs) + const payload = JSON.parse(event.data) + if (!Array.isArray(payload)) return + for (const p of payload as JobStatusPayload[]) { + if (!p.job_id) continue + 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 @@ -75,5 +87,5 @@ export default function useJobsRealtime() { if (reconnectTimer) clearTimeout(reconnectTimer) es?.close() } - }, [initJobs, upsertJob]) + }, [upsertJob]) } From 25bd59c0b99debb4bff6dee465b6a9a731174c69 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 20:58:27 +0200 Subject: [PATCH 16/73] fix(PBI-59): jobs sorted newest-first, unified on created_at (#157) - actions/jobs-page.ts: beide kolommen orderBy created_at desc - stores/jobs-store.ts: nieuwe actieve jobs unshift (top) i.p.v. push (bottom) Hiermee komen nieuw aangemaakte QUEUED/CLAIMED jobs bovenaan in de linker kolom, in plaats van onderaan waar ze buiten het scrollbare deel kunnen vallen. Co-authored-by: Claude Opus 4.7 (1M context) --- actions/jobs-page.ts | 4 ++-- stores/jobs-store.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/actions/jobs-page.ts b/actions/jobs-page.ts index de9b1b8..271a187 100644 --- a/actions/jobs-page.ts +++ b/actions/jobs-page.ts @@ -151,12 +151,12 @@ export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelation prisma.claudeJob.findMany({ where: { user_id: session.userId, status: { notIn: ['DONE'] } }, include: JOB_INCLUDE, - orderBy: { created_at: 'asc' }, + orderBy: { created_at: 'desc' }, }), prisma.claudeJob.findMany({ where: { user_id: session.userId, status: 'DONE' }, include: JOB_INCLUDE, - orderBy: { finished_at: 'desc' }, + orderBy: { created_at: 'desc' }, take: 100, }), prisma.modelPrice.findMany(), diff --git a/stores/jobs-store.ts b/stores/jobs-store.ts index fe0cc40..c7b59b3 100644 --- a/stores/jobs-store.ts +++ b/stores/jobs-store.ts @@ -50,7 +50,7 @@ export const useJobsStore = create()( if (idx !== -1) { Object.assign(state.activeJobs[idx], job) } else { - state.activeJobs.push(job as JobWithRelations) + state.activeJobs.unshift(job as JobWithRelations) } } }) From 7ae8a243726c34f32275416ca95149a05bf8cc6f Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 21:46:01 +0200 Subject: [PATCH 17/73] Sprint: pbi-55 (#156) * ST-cmovs79lt: Schema + migratie PushSubscription model Voeg PushSubscription model toe aan prisma/schema.prisma met snake_case-conventie, relation field op User, en bijbehorende migratie (push_subscriptions tabel, FK + index op user_id). Co-Authored-By: Claude Sonnet 4.6 * ST-cmovs7e3o: web-push dependency + VAPID env vars feature-gated Voeg web-push + @types/web-push toe aan package.json. Registreer NEXT_PUBLIC_VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT en INTERNAL_PUSH_SECRET als .optional() in lib/env.ts. Documenteer alle vier in .env.example en README. Co-Authored-By: Claude Sonnet 4.6 * ST-cmovs7jgr: lib/push-server.ts met sendPushToUser + stale-cleanup Server-only push-lib met VAPID feature-gate, send naar alle subscriptions van een user, en automatische cleanup bij 404/410. Unit tests: success-pad, 410 verwijdert sub, 404 verwijdert sub, andere errors loggen zonder delete. Co-Authored-By: Claude Sonnet 4.6 * ST-cmovs7ouz: lib/push-client.ts client-side push helpers + stub actions/push.ts Client-side helpers: isPushSupported, isIOSSafari, isStandalonePWA, urlBase64ToUint8Array, subscribeToPush, unsubscribeFromPush. Stub actions/push.ts zodat imports resolven (implementatie volgt in volgende taak). Unit tests voor urlBase64ToUint8Array. Co-Authored-By: Claude Sonnet 4.6 * ST-cmovs7ut4: actions/push.ts subscribeToPushAction + unsubscribeFromPushAction Vervangt stub met volledige implementatie: requireUser via getSession, demo-block, Zod-validatie, upsert met user_id-scoping en user-scoped deleteMany. Tests (8): idempotentie, demo-block, unauthenticated, invalid input. Co-Authored-By: Claude Sonnet 4.6 * ST-cmovs80c1: POST /api/internal/push/send met constant-time Bearer check Route: 503 als INTERNAL_PUSH_SECRET uitstaat, 401 bij verkeerd secret (timingSafeEqual), 400 bij invalid JSON, 422 bij Zod-fout, 204 bij succes. push-server.ts: env-import vervangen door process.env om SESSION_SECRET validatie tijdens build te omzeilen. Tests aangepast. Co-Authored-By: Claude Sonnet 4.6 * ST-cmovs862j: Admin test-send route + public/sw.js service worker POST /api/internal/push/test-send: requireAdmin check (redirect bij niet-admin), optioneel body met defaults, roept sendPushToUser aan, 204. public/sw.js: push-handler met showNotification, notificationclick met same-origin guard, focus bestaand venster of openWindow. Co-Authored-By: Claude Sonnet 4.6 * ST-cmovs8jvq: PushToggle component met 3 states + iOS-banner Client component met states loading/unsupported/ios-needs-install/ denied/subscribed/unsubscribed. useEffect detecteert initial status, permission-prompt alleen via user-click. iOS-banner NL, denied-uitleg, subscribe/unsubscribe knoppen met sonner-toasts. Co-Authored-By: Claude Sonnet 4.6 * ST-cmovs8psg: notifications-sheet + iOS meta-tags in layout notifications-sheet.tsx: PushToggle onderin met sectie 'Notificatie-instellingen' en visuele scheidslijn. app/layout.tsx: appleWebApp.capable, statusBarStyle en mobile-web-app-capable meta-tags toegevoegd via Next.js Metadata API. manifest.json had al display: standalone. Co-Authored-By: Claude Sonnet 4.6 * ST-cmovs8vxj: docs/patterns/web-push.md pattern-documentatie Architectuur-diagram, payload-shape, foutcodes, VAPID-config, iOS-quirks, demo-users blokkade, trigger-voorbeelden (server + HTTP) en admin-testroute curl-voorbeeld. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- .env.example | 10 + README.md | 4 + __tests__/actions/push.test.ts | 102 +++++++ __tests__/api/push-send.test.ts | 75 +++++ __tests__/lib/push-client.test.ts | 35 +++ __tests__/lib/push-server.test.ts | 77 +++++ actions/push.ts | 52 ++++ app/api/internal/push/send/route.ts | 48 ++++ app/api/internal/push/test-send/route.ts | 30 ++ app/layout.tsx | 13 +- .../notifications/notifications-sheet.tsx | 7 + components/notifications/push-toggle.tsx | 116 ++++++++ docs/INDEX.md | 1 + docs/patterns/web-push.md | 111 ++++++++ lib/env.ts | 11 + lib/push-client.ts | 50 ++++ lib/push-server.ts | 63 +++++ package-lock.json | 266 +++++++----------- package.json | 2 + .../migration.sql | 20 ++ prisma/schema.prisma | 16 ++ public/sw.js | 42 +++ 22 files changed, 984 insertions(+), 167 deletions(-) create mode 100644 __tests__/actions/push.test.ts create mode 100644 __tests__/api/push-send.test.ts create mode 100644 __tests__/lib/push-client.test.ts create mode 100644 __tests__/lib/push-server.test.ts create mode 100644 actions/push.ts create mode 100644 app/api/internal/push/send/route.ts create mode 100644 app/api/internal/push/test-send/route.ts create mode 100644 components/notifications/push-toggle.tsx create mode 100644 docs/patterns/web-push.md create mode 100644 lib/push-client.ts create mode 100644 lib/push-server.ts create mode 100644 prisma/migrations/20260507200000_add_push_subscriptions/migration.sql create mode 100644 public/sw.js diff --git a/.env.example b/.env.example index d981a5b..291c7b0 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,16 @@ NODE_ENV="development" # 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="" + # 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). diff --git a/README.md b/README.md index 4de7224..2f154e8 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,10 @@ Zie [.env.example](.env.example). | `DIRECT_URL` | Nee | Directe Neon connection string voor migraties (Prisma `directUrl`) | | `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 | diff --git a/__tests__/actions/push.test.ts b/__tests__/actions/push.test.ts new file mode 100644 index 0000000..1e74a22 --- /dev/null +++ b/__tests__/actions/push.test.ts @@ -0,0 +1,102 @@ +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__/api/push-send.test.ts b/__tests__/api/push-send.test.ts new file mode 100644 index 0000000..44bc616 --- /dev/null +++ b/__tests__/api/push-send.test.ts @@ -0,0 +1,75 @@ +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__/lib/push-client.test.ts b/__tests__/lib/push-client.test.ts new file mode 100644 index 0000000..761b6e1 --- /dev/null +++ b/__tests__/lib/push-client.test.ts @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000..87af039 --- /dev/null +++ b/__tests__/lib/push-server.test.ts @@ -0,0 +1,77 @@ +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/actions/push.ts b/actions/push.ts new file mode 100644 index 0000000..ec9a216 --- /dev/null +++ b/actions/push.ts @@ -0,0 +1,52 @@ +'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 + +export async function subscribeToPushAction(input: SubscribeToPushInput): Promise { + 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 { + const session = await getSession() + if (!session.userId) return + + await prisma.pushSubscription.deleteMany({ + where: { endpoint: args.endpoint, user_id: session.userId }, + }) +} diff --git a/app/api/internal/push/send/route.ts b/app/api/internal/push/send/route.ts new file mode 100644 index 0000000..4891e59 --- /dev/null +++ b/app/api/internal/push/send/route.ts @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000..7359f46 --- /dev/null +++ b/app/api/internal/push/test-send/route.ts @@ -0,0 +1,30 @@ +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 = {} + 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/layout.tsx b/app/layout.tsx index 78b08fe..5cc59ad 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Analytics } from "@vercel/analytics/next"; import { Toaster } from "sonner"; @@ -30,6 +30,17 @@ 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/components/notifications/notifications-sheet.tsx b/components/notifications/notifications-sheet.tsx index 44aeaf6..a161eb2 100644 --- a/components/notifications/notifications-sheet.tsx +++ b/components/notifications/notifications-sheet.tsx @@ -17,6 +17,7 @@ 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' @@ -94,6 +95,12 @@ export function NotificationsSheet({ })} )} +
+

+ Notificatie-instellingen +

+ +
diff --git a/components/notifications/push-toggle.tsx b/components/notifications/push-toggle.tsx new file mode 100644 index 0000000..0351335 --- /dev/null +++ b/components/notifications/push-toggle.tsx @@ -0,0 +1,116 @@ +'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' + +type PushStatus = + | 'loading' + | 'unsupported' + | 'ios-needs-install' + | 'denied' + | 'subscribed' + | 'unsubscribed' + +interface PushToggleProps { + vapidPublicKey?: string +} + +export function PushToggle({ vapidPublicKey }: PushToggleProps) { + const [status, setStatus] = useState('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 ( +
+ Op iPhone/iPad: tik op het delen-icoon en kies{' '} + Zet op beginscherm. Daarna kun je notificaties activeren. +
+ ) + } + + if (status === 'denied') { + return ( +

+ Notificaties zijn geblokkeerd. Schakel ze in via je browser-instellingen. +

+ ) + } + + if (status === 'unsubscribed') { + return ( + + ) + } + + return ( + + ) +} diff --git a/docs/INDEX.md b/docs/INDEX.md index 3c7670c..d391d49 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -80,6 +80,7 @@ Auto-generated on 2026-05-07 from front-matter and headings. | [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 | | [Zustand optimistische update + rollback](./patterns/zustand-optimistic.md) | active | 2026-05-03 | ## Other Docs diff --git a/docs/patterns/web-push.md b/docs/patterns/web-push.md new file mode 100644 index 0000000..24c83ff --- /dev/null +++ b/docs/patterns/web-push.md @@ -0,0 +1,111 @@ +--- +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="" +VAPID_PRIVATE_KEY="" +VAPID_SUBJECT="mailto:admin@example.com" +INTERNAL_PUSH_SECRET="" +``` + +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:///api/internal/push/test-send \ + -H "Cookie: " +# Vereist ingelogde admin-sessie; stuurt push naar eigen account. +``` diff --git a/lib/env.ts b/lib/env.ts index 40d0676..482cef5 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -9,6 +9,17 @@ 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(), }) const parsed = envSchema.safeParse(process.env) diff --git a/lib/push-client.ts b/lib/push-client.ts new file mode 100644 index 0000000..2889c7d --- /dev/null +++ b/lib/push-client.ts @@ -0,0 +1,50 @@ +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 { + 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 { + 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[0]) + return sub +} + +export async function unsubscribeFromPush(): Promise { + 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 new file mode 100644 index 0000000..5774253 --- /dev/null +++ b/lib/push-server.ts @@ -0,0 +1,63 @@ +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 { + 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 { + 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/package-lock.json b/package-lock.json index cfcb587..76a8d6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "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" @@ -60,6 +61,7 @@ "@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", @@ -2159,9 +2161,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2178,9 +2177,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2197,9 +2193,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2216,9 +2209,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2235,9 +2225,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2254,9 +2241,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2273,9 +2257,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2292,9 +2273,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -2311,9 +2289,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2336,9 +2311,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2361,9 +2333,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2386,9 +2355,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2411,9 +2377,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2436,9 +2399,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2461,9 +2421,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2486,9 +2443,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2965,9 +2919,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2984,9 +2935,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3003,9 +2951,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3022,9 +2967,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4306,9 +4248,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4326,9 +4265,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4346,9 +4282,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4366,9 +4299,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4386,9 +4316,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4406,9 +4333,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4700,9 +4624,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4716,9 +4637,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4732,9 +4650,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4748,9 +4663,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4764,9 +4676,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4780,9 +4689,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4796,9 +4702,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4812,9 +4715,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4828,9 +4728,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4844,9 +4741,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4860,9 +4754,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4876,9 +4767,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4892,9 +4780,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5678,9 +5563,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5698,9 +5580,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5718,9 +5597,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5738,9 +5614,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6652,6 +6525,16 @@ "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", @@ -7071,9 +6954,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7088,9 +6968,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7105,9 +6982,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7122,9 +6996,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7139,9 +7010,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7156,9 +7024,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7173,9 +7038,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7190,9 +7052,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8238,6 +8097,18 @@ "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", @@ -8566,6 +8437,12 @@ "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", @@ -8682,6 +8559,12 @@ "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", @@ -10707,6 +10590,15 @@ "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", @@ -12783,6 +12675,15 @@ "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", @@ -14011,6 +13912,27 @@ "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", @@ -14261,9 +14183,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -14285,9 +14204,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -14309,9 +14225,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -14333,9 +14246,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -15836,6 +15746,12 @@ "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", @@ -18542,7 +18458,6 @@ "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", @@ -21089,6 +21004,25 @@ "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", diff --git a/package.json b/package.json index 0f6d444..2c6252e 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "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,6 +85,7 @@ "@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", diff --git a/prisma/migrations/20260507200000_add_push_subscriptions/migration.sql b/prisma/migrations/20260507200000_add_push_subscriptions/migration.sql new file mode 100644 index 0000000..2abe20f --- /dev/null +++ b/prisma/migrations/20260507200000_add_push_subscriptions/migration.sql @@ -0,0 +1,20 @@ +-- 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/schema.prisma b/prisma/schema.prisma index 548f8fc..52da4a6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -164,6 +164,7 @@ model User { claude_jobs ClaudeJob[] claude_workers ClaudeWorker[] started_sprint_runs SprintRun[] @relation("SprintRunStartedBy") + push_subscriptions PushSubscription[] @@index([active_product_id]) @@map("users") @@ -624,3 +625,18 @@ model ClaudeQuestion { @@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/public/sw.js b/public/sw.js new file mode 100644 index 0000000..f4ebb07 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,42 @@ +// 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) + }) + ) +}) From e8371b9f959ac4e1f149e9cb4b051500ad671226 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 21:52:27 +0200 Subject: [PATCH 18/73] feat(PBI-61): filter popover + created_at op job-kaart (#158) - Nieuwe JobsColumn met Kind/Status filter-popover per kolom (Actief/Klaar) - Filterstate persistent in localStorage (whitelist-validatie tegen corrupte waardes) - Active-filter badges in kolomheader, klikbaar om te wissen - Aanmaakdatum + tijd rechtsonder op elke JobCard (nl-NL short formaat) Co-authored-by: Claude Opus 4.7 (1M context) --- components/jobs/job-card.tsx | 10 +- components/jobs/jobs-board.tsx | 78 +++++------ components/jobs/jobs-column.tsx | 228 ++++++++++++++++++++++++++++++++ 3 files changed, 270 insertions(+), 46 deletions(-) create mode 100644 components/jobs/jobs-column.tsx diff --git a/components/jobs/job-card.tsx b/components/jobs/job-card.tsx index 0d888a6..5dc5e9d 100644 --- a/components/jobs/job-card.tsx +++ b/components/jobs/job-card.tsx @@ -19,6 +19,7 @@ interface JobCardProps { branch?: string | null error?: string | null summary?: string | null + createdAt: Date | string isSelected?: boolean onClick?: () => void } @@ -33,7 +34,7 @@ const KIND_LABELS: Record = { export default function JobCard({ kind, status, taskCode, taskTitle, ideaCode, ideaTitle, - sprintGoal, sprintCode, productName, branch, error, isSelected, onClick, + sprintGoal, sprintCode, productName, branch, error, createdAt, isSelected, onClick, }: JobCardProps) { let titleText: string if (kind === 'TASK_IMPLEMENTATION') { @@ -70,7 +71,12 @@ export default function JobCard({

{titleText}

-

{detailText}

+
+

{detailText}

+ + {new Date(createdAt).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })} + +
) } diff --git a/components/jobs/jobs-board.tsx b/components/jobs/jobs-board.tsx index 498ba60..5fcb3c0 100644 --- a/components/jobs/jobs-board.tsx +++ b/components/jobs/jobs-board.tsx @@ -3,12 +3,13 @@ import { useEffect, useState } from 'react' import { Button } from '@/components/ui/button' import { SplitPane } from '@/components/split-pane/split-pane' -import JobCard from './job-card' +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 { 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 { @@ -18,23 +19,20 @@ interface JobsBoardProps { type View = 'detail' | 'usage' -function jobToCardProps(j: JobWithRelations) { - return { - 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, - branch: j.branch, - error: j.error, - summary: j.summary, - } -} +const ACTIVE_STATUS_OPTIONS: Array<{ value: ClaudeJobStatusApi | 'all'; label: string }> = [ + { value: 'all', label: 'Alle' }, + { value: 'queued', label: 'Wacht…' }, + { value: 'claimed', label: 'Geclaimd…' }, + { value: 'running', label: 'Bezig…' }, +] + +const DONE_STATUS_OPTIONS: Array<{ value: ClaudeJobStatusApi | 'all'; label: string }> = [ + { value: 'all', label: 'Alle' }, + { value: 'done', label: 'Klaar' }, + { value: 'failed', label: 'Mislukt' }, + { value: 'cancelled', label: 'Geannuleerd' }, + { value: 'skipped', label: 'Overgeslagen' }, +] export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBoardProps) { const { activeJobs, doneJobs, selectedJobId, initJobs, setSelectedJobId } = useJobsStore() @@ -47,19 +45,15 @@ export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBo const selectedJob = [...activeJobs, ...doneJobs].find(j => j.id === selectedJobId) ?? null const leftPane = ( -
- {activeJobs.map(j => ( - setSelectedJobId(j.id)} - /> - ))} - {activeJobs.length === 0 && ( -

Geen actieve jobs

- )} -
+ ) const middlePane = ( @@ -91,19 +85,15 @@ export default function JobsBoard({ initialActiveJobs, initialDoneJobs }: JobsBo ) const rightPane = ( -
- {doneJobs.map(j => ( - setSelectedJobId(j.id)} - /> - ))} - {doneJobs.length === 0 && ( -

Nog geen afgeronde jobs

- )} -
+ ) return ( diff --git a/components/jobs/jobs-column.tsx b/components/jobs/jobs-column.tsx new file mode 100644 index 0000000..b01e708 --- /dev/null +++ b/components/jobs/jobs-column.tsx @@ -0,0 +1,228 @@ +'use client' + +import { useEffect, useState } from 'react' +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 { jobStatusToApi, type ClaudeJobStatusApi } from '@/lib/job-status' +import { cn } from '@/lib/utils' +import type { JobWithRelations } from '@/actions/jobs-page' +import type { ClaudeJobKind } from '@prisma/client' + +type KindFilter = ClaudeJobKind | 'all' +type StatusFilter = ClaudeJobStatusApi | 'all' + +const KIND_LABELS: Record = { + TASK_IMPLEMENTATION: 'TAAK', + SPRINT_IMPLEMENTATION: 'SPRINT', + IDEA_GRILL: 'GRILL', + IDEA_MAKE_PLAN: 'PLAN', + PLAN_CHAT: 'CHAT', +} + +const KIND_OPTIONS: Array<{ value: KindFilter; label: string }> = [ + { value: 'all', label: 'Alle' }, + { value: 'TASK_IMPLEMENTATION', label: 'TAAK' }, + { value: 'SPRINT_IMPLEMENTATION', label: 'SPRINT' }, + { value: 'IDEA_GRILL', label: 'GRILL' }, + { value: 'IDEA_MAKE_PLAN', label: 'PLAN' }, + { value: 'PLAN_CHAT', label: 'CHAT' }, +] + +const KIND_VALUES = new Set([ + 'TASK_IMPLEMENTATION', + 'SPRINT_IMPLEMENTATION', + 'IDEA_GRILL', + 'IDEA_MAKE_PLAN', + 'PLAN_CHAT', +]) + +function FilterPills({ + label, + options, + value, + onChange, +}: { + label: string + options: Array<{ value: T; label: string }> + value: T + onChange: (v: T) => void +}) { + return ( +
+

{label}

+
+ {options.map((opt) => ( + + ))} +
+
+ ) +} + +interface JobsColumnProps { + title: string + jobs: JobWithRelations[] + selectedJobId: string | null + onSelect: (id: string) => void + storageKeyPrefix: string + statusOptions: Array<{ value: StatusFilter; label: string }> + emptyText: string +} + +export default function JobsColumn({ + title, + jobs, + selectedJobId, + onSelect, + storageKeyPrefix, + statusOptions, + emptyText, +}: JobsColumnProps) { + const [filterKind, setFilterKind] = useState('all') + const [filterStatus, setFilterStatus] = useState('all') + const [prefsLoaded, setPrefsLoaded] = useState(false) + + const kindKey = `${storageKeyPrefix}_filter_kind` + const statusKey = `${storageKeyPrefix}_filter_status` + + useEffect(() => { + const savedKind = localStorage.getItem(kindKey) + if (savedKind && (savedKind === 'all' || KIND_VALUES.has(savedKind as ClaudeJobKind))) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setFilterKind(savedKind as KindFilter) + } + const savedStatus = localStorage.getItem(statusKey) + if (savedStatus && statusOptions.some((o) => o.value === savedStatus)) { + setFilterStatus(savedStatus as StatusFilter) + } + setPrefsLoaded(true) + }, [kindKey, statusKey, statusOptions]) + + useEffect(() => { + if (prefsLoaded) localStorage.setItem(kindKey, filterKind) + }, [filterKind, prefsLoaded, kindKey]) + + useEffect(() => { + if (prefsLoaded) localStorage.setItem(statusKey, filterStatus) + }, [filterStatus, prefsLoaded, statusKey]) + + const filtered = jobs.filter((j) => { + if (filterKind !== 'all' && j.kind !== filterKind) return false + if (filterStatus !== 'all' && jobStatusToApi(j.status) !== filterStatus) return false + return true + }) + + const activeFilterCount = (filterKind !== 'all' ? 1 : 0) + (filterStatus !== 'all' ? 1 : 0) + + return ( +
+
+ {title} +
+ {filterKind !== 'all' && ( + + )} + {filterStatus !== 'all' && ( + + )} + + + {`Filters${activeFilterCount > 0 ? ` (${activeFilterCount})` : ''}`} + + } + /> + + + +
+ +
+
+
+
+
+
+ {filtered.map((j) => ( + onSelect(j.id)} + /> + ))} + {filtered.length === 0 && ( +

+ {jobs.length === 0 ? emptyText : 'Geen jobs voldoen aan filter'} +

+ )} +
+
+ ) +} From 10bf25dadd7a3dc5075ea4b2cb8c242675b8e8af Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 22:21:09 +0200 Subject: [PATCH 19/73] feat(PBI-61): multi-select op kind- en status-filter (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Filter-pills zijn nu toggle-knoppen; meerdere waardes per dimensie selecteerbaar - "Alle"-pill wist de selectie binnen die dimensie - Eén active-badge per geselecteerde waarde, klikbaar om losse selectie te wissen - localStorage formaat is nu CSV met whitelist-validatie (oude 'all'-waarde valt vanzelf weg → leeg = geen filter) - Filtercount in trigger toont som van actieve selecties Co-authored-by: Claude Opus 4.7 (1M context) --- components/jobs/jobs-board.tsx | 6 +- components/jobs/jobs-column.tsx | 189 ++++++++++++++++++++------------ 2 files changed, 119 insertions(+), 76 deletions(-) diff --git a/components/jobs/jobs-board.tsx b/components/jobs/jobs-board.tsx index 5fcb3c0..6fd3024 100644 --- a/components/jobs/jobs-board.tsx +++ b/components/jobs/jobs-board.tsx @@ -19,15 +19,13 @@ interface JobsBoardProps { type View = 'detail' | 'usage' -const ACTIVE_STATUS_OPTIONS: Array<{ value: ClaudeJobStatusApi | 'all'; label: string }> = [ - { value: 'all', label: 'Alle' }, +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 | 'all'; label: string }> = [ - { value: 'all', label: 'Alle' }, +const DONE_STATUS_OPTIONS: Array<{ value: ClaudeJobStatusApi; label: string }> = [ { value: 'done', label: 'Klaar' }, { value: 'failed', label: 'Mislukt' }, { value: 'cancelled', label: 'Geannuleerd' }, diff --git a/components/jobs/jobs-column.tsx b/components/jobs/jobs-column.tsx index b01e708..43c3965 100644 --- a/components/jobs/jobs-column.tsx +++ b/components/jobs/jobs-column.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Button } from '@/components/ui/button' import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover' import JobCard from './job-card' @@ -10,9 +10,6 @@ import { cn } from '@/lib/utils' import type { JobWithRelations } from '@/actions/jobs-page' import type { ClaudeJobKind } from '@prisma/client' -type KindFilter = ClaudeJobKind | 'all' -type StatusFilter = ClaudeJobStatusApi | 'all' - const KIND_LABELS: Record = { TASK_IMPLEMENTATION: 'TAAK', SPRINT_IMPLEMENTATION: 'SPRINT', @@ -21,8 +18,7 @@ const KIND_LABELS: Record = { PLAN_CHAT: 'CHAT', } -const KIND_OPTIONS: Array<{ value: KindFilter; label: string }> = [ - { value: 'all', label: 'Alle' }, +const KIND_OPTIONS: Array<{ value: ClaudeJobKind; label: string }> = [ { value: 'TASK_IMPLEMENTATION', label: 'TAAK' }, { value: 'SPRINT_IMPLEMENTATION', label: 'SPRINT' }, { value: 'IDEA_GRILL', label: 'GRILL' }, @@ -30,56 +26,82 @@ const KIND_OPTIONS: Array<{ value: KindFilter; label: string }> = [ { value: 'PLAN_CHAT', label: 'CHAT' }, ] -const KIND_VALUES = new Set([ - 'TASK_IMPLEMENTATION', - 'SPRINT_IMPLEMENTATION', - 'IDEA_GRILL', - 'IDEA_MAKE_PLAN', - 'PLAN_CHAT', -]) +const KIND_VALUES = new Set(KIND_OPTIONS.map((o) => o.value)) -function FilterPills({ +function MultiFilterPills({ label, options, - value, - onChange, + selected, + onToggle, + onClear, }: { label: string options: Array<{ value: T; label: string }> - value: T - onChange: (v: T) => void + selected: Set + onToggle: (v: T) => void + onClear: () => void }) { + const allActive = selected.size === 0 return (

{label}

- {options.map((opt) => ( - - ))} + + {options.map((opt) => { + const active = selected.has(opt.value) + return ( + + ) + })}
) } +function parseCsv(raw: string | null, allowed: Set): Set { + if (!raw) return new Set() + const out = new Set() + for (const part of raw.split(',')) { + const v = part.trim() + if (v && allowed.has(v as T)) out.add(v as T) + } + return out +} + +function setToCsv(s: Set): string { + return Array.from(s).join(',') +} + interface JobsColumnProps { title: string jobs: JobWithRelations[] selectedJobId: string | null onSelect: (id: string) => void storageKeyPrefix: string - statusOptions: Array<{ value: StatusFilter; label: string }> + statusOptions: Array<{ value: ClaudeJobStatusApi; label: string }> emptyText: string } @@ -92,69 +114,90 @@ export default function JobsColumn({ statusOptions, emptyText, }: JobsColumnProps) { - const [filterKind, setFilterKind] = useState('all') - const [filterStatus, setFilterStatus] = useState('all') + const [filterKinds, setFilterKinds] = useState>(() => new Set()) + const [filterStatuses, setFilterStatuses] = useState>(() => new Set()) const [prefsLoaded, setPrefsLoaded] = useState(false) const kindKey = `${storageKeyPrefix}_filter_kind` const statusKey = `${storageKeyPrefix}_filter_status` + const statusValues = useMemo( + () => new Set(statusOptions.map((o) => o.value)), + [statusOptions] + ) + useEffect(() => { - const savedKind = localStorage.getItem(kindKey) - if (savedKind && (savedKind === 'all' || KIND_VALUES.has(savedKind as ClaudeJobKind))) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setFilterKind(savedKind as KindFilter) - } - const savedStatus = localStorage.getItem(statusKey) - if (savedStatus && statusOptions.some((o) => o.value === savedStatus)) { - setFilterStatus(savedStatus as StatusFilter) - } + // Hydratie van localStorage post-mount: zetters intentioneel, voorkomt SSR-mismatch. + /* eslint-disable react-hooks/set-state-in-effect */ + setFilterKinds(parseCsv(localStorage.getItem(kindKey), KIND_VALUES)) + setFilterStatuses(parseCsv(localStorage.getItem(statusKey), statusValues)) setPrefsLoaded(true) - }, [kindKey, statusKey, statusOptions]) + /* eslint-enable react-hooks/set-state-in-effect */ + }, [kindKey, statusKey, statusValues]) useEffect(() => { - if (prefsLoaded) localStorage.setItem(kindKey, filterKind) - }, [filterKind, prefsLoaded, kindKey]) + if (prefsLoaded) localStorage.setItem(kindKey, setToCsv(filterKinds)) + }, [filterKinds, prefsLoaded, kindKey]) useEffect(() => { - if (prefsLoaded) localStorage.setItem(statusKey, filterStatus) - }, [filterStatus, prefsLoaded, statusKey]) + if (prefsLoaded) localStorage.setItem(statusKey, setToCsv(filterStatuses)) + }, [filterStatuses, prefsLoaded, statusKey]) + + function toggleKind(v: ClaudeJobKind) { + setFilterKinds((prev) => { + const next = new Set(prev) + if (next.has(v)) next.delete(v) + else next.add(v) + return next + }) + } + + function toggleStatus(v: ClaudeJobStatusApi) { + setFilterStatuses((prev) => { + const next = new Set(prev) + if (next.has(v)) next.delete(v) + else next.add(v) + return next + }) + } const filtered = jobs.filter((j) => { - if (filterKind !== 'all' && j.kind !== filterKind) return false - if (filterStatus !== 'all' && jobStatusToApi(j.status) !== filterStatus) 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 = (filterKind !== 'all' ? 1 : 0) + (filterStatus !== 'all' ? 1 : 0) + const activeFilterCount = filterKinds.size + filterStatuses.size return (
{title} -
- {filterKind !== 'all' && ( +
+ {Array.from(filterKinds).map((k) => ( - )} - {filterStatus !== 'all' && ( + ))} + {Array.from(filterStatuses).map((s) => ( - )} + ))} - setFilterKinds(new Set())} /> - setFilterStatuses(new Set())} />
+ + +
+ {selectionMode && ( +
+ + {selectedIds.size} geselecteerd + +
+ + +
+
+ )} + setDialogState(null)} isDemo={isDemo} /> + + { + setNewSprintOpen(open) + if (!open) { + // Sluit selectie bij geslaagde aanmaak; bij annuleren laat de selectie staan + } + }} + onCreated={() => { + setNewSprintOpen(false) + exitSelection() + }} + />
) } diff --git a/components/shared/nav-bar.tsx b/components/shared/nav-bar.tsx index 61365b4..041a22d 100644 --- a/components/shared/nav-bar.tsx +++ b/components/shared/nav-bar.tsx @@ -20,6 +20,10 @@ 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 { setActiveSprintAction } from '@/actions/active-sprint' +import type { SprintStatusApi } from '@/lib/task-status' + +type SprintItem = { id: string; code: string; status: SprintStatusApi } interface NavBarProps { isDemo: boolean @@ -29,10 +33,26 @@ interface NavBarProps { email: string | null activeProduct: { id: string; name: string } | null products: { id: string; name: string }[] - hasActiveSprint: boolean + sprints: SprintItem[] + activeSprint: SprintItem | null + buildingSprintIds: string[] minQuotaPct: number } +const SPRINT_STATUS_LABEL: Record = { + open: 'Open', + closed: 'Gesloten', + archived: 'Gearchiveerd', + failed: 'Mislukt', +} + +const SPRINT_STATUS_BADGE: Record = { + open: 'bg-status-in-progress text-foreground', + closed: 'bg-status-done text-foreground', + archived: 'bg-surface-container text-muted-foreground', + failed: 'bg-status-failed text-foreground', +} + export function NavBar({ isDemo, roles, @@ -41,12 +61,16 @@ export function NavBar({ email, activeProduct, products, - hasActiveSprint, + sprints, + activeSprint, + buildingSprintIds, minQuotaPct, }: NavBarProps) { const pathname = usePathname() const router = useRouter() const [isPending, startTransition] = useTransition() + const buildingSet = new Set(buildingSprintIds) + const hasActiveSprint = !!activeSprint function handleSwitchProduct(productId: string) { startTransition(async () => { @@ -61,6 +85,23 @@ export function NavBar({ }) } + function handleSwitchSprint(sprintId: string) { + if (!activeProduct) return + if (sprintId === activeSprint?.id) return + startTransition(async () => { + const result = await setActiveSprintAction(activeProduct.id, sprintId) + if (result?.error) { + toast.error(typeof result.error === 'string' ? result.error : 'Wisselen mislukt') + return + } + if (pathname.includes('/sprint')) { + router.push(`/products/${activeProduct.id}/sprint/${sprintId}`) + } else { + router.refresh() + } + }) + } + const activeId = activeProduct?.id ?? null // Nav link helpers @@ -90,7 +131,6 @@ export function NavBar({ const sprintNode = () => { if (!activeId) return disabledSpan('Sprint') - const href = `/products/${activeId}/sprint` const isActive = pathname.includes('/sprint') if (!hasActiveSprint) { return ( @@ -107,6 +147,7 @@ export function NavBar({ ) } + const href = `/products/${activeId}/sprint/${activeSprint!.id}` return navLink(href, 'Sprint', isActive) } @@ -149,8 +190,8 @@ export function NavBar({
- {/* Midden: actief product dropdown */} -
+ {/* Midden: actief product + sprint, gestapeld */} +
{activeProduct ? ( Geen actief product )} + + {activeProduct && ( + sprints.length === 0 ? ( + + + + Geen sprints + + Maak een sprint aan vanuit de Product Backlog + + + ) : ( + + + + {activeSprint ? activeSprint.code : 'Selecteer sprint'} + + {activeSprint && ( + + {buildingSet.has(activeSprint.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[activeSprint.status]} + + )} + + + + {sprints.map(s => ( + handleSwitchSprint(s.id)} + className={cn( + 'flex items-center justify-between gap-2', + s.id === activeSprint?.id && 'bg-primary-container text-primary-container-foreground font-medium', + )} + > + {s.code} + + {buildingSet.has(s.id) ? 'BUILDING' : SPRINT_STATUS_LABEL[s.status]} + + + ))} + + + ) + )}
{/* Rechts: solo-status + notifications + account-menu */} diff --git a/components/sprint/new-sprint-dialog.tsx b/components/sprint/new-sprint-dialog.tsx new file mode 100644 index 0000000..21413ea --- /dev/null +++ b/components/sprint/new-sprint-dialog.tsx @@ -0,0 +1,187 @@ +'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' + +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(null) + const [dirty, setDirty] = useState(false) + const [isPending, startTransition] = useTransition() + const formRef = useRef(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 ( + <> + { + if (!o) closeGuard.attemptClose() + else onOpenChange(o) + }} + > + +
+ Nieuwe sprint +

+ {pbiIds.length} PBI{pbiIds.length === 1 ? '' : "'s"} worden in deze sprint geplaatst +

+
+ +
setDirty(true)} + className="flex-1 overflow-y-auto px-6 py-6 space-y-6" + > +
+ +