diff --git a/.env.example b/.env.example index ede2b3c..291c7b0 100644 --- a/.env.example +++ b/.env.example @@ -25,12 +25,6 @@ VAPID_SUBJECT="mailto:admin@example.com" # Generate with: openssl rand -base64 32 INTERNAL_PUSH_SECRET="" -# PBI-66 — Anthropic API key voor `npm run db:sync-model-prices`. -# Optional. Alleen nodig om wekelijks de model_prices tabel te synchroniseren. -# Genereer op https://console.anthropic.com/ → API Keys. -# /v1/models is een gratis metadata-call (geen tokens, geen credit nodig). -ANTHROPIC_API_KEY="" - # v1-readiness item 2 — Sentry error monitoring. # Optional. Without DSN, the SDK is a no-op (no network, no overhead). # Get a DSN at https://sentry.io → Project → Settings → Client Keys (DSN). diff --git a/.gitignore b/.gitignore index fe6b79a..d20df70 100644 --- a/.gitignore +++ b/.gitignore @@ -50,7 +50,6 @@ next-env.d.ts # Claude Code local settings .claude/settings.local.json -.claude/worktrees/ # Local plan/scratch files (per-developer, not shared) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40a8e6c..89a9b97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,7 +67,7 @@ launch-ready state na de v1-readiness-checklist (Now + Before-launch items). edit-iconen op PBI/story/task-rijen. ([#79](https://github.com/madhura68/Scrum4Me/pull/79)) - Edit-icoon op product-card in dashboard (consistent met PBI/story/task-pattern). ([#83](https://github.com/madhura68/Scrum4Me/pull/83)) -- v1.0 readiness checklist in `docs/old/plans/v1-readiness.md`. +- v1.0 readiness checklist in `docs/plans/v1-readiness.md`. ([#82](https://github.com/madhura68/Scrum4Me/pull/82)) ### Changed @@ -95,7 +95,7 @@ Initiële stabilisatie-release. ## Pre-0.3.x Foundation-werk (M0 t/m M8) is niet retroactief in dit changelog opgenomen. -Voor de volledige milestone-historie zie [docs/old/backlog/index.md](./docs/old/backlog/index.md). +Voor de volledige milestone-historie zie [docs/backlog/index.md](./docs/backlog/index.md). --- diff --git a/CLAUDE.md b/CLAUDE.md index 06dc2fb..ea2dbb5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ title: "CLAUDE.md — Scrum4Me" status: active audience: [ai-agent] language: nl -last_updated: 2026-05-11 +last_updated: 2026-05-03 --- # CLAUDE.md — Scrum4Me @@ -19,25 +19,30 @@ Desktop-first Scrum-app voor solo developers en kleine teams. Hiërarchie: produ | `docs/INDEX.md` | Gegenereerde index van alle docs — begin hier | | `docs/specs/functional.md` | Acceptatiecriteria, user flows | | `docs/architecture.md` | Breadcrumb → 6 topische arch-bestanden | +| `docs/backlog/index.md` | Implementatievolgorde, "done when"-criteria | | `docs/api/rest-contract.md` | REST API contract voor Claude Code | | `docs/design/styling.md` | **Lees vóór elk component** — MD3-tokens, shadcn | -| `docs/adr/` | Architecture Decision Records — tech-keuzes (base-ui vs Radix, sort-order, demo-policy, …) | -| `docs/architecture/` | 6 topische architecture-bestanden (data-model, auth, sprint-execution, …) — uitwerking van `docs/architecture.md` | -| `docs/runbooks/plan-to-pbi-flow.md` | **Na goedgekeurd plan** — PBI/Story/Task aanmaken via MCP, zónder direct uitvoeren | +| `docs/plans/-*.md` | Implementatieplan per milestone | --- ## Hoe werk vinden +**Track A — MCP (aanbevolen):** 1. Branch aanmaken: `git checkout -b feat/` — nog **geen** `gh pr create` 2. `mcp__scrum4me__get_claude_context` → pak de next story 3. Voer taken uit in `sort_order`; update status per taak 4. Lees het relevante patroon en styling vóór je begint -5. Verifieer: `npm run verify && npm run build` — `verify` = lint + typecheck + test +5. Verifieer: `npm run lint && npm test && npm run build` 6. Commit per laag: `git add -A && git commit` — **geen** `git push` — zie [docs/runbooks/branch-and-commit.md](./docs/runbooks/branch-and-commit.md) 7. Herhaal stap 2–6 per story; branch blijft dezelfde 8. Queue leeg → `git push -u origin ` + `gh pr create` +**Track B — manueel:** +1. Lees taak in `docs/backlog/index.md` +2. Zoek spec in `docs/specs/functional.md` +3. Lees patroon + styling → bouw → verifieer → vraag bevestiging → commit + Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbooks/mcp-integration.md) --- @@ -48,13 +53,10 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo - **UI:** gebruik `@base-ui/react` met `render`-prop, niet Radix `asChild` - **Push:** commits accumuleren lokaal per taak (`git add -A && git commit`); push + PR pas bij lege queue of na expliciete gebruikersbevestiging — zie [branch-and-commit.md](./docs/runbooks/branch-and-commit.md) - **Demo:** drie lagen — proxy.ts + server action + UI disabled knop -- **Proxy:** `proxy.ts` in repo-root (géén `middleware.ts`) onverzegelt de iron-session, redirect niet-geauthenticeerde users op `/dashboard|/products|/ideas`, en blokkeert niet-GET API-writes voor demo-users behalve `/api/cron/*` - **Enum:** DB UPPER_SNAKE ↔ API lowercase — uitsluitend via `lib/task-status.ts` - **Foutcodes:** 400 = parse-fout, 422 = Zod-validatie, 403 = demo-token - **Server/client grens:** `*-server.ts` bevat DB/node-only; nooit importeren in client component -- **Worker/jobs:** `ClaudeJob` queue (`QUEUED → CLAIMED → RUNNING → DONE|FAILED|SKIPPED`); MCP-worker claimt via `wait_for_job` en sluit met `update_job_status` — zie [worker-idempotency.md](./docs/runbooks/worker-idempotency.md) -- **Model/mode per ClaudeJob:** kind-default → product → job-snapshot → `task.requires_opus`. Resolver in `scrum4me-mcp/src/lib/job-config.ts` (en gespiegeld in `lib/job-config.ts`) — zie [job-model-selection.md](./docs/runbooks/job-model-selection.md) -- **Deployment:** `npm run verify && npm run build` vóór elke PR. Selectieve deploy-controle (labels + path-filter): zie [docs/runbooks/deploy-control.md](./docs/runbooks/deploy-control.md) +- **Deployment:** `npm run lint && npm test && npm run build` vóór elke PR. Selectieve deploy-controle (labels + path-filter): zie [docs/runbooks/deploy-control.md](./docs/runbooks/deploy-control.md) --- @@ -62,13 +64,12 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo | Laag | Technologie | |---|---| -| Framework | Next.js 16.2 (App Router) + React 19.2 — PPR/Cache Components beschikbaar | +| Framework | Next.js 16 (App Router) + React 19 | | Taal | TypeScript strict | -| Styling | Tailwind CSS v4 + shadcn/ui + MD3 via `app/styles/theme.css` | +| Styling | Tailwind CSS + shadcn/ui + MD3 via `app/styles/theme.css` | | State | Zustand + dnd-kit | -| DB | Prisma v7.8 + PostgreSQL (Neon) | +| DB | Prisma v7 + PostgreSQL (Neon) | | Auth | iron-session + bcryptjs | -| Test | Vitest (`__tests__/`, config in `vitest.config.ts`) | | Utilities | Zod, Sonner, Sharp, Vercel Analytics | --- @@ -81,20 +82,12 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo | Prisma singleton | `docs/patterns/prisma-client.md` | | Server Action (auth + Zod) | `docs/patterns/server-action.md` | | Route Handler (REST) | `docs/patterns/route-handler.md` | -| Workspace-store + realtime (PBI-74) | `docs/patterns/workspace-store.md` | | Zustand optimistic update | `docs/patterns/zustand-optimistic.md` | | Float sort_order / drag-and-drop | `docs/patterns/sort-order.md` | | Proxy / route protection | `docs/patterns/proxy.md` | | QR-pairing | `docs/patterns/qr-login.md` | | Claude ↔ user vraagkanaal | `docs/patterns/claude-question-channel.md` | | Entity Dialog (verplicht) | `docs/patterns/dialog.md` | -| Realtime NOTIFY-payload | `docs/patterns/realtime-notify-payload.md` | -| Story met UI-component | `docs/patterns/story-with-ui-component.md` | -| Web Push | `docs/patterns/web-push.md` | -| Job-config resolver (PBI-67) | `lib/job-config.ts` ↔ `scrum4me-mcp/src/lib/job-config.ts` | -| Debug-id op component-root | `docs/patterns/debug-id.md` | -| Debug-labels (BEM) | `docs/patterns/debug-labels.md` | -| Demo client-state (PBI-80) | `docs/patterns/demo-client-state.md` | --- @@ -107,18 +100,7 @@ SESSION_SECRET="" # min 32 chars CRON_SECRET="" # Bearer-secret /api/cron/* ``` -Volledig schema: `lib/env.ts`. Canonieke lijst: `.env.example` — bevat ook web-push (`VAPID_*`, `INTERNAL_PUSH_SECRET`), Sentry (`SENTRY_*`) en optioneel `ANTHROPIC_API_KEY`. - ---- - -## MCP & cron - -- **MCP-server (extern):** standalone Node-proces in `~/Development/scrum4me-mcp/` — Prisma-schema gesynced via `sync-schema.sh`. 30+ tools (`get_claude_context`, `wait_for_job`, `update_task_status`, …) -- **Bewuste duplicaten:** `lib/job-config.ts` (deze repo) en `scrum4me-mcp/src/lib/job-config.ts` (externe MCP) bevatten dezelfde resolver-logica; dit voorkomt dat de MCP-server Next-deps importeert. **Wijzig beide** bij elke job-config aanpassing -- **Cron (vercel.json):** - - `/api/cron/expire-questions` — dagelijks 04:00 UTC - - `/api/cron/cleanup-agent-artifacts` — dagelijks 03:00 UTC -- **Realtime:** SSE op `/api/realtime/*`, gevoed door PostgreSQL `LISTEN`/`NOTIFY` op kanaal `scrum4me_changes` (vereist `DIRECT_URL` voor pooler-bypass) +Volledig schema: `lib/env.ts`. Canonieke lijst: `.env.example`. --- @@ -131,24 +113,7 @@ PBI (niet: Feature/Epic) · Story (niet: Ticket) · Sprint Goal (niet: Objective ## Verificatie ```bash -npm run verify && npm run build # verify = lint + typecheck + test +npm run lint && npm test && npm run build ``` Worker job-status protocol (wanneer `DONE` / `SKIPPED` / `FAILED`): zie [docs/runbooks/worker-idempotency.md](./docs/runbooks/worker-idempotency.md). - -### Scripts - -| Commando | Doel | -|---|---| -| `npm run dev` | Next dev op poort 3000 (`predev` kill-port draait automatisch) | -| `npm test` | Vitest eenmalig (`vitest run`) | -| `npm run test:watch` | Vitest watch-mode | -| `npm test -- ` | Eén bestand draaien — bv. `npm test -- lib/env` | -| `npm run seed` | Prisma seed via `prisma/seed.ts` | -| `npm run create-admin` | Admin-user toevoegen (`scripts/create-admin.ts`) | -| `npm run db:insert-milestone` | Milestone-script (`scripts/insert-milestone.ts`) | -| `npm run db:sync-model-prices` | Sync Anthropic-model-prijzen — vereist `ANTHROPIC_API_KEY` | -| `npm run docs` | Regenereer `docs/INDEX.md` + check links | -| `npm run diagrams` | Mermaid → SVG (`public/diagrams/architecture-{light,dark}.svg`) | - -> Vitest sluit `.claude/**` uit (relevant voor worktrees). `server-only` wordt via alias gemockt naar `tests/stubs/server-only.ts`, zodat `*-server.ts` modules laadbaar zijn in jsdom-tests. diff --git a/README.md b/README.md index 7cf3a14..2f154e8 100644 --- a/README.md +++ b/README.md @@ -123,12 +123,16 @@ Vul daarna `DATABASE_URL` en `SESSION_SECRET` in. `DIRECT_URL` is optioneel loka npx prisma db push ``` -4. Genereer Prisma Client: +4. Genereer Prisma Client en de ERD: ```bash -npx prisma generate +npm run db:erd ``` +Deze command voert lokaal `prisma generate` uit. Daardoor worden zowel de Prisma Client als `docs/assets/erd.svg` opnieuw opgebouwd. + +In CI en deployment wordt bewust alleen de Prisma Client gegenereerd met `prisma generate --generator client`. Het ERD-diagram gebruikt Mermaid/Puppeteer en wordt daarom niet in GitHub Actions of Vercel gegenereerd. + 5. Seed testdata indien nodig: ```bash @@ -162,9 +166,19 @@ De curl-tests dekken alle 7 API-endpoints: auth (401), demo-blokkering (403), in ## Database -Het schema staat in `prisma/schema.prisma`; uitgebreide documentatie in [`docs/architecture/data-model.md`](./docs/architecture/data-model.md). +![ERD](./docs/assets/erd.svg) -Gebruik `npx prisma db push` om schema-wijzigingen naar de database te synchroniseren. `npx prisma generate` (of `prisma generate --generator client` in CI) genereert de Prisma Client. +De databasevisualisatie wordt lokaal gegenereerd uit `prisma/schema.prisma` via `prisma-erd-generator`. + +Handmatige generatie: + +```bash +npm run db:erd +``` + +Optioneel: `npm run db:erd:watch` parallel aan `npm run dev` om bij wijzigingen in `prisma/schema.prisma` `docs/assets/erd.svg` automatisch opnieuw te genereren. + +Gebruik `npx prisma db push` alleen om het schema naar de database te synchroniseren. Gebruik `npm run db:erd` om lokaal Prisma Client en de ERD te genereren. Gebruik in CI uitsluitend `npx prisma generate --generator client`. De app draait standaard op `http://localhost:3000`. @@ -175,6 +189,7 @@ npm run dev # lokale development server npm run lint # ESLint npm test # Vitest test suite npm run build # productiebuild zoals Vercel die verwacht +npm run db:erd # Prisma Client + docs/assets/erd.svg genereren ``` ### Environment variables @@ -247,20 +262,13 @@ Authorization: Bearer | Methode | Endpoint | Doel | |---|---|---| -| `GET` | `/api/health` | Liveness; `?db=1` doet ook een DB-ping (geen auth) | | `GET` | `/api/products` | Actieve producten waarvoor de tokengebruiker eigenaar of teamlid is | -| `GET` | `/api/products/:id/next-story` | Hoogst geprioriteerde open story uit de actieve sprint | -| `GET` | `/api/products/:id/claude-context` | Bundled product / actieve sprint / next-story (met tasks) / open ideas voor MCP | +| `GET` | `/api/products/:id/next-story` | Volgende story uit de actieve sprint | | `GET` | `/api/sprints/:id/tasks?limit=10` | Eerste taken van een sprint | | `PATCH` | `/api/stories/:id/tasks/reorder` | Taakvolgorde aanpassen; alle IDs moeten bij de story horen | | `POST` | `/api/stories/:id/log` | Implementatieplan, testresultaat of commit vastleggen | -| `PATCH` | `/api/tasks/:id` | Taakstatus of `implementation_plan` bijwerken | -| `GET / POST` | `/api/ideas` · `GET / PATCH /api/ideas/:id` | Idea CRUD (M12 — vervangt voormalige `/api/todos`) | -| `GET` | `/api/jobs/:id/sub-tasks` | `sprint_task_executions` van een SPRINT_IMPLEMENTATION-job | -| `GET` | `/api/users/:id/avatar` | Avatar van een specifieke gebruiker | -| `POST / GET` | `/api/profile/avatar` | Eigen avatar uploaden of opvragen | - -Daarnaast leveren `/api/realtime/{backlog,solo,jobs,notifications}` SSE-streams en zijn er auth-helpers `/api/auth/pair/*` (QR-pairing, M10), interne push-routes onder `/api/internal/push/*`, en cron-handlers (`/api/cron/cleanup-agent-artifacts`, `/api/cron/expire-questions`). +| `PATCH` | `/api/tasks/:id` | Taakstatus of implementatieplan bijwerken | +| `POST` | `/api/todos` | Todo aanmaken binnen een productcontext | ### Security-regels @@ -287,4 +295,5 @@ De productieomgeving is gericht op Vercel + Neon. - [Functionele specificatie](docs/specs/functional.md) - [Technische architectuur](docs/architecture.md) +- [Backlog](docs/backlog/index.md) - [Agent-instructie audit](docs/decisions/agent-instructions-history.md) diff --git a/__tests__/actions/active-sprint-action.test.ts b/__tests__/actions/active-sprint-action.test.ts deleted file mode 100644 index b87a767..0000000 --- a/__tests__/actions/active-sprint-action.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('next/headers', () => ({ - cookies: vi.fn().mockResolvedValue({ - set: vi.fn(), - get: vi.fn(), - delete: vi.fn(), - }), -})) -vi.mock('iron-session', () => ({ - getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), -})) -vi.mock('@/lib/session', () => ({ - sessionOptions: { cookieName: 'test', password: 'test' }, -})) -vi.mock('@/lib/product-access', () => ({ - productAccessFilter: vi.fn().mockReturnValue({}), -})) -vi.mock('@/lib/prisma', () => ({ - prisma: { - sprint: { findFirst: vi.fn() }, - product: { findFirst: vi.fn() }, - user: { - findUnique: vi.fn(), - update: vi.fn().mockResolvedValue({}), - }, - $executeRaw: vi.fn().mockResolvedValue(1), - }, -})) - -import { prisma } from '@/lib/prisma' -import { clearActiveSprintAction } from '@/actions/active-sprint' - -const mockPrisma = prisma as unknown as { - product: { findFirst: ReturnType } - user: { - findUnique: ReturnType - update: ReturnType - } -} - -describe('clearActiveSprintAction', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('writes null instead of deleting the key', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) - mockPrisma.user.findUnique.mockResolvedValueOnce({ - settings: { layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } } }, - }) - - const result = await clearActiveSprintAction('p1') - - expect(result).toEqual({ success: true }) - const updateArg = mockPrisma.user.update.mock.calls[0][0] as { - data: { settings: { layout?: { activeSprints?: Record } } } - } - expect(updateArg.data.settings.layout?.activeSprints).toEqual({ - p1: null, - p2: 'sprint-2', - }) - }) - - it('preserves other product keys when clearing one', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) - mockPrisma.user.findUnique.mockResolvedValueOnce({ - settings: { - layout: { - activeSprints: { p1: 'sprint-1', p2: 'sprint-2', p3: null }, - }, - }, - }) - - await clearActiveSprintAction('p1') - - const updateArg = mockPrisma.user.update.mock.calls[0][0] as { - data: { settings: { layout?: { activeSprints?: Record } } } - } - expect(updateArg.data.settings.layout?.activeSprints).toEqual({ - p1: null, - p2: 'sprint-2', - p3: null, - }) - }) - - it('rejects when product is not accessible', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce(null) - - const result = await clearActiveSprintAction('p1') - - expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' }) - expect(mockPrisma.user.update).not.toHaveBeenCalled() - }) - - it('rejects invalid productId', async () => { - const result = await clearActiveSprintAction('') - - expect(result).toEqual({ error: 'Ongeldig product-id' }) - expect(mockPrisma.user.update).not.toHaveBeenCalled() - }) -}) diff --git a/__tests__/actions/claude-jobs.test.ts b/__tests__/actions/claude-jobs.test.ts index 484f185..5e95878 100644 --- a/__tests__/actions/claude-jobs.test.ts +++ b/__tests__/actions/claude-jobs.test.ts @@ -8,24 +8,13 @@ const { mockGetSession, mockFindFirstJob, mockUpdateJob, - mockUpdateManyJob, - mockUpdateManySprintTaskExecution, - mockTransaction, mockExecuteRaw, -} = vi.hoisted(() => { - const mockUpdateManyJob = vi.fn() - const mockUpdateManySprintTaskExecution = vi.fn() - const mockTransaction = vi.fn() - return { - mockGetSession: vi.fn(), - mockFindFirstJob: vi.fn(), - mockUpdateJob: vi.fn(), - mockUpdateManyJob, - mockUpdateManySprintTaskExecution, - mockTransaction, - mockExecuteRaw: vi.fn().mockResolvedValue(undefined), - } -}) +} = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockFindFirstJob: vi.fn(), + mockUpdateJob: vi.fn(), + mockExecuteRaw: vi.fn().mockResolvedValue(undefined), +})) vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) @@ -34,12 +23,7 @@ vi.mock('@/lib/prisma', () => ({ claudeJob: { findFirst: mockFindFirstJob, update: mockUpdateJob, - updateMany: mockUpdateManyJob, }, - sprintTaskExecution: { - updateMany: mockUpdateManySprintTaskExecution, - }, - $transaction: mockTransaction, $executeRaw: mockExecuteRaw, }, })) @@ -48,7 +32,6 @@ import { enqueueClaudeJobAction, enqueueAllTodoJobsAction, cancelClaudeJobAction, - restartClaudeJobAction, } from '@/actions/claude-jobs' const SESSION_USER = { userId: 'user-1', isDemo: false } @@ -56,12 +39,6 @@ const SESSION_USER = { userId: 'user-1', isDemo: false } beforeEach(() => { vi.clearAllMocks() mockExecuteRaw.mockResolvedValue(undefined) - mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise) => - fn({ - claudeJob: { updateMany: mockUpdateManyJob }, - sprintTaskExecution: { updateMany: mockUpdateManySprintTaskExecution }, - }) - ) }) describe('enqueueClaudeJobAction (deprecated)', () => { @@ -127,115 +104,3 @@ describe('cancelClaudeJobAction', () => { expect(result).toMatchObject({ error: expect.stringContaining('actieve') }) }) }) - -describe('restartClaudeJobAction', () => { - const FAILED_JOB = { - id: 'job-1', - status: 'FAILED', - kind: 'TASK_IMPLEMENTATION', - task_id: 'task-1', - idea_id: null, - sprint_run_id: null, - product_id: 'prod-1', - } - - it('reset een FAILED job naar QUEUED (happy path)', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue(FAILED_JOB) - mockUpdateManyJob.mockResolvedValue({ count: 1 }) - - const result = await restartClaudeJobAction('job-1') - - expect(result).toEqual({ success: true }) - expect(mockUpdateManyJob).toHaveBeenCalledWith( - expect.objectContaining({ - where: expect.objectContaining({ id: 'job-1', status: { in: ['FAILED', 'CANCELLED', 'SKIPPED'] } }), - data: expect.objectContaining({ status: 'QUEUED' }), - }) - ) - expect(mockExecuteRaw).toHaveBeenCalled() - }) - - it('reset een CANCELLED job naar QUEUED', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue({ ...FAILED_JOB, status: 'CANCELLED' }) - mockUpdateManyJob.mockResolvedValue({ count: 1 }) - - const result = await restartClaudeJobAction('job-1') - expect(result).toEqual({ success: true }) - }) - - it('reset een SKIPPED job naar QUEUED', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue({ ...FAILED_JOB, status: 'SKIPPED' }) - mockUpdateManyJob.mockResolvedValue({ count: 1 }) - - const result = await restartClaudeJobAction('job-1') - expect(result).toEqual({ success: true }) - }) - - it('weigert demo-sessie', async () => { - mockGetSession.mockResolvedValue({ userId: 'demo', isDemo: true }) - - const result = await restartClaudeJobAction('job-1') - expect(result).toMatchObject({ error: expect.stringContaining('demo') }) - expect(mockUpdateManyJob).not.toHaveBeenCalled() - }) - - it('retourneert error als job niet gevonden', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue(null) - - const result = await restartClaudeJobAction('job-1') - expect(result).toMatchObject({ error: expect.stringContaining('niet gevonden') }) - }) - - it('weigert wanneer job een niet-restartbare status heeft', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue({ ...FAILED_JOB, status: 'DONE' }) - - const result = await restartClaudeJobAction('job-1') - expect(result).toMatchObject({ error: expect.stringContaining('mislukte') }) - expect(mockUpdateManyJob).not.toHaveBeenCalled() - }) - - it('retourneert error bij race-conditie (updateMany count === 0)', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue(FAILED_JOB) - mockUpdateManyJob.mockResolvedValue({ count: 0 }) - - const result = await restartClaudeJobAction('job-1') - expect(result).toMatchObject({ error: expect.stringContaining('gewijzigd') }) - }) - - it('reset ook SprintTaskExecution-rows bij SPRINT_IMPLEMENTATION', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue({ - ...FAILED_JOB, - kind: 'SPRINT_IMPLEMENTATION', - sprint_run_id: 'run-1', - }) - mockUpdateManyJob.mockResolvedValue({ count: 1 }) - mockUpdateManySprintTaskExecution.mockResolvedValue({ count: 3 }) - - const result = await restartClaudeJobAction('job-1') - - expect(result).toEqual({ success: true }) - expect(mockUpdateManySprintTaskExecution).toHaveBeenCalledWith( - expect.objectContaining({ - where: { sprint_job_id: 'job-1' }, - data: expect.objectContaining({ status: 'PENDING' }), - }) - ) - }) - - it('reset geen SprintTaskExecution-rows bij TASK_IMPLEMENTATION', async () => { - mockGetSession.mockResolvedValue(SESSION_USER) - mockFindFirstJob.mockResolvedValue(FAILED_JOB) - mockUpdateManyJob.mockResolvedValue({ count: 1 }) - - await restartClaudeJobAction('job-1') - - expect(mockUpdateManySprintTaskExecution).not.toHaveBeenCalled() - }) -}) diff --git a/__tests__/actions/commit-sprint-membership.test.ts b/__tests__/actions/commit-sprint-membership.test.ts deleted file mode 100644 index af80547..0000000 --- a/__tests__/actions/commit-sprint-membership.test.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('next/headers', () => ({ - cookies: vi.fn().mockResolvedValue({ - set: vi.fn(), - get: vi.fn(), - delete: vi.fn(), - }), -})) -vi.mock('iron-session', () => ({ - getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), -})) -vi.mock('@/lib/session', () => ({ - sessionOptions: { cookieName: 'test', password: 'test' }, -})) -vi.mock('@/lib/product-access', () => ({ - productAccessFilter: vi.fn().mockReturnValue({}), - getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }), -})) -vi.mock('@/lib/rate-limit', () => ({ - enforceUserRateLimit: vi.fn().mockReturnValue(null), -})) -vi.mock('@/lib/code-server', () => ({ - createWithCodeRetry: vi.fn(), - generateNextSprintCode: vi.fn(), -})) -vi.mock('@/lib/active-sprint', () => ({ - setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined), -})) -vi.mock('@/lib/prisma', () => { - const txClient = { - sprint: { create: vi.fn() }, - story: { updateMany: vi.fn() }, - task: { updateMany: vi.fn() }, - } - return { - prisma: { - sprint: { findFirst: vi.fn() }, - story: { - findMany: vi.fn(), - updateMany: vi.fn(), - }, - task: { - findMany: vi.fn(), - updateMany: vi.fn(), - }, - $transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) => fn(txClient)), - __txClient: txClient, - }, - } -}) - -import { prisma } from '@/lib/prisma' -import { commitSprintMembershipAction } from '@/actions/sprints' - -type Mocked = { - sprint: { findFirst: ReturnType } - story: { - findMany: ReturnType - updateMany: ReturnType - } - task: { - findMany: ReturnType - updateMany: ReturnType - } - $transaction: ReturnType - __txClient: { - sprint: { create: ReturnType } - story: { updateMany: ReturnType } - task: { updateMany: ReturnType } - } -} -const mockPrisma = prisma as unknown as Mocked - -beforeEach(() => { - vi.clearAllMocks() - mockPrisma.sprint.findFirst.mockReset().mockResolvedValue({ - id: 'sprint-active', - product_id: 'product-1', - }) - mockPrisma.story.findMany.mockReset() - mockPrisma.story.updateMany.mockReset() - mockPrisma.task.findMany.mockReset() - mockPrisma.task.updateMany.mockReset() - mockPrisma.$transaction.mockImplementation( - async (fn: (tx: typeof mockPrisma.__txClient) => unknown) => - fn(mockPrisma.__txClient), - ) - mockPrisma.__txClient.story.updateMany.mockReset().mockResolvedValue({ count: 0 }) - mockPrisma.__txClient.task.updateMany.mockReset().mockResolvedValue({ count: 0 }) -}) - -describe('commitSprintMembershipAction', () => { - it('happy path: eligible adds + valid removes → transactie commits', async () => { - // adds-partition: alle eligible (sprint_id=null + niet DONE) - mockPrisma.story.findMany - // partition lookup - .mockResolvedValueOnce([ - { id: 's-add-1', sprint_id: null, status: 'OPEN', sprint: null }, - ]) - // removes-filter (sprint_id == activeSprintId) - .mockResolvedValueOnce([{ id: 's-rem-1' }]) - // affectedStories - .mockResolvedValueOnce([ - { pbi_id: 'pbiA' }, - { pbi_id: 'pbiB' }, - ]) - mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }]) - - const result = await commitSprintMembershipAction({ - activeSprintId: 'sprint-active', - adds: ['s-add-1'], - removes: ['s-rem-1'], - }) - - expect('success' in result).toBe(true) - if ('success' in result) { - expect(result.affectedStoryIds.sort()).toEqual(['s-add-1', 's-rem-1']) - expect(result.affectedPbiIds.sort()).toEqual(['pbiA', 'pbiB']) - expect(result.affectedTaskIds).toEqual(['t1']) - expect(result.conflicts.notEligible).toEqual([]) - expect(result.conflicts.alreadyRemoved).toEqual([]) - } - expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1) - expect(mockPrisma.__txClient.story.updateMany).toHaveBeenCalledTimes(2) - expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledTimes(2) - }) - - it('add met status=DONE → conflicts.notEligible, story niet ge-update', async () => { - mockPrisma.story.findMany - .mockResolvedValueOnce([ - { id: 's-done', sprint_id: null, status: 'DONE', sprint: null }, - ]) - // removes-filter (geen removes) - .mockResolvedValueOnce([]) - - const result = await commitSprintMembershipAction({ - activeSprintId: 'sprint-active', - adds: ['s-done'], - removes: [], - }) - - expect('success' in result).toBe(true) - if ('success' in result) { - expect(result.affectedStoryIds).toEqual([]) - expect(result.conflicts.notEligible).toEqual([ - { storyId: 's-done', reason: 'DONE' }, - ]) - } - // Geen transaction omdat er niets te commiten valt. - expect(mockPrisma.$transaction).not.toHaveBeenCalled() - }) - - it('add met sprint_id in andere OPEN sprint → conflicts.notEligible IN_OTHER_SPRINT', async () => { - mockPrisma.story.findMany - .mockResolvedValueOnce([ - { - id: 's-elsewhere', - sprint_id: 'sprint-other', - status: 'IN_SPRINT', - sprint: { id: 'sprint-other', code: 'SP-O', status: 'OPEN' }, - }, - ]) - .mockResolvedValueOnce([]) - - const result = await commitSprintMembershipAction({ - activeSprintId: 'sprint-active', - adds: ['s-elsewhere'], - removes: [], - }) - - if ('success' in result) { - expect(result.conflicts.notEligible).toEqual([ - { storyId: 's-elsewhere', reason: 'IN_OTHER_SPRINT' }, - ]) - } - }) - - it('remove voor story die niet in actieve sprint zit → conflicts.alreadyRemoved', async () => { - mockPrisma.story.findMany - // adds-partition (geen adds) - .mockResolvedValueOnce([]) - // removes-filter — race scenario: story zit niet meer in active sprint - .mockResolvedValueOnce([]) - - const result = await commitSprintMembershipAction({ - activeSprintId: 'sprint-active', - adds: [], - removes: ['s-was-removed'], - }) - - if ('success' in result) { - expect(result.affectedStoryIds).toEqual([]) - expect(result.conflicts.alreadyRemoved).toEqual(['s-was-removed']) - } - }) - - it('transactie: story.status=IN_SPRINT bij add, =OPEN bij remove', async () => { - mockPrisma.story.findMany - .mockResolvedValueOnce([ - { id: 's-add', sprint_id: null, status: 'OPEN', sprint: null }, - ]) - .mockResolvedValueOnce([{ id: 's-rem' }]) - .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) - mockPrisma.task.findMany.mockResolvedValueOnce([]) - - await commitSprintMembershipAction({ - activeSprintId: 'sprint-active', - adds: ['s-add'], - removes: ['s-rem'], - }) - - const calls = mockPrisma.__txClient.story.updateMany.mock.calls - // Add: status=IN_SPRINT + sprint_id=sprint-active - expect(calls[0][0].data).toEqual({ - sprint_id: 'sprint-active', - status: 'IN_SPRINT', - }) - // Remove: status=OPEN + sprint_id=null - expect(calls[1][0].data).toEqual({ sprint_id: null, status: 'OPEN' }) - }) - - it('task.sprint_id wordt in dezelfde transactie ge-update', async () => { - mockPrisma.story.findMany - .mockResolvedValueOnce([ - { id: 's-add', sprint_id: null, status: 'OPEN', sprint: null }, - ]) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) - mockPrisma.task.findMany.mockResolvedValueOnce([]) - - await commitSprintMembershipAction({ - activeSprintId: 'sprint-active', - adds: ['s-add'], - removes: [], - }) - - expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ - where: { story_id: { in: ['s-add'] } }, - data: { sprint_id: 'sprint-active' }, - }), - ) - }) - - it('return: affectedStoryIds + affectedPbiIds + affectedTaskIds + conflicts', async () => { - mockPrisma.story.findMany - .mockResolvedValueOnce([ - { id: 's-add', sprint_id: null, status: 'OPEN', sprint: null }, - ]) - .mockResolvedValueOnce([{ id: 's-rem' }]) - .mockResolvedValueOnce([ - { pbi_id: 'pbiA' }, - { pbi_id: 'pbiB' }, - ]) - mockPrisma.task.findMany.mockResolvedValueOnce([ - { id: 't1' }, - { id: 't2' }, - ]) - - const result = await commitSprintMembershipAction({ - activeSprintId: 'sprint-active', - adds: ['s-add'], - removes: ['s-rem'], - }) - - expect(result).toMatchObject({ - success: true, - affectedStoryIds: expect.arrayContaining(['s-add', 's-rem']), - affectedPbiIds: expect.arrayContaining(['pbiA', 'pbiB']), - affectedTaskIds: expect.arrayContaining(['t1', 't2']), - }) - }) - - it('rejects when sprint is not accessible', async () => { - mockPrisma.sprint.findFirst.mockResolvedValue(null) - - const result = await commitSprintMembershipAction({ - activeSprintId: 'sprint-active', - adds: [], - removes: [], - }) - - expect('error' in result).toBe(true) - if ('error' in result) { - expect(result.code).toBe(403) - } - }) -}) diff --git a/__tests__/actions/create-sprint-with-selection.test.ts b/__tests__/actions/create-sprint-with-selection.test.ts deleted file mode 100644 index 444008a..0000000 --- a/__tests__/actions/create-sprint-with-selection.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('next/headers', () => ({ - cookies: vi.fn().mockResolvedValue({ - set: vi.fn(), - get: vi.fn(), - delete: vi.fn(), - }), -})) -vi.mock('iron-session', () => ({ - getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), -})) -vi.mock('@/lib/session', () => ({ - sessionOptions: { cookieName: 'test', password: 'test' }, -})) -vi.mock('@/lib/product-access', () => ({ - productAccessFilter: vi.fn().mockReturnValue({}), - getAccessibleProduct: vi.fn().mockResolvedValue({ - id: 'product-1', - user_id: 'user-1', - }), -})) -vi.mock('@/lib/rate-limit', () => ({ - enforceUserRateLimit: vi.fn().mockReturnValue(null), -})) -vi.mock('@/lib/code-server', () => ({ - createWithCodeRetry: vi.fn(async (_gen, fn) => fn('SP-1')), - generateNextSprintCode: vi.fn().mockResolvedValue('SP-1'), -})) -vi.mock('@/lib/active-sprint', () => ({ - setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined), -})) -vi.mock('@/lib/prisma', () => { - const txClient = { - sprint: { create: vi.fn() }, - story: { updateMany: vi.fn() }, - task: { updateMany: vi.fn() }, - } - return { - prisma: { - sprint: { - create: vi.fn(), - findFirst: vi.fn(), - update: vi.fn(), - }, - story: { - findMany: vi.fn(), - updateMany: vi.fn(), - }, - task: { - findMany: vi.fn(), - updateMany: vi.fn(), - }, - pbi: { findMany: vi.fn() }, - user: { - findUnique: vi.fn(), - update: vi.fn(), - }, - $transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) => fn(txClient)), - __txClient: txClient, - }, - } -}) - -import { prisma } from '@/lib/prisma' -import { - createSprintWithSelectionAction, - type CreateSprintWithSelectionInput, -} from '@/actions/sprints' - -type Mocked = { - sprint: { - create: ReturnType - findFirst: ReturnType - update: ReturnType - } - story: { - findMany: ReturnType - updateMany: ReturnType - } - task: { - findMany: ReturnType - updateMany: ReturnType - } - $transaction: ReturnType - __txClient: { - sprint: { create: ReturnType } - story: { updateMany: ReturnType } - task: { updateMany: ReturnType } - } -} -const mockPrisma = prisma as unknown as Mocked - -function baseInput( - overrides: Partial = {}, -): CreateSprintWithSelectionInput { - return { - productId: 'product-1', - metadata: { goal: 'Sprint 1' }, - pbiIntent: {}, - storyOverrides: {}, - ...overrides, - } -} - -beforeEach(() => { - vi.clearAllMocks() - mockPrisma.sprint.create.mockReset() - mockPrisma.story.findMany.mockReset() - mockPrisma.story.updateMany.mockReset() - mockPrisma.task.findMany.mockReset() - mockPrisma.task.updateMany.mockReset() - mockPrisma.$transaction.mockImplementation( - async (fn: (tx: typeof mockPrisma.__txClient) => unknown) => - fn(mockPrisma.__txClient), - ) - mockPrisma.__txClient.sprint.create - .mockReset() - .mockResolvedValue({ id: 'sprint-1', code: 'SP-1' }) - mockPrisma.__txClient.story.updateMany - .mockReset() - .mockResolvedValue({ count: 0 }) - mockPrisma.__txClient.task.updateMany - .mockReset() - .mockResolvedValue({ count: 0 }) -}) - -describe('createSprintWithSelectionAction', () => { - it('resolves intent=all naar alle child-stories en weert overrides.remove', async () => { - // Stap 1: stories voor PBI-A (intent=all). Plus eligibility-fetch. - mockPrisma.story.findMany - // resolve step (only for pbis with intent='all') - .mockResolvedValueOnce([ - { id: 's1', pbi_id: 'pbiA' }, - { id: 's2', pbi_id: 'pbiA' }, - { id: 's3', pbi_id: 'pbiA' }, - ]) - // partitionByEligibility — alle eligible - .mockResolvedValueOnce([ - { id: 's1', sprint_id: null, status: 'OPEN', sprint: null }, - { id: 's3', sprint_id: null, status: 'OPEN', sprint: null }, - ]) - // affectedStories - .mockResolvedValueOnce([ - { pbi_id: 'pbiA' }, - { pbi_id: 'pbiA' }, - ]) - mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }]) - - const result = await createSprintWithSelectionAction( - baseInput({ - pbiIntent: { pbiA: 'all' }, - storyOverrides: { pbiA: { add: [], remove: ['s2'] } }, - }), - ) - - expect('success' in result).toBe(true) - if ('success' in result) { - expect(result.affectedStoryIds).toEqual(['s1', 's3']) - expect(result.conflicts.notEligible).toEqual([]) - } - }) - - it('voegt storyOverrides.add toe over PBI heen (zelfs intent=none)', async () => { - // Geen PBI met intent=all → stap 1 wordt niet uitgevoerd. - mockPrisma.story.findMany - // partition - .mockResolvedValueOnce([ - { id: 's10', sprint_id: null, status: 'OPEN', sprint: null }, - ]) - // affectedStories - .mockResolvedValueOnce([{ pbi_id: 'pbiB' }]) - mockPrisma.task.findMany.mockResolvedValueOnce([]) - - const result = await createSprintWithSelectionAction( - baseInput({ - pbiIntent: { pbiB: 'none' }, - storyOverrides: { pbiB: { add: ['s10'], remove: [] } }, - }), - ) - - expect('success' in result).toBe(true) - if ('success' in result) { - expect(result.affectedStoryIds).toEqual(['s10']) - } - }) - - it('eligibility-filter classificeert DONE en cross-sprint stories', async () => { - mockPrisma.story.findMany - // resolve - .mockResolvedValueOnce([ - { id: 's1', pbi_id: 'pbiA' }, - { id: 's2', pbi_id: 'pbiA' }, - { id: 's3', pbi_id: 'pbiA' }, - ]) - // partition: s1=DONE, s2=eligible, s3=in andere OPEN sprint - .mockResolvedValueOnce([ - { id: 's1', sprint_id: null, status: 'DONE', sprint: null }, - { id: 's2', sprint_id: null, status: 'OPEN', sprint: null }, - { - id: 's3', - sprint_id: 'sprint-other', - status: 'IN_SPRINT', - sprint: { id: 'sprint-other', code: 'SP-O', status: 'OPEN' }, - }, - ]) - // affectedStories - .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) - mockPrisma.task.findMany.mockResolvedValueOnce([]) - - const result = await createSprintWithSelectionAction( - baseInput({ pbiIntent: { pbiA: 'all' } }), - ) - - expect('success' in result).toBe(true) - if ('success' in result) { - expect(result.affectedStoryIds).toEqual(['s2']) - expect(result.conflicts.notEligible.map((n) => n.storyId).sort()).toEqual( - ['s1', 's3'], - ) - expect(result.conflicts.crossSprint.map((c) => c.storyId)).toEqual(['s3']) - } - }) - - it('zet story.status=IN_SPRINT en task.sprint_id mee in dezelfde transactie', async () => { - mockPrisma.story.findMany - .mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }]) - .mockResolvedValueOnce([ - { id: 's1', sprint_id: null, status: 'OPEN', sprint: null }, - ]) - .mockResolvedValueOnce([{ pbi_id: 'pbiA' }]) - mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }]) - - await createSprintWithSelectionAction( - baseInput({ pbiIntent: { pbiA: 'all' } }), - ) - - expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1) - expect(mockPrisma.__txClient.story.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ - sprint_id: 'sprint-1', - status: 'IN_SPRINT', - }), - }), - ) - expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledWith( - expect.objectContaining({ - data: { sprint_id: 'sprint-1' }, - }), - ) - }) - - it('returnt affectedStoryIds + affectedPbiIds + affectedTaskIds', async () => { - mockPrisma.story.findMany - .mockResolvedValueOnce([ - { id: 's1', pbi_id: 'pbiA' }, - { id: 's2', pbi_id: 'pbiB' }, - ]) - .mockResolvedValueOnce([ - { id: 's1', sprint_id: null, status: 'OPEN', sprint: null }, - { id: 's2', sprint_id: null, status: 'OPEN', sprint: null }, - ]) - .mockResolvedValueOnce([{ pbi_id: 'pbiA' }, { pbi_id: 'pbiB' }]) - mockPrisma.task.findMany.mockResolvedValueOnce([ - { id: 't1' }, - { id: 't2' }, - ]) - - const result = await createSprintWithSelectionAction( - baseInput({ pbiIntent: { pbiA: 'all', pbiB: 'all' } }), - ) - - expect('success' in result).toBe(true) - if ('success' in result) { - expect(result.affectedStoryIds.sort()).toEqual(['s1', 's2']) - expect(result.affectedPbiIds.sort()).toEqual(['pbiA', 'pbiB']) - expect(result.affectedTaskIds.sort()).toEqual(['t1', 't2']) - } - }) - - it('returnt error wanneer geen eligible stories overblijven', async () => { - mockPrisma.story.findMany - .mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }]) - // s1 is DONE → notEligible - .mockResolvedValueOnce([ - { id: 's1', sprint_id: null, status: 'DONE', sprint: null }, - ]) - - const result = await createSprintWithSelectionAction( - baseInput({ pbiIntent: { pbiA: 'all' } }), - ) - - expect('error' in result).toBe(true) - if ('error' in result) { - expect(result.code).toBe(422) - } - }) -}) diff --git a/__tests__/actions/ideas-crud.test.ts b/__tests__/actions/ideas-crud.test.ts index bf1ba41..6c038fc 100644 --- a/__tests__/actions/ideas-crud.test.ts +++ b/__tests__/actions/ideas-crud.test.ts @@ -47,10 +47,6 @@ vi.mock('@/lib/prisma', () => ({ findMany: vi.fn(), create: vi.fn(), count: vi.fn(), - findUnique: vi.fn().mockResolvedValue(null), - }, - product: { - findUnique: vi.fn().mockResolvedValue(null), }, $transaction: vi.fn(), $executeRaw: vi.fn().mockResolvedValue(0), @@ -65,7 +61,6 @@ import { deleteIdeaAction, updateGrillMdAction, updatePlanMdAction, - uploadPlanMdAction, downloadIdeaMdAction, startGrillJobAction, startMakePlanJobAction, @@ -252,97 +247,6 @@ body }) }) -describe('uploadPlanMdAction', () => { - const VALID_PLAN = `--- -pbi: - title: Uploaded - priority: 2 -stories: - - title: S1 - priority: 2 - tasks: - - title: T1 - priority: 2 ---- - -body -` - - it('happy: uploads from DRAFT — skips grill, sets PLAN_READY', async () => { - m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' }) - const r = await uploadPlanMdAction('idea-1', VALID_PLAN) - expect(r).toEqual({ success: true }) - expect(m.$transaction).toHaveBeenCalled() - const txnArg = m.$transaction.mock.calls.at(-1)?.[0] as unknown[] | undefined - expect(txnArg).toBeDefined() - // The first call in the transaction is the update — confirm status=PLAN_READY. - expect(m.idea.update).toHaveBeenCalledWith( - expect.objectContaining({ - data: expect.objectContaining({ plan_md: VALID_PLAN, status: 'PLAN_READY' }), - }), - ) - }) - - it('happy: uploads from GRILLED', async () => { - m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLED' }) - const r = await uploadPlanMdAction('idea-1', VALID_PLAN) - expect(r).toEqual({ success: true }) - }) - - it('happy: overwrites existing plan from PLAN_READY', async () => { - m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_READY' }) - const r = await uploadPlanMdAction('idea-1', VALID_PLAN) - expect(r).toEqual({ success: true }) - }) - - it('happy: uploads from PLAN_FAILED (retry)', async () => { - m.idea.findFirst.mockResolvedValueOnce({ status: 'PLAN_FAILED' }) - const r = await uploadPlanMdAction('idea-1', VALID_PLAN) - expect(r).toEqual({ success: true }) - }) - - it('rejects from PLANNED (already materialized)', async () => { - m.idea.findFirst.mockResolvedValueOnce({ status: 'PLANNED' }) - const r = await uploadPlanMdAction('idea-1', VALID_PLAN) - expect(r).toMatchObject({ code: 422 }) - expect(m.$transaction).not.toHaveBeenCalled() - }) - - it('rejects from GRILLING (job running)', async () => { - m.idea.findFirst.mockResolvedValueOnce({ status: 'GRILLING' }) - const r = await uploadPlanMdAction('idea-1', VALID_PLAN) - expect(r).toMatchObject({ code: 422 }) - }) - - it('rejects empty markdown', async () => { - const r = await uploadPlanMdAction('idea-1', ' \n ') - expect(r).toMatchObject({ code: 422 }) - // Should fail before touching DB - expect(m.idea.findFirst).not.toHaveBeenCalled() - }) - - it('rejects oversized markdown', async () => { - const huge = 'a'.repeat(100_001) - const r = await uploadPlanMdAction('idea-1', huge) - expect(r).toMatchObject({ code: 422 }) - expect(m.idea.findFirst).not.toHaveBeenCalled() - }) - - it('rejects invalid yaml (parse-fail 422 with details)', async () => { - m.idea.findFirst.mockResolvedValueOnce({ status: 'DRAFT' }) - const r = await uploadPlanMdAction('idea-1', '# no frontmatter') - expect(r).toMatchObject({ code: 422 }) - expect((r as { details?: unknown }).details).toBeDefined() - expect(m.$transaction).not.toHaveBeenCalled() - }) - - it('returns 404 when idea not found', async () => { - m.idea.findFirst.mockResolvedValueOnce(null) - const r = await uploadPlanMdAction('nope', VALID_PLAN) - expect(r).toMatchObject({ code: 404 }) - }) -}) - describe('startGrillJobAction', () => { const idea = { id: 'idea-1', @@ -520,7 +424,7 @@ body .mockResolvedValueOnce({ id: 't-B1' }) }) - it('happy: creates PBI + 2 stories + 3 tasks, links idea, returns ids; sort_order = parseCodeNumber(code)', async () => { + it('happy: creates PBI + 2 stories + 3 tasks, links idea, returns ids', async () => { const r = await materializeIdeaPlanAction('idea-1') expect(r).toMatchObject({ success: true, @@ -534,15 +438,6 @@ body expect(m.pbi.create).toHaveBeenCalledTimes(1) expect(m.story.create).toHaveBeenCalledTimes(2) expect(m.task.create).toHaveBeenCalledTimes(3) - - // story sort_order = parseCodeNumber(auto-code): ST-001→1, ST-002→2 - expect(m.story.create.mock.calls[0][0].data.sort_order).toBe(1) - expect(m.story.create.mock.calls[1][0].data.sort_order).toBe(2) - - // task sort_order = parseCodeNumber(auto-code): T-1→1, T-2→2, T-3→3 - expect(m.task.create.mock.calls[0][0].data.sort_order).toBe(1) - expect(m.task.create.mock.calls[1][0].data.sort_order).toBe(2) - expect(m.task.create.mock.calls[2][0].data.sort_order).toBe(3) }) it('blocks when not PLAN_READY (e.g. GRILLED)', async () => { diff --git a/__tests__/actions/sprint-dates.test.ts b/__tests__/actions/sprint-dates.test.ts index af2474f..875ab1d 100644 --- a/__tests__/actions/sprint-dates.test.ts +++ b/__tests__/actions/sprint-dates.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({ set: vi.fn(), get: vi.fn(), delete: vi.fn() }) })) +vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) vi.mock('iron-session', () => ({ getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), })) @@ -20,11 +20,6 @@ vi.mock('@/lib/prisma', () => ({ create: vi.fn(), update: vi.fn(), }, - user: { - findUnique: vi.fn().mockResolvedValue({ settings: {} }), - update: vi.fn().mockResolvedValue({}), - }, - $executeRaw: vi.fn().mockResolvedValue(1), }, })) diff --git a/__tests__/actions/sprint-draft.test.ts b/__tests__/actions/sprint-draft.test.ts deleted file mode 100644 index f6fa3b1..0000000 --- a/__tests__/actions/sprint-draft.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('next/headers', () => ({ - cookies: vi.fn().mockResolvedValue({ - set: vi.fn(), - get: vi.fn(), - delete: vi.fn(), - }), -})) -vi.mock('iron-session', () => ({ - getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), -})) -vi.mock('@/lib/session', () => ({ - sessionOptions: { cookieName: 'test', password: 'test' }, -})) -vi.mock('@/lib/product-access', () => ({ - productAccessFilter: vi.fn().mockReturnValue({}), -})) -vi.mock('@/lib/prisma', () => ({ - prisma: { - product: { findFirst: vi.fn() }, - user: { - findUnique: vi.fn(), - update: vi.fn().mockResolvedValue({}), - }, - $executeRaw: vi.fn().mockResolvedValue(1), - }, -})) - -import { prisma } from '@/lib/prisma' -import { - clearPendingSprintDraftAction, - setPendingSprintDraftAction, -} from '@/actions/sprint-draft' -import type { PendingSprintDraft, UserSettings } from '@/lib/user-settings' - -const mockPrisma = prisma as unknown as { - product: { findFirst: ReturnType } - user: { - findUnique: ReturnType - update: ReturnType - } -} - -const validDraft: PendingSprintDraft = { - goal: 'Sprint 1', - pbiIntent: { pbiA: 'all' }, - storyOverrides: { pbiA: { add: [], remove: ['story-1'] } }, -} - -describe('setPendingSprintDraftAction', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPrisma.product.findFirst.mockReset() - mockPrisma.user.findUnique.mockReset() - mockPrisma.user.update.mockReset().mockResolvedValue({}) - }) - - it('persists draft for accessible product', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) - mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} }) - - const result = await setPendingSprintDraftAction('p1', validDraft) - - expect(result).toEqual({ success: true }) - const updateArg = mockPrisma.user.update.mock.calls[0][0] as { - data: { settings: UserSettings } - } - expect(updateArg.data.settings.workflow?.pendingSprintDraft?.p1).toMatchObject({ - goal: 'Sprint 1', - pbiIntent: { pbiA: 'all' }, - }) - }) - - it('preserves drafts for other products', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) - mockPrisma.user.findUnique.mockResolvedValueOnce({ - settings: { - workflow: { - pendingSprintDraft: { - p2: { goal: 'P2 draft', pbiIntent: {}, storyOverrides: {} }, - }, - }, - }, - }) - - await setPendingSprintDraftAction('p1', validDraft) - - const updateArg = mockPrisma.user.update.mock.calls[0][0] as { - data: { settings: UserSettings } - } - const drafts = updateArg.data.settings.workflow?.pendingSprintDraft - expect(Object.keys(drafts ?? {})).toEqual(expect.arrayContaining(['p1', 'p2'])) - }) - - it('rejects invalid draft (empty goal)', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) - - const result = await setPendingSprintDraftAction('p1', { - ...validDraft, - goal: '', - } as PendingSprintDraft) - - expect(result).toHaveProperty('error') - expect(mockPrisma.user.update).not.toHaveBeenCalled() - }) - - it('rejects when product not accessible', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce(null) - - const result = await setPendingSprintDraftAction('p1', validDraft) - - expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' }) - expect(mockPrisma.user.update).not.toHaveBeenCalled() - }) -}) - -describe('clearPendingSprintDraftAction', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPrisma.product.findFirst.mockReset() - mockPrisma.user.findUnique.mockReset() - mockPrisma.user.update.mockReset().mockResolvedValue({}) - }) - - it('removes draft key for product', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) - mockPrisma.user.findUnique.mockResolvedValueOnce({ - settings: { - workflow: { - pendingSprintDraft: { - p1: { goal: 'gone', pbiIntent: {}, storyOverrides: {} }, - p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} }, - }, - }, - }, - }) - - await clearPendingSprintDraftAction('p1') - - const updateArg = mockPrisma.user.update.mock.calls[0][0] as { - data: { settings: UserSettings } - } - expect(updateArg.data.settings.workflow?.pendingSprintDraft).toEqual({ - p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} }, - }) - }) - - it('is a no-op when there is no draft for the product', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' }) - mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} }) - - const result = await clearPendingSprintDraftAction('p1') - - expect(result).toEqual({ success: true }) - expect(mockPrisma.user.update).not.toHaveBeenCalled() - }) - - it('rejects when product not accessible', async () => { - mockPrisma.product.findFirst.mockResolvedValueOnce(null) - - const result = await clearPendingSprintDraftAction('p1') - - expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' }) - }) -}) diff --git a/__tests__/actions/sprint-runs.test.ts b/__tests__/actions/sprint-runs.test.ts index acf4396..a939d6e 100644 --- a/__tests__/actions/sprint-runs.test.ts +++ b/__tests__/actions/sprint-runs.test.ts @@ -30,7 +30,6 @@ vi.mock('@/lib/prisma', () => ({ }, task: { updateMany: vi.fn(), - findUnique: vi.fn().mockResolvedValue(null), }, claudeQuestion: { findMany: vi.fn(), @@ -39,9 +38,6 @@ vi.mock('@/lib/prisma', () => ({ create: vi.fn(), updateMany: vi.fn(), }, - product: { - findUnique: vi.fn().mockResolvedValue(null), - }, $transaction: vi.fn(), }, })) diff --git a/__tests__/actions/update-sprint.test.ts b/__tests__/actions/update-sprint.test.ts deleted file mode 100644 index f51219d..0000000 --- a/__tests__/actions/update-sprint.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })) -vi.mock('next/headers', () => ({ - cookies: vi.fn().mockResolvedValue({ - set: vi.fn(), - get: vi.fn(), - delete: vi.fn(), - }), -})) -vi.mock('iron-session', () => ({ - getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), -})) -vi.mock('@/lib/session', () => ({ - sessionOptions: { cookieName: 'test', password: 'test' }, -})) -vi.mock('@/lib/product-access', () => ({ - productAccessFilter: vi.fn().mockReturnValue({}), - getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }), -})) -vi.mock('@/lib/rate-limit', () => ({ - enforceUserRateLimit: vi.fn().mockReturnValue(null), -})) -vi.mock('@/lib/code-server', () => ({ - createWithCodeRetry: vi.fn(), - generateNextSprintCode: vi.fn(), -})) -vi.mock('@/lib/active-sprint', () => ({ - setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined), -})) -vi.mock('@/lib/prisma', () => ({ - prisma: { - sprint: { - findFirst: vi.fn(), - update: vi.fn(), - }, - story: { - findMany: vi.fn(), - updateMany: vi.fn(), - }, - task: { - findMany: vi.fn(), - updateMany: vi.fn(), - }, - $transaction: vi.fn(), - }, -})) - -import { prisma } from '@/lib/prisma' -import { updateSprintAction } from '@/actions/sprints' - -type Mocked = { - sprint: { - findFirst: ReturnType - update: ReturnType - } -} -const mockPrisma = prisma as unknown as Mocked - -beforeEach(() => { - vi.clearAllMocks() - mockPrisma.sprint.findFirst.mockReset().mockResolvedValue({ - id: 'sprint-1', - product_id: 'product-1', - }) - mockPrisma.sprint.update.mockReset().mockResolvedValue({}) -}) - -describe('updateSprintAction', () => { - it('updates sprint_goal alone', async () => { - const result = await updateSprintAction({ - sprintId: 'sprint-1', - fields: { goal: 'Nieuw doel' }, - }) - - expect('success' in result).toBe(true) - expect(mockPrisma.sprint.update).toHaveBeenCalledWith({ - where: { id: 'sprint-1' }, - data: { sprint_goal: 'Nieuw doel' }, - }) - }) - - it('updates dates only', async () => { - await updateSprintAction({ - sprintId: 'sprint-1', - fields: { startAt: '2026-06-01', endAt: '2026-06-14' }, - }) - - expect(mockPrisma.sprint.update).toHaveBeenCalledWith({ - where: { id: 'sprint-1' }, - data: { - start_date: new Date('2026-06-01'), - end_date: new Date('2026-06-14'), - }, - }) - }) - - it('accepts null to clear a date', async () => { - await updateSprintAction({ - sprintId: 'sprint-1', - fields: { startAt: null }, - }) - - expect(mockPrisma.sprint.update).toHaveBeenCalledWith({ - where: { id: 'sprint-1' }, - data: { start_date: null }, - }) - }) - - it('rejects when sprint not accessible', async () => { - mockPrisma.sprint.findFirst.mockResolvedValue(null) - - const result = await updateSprintAction({ - sprintId: 'sprint-1', - fields: { goal: 'x' }, - }) - - expect('error' in result).toBe(true) - if ('error' in result) { - expect(result.code).toBe(403) - } - expect(mockPrisma.sprint.update).not.toHaveBeenCalled() - }) - - it('rejects empty goal', async () => { - const result = await updateSprintAction({ - sprintId: 'sprint-1', - fields: { goal: '' }, - }) - - expect('error' in result).toBe(true) - expect(mockPrisma.sprint.update).not.toHaveBeenCalled() - }) - - it('rejects when no fields are supplied', async () => { - const result = await updateSprintAction({ - sprintId: 'sprint-1', - fields: {}, - }) - - // Schema-refine should reject; OR action treats empty data as no-op success. - // Current implementation: refine forces minstens één veld → 422 error. - expect('error' in result).toBe(true) - if ('error' in result) { - expect(result.code).toBe(422) - } - }) -}) diff --git a/__tests__/actions/user-settings.test.ts b/__tests__/actions/user-settings.test.ts deleted file mode 100644 index 1fb53ad..0000000 --- a/__tests__/actions/user-settings.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) })) -vi.mock('iron-session', () => ({ - getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }), -})) -vi.mock('@/lib/session', () => ({ - sessionOptions: { cookieName: 'test', password: 'test' }, -})) -vi.mock('@/lib/prisma', () => ({ - prisma: { - user: { findUnique: vi.fn() }, - $transaction: vi.fn(async (fn: (tx: unknown) => Promise) => { - return fn({ - user: { - findUnique: vi.fn().mockResolvedValue({ settings: {} }), - update: vi.fn().mockResolvedValue({}), - }, - }) - }), - $executeRaw: vi.fn().mockResolvedValue(1), - }, -})) - -import { prisma } from '@/lib/prisma' -import { getIronSession } from 'iron-session' -import { updateUserSettingsAction } from '@/actions/user-settings' - -const mockPrisma = prisma as unknown as { - user: { findUnique: ReturnType } - $transaction: ReturnType - $executeRaw: ReturnType -} -const mockGetIronSession = getIronSession as ReturnType - -beforeEach(() => { - vi.clearAllMocks() - mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) - mockPrisma.$executeRaw.mockResolvedValue(1) -}) - -describe('updateUserSettingsAction', () => { - it('returns 401 when not logged in', async () => { - mockGetIronSession.mockResolvedValue({ userId: undefined, isDemo: false }) - const result = await updateUserSettingsAction({}) - expect(result).toEqual({ error: 'Niet ingelogd', code: 401 }) - }) - - it('returns 403 for demo accounts', async () => { - mockGetIronSession.mockResolvedValue({ userId: 'user-1', isDemo: true }) - const result = await updateUserSettingsAction({}) - expect('error' in result && result.code).toBe(403) - }) - - it('returns 422 when patch is invalid', async () => { - const result = await updateUserSettingsAction({ - views: { sprintBacklog: { filterStatus: 'NONSENSE' } }, - } as never) - expect('error' in result && result.code).toBe(422) - }) - - it('merges with current settings and emits notify on success', async () => { - const existingFindUnique = vi.fn().mockResolvedValue({ - settings: { views: { sprintBacklog: { sort: 'code' } } }, - }) - const update = vi.fn().mockResolvedValue({}) - mockPrisma.$transaction.mockImplementationOnce(async (fn: (tx: unknown) => Promise) => { - return fn({ user: { findUnique: existingFindUnique, update } }) - }) - - const result = await updateUserSettingsAction({ - views: { sprintBacklog: { sortDir: 'desc' } }, - }) - - expect('success' in result && result.success).toBe(true) - expect(update).toHaveBeenCalledWith({ - where: { id: 'user-1' }, - data: { settings: { views: { sprintBacklog: { sort: 'code', sortDir: 'desc' } } } }, - }) - expect(mockPrisma.$executeRaw).toHaveBeenCalled() - }) -}) diff --git a/__tests__/api/backlog-realtime.test.ts b/__tests__/api/backlog-realtime.test.ts new file mode 100644 index 0000000..4898cda --- /dev/null +++ b/__tests__/api/backlog-realtime.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() })) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/product-access', () => ({ + getAccessibleProduct: vi.fn(), +})) + +import { getAccessibleProduct } from '@/lib/product-access' +import type { NextRequest } from 'next/server' +import { GET } from '@/app/api/realtime/backlog/route' +import { useBacklogStore } from '@/stores/backlog-store' + +const mockGetAccessibleProduct = getAccessibleProduct as ReturnType + +function makeReq(productId?: string): NextRequest { + const url = productId + ? `http://localhost/api/realtime/backlog?product_id=${productId}` + : 'http://localhost/api/realtime/backlog' + return { + signal: new AbortController().signal, + nextUrl: new URL(url), + } as unknown as NextRequest +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('GET /api/realtime/backlog', () => { + it('401 when not authenticated', async () => { + mockGetSession.mockResolvedValue({ userId: undefined, isDemo: false }) + const res = await GET(makeReq('prod-1')) + expect(res.status).toBe(401) + expect(mockGetAccessibleProduct).not.toHaveBeenCalled() + }) + + it('400 when product_id is missing', async () => { + mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + const res = await GET(makeReq()) + expect(res.status).toBe(400) + }) + + it('403 when user has no access to the product', async () => { + mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + mockGetAccessibleProduct.mockResolvedValue(null) + const res = await GET(makeReq('prod-1')) + expect(res.status).toBe(403) + expect(mockGetAccessibleProduct).toHaveBeenCalledWith('prod-1', 'user-1') + }) + + it('500 when DIRECT_URL and DATABASE_URL are absent', async () => { + mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) + mockGetAccessibleProduct.mockResolvedValue({ id: 'prod-1' }) + + const before = { DIRECT_URL: process.env.DIRECT_URL, DATABASE_URL: process.env.DATABASE_URL } + delete process.env.DIRECT_URL + delete process.env.DATABASE_URL + try { + const res = await GET(makeReq('prod-1')) + expect(res.status).toBe(500) + } finally { + if (before.DIRECT_URL !== undefined) process.env.DIRECT_URL = before.DIRECT_URL + if (before.DATABASE_URL !== undefined) process.env.DATABASE_URL = before.DATABASE_URL + } + }) + + it('demo user is allowed (no 403) when product is accessible', async () => { + mockGetSession.mockResolvedValue({ userId: 'demo-user', isDemo: true }) + mockGetAccessibleProduct.mockResolvedValue({ id: 'prod-1' }) + + const before = { DIRECT_URL: process.env.DIRECT_URL, DATABASE_URL: process.env.DATABASE_URL } + delete process.env.DIRECT_URL + delete process.env.DATABASE_URL + try { + const res = await GET(makeReq('prod-1')) + // Fails at 500 (no DB URL) — not 403, confirming demo user is not blocked + expect(res.status).toBe(500) + } finally { + if (before.DIRECT_URL !== undefined) process.env.DIRECT_URL = before.DIRECT_URL + if (before.DATABASE_URL !== undefined) process.env.DATABASE_URL = before.DATABASE_URL + } + }) +}) + +// shouldEmit scope filter — white-box unit tests +describe('shouldEmit scope filter (via backlog-store reducer)', () => { + it('applyChange: pbi INSERT adds to pbis array', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} }) + const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Test', priority: 2, created_at: new Date(), status: 'ready' as const } + useBacklogStore.getState().applyChange('pbi', 'I', pbi) + expect(useBacklogStore.getState().pbis).toHaveLength(1) + expect(useBacklogStore.getState().pbis[0].id).toBe('pbi-1') + }) + + it('applyChange: pbi UPDATE patches existing pbi', () => { + const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Old', priority: 2, created_at: new Date(), status: 'ready' as const } + useBacklogStore.setState({ pbis: [pbi], storiesByPbi: {}, tasksByStory: {} }) + useBacklogStore.getState().applyChange('pbi', 'U', { id: 'pbi-1', title: 'New' }) + expect(useBacklogStore.getState().pbis[0].title).toBe('New') + }) + + it('applyChange: pbi DELETE removes pbi', () => { + const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Test', priority: 2, created_at: new Date(), status: 'ready' as const } + useBacklogStore.setState({ pbis: [pbi], storiesByPbi: {}, tasksByStory: {} }) + useBacklogStore.getState().applyChange('pbi', 'D', { id: 'pbi-1' }) + expect(useBacklogStore.getState().pbis).toHaveLength(0) + }) + + it('applyChange: story INSERT adds to storiesByPbi', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} }) + const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', created_at: new Date() } + useBacklogStore.getState().applyChange('story', 'I', story) + expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1) + }) + + it('applyChange: story DELETE removes from correct pbi bucket', () => { + const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', created_at: new Date() } + useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [story] }, tasksByStory: {} }) + useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' }) + expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0) + }) + + it('applyChange: task UPDATE patches task across story buckets', () => { + const task = { id: 'task-1', title: 'Old', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: 'story-1', created_at: new Date() } + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [task] } }) + useBacklogStore.getState().applyChange('task', 'U', { id: 'task-1', status: 'IN_PROGRESS' }) + expect(useBacklogStore.getState().tasksByStory['story-1'][0].status).toBe('IN_PROGRESS') + }) +}) diff --git a/__tests__/api/cross-sprint-blocks.test.ts b/__tests__/api/cross-sprint-blocks.test.ts deleted file mode 100644 index 5447900..0000000 --- a/__tests__/api/cross-sprint-blocks.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('@/lib/prisma', () => ({ - prisma: { - product: { findFirst: vi.fn() }, - story: { findMany: vi.fn() }, - }, -})) - -vi.mock('@/lib/api-auth', () => ({ - authenticateApiRequest: vi.fn(), -})) - -vi.mock('@/lib/product-access', () => ({ - productAccessFilter: vi.fn().mockReturnValue({}), -})) - -import { prisma } from '@/lib/prisma' -import { authenticateApiRequest } from '@/lib/api-auth' -import { GET } from '@/app/api/products/[id]/cross-sprint-blocks/route' - -const mockPrisma = prisma as unknown as { - product: { findFirst: ReturnType } - story: { findMany: ReturnType } -} -const mockAuth = authenticateApiRequest as unknown as ReturnType - -function makeRequest(url: string) { - return new Request(url) -} - -describe('GET /api/products/[id]/cross-sprint-blocks', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPrisma.product.findFirst.mockReset() - mockPrisma.story.findMany.mockReset() - mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' }) - }) - - it('returns blocking sprint info per story for happy path', async () => { - mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) - mockPrisma.story.findMany.mockResolvedValue([ - { - id: 'story-1', - sprint: { id: 'sprint-x', code: 'SP-X' }, - }, - { - id: 'story-2', - sprint: { id: 'sprint-y', code: 'SP-Y' }, - }, - ]) - - const req = makeRequest( - 'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1&pbiIds=pbiA', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - - expect(res.status).toBe(200) - const body = await res.json() - expect(body).toEqual({ - 'story-1': { sprintId: 'sprint-x', sprintName: 'SP-X' }, - 'story-2': { sprintId: 'sprint-y', sprintName: 'SP-Y' }, - }) - }) - - it('rejects when pbiIds is missing', async () => { - const req = makeRequest( - 'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - expect(res.status).toBe(400) - }) - - it('rejects when pbiIds is empty', async () => { - const req = makeRequest( - 'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - expect(res.status).toBe(400) - }) - - it('returns 404 when product is not accessible', async () => { - mockPrisma.product.findFirst.mockResolvedValue(null) - const req = makeRequest( - 'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - expect(res.status).toBe(404) - }) - - it('returns auth error when authenticate fails', async () => { - mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 }) - const req = makeRequest( - 'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - expect(res.status).toBe(401) - }) - - it('passes NOT excludeSprintId to prisma when provided', async () => { - mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) - mockPrisma.story.findMany.mockResolvedValue([]) - - const req = makeRequest( - 'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-active&pbiIds=pbiA', - ) - await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - - const callArg = mockPrisma.story.findMany.mock.calls[0][0] as { - where: Record - } - expect(callArg.where).toMatchObject({ - pbi_id: { in: ['pbiA'] }, - product_id: 'p1', - sprint_id: { not: null }, - NOT: { sprint_id: 'sp-active' }, - sprint: { status: 'OPEN' }, - }) - }) -}) diff --git a/__tests__/api/next-story.test.ts b/__tests__/api/next-story.test.ts index fc549d8..4c614e9 100644 --- a/__tests__/api/next-story.test.ts +++ b/__tests__/api/next-story.test.ts @@ -95,7 +95,7 @@ describe('GET /api/products/:id/next-story', () => { expect(data.tasks[0]).toMatchObject({ id: 'task-1', status: 'todo' }) }) - it('queries story ordered by sort_order only', async () => { + it('queries story ordered by priority then sort_order', async () => { mockPrisma.sprint.findFirst.mockResolvedValue(SPRINT) mockPrisma.story.findFirst.mockResolvedValue(STORY) @@ -103,7 +103,7 @@ describe('GET /api/products/:id/next-story', () => { expect(mockPrisma.story.findFirst).toHaveBeenCalledWith( expect.objectContaining({ - orderBy: [{ sort_order: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], }) ) }) diff --git a/__tests__/api/reorder.test.ts b/__tests__/api/reorder.test.ts new file mode 100644 index 0000000..cff62ae --- /dev/null +++ b/__tests__/api/reorder.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/lib/prisma', () => ({ + prisma: { + story: { + findFirst: vi.fn(), + }, + task: { + update: vi.fn(), + }, + $transaction: vi.fn(), + }, +})) + +vi.mock('@/lib/api-auth', () => ({ + authenticateApiRequest: vi.fn(), +})) + +import { prisma } from '@/lib/prisma' +import { authenticateApiRequest } from '@/lib/api-auth' +import { PATCH as patchReorder } from '@/app/api/stories/[id]/tasks/reorder/route' + +const mockPrisma = prisma as unknown as { + story: { findFirst: ReturnType } + task: { update: ReturnType } + $transaction: ReturnType +} +const mockAuth = authenticateApiRequest as ReturnType + +function makeStory(taskIds: string[]) { + return { + id: 'story-1', + product_id: 'prod-1', + tasks: taskIds.map(id => ({ id })), + } +} + +function makeRequest(body: unknown, storyId = 'story-1'): [Request, { params: Promise<{ id: string }> }] { + return [ + new Request(`http://localhost/api/stories/${storyId}/tasks/reorder`, { + method: 'PATCH', + headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ id: storyId }) }, + ] +} + +describe('PATCH /api/stories/:id/tasks/reorder', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuth.mockResolvedValue({ userId: 'user-1', isDemo: false }) + mockPrisma.$transaction.mockResolvedValue([]) + mockPrisma.task.update.mockResolvedValue({ id: 'task-1', sort_order: 1 }) + }) + + // TC-RO-06 — body validation fires before story lookup + it('returns 422 when task_ids is an empty array', async () => { + const res = await patchReorder(...makeRequest({ task_ids: [] })) + expect(res.status).toBe(422) + expect(mockPrisma.story.findFirst).not.toHaveBeenCalled() + }) + + // TC-RO-07 + it('returns 422 when task_ids is not an array', async () => { + const res = await patchReorder(...makeRequest({ task_ids: 'task-1' })) + expect(res.status).toBe(422) + expect(mockPrisma.story.findFirst).not.toHaveBeenCalled() + }) + + it('returns 422 when task_ids is missing entirely', async () => { + const res = await patchReorder(...makeRequest({})) + expect(res.status).toBe(422) + }) + + // TC-RO-08 + it('returns 422 when task_ids contains an ID not belonging to the story', async () => { + mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2'])) + + const res = await patchReorder(...makeRequest({ task_ids: ['task-1', 'task-from-other-story'] })) + const data = await res.json() + + expect(res.status).toBe(422) + expect(data.error).toContain('task-from-other-story') + }) + + // TC-RO-09 + it('reorders tasks and returns 200 with success: true', async () => { + mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2', 'task-3'])) + + const res = await patchReorder(...makeRequest({ task_ids: ['task-3', 'task-1', 'task-2'] })) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data).toEqual({ success: true }) + expect(mockPrisma.$transaction).toHaveBeenCalled() + }) + + it('updates each task with its new sort_order index', async () => { + mockPrisma.story.findFirst.mockResolvedValue(makeStory(['task-1', 'task-2'])) + + await patchReorder(...makeRequest({ task_ids: ['task-2', 'task-1'] })) + + expect(mockPrisma.task.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'task-2' }, data: { sort_order: 1 } }) + ) + expect(mockPrisma.task.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'task-1' }, data: { sort_order: 2 } }) + ) + }) +}) diff --git a/__tests__/api/security.test.ts b/__tests__/api/security.test.ts index 9a1d508..467e248 100644 --- a/__tests__/api/security.test.ts +++ b/__tests__/api/security.test.ts @@ -54,6 +54,7 @@ import { authenticateApiRequest } from '@/lib/api-auth' import { GET as getProducts } from '@/app/api/products/route' import { GET as getNextStory } from '@/app/api/products/[id]/next-story/route' import { GET as getSprintTasks } from '@/app/api/sprints/[id]/tasks/route' +import { PATCH as patchReorder } from '@/app/api/stories/[id]/tasks/reorder/route' import { POST as postStoryLog } from '@/app/api/stories/[id]/log/route' import { PATCH as patchTask } from '@/app/api/tasks/[id]/route' @@ -275,6 +276,56 @@ describe('GET /api/sprints/:id/tasks', () => { }) }) +// ─── PATCH /api/stories/:id/tasks/reorder ──────────────────────────────────── + +describe('PATCH /api/stories/:id/tasks/reorder', () => { + const VALID_BODY = { task_ids: ['task-x'] } + + // TC-RO-01 + it('returns 401 when no valid token provided', async () => { + mockAuth.mockResolvedValue(UNAUTHORIZED) + const res = await patchReorder( + makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY), + routeCtx('story-1') + ) + expect(res.status).toBe(401) + }) + + // TC-RO-03 + it('returns 403 for demo users', async () => { + mockAuth.mockResolvedValue(DEMO_AUTH) + const res = await patchReorder( + makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY), + routeCtx('story-1') + ) + expect(res.status).toBe(403) + const data = await res.json() + expect(data.error).toBe('Niet beschikbaar in demo-modus') + }) + + // TC-RO-04 / TC-RO-05 + it('returns 404 when story is not accessible to the authenticated user', async () => { + mockAuth.mockResolvedValue(USER_2_AUTH) + mockPrisma.story.findFirst.mockResolvedValue(null) + + const res = await patchReorder( + makePatch('http://localhost/api/stories/story-1/tasks/reorder', VALID_BODY), + routeCtx('story-1') + ) + expect(res.status).toBe(404) + expect(mockPrisma.story.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: 'story-1', + product: expect.objectContaining({ + OR: expect.arrayContaining([{ user_id: 'user-2' }]), + }), + }), + }) + ) + }) +}) + // ─── POST /api/stories/:id/log ──────────────────────────────────────────────── describe('POST /api/stories/:id/log', () => { diff --git a/__tests__/api/sprint-membership-summary.test.ts b/__tests__/api/sprint-membership-summary.test.ts deleted file mode 100644 index c526210..0000000 --- a/__tests__/api/sprint-membership-summary.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('@/lib/prisma', () => ({ - prisma: { - product: { findFirst: vi.fn() }, - story: { groupBy: vi.fn() }, - }, -})) - -vi.mock('@/lib/api-auth', () => ({ - authenticateApiRequest: vi.fn(), -})) - -vi.mock('@/lib/product-access', () => ({ - productAccessFilter: vi.fn().mockReturnValue({}), -})) - -import { prisma } from '@/lib/prisma' -import { authenticateApiRequest } from '@/lib/api-auth' -import { GET } from '@/app/api/products/[id]/sprint-membership-summary/route' - -const mockPrisma = prisma as unknown as { - product: { findFirst: ReturnType } - story: { groupBy: ReturnType } -} -const mockAuth = authenticateApiRequest as unknown as ReturnType - -function makeRequest(url: string) { - return new Request(url) -} - -describe('GET /api/products/[id]/sprint-membership-summary', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPrisma.product.findFirst.mockReset() - mockPrisma.story.groupBy.mockReset() - mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' }) - }) - - it('returns counts per PBI for happy path', async () => { - mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) - mockPrisma.story.groupBy - .mockResolvedValueOnce([ - { pbi_id: 'pbiA', _count: { _all: 5 } }, - { pbi_id: 'pbiB', _count: { _all: 3 } }, - ]) - .mockResolvedValueOnce([{ pbi_id: 'pbiA', _count: { _all: 2 } }]) - - const req = makeRequest( - 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - - expect(res.status).toBe(200) - const body = await res.json() - expect(body).toEqual({ - pbiA: { total: 5, inSprint: 2 }, - pbiB: { total: 3, inSprint: 0 }, - }) - }) - - it('rejects when pbiIds is missing', async () => { - const req = makeRequest( - 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - expect(res.status).toBe(400) - }) - - it('rejects when pbiIds is empty', async () => { - const req = makeRequest( - 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - expect(res.status).toBe(400) - }) - - it('rejects when sprintId is missing', async () => { - const req = makeRequest( - 'http://localhost/api/products/p1/sprint-membership-summary?pbiIds=pbiA', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - expect(res.status).toBe(400) - }) - - it('returns 404 when product is not accessible', async () => { - mockPrisma.product.findFirst.mockResolvedValue(null) - const req = makeRequest( - 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - expect(res.status).toBe(404) - }) - - it('returns auth error when authenticate fails', async () => { - mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 }) - const req = makeRequest( - 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - expect(res.status).toBe(401) - }) - - it('returns zero counts for PBIs without stories', async () => { - mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' }) - mockPrisma.story.groupBy - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([]) - - const req = makeRequest( - 'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB', - ) - const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) }) - - const body = await res.json() - expect(body).toEqual({ - pbiA: { total: 0, inSprint: 0 }, - pbiB: { total: 0, inSprint: 0 }, - }) - }) -}) diff --git a/__tests__/app/api/jobs/job-by-id-route.test.ts b/__tests__/app/api/jobs/job-by-id-route.test.ts deleted file mode 100644 index a9d2c1a..0000000 --- a/__tests__/app/api/jobs/job-by-id-route.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -const { mockGetSession, mockFindFirstJob, mockFindManyPrice } = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockFindFirstJob: vi.fn(), - mockFindManyPrice: vi.fn(), -})) - -vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) -vi.mock('@/lib/prisma', () => ({ - prisma: { - claudeJob: { findFirst: mockFindFirstJob }, - modelPrice: { findMany: mockFindManyPrice }, - }, -})) - -import { GET } from '@/app/api/jobs/[id]/route' - -function makeParams(id = 'job-1'): { params: Promise<{ id: string }> } { - return { params: Promise.resolve({ id }) } -} - -function makeRequest(id = 'job-1'): Request { - return new Request(`http://localhost/api/jobs/${id}`) -} - -const RAW_JOB = { - id: 'job-1', - kind: 'TASK_IMPLEMENTATION' as const, - status: 'DONE' as const, - model_id: 'claude-sonnet-4-6', - input_tokens: 100, - output_tokens: 50, - cache_read_tokens: 0, - cache_write_tokens: 0, - branch: 'feat/test', - pr_url: null, - error: null, - summary: 'Done', - verify_result: 'ALIGNED' as const, - started_at: new Date('2026-01-01T10:00:00Z'), - finished_at: new Date('2026-01-01T10:05:00Z'), - created_at: new Date('2026-01-01T09:59:00Z'), - sprint_run_id: null, - task: { - code: 'T-42', - title: 'Some task', - description: null, - implementation_plan: 'Do the thing', - story: { code: 'S-10', pbi: { code: 'PBI-5' } }, - }, - idea: null, - product: { name: 'Scrum4Me', code: 'SCR' }, - sprint_run: null, -} - -describe('GET /api/jobs/:id', () => { - beforeEach(() => { - vi.clearAllMocks() - mockGetSession.mockResolvedValue({ userId: 'user-1' }) - mockFindFirstJob.mockResolvedValue(RAW_JOB) - mockFindManyPrice.mockResolvedValue([]) - }) - - it('returns 401 when not logged in', async () => { - mockGetSession.mockResolvedValue({ userId: undefined }) - const res = await GET(makeRequest() as never, makeParams()) - expect(res.status).toBe(401) - const body = await res.json() - expect(body.error).toBeTruthy() - }) - - it('returns 404 when job not found', async () => { - mockFindFirstJob.mockResolvedValue(null) - const res = await GET(makeRequest() as never, makeParams()) - expect(res.status).toBe(404) - const body = await res.json() - expect(body.error).toBeTruthy() - }) - - it('queries with user_id filter to prevent cross-user access', async () => { - await GET(makeRequest() as never, makeParams()) - expect(mockFindFirstJob).toHaveBeenCalledWith( - expect.objectContaining({ - where: { id: 'job-1', user_id: 'user-1' }, - }) - ) - }) - - it('returns 200 with mapped job shape including breadcrumb codes', async () => { - const res = await GET(makeRequest() as never, makeParams()) - expect(res.status).toBe(200) - const body = await res.json() - expect(body).toMatchObject({ - id: 'job-1', - kind: 'TASK_IMPLEMENTATION', - status: 'DONE', - taskCode: 'T-42', - taskTitle: 'Some task', - productCode: 'SCR', - storyCode: 'S-10', - pbiCode: 'PBI-5', - branch: 'feat/test', - }) - }) -}) diff --git a/__tests__/components/backlog/backlog-split-pane.test.tsx b/__tests__/components/backlog/backlog-split-pane.test.tsx index 27d7626..f57e53f 100644 --- a/__tests__/components/backlog/backlog-split-pane.test.tsx +++ b/__tests__/components/backlog/backlog-split-pane.test.tsx @@ -1,21 +1,9 @@ // @vitest-environment jsdom -import { describe, it, expect, beforeEach, vi } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' import { render, screen } from '@testing-library/react' - -vi.mock('@/actions/user-settings', () => ({ - updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }), -})) - -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import { useSelectionStore } from '@/stores/selection-store' import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane' -function setSelection(pbiId: string | null, storyId: string | null) { - useProductWorkspaceStore.setState((s) => { - s.context.activePbiId = pbiId - s.context.activeStoryId = storyId - }) -} - const PANES = [
PBI pane
,
Stories pane
, @@ -34,7 +22,7 @@ function renderPane() { } beforeEach(() => { - setSelection(null, null) + useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null }) // Force mobile viewport Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) window.dispatchEvent(new Event('resize')) @@ -49,7 +37,7 @@ describe('BacklogSplitPane auto-switch', () => { it('auto-switches to tab 1 when PBI is selected', () => { const { rerender } = renderPane() - setSelection('pbi-1', null) + useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: null }) rerender( { it('auto-switches to tab 2 when story is selected', () => { const { rerender } = renderPane() - setSelection('pbi-1', 'story-1') + useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' }) rerender( { it('switches to tab 1 on cascade-reset (story cleared when new PBI selected)', () => { // Start with story selected (tab 2) - setSelection('pbi-1', 'story-1') + useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' }) const { rerender } = renderPane() // Cascade-reset: new PBI → story clears - setSelection('pbi-2', null) + useSelectionStore.setState({ selectedPbiId: 'pbi-2', selectedStoryId: null }) rerender( ({ + reorderStoriesAction: vi.fn().mockResolvedValue({ success: true }), reorderPbisAction: vi.fn().mockResolvedValue({ success: true }), updatePbiPriorityAction: vi.fn().mockResolvedValue({ success: true }), })) vi.mock('@/actions/pbis', () => ({ deletePbiAction: vi.fn().mockResolvedValue({ success: true }) })) -vi.mock('@/actions/user-settings', () => ({ - updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }), -})) +vi.mock('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) })) vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) -// Mock dnd-kit (still needed for PBI panel which supports drag-and-drop) +// Mock dnd-kit vi.mock('@dnd-kit/core', () => ({ DndContext: ({ children }: { children: React.ReactNode }) => <>{children}, PointerSensor: class {}, @@ -65,40 +61,19 @@ const PBI_ID = 'pbi-1' const ALT_PBI_ID = 'pbi-2' const STORY_ID = 'story-1' -const STORIES: BacklogStory[] = [ - { id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, sort_order: 1, status: 'OPEN', pbi_id: PBI_ID, sprint_id: null, created_at: new Date() }, +const STORIES = [ + { id: STORY_ID, code: 'ST-1', title: 'Eerste story', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: PBI_ID, created_at: new Date() }, ] -const TASKS: BacklogTask[] = [ - { id: 'task-1', code: null, title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, +const TASKS = [ + { id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, ] function resetStores() { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = null - s.context.activePbiId = null - s.context.activeStoryId = null - s.context.activeTaskId = null - s.entities.pbisById = {} - s.entities.storiesById = Object.fromEntries(STORIES.map((st) => [st.id, st])) - s.entities.tasksById = Object.fromEntries(TASKS.map((t) => [t.id, t])) - s.relations.pbiIds = [] - s.relations.storyIdsByPbi = { [PBI_ID]: STORIES.map((st) => st.id) } - s.relations.taskIdsByStory = { [STORY_ID]: TASKS.map((t) => t.id) } - }) -} - -function selectPbi(pbiId: string | null) { - useProductWorkspaceStore.setState((s) => { - s.context.activePbiId = pbiId - s.context.activeStoryId = null - s.context.activeTaskId = null - }) -} - -function selectStory(pbiId: string | null, storyId: string | null) { - useProductWorkspaceStore.setState((s) => { - s.context.activePbiId = pbiId - s.context.activeStoryId = storyId + useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null }) + useBacklogStore.setState({ + pbis: [], + storiesByPbi: { [PBI_ID]: STORIES }, + tasksByStory: { [STORY_ID]: TASKS }, }) } @@ -114,40 +89,42 @@ describe('Backlog 3-pane integration', () => { }) it('StoryPanel shows stories when PBI is selected', () => { - selectPbi(PBI_ID) + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null }) render() expect(screen.getByText('Eerste story')).toBeTruthy() }) - it('clicking a story dispatches setActiveStory to the workspace-store', () => { - selectPbi(PBI_ID) + it('clicking a story dispatches selectStory to the store', () => { + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null }) render() fireEvent.click(screen.getByText('Eerste story')) - expect(useProductWorkspaceStore.getState().context.activeStoryId).toBe(STORY_ID) + expect(useSelectionStore.getState().selectedStoryId).toBe(STORY_ID) }) - it('cascade-reset: selecting different PBI clears activeStoryId', () => { - selectStory(PBI_ID, STORY_ID) - useProductWorkspaceStore.getState().setActivePbi(ALT_PBI_ID) - expect(useProductWorkspaceStore.getState().context.activeStoryId).toBeNull() + it('cascade-reset: selecting different PBI clears selectedStoryId', () => { + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) + useSelectionStore.getState().selectPbi(ALT_PBI_ID) + expect(useSelectionStore.getState().selectedStoryId).toBeNull() }) it('TaskPanel shows tasks after story is selected', () => { - selectStory(PBI_ID, STORY_ID) + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) render() expect(screen.getByText('Eerste taak')).toBeTruthy() }) it('TaskPanel shows empty state after cascade-reset', () => { - selectStory(PBI_ID, STORY_ID) + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) render() - useProductWorkspaceStore.getState().setActivePbi(ALT_PBI_ID) + // Reset via selectPbi + useSelectionStore.getState().selectPbi(ALT_PBI_ID) + // Re-render reflects new store state render() expect(screen.getAllByText('Selecteer een story om de taken te bekijken.').length).toBeGreaterThan(0) }) it('selected story card has isSelected highlight class applied', () => { - selectStory(PBI_ID, STORY_ID) + useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) const { container } = render() // bg-primary-container is applied when isSelected const selected = container.querySelector('.bg-primary-container') diff --git a/__tests__/components/backlog/new-sprint-trigger.test.tsx b/__tests__/components/backlog/new-sprint-trigger.test.tsx deleted file mode 100644 index 72c669e..0000000 --- a/__tests__/components/backlog/new-sprint-trigger.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { render, screen } from '@testing-library/react' -import '@testing-library/jest-dom' -import type { ReactNode } from 'react' - -const workflowMock: { - value: { pendingSprintDraft?: Record } | undefined -} = { value: undefined } - -vi.mock('@/stores/user-settings/store', () => ({ - useUserSettingsStore: ( - selector: (s: { - entities: { - settings: { - workflow: { pendingSprintDraft?: Record } | undefined - } - } - }) => unknown, - ) => selector({ entities: { settings: { workflow: workflowMock.value } } }), -})) - -vi.mock('./new-sprint-metadata-dialog', () => ({ - NewSprintMetadataDialog: () => null, -})) - -vi.mock('@/components/shared/demo-tooltip', () => ({ - DemoTooltip: ({ children }: { children: ReactNode }) => children, -})) - -import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger' - -beforeEach(() => { - workflowMock.value = undefined -}) - -describe('NewSprintTrigger', () => { - it('renders the button on an active product without a draft', () => { - render() - expect(screen.getByText('Nieuwe sprint')).toBeInTheDocument() - }) - - it('renders nothing on a non-active product (G6)', () => { - const { container } = render( - , - ) - expect(container).toBeEmptyDOMElement() - }) - - it('renders nothing when a sprint draft is pending', () => { - workflowMock.value = { pendingSprintDraft: { p1: { goal: 'X' } } } - const { container } = render( - , - ) - expect(container).toBeEmptyDOMElement() - }) -}) diff --git a/__tests__/components/backlog/task-panel.test.tsx b/__tests__/components/backlog/task-panel.test.tsx index fc5cf7a..97a5894 100644 --- a/__tests__/components/backlog/task-panel.test.tsx +++ b/__tests__/components/backlog/task-panel.test.tsx @@ -1,40 +1,44 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import type { BacklogTask } from '@/stores/product-workspace/types' - -function resetWorkspace() { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = null - s.context.activePbiId = null - s.context.activeStoryId = null - s.context.activeTaskId = null - s.entities.pbisById = {} - s.entities.storiesById = {} - s.entities.tasksById = {} - s.relations.pbiIds = [] - s.relations.storyIdsByPbi = {} - s.relations.taskIdsByStory = {} - }) -} - -function setActiveStoryAndTasks(storyId: string | null, tasks: BacklogTask[] = []) { - useProductWorkspaceStore.setState((s) => { - s.context.activeStoryId = storyId - if (storyId) { - s.relations.taskIdsByStory[storyId] = tasks.map((t) => t.id) - for (const task of tasks) s.entities.tasksById[task.id] = task - } - }) -} +import { useSelectionStore } from '@/stores/selection-store' +import { useBacklogStore } from '@/stores/backlog-store' // Mock next/navigation const mockPush = vi.fn() vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }) })) +// Mock reorderTasksAction +vi.mock('@/actions/tasks', () => ({ reorderTasksAction: vi.fn().mockResolvedValue({ success: true }) })) vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) +// Mock dnd-kit to avoid jsdom drag complexity +vi.mock('@dnd-kit/core', () => ({ + DndContext: ({ children }: { children: React.ReactNode }) => <>{children}, + PointerSensor: class {}, + KeyboardSensor: class {}, + useSensor: vi.fn(), + useSensors: vi.fn(() => []), + closestCenter: vi.fn(), + DragOverlay: () => null, +})) +vi.mock('@dnd-kit/sortable', () => ({ + SortableContext: ({ children }: { children: React.ReactNode }) => <>{children}, + useSortable: () => ({ + attributes: {}, listeners: {}, setNodeRef: vi.fn(), + transform: null, transition: undefined, isDragging: false, + }), + rectSortingStrategy: {}, + sortableKeyboardCoordinates: {}, + arrayMove: (arr: unknown[], from: number, to: number) => { + const next = [...arr] + next.splice(from, 1) + next.splice(to, 0, arr[from]) + return next + }, +})) +vi.mock('@dnd-kit/utilities', () => ({ CSS: { Transform: { toString: () => '' } } })) + import { TaskPanel } from '@/components/backlog/task-panel' const PRODUCT_ID = 'prod-1' @@ -42,8 +46,8 @@ const STORY_ID = 'story-1' const CLOSE_PATH = `/products/${PRODUCT_ID}` const TASKS = [ - { id: 'task-1', code: null, title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, - { id: 'task-2', code: null, title: 'Tweede taak', description: null, priority: 3, status: 'IN_PROGRESS', sort_order: 2, story_id: STORY_ID, created_at: new Date() }, + { id: 'task-1', title: 'Eerste taak', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: STORY_ID, created_at: new Date() }, + { id: 'task-2', title: 'Tweede taak', description: null, priority: 3, status: 'IN_PROGRESS', sort_order: 2, story_id: STORY_ID, created_at: new Date() }, ] function renderPanel(isDemo = false) { @@ -53,7 +57,8 @@ function renderPanel(isDemo = false) { describe('TaskPanel', () => { beforeEach(() => { mockPush.mockClear() - resetWorkspace() + useSelectionStore.setState({ selectedStoryId: null, selectedPbiId: null }) + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} }) }) it('shows empty state when no story is selected', () => { @@ -62,35 +67,40 @@ describe('TaskPanel', () => { }) it('shows empty state with action when story selected but no tasks', () => { - setActiveStoryAndTasks(STORY_ID, []) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } }) renderPanel() expect(screen.getByText('Nog geen taken voor deze story.')).toBeTruthy() expect(screen.getAllByText('+ Nieuwe taak').length).toBeGreaterThanOrEqual(1) }) it('renders task cards when tasks are present', () => { - setActiveStoryAndTasks(STORY_ID, TASKS) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) renderPanel() expect(screen.getByText('Eerste taak')).toBeTruthy() expect(screen.getByText('Tweede taak')).toBeTruthy() }) it('renders status badges on task cards', () => { - setActiveStoryAndTasks(STORY_ID, TASKS) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) renderPanel() expect(screen.getByText('To Do')).toBeTruthy() expect(screen.getByText('Bezig')).toBeTruthy() }) it('task cards are rendered inside a grid container', () => { - setActiveStoryAndTasks(STORY_ID, TASKS) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) const { container } = renderPanel() const grid = container.querySelector('.grid') expect(grid).toBeTruthy() }) it('clicking + button calls router.push with newTask params', () => { - setActiveStoryAndTasks(STORY_ID, []) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } }) renderPanel() const buttons = screen.getAllByText('+ Nieuwe taak') fireEvent.click(buttons[0]) @@ -98,18 +108,29 @@ describe('TaskPanel', () => { }) it('clicking task card calls router.push with editTask param', () => { - setActiveStoryAndTasks(STORY_ID, TASKS) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) renderPanel() fireEvent.click(screen.getByText('Eerste taak')) expect(mockPush).toHaveBeenCalledWith(`${CLOSE_PATH}?editTask=task-1`) }) it('+ button is disabled in demo mode', () => { - setActiveStoryAndTasks(STORY_ID, []) + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } }) renderPanel(true) const btn = screen.getAllByText('+ Nieuwe taak')[0].closest('button') expect(btn).toBeTruthy() expect((btn as HTMLButtonElement).disabled).toBe(true) }) + it('cards have no drag listeners in demo mode (whole-card drag disabled)', () => { + useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) + useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + // In demo mode, listeners ({} from useSortable mock) are not spread onto the card. + // The mock always returns empty listeners, so we just verify the cards render without error. + renderPanel(true) + expect(screen.getByText('Eerste taak')).toBeTruthy() + expect(screen.getByText('Tweede taak')).toBeTruthy() + }) }) diff --git a/__tests__/components/dialogs/answer-modal.test.tsx b/__tests__/components/dialogs/answer-modal.test.tsx deleted file mode 100644 index 26aad0f..0000000 --- a/__tests__/components/dialogs/answer-modal.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import React from 'react' - -vi.mock('@/actions/questions', () => ({ - answerQuestion: vi.fn(), -})) -vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) -vi.mock('@/stores/notifications-store', () => ({ - useNotificationsStore: { - getState: () => ({ remove: vi.fn() }), - }, -})) -vi.mock('next/link', () => ({ - default: ({ href, children }: { href: string; children: React.ReactNode }) => ( - {children} - ), -})) - -import { AnswerModal } from '@/components/notifications/answer-modal' -import { answerQuestion } from '@/actions/questions' -import { toast } from 'sonner' -import type { NotificationQuestion } from '@/stores/notifications-store' - -const mockAnswerQuestion = answerQuestion as ReturnType -const mockToast = toast as unknown as { - success: ReturnType - error: ReturnType -} - -const QUESTION: NotificationQuestion = { - kind: 'idea', - id: 'q-1', - product_id: 'prod-1', - idea_id: 'idea-1', - idea_code: 'IDEA-42', - idea_title: 'Mijn Idee', - question: 'Wat denk jij?', - options: ['Optie A', 'Optie B'], - created_at: '2026-01-01T00:00:00Z', - expires_at: '2026-12-31T00:00:00Z', -} - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('AnswerModal — met opties', () => { - it('toont optieknoppen, textarea en Verstuur-knop', () => { - render() - expect(screen.getByRole('button', { name: 'Optie A' })).toBeTruthy() - expect(screen.getByRole('button', { name: 'Optie B' })).toBeTruthy() - expect(screen.getByLabelText(/Antwoord op Claude/)).toBeTruthy() - expect(screen.getByRole('button', { name: 'Verstuur' })).toBeTruthy() - }) - - it('roept answerQuestion aan met optiewaarde bij klik op optieknop', async () => { - mockAnswerQuestion.mockResolvedValue({ ok: true }) - render() - - fireEvent.click(screen.getByRole('button', { name: 'Optie A' })) - - await waitFor(() => { - expect(mockAnswerQuestion).toHaveBeenCalledWith('q-1', 'Optie A') - }) - }) - - it('roept answerQuestion aan met getypte tekst bij klik op Verstuur', async () => { - mockAnswerQuestion.mockResolvedValue({ ok: true }) - render() - - fireEvent.change(screen.getByLabelText(/Antwoord op Claude/), { - target: { value: 'Mijn eigen antwoord' }, - }) - fireEvent.click(screen.getByRole('button', { name: 'Verstuur' })) - - await waitFor(() => { - expect(mockAnswerQuestion).toHaveBeenCalledWith('q-1', 'Mijn eigen antwoord') - }) - }) - - it('Verstuur-knop is disabled zolang het tekstveld leeg is', () => { - render() - expect(screen.getByRole('button', { name: 'Verstuur' })).toHaveProperty('disabled', true) - }) -}) - -describe('AnswerModal — demo-modus', () => { - it('textarea is disabled en Verstuur is disabled bij isDemo=true', () => { - render() - expect(screen.getByLabelText(/Antwoord op Claude/)).toHaveProperty('disabled', true) - expect(screen.getByRole('button', { name: 'Verstuur' })).toHaveProperty('disabled', true) - }) -}) - -describe('AnswerModal — geen vraag', () => { - it('rendert niets wanneer question null is', () => { - const { container } = render( - , - ) - expect(container.firstChild).toBeNull() - }) -}) diff --git a/__tests__/components/ideas/idea-list.test.tsx b/__tests__/components/ideas/idea-list.test.tsx deleted file mode 100644 index 0e0a351..0000000 --- a/__tests__/components/ideas/idea-list.test.tsx +++ /dev/null @@ -1,277 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import '@testing-library/jest-dom' -import React from 'react' - -// --- Navigation mock --- -vi.mock('next/navigation', () => ({ - useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }), -})) - -// --- Actions mocks --- -vi.mock('@/actions/ideas', () => ({ - createIdeaAction: vi.fn(), - archiveIdeaAction: vi.fn(), -})) - -vi.mock('@/actions/user-settings', () => ({ - updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }), -})) - -// --- Sonner mock --- -vi.mock('sonner', () => ({ - toast: { error: vi.fn(), success: vi.fn() }, -})) - -// --- IdeaRowActions mock (complex component with many deps) --- -vi.mock('@/components/ideas/idea-row-actions', () => ({ - IdeaRowActions: () =>
, -})) - -// --- DemoTooltip mock --- -vi.mock('@/components/shared/demo-tooltip', () => ({ - DemoTooltip: ({ children }: { children: React.ReactNode }) => <>{children}, -})) - -// --- Popover mock — controlled via open prop --- -vi.mock('@/components/ui/popover', () => { - const PopoverCtx = React.createContext<{ - open: boolean - onOpenChange: (v: boolean) => void - }>({ open: false, onOpenChange: () => {} }) - - return { - Popover: ({ - children, - open, - onOpenChange, - }: { - children: React.ReactNode - open?: boolean - onOpenChange?: (v: boolean) => void - }) => ( - {}) }}> - {children} - - ), - PopoverTrigger: ({ render: renderEl }: { render: React.ReactElement<{ onClick?: (e: React.MouseEvent) => void }> }) => { - const { open, onOpenChange } = React.useContext(PopoverCtx) - return React.cloneElement(renderEl, { - onClick: (e: React.MouseEvent) => { - onOpenChange(!open) - renderEl.props.onClick?.(e) - }, - }) - }, - PopoverContent: ({ children }: { children: React.ReactNode }) => { - const { open } = React.useContext(PopoverCtx) - return open ?
{children}
: null - }, - } -}) - -// Import after mocks -import { useUserSettingsStore } from '@/stores/user-settings/store' -import { IdeaList } from '@/components/ideas/idea-list' -import { createIdeaAction } from '@/actions/ideas' -import type { IdeaDto } from '@/lib/idea-dto' - -const PRODUCTS = [ - { id: 'prod-1', name: 'Product A', repo_url: null }, - // repo_url ingesteld zodat de optietekst gewoon "Product B" is (zonder "(geen repo)" suffix) - { id: 'prod-2', name: 'Product B', repo_url: 'https://github.com/org/prod-b' }, -] - -// Minimal IdeaDto factory -function makeIdea(overrides: Partial = {}): IdeaDto { - return { - id: 'idea-1', - code: 'ID-1', - title: 'Test Idee', - description: null, - status: 'draft', - product_id: null, - product: null, - pbi_id: null, - pbi: null, - secondary_products: [], - archived: false, - has_grill_md: false, - has_plan_md: false, - created_at: '2024-01-01T00:00:00.000Z', - updated_at: '2024-01-01T00:00:00.000Z', - ...overrides, - } -} - -const IDEAS: IdeaDto[] = [ - makeIdea({ id: 'idea-1', code: 'ID-1', title: 'Idee Concept', status: 'draft' }), - makeIdea({ id: 'idea-2', code: 'ID-2', title: 'Idee Gegrilld', status: 'grilled' }), - makeIdea({ id: 'idea-3', code: 'ID-3', title: 'Idee Gepland', status: 'planned' }), -] - -beforeEach(() => { - vi.clearAllMocks() - useUserSettingsStore.getState().hydrate({}, false) -}) - -describe('IdeaList — filterpopover', () => { - it('toont de "Filters"-knop in de toolbar (geen inline chip-rij)', () => { - render() - - // Filters-knop aanwezig - expect(screen.getByText('Filters')).toBeInTheDocument() - - // Status-labels zoals "Concept" mogen NIET los zichtbaar zijn zonder popover te openen - // (anders was de oude inline chip-rij er nog) - expect(screen.queryByRole('button', { name: 'Concept' })).not.toBeInTheDocument() - }) - - it('klik op "Filters" opent de popover en toont 11 statusopties', () => { - render() - - // Popover nog niet open: content niet zichtbaar - expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument() - - fireEvent.click(screen.getByText('Filters')) - - // Content verschijnt - expect(screen.getByTestId('popover-content')).toBeInTheDocument() - - // 11 statusopties + "Alle" = 12 buttons in de popover - // Controleer specifiek de 11 status-labels - const statusLabels = [ - 'Concept', 'Grillen', 'Gegrilld', 'Plannen', 'Plan klaar', - 'Plan beoordelen', 'Gepland', 'Grill mislukt', 'Plan mislukt', - 'Beoordeling mislukt', 'Plan beoordeeld', - ] - for (const label of statusLabels) { - expect(screen.getByRole('button', { name: label })).toBeInTheDocument() - } - }) - - it('klik op een statuschip schrijft de status naar de store', () => { - render() - - fireEvent.click(screen.getByText('Filters')) - fireEvent.click(screen.getByRole('button', { name: 'Concept' })) - - const stored = - useUserSettingsStore.getState().entities.settings.views?.ideasList?.filterStatuses - expect(stored).toContain('draft') - }) - - it('gehydrateerde filter toont "Filters (1)" en filtert de tabel', () => { - useUserSettingsStore - .getState() - .hydrate({ views: { ideasList: { filterStatuses: ['draft'] } } }, false) - - render() - - // Trigger toont het actieve filteraantal - expect(screen.getByText('Filters (1)')).toBeInTheDocument() - - // Alleen het concept-idee is zichtbaar; de andere twee worden weggefilterd - expect(screen.getByText('Idee Concept')).toBeInTheDocument() - expect(screen.queryByText('Idee Gegrilld')).not.toBeInTheDocument() - expect(screen.queryByText('Idee Gepland')).not.toBeInTheDocument() - }) - - it('"Wis filters" is disabled wanneer geen filter actief is', () => { - render() - - fireEvent.click(screen.getByText('Filters')) - - const wisButton = screen.getByRole('button', { name: 'Wis filters' }) - expect(wisButton).toBeDisabled() - }) - - it('"Wis filters" is enabled en wist de filter wanneer een filter actief is', () => { - useUserSettingsStore - .getState() - .hydrate({ views: { ideasList: { filterStatuses: ['draft'] } } }, false) - - render() - - fireEvent.click(screen.getByText('Filters (1)')) - - const wisButton = screen.getByRole('button', { name: 'Wis filters' }) - expect(wisButton).not.toBeDisabled() - - fireEvent.click(wisButton) - - const stored = - useUserSettingsStore.getState().entities.settings.views?.ideasList?.filterStatuses - expect(stored).toEqual([]) - }) -}) - -describe('IdeaList — activeProductId voorvullen', () => { - // Hulpfunctie: vind een knop op basis van gedeeltelijke tekstinhoud. - // getByText() werkt hier betrouwbaarder dan getByRole({name}) voor knoppen - // met SVG-icoon omdat de accessible-name-berekening van Base UI knoppen in - // jsdom soms afwijkt van wat we verwachten. - function clickButton(label: string) { - const btn = Array.from(document.querySelectorAll('button')).find( - (b) => b.textContent?.trim().includes(label) - ) - if (!btn) throw new Error(`Knop met tekst "${label}" niet gevonden`) - fireEvent.click(btn) - } - - it('AC1: "Nieuw idee"-select is voorgevuld met het actieve product', async () => { - render( - - ) - - clickButton('Nieuw idee') - - // Wacht tot het formulier verschijnt; create-form-select toont "Product B" (waarde 'prod-2'). - // De toolbar-select toont "Alle producten" (waarde 'all'), zodat displayValue uniek is. - const createFormSelect = await waitFor(() => screen.getByDisplayValue('Product B')) - expect(createFormSelect).toHaveValue('prod-2') - }) - - it('AC2: "Nieuw idee"-select staat op leeg wanneer activeProductId null is', async () => { - render( - - ) - - clickButton('Nieuw idee') - - // Toolbar-select toont "Alle producten"; create-form-select toont de placeholder (waarde ''). - const createFormSelect = await waitFor(() => - screen.getByDisplayValue('Geen product (kan later worden gekoppeld)') - ) - expect(createFormSelect).toHaveValue('') - }) - - it('AC3: "Snel idee" stuurt product_id gelijk aan activeProductId mee', async () => { - vi.mocked(createIdeaAction).mockResolvedValue({ data: { code: 'ID-99', id: 'idea-99' } } as never) - - render( - - ) - - // Open "Snel idee"-formulier en wacht tot het verschijnt - clickButton('Snel idee') - await waitFor(() => screen.getByPlaceholderText('Titel *')) - - // Vul de verplichte titel in - fireEvent.change(screen.getByPlaceholderText('Titel *'), { - target: { value: 'Mijn snel idee' }, - }) - - // Klik Opslaan — startTransition roept createIdeaAction synchroon aan - clickButton('Opslaan') - - await waitFor(() => { - expect(createIdeaAction).toHaveBeenCalledWith({ - title: 'Mijn snel idee', - description: null, - product_id: 'prod-2', - }) - }) - }) -}) diff --git a/__tests__/components/jobs/job-card.test.tsx b/__tests__/components/jobs/job-card.test.tsx deleted file mode 100644 index 09bc3a2..0000000 --- a/__tests__/components/jobs/job-card.test.tsx +++ /dev/null @@ -1,85 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect } from 'vitest' -import { render, screen } from '@testing-library/react' -import '@testing-library/jest-dom' -import JobCard from '@/components/jobs/job-card' - -const BASE_PROPS = { - id: 'job-1', - kind: 'TASK_IMPLEMENTATION' as const, - status: 'RUNNING' as const, - productName: 'Scrum4Me', - productCode: 'S4M', - pbiCode: 'PBI-1', - storyCode: 'ST-1', - createdAt: new Date('2026-01-01T10:00:00Z'), -} - -describe('JobCard breadcrumb', () => { - it('TASK-job toont productCode, pbiCode en storyCode in de breadcrumb', () => { - render() - const breadcrumb = screen.getByText('S4M PBI-1 ST-1') - expect(breadcrumb).toBeInTheDocument() - }) - - it('TASK-job zonder productCode valt terug op productName in de breadcrumb', () => { - render() - expect(screen.getByText('Scrum4Me PBI-1 ST-1')).toBeInTheDocument() - }) - - it('TASK-job laat ontbrekende codes weg uit de breadcrumb', () => { - render() - expect(screen.getByText('S4M')).toBeInTheDocument() - }) - - it('GRILL-job toont productCode en ideaCode', () => { - render( - , - ) - expect(screen.getByText('S4M IDEA-5')).toBeInTheDocument() - }) - - it('SPRINT-job toont productCode en sprintCode', () => { - render( - , - ) - expect(screen.getByText('S4M SP-3')).toBeInTheDocument() - }) -}) - -describe('JobCard datumweergave', () => { - it('toont finishedAt als die beschikbaar is', () => { - const finishedAt = new Date('2026-03-15T14:30:00Z') - render() - const formatted = finishedAt.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }) - expect(screen.getByText(formatted)).toBeInTheDocument() - }) - - it('toont startedAt als finishedAt ontbreekt', () => { - const startedAt = new Date('2026-03-10T09:00:00Z') - render() - const formatted = startedAt.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }) - expect(screen.getByText(formatted)).toBeInTheDocument() - }) - - it('toont createdAt als zowel finishedAt als startedAt ontbreken', () => { - const createdAt = new Date('2026-01-01T10:00:00Z') - render() - const formatted = createdAt.toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' }) - expect(screen.getByText(formatted)).toBeInTheDocument() - }) -}) diff --git a/__tests__/components/jobs/job-detail-pane.test.tsx b/__tests__/components/jobs/job-detail-pane.test.tsx deleted file mode 100644 index 9a5d0f6..0000000 --- a/__tests__/components/jobs/job-detail-pane.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' -import '@testing-library/jest-dom' -import type { JobWithRelations } from '@/actions/jobs-page' - -vi.mock('@/actions/claude-jobs', () => ({ - restartClaudeJobAction: vi.fn(), -})) - -vi.mock('sonner', () => ({ toast: { error: vi.fn() } })) - -import { restartClaudeJobAction } from '@/actions/claude-jobs' -import JobDetailPane from '@/components/jobs/job-detail-pane' - -const mockAction = restartClaudeJobAction as ReturnType - -function makeJob(status: JobWithRelations['status']): JobWithRelations { - return { - id: 'job-1', - kind: 'TASK_IMPLEMENTATION', - status, - taskCode: 'T-1', - taskTitle: 'Test taak', - ideaCode: null, - ideaTitle: null, - sprintGoal: null, - sprintCode: null, - productName: 'Scrum4Me', - productCode: null, - storyCode: null, - pbiCode: null, - modelId: null, - inputTokens: null, - outputTokens: null, - cacheReadTokens: null, - cacheWriteTokens: null, - costUsd: null, - branch: null, - prUrl: null, - error: null, - summary: null, - description: null, - verifyResult: null, - startedAt: null, - finishedAt: null, - createdAt: new Date('2026-01-01'), - sprintRunId: null, - } -} - -beforeEach(() => { - vi.clearAllMocks() - mockAction.mockResolvedValue({ success: true }) -}) - -describe('JobDetailPane restart button', () => { - it('toont de knop voor FAILED-jobs', () => { - render() - expect(screen.getByRole('button', { name: /opnieuw starten/i })).toBeInTheDocument() - }) - - it('toont de knop niet voor DONE-jobs', () => { - render() - expect(screen.queryByRole('button', { name: /opnieuw starten/i })).not.toBeInTheDocument() - }) - - it('roept restartClaudeJobAction aan met het juiste id bij klik', () => { - render() - fireEvent.click(screen.getByRole('button', { name: /opnieuw starten/i })) - expect(mockAction).toHaveBeenCalledWith('job-1') - }) - - it('knop is disabled in demo-modus', () => { - render() - expect(screen.getByRole('button', { name: /opnieuw starten/i })).toBeDisabled() - }) -}) diff --git a/__tests__/components/shared/nav-bar.test.tsx b/__tests__/components/shared/nav-bar.test.tsx deleted file mode 100644 index 28e9037..0000000 --- a/__tests__/components/shared/nav-bar.test.tsx +++ /dev/null @@ -1,179 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' -import '@testing-library/jest-dom' -import React from 'react' - -const pushMock = vi.fn() -const refreshMock = vi.fn() -const pathnameMock = vi.fn(() => '/dashboard') - -vi.mock('next/navigation', () => ({ - useRouter: () => ({ push: pushMock, refresh: refreshMock }), - usePathname: () => pathnameMock(), -})) - -vi.mock('@/actions/active-product', () => ({ - setActiveProductAction: vi.fn(), -})) - -vi.mock('sonner', () => ({ - toast: { error: vi.fn(), success: vi.fn() }, -})) - -vi.mock('@/components/ui/dropdown-menu', () => { - type Props = React.HTMLAttributes & { - children?: React.ReactNode - onClick?: () => void - } - const PassThrough = ({ children }: Props) => <>{children} - const Forwarding = ({ children, ...rest }: Props) =>
{children}
- return { - DropdownMenu: PassThrough, - DropdownMenuTrigger: Forwarding, - DropdownMenuContent: PassThrough, - DropdownMenuItem: ({ children, onClick, className }: Props) => ( - - ), - DropdownMenuSeparator: () => null, - } -}) - -vi.mock('@/components/ui/tooltip', () => { - type Props = { children?: React.ReactNode } - const PassThrough = ({ children }: Props) => <>{children} - return { - Tooltip: PassThrough, - TooltipContent: PassThrough, - TooltipProvider: PassThrough, - TooltipTrigger: PassThrough, - } -}) - -vi.mock('@/components/shared/app-icon', () => ({ AppIcon: () => null })) -vi.mock('@/components/shared/user-menu', () => ({ UserMenu: () => null })) -vi.mock('@/components/shared/notifications-bell', () => ({ NotificationsBell: () => null })) -vi.mock('@/components/solo/nav-status-indicators', () => ({ - SoloNavStatusIndicators: () => null, -})) - -import { setActiveProductAction } from '@/actions/active-product' -import { toast } from 'sonner' -import { NavBar } from '@/components/shared/nav-bar' - -const actionMock = setActiveProductAction as unknown as ReturnType -const toastSuccess = toast.success as unknown as ReturnType - -const products = [ - { id: 'A', name: 'Alpha' }, - { id: 'B', name: 'Beta' }, -] - -function renderNavBar(overrides: { isDemo?: boolean; activeProductId?: string } = {}) { - const isDemo = overrides.isDemo ?? false - const activeId = overrides.activeProductId ?? 'A' - const activeProduct = products.find(p => p.id === activeId) ?? null - return render( - , - ) -} - -beforeEach(() => { - vi.clearAllMocks() - actionMock.mockResolvedValue({ success: true }) - pathnameMock.mockReturnValue('/dashboard') -}) - -describe('NavBar — product switch', () => { - it('demo: clicking another product navigates via router.push without calling the action', () => { - renderNavBar({ isDemo: true, activeProductId: 'A' }) - fireEvent.click(screen.getByText('Beta')) - expect(pushMock).toHaveBeenCalledWith('/products/B') - expect(actionMock).not.toHaveBeenCalled() - expect(toastSuccess).not.toHaveBeenCalled() - }) - - it('non-demo: clicking another product calls setActiveProductAction', async () => { - renderNavBar({ isDemo: false, activeProductId: 'A' }) - fireEvent.click(screen.getByText('Beta')) - await Promise.resolve() - expect(actionMock).toHaveBeenCalledWith('B') - }) - - it('non-demo: on /products/A navigates to /products/B', async () => { - pathnameMock.mockReturnValue('/products/A') - renderNavBar({ isDemo: false, activeProductId: 'A' }) - fireEvent.click(screen.getByText('Beta')) - await Promise.resolve() - await Promise.resolve() - expect(pushMock).toHaveBeenCalledWith('/products/B') - expect(toastSuccess).toHaveBeenCalled() - }) - - it('non-demo: on /products/A/sprint/SPR1 navigates to /products/B/sprint', async () => { - pathnameMock.mockReturnValue('/products/A/sprint/SPR1') - renderNavBar({ isDemo: false, activeProductId: 'A' }) - fireEvent.click(screen.getByText('Beta')) - await Promise.resolve() - await Promise.resolve() - expect(pushMock).toHaveBeenCalledWith('/products/B/sprint') - expect(toastSuccess).toHaveBeenCalled() - }) - - it('non-demo: on /products/A/solo navigates to /products/B/solo', async () => { - pathnameMock.mockReturnValue('/products/A/solo') - renderNavBar({ isDemo: false, activeProductId: 'A' }) - fireEvent.click(screen.getByText('Beta')) - await Promise.resolve() - await Promise.resolve() - expect(pushMock).toHaveBeenCalledWith('/products/B/solo') - expect(toastSuccess).toHaveBeenCalled() - }) - - it('non-demo: on /dashboard calls router.refresh and not router.push', async () => { - pathnameMock.mockReturnValue('/dashboard') - renderNavBar({ isDemo: false, activeProductId: 'A' }) - fireEvent.click(screen.getByText('Beta')) - await Promise.resolve() - await Promise.resolve() - expect(refreshMock).toHaveBeenCalled() - expect(pushMock).not.toHaveBeenCalled() - expect(toastSuccess).toHaveBeenCalled() - }) -}) - -describe('NavBar — URL-derived active product (demo only)', () => { - it('demo: label and dropdown highlight follow pathname, not the activeProduct prop', () => { - pathnameMock.mockReturnValue('/products/B/sprint') - const { container } = renderNavBar({ isDemo: true, activeProductId: 'A' }) - const trigger = container.querySelector('[data-debug-id="nav-bar__product-switcher"]') - expect(trigger?.textContent).toContain('Beta') - expect(trigger?.textContent).not.toContain('Alpha') - const items = screen.getAllByTestId('dd-item') - const itemB = items.find(el => el.textContent?.includes('Beta')) - expect(itemB?.className).toContain('bg-primary-container') - const itemA = items.find(el => el.textContent?.includes('Alpha')) - expect(itemA?.className ?? '').not.toContain('bg-primary-container') - }) - - it('non-demo: pathname does NOT override the activeProduct prop', () => { - pathnameMock.mockReturnValue('/products/B/sprint') - renderNavBar({ isDemo: false, activeProductId: 'A' }) - // Label still reflects server-rendered activeProduct (Alpha) - const items = screen.getAllByTestId('dd-item') - const itemA = items.find(el => el.textContent?.includes('Alpha')) - expect(itemA?.className).toContain('bg-primary-container') - }) -}) diff --git a/__tests__/components/shared/sprint-switcher.test.tsx b/__tests__/components/shared/sprint-switcher.test.tsx deleted file mode 100644 index 8af2df1..0000000 --- a/__tests__/components/shared/sprint-switcher.test.tsx +++ /dev/null @@ -1,174 +0,0 @@ -// @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' -import '@testing-library/jest-dom' -import React from 'react' - -const pushMock = vi.fn() -const refreshMock = vi.fn() -const pathnameMock = vi.fn(() => '/products/p1/sprint') - -vi.mock('next/navigation', () => ({ - useRouter: () => ({ push: pushMock, refresh: refreshMock }), - usePathname: () => pathnameMock(), -})) - -vi.mock('@/actions/active-sprint', () => ({ - setActiveSprintAction: vi.fn(), - switchActiveSprintAction: vi.fn(), - clearActiveSprintAction: vi.fn(), -})) - -vi.mock('sonner', () => ({ - toast: { error: vi.fn(), success: vi.fn() }, -})) - -const isDemoMock = { value: false } -const workflowMock: { - value: - | { pendingSprintDraft?: Record } - | undefined -} = { value: undefined } -// Mock-state shape moet alle paden dekken die SprintSwitcher selecteert: -// - s.context.isDemo (oude code) -// - s.entities.settings.workflow?.pendingSprintDraft?.[productId]?.goal (PBI-79) -type MockStoreState = { - context: { isDemo: boolean } - entities: { - settings: { - workflow?: { - pendingSprintDraft?: Record - } - } - } -} -vi.mock('@/stores/user-settings/store', () => ({ - useUserSettingsStore: (selector: (s: MockStoreState) => unknown) => - selector({ - context: { isDemo: isDemoMock.value }, - entities: { settings: { workflow: workflowMock.value } }, - }), -})) - -vi.mock('@/components/ui/dropdown-menu', () => { - type Props = { children?: React.ReactNode; onClick?: () => void; className?: string } - const PassThrough = ({ children }: Props) => <>{children} - return { - DropdownMenu: PassThrough, - DropdownMenuTrigger: PassThrough, - DropdownMenuContent: PassThrough, - DropdownMenuItem: ({ children, onClick, className }: Props) => ( - - ), - DropdownMenuSeparator: () => null, - } -}) - -vi.mock('@/components/ui/tooltip', () => { - type Props = { children?: React.ReactNode } - const PassThrough = ({ children }: Props) => <>{children} - return { - Tooltip: PassThrough, - TooltipContent: PassThrough, - TooltipProvider: PassThrough, - TooltipTrigger: PassThrough, - } -}) - -import { switchActiveSprintAction } from '@/actions/active-sprint' -import { toast } from 'sonner' -import { SprintSwitcher } from '@/components/shared/sprint-switcher' - -const actionMock = switchActiveSprintAction as unknown as ReturnType -const toastError = toast.error as unknown as ReturnType -const toastSuccess = toast.success as unknown as ReturnType - -const sprints = [ - { id: 's1', code: 'SP-1', sprint_goal: 'Goal 1', status: 'open' as const }, - { id: 's2', code: 'SP-2', sprint_goal: 'Goal 2', status: 'open' as const }, -] - -beforeEach(() => { - vi.clearAllMocks() - isDemoMock.value = false - workflowMock.value = undefined - actionMock.mockResolvedValue({ success: true }) - pathnameMock.mockReturnValue('/products/p1/sprint') -}) - -describe('SprintSwitcher', () => { - it('demo: clicking another sprint navigates via router.push without calling the action', () => { - isDemoMock.value = true - render( - , - ) - fireEvent.click(screen.getByText('Goal 2')) - expect(pushMock).toHaveBeenCalledWith('/products/p1/sprint/s2') - expect(actionMock).not.toHaveBeenCalled() - expect(toastError).not.toHaveBeenCalled() - expect(toastSuccess).not.toHaveBeenCalled() - }) - - it('non-demo: clicking another sprint calls setActiveSprintAction', async () => { - isDemoMock.value = false - render( - , - ) - fireEvent.click(screen.getByText('Goal 2')) - // Wait microtask for the transition to flush. - await Promise.resolve() - expect(actionMock).toHaveBeenCalledWith('p1', 's2') - }) - - it('clicking the already-active sprint does nothing', () => { - isDemoMock.value = true - render( - , - ) - fireEvent.click(screen.getByText('Goal 1')) - expect(pushMock).not.toHaveBeenCalled() - expect(actionMock).not.toHaveBeenCalled() - }) - - it('shows the concept-sprint on the trigger when a draft is pending (G5)', () => { - workflowMock.value = { pendingSprintDraft: { p1: { goal: 'Test goal' } } } - render( - , - ) - expect(screen.getByText('⚙ Concept — Test goal')).toBeInTheDocument() - }) - - it('shows no concept label on the trigger when no draft is pending', () => { - render( - , - ) - expect(screen.queryByText(/⚙ Concept/)).not.toBeInTheDocument() - }) -}) diff --git a/__tests__/components/split-pane.test.tsx b/__tests__/components/split-pane.test.tsx index 40cb515..cd166c0 100644 --- a/__tests__/components/split-pane.test.tsx +++ b/__tests__/components/split-pane.test.tsx @@ -1,35 +1,28 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' - -vi.mock('@/actions/user-settings', () => ({ - updateUserSettingsAction: vi.fn().mockResolvedValue({ success: true, settings: {} }), -})) - import { SplitPane } from '@/components/split-pane/split-pane' -import { useUserSettingsStore } from '@/stores/user-settings/store' -function seedPositions(key: string, positions: number[]) { - useUserSettingsStore.setState((s) => { - s.entities.settings = { - layout: { - splitPanePositions: { [key]: positions }, - }, - } +// Helper to set a cookie +function setCookie(key: string, value: string) { + Object.defineProperty(document, 'cookie', { + writable: true, + configurable: true, + value: `sp:${key}=${encodeURIComponent(value)}`, }) } -function resetStore() { - useUserSettingsStore.setState((s) => { - s.entities.settings = {} - s.context.hydrated = false - s.context.isDemo = false +function clearCookies() { + Object.defineProperty(document, 'cookie', { + writable: true, + configurable: true, + value: '', }) } describe('SplitPane', () => { beforeEach(() => { - resetStore() + clearCookies() // Default: desktop viewport Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1440 }) window.dispatchEvent(new Event('resize')) @@ -71,8 +64,9 @@ describe('SplitPane', () => { expect(dividers).toHaveLength(2) }) - it('restores splits from user-settings store on mount', () => { - seedPositions('test-restore', [40, 60]) + it('restores splits from cookie on mount', () => { + const stored = JSON.stringify([40, 60]) + setCookie('test-restore', stored) const { container } = render( { expect(paneDiv).toBeTruthy() }) - it('falls back to defaultSplit when persisted positions are invalid', () => { - // Wrong number of values for a 2-pane layout - seedPositions('test-invalid', [10, 30, 60]) + it('falls back to defaultSplit when cookie is invalid', () => { + setCookie('test-invalid', 'not-valid-json') const { container } = render( ({ useRouter: () => ({ push: vi.fn() }) })) -vi.mock('@/actions/tasks', () => ({ - saveTask: vi.fn(), - deleteTask: vi.fn(), -})) -vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) - -import { SprintTaskDialogMount } from '@/components/sprint/sprint-task-dialog-mount' -import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' -import type { SprintWorkspaceTaskDetail } from '@/stores/sprint-workspace/types' - -const TASK_DETAIL: SprintWorkspaceTaskDetail = { - id: 't1', - code: 'T-1', - title: 'Mijn taak', - description: 'Beschrijving', - priority: 2, - sort_order: 1, - status: 'in_progress', - story_id: 'story-1', - sprint_id: 'sprint-1', - created_at: new Date('2026-01-15'), - _detail: true, - implementation_plan: 'Stap 1\nStap 2', -} - -function resetStore() { - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = null - s.context.activeSprintId = null - s.context.activeStoryId = null - s.context.activeTaskId = null - s.entities.sprintsById = {} - s.entities.storiesById = {} - s.entities.tasksById = {} - s.relations.sprintIdsByProduct = {} - s.relations.storyIdsBySprint = {} - s.relations.taskIdsByStory = {} - s.loading.loadedProductSprintsIds = {} - s.loading.loadingProductId = null - s.loading.loadedSprintIds = {} - s.loading.loadingSprintId = null - s.loading.loadedStoryIds = {} - s.loading.loadedTaskIds = {} - s.loading.activeRequestId = null - s.pendingMutations = {} - }) -} - -beforeEach(() => { - resetStore() -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('SprintTaskDialogMount', () => { - it('rendert niets wanneer er geen active task is', () => { - const { container } = render( - , - ) - expect(container.textContent).toBe('') - }) - - it('rendert niets wanneer active task geen _detail heeft', () => { - useSprintWorkspaceStore.setState((s) => { - s.entities.tasksById['t1'] = { - id: 't1', - code: 'T-1', - title: 'Mijn taak', - description: null, - priority: 2, - sort_order: 1, - status: 'todo', - story_id: 'story-1', - sprint_id: 'sprint-1', - created_at: new Date(), - } - s.context.activeTaskId = 't1' - }) - - const { container } = render( - , - ) - expect(container.textContent).toBe('') - }) - - it('rendert TaskDialog met titel "Taak bewerken" wanneer detail aanwezig is', () => { - useSprintWorkspaceStore.setState((s) => { - s.entities.tasksById['t1'] = TASK_DETAIL - s.context.activeTaskId = 't1' - }) - - render() - - expect(screen.getByText('Taak bewerken')).toBeTruthy() - expect((screen.getByLabelText(/Titel/) as HTMLInputElement).value).toBe('Mijn taak') - }) - - it('clear activeTaskId wanneer Annuleren wordt geklikt', async () => { - useSprintWorkspaceStore.setState((s) => { - s.entities.tasksById['t1'] = TASK_DETAIL - s.context.activeTaskId = 't1' - }) - - render() - - fireEvent.click(screen.getByRole('button', { name: 'Annuleren' })) - - await waitFor(() => { - expect(useSprintWorkspaceStore.getState().context.activeTaskId).toBeNull() - }) - }) -}) diff --git a/__tests__/hooks/use-jobs-realtime.test.tsx b/__tests__/hooks/use-jobs-realtime.test.tsx deleted file mode 100644 index 49b9817..0000000 --- a/__tests__/hooks/use-jobs-realtime.test.tsx +++ /dev/null @@ -1,147 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { renderHook, act } from '@testing-library/react' -import { useJobsStore } from '@/stores/jobs-store' -import useJobsRealtime from '@/hooks/use-jobs-realtime' - -type Listener = (event: { data: string }) => void - -class MockEventSource { - static instance: MockEventSource | null = null - private listeners: Record = {} - onerror: (() => void) | null = null - - constructor(_url: string) { - MockEventSource.instance = this - } - - addEventListener(type: string, listener: Listener) { - if (!this.listeners[type]) this.listeners[type] = [] - this.listeners[type].push(listener) - } - - dispatch(type: string, data: unknown) { - for (const l of this.listeners[type] ?? []) { - l({ data: JSON.stringify(data) }) - } - } - - close() {} -} - -const fullJob = { - id: 'job-unknown-1', - kind: 'TASK_IMPLEMENTATION', - status: 'RUNNING', - taskCode: 'T-1', - taskTitle: 'Test', - ideaCode: null, - ideaTitle: null, - sprintGoal: null, - sprintCode: null, - productName: 'Scrum4Me', - productCode: null, - storyCode: null, - pbiCode: null, - modelId: null, - inputTokens: null, - outputTokens: null, - cacheReadTokens: null, - cacheWriteTokens: null, - costUsd: null, - branch: null, - prUrl: null, - error: null, - summary: null, - description: null, - verifyResult: null, - startedAt: null, - finishedAt: null, - createdAt: new Date('2026-01-01'), - sprintRunId: null, -} - -beforeEach(() => { - vi.stubGlobal('EventSource', MockEventSource) - MockEventSource.instance = null - - // Lege store - useJobsStore.setState({ activeJobs: [], doneJobs: [], selectedJobId: null }) - - // fetch resolveert naar de volledige job - vi.stubGlobal( - 'fetch', - vi.fn().mockImplementation(async () => ({ - ok: true, - json: async () => fullJob, - })) - ) -}) - -afterEach(() => { - vi.unstubAllGlobals() - vi.restoreAllMocks() -}) - -describe('useJobsRealtime: fetch-on-unknown', () => { - it('haalt onbekende job op via REST bij message-event', async () => { - renderHook(() => useJobsRealtime()) - const es = MockEventSource.instance! - - // Dispatch twee events met hetzelfde onbekende job_id gelijktijdig - act(() => { - es.dispatch('message', { job_id: 'job-unknown-1', status: 'RUNNING' }) - es.dispatch('message', { job_id: 'job-unknown-1', status: 'RUNNING' }) - }) - - // Wacht op alle microtasks / fetch-promises - await act(async () => { - await Promise.resolve() - }) - - expect(fetch).toHaveBeenCalledTimes(1) - expect(fetch).toHaveBeenCalledWith('/api/jobs/job-unknown-1') - - const { activeJobs } = useJobsStore.getState() - expect(activeJobs.some(j => j.id === 'job-unknown-1')).toBe(true) - expect(activeJobs.find(j => j.id === 'job-unknown-1')?.taskTitle).toBe('Test') - }) - - it('gebruikt partial-upsert voor bekende jobs bij message-event', async () => { - // Zet een bekende job in de store - useJobsStore.setState({ - activeJobs: [{ ...fullJob, id: 'job-known-1', status: 'QUEUED' } as never], - doneJobs: [], - selectedJobId: null, - }) - - renderHook(() => useJobsRealtime()) - const es = MockEventSource.instance! - - act(() => { - es.dispatch('message', { job_id: 'job-known-1', status: 'RUNNING', branch: 'feat/x' }) - }) - - await act(async () => { await Promise.resolve() }) - - expect(fetch).not.toHaveBeenCalled() - const { activeJobs } = useJobsStore.getState() - expect(activeJobs.find(j => j.id === 'job-known-1')?.status).toBe('RUNNING') - }) - - it('haalt onbekende job op via REST bij jobs_initial-event', async () => { - renderHook(() => useJobsRealtime()) - const es = MockEventSource.instance! - - act(() => { - es.dispatch('jobs_initial', [{ job_id: 'job-unknown-1', status: 'RUNNING' }]) - }) - - await act(async () => { await Promise.resolve() }) - - expect(fetch).toHaveBeenCalledTimes(1) - expect(fetch).toHaveBeenCalledWith('/api/jobs/job-unknown-1') - const { activeJobs } = useJobsStore.getState() - expect(activeJobs.some(j => j.id === 'job-unknown-1')).toBe(true) - }) -}) diff --git a/__tests__/lib/active-sprint.test.ts b/__tests__/lib/active-sprint.test.ts deleted file mode 100644 index b2de7ef..0000000 --- a/__tests__/lib/active-sprint.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('@/lib/prisma', () => ({ - prisma: { - sprint: { findFirst: vi.fn() }, - user: { - findUnique: vi.fn(), - update: vi.fn().mockResolvedValue({}), - }, - $executeRaw: vi.fn().mockResolvedValue(1), - }, -})) - -import { prisma } from '@/lib/prisma' -import type { UserSettings } from '@/lib/user-settings' -import { - clearActiveSprintInSettings, - readStoredActiveSprintState, - resolveActiveSprint, -} from '@/lib/active-sprint' - -const mockPrisma = prisma as unknown as { - sprint: { findFirst: ReturnType } - user: { - findUnique: ReturnType - update: ReturnType - } - $executeRaw: ReturnType -} - -function withSettings(settings: UserSettings) { - mockPrisma.user.findUnique.mockResolvedValueOnce({ settings }) -} - -describe('readStoredActiveSprintState', () => { - it('returns unset when activeSprints map is absent', () => { - expect(readStoredActiveSprintState({}, 'p1')).toEqual({ kind: 'unset' }) - }) - - it('returns unset when productId key is absent', () => { - const settings: UserSettings = { - layout: { activeSprints: { p2: 'sprint-2' } }, - } - expect(readStoredActiveSprintState(settings, 'p1')).toEqual({ - kind: 'unset', - }) - }) - - it('returns cleared when key is present with null value', () => { - const settings: UserSettings = { - layout: { activeSprints: { p1: null } }, - } - expect(readStoredActiveSprintState(settings, 'p1')).toEqual({ - kind: 'cleared', - }) - }) - - it('returns set when key is present with string value', () => { - const settings: UserSettings = { - layout: { activeSprints: { p1: 'sprint-1' } }, - } - expect(readStoredActiveSprintState(settings, 'p1')).toEqual({ - kind: 'set', - sprintId: 'sprint-1', - }) - }) -}) - -describe('resolveActiveSprint', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('returns null without fallback when key is explicitly null (cleared)', async () => { - withSettings({ layout: { activeSprints: { p1: null } } }) - - const result = await resolveActiveSprint('p1', 'user-1') - - expect(result).toBeNull() - expect(mockPrisma.sprint.findFirst).not.toHaveBeenCalled() - }) - - it('returns the stored sprint when key is set and sprint exists', async () => { - withSettings({ layout: { activeSprints: { p1: 'sprint-1' } } }) - mockPrisma.sprint.findFirst.mockResolvedValueOnce({ - id: 'sprint-1', - code: 'SP-1', - status: 'OPEN', - }) - - const result = await resolveActiveSprint('p1', 'user-1') - - expect(result).toEqual({ id: 'sprint-1', code: 'SP-1', status: 'OPEN' }) - expect(mockPrisma.sprint.findFirst).toHaveBeenCalledTimes(1) - }) - - it('falls back when stored sprint is not found in DB', async () => { - withSettings({ layout: { activeSprints: { p1: 'stale-id' } } }) - mockPrisma.sprint.findFirst - .mockResolvedValueOnce(null) // stored lookup misses - .mockResolvedValueOnce({ id: 'sprint-open', code: 'SP-O', status: 'OPEN' }) - - const result = await resolveActiveSprint('p1', 'user-1') - - expect(result).toEqual({ - id: 'sprint-open', - code: 'SP-O', - status: 'OPEN', - }) - }) - - it('falls back to first OPEN sprint when key is absent', async () => { - withSettings({}) - mockPrisma.sprint.findFirst.mockResolvedValueOnce({ - id: 'sprint-open', - code: 'SP-O', - status: 'OPEN', - }) - - const result = await resolveActiveSprint('p1', 'user-1') - - expect(result).toEqual({ - id: 'sprint-open', - code: 'SP-O', - status: 'OPEN', - }) - }) - - it('falls back to recent CLOSED sprint when no OPEN exists', async () => { - withSettings({}) - mockPrisma.sprint.findFirst - .mockResolvedValueOnce(null) // no OPEN - .mockResolvedValueOnce({ - id: 'sprint-closed', - code: 'SP-C', - status: 'CLOSED', - }) - - const result = await resolveActiveSprint('p1', 'user-1') - - expect(result).toEqual({ - id: 'sprint-closed', - code: 'SP-C', - status: 'CLOSED', - }) - }) - - it('returns null when key absent and no sprints exist', async () => { - withSettings({}) - mockPrisma.sprint.findFirst.mockResolvedValue(null) - - const result = await resolveActiveSprint('p1', 'user-1') - - expect(result).toBeNull() - }) -}) - -describe('clearActiveSprintInSettings', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('writes null instead of deleting the key', async () => { - withSettings({ - layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } }, - }) - - await clearActiveSprintInSettings('user-1', 'p1') - - expect(mockPrisma.user.update).toHaveBeenCalledTimes(1) - const updateArg = mockPrisma.user.update.mock.calls[0][0] as { - data: { settings: UserSettings } - } - expect(updateArg.data.settings.layout?.activeSprints).toEqual({ - p1: null, - p2: 'sprint-2', - }) - }) - - it('adds the key with null when previously unset', async () => { - withSettings({}) - - await clearActiveSprintInSettings('user-1', 'p1') - - const updateArg = mockPrisma.user.update.mock.calls[0][0] as { - data: { settings: UserSettings } - } - expect(updateArg.data.settings.layout?.activeSprints).toEqual({ p1: null }) - }) -}) diff --git a/__tests__/lib/code.test.ts b/__tests__/lib/code.test.ts deleted file mode 100644 index 7b83640..0000000 --- a/__tests__/lib/code.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { describe, it, expect } from 'vitest' - -import { parseCodeNumber } from '@/lib/code' - -describe('parseCodeNumber', () => { - it('parses a standard story code', () => { - expect(parseCodeNumber('ST-001')).toBe(1) - }) - - it('parses a task code', () => { - expect(parseCodeNumber('T-42')).toBe(42) - }) - - it('parses a large number', () => { - expect(parseCodeNumber('ST-1000')).toBe(1000) - }) - - it('returns MAX_SAFE_INTEGER for a code with no trailing digits', () => { - expect(parseCodeNumber('FOO')).toBe(Number.MAX_SAFE_INTEGER) - }) - - it('returns MAX_SAFE_INTEGER for an empty string', () => { - expect(parseCodeNumber('')).toBe(Number.MAX_SAFE_INTEGER) - }) -}) diff --git a/__tests__/lib/debug.test.ts b/__tests__/lib/debug.test.ts deleted file mode 100644 index 12a1e33..0000000 --- a/__tests__/lib/debug.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' - -import { debugProps } from '@/lib/debug' - -describe('debugProps', () => { - it('returns data-debug-id attr in dev mode', () => { - const result = debugProps('sprint-board', 'SprintBoard', 'components/sprint/sprint-board.tsx') - expect(result).toEqual({ - 'data-debug-id': 'sprint-board', - }) - }) - - it('returns empty object in production mode', () => { - const original = process.env.NODE_ENV - try { - vi.stubEnv('NODE_ENV', 'production') - const result = debugProps('sprint-board', 'SprintBoard', 'components/sprint/sprint-board.tsx') - expect(result).toEqual({}) - } finally { - vi.stubEnv('NODE_ENV', original ?? 'test') - } - }) -}) diff --git a/__tests__/lib/idea-plan-parser.test.ts b/__tests__/lib/idea-plan-parser.test.ts index c279ea8..30169aa 100644 --- a/__tests__/lib/idea-plan-parser.test.ts +++ b/__tests__/lib/idea-plan-parser.test.ts @@ -62,41 +62,6 @@ body } }) - it('hints when markdown sneaks into frontmatter', () => { - // "1. **...**: [unclosed" triggers a YAMLParseError at the markdown line - // (plain-list-with-bold parses as valid YAML without an unclosed flow) - const broken = `--- -pbi: - title: Test - priority: 2 -stories: -1. **Toggle zichtbaar in productie**: [unclosed ---- - -body -` - const r = parsePlanMd(broken) - expect(r.ok).toBe(false) - if (!r.ok) { - expect(r.errors[0].hint).toMatch(/markdown/i) - expect(r.errors[0].line).toBeGreaterThan(1) - } - }) - - it('omits hint for non-markdown yaml errors', () => { - const broken = `--- -pbi: - title: Test - priority: [unclosed -stories: - - foo ---- -` - const r = parsePlanMd(broken) - expect(r.ok).toBe(false) - if (!r.ok) expect(r.errors[0].hint).toBeUndefined() - }) - it('reports schema-validation error when pbi-section missing', () => { const noPbi = `--- stories: diff --git a/__tests__/lib/idea-schemas.test.ts b/__tests__/lib/idea-schemas.test.ts index 637ce1c..1514f5d 100644 --- a/__tests__/lib/idea-schemas.test.ts +++ b/__tests__/lib/idea-schemas.test.ts @@ -128,21 +128,4 @@ describe('ideaPlanMdFrontmatterSchema', () => { }) expect(r.success).toBe(false) }) - - it('accepts plan with task.priority omitted (inherits story-priority via materialize)', () => { - const r = ideaPlanMdFrontmatterSchema.safeParse({ - ...validPlan, - stories: [ - { - title: 'Story zonder task-priorities', - priority: 2, - tasks: [ - { title: 'Taak 1' }, // geen priority — moet geaccepteerd - { title: 'Taak 2', verify_required: 'ALIGNED' }, - ], - }, - ], - }) - expect(r.success).toBe(true) - }) }) diff --git a/__tests__/lib/job-config.test.ts b/__tests__/lib/job-config.test.ts deleted file mode 100644 index 16b90b5..0000000 --- a/__tests__/lib/job-config.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - getKindDefault, - resolveJobConfig, - mapBudgetToEffort, -} from '@/lib/job-config' - -describe('mapBudgetToEffort', () => { - it.each([ - [0, null], - [-1, null], - [1, 'medium'], - [3000, 'medium'], - [6000, 'medium'], - [6001, 'high'], - [9000, 'high'], - [12000, 'high'], - [12001, 'xhigh'], - [18000, 'xhigh'], - [24000, 'xhigh'], - [24001, 'max'], - [50000, 'max'], - [100000, 'max'], - ])('budget %i → %s', (budget, expected) => { - expect(mapBudgetToEffort(budget)).toBe(expected) - }) -}) - -describe('KIND_DEFAULTS.allowed_tools — sync met scrum4me-mcp', () => { - it('TASK_IMPLEMENTATION bevat geen claim-tools', () => { - const cfg = getKindDefault('TASK_IMPLEMENTATION') - expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job') - expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__check_queue_empty') - expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__get_idea_context') - }) - - it('TASK_IMPLEMENTATION bevat de essentiële task-tools', () => { - const cfg = getKindDefault('TASK_IMPLEMENTATION') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_status') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_task_against_plan') - expect(cfg.allowed_tools).toContain('Bash') - expect(cfg.allowed_tools).toContain('Edit') - expect(cfg.allowed_tools).toContain('Write') - }) - - it('SPRINT_IMPLEMENTATION bevat sprint-specifieke tools maar GEEN job_heartbeat (runner doet die)', () => { - const cfg = getKindDefault('SPRINT_IMPLEMENTATION') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_task_execution') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__verify_sprint_task') - expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__job_heartbeat') - }) - - it('IDEA_GRILL bevat update_idea_grill_md en geen wait_for_job', () => { - const cfg = getKindDefault('IDEA_GRILL') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_grill_md') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_job_status') - expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job') - }) - - it('IDEA_MAKE_PLAN bevat update_idea_plan_md en geen wait_for_job', () => { - const cfg = getKindDefault('IDEA_MAKE_PLAN') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__update_idea_plan_md') - expect(cfg.allowed_tools).toContain('mcp__scrum4me__log_idea_decision') - expect(cfg.allowed_tools).not.toContain('mcp__scrum4me__wait_for_job') - }) - - it('alle kinds hebben non-null allowed_tools', () => { - for (const kind of [ - 'IDEA_GRILL', - 'IDEA_MAKE_PLAN', - 'PLAN_CHAT', - 'TASK_IMPLEMENTATION', - 'SPRINT_IMPLEMENTATION', - ]) { - const cfg = getKindDefault(kind) - expect(cfg.allowed_tools).not.toBeNull() - expect(Array.isArray(cfg.allowed_tools)).toBe(true) - } - }) -}) - -describe('resolveJobConfig — cascade (regression)', () => { - it('task.requires_opus overrult product.preferred_model', () => { - const cfg = resolveJobConfig( - { kind: 'TASK_IMPLEMENTATION' }, - { preferred_model: 'claude-sonnet-4-6' }, - { requires_opus: true }, - ) - expect(cfg.model).toBe('claude-opus-4-7') - }) - - it('product.preferred_permission_mode overrult bypassPermissions', () => { - const cfg = resolveJobConfig( - { kind: 'TASK_IMPLEMENTATION' }, - { preferred_permission_mode: 'acceptEdits' }, - ) - expect(cfg.permission_mode).toBe('acceptEdits') - }) -}) diff --git a/__tests__/lib/jobs-time-filter.test.ts b/__tests__/lib/jobs-time-filter.test.ts deleted file mode 100644 index 3e1be4b..0000000 --- a/__tests__/lib/jobs-time-filter.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { isWithinTimeWindow } from '@/lib/jobs-time-filter' - -const HOUR_MS = 60 * 60 * 1000 - -describe('isWithinTimeWindow', () => { - it("returns true for filter='all' regardless of age", () => { - const old = new Date(0) - expect(isWithinTimeWindow(old, 'all')).toBe(true) - }) - - describe("filter='1h'", () => { - const now = Date.now() - - it('returns true for a job created 30 minutes ago', () => { - const createdAt = new Date(now - 30 * 60 * 1000) - expect(isWithinTimeWindow(createdAt, '1h', now)).toBe(true) - }) - - it('returns false for a job created 90 minutes ago', () => { - const createdAt = new Date(now - 90 * 60 * 1000) - expect(isWithinTimeWindow(createdAt, '1h', now)).toBe(false) - }) - }) - - describe("filter='24h'", () => { - const now = Date.now() - - it('returns true for a job created 23 hours ago', () => { - const createdAt = new Date(now - 23 * HOUR_MS) - expect(isWithinTimeWindow(createdAt, '24h', now)).toBe(true) - }) - - it('returns false for a job created 25 hours ago', () => { - const createdAt = new Date(now - 25 * HOUR_MS) - expect(isWithinTimeWindow(createdAt, '24h', now)).toBe(false) - }) - }) - - describe('accepts both Date and ISO string for createdAt', () => { - const now = Date.now() - const recent = new Date(now - 30 * 60 * 1000) - - it('accepts a Date object', () => { - expect(isWithinTimeWindow(recent, '1h', now)).toBe(true) - }) - - it('accepts an ISO string', () => { - expect(isWithinTimeWindow(recent.toISOString(), '1h', now)).toBe(true) - }) - }) - - it('returns true for an invalid date string (fail-open)', () => { - expect(isWithinTimeWindow('not-a-date', '1h')).toBe(true) - }) -}) diff --git a/__tests__/lib/product-switch-path.test.ts b/__tests__/lib/product-switch-path.test.ts deleted file mode 100644 index 02983e9..0000000 --- a/__tests__/lib/product-switch-path.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { resolveProductSwitchTarget } from '@/lib/product-switch-path' - -describe('resolveProductSwitchTarget', () => { - it('returns null for non-product pages', () => { - expect(resolveProductSwitchTarget('/dashboard', 'new-id')).toBeNull() - expect(resolveProductSwitchTarget('/insights', 'new-id')).toBeNull() - expect(resolveProductSwitchTarget('/ideas', 'new-id')).toBeNull() - expect(resolveProductSwitchTarget('/jobs', 'new-id')).toBeNull() - expect(resolveProductSwitchTarget('/', 'new-id')).toBeNull() - }) - - it('maps /products/ to /products/', () => { - expect(resolveProductSwitchTarget('/products/old-id', 'new-id')).toBe('/products/new-id') - }) - - it('maps /products// to /products/', () => { - expect(resolveProductSwitchTarget('/products/old-id/', 'new-id')).toBe('/products/new-id') - }) - - it('maps /products//sprint to /products//sprint', () => { - expect(resolveProductSwitchTarget('/products/old-id/sprint', 'new-id')).toBe( - '/products/new-id/sprint', - ) - }) - - it('maps /products//sprint/ to /products//sprint', () => { - expect(resolveProductSwitchTarget('/products/old-id/sprint/abc123', 'new-id')).toBe( - '/products/new-id/sprint', - ) - }) - - it('maps /products//sprint/.../planning to /products//sprint', () => { - expect(resolveProductSwitchTarget('/products/old-id/sprint/abc123/planning', 'new-id')).toBe( - '/products/new-id/sprint', - ) - }) - - it('maps /products//solo to /products//solo', () => { - expect(resolveProductSwitchTarget('/products/old-id/solo', 'new-id')).toBe( - '/products/new-id/solo', - ) - }) - - it('falls back to /products/ for /products//settings', () => { - expect(resolveProductSwitchTarget('/products/old-id/settings', 'new-id')).toBe( - '/products/new-id', - ) - }) - - it('falls back to /products/ for unknown sub-segments', () => { - expect(resolveProductSwitchTarget('/products/old-id/unknown/deep', 'new-id')).toBe( - '/products/new-id', - ) - }) -}) diff --git a/__tests__/lib/sprint-conflicts.test.ts b/__tests__/lib/sprint-conflicts.test.ts deleted file mode 100644 index bf6edbe..0000000 --- a/__tests__/lib/sprint-conflicts.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import type { StoryStatus } from '@prisma/client' - -import { - getBlockingSprintMap, - isEligibleForSprint, - partitionByEligibility, -} from '@/lib/sprint-conflicts' - -function mockPrisma(stories: Array>) { - return { - story: { - findMany: vi.fn().mockResolvedValue(stories), - }, - } as unknown as Parameters[0] -} - -describe('isEligibleForSprint', () => { - it('returns true for OPEN story without sprint', () => { - expect( - isEligibleForSprint({ sprint_id: null, status: 'OPEN' as StoryStatus }), - ).toBe(true) - }) - - it('returns true for IN_SPRINT story without sprint_id (edge: restoration)', () => { - expect( - isEligibleForSprint({ - sprint_id: null, - status: 'IN_SPRINT' as StoryStatus, - }), - ).toBe(true) - }) - - it('returns false for DONE story without sprint', () => { - expect( - isEligibleForSprint({ sprint_id: null, status: 'DONE' as StoryStatus }), - ).toBe(false) - }) - - it('returns false when story is in an OPEN sprint', () => { - expect( - isEligibleForSprint({ - sprint_id: 'abc', - status: 'IN_SPRINT' as StoryStatus, - sprint: { status: 'OPEN' }, - }), - ).toBe(false) - }) - - it('returns false when story is DONE (sprint_id irrelevant)', () => { - expect( - isEligibleForSprint({ - sprint_id: 'abc', - status: 'DONE' as StoryStatus, - sprint: { status: 'CLOSED' }, - }), - ).toBe(false) - }) - - it('returns true when story is in a CLOSED sprint (released back to planning)', () => { - expect( - isEligibleForSprint({ - sprint_id: 'abc', - status: 'IN_SPRINT' as StoryStatus, - sprint: { status: 'CLOSED' }, - }), - ).toBe(true) - }) - - it('returns true when story is in an ARCHIVED sprint', () => { - expect( - isEligibleForSprint({ - sprint_id: 'abc', - status: 'IN_SPRINT' as StoryStatus, - sprint: { status: 'ARCHIVED' }, - }), - ).toBe(true) - }) - - it('returns true when story is in a FAILED sprint', () => { - expect( - isEligibleForSprint({ - sprint_id: 'abc', - status: 'IN_SPRINT' as StoryStatus, - sprint: { status: 'FAILED' }, - }), - ).toBe(true) - }) - - it('returns false when sprint_id is set but sprint relation is missing (defensive)', () => { - // Zonder sprint-data weten we niet of die OPEN is, dus blijven we - // conservatief — niet eligible. - expect( - isEligibleForSprint({ - sprint_id: 'abc', - status: 'IN_SPRINT' as StoryStatus, - }), - ).toBe(false) - }) -}) - -describe('partitionByEligibility', () => { - it('returns empty partition for empty input', async () => { - const prisma = mockPrisma([]) - const result = await partitionByEligibility(prisma, []) - expect(result).toEqual({ eligible: [], notEligible: [], crossSprint: [] }) - }) - - it('classifies all eligible when stories are free + OPEN', async () => { - const prisma = mockPrisma([ - { id: 's1', sprint_id: null, status: 'OPEN', sprint: null }, - { id: 's2', sprint_id: null, status: 'IN_SPRINT', sprint: null }, - ]) - const result = await partitionByEligibility(prisma, ['s1', 's2']) - expect(result.eligible).toEqual(['s1', 's2']) - expect(result.notEligible).toEqual([]) - expect(result.crossSprint).toEqual([]) - }) - - it('marks DONE stories as notEligible with reason=DONE', async () => { - const prisma = mockPrisma([ - { id: 's1', sprint_id: null, status: 'DONE', sprint: null }, - ]) - const result = await partitionByEligibility(prisma, ['s1']) - expect(result.eligible).toEqual([]) - expect(result.notEligible).toEqual([{ storyId: 's1', reason: 'DONE' }]) - }) - - it('marks stories in other OPEN sprint as crossSprint + notEligible', async () => { - const prisma = mockPrisma([ - { - id: 's1', - sprint_id: 'sprint-other', - status: 'IN_SPRINT', - sprint: { id: 'sprint-other', code: 'SP-2', status: 'OPEN' }, - }, - ]) - const result = await partitionByEligibility(prisma, ['s1']) - expect(result.crossSprint).toEqual([ - { storyId: 's1', sprintId: 'sprint-other', sprintName: 'SP-2' }, - ]) - expect(result.notEligible).toEqual([ - { storyId: 's1', reason: 'IN_OTHER_SPRINT' }, - ]) - expect(result.eligible).toEqual([]) - }) - - it('classifies story in CLOSED sprint with status=OPEN as eligible (status reset already happened)', async () => { - const prisma = mockPrisma([ - { - id: 's1', - sprint_id: null, - status: 'OPEN', - sprint: null, - }, - ]) - const result = await partitionByEligibility(prisma, ['s1']) - expect(result.eligible).toEqual(['s1']) - }) - - it('frees stories from a CLOSED sprint — they become eligible again', async () => { - const prisma = mockPrisma([ - { - id: 's1', - sprint_id: 'sprint-closed', - status: 'IN_SPRINT', - sprint: { id: 'sprint-closed', code: 'SP-C', status: 'CLOSED' }, - }, - ]) - const result = await partitionByEligibility(prisma, ['s1']) - expect(result.eligible).toEqual(['s1']) - expect(result.crossSprint).toEqual([]) - expect(result.notEligible).toEqual([]) - }) - - it('frees stories from ARCHIVED and FAILED sprints', async () => { - const prisma = mockPrisma([ - { - id: 's1', - sprint_id: 'sprint-arch', - status: 'IN_SPRINT', - sprint: { id: 'sprint-arch', code: 'SP-A', status: 'ARCHIVED' }, - }, - { - id: 's2', - sprint_id: 'sprint-fail', - status: 'IN_SPRINT', - sprint: { id: 'sprint-fail', code: 'SP-F', status: 'FAILED' }, - }, - ]) - const result = await partitionByEligibility(prisma, ['s1', 's2']) - expect(result.eligible).toEqual(['s1', 's2']) - expect(result.notEligible).toEqual([]) - }) - - it('a DONE story in a CLOSED sprint is notEligible because DONE (sprint inactive)', async () => { - // Volgorde: niet-actieve sprint blokkeert niet meer, dus de DONE-check - // bepaalt de reason. Vroeger werd dit 'IN_OTHER_SPRINT' — dat was misleidend - // omdat de sprint helemaal niet meer actief was. - const prisma = mockPrisma([ - { - id: 's1', - sprint_id: 'sprint-closed', - status: 'DONE', - sprint: { id: 'sprint-closed', code: 'SP-C', status: 'CLOSED' }, - }, - ]) - const result = await partitionByEligibility(prisma, ['s1']) - expect(result.crossSprint).toEqual([]) - expect(result.notEligible).toEqual([{ storyId: 's1', reason: 'DONE' }]) - expect(result.eligible).toEqual([]) - }) - - it('respects excludeSprintId — story in same sprint is eligible', async () => { - const prisma = mockPrisma([ - { - id: 's1', - sprint_id: 'sprint-active', - status: 'IN_SPRINT', - sprint: { id: 'sprint-active', code: 'SP-A', status: 'OPEN' }, - }, - ]) - const result = await partitionByEligibility(prisma, ['s1'], 'sprint-active') - expect(result.eligible).toEqual(['s1']) - expect(result.crossSprint).toEqual([]) - }) -}) - -describe('getBlockingSprintMap', () => { - it('returns empty map for empty input', async () => { - const prisma = mockPrisma([]) - const result = await getBlockingSprintMap(prisma, 'p1', []) - expect(result.size).toBe(0) - }) - - it('returns blocking sprint info for stories in OPEN sprints', async () => { - const prisma = mockPrisma([ - { - id: 's1', - sprint_id: 'sprint-x', - sprint: { id: 'sprint-x', code: 'SP-X', status: 'OPEN' }, - }, - ]) - const result = await getBlockingSprintMap(prisma, 'p1', ['s1']) - expect(result.get('s1')).toEqual({ - sprintId: 'sprint-x', - sprintName: 'SP-X', - }) - }) - - it('excludes the active sprint from blocking', async () => { - const prisma = mockPrisma([ - { - id: 's1', - sprint_id: 'sprint-active', - sprint: { id: 'sprint-active', code: 'SP-A', status: 'OPEN' }, - }, - ]) - const result = await getBlockingSprintMap( - prisma, - 'p1', - ['s1'], - 'sprint-active', - ) - expect(result.size).toBe(0) - }) - - it('does not include CLOSED sprints (filtered at DB query level)', async () => { - // The prisma mock receives WHERE sprint.status='OPEN' so CLOSED stories - // are already filtered out before reaching this function's mapping logic. - const prisma = mockPrisma([]) - const result = await getBlockingSprintMap(prisma, 'p1', ['s1']) - expect(result.size).toBe(0) - }) -}) diff --git a/__tests__/lib/user-settings-migration.test.ts b/__tests__/lib/user-settings-migration.test.ts deleted file mode 100644 index 38346b4..0000000 --- a/__tests__/lib/user-settings-migration.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { - buildMigrationPatch, - clearLegacyStorage, -} from '@/lib/user-settings-migration' - -function clearAllCookies() { - for (const part of document.cookie.split(';')) { - const eq = part.indexOf('=') - const name = (eq < 0 ? part : part.slice(0, eq)).trim() - if (name) document.cookie = `${name}=; max-age=0; path=/` - } -} - -beforeEach(() => { - localStorage.clear() - clearAllCookies() -}) - -afterEach(() => { - localStorage.clear() - clearAllCookies() -}) - -describe('buildMigrationPatch', () => { - it('returns no data when nothing is stored', () => { - const result = buildMigrationPatch() - expect(result.hasData).toBe(false) - expect(result.patch).toEqual({}) - expect(result.legacyKeys).toEqual([]) - }) - - it('skips after marker is set to current version', () => { - localStorage.setItem('scrum4me:sprint_pb_filter_status', 'all') - localStorage.setItem('scrum4me:settings_migrated', 'v2') - const result = buildMigrationPatch() - expect(result.hasData).toBe(false) - }) - - it('still runs when only the v1 marker is set (re-migration)', () => { - localStorage.setItem('scrum4me:sprint_pb_filter_status', 'all') - localStorage.setItem('scrum4me:settings_migrated', 'v1') - const result = buildMigrationPatch() - expect(result.hasData).toBe(true) - }) - - it('extracts split-pane cookies into layout', () => { - document.cookie = `sp:backlog-p1=${encodeURIComponent(JSON.stringify([25, 35, 40]))}; path=/` - const result = buildMigrationPatch() - expect(result.patch.layout?.splitPanePositions).toEqual({ 'backlog-p1': [25, 35, 40] }) - expect(result.legacyCookies).toContain('sp:backlog-p1') - }) - - it('ignores split-pane cookies that do not sum to 100', () => { - document.cookie = `sp:bad=${encodeURIComponent(JSON.stringify([10, 20]))}; path=/` - const result = buildMigrationPatch() - expect(result.patch.layout).toBeUndefined() - }) - - it('extracts active-sprint cookies into layout.activeSprints', () => { - document.cookie = `active_sprint_prod-1=sprint-abc; path=/` - document.cookie = `active_sprint_prod-2=sprint-xyz; path=/` - const result = buildMigrationPatch() - expect(result.patch.layout?.activeSprints).toEqual({ - 'prod-1': 'sprint-abc', - 'prod-2': 'sprint-xyz', - }) - expect(result.legacyCookies).toContain('active_sprint_prod-1') - }) - - it('extracts sprint backlog prefs into nested patch', () => { - localStorage.setItem('scrum4me:sprint_pb_filter_status', 'all') - localStorage.setItem('scrum4me:sprint_pb_sort', 'priority') - localStorage.setItem('scrum4me:sprint_pb_sort_dir', 'desc') - localStorage.setItem('scrum4me:sprint_pb_collapsed', JSON.stringify(['pbi-1', 'pbi-2'])) - localStorage.setItem('scrum4me:sprint_pb_filter_popover_open', 'true') - - const result = buildMigrationPatch() - - expect(result.hasData).toBe(true) - expect(result.patch.views?.sprintBacklog).toEqual({ - filterStatus: 'all', - sort: 'priority', - sortDir: 'desc', - collapsedPbis: ['pbi-1', 'pbi-2'], - filterPopoverOpen: true, - }) - expect(result.legacyKeys).toContain('scrum4me:sprint_pb_filter_status') - expect(result.legacyKeys).toContain('scrum4me:sprint_pb_collapsed') - }) - - it('extracts pbi-list prefs', () => { - localStorage.setItem('scrum4me:pbi_sort', 'date') - localStorage.setItem('scrum4me:pbi_filter_priority', '2') - - const result = buildMigrationPatch() - expect(result.patch.views?.pbiList).toEqual({ sort: 'date', filterPriority: 2 }) - }) - - it('extracts story_sort', () => { - localStorage.setItem('scrum4me:story_sort', 'code') - const result = buildMigrationPatch() - expect(result.patch.views?.storyPanel).toEqual({ sort: 'code' }) - }) - - it('extracts debug-mode', () => { - localStorage.setItem('scrum4me:debug-mode', 'true') - const result = buildMigrationPatch() - expect(result.patch.devTools).toEqual({ debugMode: true }) - }) - - it('extracts jobs-column dynamic prefixes from CSV values', () => { - localStorage.setItem('queue_filter_kind', 'TASK_IMPLEMENTATION,SPRINT_IMPLEMENTATION') - localStorage.setItem('queue_filter_status', 'queued,running') - - const result = buildMigrationPatch() - expect(result.patch.views?.jobsColumns?.['queue']).toEqual({ - kinds: ['TASK_IMPLEMENTATION', 'SPRINT_IMPLEMENTATION'], - statuses: ['queued', 'running'], - }) - }) - - it('ignores invalid enum values', () => { - localStorage.setItem('scrum4me:sprint_pb_filter_status', 'BOGUS') - const result = buildMigrationPatch() - expect(result.hasData).toBe(false) - }) -}) - -describe('clearLegacyStorage', () => { - it('removes given keys and cookies and sets the v2 marker', () => { - localStorage.setItem('scrum4me:sprint_pb_sort', 'code') - document.cookie = 'sp:x=foo; path=/' - - clearLegacyStorage(['scrum4me:sprint_pb_sort'], ['sp:x']) - - expect(localStorage.getItem('scrum4me:sprint_pb_sort')).toBeNull() - expect(document.cookie).not.toContain('sp:x=foo') - expect(localStorage.getItem('scrum4me:settings_migrated')).toBe('v2') - }) - - it('sets marker even with empty lists (no-op migration)', () => { - clearLegacyStorage([], []) - expect(localStorage.getItem('scrum4me:settings_migrated')).toBe('v2') - }) -}) diff --git a/__tests__/lib/user-settings.test.ts b/__tests__/lib/user-settings.test.ts deleted file mode 100644 index 2e694d7..0000000 --- a/__tests__/lib/user-settings.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - DEFAULT_USER_SETTINGS, - UserSettingsSchema, - mergeSettings, - parseUserSettings, - type UserSettings, -} from '@/lib/user-settings' - -describe('mergeSettings', () => { - it('returns the patch when previous is empty', () => { - const result = mergeSettings({}, { views: { sprintBacklog: { sort: 'code' } } }) - expect(result).toEqual({ views: { sprintBacklog: { sort: 'code' } } }) - }) - - it('preserves existing keys when patch only sets new ones', () => { - const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } } - const result = mergeSettings(prev, { - views: { pbiList: { sort: 'date' } }, - }) - expect(result).toEqual({ - views: { - sprintBacklog: { sort: 'code' }, - pbiList: { sort: 'date' }, - }, - }) - }) - - it('merges nested objects without overwriting siblings', () => { - const prev: UserSettings = { - views: { sprintBacklog: { sort: 'code', sortDir: 'asc' } }, - } - const result = mergeSettings(prev, { - views: { sprintBacklog: { sort: 'priority' } }, - }) - expect(result).toEqual({ - views: { sprintBacklog: { sort: 'priority', sortDir: 'asc' } }, - }) - }) - - it('replaces arrays instead of appending', () => { - const prev: UserSettings = { - views: { sprintBacklog: { collapsedPbis: ['a', 'b'] } }, - } - const result = mergeSettings(prev, { - views: { sprintBacklog: { collapsedPbis: ['c'] } }, - }) - expect(result.views?.sprintBacklog?.collapsedPbis).toEqual(['c']) - }) - - it('does not mutate the previous object', () => { - const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } } - const snapshot = JSON.parse(JSON.stringify(prev)) - mergeSettings(prev, { views: { sprintBacklog: { sortDir: 'desc' } } }) - expect(prev).toEqual(snapshot) - }) - - it('skips undefined values in the patch', () => { - const prev: UserSettings = { views: { sprintBacklog: { sort: 'code' } } } - const result = mergeSettings(prev, { views: undefined }) - expect(result).toEqual(prev) - }) -}) - -describe('parseUserSettings', () => { - it('returns defaults for null', () => { - expect(parseUserSettings(null)).toEqual(DEFAULT_USER_SETTINGS) - }) - - it('returns defaults for undefined', () => { - expect(parseUserSettings(undefined)).toEqual(DEFAULT_USER_SETTINGS) - }) - - it('returns defaults for invalid input', () => { - expect(parseUserSettings({ views: { sprintBacklog: { filterStatus: 'BOGUS' } } })) - .toEqual(DEFAULT_USER_SETTINGS) - }) - - it('passes valid settings through', () => { - const valid = { views: { sprintBacklog: { sort: 'code' as const } } } - expect(parseUserSettings(valid)).toEqual(valid) - }) -}) - -describe('UserSettingsSchema', () => { - it('rejects unknown top-level keys', () => { - const result = UserSettingsSchema.safeParse({ unknown: 1 }) - expect(result.success).toBe(false) - }) - - it('accepts an empty object', () => { - expect(UserSettingsSchema.safeParse({}).success).toBe(true) - }) - - it('accepts the full shape', () => { - const result = UserSettingsSchema.safeParse({ - views: { - sprintBacklog: { - filterPriority: 1, - filterStatus: 'OPEN', - sort: 'code', - sortDir: 'asc', - collapsedPbis: ['x'], - filterPopoverOpen: true, - }, - pbiList: { sort: 'priority', filterPriority: 'all', filterStatus: 'ready', sortDir: 'desc' }, - storyPanel: { sort: 'date' }, - jobsColumns: { 'queue:active': { kinds: ['TASK_IMPLEMENTATION'], statuses: [] } }, - jobs: { timeFilter: '24h' }, - ideasList: { filterStatuses: ['draft', 'planned'] }, - }, - devTools: { debugMode: true }, - layout: { - splitPanePositions: { 'backlog-pid': [25, 35, 40] }, - activeSprints: { 'product-1': 'sprint-1' }, - }, - }) - expect(result.success).toBe(true) - }) - - it('accepts views.jobs.timeFilter and returns it via parseUserSettings', () => { - const input = { views: { jobs: { timeFilter: '1h' as const } } } - const result = parseUserSettings(input) - expect(result).toEqual(input) - }) - - it('rejects an invalid views.jobs.timeFilter value', () => { - const result = UserSettingsSchema.safeParse({ views: { jobs: { timeFilter: 'BOGUS' } } }) - expect(result.success).toBe(false) - }) - - it('accepts layout-only settings', () => { - expect(UserSettingsSchema.safeParse({ - layout: { splitPanePositions: { x: [50, 50] }, activeSprints: { p: 's' } }, - }).success).toBe(true) - }) - - it('accepts null values in activeSprints (explicit "no active sprint")', () => { - const result = UserSettingsSchema.safeParse({ - layout: { activeSprints: { 'product-1': null, 'product-2': 'sprint-2' } }, - }) - expect(result.success).toBe(true) - if (result.success) { - expect(result.data.layout?.activeSprints).toEqual({ - 'product-1': null, - 'product-2': 'sprint-2', - }) - } - }) - - it('accepts pendingSprintDraft with per-PBI intent and overrides', () => { - const result = UserSettingsSchema.safeParse({ - workflow: { - pendingSprintDraft: { - 'product-1': { - goal: 'Sprint goal', - pbiIntent: { pbiA: 'all', pbiB: 'none' }, - storyOverrides: { - pbiA: { add: [], remove: ['story-1'] }, - pbiB: { add: ['story-2'], remove: [] }, - }, - }, - }, - }, - }) - expect(result.success).toBe(true) - }) - - it('fills empty defaults for pbiIntent and storyOverrides in draft', () => { - const result = UserSettingsSchema.safeParse({ - workflow: { pendingSprintDraft: { 'product-1': { goal: 'g' } } }, - }) - expect(result.success).toBe(true) - if (result.success) { - const draft = result.data.workflow?.pendingSprintDraft?.['product-1'] - expect(draft?.pbiIntent).toEqual({}) - expect(draft?.storyOverrides).toEqual({}) - } - }) - - it('rejects pendingSprintDraft with empty goal', () => { - const result = UserSettingsSchema.safeParse({ - workflow: { pendingSprintDraft: { 'p': { goal: '' } } }, - }) - expect(result.success).toBe(false) - }) - - it('rejects an invalid ideasList.filterStatuses value', () => { - const result = UserSettingsSchema.safeParse({ views: { ideasList: { filterStatuses: ['BOGUS'] } } }) - expect(result.success).toBe(false) - }) - - it('accepts an empty ideasList.filterStatuses array', () => { - const result = UserSettingsSchema.safeParse({ views: { ideasList: { filterStatuses: [] } } }) - expect(result.success).toBe(true) - }) - - it('rejects unknown intent value', () => { - const result = UserSettingsSchema.safeParse({ - workflow: { - pendingSprintDraft: { - p: { goal: 'x', pbiIntent: { a: 'partial' } }, - }, - }, - }) - expect(result.success).toBe(false) - }) -}) diff --git a/__tests__/realtime/payload-contract.test.ts b/__tests__/realtime/payload-contract.test.ts new file mode 100644 index 0000000..b36bc09 --- /dev/null +++ b/__tests__/realtime/payload-contract.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useBacklogStore } from '@/stores/backlog-store' +import type { BacklogPbi, BacklogStory, BacklogTask } from '@/stores/backlog-store' + +const PBI: BacklogPbi = { + id: 'pbi-1', + code: 'PBI-1', + title: 'Realtime PBI', + priority: 2, + description: 'desc', + created_at: new Date('2024-01-01T00:00:00Z'), + status: 'ready', +} + +const STORY: BacklogStory = { + id: 'story-1', + code: 'ST-1', + title: 'Realtime story', + description: null, + acceptance_criteria: null, + priority: 2, + status: 'OPEN', + pbi_id: 'pbi-1', + created_at: new Date('2024-01-01T00:00:00Z'), +} + +const TASK: BacklogTask = { + id: 'task-1', + title: 'Realtime task', + description: null, + priority: 2, + status: 'TO_DO', + sort_order: 1, + story_id: 'story-1', + created_at: new Date('2024-01-01T00:00:00Z'), +} + +beforeEach(() => { + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} }) +}) + +// --------------------------------------------------------------------------- +// PBI +// --------------------------------------------------------------------------- + +describe('PBI payload contract', () => { + it('INSERT: entity appears in pbis with correct title and status', () => { + useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI }) + const state = useBacklogStore.getState() + expect(state.pbis).toHaveLength(1) + expect(state.pbis[0].id).toBe('pbi-1') + expect(state.pbis[0].title).toBe('Realtime PBI') + expect(state.pbis[0].status).toBe('ready') + }) + + it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => { + useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI }) + useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI }) + expect(useBacklogStore.getState().pbis).toHaveLength(1) + }) + + it('UPDATE: changed_fields partial merges into existing entity', () => { + useBacklogStore.setState({ pbis: [{ ...PBI }], storiesByPbi: {}, tasksByStory: {} }) + useBacklogStore.getState().applyChange('pbi', 'U', { id: 'pbi-1', title: 'Updated PBI', status: 'in_sprint' as const }) + const pbi = useBacklogStore.getState().pbis[0] + expect(pbi.title).toBe('Updated PBI') + expect(pbi.status).toBe('in_sprint') + expect(pbi.priority).toBe(2) // unchanged field retained + }) + + it('DELETE: entity is removed from pbis', () => { + useBacklogStore.setState({ pbis: [{ ...PBI }], storiesByPbi: {}, tasksByStory: {} }) + useBacklogStore.getState().applyChange('pbi', 'D', { id: 'pbi-1' }) + expect(useBacklogStore.getState().pbis).toHaveLength(0) + }) +}) + +// --------------------------------------------------------------------------- +// Story +// --------------------------------------------------------------------------- + +describe('Story payload contract', () => { + it('INSERT: entity appears in storiesByPbi[pbi_id] with correct title and status', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} }) + useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) + const bucket = useBacklogStore.getState().storiesByPbi['pbi-1'] + expect(bucket).toHaveLength(1) + expect(bucket[0].id).toBe('story-1') + expect(bucket[0].title).toBe('Realtime story') + expect(bucket[0].status).toBe('OPEN') + }) + + it('INSERT: creates bucket when pbi_id was not yet in storiesByPbi', () => { + useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) + expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1) + }) + + it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => { + useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) + useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) + expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1) + }) + + it('UPDATE: changed_fields partial merges into existing story', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [{ ...STORY }] }, tasksByStory: {} }) + useBacklogStore.getState().applyChange('story', 'U', { id: 'story-1', title: 'Updated story', status: 'IN_SPRINT' }) + const story = useBacklogStore.getState().storiesByPbi['pbi-1'][0] + expect(story.title).toBe('Updated story') + expect(story.status).toBe('IN_SPRINT') + expect(story.priority).toBe(2) // unchanged field retained + }) + + it('DELETE: entity is removed from its pbi bucket', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [{ ...STORY }] }, tasksByStory: {} }) + useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' }) + expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0) + }) +}) + +// --------------------------------------------------------------------------- +// Task +// --------------------------------------------------------------------------- + +describe('Task payload contract', () => { + it('INSERT: entity appears in tasksByStory[story_id] with correct title and status', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [] } }) + useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) + const bucket = useBacklogStore.getState().tasksByStory['story-1'] + expect(bucket).toHaveLength(1) + expect(bucket[0].id).toBe('task-1') + expect(bucket[0].title).toBe('Realtime task') + expect(bucket[0].status).toBe('TO_DO') + }) + + it('INSERT: creates bucket when story_id was not yet in tasksByStory', () => { + useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) + expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(1) + }) + + it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => { + useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) + useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) + expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(1) + }) + + it('UPDATE: changed_fields partial merges into existing task', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [{ ...TASK }] } }) + useBacklogStore.getState().applyChange('task', 'U', { id: 'task-1', title: 'Updated task', status: 'IN_PROGRESS' }) + const task = useBacklogStore.getState().tasksByStory['story-1'][0] + expect(task.title).toBe('Updated task') + expect(task.status).toBe('IN_PROGRESS') + expect(task.sort_order).toBe(1) // unchanged field retained + }) + + it('DELETE: entity is removed from its story bucket', () => { + useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [{ ...TASK }] } }) + useBacklogStore.getState().applyChange('task', 'D', { id: 'task-1' }) + expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(0) + }) +}) diff --git a/__tests__/realtime/use-workspace-resync.test.tsx b/__tests__/realtime/use-workspace-resync.test.tsx deleted file mode 100644 index cbc50a5..0000000 --- a/__tests__/realtime/use-workspace-resync.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -// @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { renderHook } from '@testing-library/react' - -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import { useWorkspaceResync } from '@/lib/realtime/use-workspace-resync' - -let resyncSpy: ReturnType - -beforeEach(() => { - resyncSpy = vi.fn().mockResolvedValue(undefined) - useProductWorkspaceStore.setState((s) => { - s.resyncActiveScopes = resyncSpy as unknown as typeof s.resyncActiveScopes - }) - // visibilitychange handler leest document.visibilityState — default is 'visible' - Object.defineProperty(document, 'visibilityState', { - value: 'visible', - writable: true, - configurable: true, - }) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('useWorkspaceResync', () => { - it('triggert resyncActiveScopes("visible") op visibilitychange hidden→visible', () => { - renderHook(() => useWorkspaceResync()) - - Object.defineProperty(document, 'visibilityState', { - value: 'visible', - writable: true, - configurable: true, - }) - document.dispatchEvent(new Event('visibilitychange')) - - expect(resyncSpy).toHaveBeenCalledWith('visible') - }) - - it('triggert resyncActiveScopes("reconnect") op online-event', () => { - renderHook(() => useWorkspaceResync()) - window.dispatchEvent(new Event('online')) - expect(resyncSpy).toHaveBeenCalledWith('reconnect') - }) - - it('triggert geen resync bij visibilitychange naar hidden', () => { - renderHook(() => useWorkspaceResync()) - - Object.defineProperty(document, 'visibilityState', { - value: 'hidden', - writable: true, - configurable: true, - }) - document.dispatchEvent(new Event('visibilitychange')) - - expect(resyncSpy).not.toHaveBeenCalled() - }) - - it('cleanup verwijdert listeners bij unmount', () => { - const { unmount } = renderHook(() => useWorkspaceResync()) - unmount() - - window.dispatchEvent(new Event('online')) - document.dispatchEvent(new Event('visibilitychange')) - - expect(resyncSpy).not.toHaveBeenCalled() - }) -}) diff --git a/__tests__/review-plan-job.test.ts b/__tests__/review-plan-job.test.ts deleted file mode 100644 index 2b298dc..0000000 --- a/__tests__/review-plan-job.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { describe, it, expect } from 'vitest' - -/** - * Review-Plan Job Tests - * - * Tests for the IDEA_REVIEW_PLAN job kind and review-log schema validation. - */ - -// Sample review-log structure for testing -const sampleReviewLog = { - plan_file: 'I-042', - created_at: new Date().toISOString(), - rounds: [ - { - round: 0, - model: 'claude-3-5-haiku', - role: 'Structure Review', - focus: 'YAML parsing, format, syntax', - plan_before: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---', - plan_after: - '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n priority: 2\n---', - issues: [ - { - category: 'structure', - severity: 'warning', - suggestion: 'Add priority field to story', - }, - ], - score: 75, - plan_diff_lines: 1, - converged: false, - timestamp: new Date().toISOString(), - }, - { - round: 1, - model: 'claude-3-5-sonnet', - role: 'Logic & Patterns', - focus: 'Logic gaps, missing patterns, architecture fit', - plan_before: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---', - plan_after: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---', - issues: [ - { - category: 'logic', - severity: 'info', - suggestion: 'Consider adding acceptance criteria', - }, - ], - score: 80, - plan_diff_lines: 0, - converged: false, - timestamp: new Date().toISOString(), - }, - { - round: 2, - model: 'claude-opus-4-7', - role: 'Risk Assessment', - focus: 'Risk assessment, edge cases, refactoring', - plan_before: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---', - plan_after: '---\npbi:\n title: "Test PBI"\nstories:\n - title: "Story 1"\n---', - issues: [], - score: 85, - plan_diff_lines: 0, - converged: true, - timestamp: new Date().toISOString(), - }, - ], - convergence: { - stable_at_round: 2, - final_diff_pct: 0.5, - convergence_metric: 'plan_stability', - }, - approval: { - status: 'approved', - timestamp: new Date().toISOString(), - }, - summary: 'Plan reviewed across three rounds. Minor structure improvements suggested. Plan approved.', -} - -describe('review-plan-job', () => { - describe('ReviewLog Schema', () => { - it('should have required top-level fields', () => { - expect(sampleReviewLog).toHaveProperty('plan_file') - expect(sampleReviewLog).toHaveProperty('created_at') - expect(sampleReviewLog).toHaveProperty('rounds') - expect(sampleReviewLog).toHaveProperty('convergence') - expect(sampleReviewLog).toHaveProperty('approval') - expect(sampleReviewLog).toHaveProperty('summary') - }) - - it('should have valid plan_file format', () => { - expect(typeof sampleReviewLog.plan_file).toBe('string') - expect(sampleReviewLog.plan_file.length).toBeGreaterThan(0) - }) - - it('should have valid ISO timestamps', () => { - const isoRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/ - expect(sampleReviewLog.created_at).toMatch(isoRegex) - expect(sampleReviewLog.approval.timestamp).toMatch(isoRegex) - }) - - it('should have at least one round', () => { - expect(sampleReviewLog.rounds.length).toBeGreaterThan(0) - }) - - it('should have valid round structure', () => { - for (const round of sampleReviewLog.rounds) { - expect(round).toHaveProperty('round') - expect(round).toHaveProperty('model') - expect(round).toHaveProperty('role') - expect(round).toHaveProperty('focus') - expect(round).toHaveProperty('plan_before') - expect(round).toHaveProperty('plan_after') - expect(round).toHaveProperty('issues') - expect(round).toHaveProperty('score') - expect(round).toHaveProperty('plan_diff_lines') - expect(round).toHaveProperty('converged') - expect(round).toHaveProperty('timestamp') - - expect(typeof round.round).toBe('number') - expect(round.round).toBeGreaterThanOrEqual(0) - expect(typeof round.score).toBe('number') - expect(round.score).toBeGreaterThanOrEqual(0) - expect(round.score).toBeLessThanOrEqual(100) - expect(typeof round.plan_diff_lines).toBe('number') - expect(round.plan_diff_lines).toBeGreaterThanOrEqual(0) - } - }) - - it('should have valid issue structure per round', () => { - for (const round of sampleReviewLog.rounds) { - for (const issue of round.issues) { - expect(issue).toHaveProperty('category') - expect(issue).toHaveProperty('severity') - expect(issue).toHaveProperty('suggestion') - - expect(['structure', 'logic', 'risk', 'pattern']).toContain(issue.category) - expect(['error', 'warning', 'info']).toContain(issue.severity) - expect(typeof issue.suggestion).toBe('string') - expect(issue.suggestion.length).toBeGreaterThan(0) - } - } - }) - - it('should have valid convergence structure when present', () => { - if (sampleReviewLog.convergence) { - expect(sampleReviewLog.convergence).toHaveProperty('stable_at_round') - expect(sampleReviewLog.convergence).toHaveProperty('final_diff_pct') - expect(sampleReviewLog.convergence).toHaveProperty('convergence_metric') - - expect(typeof sampleReviewLog.convergence.stable_at_round).toBe('number') - expect(sampleReviewLog.convergence.stable_at_round).toBeGreaterThanOrEqual(0) - expect(typeof sampleReviewLog.convergence.final_diff_pct).toBe('number') - expect(sampleReviewLog.convergence.final_diff_pct).toBeGreaterThanOrEqual(0) - expect(sampleReviewLog.convergence.final_diff_pct).toBeLessThanOrEqual(100) - } - }) - - it('should have valid approval status', () => { - expect(['pending', 'approved', 'rejected']).toContain(sampleReviewLog.approval.status) - if (sampleReviewLog.approval.status !== 'pending') { - expect(sampleReviewLog.approval.timestamp).toBeDefined() - } - }) - - it('should have non-empty summary', () => { - expect(typeof sampleReviewLog.summary).toBe('string') - expect(sampleReviewLog.summary.length).toBeGreaterThan(0) - }) - }) - - describe('Convergence Detection', () => { - it('should detect convergence when diff_pct < 5% for two consecutive rounds', () => { - // Simulate convergence: round 0 has 1 diff line, rounds 1-2 have 0 diffs - const totalLines = 50 - const diff0 = 1 - const diff1 = 0 - const diff2 = 0 - - const pct0 = (diff0 / totalLines) * 100 // 2% - const pct1 = (diff1 / totalLines) * 100 // 0% - const pct2 = (diff2 / totalLines) * 100 // 0% - - expect(pct0).toBeLessThan(5) // Should converge - expect(pct1).toBeLessThan(5) // Should converge - expect(pct2).toBeLessThan(5) // Should converge - }) - - it('should not detect convergence when diff_pct >= 5%', () => { - const totalLines = 50 - const diff = 3 // 6% change - - const pct = (diff / totalLines) * 100 - expect(pct).toBeGreaterThanOrEqual(5) - }) - }) - - describe('Status Transitions', () => { - it('should transition REVIEWING_PLAN → PLAN_REVIEWED when approved', () => { - const log = { ...sampleReviewLog, approval: { status: 'approved', timestamp: new Date().toISOString() } } - expect(log.approval.status).toBe('approved') - // In actual implementation: update_idea_plan_reviewed({ approval_status: 'approved' }) - // → idea.status = 'PLAN_REVIEWED' - }) - - it('should transition REVIEWING_PLAN → PLAN_REVIEW_FAILED when rejected', () => { - const log = { ...sampleReviewLog, approval: { status: 'rejected' } } - expect(log.approval.status).toBe('rejected') - // In actual implementation: update_idea_plan_reviewed({ approval_status: 'rejected' }) - // → idea.status = 'PLAN_REVIEW_FAILED' - }) - }) -}) diff --git a/__tests__/stores/product-workspace/restore.test.ts b/__tests__/stores/product-workspace/restore.test.ts deleted file mode 100644 index baa8120..0000000 --- a/__tests__/stores/product-workspace/restore.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - clearHints, - readHints, - writePbiHint, - writeProductHint, - writeStoryHint, - writeTaskHint, -} from '@/stores/product-workspace/restore' - -describe('readHints', () => { - it('retourneert lege defaults wanneer localStorage leeg is', () => { - const hints = readHints() - expect(hints.lastActiveProductId).toBeNull() - expect(hints.perProduct).toEqual({}) - }) - - it('herstelt hints uit localStorage', () => { - localStorage.setItem( - 'product-workspace-hints', - JSON.stringify({ - lastActiveProductId: 'p1', - perProduct: { p1: { lastActivePbiId: 'pbi-1' } }, - }), - ) - const hints = readHints() - expect(hints.lastActiveProductId).toBe('p1') - expect(hints.perProduct.p1.lastActivePbiId).toBe('pbi-1') - }) - - it('valt terug op defaults bij ongeldige JSON', () => { - localStorage.setItem('product-workspace-hints', '{not-json') - const hints = readHints() - expect(hints.lastActiveProductId).toBeNull() - expect(hints.perProduct).toEqual({}) - }) - - it('valt terug op defaults bij verkeerde shape', () => { - localStorage.setItem('product-workspace-hints', '"just a string"') - const hints = readHints() - expect(hints.perProduct).toEqual({}) - }) -}) - -describe('writeProductHint', () => { - it('schrijft lastActiveProductId', () => { - writeProductHint('p1') - expect(readHints().lastActiveProductId).toBe('p1') - }) - - it('overschrijft bestaande waarde', () => { - writeProductHint('p1') - writeProductHint('p2') - expect(readHints().lastActiveProductId).toBe('p2') - }) - - it('accepteert null om hint te wissen', () => { - writeProductHint('p1') - writeProductHint(null) - expect(readHints().lastActiveProductId).toBeNull() - }) -}) - -describe('writePbiHint', () => { - it('schrijft lastActivePbiId per productId', () => { - writePbiHint('prod-1', 'pbi-a') - writePbiHint('prod-2', 'pbi-b') - const hints = readHints() - expect(hints.perProduct['prod-1'].lastActivePbiId).toBe('pbi-a') - expect(hints.perProduct['prod-2'].lastActivePbiId).toBe('pbi-b') - }) - - it('null wist child story- en task-hints', () => { - writePbiHint('prod-1', 'pbi-1') - writeStoryHint('prod-1', 's-1') - writeTaskHint('prod-1', 't-1') - writePbiHint('prod-1', null) - const hints = readHints() - expect(hints.perProduct['prod-1'].lastActivePbiId).toBeNull() - expect(hints.perProduct['prod-1'].lastActiveStoryId).toBeNull() - expect(hints.perProduct['prod-1'].lastActiveTaskId).toBeNull() - }) -}) - -describe('writeStoryHint', () => { - it('schrijft lastActiveStoryId per productId', () => { - writeStoryHint('prod-1', 's-1') - expect(readHints().perProduct['prod-1'].lastActiveStoryId).toBe('s-1') - }) - - it('null wist child task-hint', () => { - writeStoryHint('prod-1', 's-1') - writeTaskHint('prod-1', 't-1') - writeStoryHint('prod-1', null) - expect(readHints().perProduct['prod-1'].lastActiveStoryId).toBeNull() - expect(readHints().perProduct['prod-1'].lastActiveTaskId).toBeNull() - }) -}) - -describe('writeTaskHint', () => { - it('schrijft lastActiveTaskId per productId', () => { - writeTaskHint('prod-1', 't-1') - expect(readHints().perProduct['prod-1'].lastActiveTaskId).toBe('t-1') - }) -}) - -describe('clearHints', () => { - it('verwijdert alle hints', () => { - writeProductHint('p1') - writePbiHint('p1', 'pbi-1') - clearHints() - const hints = readHints() - expect(hints.lastActiveProductId).toBeNull() - expect(hints.perProduct).toEqual({}) - }) -}) diff --git a/__tests__/stores/product-workspace/screen-state.test.ts b/__tests__/stores/product-workspace/screen-state.test.ts deleted file mode 100644 index 7463fff..0000000 --- a/__tests__/stores/product-workspace/screen-state.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - deriveScreenState, - type ScreenStateInput, -} from '@/stores/product-workspace/screen-state' - -const base: ScreenStateInput = { - activeSprintItem: null, - buildingSprintIds: [], - hasPendingDraft: false, - pendingAdds: [], - pendingRemoves: [], -} - -describe('deriveScreenState', () => { - it('returns NO_SPRINT without draft or active sprint', () => { - expect(deriveScreenState(base)).toEqual({ kind: 'NO_SPRINT' }) - }) - - it('returns DRAFT when a pending draft exists', () => { - expect(deriveScreenState({ ...base, hasPendingDraft: true })).toEqual({ - kind: 'DRAFT', - }) - }) - - it('lets a draft win over an active sprint with pending changes', () => { - expect( - deriveScreenState({ - ...base, - hasPendingDraft: true, - activeSprintItem: { id: 's1' }, - pendingAdds: ['x'], - }), - ).toEqual({ kind: 'DRAFT' }) - }) - - it('returns ACTIVE for an active sprint with no pending changes', () => { - expect( - deriveScreenState({ ...base, activeSprintItem: { id: 's1' } }), - ).toEqual({ kind: 'ACTIVE', building: false }) - }) - - it('flags building when the active sprint is in buildingSprintIds', () => { - expect( - deriveScreenState({ - ...base, - activeSprintItem: { id: 's1' }, - buildingSprintIds: ['s1'], - }), - ).toEqual({ kind: 'ACTIVE', building: true }) - }) - - it('returns EDITING when there are pending adds', () => { - expect( - deriveScreenState({ - ...base, - activeSprintItem: { id: 's1' }, - pendingAdds: ['x'], - }), - ).toEqual({ kind: 'EDITING', building: false }) - }) - - it('returns EDITING when there are pending removes', () => { - expect( - deriveScreenState({ - ...base, - activeSprintItem: { id: 's1' }, - pendingRemoves: ['y'], - }), - ).toEqual({ kind: 'EDITING', building: false }) - }) - - it('flags building on EDITING when the active sprint is building', () => { - expect( - deriveScreenState({ - ...base, - activeSprintItem: { id: 's1' }, - pendingAdds: ['x'], - buildingSprintIds: ['s1'], - }), - ).toEqual({ kind: 'EDITING', building: true }) - }) -}) diff --git a/__tests__/stores/product-workspace/sprint-membership.test.ts b/__tests__/stores/product-workspace/sprint-membership.test.ts deleted file mode 100644 index 6f271de..0000000 --- a/__tests__/stores/product-workspace/sprint-membership.test.ts +++ /dev/null @@ -1,341 +0,0 @@ -// @vitest-environment jsdom -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import { - selectIsDirty, - selectPbiTriState, - selectPendingCount, - selectStoryEffectiveInSprint, - selectStoryIsBlocked, -} from '@/stores/product-workspace/selectors' -import type { BacklogStory } from '@/stores/product-workspace/types' - -function resetMembership() { - useProductWorkspaceStore.setState((s) => { - s.entities.storiesById = {} - s.relations.storyIdsByPbi = {} - s.sprintMembership = { - pbiSummary: {}, - crossSprintBlocks: {}, - pending: { adds: [], removes: [] }, - loadedSummaryForSprintId: null, - } - }) -} - -function seedStory(id: string, pbiId: string, sprintId: string | null): BacklogStory { - return { - id, - code: id, - title: id, - description: null, - acceptance_criteria: null, - priority: 2, - sort_order: 1, - status: sprintId ? 'IN_SPRINT' : 'OPEN', - pbi_id: pbiId, - sprint_id: sprintId, - created_at: new Date('2026-01-01'), - } -} - -beforeEach(() => { - resetMembership() -}) - -describe('toggleStorySprintMembership', () => { - it('adds storyId to pending.adds when currently not in sprint', () => { - useProductWorkspaceStore.getState().toggleStorySprintMembership('s1', false) - const pending = useProductWorkspaceStore.getState().sprintMembership.pending - expect(pending.adds).toEqual(['s1']) - expect(pending.removes).toEqual([]) - }) - - it('adds storyId to pending.removes when currently in sprint', () => { - useProductWorkspaceStore.getState().toggleStorySprintMembership('s1', true) - const pending = useProductWorkspaceStore.getState().sprintMembership.pending - expect(pending.removes).toEqual(['s1']) - expect(pending.adds).toEqual([]) - }) - - it('cancels out: toggle add → toggle remove same story (in-sprint) clears pending', () => { - const store = useProductWorkspaceStore.getState() - store.toggleStorySprintMembership('s1', false) // adds - // Story now appears to be "in sprint" via pending; calling with true should cancel - store.toggleStorySprintMembership('s1', false) // second click with same baseline - const pending = useProductWorkspaceStore.getState().sprintMembership.pending - expect(pending.adds).toEqual([]) - expect(pending.removes).toEqual([]) - }) - - it('removes from pending.removes when toggled back', () => { - const store = useProductWorkspaceStore.getState() - store.toggleStorySprintMembership('s1', true) - store.toggleStorySprintMembership('s1', true) - const pending = useProductWorkspaceStore.getState().sprintMembership.pending - expect(pending.removes).toEqual([]) - expect(pending.adds).toEqual([]) - }) - - it('resetSprintMembershipPending empties both arrays', () => { - const store = useProductWorkspaceStore.getState() - store.toggleStorySprintMembership('s1', false) - store.toggleStorySprintMembership('s2', true) - store.resetSprintMembershipPending() - const pending = useProductWorkspaceStore.getState().sprintMembership.pending - expect(pending.adds).toEqual([]) - expect(pending.removes).toEqual([]) - }) -}) - -describe('selectPbiTriState', () => { - function seedSummary(pbiId: string, total: number, inSprint: number) { - useProductWorkspaceStore.setState((s) => { - s.sprintMembership.pbiSummary[pbiId] = { - totalStoryCount: total, - inActiveSprintStoryCount: inSprint, - } - }) - } - - it('returns empty for PBI without summary', () => { - expect( - selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), - ).toBe('empty') - }) - - it('returns empty when totalStoryCount == 0', () => { - seedSummary('pbi-1', 0, 0) - expect( - selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), - ).toBe('empty') - }) - - it('returns full when all stories in sprint (no pending)', () => { - seedSummary('pbi-1', 3, 3) - expect( - selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), - ).toBe('full') - }) - - it('returns partial when some stories in sprint', () => { - seedSummary('pbi-1', 3, 2) - expect( - selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), - ).toBe('partial') - }) - - it('returns empty when inSprint == 0', () => { - seedSummary('pbi-1', 3, 0) - expect( - selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), - ).toBe('empty') - }) - - it('applies pending adds when stories are loaded for the PBI', () => { - seedSummary('pbi-1', 3, 1) - useProductWorkspaceStore.setState((s) => { - s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3'] - s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-1') - s.entities.storiesById['s2'] = seedStory('s2', 'pbi-1', null) - s.entities.storiesById['s3'] = seedStory('s3', 'pbi-1', null) - s.sprintMembership.pending.adds = ['s2', 's3'] - }) - expect( - selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), - ).toBe('full') - }) - - it('applies pending removes when stories are loaded for the PBI', () => { - seedSummary('pbi-1', 3, 3) - useProductWorkspaceStore.setState((s) => { - s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3'] - s.sprintMembership.pending.removes = ['s2'] - }) - expect( - selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), - ).toBe('partial') - }) - - it('ignores pending entries for stories of other PBIs', () => { - seedSummary('pbi-1', 3, 3) - useProductWorkspaceStore.setState((s) => { - s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3'] - s.sprintMembership.pending.removes = ['s99'] // not in pbi-1 - }) - expect( - selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'), - ).toBe('full') - }) -}) - -describe('selectStoryEffectiveInSprint', () => { - it('returns true when story.sprint_id matches activeSprintId and no pending', () => { - useProductWorkspaceStore.setState((s) => { - s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A') - }) - expect( - selectStoryEffectiveInSprint( - useProductWorkspaceStore.getState(), - 's1', - 'sprint-A', - ), - ).toBe(true) - }) - - it('returns false when story.sprint_id is null', () => { - useProductWorkspaceStore.setState((s) => { - s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', null) - }) - expect( - selectStoryEffectiveInSprint( - useProductWorkspaceStore.getState(), - 's1', - 'sprint-A', - ), - ).toBe(false) - }) - - it('returns true when story in pending.adds even if DB says no', () => { - useProductWorkspaceStore.setState((s) => { - s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', null) - s.sprintMembership.pending.adds = ['s1'] - }) - expect( - selectStoryEffectiveInSprint( - useProductWorkspaceStore.getState(), - 's1', - 'sprint-A', - ), - ).toBe(true) - }) - - it('returns false when story in pending.removes even if DB says yes', () => { - useProductWorkspaceStore.setState((s) => { - s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A') - s.sprintMembership.pending.removes = ['s1'] - }) - expect( - selectStoryEffectiveInSprint( - useProductWorkspaceStore.getState(), - 's1', - 'sprint-A', - ), - ).toBe(false) - }) - - it('returns false when activeSprintId is null', () => { - useProductWorkspaceStore.setState((s) => { - s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A') - }) - expect( - selectStoryEffectiveInSprint( - useProductWorkspaceStore.getState(), - 's1', - null, - ), - ).toBe(false) - }) -}) - -describe('selectStoryIsBlocked', () => { - it('returns null when no block', () => { - expect( - selectStoryIsBlocked(useProductWorkspaceStore.getState(), 's1'), - ).toBeNull() - }) - - it('returns block info when story is in another sprint', () => { - useProductWorkspaceStore.setState((s) => { - s.sprintMembership.crossSprintBlocks['s1'] = { - sprintId: 'sprint-x', - sprintName: 'SP-X', - } - }) - expect( - selectStoryIsBlocked(useProductWorkspaceStore.getState(), 's1'), - ).toEqual({ sprintId: 'sprint-x', sprintName: 'SP-X' }) - }) -}) - -describe('selectIsDirty + selectPendingCount', () => { - it('clean by default', () => { - expect(selectIsDirty(useProductWorkspaceStore.getState())).toBe(false) - expect(selectPendingCount(useProductWorkspaceStore.getState())).toBe(0) - }) - - it('counts adds + removes', () => { - useProductWorkspaceStore.setState((s) => { - s.sprintMembership.pending = { - adds: ['a1', 'a2'], - removes: ['r1'], - } - }) - expect(selectIsDirty(useProductWorkspaceStore.getState())).toBe(true) - expect(selectPendingCount(useProductWorkspaceStore.getState())).toBe(3) - }) -}) - -describe('fetch helpers', () => { - it('fetchSprintMembershipSummary populates store and gates by sprintId', async () => { - const originalFetch = globalThis.fetch - const responseBody = { - pbiA: { totalStoryCount: 5, inActiveSprintStoryCount: 2 }, - } - globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify(responseBody), { status: 200 }), - ) as unknown as typeof fetch - try { - await useProductWorkspaceStore - .getState() - .fetchSprintMembershipSummary('prod-1', 'sprint-A', ['pbiA']) - - const slice = useProductWorkspaceStore.getState().sprintMembership - expect(slice.pbiSummary.pbiA).toEqual({ - totalStoryCount: 5, - inActiveSprintStoryCount: 2, - }) - expect(slice.loadedSummaryForSprintId).toBe('sprint-A') - } finally { - globalThis.fetch = originalFetch - } - }) - - it('fetchCrossSprintBlocks populates store', async () => { - const originalFetch = globalThis.fetch - const responseBody = { - 's1': { sprintId: 'sprint-x', sprintName: 'SP-X' }, - } - globalThis.fetch = vi.fn().mockResolvedValue( - new Response(JSON.stringify(responseBody), { status: 200 }), - ) as unknown as typeof fetch - try { - await useProductWorkspaceStore - .getState() - .fetchCrossSprintBlocks('prod-1', 'sprint-A', ['pbiA']) - - const slice = useProductWorkspaceStore.getState().sprintMembership - expect(slice.crossSprintBlocks['s1']).toEqual({ - sprintId: 'sprint-x', - sprintName: 'SP-X', - }) - } finally { - globalThis.fetch = originalFetch - } - }) - - it('fetchSprintMembershipSummary is a no-op for empty pbiIds', async () => { - const fetchSpy = vi.fn() - const originalFetch = globalThis.fetch - globalThis.fetch = fetchSpy as unknown as typeof fetch - try { - await useProductWorkspaceStore - .getState() - .fetchSprintMembershipSummary('prod-1', 'sprint-A', []) - expect(fetchSpy).not.toHaveBeenCalled() - } finally { - globalThis.fetch = originalFetch - } - }) -}) diff --git a/__tests__/stores/product-workspace/store.test.ts b/__tests__/stores/product-workspace/store.test.ts deleted file mode 100644 index ff86cfc..0000000 --- a/__tests__/stores/product-workspace/store.test.ts +++ /dev/null @@ -1,890 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import type { - BacklogPbi, - BacklogStory, - BacklogTask, - ProductBacklogSnapshot, - TaskDetail, -} from '@/stores/product-workspace/types' - -// G5: snapshot original actions on module-load; restore in beforeEach. -// vi.fn-spies on actions could leak across tests otherwise. -const originalActions = (() => { - const s = useProductWorkspaceStore.getState() - return { - hydrateSnapshot: s.hydrateSnapshot, - setActiveProduct: s.setActiveProduct, - setActivePbi: s.setActivePbi, - setActiveStory: s.setActiveStory, - setActiveTask: s.setActiveTask, - ensureProductLoaded: s.ensureProductLoaded, - ensurePbiLoaded: s.ensurePbiLoaded, - ensureStoryLoaded: s.ensureStoryLoaded, - ensureTaskLoaded: s.ensureTaskLoaded, - applyRealtimeEvent: s.applyRealtimeEvent, - resyncActiveScopes: s.resyncActiveScopes, - resyncLoadedScopes: s.resyncLoadedScopes, - applyOptimisticMutation: s.applyOptimisticMutation, - rollbackMutation: s.rollbackMutation, - settleMutation: s.settleMutation, - setRealtimeStatus: s.setRealtimeStatus, - } -})() - -function resetStore() { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = null - s.context.activePbiId = null - s.context.activeStoryId = null - s.context.activeTaskId = null - s.entities.pbisById = {} - s.entities.storiesById = {} - s.entities.tasksById = {} - s.relations.pbiIds = [] - s.relations.storyIdsByPbi = {} - s.relations.taskIdsByStory = {} - s.loading.loadedProductId = null - s.loading.loadingProductId = null - s.loading.loadedPbiIds = {} - s.loading.loadedStoryIds = {} - s.loading.loadedTaskIds = {} - s.loading.activeRequestId = null - s.sync.realtimeStatus = 'connecting' - s.sync.lastEventAt = null - s.sync.lastResyncAt = null - s.sync.resyncReason = null - s.pendingMutations = {} - s.sprintMembership = { - pbiSummary: {}, - crossSprintBlocks: {}, - pending: { adds: [], removes: [] }, - loadedSummaryForSprintId: null, - } - Object.assign(s, originalActions) - }) -} - -beforeEach(() => { - resetStore() -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -function makePbi(overrides: Partial & { id: string }): BacklogPbi { - return { - id: overrides.id, - code: overrides.code ?? overrides.id, - title: overrides.title ?? `PBI ${overrides.id}`, - priority: overrides.priority ?? 2, - sort_order: overrides.sort_order ?? 1, - description: overrides.description ?? null, - created_at: overrides.created_at ?? new Date('2026-01-01'), - status: overrides.status ?? 'ready', - } -} - -function makeStory(overrides: Partial & { id: string; pbi_id: string }): BacklogStory { - return { - id: overrides.id, - code: overrides.code ?? overrides.id, - title: overrides.title ?? `Story ${overrides.id}`, - description: overrides.description ?? null, - acceptance_criteria: overrides.acceptance_criteria ?? null, - priority: overrides.priority ?? 2, - sort_order: overrides.sort_order ?? 1, - status: overrides.status ?? 'OPEN', - pbi_id: overrides.pbi_id, - sprint_id: overrides.sprint_id ?? null, - created_at: overrides.created_at ?? new Date('2026-01-01'), - } -} - -function makeTask(overrides: Partial & { id: string; story_id: string }): BacklogTask { - return { - id: overrides.id, - code: overrides.code ?? null, - title: overrides.title ?? `Task ${overrides.id}`, - description: overrides.description ?? null, - priority: overrides.priority ?? 2, - sort_order: overrides.sort_order ?? 1, - status: overrides.status ?? 'TO_DO', - story_id: overrides.story_id, - created_at: overrides.created_at ?? new Date('2026-01-01'), - } -} - -function snapshotWith( - pbis: BacklogPbi[], - storiesByPbi: Record = {}, - tasksByStory: Record = {}, - product?: { id: string; name: string }, -): ProductBacklogSnapshot { - return { product, pbis, storiesByPbi, tasksByStory } -} - -// G7: mock fetch — never let it fall through to real network -// G8: mockImplementation per call so each fetch gets a fresh Response -function mockFetchSequence( - responses: Array unknown)>, -) { - let i = 0 - return vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string, init?: RequestInit) => { - const r = responses[Math.min(i, responses.length - 1)] - i += 1 - const body = typeof r === 'function' ? (r as (u: string, i?: RequestInit) => unknown)(url, init) : r - return new Response(JSON.stringify(body ?? null), { status: 200 }) - }) as unknown as typeof fetch) -} - -// ───────────────────────────────────────────────────────────────────────── -// hydrateSnapshot -// ───────────────────────────────────────────────────────────────────────── - -describe('hydrateSnapshot', () => { - it('vult entities en relations met gesorteerde id-lijsten', () => { - const pbiA = makePbi({ id: 'pbi-a', priority: 2, sort_order: 2 }) - const pbiB = makePbi({ id: 'pbi-b', priority: 1, sort_order: 5 }) - const pbiC = makePbi({ id: 'pbi-c', priority: 2, sort_order: 1 }) - const storyB1 = makeStory({ id: 'st-1', pbi_id: 'pbi-b', sort_order: 2 }) - const storyB2 = makeStory({ id: 'st-2', pbi_id: 'pbi-b', sort_order: 1 }) - const taskA = makeTask({ id: 'tk-2', story_id: 'st-1', sort_order: 2 }) - const taskB = makeTask({ id: 'tk-1', story_id: 'st-1', sort_order: 1 }) - - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - [pbiA, pbiB, pbiC], - { 'pbi-b': [storyB1, storyB2] }, - { 'st-1': [taskA, taskB] }, - { id: 'prod-1', name: 'Product 1' }, - ), - ) - - const s = useProductWorkspaceStore.getState() - expect(s.entities.pbisById['pbi-a']).toBe(pbiA) - expect(s.entities.pbisById['pbi-b']).toBe(pbiB) - expect(s.entities.pbisById['pbi-c']).toBe(pbiC) - // pbi-b heeft priority 1 (komt eerst), dan pbi-c (sort_order 1) en pbi-a (sort_order 2) - expect(s.relations.pbiIds).toEqual(['pbi-b', 'pbi-c', 'pbi-a']) - expect(s.relations.storyIdsByPbi['pbi-b']).toEqual(['st-2', 'st-1']) - expect(s.relations.taskIdsByStory['st-1']).toEqual(['tk-1', 'tk-2']) - expect(s.context.activeProduct).toEqual({ id: 'prod-1', name: 'Product 1' }) - expect(s.loading.loadedProductId).toBe('prod-1') - }) - - it('normaliseert API-statussen naar het interne store-contract', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - [makePbi({ id: 'pbi-1', status: 'READY' as BacklogPbi['status'] })], - { - 'pbi-1': [ - makeStory({ id: 'st-1', pbi_id: 'pbi-1', status: 'in_sprint' }), - ], - }, - { - 'st-1': [makeTask({ id: 'tk-1', story_id: 'st-1', status: 'todo' })], - }, - ), - ) - - const s = useProductWorkspaceStore.getState() - expect(s.entities.pbisById['pbi-1'].status).toBe('ready') - expect(s.entities.storiesById['st-1'].status).toBe('IN_SPRINT') - expect(s.entities.tasksById['tk-1'].status).toBe('TO_DO') - }) - - it('reset bestaande entities en relations bij her-hydratie', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([makePbi({ id: 'old-pbi' })]), - ) - expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['old-pbi']) - - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([makePbi({ id: 'new-pbi' })]), - ) - const s = useProductWorkspaceStore.getState() - expect(s.entities.pbisById['old-pbi']).toBeUndefined() - expect(s.entities.pbisById['new-pbi']).toBeDefined() - expect(s.relations.pbiIds).toEqual(['new-pbi']) - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// Selection cascade -// ───────────────────────────────────────────────────────────────────────── - -describe('selection cascade', () => { - it('setActivePbi reset story+task; setActiveStory reset task', () => { - useProductWorkspaceStore.setState((s) => { - s.context.activePbiId = 'pbi-old' - s.context.activeStoryId = 'st-old' - s.context.activeTaskId = 'tk-old' - }) - - useProductWorkspaceStore.getState().setActivePbi('pbi-new') - let s = useProductWorkspaceStore.getState() - expect(s.context.activePbiId).toBe('pbi-new') - expect(s.context.activeStoryId).toBeNull() - expect(s.context.activeTaskId).toBeNull() - - useProductWorkspaceStore.setState((draft) => { - draft.context.activeStoryId = 'st-old' - draft.context.activeTaskId = 'tk-old' - }) - useProductWorkspaceStore.getState().setActiveStory('st-new') - s = useProductWorkspaceStore.getState() - expect(s.context.activeStoryId).toBe('st-new') - expect(s.context.activeTaskId).toBeNull() - }) - - it('setActiveProduct(null) ruimt entities en relations op', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - [makePbi({ id: 'p-1' })], - { 'p-1': [makeStory({ id: 's-1', pbi_id: 'p-1' })] }, - { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] }, - { id: 'prod-1', name: 'Product 1' }, - ), - ) - - useProductWorkspaceStore.getState().setActiveProduct(null) - const s = useProductWorkspaceStore.getState() - expect(s.context.activeProduct).toBeNull() - expect(s.context.activePbiId).toBeNull() - expect(s.context.activeStoryId).toBeNull() - expect(s.context.activeTaskId).toBeNull() - expect(s.entities.pbisById).toEqual({}) - expect(s.entities.storiesById).toEqual({}) - expect(s.entities.tasksById).toEqual({}) - expect(s.relations.pbiIds).toEqual([]) - expect(s.relations.storyIdsByPbi).toEqual({}) - expect(s.relations.taskIdsByStory).toEqual({}) - expect(s.loading.loadedProductId).toBeNull() - }) - - it('setActiveProduct kan alleen context zetten zonder full backlog load', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - [makePbi({ id: 'p-1' })], - { 'p-1': [makeStory({ id: 's-1', pbi_id: 'p-1' })] }, - { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] }, - { id: 'prod-1', name: 'Product 1' }, - ), - ) - useProductWorkspaceStore.setState((s) => { - s.context.activePbiId = 'p-1' - s.context.activeStoryId = 's-1' - }) - const fetchSpy = vi.spyOn(globalThis, 'fetch') - - useProductWorkspaceStore - .getState() - .setActiveProduct( - { id: 'prod-1', name: 'Product 1' }, - { load: false, preserveSelection: true }, - ) - - const s = useProductWorkspaceStore.getState() - expect(fetchSpy).not.toHaveBeenCalled() - expect(s.context.activePbiId).toBe('p-1') - expect(s.context.activeStoryId).toBe('s-1') - expect(s.entities.pbisById['p-1']).toBeDefined() - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// applyRealtimeEvent -// ───────────────────────────────────────────────────────────────────────── - -describe('applyRealtimeEvent — pbi', () => { - beforeEach(() => { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - }) - - it('I — voegt PBI toe en sorteert', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([makePbi({ id: 'a', priority: 2, sort_order: 5 })]), - ) - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'pbi', - op: 'I', - id: 'b', - product_id: 'prod-1', - code: 'B', - title: 'New PBI', - priority: 1, - sort_order: 1, - created_at: new Date('2026-02-01').toISOString(), - status: 'ready', - }) - const s = useProductWorkspaceStore.getState() - expect(s.entities.pbisById['b']).toBeDefined() - expect(s.relations.pbiIds).toEqual(['b', 'a']) - }) - - it('I — idempotent voor bestaande id', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([makePbi({ id: 'a' })]), - ) - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'pbi', - op: 'I', - id: 'a', - product_id: 'prod-1', - title: 'mutated', - }) - const s = useProductWorkspaceStore.getState() - expect(s.entities.pbisById['a'].title).toBe('PBI a') // niet overschreven - expect(s.relations.pbiIds).toEqual(['a']) - }) - - it('U — patch + her-sorteert', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([ - makePbi({ id: 'a', priority: 2, sort_order: 1 }), - makePbi({ id: 'b', priority: 2, sort_order: 2 }), - ]), - ) - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'pbi', - op: 'U', - id: 'b', - product_id: 'prod-1', - priority: 1, - }) - const s = useProductWorkspaceStore.getState() - expect(s.relations.pbiIds).toEqual(['b', 'a']) - }) - - it('D — verwijdert PBI inclusief child stories en tasks', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - [makePbi({ id: 'p1' })], - { p1: [makeStory({ id: 's1', pbi_id: 'p1' })] }, - { s1: [makeTask({ id: 't1', story_id: 's1' })] }, - ), - ) - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'pbi', - op: 'D', - id: 'p1', - product_id: 'prod-1', - }) - const s = useProductWorkspaceStore.getState() - expect(s.entities.pbisById['p1']).toBeUndefined() - expect(s.entities.storiesById['s1']).toBeUndefined() - expect(s.entities.tasksById['t1']).toBeUndefined() - expect(s.relations.pbiIds).toEqual([]) - expect(s.relations.storyIdsByPbi['p1']).toBeUndefined() - expect(s.relations.taskIdsByStory['s1']).toBeUndefined() - }) - - it('D — clear actieve PBI selectie als die onder de verwijderde PBI viel', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([makePbi({ id: 'p1' })]), - ) - useProductWorkspaceStore.setState((s) => { - s.context.activePbiId = 'p1' - s.context.activeStoryId = 's-x' - s.context.activeTaskId = 't-x' - }) - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'pbi', - op: 'D', - id: 'p1', - product_id: 'prod-1', - }) - const s = useProductWorkspaceStore.getState() - expect(s.context.activePbiId).toBeNull() - expect(s.context.activeStoryId).toBeNull() - expect(s.context.activeTaskId).toBeNull() - }) -}) - -describe('applyRealtimeEvent — story parent-move', () => { - beforeEach(() => { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - [makePbi({ id: 'p1' }), makePbi({ id: 'p2' })], - { - p1: [makeStory({ id: 's1', pbi_id: 'p1' })], - p2: [], - }, - ), - ) - }) - - it('U met andere pbi_id verplaatst story naar nieuwe parent-lijst', () => { - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'story', - op: 'U', - id: 's1', - product_id: 'prod-1', - pbi_id: 'p2', - }) - const s = useProductWorkspaceStore.getState() - expect(s.relations.storyIdsByPbi['p1']).toEqual([]) - expect(s.relations.storyIdsByPbi['p2']).toEqual(['s1']) - expect(s.entities.storiesById['s1'].pbi_id).toBe('p2') - }) -}) - -describe('applyRealtimeEvent — task parent-move', () => { - beforeEach(() => { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - [makePbi({ id: 'p1' })], - { p1: [makeStory({ id: 's1', pbi_id: 'p1' }), makeStory({ id: 's2', pbi_id: 'p1' })] }, - { - s1: [makeTask({ id: 't1', story_id: 's1' })], - s2: [], - }, - ), - ) - }) - - it('U met andere story_id verplaatst task naar nieuwe parent', () => { - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'task', - op: 'U', - id: 't1', - product_id: 'prod-1', - story_id: 's2', - }) - const s = useProductWorkspaceStore.getState() - expect(s.relations.taskIdsByStory['s1']).toEqual([]) - expect(s.relations.taskIdsByStory['s2']).toEqual(['t1']) - expect(s.entities.tasksById['t1'].story_id).toBe('s2') - }) -}) - -describe('applyRealtimeEvent — andere product genegeerd', () => { - it('event met ander product_id raakt de store niet', () => { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([makePbi({ id: 'a' })]), - ) - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'pbi', - op: 'I', - id: 'b', - product_id: 'prod-2', - title: 'Other product', - priority: 1, - sort_order: 1, - }) - const s = useProductWorkspaceStore.getState() - expect(s.entities.pbisById['b']).toBeUndefined() - expect(s.relations.pbiIds).toEqual(['a']) - }) -}) - -describe('applyRealtimeEvent — unknown entity → resync trigger', () => { - function withSpy(): ReturnType { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - const spy = vi.fn().mockResolvedValue(undefined) - useProductWorkspaceStore.setState((s) => { - s.resyncActiveScopes = spy as unknown as typeof s.resyncActiveScopes - }) - return spy - } - - it('unknown entity (b.v. comment) met matching product triggert resync', () => { - const spy = withSpy() - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'comment', - op: 'I', - id: 'cm-1', - product_id: 'prod-1', - } as unknown as Record) - expect(spy).toHaveBeenCalledWith('unknown-event') - }) - - it('unknown entity met ander product_id triggert geen resync', () => { - const spy = withSpy() - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'comment', - op: 'I', - id: 'cm-1', - product_id: 'prod-2', - } as unknown as Record) - expect(spy).not.toHaveBeenCalled() - }) - - it('claude_job_status (type-veld) triggert geen resync', () => { - const spy = withSpy() - useProductWorkspaceStore.getState().applyRealtimeEvent({ - type: 'claude_job_status', - job_id: 'job-1', - product_id: 'prod-1', - status: 'queued', - } as unknown as Record) - expect(spy).not.toHaveBeenCalled() - }) - - it('worker_heartbeat (type-veld) triggert geen resync', () => { - const spy = withSpy() - useProductWorkspaceStore.getState().applyRealtimeEvent({ - type: 'worker_heartbeat', - worker_id: 'w-1', - product_id: 'prod-1', - } as unknown as Record) - expect(spy).not.toHaveBeenCalled() - }) - - it('claude_job_enqueued (type-veld) triggert geen resync', () => { - const spy = withSpy() - useProductWorkspaceStore.getState().applyRealtimeEvent({ - type: 'claude_job_enqueued', - job_id: 'job-2', - product_id: 'prod-1', - kind: 'PER_TASK', - } as unknown as Record) - expect(spy).not.toHaveBeenCalled() - }) - - it('payload zonder entity en zonder type wordt genegeerd', () => { - const spy = withSpy() - useProductWorkspaceStore.getState().applyRealtimeEvent({ - product_id: 'prod-1', - something: 'else', - } as unknown as Record) - expect(spy).not.toHaveBeenCalled() - }) - - it('question-event met entity-veld maar zonder pbi/story/task triggert resync', () => { - // question is geen pbi/story/task entity dus telt als unknown wanneer - // hij geen 'type' draagt — dat zou een nieuwe entiteit kunnen zijn die - // we nog niet kennen. - const spy = withSpy() - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'question', - op: 'I', - id: 'q-1', - product_id: 'prod-1', - } as unknown as Record) - expect(spy).toHaveBeenCalledWith('unknown-event') - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// ensure*Loaded fetches + race-safe guard + sortering -// ───────────────────────────────────────────────────────────────────────── - -describe('ensureProductLoaded', () => { - it('fetcht backlog snapshot en hydreert met sortering', async () => { - const snapshot: ProductBacklogSnapshot = { - product: { id: 'prod-1', name: 'Product 1' }, - pbis: [ - makePbi({ id: 'a', priority: 2, sort_order: 5 }), - makePbi({ id: 'b', priority: 1, sort_order: 9 }), - ], - storiesByPbi: {}, - tasksByStory: {}, - } - const fetchSpy = mockFetchSequence([snapshot]) - - await useProductWorkspaceStore.getState().ensureProductLoaded('prod-1') - - expect(fetchSpy).toHaveBeenCalledWith( - '/api/products/prod-1/backlog', - expect.objectContaining({ cache: 'no-store' }), - ) - const s = useProductWorkspaceStore.getState() - expect(s.relations.pbiIds).toEqual(['b', 'a']) - expect(s.loading.loadedProductId).toBe('prod-1') - expect(s.loading.loadedPbiIds['a']).toBe(true) - expect(s.loading.loadedPbiIds['b']).toBe(true) - }) -}) - -describe('race-safe ensure*Loaded — activeRequestId guard', () => { - it('oudere in-flight ensurePbiLoaded mag nieuwere selectie niet overschrijven', async () => { - let resolveOld: ((stories: BacklogStory[]) => void) | null = null - - vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string) => { - if (url === '/api/pbis/pbi-old/stories') { - const stories = await new Promise((resolve) => { - resolveOld = resolve - }) - return new Response(JSON.stringify(stories), { status: 200 }) - } - if (url === '/api/pbis/pbi-new/stories') { - return new Response( - JSON.stringify([makeStory({ id: 'new-st', pbi_id: 'pbi-new' })]), - { status: 200 }, - ) - } - return new Response('null', { status: 200 }) - }) as unknown as typeof fetch) - - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - s.context.activePbiId = 'pbi-old' - s.loading.activeRequestId = 'req-old' - }) - const oldPromise = useProductWorkspaceStore - .getState() - .ensurePbiLoaded('pbi-old', 'req-old') - - // gebruiker selecteert ondertussen pbi-new - useProductWorkspaceStore.setState((s) => { - s.context.activePbiId = 'pbi-new' - s.loading.activeRequestId = 'req-new' - }) - await useProductWorkspaceStore.getState().ensurePbiLoaded('pbi-new', 'req-new') - - expect(useProductWorkspaceStore.getState().entities.storiesById['new-st']).toBeDefined() - - // resolve de oude fetch — guard moet de stale data weigeren - resolveOld!([makeStory({ id: 'old-st', pbi_id: 'pbi-old' })]) - await oldPromise - - const s = useProductWorkspaceStore.getState() - expect(s.context.activePbiId).toBe('pbi-new') - expect(s.entities.storiesById['old-st']).toBeUndefined() - expect(s.entities.storiesById['new-st']).toBeDefined() - }) -}) - -describe('ensureTaskLoaded — zet detail-flag', () => { - it('verrijkt task naar TaskDetail met _detail: true', async () => { - mockFetchSequence([ - { - id: 't-1', - title: 'Task 1', - description: 'desc', - priority: 1, - sort_order: 1, - status: 'todo', - story_id: 's-1', - created_at: new Date('2026-02-01').toISOString(), - implementation_plan: 'detailed plan here', - }, - ]) - - await useProductWorkspaceStore.getState().ensureTaskLoaded('t-1') - const task = useProductWorkspaceStore.getState().entities.tasksById['t-1'] as TaskDetail - expect(task._detail).toBe(true) - expect(task.status).toBe('TO_DO') - expect(task.implementation_plan).toBe('detailed plan here') - expect(useProductWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true) - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// resyncActiveScopes -// ───────────────────────────────────────────────────────────────────────── - -describe('resyncActiveScopes', () => { - it('triggert ensure-keten voor alle actieve scopes en zet sync velden', async () => { - const fetchSpy = mockFetchSequence([ - // ensureProductLoaded - { product: { id: 'prod-1', name: 'P' }, pbis: [], storiesByPbi: {}, tasksByStory: {} }, - // ensurePbiLoaded - [], - // ensureStoryLoaded - [], - // ensureTaskLoaded - { - id: 't-1', - title: 'T', - description: null, - priority: 1, - sort_order: 1, - status: 'todo', - story_id: 's-1', - created_at: '2026-02-01', - }, - ]) - - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'P' } - s.context.activePbiId = 'pbi-1' - s.context.activeStoryId = 's-1' - s.context.activeTaskId = 't-1' - }) - - await useProductWorkspaceStore.getState().resyncActiveScopes('manual') - - const calls = fetchSpy.mock.calls.map(([url]) => url) - expect(calls).toContain('/api/products/prod-1/backlog') - expect(calls).toContain('/api/pbis/pbi-1/stories') - expect(calls).toContain('/api/stories/s-1/tasks') - expect(calls).toContain('/api/tasks/t-1') - - const s = useProductWorkspaceStore.getState() - expect(s.sync.lastResyncAt).toBeTypeOf('number') - expect(s.sync.resyncReason).toBe('manual') - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// Optimistic mutations -// ───────────────────────────────────────────────────────────────────────── - -// ───────────────────────────────────────────────────────────────────────── -// Restore-hint integratie (Story 4) -// ───────────────────────────────────────────────────────────────────────── - -describe('restore-hint flow — setters persisteren hints', () => { - it('setActiveProduct schrijft lastActiveProductId', () => { - useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) - const raw = localStorage.getItem('product-workspace-hints') - expect(raw).not.toBeNull() - const hints = JSON.parse(raw!) - expect(hints.lastActiveProductId).toBe('prod-1') - }) - - it('setActivePbi schrijft lastActivePbiId per product', () => { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'P1' } - }) - useProductWorkspaceStore.getState().setActivePbi('pbi-a') - const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!) - expect(hints.perProduct['prod-1'].lastActivePbiId).toBe('pbi-a') - }) - - it('setActiveStory schrijft lastActiveStoryId per product', () => { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'P1' } - }) - useProductWorkspaceStore.getState().setActiveStory('story-a') - const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!) - expect(hints.perProduct['prod-1'].lastActiveStoryId).toBe('story-a') - }) - - it('setActiveTask schrijft lastActiveTaskId per product', () => { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'P1' } - }) - useProductWorkspaceStore.getState().setActiveTask('task-a') - const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!) - expect(hints.perProduct['prod-1'].lastActiveTaskId).toBe('task-a') - }) -}) - -describe('restore-hint flow — chain triggert na ensure*Loaded', () => { - it('hint die NIET in entities zit wordt genegeerd', async () => { - // Schrijf een hint voor een PBI die niet bestaat - localStorage.setItem( - 'product-workspace-hints', - JSON.stringify({ - lastActiveProductId: 'prod-1', - perProduct: { 'prod-1': { lastActivePbiId: 'ghost-pbi' } }, - }), - ) - // Mock ensureProductLoaded zodat hij een lege snapshot terugstuurt — geen - // ghost-pbi in entities. - mockFetchSequence([ - { product: { id: 'prod-1', name: 'P1' }, pbis: [], storiesByPbi: {}, tasksByStory: {} }, - ]) - - useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) - // Wacht tot async restore-flow afgewikkeld is. - await new Promise((r) => setTimeout(r, 20)) - - expect(useProductWorkspaceStore.getState().context.activePbiId).toBeNull() - }) - - it('hint die wel in entities zit wordt toegepast', async () => { - const validPbi = makePbi({ id: 'pbi-known' }) - localStorage.setItem( - 'product-workspace-hints', - JSON.stringify({ - lastActiveProductId: 'prod-1', - perProduct: { 'prod-1': { lastActivePbiId: 'pbi-known' } }, - }), - ) - mockFetchSequence([ - // ensureProductLoaded levert pbi-known - { - product: { id: 'prod-1', name: 'P1' }, - pbis: [validPbi], - storiesByPbi: {}, - tasksByStory: {}, - }, - // ensurePbiLoaded triggered door setActivePbi(hint) — geen stories - [], - ]) - - useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) - await new Promise((r) => setTimeout(r, 30)) - - expect(useProductWorkspaceStore.getState().context.activePbiId).toBe('pbi-known') - }) -}) - -describe('optimistic mutations', () => { - it('rollback herstelt vorige pbi-order', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([ - makePbi({ id: 'a', priority: 2, sort_order: 1 }), - makePbi({ id: 'b', priority: 2, sort_order: 2 }), - ]), - ) - const prevOrder = [...useProductWorkspaceStore.getState().relations.pbiIds] - - const id = useProductWorkspaceStore.getState().applyOptimisticMutation({ - kind: 'pbi-order', - prevPbiIds: prevOrder, - }) - // simuleer de optimistic order-wijziging buiten de mutation - useProductWorkspaceStore.setState((s) => { - s.relations.pbiIds = ['b', 'a'] - }) - expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['b', 'a']) - - useProductWorkspaceStore.getState().rollbackMutation(id) - expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(prevOrder) - expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeUndefined() - }) - - it('settle ruimt pending op zonder state te wijzigen', () => { - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([makePbi({ id: 'a' })]), - ) - const id = useProductWorkspaceStore.getState().applyOptimisticMutation({ - kind: 'pbi-order', - prevPbiIds: ['a'], - }) - expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeDefined() - - useProductWorkspaceStore.getState().settleMutation(id) - expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeUndefined() - expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['a']) - }) - - it('SSE-echo van een al-bestaande PBI is idempotent', () => { - useProductWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'P' } - }) - useProductWorkspaceStore.getState().hydrateSnapshot( - snapshotWith([makePbi({ id: 'a', title: 'Origineel' })]), - ) - useProductWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'pbi', - op: 'I', - id: 'a', - product_id: 'prod-1', - title: 'echo', - }) - expect(useProductWorkspaceStore.getState().entities.pbisById['a'].title).toBe('Origineel') - expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['a']) - }) -}) diff --git a/__tests__/stores/solo-workspace/store.test.ts b/__tests__/stores/solo-workspace/store.test.ts deleted file mode 100644 index b31000d..0000000 --- a/__tests__/stores/solo-workspace/store.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { useSoloStore } from '@/stores/solo-store' -import type { RealtimeEvent } from '@/stores/solo-store' -import type { SoloTask, SoloWorkspaceSnapshot } from '@/stores/solo-workspace/types' - -function baseTask(id: string, overrides: Partial = {}): SoloTask { - return { - id, - title: `Task ${id}`, - description: null, - implementation_plan: null, - priority: 1, - sort_order: 1, - status: 'TO_DO', - verify_only: false, - verify_required: 'ALIGNED_OR_PARTIAL', - story_id: 'story-1', - story_code: 'ST-1', - story_title: 'Story 1', - task_code: `ST-1.${id}`, - pbi_code: null, - pbi_title: null, - pbi_description: null, - ...overrides, - } -} - -function snapshot(tasks: SoloTask[]): SoloWorkspaceSnapshot { - return { - product: { id: 'prod-1', name: 'Product 1' }, - sprint: { id: 'sprint-1', sprint_goal: 'Goal' }, - activeUserId: 'user-1', - tasks, - unassignedStories: [ - { id: 'story-b', code: 'ST-2', title: 'Story B', tasks: [] }, - { id: 'story-a', code: 'ST-1', title: 'Story A', tasks: [] }, - ], - } -} - -function taskEvent(overrides: Partial): RealtimeEvent { - return { - op: 'U', - entity: 'task', - id: 'task-1', - story_id: 'story-1', - product_id: 'prod-1', - sprint_id: 'sprint-1', - assignee_id: 'user-1', - ...overrides, - } -} - -beforeEach(() => { - useSoloStore.setState({ - context: { activeProduct: null, activeSprint: null, activeUserId: null }, - entities: { tasksById: {}, unassignedStoriesById: {}, jobsByTaskId: {} }, - relations: { - taskIdsByColumn: { TO_DO: [], IN_PROGRESS: [], DONE: [] }, - unassignedStoryIds: [], - }, - loading: { - loadedProductId: null, - loadedSprintId: null, - loadingSprintId: null, - activeRequestId: null, - }, - sync: { - realtimeStatus: 'connecting', - showConnectingIndicator: false, - lastEventAt: null, - lastResyncAt: null, - resyncReason: null, - }, - pendingOps: new Set(), - tasks: {}, - unassignedStoriesById: {}, - claudeJobsByTaskId: {}, - }) - vi.restoreAllMocks() -}) - -describe('solo workspace store', () => { - it('hydrateert genormaliseerde taken, kolomrelaties en unassigned stories', () => { - useSoloStore.getState().hydrateSnapshot( - snapshot([ - baseTask('task-2', { status: 'DONE', sort_order: 2 }), - baseTask('task-1', { status: 'TO_DO', sort_order: 1 }), - baseTask('task-3', { status: 'REVIEW', sort_order: 3 }), - ]), - ) - - const s = useSoloStore.getState() - expect(s.context.activeSprint?.id).toBe('sprint-1') - expect(s.relations.taskIdsByColumn.TO_DO).toEqual(['task-1']) - expect(s.relations.taskIdsByColumn.IN_PROGRESS).toEqual(['task-3']) - expect(s.relations.taskIdsByColumn.DONE).toEqual(['task-2']) - expect(s.relations.unassignedStoryIds).toEqual(['story-a', 'story-b']) - }) - - it('past realtime task updates toe en herbouwt kolomrelaties', () => { - useSoloStore.getState().hydrateSnapshot(snapshot([baseTask('task-1')])) - useSoloStore.getState().handleRealtimeEvent( - taskEvent({ status: 'DONE', sort_order: 5, title: 'Done task' }), - ) - - const s = useSoloStore.getState() - expect(s.tasks['task-1'].status).toBe('DONE') - expect(s.tasks['task-1'].title).toBe('Done task') - expect(s.relations.taskIdsByColumn.TO_DO).toEqual([]) - expect(s.relations.taskIdsByColumn.DONE).toEqual(['task-1']) - }) - - it('resynct actieve scopes via de solo-workspace route', async () => { - useSoloStore.getState().hydrateSnapshot(snapshot([baseTask('task-1')])) - const next = snapshot([baseTask('task-1', { status: 'IN_PROGRESS' })]) - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(JSON.stringify(next), { status: 200 }), - ) - - await useSoloStore.getState().resyncActiveScopes('manual') - - expect(fetchSpy).toHaveBeenCalledWith( - '/api/products/prod-1/solo-workspace?sprint_id=sprint-1', - expect.objectContaining({ cache: 'no-store' }), - ) - const s = useSoloStore.getState() - expect(s.tasks['task-1'].status).toBe('IN_PROGRESS') - expect(s.sync.resyncReason).toBe('manual') - }) -}) diff --git a/__tests__/stores/sprint-workspace/restore.test.ts b/__tests__/stores/sprint-workspace/restore.test.ts deleted file mode 100644 index 66c626f..0000000 --- a/__tests__/stores/sprint-workspace/restore.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { - clearHints, - readHints, - writeProductHint, - writeSprintHint, - writeStoryHint, - writeTaskHint, -} from '@/stores/sprint-workspace/restore' - -describe('readHints', () => { - it('retourneert lege defaults wanneer localStorage leeg is', () => { - const hints = readHints() - expect(hints.lastActiveProductId).toBeNull() - expect(hints.perProduct).toEqual({}) - expect(hints.perSprint).toEqual({}) - }) - - it('herstelt hints uit localStorage', () => { - localStorage.setItem( - 'sprint-workspace-hints', - JSON.stringify({ - lastActiveProductId: 'p1', - perProduct: { p1: { lastActiveSprintId: 'sp-1' } }, - perSprint: { 'sp-1': { lastActiveStoryId: 's-1' } }, - }), - ) - const hints = readHints() - expect(hints.lastActiveProductId).toBe('p1') - expect(hints.perProduct.p1.lastActiveSprintId).toBe('sp-1') - expect(hints.perSprint['sp-1'].lastActiveStoryId).toBe('s-1') - }) - - it('valt terug op defaults bij ongeldige JSON', () => { - localStorage.setItem('sprint-workspace-hints', '{not-json') - const hints = readHints() - expect(hints.lastActiveProductId).toBeNull() - expect(hints.perProduct).toEqual({}) - expect(hints.perSprint).toEqual({}) - }) - - it('valt terug op defaults bij verkeerde shape', () => { - localStorage.setItem('sprint-workspace-hints', '"just a string"') - const hints = readHints() - expect(hints.perProduct).toEqual({}) - expect(hints.perSprint).toEqual({}) - }) -}) - -describe('writeProductHint', () => { - it('schrijft lastActiveProductId', () => { - writeProductHint('p1') - expect(readHints().lastActiveProductId).toBe('p1') - }) - - it('overschrijft bestaande waarde', () => { - writeProductHint('p1') - writeProductHint('p2') - expect(readHints().lastActiveProductId).toBe('p2') - }) - - it('accepteert null om hint te wissen', () => { - writeProductHint('p1') - writeProductHint(null) - expect(readHints().lastActiveProductId).toBeNull() - }) -}) - -describe('writeSprintHint', () => { - it('schrijft lastActiveSprintId per productId', () => { - writeSprintHint('prod-1', 'sp-a') - writeSprintHint('prod-2', 'sp-b') - const hints = readHints() - expect(hints.perProduct['prod-1'].lastActiveSprintId).toBe('sp-a') - expect(hints.perProduct['prod-2'].lastActiveSprintId).toBe('sp-b') - }) - - it('accepteert null om sprint-hint te wissen', () => { - writeSprintHint('prod-1', 'sp-a') - writeSprintHint('prod-1', null) - expect(readHints().perProduct['prod-1'].lastActiveSprintId).toBeNull() - }) -}) - -describe('writeStoryHint', () => { - it('schrijft lastActiveStoryId per sprintId', () => { - writeStoryHint('sp-1', 's-1') - expect(readHints().perSprint['sp-1'].lastActiveStoryId).toBe('s-1') - }) - - it('null wist child task-hint', () => { - writeStoryHint('sp-1', 's-1') - writeTaskHint('sp-1', 't-1') - writeStoryHint('sp-1', null) - expect(readHints().perSprint['sp-1'].lastActiveStoryId).toBeNull() - expect(readHints().perSprint['sp-1'].lastActiveTaskId).toBeNull() - }) -}) - -describe('writeTaskHint', () => { - it('schrijft lastActiveTaskId per sprintId', () => { - writeTaskHint('sp-1', 't-1') - expect(readHints().perSprint['sp-1'].lastActiveTaskId).toBe('t-1') - }) -}) - -describe('clearHints', () => { - it('verwijdert alle hints', () => { - writeProductHint('p1') - writeSprintHint('p1', 'sp-1') - writeStoryHint('sp-1', 's-1') - clearHints() - const hints = readHints() - expect(hints.lastActiveProductId).toBeNull() - expect(hints.perProduct).toEqual({}) - expect(hints.perSprint).toEqual({}) - }) -}) diff --git a/__tests__/stores/sprint-workspace/store.test.ts b/__tests__/stores/sprint-workspace/store.test.ts deleted file mode 100644 index 5fa0502..0000000 --- a/__tests__/stores/sprint-workspace/store.test.ts +++ /dev/null @@ -1,875 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' -import type { - SprintWorkspaceSnapshot, - SprintWorkspaceSprint, - SprintWorkspaceStory, - SprintWorkspaceTask, - SprintWorkspaceTaskDetail, -} from '@/stores/sprint-workspace/types' - -// G5: snapshot original actions on module-load; restore in beforeEach. -const originalActions = (() => { - const s = useSprintWorkspaceStore.getState() - return { - hydrateSnapshot: s.hydrateSnapshot, - hydrateProductSprints: s.hydrateProductSprints, - setActiveProduct: s.setActiveProduct, - setActiveSprint: s.setActiveSprint, - setActiveStory: s.setActiveStory, - setActiveTask: s.setActiveTask, - ensureProductSprintsLoaded: s.ensureProductSprintsLoaded, - ensureSprintLoaded: s.ensureSprintLoaded, - ensureStoryLoaded: s.ensureStoryLoaded, - ensureTaskLoaded: s.ensureTaskLoaded, - applyRealtimeEvent: s.applyRealtimeEvent, - resyncActiveScopes: s.resyncActiveScopes, - resyncLoadedScopes: s.resyncLoadedScopes, - applyOptimisticMutation: s.applyOptimisticMutation, - rollbackMutation: s.rollbackMutation, - settleMutation: s.settleMutation, - setRealtimeStatus: s.setRealtimeStatus, - } -})() - -function resetStore() { - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = null - s.context.activeSprintId = null - s.context.activeStoryId = null - s.context.activeTaskId = null - s.entities.sprintsById = {} - s.entities.storiesById = {} - s.entities.tasksById = {} - s.relations.sprintIdsByProduct = {} - s.relations.storyIdsBySprint = {} - s.relations.taskIdsByStory = {} - s.loading.loadedProductSprintsIds = {} - s.loading.loadingProductId = null - s.loading.loadedSprintIds = {} - s.loading.loadingSprintId = null - s.loading.loadedStoryIds = {} - s.loading.loadedTaskIds = {} - s.loading.activeRequestId = null - s.sync.realtimeStatus = 'connecting' - s.sync.lastEventAt = null - s.sync.lastResyncAt = null - s.sync.resyncReason = null - s.pendingMutations = {} - Object.assign(s, originalActions) - }) -} - -beforeEach(() => { - resetStore() -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -function makeSprint( - overrides: Partial & { id: string; product_id: string }, -): SprintWorkspaceSprint { - return { - id: overrides.id, - product_id: overrides.product_id, - code: overrides.code ?? `S-${overrides.id}`, - sprint_goal: overrides.sprint_goal ?? `Goal ${overrides.id}`, - status: overrides.status ?? 'OPEN', - start_date: overrides.start_date ?? '2026-04-01', - end_date: overrides.end_date ?? '2026-04-14', - created_at: overrides.created_at ?? new Date('2026-03-15'), - completed_at: overrides.completed_at ?? null, - } -} - -function makeStory( - overrides: Partial & { id: string; pbi_id: string }, -): SprintWorkspaceStory { - return { - id: overrides.id, - code: overrides.code ?? overrides.id, - title: overrides.title ?? `Story ${overrides.id}`, - description: overrides.description ?? null, - acceptance_criteria: overrides.acceptance_criteria ?? null, - priority: overrides.priority ?? 2, - sort_order: overrides.sort_order ?? 1, - status: overrides.status ?? 'OPEN', - pbi_id: overrides.pbi_id, - sprint_id: overrides.sprint_id ?? null, - created_at: overrides.created_at ?? new Date('2026-01-01'), - } -} - -function makeTask( - overrides: Partial & { id: string; story_id: string }, -): SprintWorkspaceTask { - return { - id: overrides.id, - code: overrides.code ?? null, - title: overrides.title ?? `Task ${overrides.id}`, - description: overrides.description ?? null, - priority: overrides.priority ?? 2, - sort_order: overrides.sort_order ?? 1, - status: overrides.status ?? 'TO_DO', - story_id: overrides.story_id, - sprint_id: overrides.sprint_id ?? null, - created_at: overrides.created_at ?? new Date('2026-01-01'), - } -} - -function snapshotWith( - sprint: SprintWorkspaceSprint | undefined, - stories: SprintWorkspaceStory[] = [], - tasksByStory: Record = {}, - product?: { id: string; name: string }, -): SprintWorkspaceSnapshot { - return { product, sprint, stories, tasksByStory } -} - -// G7/G8: mock fetch via mockImplementation (vers Response per call) -function mockFetchSequence( - responses: Array unknown)>, -) { - let i = 0 - return vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string, init?: RequestInit) => { - const r = responses[Math.min(i, responses.length - 1)] - i += 1 - const body = typeof r === 'function' ? (r as (u: string, i?: RequestInit) => unknown)(url, init) : r - return new Response(JSON.stringify(body ?? null), { status: 200 }) - }) as unknown as typeof fetch) -} - -// ───────────────────────────────────────────────────────────────────────── -// hydrateSnapshot -// ───────────────────────────────────────────────────────────────────────── - -describe('hydrateSnapshot', () => { - it('vult entities, relations en loaded-marker', () => { - const sprint = makeSprint({ id: 'sp-1', product_id: 'prod-1' }) - const storyA = makeStory({ id: 's-a', pbi_id: 'pbi-1', sprint_id: 'sp-1', sort_order: 2 }) - const storyB = makeStory({ id: 's-b', pbi_id: 'pbi-1', sprint_id: 'sp-1', sort_order: 1 }) - const taskA = makeTask({ id: 't-a', story_id: 's-a', sort_order: 2 }) - const taskB = makeTask({ id: 't-b', story_id: 's-a', sort_order: 1 }) - - useSprintWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - sprint, - [storyA, storyB], - { 's-a': [taskA, taskB] }, - { id: 'prod-1', name: 'Product 1' }, - ), - ) - - const s = useSprintWorkspaceStore.getState() - expect(s.entities.sprintsById['sp-1']).toEqual(sprint) - expect(s.entities.storiesById['s-a']).toEqual(storyA) - expect(s.entities.storiesById['s-b']).toEqual(storyB) - // sort_order: storyB (1) before storyA (2) - expect(s.relations.storyIdsBySprint['sp-1']).toEqual(['s-b', 's-a']) - // sort_order: taskB (1) before taskA (2) - expect(s.relations.taskIdsByStory['s-a']).toEqual(['t-b', 't-a']) - expect(s.context.activeProduct).toEqual({ id: 'prod-1', name: 'Product 1' }) - expect(s.loading.loadedSprintIds['sp-1']).toBe(true) - }) - - it('normaliseert API-statussen naar het interne store-contract', () => { - useSprintWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - makeSprint({ id: 'sp-1', product_id: 'prod-1' }), - [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1', status: 'in_sprint' })], - { 's-1': [makeTask({ id: 't-1', story_id: 's-1', status: 'todo' })] }, - ), - ) - - const s = useSprintWorkspaceStore.getState() - expect(s.entities.storiesById['s-1'].status).toBe('IN_SPRINT') - expect(s.entities.tasksById['t-1'].status).toBe('TO_DO') - }) -}) - -describe('hydrateProductSprints', () => { - it('sorteert OPEN voor CLOSED, dan op start_date desc', () => { - const closedOld = makeSprint({ - id: 'sp-closed-old', - product_id: 'prod-1', - status: 'CLOSED', - start_date: '2026-01-01', - }) - const openNew = makeSprint({ - id: 'sp-open-new', - product_id: 'prod-1', - status: 'OPEN', - start_date: '2026-04-01', - }) - const openOld = makeSprint({ - id: 'sp-open-old', - product_id: 'prod-1', - status: 'OPEN', - start_date: '2026-02-01', - }) - - useSprintWorkspaceStore - .getState() - .hydrateProductSprints('prod-1', [closedOld, openOld, openNew]) - - const s = useSprintWorkspaceStore.getState() - expect(s.relations.sprintIdsByProduct['prod-1']).toEqual([ - 'sp-open-new', - 'sp-open-old', - 'sp-closed-old', - ]) - expect(s.loading.loadedProductSprintsIds['prod-1']).toBe(true) - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// Selection cascade -// ───────────────────────────────────────────────────────────────────────── - -describe('selection cascade', () => { - it('setActiveSprint reset story+task; setActiveStory reset task', () => { - useSprintWorkspaceStore.setState((s) => { - s.context.activeSprintId = 'sp-old' - s.context.activeStoryId = 's-old' - s.context.activeTaskId = 't-old' - }) - - useSprintWorkspaceStore.getState().setActiveSprint('sp-new') - let s = useSprintWorkspaceStore.getState() - expect(s.context.activeSprintId).toBe('sp-new') - expect(s.context.activeStoryId).toBeNull() - expect(s.context.activeTaskId).toBeNull() - - useSprintWorkspaceStore.setState((draft) => { - draft.context.activeStoryId = 's-old' - draft.context.activeTaskId = 't-old' - }) - useSprintWorkspaceStore.getState().setActiveStory('s-new') - s = useSprintWorkspaceStore.getState() - expect(s.context.activeStoryId).toBe('s-new') - expect(s.context.activeTaskId).toBeNull() - }) - - it('setActiveProduct(null) ruimt entities en relations op', () => { - useSprintWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - makeSprint({ id: 'sp-1', product_id: 'prod-1' }), - [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], - { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] }, - { id: 'prod-1', name: 'Product 1' }, - ), - ) - - useSprintWorkspaceStore.getState().setActiveProduct(null) - const s = useSprintWorkspaceStore.getState() - expect(s.context.activeProduct).toBeNull() - expect(s.context.activeSprintId).toBeNull() - expect(s.entities.sprintsById).toEqual({}) - expect(s.entities.storiesById).toEqual({}) - expect(s.entities.tasksById).toEqual({}) - expect(s.relations.sprintIdsByProduct).toEqual({}) - expect(s.relations.storyIdsBySprint).toEqual({}) - expect(s.relations.taskIdsByStory).toEqual({}) - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// applyRealtimeEvent -// ───────────────────────────────────────────────────────────────────────── - -describe('applyRealtimeEvent — sprint', () => { - beforeEach(() => { - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - }) - - it('I — voegt sprint toe aan product-lijst', () => { - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'sprint', - op: 'I', - id: 'sp-new', - product_id: 'prod-1', - code: 'S-1', - sprint_goal: 'New Sprint', - status: 'OPEN', - start_date: '2026-05-01', - end_date: '2026-05-14', - created_at: new Date('2026-04-15').toISOString(), - }) - const s = useSprintWorkspaceStore.getState() - expect(s.entities.sprintsById['sp-new']).toBeDefined() - expect(s.relations.sprintIdsByProduct['prod-1']).toContain('sp-new') - }) - - it('I — idempotent voor bestaande id', () => { - useSprintWorkspaceStore - .getState() - .hydrateProductSprints('prod-1', [ - makeSprint({ id: 'sp-1', product_id: 'prod-1', sprint_goal: 'Origineel' }), - ]) - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'sprint', - op: 'I', - id: 'sp-1', - product_id: 'prod-1', - sprint_goal: 'echo', - }) - expect(useSprintWorkspaceStore.getState().entities.sprintsById['sp-1'].sprint_goal).toBe( - 'Origineel', - ) - }) - - it('U — patch + her-sorteert', () => { - useSprintWorkspaceStore - .getState() - .hydrateProductSprints('prod-1', [ - makeSprint({ - id: 'sp-a', - product_id: 'prod-1', - status: 'OPEN', - start_date: '2026-04-01', - }), - makeSprint({ - id: 'sp-b', - product_id: 'prod-1', - status: 'OPEN', - start_date: '2026-03-01', - }), - ]) - // sp-a (newer) komt eerst - expect( - useSprintWorkspaceStore.getState().relations.sprintIdsByProduct['prod-1'], - ).toEqual(['sp-a', 'sp-b']) - - // Sluit sp-a → moet naar achteren - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'sprint', - op: 'U', - id: 'sp-a', - product_id: 'prod-1', - status: 'CLOSED', - }) - expect( - useSprintWorkspaceStore.getState().relations.sprintIdsByProduct['prod-1'], - ).toEqual(['sp-b', 'sp-a']) - }) - - it('D — verwijdert sprint inclusief child stories en tasks', () => { - useSprintWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - makeSprint({ id: 'sp-1', product_id: 'prod-1' }), - [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], - { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] }, - { id: 'prod-1', name: 'Product 1' }, - ), - ) - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'sprint', - op: 'D', - id: 'sp-1', - product_id: 'prod-1', - }) - const s = useSprintWorkspaceStore.getState() - expect(s.entities.sprintsById['sp-1']).toBeUndefined() - expect(s.entities.storiesById['s-1']).toBeUndefined() - expect(s.entities.tasksById['t-1']).toBeUndefined() - expect(s.relations.storyIdsBySprint['sp-1']).toBeUndefined() - expect(s.relations.taskIdsByStory['s-1']).toBeUndefined() - }) - - it('D — clear actieve sprint selectie als die de verwijderde sprint was', () => { - useSprintWorkspaceStore.getState().hydrateSnapshot( - snapshotWith(makeSprint({ id: 'sp-1', product_id: 'prod-1' }), []), - ) - useSprintWorkspaceStore.setState((s) => { - s.context.activeSprintId = 'sp-1' - s.context.activeStoryId = 's-x' - s.context.activeTaskId = 't-x' - }) - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'sprint', - op: 'D', - id: 'sp-1', - product_id: 'prod-1', - }) - const s = useSprintWorkspaceStore.getState() - expect(s.context.activeSprintId).toBeNull() - expect(s.context.activeStoryId).toBeNull() - expect(s.context.activeTaskId).toBeNull() - }) -}) - -describe('applyRealtimeEvent — story sprint-move', () => { - beforeEach(() => { - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - useSprintWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - makeSprint({ id: 'sp-1', product_id: 'prod-1' }), - [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], - ), - ) - }) - - it('U met andere sprint_id verplaatst story uit sprint-relatie', () => { - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'story', - op: 'U', - id: 's-1', - product_id: 'prod-1', - pbi_id: 'pbi-1', - sprint_id: 'sp-other', - }) - const s = useSprintWorkspaceStore.getState() - expect(s.relations.storyIdsBySprint['sp-1']).toEqual([]) - expect(s.relations.storyIdsBySprint['sp-other']).toEqual(['s-1']) - expect(s.entities.storiesById['s-1'].sprint_id).toBe('sp-other') - }) - - it('U met sprint_id=null haalt story uit sprint-relatie', () => { - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'story', - op: 'U', - id: 's-1', - product_id: 'prod-1', - pbi_id: 'pbi-1', - sprint_id: null, - }) - expect(useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1']).toEqual([]) - expect(useSprintWorkspaceStore.getState().entities.storiesById['s-1'].sprint_id).toBeNull() - }) -}) - -describe('applyRealtimeEvent — task parent-move', () => { - beforeEach(() => { - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - useSprintWorkspaceStore.getState().hydrateSnapshot( - snapshotWith( - makeSprint({ id: 'sp-1', product_id: 'prod-1' }), - [ - makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' }), - makeStory({ id: 's-2', pbi_id: 'pbi-1', sprint_id: 'sp-1' }), - ], - { - 's-1': [makeTask({ id: 't-1', story_id: 's-1' })], - 's-2': [], - }, - ), - ) - }) - - it('U met andere story_id verplaatst task naar nieuwe parent', () => { - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'task', - op: 'U', - id: 't-1', - product_id: 'prod-1', - story_id: 's-2', - }) - const s = useSprintWorkspaceStore.getState() - expect(s.relations.taskIdsByStory['s-1']).toEqual([]) - expect(s.relations.taskIdsByStory['s-2']).toEqual(['t-1']) - expect(s.entities.tasksById['t-1'].story_id).toBe('s-2') - }) -}) - -describe('applyRealtimeEvent — andere product genegeerd', () => { - it('event met ander product_id raakt de store niet', () => { - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - useSprintWorkspaceStore - .getState() - .hydrateProductSprints('prod-1', [makeSprint({ id: 'sp-1', product_id: 'prod-1' })]) - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'sprint', - op: 'I', - id: 'sp-other', - product_id: 'prod-2', - code: 'X', - }) - const s = useSprintWorkspaceStore.getState() - expect(s.entities.sprintsById['sp-other']).toBeUndefined() - }) -}) - -describe('applyRealtimeEvent — unknown entity → resync trigger', () => { - function withSpy(): ReturnType { - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - }) - const spy = vi.fn().mockResolvedValue(undefined) - useSprintWorkspaceStore.setState((s) => { - s.resyncActiveScopes = spy as unknown as typeof s.resyncActiveScopes - }) - return spy - } - - it('unknown entity met matching product triggert resync', () => { - const spy = withSpy() - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'comment', - op: 'I', - id: 'cm-1', - product_id: 'prod-1', - }) - expect(spy).toHaveBeenCalledWith('unknown-event') - }) - - it('unknown entity met ander product_id triggert geen resync', () => { - const spy = withSpy() - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'comment', - op: 'I', - id: 'cm-1', - product_id: 'prod-2', - }) - expect(spy).not.toHaveBeenCalled() - }) - - it('claude_job_status (type-veld) triggert geen resync', () => { - const spy = withSpy() - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - type: 'claude_job_status', - job_id: 'job-1', - product_id: 'prod-1', - status: 'queued', - }) - expect(spy).not.toHaveBeenCalled() - }) - - it('worker_heartbeat (type-veld) triggert geen resync', () => { - const spy = withSpy() - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - type: 'worker_heartbeat', - worker_id: 'w-1', - product_id: 'prod-1', - }) - expect(spy).not.toHaveBeenCalled() - }) - - it('payload zonder entity en zonder type wordt genegeerd', () => { - const spy = withSpy() - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - product_id: 'prod-1', - something: 'else', - }) - expect(spy).not.toHaveBeenCalled() - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// ensure*Loaded -// ───────────────────────────────────────────────────────────────────────── - -describe('ensureProductSprintsLoaded', () => { - it('fetcht sprint-list en hydreert met sortering', async () => { - const sprints = [ - makeSprint({ - id: 'sp-old', - product_id: 'prod-1', - status: 'CLOSED', - start_date: '2026-01-01', - }), - makeSprint({ - id: 'sp-new', - product_id: 'prod-1', - status: 'OPEN', - start_date: '2026-04-01', - }), - ] - const fetchSpy = mockFetchSequence([sprints]) - - await useSprintWorkspaceStore.getState().ensureProductSprintsLoaded('prod-1') - - expect(fetchSpy).toHaveBeenCalledWith( - '/api/products/prod-1/sprints', - expect.objectContaining({ cache: 'no-store' }), - ) - const s = useSprintWorkspaceStore.getState() - expect(s.relations.sprintIdsByProduct['prod-1']).toEqual(['sp-new', 'sp-old']) - expect(s.loading.loadedProductSprintsIds['prod-1']).toBe(true) - }) -}) - -describe('ensureSprintLoaded', () => { - it('fetcht sprint-snapshot en hydreert', async () => { - const sprint = makeSprint({ id: 'sp-1', product_id: 'prod-1' }) - const snapshot: SprintWorkspaceSnapshot = { - product: { id: 'prod-1', name: 'P1' }, - sprint, - stories: [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], - tasksByStory: { - 's-1': [makeTask({ id: 't-1', story_id: 's-1' })], - }, - } - const fetchSpy = mockFetchSequence([snapshot]) - - await useSprintWorkspaceStore.getState().ensureSprintLoaded('sp-1') - - expect(fetchSpy).toHaveBeenCalledWith( - '/api/sprints/sp-1/workspace', - expect.objectContaining({ cache: 'no-store' }), - ) - const s = useSprintWorkspaceStore.getState() - expect(s.entities.sprintsById['sp-1']).toBeDefined() - expect(s.relations.storyIdsBySprint['sp-1']).toEqual(['s-1']) - expect(s.relations.taskIdsByStory['s-1']).toEqual(['t-1']) - expect(s.loading.loadedSprintIds['sp-1']).toBe(true) - }) -}) - -describe('race-safe ensure*Loaded — activeRequestId guard', () => { - it('oudere in-flight ensureSprintLoaded mag nieuwere selectie niet overschrijven', async () => { - let resolveOld: ((snap: SprintWorkspaceSnapshot) => void) | null = null - - vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string) => { - if (url === '/api/sprints/sp-old/workspace') { - const snap = await new Promise((resolve) => { - resolveOld = resolve - }) - return new Response(JSON.stringify(snap), { status: 200 }) - } - if (url === '/api/sprints/sp-new/workspace') { - return new Response( - JSON.stringify({ - sprint: makeSprint({ id: 'sp-new', product_id: 'prod-1' }), - stories: [makeStory({ id: 'new-st', pbi_id: 'p', sprint_id: 'sp-new' })], - tasksByStory: {}, - }), - { status: 200 }, - ) - } - return new Response('null', { status: 200 }) - }) as unknown as typeof fetch) - - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } - s.context.activeSprintId = 'sp-old' - s.loading.activeRequestId = 'req-old' - }) - const oldPromise = useSprintWorkspaceStore - .getState() - .ensureSprintLoaded('sp-old', 'req-old') - - useSprintWorkspaceStore.setState((s) => { - s.context.activeSprintId = 'sp-new' - s.loading.activeRequestId = 'req-new' - }) - await useSprintWorkspaceStore - .getState() - .ensureSprintLoaded('sp-new', 'req-new') - - expect(useSprintWorkspaceStore.getState().entities.storiesById['new-st']).toBeDefined() - - resolveOld!({ - sprint: makeSprint({ id: 'sp-old', product_id: 'prod-1' }), - stories: [makeStory({ id: 'old-st', pbi_id: 'p', sprint_id: 'sp-old' })], - tasksByStory: {}, - }) - await oldPromise - - const s = useSprintWorkspaceStore.getState() - expect(s.context.activeSprintId).toBe('sp-new') - expect(s.entities.storiesById['old-st']).toBeUndefined() - expect(s.entities.storiesById['new-st']).toBeDefined() - }) -}) - -describe('ensureTaskLoaded — zet detail-flag', () => { - it('verrijkt task naar TaskDetail met _detail: true', async () => { - mockFetchSequence([ - { - id: 't-1', - code: 'C1', - title: 'Task 1', - description: 'desc', - priority: 1, - sort_order: 1, - status: 'todo', - story_id: 's-1', - sprint_id: 'sp-1', - created_at: new Date('2026-02-01').toISOString(), - implementation_plan: 'detailed plan here', - }, - ]) - - await useSprintWorkspaceStore.getState().ensureTaskLoaded('t-1') - const task = useSprintWorkspaceStore.getState().entities.tasksById[ - 't-1' - ] as SprintWorkspaceTaskDetail - expect(task._detail).toBe(true) - expect(task.status).toBe('TO_DO') - expect(task.implementation_plan).toBe('detailed plan here') - expect(useSprintWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true) - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// resyncActiveScopes -// ───────────────────────────────────────────────────────────────────────── - -describe('resyncActiveScopes', () => { - it('triggert ensure-keten voor alle actieve scopes en zet sync velden', async () => { - const fetchSpy = mockFetchSequence([ - // ensureProductSprintsLoaded - [], - // ensureSprintLoaded - { - sprint: makeSprint({ id: 'sp-1', product_id: 'prod-1' }), - stories: [], - tasksByStory: {}, - }, - // ensureStoryLoaded - [], - // ensureTaskLoaded - { - id: 't-1', - title: 'T', - description: null, - priority: 1, - sort_order: 1, - status: 'todo', - story_id: 's-1', - sprint_id: 'sp-1', - created_at: '2026-02-01', - }, - ]) - - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'P' } - s.context.activeSprintId = 'sp-1' - s.context.activeStoryId = 's-1' - s.context.activeTaskId = 't-1' - }) - - await useSprintWorkspaceStore.getState().resyncActiveScopes('manual') - - const calls = fetchSpy.mock.calls.map(([url]) => url) - expect(calls).toContain('/api/products/prod-1/sprints') - expect(calls).toContain('/api/sprints/sp-1/workspace') - expect(calls).toContain('/api/stories/s-1/tasks') - expect(calls).toContain('/api/tasks/t-1') - - const s = useSprintWorkspaceStore.getState() - expect(s.sync.lastResyncAt).toBeTypeOf('number') - expect(s.sync.resyncReason).toBe('manual') - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// Restore-hint flow -// ───────────────────────────────────────────────────────────────────────── - -describe('restore-hint flow — setters persisteren hints', () => { - it('setActiveProduct schrijft lastActiveProductId', () => { - useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) - const raw = localStorage.getItem('sprint-workspace-hints') - expect(raw).not.toBeNull() - const hints = JSON.parse(raw!) - expect(hints.lastActiveProductId).toBe('prod-1') - }) - - it('setActiveSprint schrijft lastActiveSprintId per product', () => { - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'P1' } - }) - useSprintWorkspaceStore.getState().setActiveSprint('sp-a') - const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!) - expect(hints.perProduct['prod-1'].lastActiveSprintId).toBe('sp-a') - }) - - it('setActiveStory schrijft lastActiveStoryId per sprint', () => { - useSprintWorkspaceStore.setState((s) => { - s.context.activeSprintId = 'sp-1' - }) - useSprintWorkspaceStore.getState().setActiveStory('s-a') - const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!) - expect(hints.perSprint['sp-1'].lastActiveStoryId).toBe('s-a') - }) - - it('setActiveTask schrijft lastActiveTaskId per sprint', () => { - useSprintWorkspaceStore.setState((s) => { - s.context.activeSprintId = 'sp-1' - }) - useSprintWorkspaceStore.getState().setActiveTask('t-a') - const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!) - expect(hints.perSprint['sp-1'].lastActiveTaskId).toBe('t-a') - }) -}) - -describe('restore-hint flow — chain triggert na ensure*Loaded', () => { - it('hint die NIET in entities zit wordt genegeerd', async () => { - localStorage.setItem( - 'sprint-workspace-hints', - JSON.stringify({ - lastActiveProductId: 'prod-1', - perProduct: { 'prod-1': { lastActiveSprintId: 'ghost-sprint' } }, - perSprint: {}, - }), - ) - mockFetchSequence([[]]) - - useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) - await new Promise((r) => setTimeout(r, 20)) - - expect(useSprintWorkspaceStore.getState().context.activeSprintId).toBeNull() - }) - - it('hint die wel in entities zit wordt toegepast', async () => { - const validSprint = makeSprint({ id: 'sp-known', product_id: 'prod-1' }) - localStorage.setItem( - 'sprint-workspace-hints', - JSON.stringify({ - lastActiveProductId: 'prod-1', - perProduct: { 'prod-1': { lastActiveSprintId: 'sp-known' } }, - perSprint: {}, - }), - ) - mockFetchSequence([ - // ensureProductSprintsLoaded — levert sp-known - [validSprint], - // ensureSprintLoaded triggered door setActiveSprint(hint) - { sprint: validSprint, stories: [], tasksByStory: {} }, - ]) - - useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) - await new Promise((r) => setTimeout(r, 30)) - - expect(useSprintWorkspaceStore.getState().context.activeSprintId).toBe('sp-known') - }) -}) - -// ───────────────────────────────────────────────────────────────────────── -// Optimistic mutations -// ───────────────────────────────────────────────────────────────────────── - -describe('optimistic mutations', () => { - it('SSE-echo van een al-bestaande sprint is idempotent', () => { - useSprintWorkspaceStore.setState((s) => { - s.context.activeProduct = { id: 'prod-1', name: 'P' } - }) - useSprintWorkspaceStore - .getState() - .hydrateProductSprints('prod-1', [ - makeSprint({ id: 'sp-1', product_id: 'prod-1', sprint_goal: 'Origineel' }), - ]) - useSprintWorkspaceStore.getState().applyRealtimeEvent({ - entity: 'sprint', - op: 'I', - id: 'sp-1', - product_id: 'prod-1', - sprint_goal: 'echo', - }) - expect( - useSprintWorkspaceStore.getState().entities.sprintsById['sp-1'].sprint_goal, - ).toBe('Origineel') - }) -}) diff --git a/__tests__/stores/user-settings.test.ts b/__tests__/stores/user-settings.test.ts deleted file mode 100644 index e159bf8..0000000 --- a/__tests__/stores/user-settings.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const updateAction = vi.fn() -const setDraftAction = vi.fn() -const clearDraftAction = vi.fn() - -vi.mock('@/actions/user-settings', () => ({ - updateUserSettingsAction: (...args: unknown[]) => updateAction(...args), -})) - -vi.mock('@/actions/sprint-draft', () => ({ - setPendingSprintDraftAction: (...args: unknown[]) => setDraftAction(...args), - clearPendingSprintDraftAction: (...args: unknown[]) => - clearDraftAction(...args), -})) - -import { useUserSettingsStore } from '@/stores/user-settings/store' -import type { PendingSprintDraft } from '@/lib/user-settings' - -function resetStore() { - useUserSettingsStore.setState((s) => { - s.entities.settings = {} - s.context.hydrated = false - s.context.isDemo = false - s.pendingMutations = {} - }) -} - -beforeEach(() => { - resetStore() - updateAction.mockReset() - setDraftAction.mockReset() - clearDraftAction.mockReset() -}) - -afterEach(() => { - resetStore() -}) - -describe('useUserSettingsStore', () => { - it('hydrate sets entities and context', () => { - useUserSettingsStore.getState().hydrate( - { views: { sprintBacklog: { sort: 'code' } } }, - false, - ) - const s = useUserSettingsStore.getState() - expect(s.entities.settings.views?.sprintBacklog?.sort).toBe('code') - expect(s.context.hydrated).toBe(true) - expect(s.context.isDemo).toBe(false) - }) - - it('setPref updates state optimistically and settles on success', async () => { - useUserSettingsStore.getState().hydrate({}, false) - updateAction.mockResolvedValueOnce({ - success: true, - settings: { views: { sprintBacklog: { filterStatus: 'all' } } }, - }) - - await useUserSettingsStore - .getState() - .setPref(['views', 'sprintBacklog', 'filterStatus'], 'all') - - const s = useUserSettingsStore.getState() - expect(s.entities.settings.views?.sprintBacklog?.filterStatus).toBe('all') - expect(Object.keys(s.pendingMutations)).toHaveLength(0) - expect(updateAction).toHaveBeenCalledWith({ - views: { sprintBacklog: { filterStatus: 'all' } }, - }) - }) - - it('setPref rolls back on server error', async () => { - useUserSettingsStore.getState().hydrate( - { views: { sprintBacklog: { sort: 'code' } } }, - false, - ) - updateAction.mockResolvedValueOnce({ error: 'boom', code: 422 }) - - await useUserSettingsStore - .getState() - .setPref(['views', 'sprintBacklog', 'sort'], 'priority') - - const s = useUserSettingsStore.getState() - expect(s.entities.settings.views?.sprintBacklog?.sort).toBe('code') - expect(Object.keys(s.pendingMutations)).toHaveLength(0) - }) - - it('setPref skips server-call for demo accounts', async () => { - useUserSettingsStore.getState().hydrate({}, true) - - await useUserSettingsStore - .getState() - .setPref(['devTools', 'debugMode'], true) - - const s = useUserSettingsStore.getState() - expect(s.entities.settings.devTools?.debugMode).toBe(true) - expect(updateAction).not.toHaveBeenCalled() - }) - - it('setPendingSprintDraft persists draft lokaal (session-only, geen server-call)', async () => { - useUserSettingsStore.getState().hydrate({}, false) - - const draft: PendingSprintDraft = { - goal: 'Sprint 1', - pbiIntent: { pbiA: 'all' }, - storyOverrides: {}, - } - await useUserSettingsStore - .getState() - .setPendingSprintDraft('product-1', draft) - - const s = useUserSettingsStore.getState() - expect( - s.entities.settings.workflow?.pendingSprintDraft?.['product-1'], - ).toMatchObject({ goal: 'Sprint 1' }) - expect(setDraftAction).not.toHaveBeenCalled() - }) - - it('hydrate strips workflow.pendingSprintDraft uit legacy server-state', () => { - useUserSettingsStore.getState().hydrate( - { - workflow: { - pendingSprintDraft: { - 'product-1': { - goal: 'Legacy draft', - pbiIntent: {}, - storyOverrides: {}, - }, - }, - }, - }, - false, - ) - - const s = useUserSettingsStore.getState() - expect(s.entities.settings.workflow?.pendingSprintDraft).toBeUndefined() - }) - - it('clearPendingSprintDraft verwijdert de key lokaal zonder server-call', async () => { - useUserSettingsStore.getState().hydrate({}, false) - await useUserSettingsStore.getState().setPendingSprintDraft('product-1', { - goal: 'Old', - pbiIntent: {}, - storyOverrides: {}, - }) - - await useUserSettingsStore - .getState() - .clearPendingSprintDraft('product-1') - - const s = useUserSettingsStore.getState() - expect( - s.entities.settings.workflow?.pendingSprintDraft?.['product-1'], - ).toBeUndefined() - expect(clearDraftAction).not.toHaveBeenCalled() - }) - - it('upsertPbiIntent updates intent and wipes storyOverrides for that PBI', async () => { - useUserSettingsStore.getState().hydrate({}, false) - await useUserSettingsStore.getState().setPendingSprintDraft('product-1', { - goal: 'g', - pbiIntent: { pbiA: 'none' }, - storyOverrides: { - pbiA: { add: ['s-1'], remove: [] }, - pbiB: { add: [], remove: ['s-2'] }, - }, - }) - - await useUserSettingsStore - .getState() - .upsertPbiIntent('product-1', 'pbiA', 'all') - - const draft = - useUserSettingsStore.getState().entities.settings.workflow - ?.pendingSprintDraft?.['product-1'] - expect(draft?.pbiIntent.pbiA).toBe('all') - expect(draft?.storyOverrides.pbiA).toBeUndefined() - expect(draft?.storyOverrides.pbiB).toEqual({ add: [], remove: ['s-2'] }) - }) - - it('upsertStoryOverride add adds to add[] and removes from remove[]', async () => { - useUserSettingsStore.getState().hydrate({}, false) - await useUserSettingsStore.getState().setPendingSprintDraft('product-1', { - goal: 'g', - pbiIntent: {}, - storyOverrides: { - pbiA: { add: [], remove: ['story-1'] }, - }, - }) - - await useUserSettingsStore - .getState() - .upsertStoryOverride('product-1', 'pbiA', 'story-1', 'add') - - const draft = - useUserSettingsStore.getState().entities.settings.workflow - ?.pendingSprintDraft?.['product-1'] - expect(draft?.storyOverrides.pbiA).toEqual({ - add: ['story-1'], - remove: [], - }) - }) - - it('upsertStoryOverride clear removes from both arrays and drops empty entry', async () => { - useUserSettingsStore.getState().hydrate({}, false) - await useUserSettingsStore.getState().setPendingSprintDraft('product-1', { - goal: 'g', - pbiIntent: {}, - storyOverrides: { - pbiA: { add: ['story-1'], remove: [] }, - }, - }) - - await useUserSettingsStore - .getState() - .upsertStoryOverride('product-1', 'pbiA', 'story-1', 'clear') - - const draft = - useUserSettingsStore.getState().entities.settings.workflow - ?.pendingSprintDraft?.['product-1'] - expect(draft?.storyOverrides.pbiA).toBeUndefined() - }) - - it('applyServerPatch merges without optimistic state', () => { - useUserSettingsStore.getState().hydrate( - { views: { sprintBacklog: { sort: 'code' } } }, - false, - ) - - useUserSettingsStore.getState().applyServerPatch({ - views: { sprintBacklog: { sortDir: 'desc' } }, - }) - - const s = useUserSettingsStore.getState() - expect(s.entities.settings.views?.sprintBacklog).toEqual({ - sort: 'code', - sortDir: 'desc', - }) - expect(Object.keys(s.pendingMutations)).toHaveLength(0) - }) -}) diff --git a/actions/active-sprint.ts b/actions/active-sprint.ts index e774376..b391d0a 100644 --- a/actions/active-sprint.ts +++ b/actions/active-sprint.ts @@ -7,11 +7,7 @@ import { z } from 'zod' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { productAccessFilter } from '@/lib/product-access' -import { - clearActiveSprintInSettings, - setActiveSelectionInSettings, - setActiveSprintInSettings, -} from '@/lib/active-sprint' +import { setActiveSprintCookie } from '@/lib/active-sprint' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -22,10 +18,6 @@ const setSchema = z.object({ sprintId: z.string().min(1), }) -const clearSchema = z.object({ - productId: z.string().min(1), -}) - export async function setActiveSprintAction(productId: string, sprintId: string) { const session = await getSession() if (!session.userId) return { error: 'Niet ingelogd' } @@ -44,121 +36,7 @@ export async function setActiveSprintAction(productId: string, sprintId: string) }) if (!sprint) return { error: 'Sprint niet gevonden of niet toegankelijk' } - await setActiveSprintInSettings(session.userId, parsed.data.productId, parsed.data.sprintId) + await setActiveSprintCookie(parsed.data.productId, parsed.data.sprintId) revalidatePath('/', 'layout') return { success: true, sprintId: parsed.data.sprintId } } - -export async function clearActiveSprintAction(productId: string) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - const parsed = clearSchema.safeParse({ productId }) - if (!parsed.success) return { error: 'Ongeldig product-id' } - - const product = await prisma.product.findFirst({ - where: { id: parsed.data.productId, ...productAccessFilter(session.userId) }, - select: { id: true }, - }) - if (!product) return { error: 'Product niet gevonden of niet toegankelijk' } - - await clearActiveSprintInSettings(session.userId, parsed.data.productId) - revalidatePath('/', 'layout') - return { success: true } -} - -const selectionSchema = z.object({ - productId: z.string().min(1), - sprintId: z.string().min(1), -}) - -/** - * PBI-79: kies een sprint en auto-select zijn enige PBI/story (indien - * singleton). Resultaat wordt server-side bepaald + atomair in user-settings - * weggeschreven (sprint+pbi+story) zodat cross-device-restore klopt. - */ -export async function switchActiveSprintAction( - productId: string, - sprintId: string, -): Promise< - | { - success: true - sprintId: string - pbiId: string | null - storyId: string | null - } - | { error: string } -> { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - const parsed = selectionSchema.safeParse({ productId, sprintId }) - if (!parsed.success) return { error: 'Ongeldig product- of sprint-id' } - - const sprint = await prisma.sprint.findFirst({ - where: { - id: parsed.data.sprintId, - product_id: parsed.data.productId, - product: productAccessFilter(session.userId), - }, - select: { id: true }, - }) - if (!sprint) return { error: 'Sprint niet gevonden of niet toegankelijk' } - - // Auto-select: alleen wanneer sprint exact één PBI heeft. Story-auto-select - // alleen wanneer die PBI exact één story binnen deze sprint heeft. - const sprintStories = await prisma.story.findMany({ - where: { - sprint_id: parsed.data.sprintId, - product_id: parsed.data.productId, - }, - select: { id: true, pbi_id: true }, - }) - const uniquePbiIds = Array.from(new Set(sprintStories.map((s) => s.pbi_id))) - let autoPbiId: string | null = null - let autoStoryId: string | null = null - if (uniquePbiIds.length === 1) { - autoPbiId = uniquePbiIds[0] - const storiesForPbi = sprintStories.filter((s) => s.pbi_id === autoPbiId) - if (storiesForPbi.length === 1) { - autoStoryId = storiesForPbi[0].id - } - } - - await setActiveSelectionInSettings(session.userId, parsed.data.productId, { - sprintId: parsed.data.sprintId, - pbiId: autoPbiId, - storyId: autoStoryId, - }) - revalidatePath('/', 'layout') - - return { - success: true, - sprintId: parsed.data.sprintId, - pbiId: autoPbiId, - storyId: autoStoryId, - } -} - -export async function syncActiveSprintCookieAction(productId: string, sprintId: string) { - const session = await getSession() - if (!session.userId) return - if (session.isDemo) return - - const parsed = setSchema.safeParse({ productId, sprintId }) - if (!parsed.success) return - - const sprint = await prisma.sprint.findFirst({ - where: { - id: parsed.data.sprintId, - product_id: parsed.data.productId, - product: productAccessFilter(session.userId), - }, - select: { id: true }, - }) - if (!sprint) return - - await setActiveSprintInSettings(session.userId, parsed.data.productId, parsed.data.sprintId) -} diff --git a/actions/claude-jobs.ts b/actions/claude-jobs.ts index 258fd1a..12fa3e9 100644 --- a/actions/claude-jobs.ts +++ b/actions/claude-jobs.ts @@ -1,7 +1,6 @@ 'use server' import { revalidatePath } from 'next/cache' -import { type ClaudeJobStatus } from '@prisma/client' import { prisma } from '@/lib/prisma' import { getSession } from '@/lib/auth' import { ACTIVE_JOB_STATUSES, jobStatusToApi } from '@/lib/job-status' @@ -16,9 +15,6 @@ type EnqueueAllResult = type CancelResult = { success: true } | { error: string } -type RestartResult = { success: true } | { error: string } -const RESTARTABLE_STATUSES: ClaudeJobStatus[] = ['FAILED', 'CANCELLED', 'SKIPPED'] - export type PreviewTask = { id: string title: string @@ -113,76 +109,3 @@ export async function cancelClaudeJobAction(jobId: string): Promise { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - if (!jobId) return { error: 'job_id is verplicht' } - - const job = await prisma.claudeJob.findFirst({ - where: { id: jobId, user_id: session.userId }, - select: { id: true, status: true, kind: true, task_id: true, idea_id: true, sprint_run_id: true, product_id: true }, - }) - if (!job) return { error: 'Job niet gevonden' } - if (!RESTARTABLE_STATUSES.includes(job.status)) { - return { error: 'Alleen mislukte, geannuleerde of overgeslagen jobs kunnen opnieuw gestart worden' } - } - - const updated = await prisma.$transaction(async (tx) => { - const result = await tx.claudeJob.updateMany({ - where: { id: jobId, status: { in: RESTARTABLE_STATUSES } }, - data: { - status: 'QUEUED', - retry_count: { increment: 1 }, - claimed_by_token_id: null, - claimed_at: null, - started_at: null, - finished_at: null, - pushed_at: null, - verify_result: null, - error: null, - summary: null, - branch: null, - head_sha: null, - lease_until: null, - }, - }) - if (result.count === 0) return 0 - if (job.kind === 'SPRINT_IMPLEMENTATION') { - await tx.sprintTaskExecution.updateMany({ - where: { sprint_job_id: jobId }, - data: { - status: 'PENDING', - verify_result: null, - verify_summary: null, - skip_reason: null, - head_sha: null, - started_at: null, - finished_at: null, - }, - }) - } - return result.count - }) - if (updated === 0) { - return { error: 'Job-status is gewijzigd; herlaad en probeer opnieuw' } - } - - await prisma.$executeRaw` - SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ - type: 'claude_job_status', - job_id: jobId, - kind: job.kind, - task_id: job.task_id, - idea_id: job.idea_id, - sprint_run_id: job.sprint_run_id, - user_id: session.userId, - product_id: job.product_id, - status: jobStatusToApi('QUEUED'), - })}::text) - ` - - revalidatePath('/jobs') - return { success: true } -} diff --git a/actions/ideas.ts b/actions/ideas.ts index 63bae6d..859457b 100644 --- a/actions/ideas.ts +++ b/actions/ideas.ts @@ -13,7 +13,6 @@ import { getIronSession } from 'iron-session' import { z } from 'zod' import { prisma } from '@/lib/prisma' -import { getJobConfigSnapshot } from '@/lib/job-config-snapshot' import { SessionData, sessionOptions } from '@/lib/session' import { enforceUserRateLimit } from '@/lib/rate-limit' import { ideaCreateSchema, ideaUpdateSchema } from '@/lib/schemas/idea' @@ -21,7 +20,6 @@ import { canTransition, isGrillMdEditable, isIdeaEditable, isPlanMdEditable } fr import { nextIdeaCode } from '@/lib/idea-code-server' import { parsePlanMd } from '@/lib/idea-plan-parser' import { ACTIVE_JOB_STATUSES } from '@/lib/job-status' -import { parseCodeNumber } from '@/lib/code' import type { ClaudeJobKind, Idea, IdeaStatus } from '@prisma/client' @@ -312,73 +310,6 @@ export async function updatePlanMdAction( return { success: true } } -// --------------------------------------------------------------------------- -// Upload — gebruiker plakt/uploadt zelf een plan.md in plaats van de Make-Plan -// AI-flow. Skipt grill als gewenst. Status springt direct naar PLAN_READY. -// Bij parse-failure: NIET opslaan (return 422), zodat een onparseerbaar plan -// nooit in de DB belandt. Geen worker nodig — synchrone parser. - -const UPLOAD_PLAN_FROM: IdeaStatus[] = ['DRAFT', 'GRILLED', 'PLAN_FAILED', 'PLAN_READY'] -const MAX_PLAN_MD_LENGTH = 100_000 - -export async function uploadPlanMdAction( - id: string, - markdown: string, -): Promise { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 401 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const limited = enforceUserRateLimit('upload-idea-plan', session.userId) - if (limited) return limited - - if (typeof markdown !== 'string' || markdown.trim().length === 0) { - return { error: 'plan_md is leeg', code: 422 } - } - if (markdown.length > MAX_PLAN_MD_LENGTH) { - return { - error: `plan_md is te groot (${markdown.length} > ${MAX_PLAN_MD_LENGTH} chars)`, - code: 422, - } - } - - const idea = await loadOwnedIdea(id, session.userId, ['status']) - if (!idea) return { error: 'Idee niet gevonden', code: 404 } - if (!UPLOAD_PLAN_FROM.includes(idea.status)) { - return { - error: `Upload plan alleen toegestaan vanuit ${UPLOAD_PLAN_FROM.join('/')} (huidige status: ${idea.status})`, - code: 422, - } - } - - const parsed = parsePlanMd(markdown) - if (!parsed.ok) { - return { - error: 'plan_md is niet parseerbaar', - code: 422, - details: parsed.errors, - } - } - - await prisma.$transaction([ - prisma.idea.update({ - where: { id }, - data: { plan_md: markdown, status: 'PLAN_READY' }, - }), - prisma.ideaLog.create({ - data: { - idea_id: id, - type: 'NOTE', - content: 'User-uploaded plan_md', - metadata: { length: markdown.length, from_status: idea.status }, - }, - }), - ]) - - revalidatePath(`/ideas/${id}`) - return { success: true } -} - // --------------------------------------------------------------------------- // Download — geeft de raw markdown terug; UI bouwt een Blob. @@ -409,7 +340,6 @@ export async function downloadIdeaMdAction( const GRILL_TRIGGERABLE_FROM: IdeaStatus[] = ['DRAFT', 'GRILLED', 'GRILL_FAILED', 'PLAN_READY', 'PLANNED'] const MAKE_PLAN_TRIGGERABLE_FROM: IdeaStatus[] = ['GRILLED', 'PLAN_FAILED', 'PLAN_READY'] -const REVIEW_PLAN_TRIGGERABLE_FROM: IdeaStatus[] = ['PLAN_READY', 'PLAN_REVIEWED'] export async function startGrillJobAction(id: string): Promise> { return startIdeaJob(id, 'IDEA_GRILL', 'GRILLING', GRILL_TRIGGERABLE_FROM) @@ -419,10 +349,6 @@ export async function startMakePlanJobAction(id: string): Promise> { - return startIdeaJob(id, 'IDEA_REVIEW_PLAN', 'REVIEWING_PLAN', REVIEW_PLAN_TRIGGERABLE_FROM) -} - async function startIdeaJob( id: string, kind: ClaudeJobKind, @@ -487,8 +413,6 @@ async function startIdeaJob( } } - const ideaSnapshot = await getJobConfigSnapshot({ kind, productId: idea.product_id! }) - // Atomic: create job + flip idea-status + log. const job = await prisma.$transaction(async (tx) => { const j = await tx.claudeJob.create({ @@ -498,7 +422,6 @@ async function startIdeaJob( idea_id: id, kind, status: 'QUEUED', - ...ideaSnapshot, }, select: { id: true }, }) @@ -553,15 +476,12 @@ export async function cancelIdeaJobAction(id: string): Promise { // Bepaal terugval-status. Bij een lopende grill: terug naar GRILLED als er // al eerder grill_md was, anders DRAFT. Bij plan-job: PLAN_READY als er al - // plan_md was (re-plan-cancel), anders GRILLED. Bij review-plan: terug naar - // PLAN_READY (review kan altijd opnieuw gestart worden). + // plan_md was (re-plan-cancel), anders GRILLED. let revertStatus: IdeaStatus if (job.kind === 'IDEA_GRILL') { revertStatus = idea.grill_md ? 'GRILLED' : 'DRAFT' } else if (job.kind === 'IDEA_MAKE_PLAN') { revertStatus = idea.plan_md ? 'PLAN_READY' : 'GRILLED' - } else if (job.kind === 'IDEA_REVIEW_PLAN') { - revertStatus = 'PLAN_READY' } else { return { error: `Job kind ${job.kind} hoort niet bij een idee`, code: 422 } } @@ -721,17 +641,16 @@ export async function materializeIdeaPlanAction( for (let si = 0; si < plan.stories.length; si++) { const s = plan.stories[si] - const storyCode = `ST-${String(nextStoryN++).padStart(3, '0')}` const story = await tx.story.create({ data: { pbi_id: pbi.id, product_id: productId, - code: storyCode, + code: `ST-${String(nextStoryN++).padStart(3, '0')}`, title: s.title, description: s.description ?? null, acceptance_criteria: s.acceptance_criteria ?? null, priority: s.priority, - sort_order: parseCodeNumber(storyCode), + sort_order: si + 1, // sequential within PBI status: 'OPEN', }, select: { id: true }, @@ -740,21 +659,16 @@ export async function materializeIdeaPlanAction( for (let ti = 0; ti < s.tasks.length; ti++) { const t = s.tasks[ti] - const taskCode = `T-${nextTaskN++}` const task = await tx.task.create({ data: { story_id: story.id, product_id: productId, - code: taskCode, + code: `T-${nextTaskN++}`, title: t.title, description: t.description ?? null, implementation_plan: t.implementation_plan ?? null, - // Erf priority van de story zodat YAML-volgorde gerespecteerd - // blijft. Worker sorteert op `priority ASC, sort_order ASC`; - // gemixte task-priorities binnen één story zouden anders de - // YAML-volgorde verstoren (zie plan-fix task-volgorde-na-upload). - priority: s.priority, - sort_order: parseCodeNumber(taskCode), + priority: t.priority, + sort_order: ti + 1, status: 'TO_DO', verify_required: t.verify_required ?? 'ALIGNED_OR_PARTIAL', verify_only: t.verify_only ?? false, diff --git a/actions/jobs-page.ts b/actions/jobs-page.ts index 22876a5..271a187 100644 --- a/actions/jobs-page.ts +++ b/actions/jobs-page.ts @@ -2,10 +2,146 @@ import { prisma } from '@/lib/prisma' import { getSession } from '@/lib/auth' -import { JOB_INCLUDE, mapJob, buildPriceMap } from '@/lib/jobs-mapper' -import type { RawJob, JobWithRelations, PriceRow } from '@/lib/jobs-mapper' +import type { ClaudeJobKind, ClaudeJobStatus, VerifyResult } from '@prisma/client' -export type { JobWithRelations } from '@/lib/jobs-mapper' +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 + costUsd: number | null + branch: string | null + prUrl: string | null + error: string | null + summary: string | null + description: string | null + verifyResult: VerifyResult | null + startedAt: Date | null + finishedAt: Date | null + createdAt: Date + sprintRunId: string | null +} + +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, code: true } } } }, +} as const + +type RawJob = { + id: string + kind: ClaudeJobKind + status: ClaudeJobStatus + model_id: string | null + input_tokens: number | null + output_tokens: number | null + cache_read_tokens: number | null + cache_write_tokens: number | null + branch: string | null + pr_url: string | null + error: string | null + summary: string | null + verify_result: VerifyResult | null + started_at: Date | null + finished_at: Date | null + created_at: Date + sprint_run_id: string | null + task: { + code: string | null + title: string + description: string | null + implementation_plan: string | null + } | 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; code: string } } | null +} + +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, + 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, + costUsd: computeCost(j, priceMap), + branch: j.branch, + prUrl: j.pr_url, + error: j.error, + summary: j.summary, + description: pickDescription(j), + verifyResult: j.verify_result, + startedAt: j.started_at, + finishedAt: j.finished_at, + createdAt: j.created_at, + sprintRunId: j.sprint_run_id, + } +} export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelations[]; doneJobs: JobWithRelations[] } | null> { const session = await getSession() @@ -26,10 +162,10 @@ export async function fetchJobsPageData(): Promise<{ activeJobs: JobWithRelation prisma.modelPrice.findMany(), ]) - const priceMap = buildPriceMap(prices as unknown as PriceRow[]) + const priceMap = new Map(prices.map((p) => [p.model_id, p as unknown as PriceRow])) return { - activeJobs: active.map((j) => mapJob(j as unknown as RawJob, priceMap)), - doneJobs: done.map((j) => mapJob(j as unknown as RawJob, priceMap)), + activeJobs: active.map((j) => mapJob(j as RawJob, priceMap)), + doneJobs: done.map((j) => mapJob(j as RawJob, priceMap)), } } diff --git a/actions/sprint-draft.ts b/actions/sprint-draft.ts deleted file mode 100644 index 37beb54..0000000 --- a/actions/sprint-draft.ts +++ /dev/null @@ -1,121 +0,0 @@ -'use server' - -import { revalidatePath } from 'next/cache' -import { cookies } from 'next/headers' -import { getIronSession } from 'iron-session' -import { z } from 'zod' -import type { Prisma } from '@prisma/client' -import { prisma } from '@/lib/prisma' -import { SessionData, sessionOptions } from '@/lib/session' -import { productAccessFilter } from '@/lib/product-access' -import { - mergeSettings, - parseUserSettings, - type PendingSprintDraft, - type UserSettings, -} from '@/lib/user-settings' - -async function getSession() { - return getIronSession(await cookies(), sessionOptions) -} - -const StoryOverridesSchema = z.object({ - add: z.array(z.string()), - remove: z.array(z.string()), -}).strict() - -const DraftSchema = z.object({ - goal: z.string().min(1), - startAt: z.string().date().optional(), - endAt: z.string().date().optional(), - pbiIntent: z.record(z.string(), z.enum(['all', 'none'])).default({}), - storyOverrides: z.record(z.string(), StoryOverridesSchema).default({}), -}).strict() - -const SetSchema = z.object({ - productId: z.string().min(1), - draft: DraftSchema, -}) - -const ClearSchema = z.object({ - productId: z.string().min(1), -}) - -async function ensureProductAccess(userId: string, productId: string) { - return prisma.product.findFirst({ - where: { id: productId, ...productAccessFilter(userId) }, - select: { id: true }, - }) -} - -async function readUserSettings(userId: string): Promise { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { settings: true }, - }) - return parseUserSettings(user?.settings) -} - -async function writeUserSettings(userId: string, next: UserSettings) { - await prisma.user.update({ - where: { id: userId }, - data: { settings: next as unknown as Prisma.InputJsonValue }, - }) -} - -export async function setPendingSprintDraftAction( - productId: string, - draft: PendingSprintDraft, -) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - const parsed = SetSchema.safeParse({ productId, draft }) - if (!parsed.success) { - return { error: 'Ongeldige draft', issues: parsed.error.issues } - } - - const product = await ensureProductAccess(session.userId, parsed.data.productId) - if (!product) return { error: 'Product niet gevonden of niet toegankelijk' } - - const current = await readUserSettings(session.userId) - const patch: Partial = { - workflow: { - pendingSprintDraft: { - ...(current.workflow?.pendingSprintDraft ?? {}), - [parsed.data.productId]: parsed.data.draft, - }, - }, - } - await writeUserSettings(session.userId, mergeSettings(current, patch)) - revalidatePath('/', 'layout') - return { success: true } -} - -export async function clearPendingSprintDraftAction(productId: string) { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd' } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } - - const parsed = ClearSchema.safeParse({ productId }) - if (!parsed.success) return { error: 'Ongeldig product-id' } - - const product = await ensureProductAccess(session.userId, parsed.data.productId) - if (!product) return { error: 'Product niet gevonden of niet toegankelijk' } - - const current = await readUserSettings(session.userId) - const existingMap = current.workflow?.pendingSprintDraft - if (!existingMap || !(parsed.data.productId in existingMap)) { - return { success: true } - } - const nextMap = { ...existingMap } - delete nextMap[parsed.data.productId] - const next: UserSettings = { - ...current, - workflow: { ...current.workflow, pendingSprintDraft: nextMap }, - } - await writeUserSettings(session.userId, next) - revalidatePath('/', 'layout') - return { success: true } -} diff --git a/actions/sprint-runs.ts b/actions/sprint-runs.ts index 8b232d0..7b4b87a 100644 --- a/actions/sprint-runs.ts +++ b/actions/sprint-runs.ts @@ -8,7 +8,6 @@ import { Prisma } from '@prisma/client' import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { parsePauseContext } from '@/lib/pause-context' -import { getJobConfigSnapshot } from '@/lib/job-config-snapshot' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -85,10 +84,10 @@ async function startSprintRunCore( // TO_DO, dus EXCLUDED/IN_PROGRESS/REVIEW/DONE/FAILED tasks komen niet // terecht in pre-flight blockers, jobs of SprintTaskExecution-rijen. where: { status: 'TO_DO' }, - orderBy: [{ sort_order: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], }, }, - orderBy: [{ sort_order: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], }) const blockers: PreFlightBlocker[] = [] @@ -167,6 +166,7 @@ async function startSprintRunCore( (a, b) => a.pbi.priority - b.pbi.priority || a.pbi.sort_order - b.pbi.sort_order || + a.priority - b.priority || a.sort_order - b.sort_order, ) .flatMap((s) => s.tasks) @@ -176,10 +176,6 @@ async function startSprintRunCore( // server-side bij claim aangemaakt zodat order/base_sha consistent zijn // met de worktree-state op claim-tijd. if (sprint.product.pr_strategy === 'SPRINT_BATCH') { - const sprintSnapshot = await getJobConfigSnapshot({ - kind: 'SPRINT_IMPLEMENTATION', - productId: sprint.product_id, - }) await tx.claudeJob.create({ data: { user_id, @@ -189,20 +185,13 @@ async function startSprintRunCore( sprint_run_id: sprintRun.id, kind: 'SPRINT_IMPLEMENTATION', status: 'QUEUED', - ...sprintSnapshot, }, }) return { ok: true, sprint_run_id: sprintRun.id, jobs_count: 1 } } - // STORY / SPRINT (per-task): bestaand pad. Snapshot per task zodat - // task.requires_opus de cascade kan overrulen. + // STORY / SPRINT (per-task): bestaand pad. for (const t of orderedTasks) { - const taskSnapshot = await getJobConfigSnapshot({ - kind: 'TASK_IMPLEMENTATION', - productId: sprint.product_id, - taskId: t.id, - }) await tx.claudeJob.create({ data: { user_id, @@ -211,7 +200,6 @@ async function startSprintRunCore( sprint_run_id: sprintRun.id, kind: 'TASK_IMPLEMENTATION', status: 'QUEUED', - ...taskSnapshot, }, }) } @@ -372,10 +360,6 @@ export async function resumePausedSprintRunAction( started_at: new Date(), }, }) - const resumeSnapshot = await getJobConfigSnapshot({ - kind: 'SPRINT_IMPLEMENTATION', - productId: sprintJob.product_id, - }) await tx.claudeJob.create({ data: { user_id: userId, @@ -385,7 +369,6 @@ export async function resumePausedSprintRunAction( sprint_run_id: newRun.id, kind: 'SPRINT_IMPLEMENTATION', status: 'QUEUED', - ...resumeSnapshot, }, }) await tx.sprintRun.update({ diff --git a/actions/sprints.ts b/actions/sprints.ts index 8ccc80e..dc5d55e 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -14,359 +14,9 @@ import { import { enforceUserRateLimit } from '@/lib/rate-limit' import { propagateStatusUpwards } from '@/lib/tasks-status-update' import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server' -import { setActiveSprintInSettings } from '@/lib/active-sprint' -import { partitionByEligibility } from '@/lib/sprint-conflicts' +import { setActiveSprintCookie } from '@/lib/active-sprint' import { z } from 'zod' -const StoryOverrideSchema = z.object({ - add: z.array(z.string()), - remove: z.array(z.string()), -}) - -const createSprintWithSelectionSchema = z.object({ - productId: z.string().min(1), - metadata: z.object({ - goal: z.string().min(1).max(2000), - startAt: z.string().date().optional(), - endAt: z.string().date().optional(), - }), - pbiIntent: z.record(z.string(), z.enum(['all', 'none'])).default({}), - storyOverrides: z.record(z.string(), StoryOverrideSchema).default({}), -}) - -export type CreateSprintWithSelectionInput = z.infer< - typeof createSprintWithSelectionSchema -> - -type SprintCreateConflicts = { - notEligible: { storyId: string; reason: 'DONE' | 'IN_OTHER_SPRINT' }[] - crossSprint: { storyId: string; sprintId: string; sprintName: string }[] -} - -export type CreateSprintWithSelectionResult = - | { - success: true - sprintId: string - affectedStoryIds: string[] - affectedPbiIds: string[] - affectedTaskIds: string[] - conflicts: SprintCreateConflicts - } - | { error: string; code: number } - -const updateSprintSchema = z.object({ - sprintId: z.string().min(1), - fields: z - .object({ - goal: z.string().min(1).max(2000).optional(), - startAt: z.string().date().nullable().optional(), - endAt: z.string().date().nullable().optional(), - }) - .refine( - (data) => Object.keys(data).length > 0, - 'Minstens één veld vereist', - ), -}) - -export type UpdateSprintInput = z.infer - -export type UpdateSprintResult = - | { success: true; sprintId: string } - | { error: string; code: number } - -export async function updateSprintAction( - input: UpdateSprintInput, -): Promise { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 403 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const parsed = updateSprintSchema.safeParse(input) - if (!parsed.success) return { error: 'Validatie mislukt', code: 422 } - - const sprint = await prisma.sprint.findFirst({ - where: { - id: parsed.data.sprintId, - product: productAccessFilter(session.userId), - }, - select: { id: true, product_id: true }, - }) - if (!sprint) return { error: 'Sprint niet gevonden', code: 403 } - - const data: { sprint_goal?: string; start_date?: Date | null; end_date?: Date | null } = {} - if (parsed.data.fields.goal !== undefined) { - data.sprint_goal = parsed.data.fields.goal - } - if (parsed.data.fields.startAt !== undefined) { - data.start_date = parseDate(parsed.data.fields.startAt) - } - if (parsed.data.fields.endAt !== undefined) { - data.end_date = parseDate(parsed.data.fields.endAt) - } - - await prisma.sprint.update({ - where: { id: parsed.data.sprintId }, - data, - }) - revalidatePath(`/products/${sprint.product_id}`, 'layout') - - return { success: true, sprintId: parsed.data.sprintId } -} - -const commitSprintMembershipSchema = z.object({ - activeSprintId: z.string().min(1), - adds: z.array(z.string()), - removes: z.array(z.string()), -}) - -export type CommitSprintMembershipInput = z.infer< - typeof commitSprintMembershipSchema -> - -type CommitConflicts = { - notEligible: { storyId: string; reason: 'DONE' | 'IN_OTHER_SPRINT' }[] - alreadyRemoved: string[] -} - -export type CommitSprintMembershipResult = - | { - success: true - affectedStoryIds: string[] - affectedPbiIds: string[] - affectedTaskIds: string[] - conflicts: CommitConflicts - } - | { error: string; code: number } - -export async function commitSprintMembershipAction( - input: CommitSprintMembershipInput, -): Promise { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 403 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const parsed = commitSprintMembershipSchema.safeParse(input) - if (!parsed.success) return { error: 'Validatie mislukt', code: 422 } - - // Sprint moet bestaan en bereikbaar zijn via product-access. - const sprint = await prisma.sprint.findFirst({ - where: { - id: parsed.data.activeSprintId, - product: productAccessFilter(session.userId), - }, - select: { id: true, product_id: true }, - }) - if (!sprint) { - return { error: 'Sprint niet gevonden of niet toegankelijk', code: 403 } - } - - // Filter adds via eligibility (sprint_id IS NULL en niet DONE; andere OPEN - // sprint → conflicts.notEligible + crossSprint). - const addPartition = await partitionByEligibility( - prisma, - parsed.data.adds, - parsed.data.activeSprintId, - ) - const eligibleAdds = addPartition.eligible - const notEligibleAdds = addPartition.notEligible - - // Race-safety voor removes: alleen stories die feitelijk in de actieve - // sprint zitten worden verwijderd. - const removeRows = - parsed.data.removes.length > 0 - ? await prisma.story.findMany({ - where: { - id: { in: parsed.data.removes }, - sprint_id: parsed.data.activeSprintId, - }, - select: { id: true }, - }) - : [] - const validRemoves = removeRows.map((r) => r.id) - const validRemoveSet = new Set(validRemoves) - const alreadyRemoved = parsed.data.removes.filter( - (id) => !validRemoveSet.has(id), - ) - - if (eligibleAdds.length === 0 && validRemoves.length === 0) { - // Geen werk te doen — geef toch een success-shape terug zodat de client - // pending buffer kan resetten + conflicts kan tonen. - return { - success: true, - affectedStoryIds: [], - affectedPbiIds: [], - affectedTaskIds: [], - conflicts: { notEligible: notEligibleAdds, alreadyRemoved }, - } - } - - await prisma.$transaction(async (tx) => { - if (eligibleAdds.length > 0) { - await tx.story.updateMany({ - where: { id: { in: eligibleAdds } }, - data: { sprint_id: parsed.data.activeSprintId, status: 'IN_SPRINT' }, - }) - await tx.task.updateMany({ - where: { story_id: { in: eligibleAdds } }, - data: { sprint_id: parsed.data.activeSprintId }, - }) - } - if (validRemoves.length > 0) { - await tx.story.updateMany({ - where: { id: { in: validRemoves } }, - data: { sprint_id: null, status: 'OPEN' }, - }) - await tx.task.updateMany({ - where: { story_id: { in: validRemoves } }, - data: { sprint_id: null }, - }) - } - }) - - const affectedStoryIds = [...eligibleAdds, ...validRemoves] - const affectedStories = - affectedStoryIds.length > 0 - ? await prisma.story.findMany({ - where: { id: { in: affectedStoryIds } }, - select: { pbi_id: true }, - }) - : [] - const affectedPbiIds = Array.from( - new Set(affectedStories.map((s) => s.pbi_id)), - ) - const affectedTasks = - affectedStoryIds.length > 0 - ? await prisma.task.findMany({ - where: { story_id: { in: affectedStoryIds } }, - select: { id: true }, - }) - : [] - const affectedTaskIds = affectedTasks.map((t) => t.id) - - revalidatePath(`/products/${sprint.product_id}`, 'layout') - - return { - success: true, - affectedStoryIds, - affectedPbiIds, - affectedTaskIds, - conflicts: { notEligible: notEligibleAdds, alreadyRemoved }, - } -} - -export async function createSprintWithSelectionAction( - input: CreateSprintWithSelectionInput, -): Promise { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 403 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const limited = enforceUserRateLimit('create-sprint', session.userId) - if (limited) return { error: limited.error, code: limited.code } - - const parsed = createSprintWithSelectionSchema.safeParse(input) - if (!parsed.success) return { error: 'Validatie mislukt', code: 422 } - - const product = await getAccessibleProduct(parsed.data.productId, session.userId) - if (!product) return { error: 'Product niet gevonden', code: 403 } - - // Resolveer intent + per-PBI overrides naar concrete story-IDs. - const allPbiAllIds = Object.entries(parsed.data.pbiIntent) - .filter(([, intent]) => intent === 'all') - .map(([pbiId]) => pbiId) - - // Stap 1: alle child-stories voor PBI's met intent='all'. - let candidate: string[] = [] - if (allPbiAllIds.length > 0) { - const rows = await prisma.story.findMany({ - where: { pbi_id: { in: allPbiAllIds }, product_id: parsed.data.productId }, - select: { id: true, pbi_id: true }, - }) - const removedSet = new Set() - for (const [pbiId, override] of Object.entries(parsed.data.storyOverrides)) { - for (const id of override.remove) removedSet.add(`${pbiId}:${id}`) - } - candidate = rows - .filter((row) => !removedSet.has(`${row.pbi_id}:${row.id}`)) - .map((row) => row.id) - } - - // Stap 2: storyOverrides.add — werkt voor zowel intent='none' als 'all' (extra - // toevoegingen). Dedupliceren met candidates uit stap 1. - const candidateSet = new Set(candidate) - for (const override of Object.values(parsed.data.storyOverrides)) { - for (const id of override.add) candidateSet.add(id) - } - const candidateIds = Array.from(candidateSet) - - // Eligibility-filter (incl. cross-sprint guard). - const partition = await partitionByEligibility(prisma, candidateIds) - - if (partition.eligible.length === 0) { - return { - error: 'Geen eligible stories voor deze sprint', - code: 422, - } - } - - const sprint = await createWithCodeRetry( - () => generateNextSprintCode(parsed.data.productId), - (code) => - prisma.$transaction(async (tx) => { - const created = await tx.sprint.create({ - data: { - product_id: parsed.data.productId, - code, - sprint_goal: parsed.data.metadata.goal, - status: 'OPEN', - start_date: parseDate(parsed.data.metadata.startAt), - end_date: parseDate(parsed.data.metadata.endAt), - }, - }) - await tx.story.updateMany({ - where: { id: { in: partition.eligible } }, - data: { sprint_id: created.id, status: 'IN_SPRINT' }, - }) - await tx.task.updateMany({ - where: { story_id: { in: partition.eligible } }, - data: { sprint_id: created.id }, - }) - return created - }), - ) - - // Snapshot affected pbi/task IDs voor client-store patches. - const affectedStories = await prisma.story.findMany({ - where: { id: { in: partition.eligible } }, - select: { pbi_id: true }, - }) - const affectedPbiIds = Array.from(new Set(affectedStories.map((s) => s.pbi_id))) - const affectedTasks = await prisma.task.findMany({ - where: { story_id: { in: partition.eligible } }, - select: { id: true }, - }) - const affectedTaskIds = affectedTasks.map((t) => t.id) - - await setActiveSprintInSettings( - session.userId, - parsed.data.productId, - sprint.id, - ) - revalidatePath(`/products/${parsed.data.productId}`, 'layout') - - return { - success: true, - sprintId: sprint.id, - affectedStoryIds: partition.eligible, - affectedPbiIds, - affectedTaskIds, - conflicts: { - notEligible: partition.notEligible, - crossSprint: partition.crossSprint, - }, - } -} - async function getSession() { return getIronSession(await cookies(), sessionOptions) } @@ -390,7 +40,6 @@ export async function createSprintAction(_prevState: unknown, formData: FormData sprint_goal: formData.get('sprint_goal'), start_date: formData.get('start_date'), end_date: formData.get('end_date'), - pbi_id: formData.get('pbi_id'), }) if (!parsed.success) { return { @@ -403,10 +52,10 @@ export async function createSprintAction(_prevState: unknown, formData: FormData const product = await getAccessibleProduct(parsed.data.productId, session.userId) if (!product) return { error: 'Product niet gevonden', code: 403 } - // PBI-79 / ST-1342: multi-OPEN sprints toegestaan. Bestaande OPEN sprints - // op hetzelfde product zijn geen reden meer om aanmaak te blokkeren — - // cross-sprint-conflicts worden per-story afgevangen in de membership- - // commit-flow. + const existing = await prisma.sprint.findFirst({ + where: { product_id: parsed.data.productId, status: 'OPEN' }, + }) + if (existing) return { error: 'Er is al een actieve Sprint voor dit product', sprintId: existing.id, code: 422 } const sprint = await createWithCodeRetry( () => generateNextSprintCode(parsed.data.productId), @@ -423,37 +72,7 @@ export async function createSprintAction(_prevState: unknown, formData: FormData }), ) - if (parsed.data.pbi_id) { - const pbi = await prisma.pbi.findFirst({ - where: { id: parsed.data.pbi_id, product_id: parsed.data.productId }, - select: { id: true }, - }) - if (pbi) { - const stories = await prisma.story.findMany({ - where: { pbi_id: pbi.id, sprint_id: null }, - orderBy: [{ sort_order: 'asc' }], - select: { id: true }, - }) - if (stories.length > 0) { - const storyIds = stories.map(s => s.id) - await prisma.$transaction([ - ...stories.map((s, i) => - prisma.story.update({ - where: { id: s.id }, - data: { sprint_id: sprint.id, status: 'IN_SPRINT' }, - }), - ), - prisma.task.updateMany({ - where: { story_id: { in: storyIds }, sprint_id: null }, - data: { sprint_id: sprint.id }, - }), - ]) - } - } - } - - await setActiveSprintInSettings(session.userId, parsed.data.productId, sprint.id) - revalidatePath(`/products/${parsed.data.productId}`, 'layout') + revalidatePath(`/products/${parsed.data.productId}`) return { success: true, sprintId: sprint.id } } @@ -531,9 +150,14 @@ export async function addStoryToSprintAction(sprintId: string, storyId: string) if (!story) return { error: 'Story niet gevonden' } if (story.product_id !== sprint.product_id) return { error: 'Story hoort niet bij deze Sprint' } + const last = await prisma.story.findFirst({ + where: { sprint_id: sprintId }, + orderBy: { sort_order: 'desc' }, + }) + await prisma.story.update({ where: { id: storyId }, - data: { sprint_id: sprintId, status: 'IN_SPRINT' }, + data: { sprint_id: sprintId, status: 'IN_SPRINT', sort_order: (last?.sort_order ?? 0) + 1.0 }, }) revalidatePath(`/products/${sprint.product_id}/sprint`) @@ -562,6 +186,32 @@ export async function removeStoryFromSprintAction(storyId: string) { return { success: true } } +export async function reorderSprintStoriesAction(sprintId: string, orderedIds: string[]) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (hasDuplicateIds(orderedIds)) return { error: 'Ongeldige Sprint Backlog-volgorde' } + + const sprint = await prisma.sprint.findFirst({ + where: { id: sprintId, product: productAccessFilter(session.userId) }, + }) + if (!sprint) return { error: 'Sprint niet gevonden' } + + const stories = await prisma.story.findMany({ + where: { id: { in: orderedIds }, sprint_id: sprintId, product_id: sprint.product_id }, + select: { id: true }, + }) + if (stories.length !== orderedIds.length) return { error: 'Ongeldige Sprint Backlog-volgorde' } + + await prisma.$transaction( + orderedIds.map((id, i) => + prisma.story.update({ where: { id }, data: { sort_order: i + 1.0 } }) + ) + ) + + revalidatePath(`/products/${sprint.product_id}/sprint`) + return { success: true } +} export async function completeSprintAction( sprintId: string, @@ -728,7 +378,7 @@ export async function createSprintWithPbisAction(input: { }), ) - await setActiveSprintInSettings(session.userId, parsed.data.productId, sprint.id) + await setActiveSprintCookie(parsed.data.productId, sprint.id) revalidatePath(`/products/${parsed.data.productId}`, 'layout') return { success: true, sprintId: sprint.id } } diff --git a/actions/stories.ts b/actions/stories.ts index dbac04a..bcc88fc 100644 --- a/actions/stories.ts +++ b/actions/stories.ts @@ -7,7 +7,7 @@ import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { getAccessibleProduct, productAccessFilter } from '@/lib/product-access' import { requireProductWriter } from '@/lib/auth' -import { isValidCode, normalizeCode, parseCodeNumber } from '@/lib/code' +import { isValidCode, normalizeCode } from '@/lib/code' import { createWithCodeRetry, generateNextStoryCode } from '@/lib/code-server' import { createStorySchema, updateStorySchema } from '@/lib/schemas/story' import { enforceUserRateLimit } from '@/lib/rate-limit' @@ -78,6 +78,12 @@ export async function createStoryAction(_prevState: unknown, formData: FormData) } } + const last = await prisma.story.findFirst({ + where: { pbi_id: parsed.data.pbiId, priority: parsed.data.priority }, + orderBy: { sort_order: 'desc' }, + }) + const sort_order = (last?.sort_order ?? 0) + 1.0 + const insert = (code: string) => prisma.story.create({ data: { @@ -88,7 +94,7 @@ export async function createStoryAction(_prevState: unknown, formData: FormData) description: parsed.data.description ?? null, acceptance_criteria: parsed.data.acceptance_criteria ?? null, priority: parsed.data.priority, - sort_order: parseCodeNumber(code), + sort_order, status: 'OPEN', }, }) @@ -161,7 +167,7 @@ export async function updateStoryAction(_prevState: unknown, formData: FormData) await prisma.story.update({ where: { id: parsed.data.id }, data: { - ...(code ? { code, sort_order: parseCodeNumber(code) } : {}), + ...(code ? { code } : {}), title: parsed.data.title, description: parsed.data.description ?? null, acceptance_criteria: parsed.data.acceptance_criteria ?? null, @@ -357,3 +363,43 @@ export async function claimAllUnassignedInActiveSprintAction(productId: string) return { success: true, count: result.count } } +export async function reorderStoriesAction( + pbiId: string, + productId: string, + orderedIds: string[], + newPriority?: number +) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + if (hasDuplicateIds(orderedIds)) return { error: 'Ongeldige story-volgorde' } + if (newPriority !== undefined && (!Number.isInteger(newPriority) || newPriority < 1 || newPriority > 4)) { + return { error: 'Ongeldige prioriteit' } + } + + const pbi = await prisma.pbi.findFirst({ + where: { id: pbiId, product: productAccessFilter(session.userId) }, + }) + if (!pbi) return { error: 'PBI niet gevonden' } + + const stories = await prisma.story.findMany({ + where: { id: { in: orderedIds }, pbi_id: pbiId, product_id: pbi.product_id }, + select: { id: true }, + }) + if (stories.length !== orderedIds.length) return { error: 'Ongeldige story-volgorde' } + + await prisma.$transaction( + orderedIds.map((id, i) => + prisma.story.update({ + where: { id }, + data: { + sort_order: i + 1.0, + ...(newPriority !== undefined ? { priority: newPriority } : {}), + }, + }) + ) + ) + + revalidatePath(`/products/${pbi.product_id}`) + return { success: true } +} diff --git a/actions/tasks.ts b/actions/tasks.ts index 1a4ef45..7b83e03 100644 --- a/actions/tasks.ts +++ b/actions/tasks.ts @@ -10,7 +10,7 @@ import { productAccessFilter } from '@/lib/product-access' import { requireProductWriter } from '@/lib/auth' import { taskSchema as sharedTaskSchema, type TaskInput } from '@/lib/schemas/task' import { propagateStatusUpwards } from '@/lib/tasks-status-update' -import { normalizeCode, parseCodeNumber } from '@/lib/code' +import { normalizeCode } from '@/lib/code' import { createWithCodeRetry, generateNextTaskCode, isCodeUniqueConflict } from '@/lib/code-server' import { enforceUserRateLimit } from '@/lib/rate-limit' @@ -80,7 +80,6 @@ export async function saveTask( description: description ?? null, implementation_plan: implementation_plan ?? null, priority, - ...(inputCode ? { code: inputCode, sort_order: parseCodeNumber(inputCode) } : {}), }, select: { id: true, title: true, status: true }, }) @@ -107,8 +106,15 @@ export async function saveTask( }) if (!story) return { ok: false, code: 403, error: 'forbidden' } + const last = await prisma.task.findFirst({ + where: { story_id: context.storyId }, + orderBy: { sort_order: 'desc' }, + select: { sort_order: true }, + }) + const productId = story.product_id const sprintId = story.sprint_id ?? null + const sortOrder = (last?.sort_order ?? 0) + 1.0 const storyId = context.storyId const task = await createWithCodeRetry( @@ -124,7 +130,7 @@ export async function saveTask( description: description ?? null, implementation_plan: implementation_plan ?? null, priority, - sort_order: parseCodeNumber(code), + sort_order: sortOrder, status: 'TO_DO', }, select: { id: true, title: true, status: true }, @@ -201,6 +207,11 @@ export async function createTaskAction(_prevState: unknown, formData: FormData) }) if (!story) return { error: 'Story niet gevonden' } + const last = await prisma.task.findFirst({ + where: { story_id: storyId }, + orderBy: { sort_order: 'desc' }, + }) + const productId = story.product_id const task = await createWithCodeRetry( () => generateNextTaskCode(productId), @@ -214,7 +225,7 @@ export async function createTaskAction(_prevState: unknown, formData: FormData) title: parsed.data.title, description: parsed.data.description ?? null, priority: parsed.data.priority, - sort_order: parseCodeNumber(code), + sort_order: (last?.sort_order ?? 0) + 1.0, status: 'TO_DO', }, }), @@ -322,3 +333,22 @@ export async function updateTaskPlanAction(taskId: string, productId: string, im return { success: true } } +export async function reorderTasksAction(storyId: string, orderedIds: string[]) { + const session = await getSession() + if (!session.userId) return { error: 'Niet ingelogd' } + if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' } + + const story = await prisma.story.findFirst({ + where: { id: storyId, product: productAccessFilter(session.userId) }, + }) + if (!story) return { error: 'Story niet gevonden' } + + await prisma.$transaction( + orderedIds.map((id, i) => + prisma.task.update({ where: { id }, data: { sort_order: i + 1.0 } }) + ) + ) + + revalidatePath(`/products/${story.product_id}/sprint/planning`) + return { success: true } +} diff --git a/actions/user-questions.ts b/actions/user-questions.ts index d43c6d7..3a076fa 100644 --- a/actions/user-questions.ts +++ b/actions/user-questions.ts @@ -9,7 +9,6 @@ import { prisma } from '@/lib/prisma' import { SessionData, sessionOptions } from '@/lib/session' import { enforceUserRateLimit } from '@/lib/rate-limit' import { ACTIVE_JOB_STATUSES } from '@/lib/job-status' -import { getJobConfigSnapshot } from '@/lib/job-config-snapshot' async function getSession() { return getIronSession(await cookies(), sessionOptions) @@ -57,8 +56,6 @@ export async function createUserQuestionAction( }) if (existing) return { error: 'Er loopt al een actieve PLAN_CHAT voor dit idee', code: 409 } - const snapshot = await getJobConfigSnapshot({ kind: 'PLAN_CHAT', productId: idea.product_id }) - const [uq, job] = await prisma.$transaction([ prisma.userQuestion.create({ data: { @@ -74,7 +71,6 @@ export async function createUserQuestionAction( idea_id: parsed.data.ideaId, kind: 'PLAN_CHAT', status: 'QUEUED', - ...snapshot, }, }), ]) diff --git a/actions/user-settings.ts b/actions/user-settings.ts deleted file mode 100644 index e3a9cbb..0000000 --- a/actions/user-settings.ts +++ /dev/null @@ -1,62 +0,0 @@ -'use server' - -import { cookies } from 'next/headers' -import { getIronSession } from 'iron-session' -import { Prisma } from '@prisma/client' -import { prisma } from '@/lib/prisma' -import { SessionData, sessionOptions } from '@/lib/session' -import { - UserSettingsSchema, - mergeSettings, - parseUserSettings, - type UserSettings, -} from '@/lib/user-settings' - -async function getSession() { - return getIronSession(await cookies(), sessionOptions) -} - -export type UpdateUserSettingsResult = - | { success: true; settings: UserSettings } - | { error: string; code: 401 | 403 | 422; fieldErrors?: Record } - -export async function updateUserSettingsAction( - patch: Partial, -): Promise { - const session = await getSession() - if (!session.userId) return { error: 'Niet ingelogd', code: 401 } - if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 } - - const parsed = UserSettingsSchema.partial().safeParse(patch) - if (!parsed.success) { - return { - error: 'Ongeldige settings-patch', - code: 422, - fieldErrors: parsed.error.flatten().fieldErrors as Record, - } - } - - const merged = await prisma.$transaction(async (tx) => { - const user = await tx.user.findUnique({ - where: { id: session.userId! }, - select: { settings: true }, - }) - const current = parseUserSettings(user?.settings) - const next = mergeSettings(current, parsed.data) - await tx.user.update({ - where: { id: session.userId! }, - data: { settings: next as unknown as Prisma.InputJsonValue }, - }) - return next - }) - - await prisma.$executeRaw` - SELECT pg_notify('scrum4me_changes', ${JSON.stringify({ - kind: 'user_settings', - userId: session.userId, - patch: parsed.data, - })}::text) - ` - - return { success: true, settings: merged } -} diff --git a/app/(app)/admin/jobs/page.tsx b/app/(app)/admin/jobs/page.tsx index 8a3ba85..76d0825 100644 --- a/app/(app)/admin/jobs/page.tsx +++ b/app/(app)/admin/jobs/page.tsx @@ -21,10 +21,6 @@ export default async function AdminJobsPage() { output_tokens: true, cache_read_tokens: true, cache_write_tokens: true, - actual_thinking_tokens: true, - requested_model: true, - requested_thinking_budget: true, - requested_permission_mode: true, user: { select: { username: true } }, product: { select: { name: true } }, }, @@ -40,8 +36,7 @@ export default async function AdminJobsPage() { (job.input_tokens ?? 0) * Number(p.input_price_per_1m) / 1_000_000 + (job.output_tokens ?? 0) * Number(p.output_price_per_1m) / 1_000_000 + (job.cache_read_tokens ?? 0) * Number(p.cache_read_price_per_1m) / 1_000_000 + - (job.cache_write_tokens ?? 0) * Number(p.cache_write_price_per_1m) / 1_000_000 + - (job.actual_thinking_tokens ?? 0) * Number(p.input_price_per_1m) / 1_000_000 + (job.cache_write_tokens ?? 0) * Number(p.cache_write_price_per_1m) / 1_000_000 return { ...job, cost_usd: cost } }) diff --git a/app/(app)/dashboard/loading.tsx b/app/(app)/dashboard/loading.tsx index 40800ab..656e323 100644 --- a/app/(app)/dashboard/loading.tsx +++ b/app/(app)/dashboard/loading.tsx @@ -1,12 +1,10 @@ -import { Skeleton } from '@/components/ui/skeleton' - export default function Loading() { return ( -
- +
+
- {[1, 2, 3].map((i) => ( - + {[1, 2, 3].map(i => ( +
))}
diff --git a/app/(app)/ideas/[id]/page.tsx b/app/(app)/ideas/[id]/page.tsx index 80d946c..d548a81 100644 --- a/app/(app)/ideas/[id]/page.tsx +++ b/app/(app)/ideas/[id]/page.tsx @@ -8,7 +8,6 @@ import { productAccessFilter } from '@/lib/product-access' import { ideaToDto } from '@/lib/idea-dto' import { IdeaDetailLayout } from '@/components/ideas/idea-detail-layout' import { loadIdeaSyncData } from './sync-tab-server' -import type { ReviewLog } from '@/components/ideas/review-log-viewer' export const dynamic = 'force-dynamic' @@ -27,25 +26,10 @@ export default async function IdeaDetailPage({ params, searchParams }: PageProps // M12: strikt user_id-only — 404 (niet 403) voor andere users (anti-enum). const idea = await prisma.idea.findFirst({ where: { id, user_id: session.userId }, - select: { - id: true, - user_id: true, - product_id: true, - code: true, - title: true, - description: true, - status: true, - pbi_id: true, - archived: true, - grill_md: true, - plan_md: true, - plan_review_log: true, - reviewed_at: true, - created_at: true, - updated_at: true, + include: { product: { select: { id: true, name: true, repo_url: true } }, pbi: { select: { id: true, code: true, title: true } }, - secondary_products: { select: { id: true, product_id: true, product: { select: { id: true, name: true } } } }, + secondary_products: { include: { product: { select: { id: true, name: true } } } }, }, }) if (!idea) notFound() @@ -107,7 +91,6 @@ export default async function IdeaDetailPage({ params, searchParams }: PageProps idea={ideaToDto(idea)} grill_md={idea.grill_md} plan_md={idea.plan_md} - plan_review_log={(idea.plan_review_log as ReviewLog | null) ?? null} products={products} logs={logs.map((l) => ({ id: l.id, diff --git a/app/(app)/ideas/page.tsx b/app/(app)/ideas/page.tsx index 1c4fd5e..1b2c45d 100644 --- a/app/(app)/ideas/page.tsx +++ b/app/(app)/ideas/page.tsx @@ -32,16 +32,6 @@ export default async function IdeasPage() { select: { id: true, name: true, repo_url: true }, }) - const user = await prisma.user.findUnique({ - where: { id: session.userId }, - select: { active_product_id: true }, - }) - - const activeProductId = - user?.active_product_id && products.some((p) => p.id === user.active_product_id) - ? user.active_product_id - : null - return (
@@ -55,7 +45,6 @@ export default async function IdeasPage() { ideas={ideas.map((i) => ideaToDto(i))} products={products} isDemo={session.isDemo ?? false} - activeProductId={activeProductId} />
) diff --git a/app/(app)/insights/components/cost-analysis.tsx b/app/(app)/insights/components/cost-analysis.tsx deleted file mode 100644 index f147ae3..0000000 --- a/app/(app)/insights/components/cost-analysis.tsx +++ /dev/null @@ -1,272 +0,0 @@ -'use client' - -import { useTransition } from 'react' -import { useRouter, usePathname, useSearchParams } from 'next/navigation' -import { - BarChart, - Bar, - XAxis, - YAxis, - Tooltip, - PieChart, - Pie, - Cell, - ResponsiveContainer, -} from 'recharts' -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select' -import type { - Period, - CostKpi, - CostByDayRow, - CostByModelRow, - CostByKindRow, - CacheEfficiency, -} from '@/lib/insights/cost-analysis' - -interface Props { - period: Period - kpi: CostKpi - byDay: CostByDayRow[] - byModel: CostByModelRow[] - byKind: CostByKindRow[] - cache: CacheEfficiency -} - -const PERIOD_LABELS: Record = { - '7d': 'Laatste 7 dagen', - '30d': 'Laatste 30 dagen', - '90d': 'Laatste 90 dagen', - mtd: 'Deze maand', -} - -const KIND_LABELS: Record = { - TASK_IMPLEMENTATION: 'Task impl.', - IDEA_GRILL: 'Idea grill', - IDEA_MAKE_PLAN: 'Idea plan', - PLAN_CHAT: 'Plan chat', - SPRINT_IMPLEMENTATION: 'Sprint impl.', -} - -function fmtUsd(n: number, decimals = 2): string { - return '$' + n.toFixed(decimals) -} - -function shortenModel(modelId: string): string { - return modelId.replace(/^claude-/, '') -} - -export function CostAnalysisCard({ period, kpi, byDay, byModel, byKind, cache }: Props) { - const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() - const [isPending, startTransition] = useTransition() - - function handlePeriodChange(value: string | null) { - if (value === null) return - startTransition(() => { - const params = new URLSearchParams(searchParams.toString()) - params.set('period', value) - router.replace(`${pathname}?${params.toString()}`) - }) - } - - const periodSelector = ( - - ) - - if (kpi.jobCount === 0) { - return ( -
-
-

Geen jobs in deze periode.

- {periodSelector} -
-
- ) - } - - const cacheData = [ - { name: 'Cache', value: cache.cacheReadTokens }, - { name: 'Uncached input', value: cache.uncachedInputTokens }, - ] - const cacheColors = ['var(--status-done)', 'var(--muted-foreground)'] - - const modelData = byModel.map(m => ({ ...m, label: shortenModel(m.modelId) })) - const kindData = byKind.map(k => ({ ...k, label: KIND_LABELS[k.kind] ?? k.kind })) - - return ( -
- {/* KPI strip + period selector */} -
-
-
-
{fmtUsd(kpi.totalCostUsd)}
-
Totaal kosten
-
-
-
{fmtUsd(kpi.avgPerDayUsd)}
-
Gem. per dag
-
-
-
- {fmtUsd(kpi.cacheSavingsUsd)} -
-
Cache-besparing
-
-
-
- {kpi.topModelId ? fmtUsd(kpi.topModelCostUsd) : '—'} -
-
- Top model{kpi.topModelId ? `: ${shortenModel(kpi.topModelId)}` : ''} -
-
-
- {periodSelector} -
- -
- {/* Daily cost */} -
-
Kosten per dag
- {byDay.length === 0 ? ( -

Geen data

- ) : ( - - - (v as string).slice(5)} - /> - fmtUsd(v as number, 2)} - /> - [fmtUsd(Number(value), 4), 'Kosten']} - /> - - - - )} -
- - {/* Per model */} -
-
Kosten per model
- {modelData.length === 0 ? ( -

Geen data

- ) : ( - - - fmtUsd(v as number, 2)} - /> - - [fmtUsd(Number(value), 4), 'Kosten']} /> - - - - )} -
- - {/* Per kind */} -
-
Kosten per job-kind
- {kindData.length === 0 ? ( -

Geen data

- ) : ( - - - fmtUsd(v as number, 2)} - /> - - [fmtUsd(Number(value), 4), 'Kosten']} /> - - - - )} -
- - {/* Cache efficiency */} -
-
Cache efficiency
- {cache.cacheReadTokens + cache.uncachedInputTokens === 0 ? ( -

Geen data

- ) : ( - <> - - - - {cacheData.map((_, i) => ( - - ))} - - [ - Number(value).toLocaleString() + ' tokens', - String(name), - ]} - /> - - -

- {(cache.cacheHitRatio * 100).toFixed(1)}%{' '} - cache hit ·{' '} - - {fmtUsd(cache.savingsUsd)} - {' '} - bespaard -

- - )} -
-
-
- ) -} diff --git a/app/(app)/insights/page.tsx b/app/(app)/insights/page.tsx index 802258c..9b90641 100644 --- a/app/(app)/insights/page.tsx +++ b/app/(app)/insights/page.tsx @@ -8,14 +8,6 @@ import { getSprintStatusBreakdown } from '@/lib/insights/sprint-status' import { getVerifyResultStats, getAlignmentTrend } from '@/lib/insights/verify-stats' import { getJobsPerDay } from '@/lib/insights/agent-throughput' import { getTokenStats } from '@/lib/insights/token-stats' -import { - getCostKpi, - getCostByDay, - getCostByModel, - getCostByKind, - getCacheEfficiency, - type Period, -} from '@/lib/insights/cost-analysis' import { getVelocity } from '@/lib/insights/velocity' import { getBacklogHealth } from '@/lib/insights/backlog-health' import { SprintInfoStrip } from './components/sprint-info-strip' @@ -24,7 +16,6 @@ import { SprintStatusDonut } from './components/sprint-status-donut' import { PlanQualityCard } from './components/plan-quality' import { AlignmentTrend } from './components/alignment-trend' import { AgentThroughputCard } from './components/agent-throughput' -import { CostAnalysisCard } from './components/cost-analysis' import { TokenUsageCard } from './components/token-usage' import { VelocityChart } from './components/velocity-chart' import { BacklogHealthCard } from './components/backlog-health' @@ -33,13 +24,7 @@ const DAY_MS = 86_400_000 const ASSUMED_SPRINT_DAYS = 14 interface InsightsPageProps { - searchParams: Promise<{ product?: string; period?: string }> -} - -const VALID_PERIODS = ['7d', '30d', '90d', 'mtd'] as const - -function parsePeriod(raw: string | undefined): Period { - return (VALID_PERIODS as readonly string[]).includes(raw ?? '') ? (raw as Period) : '30d' + searchParams: Promise<{ product?: string }> } function MissingDatesNotice({ productId, productName }: { productId: string; productName: string }) { @@ -56,8 +41,7 @@ function MissingDatesNotice({ productId, productName }: { productId: string; pro export default async function InsightsPage({ searchParams }: InsightsPageProps) { const session = await getIronSession(await cookies(), sessionOptions) const userId = session.userId! - const { product: filterProductId, period: rawPeriod } = await searchParams - const period = parsePeriod(rawPeriod) + const { product: filterProductId } = await searchParams const [ burndownSprints, @@ -69,11 +53,6 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps) jobsPerDay, velocity, backlogHealth, - costKpi, - costByDay, - costByModel, - costByKind, - cacheEff, ] = await Promise.all([ getBurndownData(userId), getSprintStatusBreakdown(userId), @@ -98,11 +77,6 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps) getJobsPerDay(userId, 14, filterProductId), getVelocity(userId, 5), getBacklogHealth(userId), - getCostKpi(userId, period), - getCostByDay(userId, period), - getCostByModel(userId, period), - getCostByKind(userId, period), - getCacheEfficiency(userId, period), ]) const activeSprintId = activeSprints.find(s => s.product.id === filterProductId)?.id ?? '' @@ -160,19 +134,6 @@ export default async function InsightsPage({ searchParams }: InsightsPageProps) )} - {/* Cost analyse */} -
-

Cost analyse

- -
- {/* Plan-quality */}

Plan-quality

diff --git a/app/(app)/jobs/page.tsx b/app/(app)/jobs/page.tsx index 25c571b..3982bff 100644 --- a/app/(app)/jobs/page.tsx +++ b/app/(app)/jobs/page.tsx @@ -2,7 +2,6 @@ import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { fetchJobsPageData } from '@/actions/jobs-page' import JobsBoard from '@/components/jobs/jobs-board' -import JobsTimeFilter from '@/components/jobs/jobs-time-filter' export const metadata = { title: 'Jobs — Scrum4Me' } @@ -15,12 +14,11 @@ export default async function JobsPage() { return (
-
+

Jobs

-
- +
) diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 15eab5a..d3f2b10 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -8,8 +8,6 @@ import { MinWidthBanner } from '@/components/shared/min-width-banner' import { StatusBar } from '@/components/shared/status-bar' import { SoloRealtimeBridge } from '@/components/solo/realtime-bridge' import { NotificationsBridge } from '@/components/notifications/notifications-bridge' -import { UserSettingsBridge } from '@/components/shared/user-settings-bridge' -import { parseUserSettings } from '@/lib/user-settings' import { AlertToast } from '@/components/shared/alert-toast' import { Suspense } from 'react' @@ -19,7 +17,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod const [user, userRoles, accessibleProducts] = await Promise.all([ prisma.user.findUnique({ where: { id: session.userId }, - select: { username: true, email: true, active_product_id: true, min_quota_pct: true, settings: true }, + select: { username: true, email: true, active_product_id: true, min_quota_pct: true }, }), prisma.userRole.findMany({ where: { user_id: session.userId }, @@ -39,6 +37,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod // Resolve active product — clear stale reference if archived or inaccessible let activeProduct: { id: string; name: string } | null = null + let activeSprintId: string | null = null let hasActiveSprint = false if (user.active_product_id) { const product = await prisma.product.findFirst({ @@ -47,7 +46,8 @@ export default async function AppLayout({ children }: { children: React.ReactNod }) if (product) { activeProduct = product - const resolved = await resolveActiveSprint(product.id, session.userId) + const resolved = await resolveActiveSprint(product.id) + activeSprintId = resolved?.id ?? null hasActiveSprint = !!resolved } else { await prisma.user.update({ @@ -72,6 +72,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod activeProduct={activeProduct} products={accessibleProducts} hasActiveSprint={hasActiveSprint} + activeSprintId={activeSprintId} minQuotaPct={user.min_quota_pct} /> @@ -81,10 +82,6 @@ export default async function AppLayout({ children }: { children: React.ReactNod - diff --git a/app/(app)/products/[id]/loading.tsx b/app/(app)/products/[id]/loading.tsx index 8dcb9c1..795b2c5 100644 --- a/app/(app)/products/[id]/loading.tsx +++ b/app/(app)/products/[id]/loading.tsx @@ -1 +1,34 @@ -export { default } from '@/components/loading/backlog-page-skeleton' +export default function Loading() { + return ( +
+ {/* Header skeleton */} +
+
+
+
+
+
+
+ + {/* Split pane skeleton */} +
+ {/* Left */} +
+
+ {[1, 2, 3, 4, 5].map(i => ( +
+ ))} +
+ {/* Right */} +
+
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+
+
+
+ ) +} diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index 161b4dc..7d1d172 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -4,25 +4,20 @@ import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' import { prisma } from '@/lib/prisma' import { pbiStatusToApi } from '@/lib/task-status' -import { getSprintSwitcherData } from '@/lib/sprint-switcher-data' import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane' import { PbiList } from '@/components/backlog/pbi-list' import { StoryPanel } from '@/components/backlog/story-panel' import type { Story } from '@/components/backlog/story-panel' import { TaskPanel } from '@/components/backlog/task-panel' import { BacklogHydrationWrapper } from '@/components/backlog/backlog-hydration-wrapper' -import { UrlTaskSync } from '@/components/backlog/url-task-sync' import { TaskDialog } from '@/app/_components/tasks/task-dialog' import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader' import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton' -import { NewSprintTrigger } from '@/components/backlog/new-sprint-trigger' -import { SprintDraftBanner } from '@/components/backlog/sprint-draft-banner' -import { SprintDraftLeaveGuard } from '@/components/backlog/sprint-draft-leave-guard' -import { SaveSprintButton } from '@/components/backlog/save-sprint-button' -import { ActiveSelectionHydrator } from '@/components/backlog/active-selection-hydrator' +import { StartSprintButton } from '@/components/sprint/start-sprint-button' +import { SprintSwitcher } from '@/components/sprint/sprint-switcher' import { ActivateProductButton } from '@/components/shared/activate-product-button' import { EditProductButton } from '@/components/products/edit-product-button' -import { SprintSwitcher } from '@/components/shared/sprint-switcher' +import { resolveActiveSprint } from '@/lib/active-sprint' import Link from 'next/link' interface Props { @@ -40,13 +35,15 @@ export default async function ProductBacklogPage({ params, searchParams }: Props const product = await getAccessibleProduct(id, session.userId) if (!product) notFound() - const [user, switcherData] = await Promise.all([ + const [allSprints, user, activeSprint] = await Promise.all([ + prisma.sprint.findMany({ + where: { product_id: id }, + orderBy: { created_at: 'desc' }, + select: { id: true, code: true }, + }), prisma.user.findUnique({ where: { id: session.userId! }, select: { active_product_id: true } }), - getSprintSwitcherData(id, { userId: session.userId }), + resolveActiveSprint(id), ]) - const { sprintItems, buildingSprintIds, activeSprintItem } = switcherData - const hasOpenSprint = sprintItems.some(s => s.status === 'open') - const isActiveProduct = user?.active_product_id === id const pbis = await prisma.pbi.findMany({ where: { product_id: id }, @@ -56,7 +53,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props const [stories, tasks] = await Promise.all([ prisma.story.findMany({ where: { product_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], select: { id: true, code: true, @@ -64,10 +61,8 @@ export default async function ProductBacklogPage({ params, searchParams }: Props description: true, acceptance_criteria: true, priority: true, - sort_order: true, status: true, pbi_id: true, - sprint_id: true, created_at: true, }, }), @@ -75,7 +70,6 @@ export default async function ProductBacklogPage({ params, searchParams }: Props where: { story: { pbi: { product_id: id } } }, select: { id: true, - code: true, title: true, description: true, priority: true, @@ -84,11 +78,11 @@ export default async function ProductBacklogPage({ params, searchParams }: Props story_id: true, created_at: true, }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], }), ]) - // Group stories by PBI id (status uit DB blijft UPPER_SNAKE in dit hydratie-pad) + // Group stories by PBI id const storiesByPbi: Record = {} for (const story of stories) { if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = [] @@ -106,38 +100,22 @@ export default async function ProductBacklogPage({ params, searchParams }: Props return (
- {/* Product header — sprint-switcher gecentreerd, actions rechts */} -
-
-
- {isActiveProduct && ( - - )} -
-
- {!isActiveProduct && ( + {/* Product header — actions only; product-naam zit al in NavBar */} +
+
+ {user?.active_product_id !== id && ( )} - {hasOpenSprint && ( - - Sprint actief → - - )} - {activeSprintItem && !isDemo && ( - - )} - {!isDemo && ( - 0 && ( + )} + {!isDemo && !activeSprint && ( + + )} {!isDemo && product.user_id === session.userId && (
- {/* Sprint definition banner (state A′) + beforeunload-guard */} - - - {/* Split pane */}
({ id: p.id, code: p.code, title: p.title, priority: p.priority, sort_order: p.sort_order, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), + pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), storiesByPbi, tasksByStory, }} > - - , , @@ -20,45 +19,101 @@ export default async function SoloProductPage({ params }: Props) { const product = await getAccessibleProduct(id, session.userId) if (!product) notFound() - const initialData = await getSoloWorkspaceSnapshot(id, session.userId) - const switcherData = await getSprintSwitcherData(id, { - activeSprintId: initialData?.sprint.id ?? null, + const sprint = await prisma.sprint.findFirst({ + where: { product_id: id, status: 'OPEN' }, }) - const switcherBar = ( -
- -
- ) - - if (!initialData) { + if (!sprint) { return (
- {switcherBar}
) } + const [rawTasks, rawUnassigned] = await Promise.all([ + prisma.task.findMany({ + where: { + story: { + sprint_id: sprint.id, + assignee_id: session.userId, + }, + }, + include: { + story: { + select: { + id: true, + code: true, + title: true, + tasks: { select: { id: true }, orderBy: { sort_order: 'asc' } }, + pbi: { select: { code: true, title: true, description: true } }, + }, + }, + }, + orderBy: [ + { story: { pbi: { priority: 'asc' } } }, + { story: { pbi: { sort_order: 'asc' } } }, + { story: { sort_order: 'asc' } }, + { priority: 'asc' }, + { sort_order: 'asc' }, + ], + }), + prisma.story.findMany({ + where: { sprint_id: sprint.id, assignee_id: null }, + select: { + id: true, + code: true, + title: true, + tasks: { + select: { id: true, title: true, description: true, priority: true, status: true }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + }, + }, + orderBy: { sort_order: 'asc' }, + }), + ]) + + const tasks: SoloTask[] = rawTasks.map(t => ({ + id: t.id, + title: t.title, + description: t.description, + implementation_plan: t.implementation_plan, + priority: t.priority, + sort_order: t.sort_order, + status: t.status as SoloTask['status'], + verify_only: t.verify_only, + verify_required: t.verify_required as SoloTask['verify_required'], + story_id: t.story.id, + story_code: t.story.code, + story_title: t.story.title, + task_code: t.code, + pbi_code: t.story.pbi?.code ?? null, + pbi_title: t.story.pbi?.title ?? null, + pbi_description: t.story.pbi?.description ?? null, + })) + + const unassignedStories: UnassignedStory[] = rawUnassigned.map(s => ({ + id: s.id, + code: s.code, + title: s.title, + tasks: s.tasks.map(t => ({ + id: t.id, + title: t.title, + description: t.description, + priority: t.priority, + status: t.status, + })), + })) + return ( -
- {switcherBar} -
- - - -
-
+ ) } diff --git a/app/(app)/products/[id]/sprint/[sprintId]/loading.tsx b/app/(app)/products/[id]/sprint/[sprintId]/loading.tsx index 8dcb9c1..795b2c5 100644 --- a/app/(app)/products/[id]/sprint/[sprintId]/loading.tsx +++ b/app/(app)/products/[id]/sprint/[sprintId]/loading.tsx @@ -1 +1,34 @@ -export { default } from '@/components/loading/backlog-page-skeleton' +export default function Loading() { + return ( +
+ {/* Header skeleton */} +
+
+
+
+
+
+
+ + {/* Split pane skeleton */} +
+ {/* Left */} +
+
+ {[1, 2, 3, 4, 5].map(i => ( +
+ ))} +
+ {/* Right */} +
+
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+
+
+
+ ) +} diff --git a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx index 2981c47..10314c9 100644 --- a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx +++ b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx @@ -1,23 +1,18 @@ +import { Suspense } from 'react' import { notFound, redirect } from 'next/navigation' import { getSession } from '@/lib/auth' import { getAccessibleProduct } from '@/lib/product-access' import { prisma } from '@/lib/prisma' import { pbiStatusToApi } from '@/lib/task-status' import { SprintBoardClient } from '@/components/sprint/sprint-board-client' -import { - SprintHydrationWrapper, - type SprintHydrationData, -} from '@/components/sprint/sprint-hydration-wrapper' -import { SprintTaskDialogMount } from '@/components/sprint/sprint-task-dialog-mount' -import { SprintUrlTaskSync } from '@/components/sprint/sprint-url-task-sync' -import { SyncActiveSprintCookie } from '@/components/sprint/sync-active-sprint-cookie' -import { getSprintSwitcherData } from '@/lib/sprint-switcher-data' import { SprintHeader } from '@/components/sprint/sprint-header' import { SprintRunControls } from '@/components/sprint/sprint-run-controls' import { parsePauseContext } from '@/lib/pause-context' import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog' -import type { SprintWorkspaceTask } from '@/stores/sprint-workspace/types' +import type { Task } from '@/components/sprint/task-list' import { TaskDialog } from '@/app/_components/tasks/task-dialog' +import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader' +import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton' import Link from 'next/link' interface Props { @@ -25,12 +20,13 @@ interface Props { searchParams: Promise<{ newTask?: string storyId?: string + editTask?: string }> } export default async function SprintBoardPage({ params, searchParams }: Props) { const { id, sprintId } = await params - const { newTask, storyId: storyIdParam } = await searchParams + const { newTask, storyId: storyIdParam, editTask } = await searchParams const session = await getSession() if (!session.userId) redirect('/login') @@ -51,8 +47,6 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { }) if (!sprint) notFound() - const switcherData = await getSprintSwitcherData(id, { activeSprintId: sprint.id }) - const activeSprintRun = await prisma.sprintRun.findFirst({ where: { sprint_id: sprint.id, @@ -72,7 +66,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { where: { sprint_id: sprint.id }, orderBy: { sort_order: 'asc' }, include: { - tasks: { orderBy: [{ sort_order: 'asc' }] }, + tasks: { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }] }, assignee: { select: { id: true, username: true } }, }, }), @@ -95,10 +89,8 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { description: s.description, acceptance_criteria: s.acceptance_criteria, pbi_id: s.pbi_id, - sprint_id: s.sprint_id, created_at: s.created_at, priority: s.priority, - sort_order: s.sort_order, status: s.status, taskCount: s.tasks.length, doneCount: s.tasks.filter(t => t.status === 'DONE').length, @@ -106,19 +98,17 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { assignee_username: s.assignee?.username ?? null, })) - const tasksByStoryWorkspace: Record = {} + const tasksByStory: Record = {} for (const story of sprintStories) { - tasksByStoryWorkspace[story.id] = story.tasks.map(t => ({ + tasksByStory[story.id] = story.tasks.map(t => ({ id: t.id, code: t.code, title: t.title, description: t.description, priority: t.priority, - sort_order: t.sort_order, status: t.status, story_id: t.story_id, sprint_id: t.sprint_id, - created_at: t.created_at, })) } @@ -128,7 +118,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], include: { stories: { - orderBy: [{ sort_order: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], }, }, }) @@ -149,10 +139,8 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { description: s.description, acceptance_criteria: s.acceptance_criteria, pbi_id: s.pbi_id, - sprint_id: s.sprint_id, created_at: s.created_at, priority: s.priority, - sort_order: s.sort_order, status: s.status, taskCount: 0, doneCount: 0, @@ -161,37 +149,18 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { })), })) + const sprintStoryIdList = sprintStories.map(s => s.id) const isDemo = session.isDemo ?? false const closePath = `/products/${id}/sprint/${sprint.id}` - const hydrationData: SprintHydrationData = { - sprint: { - id: sprint.id, - product_id: id, - code: sprint.code, - sprint_goal: sprint.sprint_goal, - status: sprint.status as 'OPEN' | 'CLOSED', - start_date: sprint.start_date ? sprint.start_date.toISOString().slice(0, 10) : null, - end_date: sprint.end_date ? sprint.end_date.toISOString().slice(0, 10) : null, - created_at: new Date(), - completed_at: null, - }, - stories: sprintStoryItems, - tasksByStory: tasksByStoryWorkspace, - } - return ( -
- +
@@ -207,23 +176,17 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
- - - - - + sprintId={sprint.id} + stories={sprintStoryItems} + pbisWithStories={pbisWithStories} + sprintStoryIdList={sprintStoryIdList} + tasksByStory={tasksByStory} + isDemo={isDemo} + currentUserId={session.userId} + members={members} + />
@@ -240,6 +203,18 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { isDemo={isDemo} /> )} + + {editTask && !newTask && ( + }> + + + )}
) } diff --git a/app/(app)/products/[id]/sprint/[sprintId]/planning/loading.tsx b/app/(app)/products/[id]/sprint/[sprintId]/planning/loading.tsx index 8dcb9c1..795b2c5 100644 --- a/app/(app)/products/[id]/sprint/[sprintId]/planning/loading.tsx +++ b/app/(app)/products/[id]/sprint/[sprintId]/planning/loading.tsx @@ -1 +1,34 @@ -export { default } from '@/components/loading/backlog-page-skeleton' +export default function Loading() { + return ( +
+ {/* Header skeleton */} +
+
+
+
+
+
+
+ + {/* Split pane skeleton */} +
+ {/* Left */} +
+
+ {[1, 2, 3, 4, 5].map(i => ( +
+ ))} +
+ {/* Right */} +
+
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+
+
+
+ ) +} diff --git a/app/(app)/products/[id]/sprint/page.tsx b/app/(app)/products/[id]/sprint/page.tsx index 5f0e6ab..cea6993 100644 --- a/app/(app)/products/[id]/sprint/page.tsx +++ b/app/(app)/products/[id]/sprint/page.tsx @@ -1,5 +1,4 @@ import { redirect } from 'next/navigation' -import { requireSession } from '@/lib/auth-guard' import { resolveActiveSprint } from '@/lib/active-sprint' interface Props { @@ -8,8 +7,7 @@ interface Props { export default async function SprintRedirectPage({ params }: Props) { const { id } = await params - const session = await requireSession() - const active = await resolveActiveSprint(id, session.userId) + const active = await resolveActiveSprint(id) if (!active) { redirect(`/products/${id}?alert=no_sprint`) } diff --git a/app/(app)/settings/loading.tsx b/app/(app)/settings/loading.tsx index 11488b0..07f3dd9 100644 --- a/app/(app)/settings/loading.tsx +++ b/app/(app)/settings/loading.tsx @@ -1,11 +1,9 @@ -import { Skeleton } from '@/components/ui/skeleton' - export default function Loading() { return ( -
- - {[1, 2, 3, 4].map((i) => ( - +
+
+ {[1, 2, 3, 4].map(i => ( +
))}
) diff --git a/app/(mobile)/m/products/[id]/page.tsx b/app/(mobile)/m/products/[id]/page.tsx index 4aa5815..136188d 100644 --- a/app/(mobile)/m/products/[id]/page.tsx +++ b/app/(mobile)/m/products/[id]/page.tsx @@ -15,7 +15,6 @@ import { StoryPanel } from '@/components/backlog/story-panel' import type { Story } from '@/components/backlog/story-panel' import { TaskPanel } from '@/components/backlog/task-panel' import { BacklogHydrationWrapper } from '@/components/backlog/backlog-hydration-wrapper' -import { UrlTaskSync } from '@/components/backlog/url-task-sync' import { TaskDialog } from '@/app/_components/tasks/task-dialog' import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader' import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton' @@ -42,7 +41,7 @@ export default async function MobileProductBacklogPage({ params, searchParams }: const [stories, tasks] = await Promise.all([ prisma.story.findMany({ where: { product_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], select: { id: true, code: true, @@ -50,10 +49,8 @@ export default async function MobileProductBacklogPage({ params, searchParams }: description: true, acceptance_criteria: true, priority: true, - sort_order: true, status: true, pbi_id: true, - sprint_id: true, created_at: true, }, }), @@ -61,7 +58,6 @@ export default async function MobileProductBacklogPage({ params, searchParams }: where: { story: { pbi: { product_id: id } } }, select: { id: true, - code: true, title: true, description: true, priority: true, @@ -70,7 +66,7 @@ export default async function MobileProductBacklogPage({ params, searchParams }: story_id: true, created_at: true, }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], }), ]) @@ -92,14 +88,12 @@ export default async function MobileProductBacklogPage({ params, searchParams }:
({ id: p.id, code: p.code, title: p.title, priority: p.priority, sort_order: p.sort_order, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), + pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), storiesByPbi, tasksByStory, }} > - @@ -23,9 +24,11 @@ export default async function MobileSoloProductPage({ params }: Props) { const product = await getAccessibleProduct(id, session.userId) if (!product) notFound() - const initialData = await getSoloWorkspaceSnapshot(id, session.userId) + const sprint = await prisma.sprint.findFirst({ + where: { product_id: id, status: 'OPEN' }, + }) - if (!initialData) { + if (!sprint) { return (
@@ -33,15 +36,89 @@ export default async function MobileSoloProductPage({ params }: Props) { ) } + const [rawTasks, rawUnassigned] = await Promise.all([ + prisma.task.findMany({ + where: { + story: { + sprint_id: sprint.id, + assignee_id: session.userId, + }, + }, + include: { + story: { + select: { + id: true, + code: true, + title: true, + tasks: { select: { id: true }, orderBy: { sort_order: 'asc' } }, + pbi: { select: { code: true, title: true, description: true } }, + }, + }, + }, + orderBy: [ + { story: { pbi: { priority: 'asc' } } }, + { story: { pbi: { sort_order: 'asc' } } }, + { story: { sort_order: 'asc' } }, + { priority: 'asc' }, + { sort_order: 'asc' }, + ], + }), + prisma.story.findMany({ + where: { sprint_id: sprint.id, assignee_id: null }, + select: { + id: true, + code: true, + title: true, + tasks: { + select: { id: true, title: true, description: true, priority: true, status: true }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], + }, + }, + orderBy: { sort_order: 'asc' }, + }), + ]) + + const tasks: SoloTask[] = rawTasks.map(t => ({ + id: t.id, + title: t.title, + description: t.description, + implementation_plan: t.implementation_plan, + priority: t.priority, + sort_order: t.sort_order, + status: t.status as SoloTask['status'], + verify_only: t.verify_only, + verify_required: t.verify_required as SoloTask['verify_required'], + story_id: t.story.id, + story_code: t.story.code, + story_title: t.story.title, + task_code: t.code, + pbi_code: t.story.pbi?.code ?? null, + pbi_title: t.story.pbi?.title ?? null, + pbi_description: t.story.pbi?.description ?? null, + })) + + const unassignedStories: UnassignedStory[] = rawUnassigned.map(s => ({ + id: s.id, + code: s.code, + title: s.title, + tasks: s.tasks.map(t => ({ + id: t.id, + title: t.title, + description: t.description, + priority: t.priority, + status: t.status, + })), + })) + return ( - - - + ) } diff --git a/app/_components/tasks/task-dialog-skeleton.tsx b/app/_components/tasks/task-dialog-skeleton.tsx index 823abb7..bb6a66b 100644 --- a/app/_components/tasks/task-dialog-skeleton.tsx +++ b/app/_components/tasks/task-dialog-skeleton.tsx @@ -1,11 +1,5 @@ import { Skeleton } from '@/components/ui/skeleton' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' -import { - entityDialogBodyClasses, - entityDialogContentClasses, - entityDialogFooterClasses, - entityDialogHeaderClasses, -} from '@/components/shared/entity-dialog-layout' import { cn } from '@/lib/utils' export function TaskDialogSkeleton() { @@ -14,54 +8,32 @@ export function TaskDialogSkeleton() { Taak laden… -
-
- - -
- + {/* Header */} +
+
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
+ {/* Body — 3 bars mimicking title + description + plan */} +
+ + +
-
-
- -
- - -
+ {/* Footer */} +
+
+ +
diff --git a/app/_components/tasks/task-dialog.tsx b/app/_components/tasks/task-dialog.tsx index 638d834..3431cda 100644 --- a/app/_components/tasks/task-dialog.tsx +++ b/app/_components/tasks/task-dialog.tsx @@ -60,9 +60,7 @@ interface TaskDialogProps { task?: TaskDialogTask storyId?: string productId: string - closePath?: string - onClose?: () => void - onSaved?: (taskId: string) => void + closePath: string isDemo?: boolean } @@ -83,7 +81,7 @@ const textareaClass = cn( 'overflow-y-auto', ) -export function TaskDialog({ task, storyId, productId, closePath, onClose, onSaved, isDemo = false }: TaskDialogProps) { +export function TaskDialog({ task, storyId, productId, closePath, isDemo = false }: TaskDialogProps) { const router = useRouter() const [isPending, startTransition] = useTransition() const [confirmDelete, setConfirmDelete] = useState(false) @@ -102,12 +100,11 @@ export function TaskDialog({ task, storyId, productId, closePath, onClose, onSav }, }) - function close() { - if (onClose) { onClose(); return } - if (closePath) router.push(closePath) + function handleClose() { + router.push(closePath) } - const closeGuard = useDirtyCloseGuard(form.formState.isDirty, close) + const closeGuard = useDirtyCloseGuard(form.formState.isDirty, handleClose) const handleKeyDown = useDialogSubmitShortcut(() => form.handleSubmit(onSubmit)()) function onSubmit(data: TaskInput) { @@ -120,8 +117,7 @@ export function TaskDialog({ task, storyId, productId, closePath, onClose, onSav if (result.ok) { toast.success(isEdit ? 'Taak opgeslagen' : 'Taak aangemaakt') - onSaved?.(result.task.id) - close() + router.push(closePath) return } @@ -156,7 +152,7 @@ export function TaskDialog({ task, storyId, productId, closePath, onClose, onSav const result = await deleteTask(task.id, { productId }) if (result.ok) { toast.success('Taak verwijderd') - close() + router.push(closePath) return } if (result.code === 403) { diff --git a/app/api/jobs/[id]/route.ts b/app/api/jobs/[id]/route.ts deleted file mode 100644 index fd11b89..0000000 --- a/app/api/jobs/[id]/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { NextRequest } from 'next/server' -import { getSession } from '@/lib/auth' -import { prisma } from '@/lib/prisma' -import { JOB_INCLUDE, buildPriceMap, mapJob } from '@/lib/jobs-mapper' -import type { PriceRow, RawJob } from '@/lib/jobs-mapper' - -export async function GET( - _request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const session = await getSession() - if (!session.userId) { - return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) - } - - const { id } = await params - - const job = await prisma.claudeJob.findFirst({ - where: { id, user_id: session.userId }, - include: JOB_INCLUDE, - }) - - if (!job) { - return Response.json({ error: 'Job niet gevonden' }, { status: 404 }) - } - - const prices = await prisma.modelPrice.findMany() - const priceMap = buildPriceMap(prices as PriceRow[]) - return Response.json(mapJob(job as RawJob, priceMap)) -} diff --git a/app/api/pbis/[id]/stories/route.ts b/app/api/pbis/[id]/stories/route.ts deleted file mode 100644 index 67de693..0000000 --- a/app/api/pbis/[id]/stories/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -// PBI-74 / T-870: GET /api/pbis/:id/stories -// -// Levert stories binnen een PBI voor ensurePbiLoaded. Access-control via -// product-eigenaarschap van het bovenliggende PBI. -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' -import { storyStatusToApi } from '@/lib/task-status' - -export const dynamic = 'force-dynamic' - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - - const pbi = await prisma.pbi.findFirst({ - where: { id, product: productAccessFilter(auth.userId) }, - select: { id: true }, - }) - if (!pbi) { - return Response.json({ error: 'PBI niet gevonden' }, { status: 404 }) - } - - const stories = await prisma.story.findMany({ - where: { pbi_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - select: { - id: true, - code: true, - title: true, - description: true, - acceptance_criteria: true, - priority: true, - sort_order: true, - status: true, - pbi_id: true, - sprint_id: true, - created_at: true, - }, - }) - - return Response.json( - stories.map((s) => ({ ...s, status: storyStatusToApi(s.status) })), - ) -} diff --git a/app/api/products/[id]/backlog/route.ts b/app/api/products/[id]/backlog/route.ts deleted file mode 100644 index 9badc35..0000000 --- a/app/api/products/[id]/backlog/route.ts +++ /dev/null @@ -1,101 +0,0 @@ -// PBI-74 / T-870: GET /api/products/:id/backlog -// -// Levert een volledige ProductBacklogSnapshot voor de workspace-store -// (ensureProductLoaded). Auth + access-control consistent met andere -// product-routes (authenticateApiRequest + productAccessFilter). -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' -import { pbiStatusToApi, storyStatusToApi, taskStatusToApi } from '@/lib/task-status' - -export const dynamic = 'force-dynamic' - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - - const product = await prisma.product.findFirst({ - where: { id, ...productAccessFilter(auth.userId) }, - select: { id: true, name: true }, - }) - if (!product) { - return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) - } - - const [pbis, stories, tasks] = await Promise.all([ - prisma.pbi.findMany({ - where: { product_id: id }, - orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }, { created_at: 'asc' }], - select: { - id: true, - code: true, - title: true, - priority: true, - sort_order: true, - description: true, - created_at: true, - status: true, - }, - }), - prisma.story.findMany({ - where: { product_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - select: { - id: true, - code: true, - title: true, - description: true, - acceptance_criteria: true, - priority: true, - sort_order: true, - status: true, - pbi_id: true, - sprint_id: true, - created_at: true, - }, - }), - prisma.task.findMany({ - where: { story: { product_id: id } }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - select: { - id: true, - code: true, - title: true, - description: true, - priority: true, - sort_order: true, - status: true, - story_id: true, - created_at: true, - }, - }), - ]) - - const storiesByPbi: Record = {} - for (const story of stories) { - const apiStory = { ...story, status: storyStatusToApi(story.status) } - if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = [] - storiesByPbi[story.pbi_id].push(apiStory) - } - - const tasksByStory: Record = {} - for (const task of tasks) { - const apiTask = { ...task, status: taskStatusToApi(task.status) } - if (!tasksByStory[task.story_id]) tasksByStory[task.story_id] = [] - tasksByStory[task.story_id].push(apiTask) - } - - return Response.json({ - product, - pbis: pbis.map((p) => ({ ...p, status: pbiStatusToApi(p.status) })), - storiesByPbi, - tasksByStory, - }) -} diff --git a/app/api/products/[id]/claude-context/route.ts b/app/api/products/[id]/claude-context/route.ts index 556d6d7..3611c64 100644 --- a/app/api/products/[id]/claude-context/route.ts +++ b/app/api/products/[id]/claude-context/route.ts @@ -58,7 +58,7 @@ export async function GET( if (activeSprint) { const story = await prisma.story.findFirst({ where: { sprint_id: activeSprint.id, status: 'IN_SPRINT' }, - orderBy: [{ sort_order: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], include: { tasks: { orderBy: { sort_order: 'asc' }, diff --git a/app/api/products/[id]/cross-sprint-blocks/route.ts b/app/api/products/[id]/cross-sprint-blocks/route.ts deleted file mode 100644 index ba10da2..0000000 --- a/app/api/products/[id]/cross-sprint-blocks/route.ts +++ /dev/null @@ -1,74 +0,0 @@ -// PBI-79 / T-929: GET /api/products/:id/cross-sprint-blocks -// -// Lichte UX-hint voor disabled-vinkjes: welke stories binnen pbiIds zitten in -// een andere OPEN sprint (excludeSprintId expliciet uitgesloten). Server-side -// commit-actions blijven autoritatief — dit endpoint is alleen voor UI. -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' - -export const dynamic = 'force-dynamic' - -function parsePbiIds(raw: string | null): string[] | null { - if (!raw) return null - const ids = raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - return ids.length === 0 ? null : ids -} - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id: productId } = await params - const url = new URL(request.url) - const excludeSprintId = url.searchParams.get('excludeSprintId') ?? undefined - const pbiIds = parsePbiIds(url.searchParams.get('pbiIds')) - - if (!pbiIds) { - return Response.json( - { error: 'pbiIds is verplicht (comma-separated)' }, - { status: 400 }, - ) - } - - const product = await prisma.product.findFirst({ - where: { id: productId, ...productAccessFilter(auth.userId) }, - select: { id: true }, - }) - if (!product) { - return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) - } - - const stories = await prisma.story.findMany({ - where: { - pbi_id: { in: pbiIds }, - product_id: productId, - sprint_id: { not: null }, - ...(excludeSprintId ? { NOT: { sprint_id: excludeSprintId } } : {}), - sprint: { status: 'OPEN' }, - }, - select: { - id: true, - sprint: { select: { id: true, code: true } }, - }, - }) - - const result: Record = {} - for (const story of stories) { - if (!story.sprint) continue - result[story.id] = { - sprintId: story.sprint.id, - sprintName: story.sprint.code, - } - } - - return Response.json(result) -} diff --git a/app/api/products/[id]/next-story/route.ts b/app/api/products/[id]/next-story/route.ts index f2dd414..4ab4529 100644 --- a/app/api/products/[id]/next-story/route.ts +++ b/app/api/products/[id]/next-story/route.ts @@ -23,7 +23,7 @@ export async function GET( const story = await prisma.story.findFirst({ where: { sprint_id: sprint.id, status: 'IN_SPRINT' }, - orderBy: [{ sort_order: 'asc' }], + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }], include: { tasks: { orderBy: { sort_order: 'asc' }, diff --git a/app/api/products/[id]/solo-workspace/route.ts b/app/api/products/[id]/solo-workspace/route.ts deleted file mode 100644 index 36d438e..0000000 --- a/app/api/products/[id]/solo-workspace/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { authenticateApiRequest } from '@/lib/api-auth' -import { getSoloWorkspaceSnapshot } from '@/lib/solo-workspace-server' - -export const dynamic = 'force-dynamic' - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - const url = new URL(request.url) - const sprintId = url.searchParams.get('sprint_id') - const snapshot = await getSoloWorkspaceSnapshot(id, auth.userId, sprintId) - - if (!snapshot) { - return Response.json({ error: 'Solo workspace niet gevonden' }, { status: 404 }) - } - - return Response.json(snapshot) -} diff --git a/app/api/products/[id]/sprint-membership-summary/route.ts b/app/api/products/[id]/sprint-membership-summary/route.ts deleted file mode 100644 index 16f6b6d..0000000 --- a/app/api/products/[id]/sprint-membership-summary/route.ts +++ /dev/null @@ -1,87 +0,0 @@ -// PBI-79 / T-928: GET /api/products/:id/sprint-membership-summary -// -// Levert per PBI {total, inSprint} counts, gescoped op de doorgegeven pbiIds. -// Endpoint weigert product-brede aanroepen (pbiIds is verplicht). Eén groupBy -// + één count-by-sprint waar pbi_id IN (pbiIds). -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' - -export const dynamic = 'force-dynamic' - -function parsePbiIds(raw: string | null): string[] | null { - if (!raw) return null - const ids = raw - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - return ids.length === 0 ? null : ids -} - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id: productId } = await params - const url = new URL(request.url) - const sprintId = url.searchParams.get('sprintId') - const pbiIds = parsePbiIds(url.searchParams.get('pbiIds')) - - if (!sprintId) { - return Response.json({ error: 'sprintId is verplicht' }, { status: 400 }) - } - if (!pbiIds) { - return Response.json( - { error: 'pbiIds is verplicht (comma-separated)' }, - { status: 400 }, - ) - } - - const product = await prisma.product.findFirst({ - where: { id: productId, ...productAccessFilter(auth.userId) }, - select: { id: true }, - }) - if (!product) { - return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) - } - - const [totals, inSprint] = await Promise.all([ - prisma.story.groupBy({ - by: ['pbi_id'], - where: { pbi_id: { in: pbiIds }, product_id: productId }, - _count: { _all: true }, - }), - prisma.story.groupBy({ - by: ['pbi_id'], - where: { - pbi_id: { in: pbiIds }, - product_id: productId, - sprint_id: sprintId, - }, - _count: { _all: true }, - }), - ]) - - const inSprintByPbi = new Map() - for (const row of inSprint) { - inSprintByPbi.set(row.pbi_id, row._count._all) - } - - const result: Record = {} - for (const pbiId of pbiIds) { - result[pbiId] = { total: 0, inSprint: inSprintByPbi.get(pbiId) ?? 0 } - } - for (const row of totals) { - result[row.pbi_id] = { - total: row._count._all, - inSprint: inSprintByPbi.get(row.pbi_id) ?? 0, - } - } - - return Response.json(result) -} diff --git a/app/api/products/[id]/sprints/route.ts b/app/api/products/[id]/sprints/route.ts deleted file mode 100644 index 50f8bb8..0000000 --- a/app/api/products/[id]/sprints/route.ts +++ /dev/null @@ -1,59 +0,0 @@ -// PBI-74 / Story 9 / T-882: GET /api/products/:id/sprints -// -// Levert een lijst sprints voor een product (sprint-workspace -// ensureProductSprintsLoaded). Auth + access-control consistent met andere -// product-routes. - -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' - -export const dynamic = 'force-dynamic' - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - - const product = await prisma.product.findFirst({ - where: { id, ...productAccessFilter(auth.userId) }, - select: { id: true }, - }) - if (!product) { - return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) - } - - const sprints = await prisma.sprint.findMany({ - where: { product_id: id }, - orderBy: [ - { status: 'asc' }, // OPEN < CLOSED alfabetisch — workspace-store her-sorteert - { start_date: 'desc' }, - { created_at: 'desc' }, - ], - select: { - id: true, - product_id: true, - code: true, - sprint_goal: true, - status: true, - start_date: true, - end_date: true, - created_at: true, - completed_at: true, - }, - }) - - return Response.json( - sprints.map((s) => ({ - ...s, - start_date: s.start_date ? s.start_date.toISOString().slice(0, 10) : null, - end_date: s.end_date ? s.end_date.toISOString().slice(0, 10) : null, - })), - ) -} diff --git a/app/api/realtime/sprint/route.ts b/app/api/realtime/sprint/route.ts deleted file mode 100644 index aaaf34c..0000000 --- a/app/api/realtime/sprint/route.ts +++ /dev/null @@ -1,141 +0,0 @@ -// SSE endpoint for the sprint workspace (sprint / story / task changes). -// Mirrors /api/realtime/backlog but with entity filter ∈ {sprint, story, task} -// scoped per product. PBI-74 / Story 9. -// -// Auth: iron-session cookie. Demo users may read. - -import { NextRequest } from 'next/server' -import { Client } from 'pg' -import { getSession } from '@/lib/auth' -import { getAccessibleProduct } from '@/lib/product-access' -import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' -export const maxDuration = 300 - -const CHANNEL = 'scrum4me_changes' -const HEARTBEAT_MS = 25_000 -const HARD_CLOSE_MS = 240_000 - -type NotifyPayload = Record - -function shouldEmit(payload: NotifyPayload, productId: string): boolean { - if ('type' in payload) return false - const entity = payload.entity as string | undefined - if (!entity || !['sprint', 'story', 'task'].includes(entity)) return false - return payload.product_id === productId -} - -export async function GET(request: NextRequest) { - const session = await getSession() - if (!session.userId) { - return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) - } - - const productId = request.nextUrl.searchParams.get('product_id') - if (!productId) { - return Response.json({ error: 'product_id is verplicht' }, { status: 400 }) - } - - const product = await getAccessibleProduct(productId, session.userId) - if (!product) { - return Response.json({ error: 'Geen toegang tot dit product' }, { status: 403 }) - } - - const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL - if (!directUrl) { - return Response.json( - { error: 'DIRECT_URL/DATABASE_URL niet geconfigureerd' }, - { status: 500 }, - ) - } - - const encoder = new TextEncoder() - const pgClient = new Client({ connectionString: directUrl }) - - let heartbeatTimer: ReturnType | 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 already closed - } - } - - const cleanup = async (reason: string) => { - if (closed) return - closed = true - if (heartbeatTimer) clearInterval(heartbeatTimer) - if (hardCloseTimer) clearTimeout(hardCloseTimer) - await closePgClientSafely(pgClient, 'realtime/sprint') - try { - controller.close() - } catch { - // already closed - } - if (process.env.NODE_ENV !== 'production') { - console.log(`[realtime/sprint] closed: ${reason}`) - } - } - - try { - await pgClient.connect() - await pgClient.query(`LISTEN ${CHANNEL}`) - } catch (err) { - console.error('[realtime/sprint] pg connect/listen failed:', err) - enqueue( - `event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`, - ) - await cleanup('pg connect failed') - return - } - - pgClient.on('notification', (msg) => { - if (!msg.payload) return - let payload: NotifyPayload - try { - payload = JSON.parse(msg.payload) as NotifyPayload - } catch { - return - } - if (!shouldEmit(payload, productId)) return - enqueue(`data: ${msg.payload}\n\n`) - }) - - pgClient.on('error', async (err) => { - console.error('[realtime/sprint] pg client error:', err) - await cleanup('pg error') - }) - - enqueue(`event: ready\ndata: ${JSON.stringify({ product_id: productId })}\n\n`) - - heartbeatTimer = setInterval(() => { - enqueue(`: heartbeat\n\n`) - }, HEARTBEAT_MS) - - hardCloseTimer = setTimeout(() => { - cleanup('hard close 240s') - }, HARD_CLOSE_MS) - - request.signal.addEventListener('abort', () => { - cleanup('client aborted') - }) - }, - }) - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream; charset=utf-8', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }, - }) -} diff --git a/app/api/realtime/user-settings/route.ts b/app/api/realtime/user-settings/route.ts deleted file mode 100644 index 6c3261f..0000000 --- a/app/api/realtime/user-settings/route.ts +++ /dev/null @@ -1,146 +0,0 @@ -// PBI-76: User-scoped SSE stream voor user-settings cross-tab/cross-device sync. -// -// Wordt door in app/(app)/layout.tsx geopend zodra de -// gebruiker is ingelogd. Filtert pg_notify-payloads op -// `kind === 'user_settings' && userId === session.userId`. Settings worden -// via prop al gehydrateerd; deze route levert alleen incrementele patches. -// -// Auth: iron-session cookie. Demo-tokens openen geen subscription (bridge -// skipt voor isDemo). -// Output: text/event-stream — `data:` met de patch (Partial). -// Sluit zelf na 240s als safety-net; client herconnect. - -import { NextRequest } from 'next/server' -import { Client } from 'pg' -import { getSession } from '@/lib/auth' -import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' - -export const runtime = 'nodejs' -export const dynamic = 'force-dynamic' -export const maxDuration = 300 - -const CHANNEL = 'scrum4me_changes' -const HEARTBEAT_MS = 25_000 -const HARD_CLOSE_MS = 240_000 - -interface UserSettingsPayload { - kind: 'user_settings' - userId: string - patch: Record -} - -function isUserSettingsPayload(p: unknown): p is UserSettingsPayload { - if (typeof p !== 'object' || p === null) return false - const obj = p as Record - return ( - obj.kind === 'user_settings' && - typeof obj.userId === 'string' && - typeof obj.patch === 'object' && - obj.patch !== null - ) -} - -export async function GET(request: NextRequest) { - const session = await getSession() - if (!session.userId) { - return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) - } - const userId = session.userId - - const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL - if (!directUrl) { - return Response.json( - { error: 'DIRECT_URL/DATABASE_URL niet geconfigureerd' }, - { status: 500 }, - ) - } - - const encoder = new TextEncoder() - const pgClient = new Client({ connectionString: directUrl }) - - let heartbeatTimer: ReturnType | 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 { - // controller already closed - } - } - - const cleanup = async (reason: string) => { - if (closed) return - closed = true - if (heartbeatTimer) clearInterval(heartbeatTimer) - if (hardCloseTimer) clearTimeout(hardCloseTimer) - await closePgClientSafely(pgClient, 'realtime/user-settings') - try { - controller.close() - } catch { - // already closed - } - if (process.env.NODE_ENV !== 'production') { - console.log(`[realtime/user-settings] closed: ${reason}`) - } - } - - try { - await pgClient.connect() - await pgClient.query(`LISTEN ${CHANNEL}`) - } catch (err) { - console.error('[realtime/user-settings] pg connect/listen failed:', err) - enqueue( - `event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`, - ) - await cleanup('pg connect failed') - return - } - - pgClient.on('notification', (msg) => { - if (!msg.payload) return - let payload: unknown - try { - payload = JSON.parse(msg.payload) - } catch { - return - } - if (!isUserSettingsPayload(payload)) return - if (payload.userId !== userId) return - enqueue(`data: ${JSON.stringify(payload.patch)}\n\n`) - }) - - pgClient.on('error', (err) => { - console.error('[realtime/user-settings] pg client error:', err) - cleanup('pg error') - }) - - enqueue(`: connected\n\n`) - - heartbeatTimer = setInterval(() => { - enqueue(`: heartbeat\n\n`) - }, HEARTBEAT_MS) - - hardCloseTimer = setTimeout(() => { - cleanup('hard close 240s') - }, HARD_CLOSE_MS) - - request.signal.addEventListener('abort', () => { - cleanup('client aborted') - }) - }, - }) - - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream; charset=utf-8', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }, - }) -} diff --git a/app/api/sprints/[id]/tasks/route.ts b/app/api/sprints/[id]/tasks/route.ts index 73f88c0..6d1e2d3 100644 --- a/app/api/sprints/[id]/tasks/route.ts +++ b/app/api/sprints/[id]/tasks/route.ts @@ -28,6 +28,7 @@ export async function GET( where: { sprint_id: id }, orderBy: [ { story: { sort_order: 'asc' } }, + { priority: 'asc' }, { sort_order: 'asc' }, ], take: limit, diff --git a/app/api/sprints/[id]/workspace/route.ts b/app/api/sprints/[id]/workspace/route.ts deleted file mode 100644 index 8d48c7d..0000000 --- a/app/api/sprints/[id]/workspace/route.ts +++ /dev/null @@ -1,110 +0,0 @@ -// PBI-74 / Story 9 / T-882: GET /api/sprints/:id/workspace -// -// Levert een SprintWorkspaceSnapshot (sprint + stories + tasksByStory) voor -// de sprint-workspace-store (ensureSprintLoaded). Auth + access-control via -// product-membership. - -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' -import { storyStatusToApi, taskStatusToApi } from '@/lib/task-status' - -export const dynamic = 'force-dynamic' - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - - const sprint = await prisma.sprint.findFirst({ - where: { id, product: productAccessFilter(auth.userId) }, - select: { - id: true, - product_id: true, - code: true, - sprint_goal: true, - status: true, - start_date: true, - end_date: true, - created_at: true, - completed_at: true, - product: { select: { id: true, name: true } }, - }, - }) - if (!sprint) { - return Response.json({ error: 'Sprint niet gevonden' }, { status: 404 }) - } - - const [stories, tasks] = await Promise.all([ - prisma.story.findMany({ - where: { sprint_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - include: { - tasks: { select: { id: true, status: true } }, - assignee: { select: { id: true, username: true } }, - }, - }), - prisma.task.findMany({ - where: { sprint_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - select: { - id: true, - code: true, - title: true, - description: true, - priority: true, - sort_order: true, - status: true, - story_id: true, - sprint_id: true, - created_at: true, - }, - }), - ]) - - const tasksByStory: Record = {} - for (const task of tasks) { - const apiTask = { ...task, status: taskStatusToApi(task.status) } - if (!tasksByStory[task.story_id]) tasksByStory[task.story_id] = [] - tasksByStory[task.story_id].push(apiTask) - } - - return Response.json({ - product: sprint.product, - sprint: { - id: sprint.id, - product_id: sprint.product_id, - code: sprint.code, - sprint_goal: sprint.sprint_goal, - status: sprint.status, - start_date: sprint.start_date ? sprint.start_date.toISOString().slice(0, 10) : null, - end_date: sprint.end_date ? sprint.end_date.toISOString().slice(0, 10) : null, - created_at: sprint.created_at, - completed_at: sprint.completed_at, - }, - stories: stories.map((s) => ({ - id: s.id, - code: s.code, - title: s.title, - description: s.description, - acceptance_criteria: s.acceptance_criteria, - priority: s.priority, - sort_order: s.sort_order, - status: storyStatusToApi(s.status), - pbi_id: s.pbi_id, - sprint_id: s.sprint_id, - created_at: s.created_at, - taskCount: s.tasks.length, - doneCount: s.tasks.filter((t) => t.status === 'DONE').length, - assignee_id: s.assignee_id, - assignee_username: s.assignee?.username ?? null, - })), - tasksByStory, - }) -} diff --git a/app/api/stories/[id]/tasks/reorder/route.ts b/app/api/stories/[id]/tasks/reorder/route.ts new file mode 100644 index 0000000..53aeab5 --- /dev/null +++ b/app/api/stories/[id]/tasks/reorder/route.ts @@ -0,0 +1,56 @@ +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' +import { z } from 'zod' + +const bodySchema = z.object({ + task_ids: z.array(z.string()).min(1), +}) + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + if (auth.isDemo) { + return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 }) + } + + const { id: storyId } = await params + + let body: unknown + try { + body = await request.json() + } catch { + return Response.json({ error: 'Malformed JSON' }, { status: 400 }) + } + const parsed = bodySchema.safeParse(body) + if (!parsed.success) { + return Response.json({ error: parsed.error.flatten() }, { status: 422 }) + } + + const story = await prisma.story.findFirst({ + where: { id: storyId, product: productAccessFilter(auth.userId) }, + include: { tasks: { select: { id: true } } }, + }) + if (!story) { + return Response.json({ error: 'Story niet gevonden' }, { status: 404 }) + } + + const storyTaskIds = new Set(story.tasks.map(t => t.id)) + const invalidId = parsed.data.task_ids.find(id => !storyTaskIds.has(id)) + if (invalidId) { + return Response.json({ error: `Ongeldig task_id: ${invalidId}` }, { status: 422 }) + } + + await prisma.$transaction( + parsed.data.task_ids.map((id, i) => + prisma.task.update({ where: { id }, data: { sort_order: i + 1.0 } }) + ) + ) + + return Response.json({ success: true }) +} diff --git a/app/api/stories/[id]/tasks/route.ts b/app/api/stories/[id]/tasks/route.ts deleted file mode 100644 index 2584ff7..0000000 --- a/app/api/stories/[id]/tasks/route.ts +++ /dev/null @@ -1,50 +0,0 @@ -// PBI-74 / T-870: GET /api/stories/:id/tasks -// -// Levert tasks binnen een story voor ensureStoryLoaded. Access-control via -// product-eigenaarschap van de bovenliggende story. -import { authenticateApiRequest } from '@/lib/api-auth' -import { prisma } from '@/lib/prisma' -import { productAccessFilter } from '@/lib/product-access' -import { taskStatusToApi } from '@/lib/task-status' - -export const dynamic = 'force-dynamic' - -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - - const story = await prisma.story.findFirst({ - where: { id, product: productAccessFilter(auth.userId) }, - select: { id: true }, - }) - if (!story) { - return Response.json({ error: 'Story niet gevonden' }, { status: 404 }) - } - - const tasks = await prisma.task.findMany({ - where: { story_id: id }, - orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], - select: { - id: true, - code: true, - title: true, - description: true, - priority: true, - sort_order: true, - status: true, - story_id: true, - created_at: true, - }, - }) - - return Response.json( - tasks.map((t) => ({ ...t, status: taskStatusToApi(t.status) })), - ) -} diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts index 52cce01..4bb2611 100644 --- a/app/api/tasks/[id]/route.ts +++ b/app/api/tasks/[id]/route.ts @@ -3,56 +3,6 @@ import { prisma } from '@/lib/prisma' import { z } from 'zod' import { TASK_STATUS_API_VALUES, taskStatusFromApi, taskStatusToApi } from '@/lib/task-status' import { propagateStatusUpwards } from '@/lib/tasks-status-update' -import { productAccessFilter } from '@/lib/product-access' - -// PBI-74 / T-869: force-dynamic zodat Next geen response-cache hangt aan -// deze route — workspace-store leest hier verse data via ensureTaskLoaded. -export const dynamic = 'force-dynamic' - -// PBI-74 / T-870: GET-handler voor ensureTaskLoaded. Levert TaskDetail-shape -// (extends BacklogTask met implementation_plan etc.). Access-control via -// product van de parent-story. -export async function GET( - request: Request, - { params }: { params: Promise<{ id: string }> }, -) { - const auth = await authenticateApiRequest(request) - if ('error' in auth) { - return Response.json({ error: auth.error }, { status: auth.status }) - } - - const { id } = await params - - const task = await prisma.task.findFirst({ - where: { - id, - story: { product: productAccessFilter(auth.userId) }, - }, - select: { - id: true, - title: true, - description: true, - priority: true, - sort_order: true, - status: true, - story_id: true, - created_at: true, - implementation_plan: true, - requires_opus: true, - verify_only: true, - verify_required: true, - }, - }) - if (!task) { - return Response.json({ error: 'Task niet gevonden' }, { status: 404 }) - } - - return Response.json({ - ...task, - status: taskStatusToApi(task.status), - _detail: true, - }) -} // `review` is a valid TaskStatus in the DB and the kanban-board UI, but the // sprint task list (components/sprint/task-list.tsx) does not yet render it. diff --git a/app/globals.css b/app/globals.css index e8d3b09..9e6d7af 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3,25 +3,3 @@ @plugin "@tailwindcss/typography"; @import "./styles/theme.css"; - -/* Debug-mode overlay (alleen actief wanneer body.debug-mode is gezet door dev-only toggle) */ -body.debug-mode [data-debug-id] { - outline: 2px dashed var(--info); - outline-offset: 1px; - position: relative; -} -body.debug-mode [data-debug-id]:hover::after { - content: attr(data-debug-id); - position: absolute; - top: 0; - left: 0; - background: var(--info-container); - color: var(--info-container-foreground); - font-size: 10px; - line-height: 1.2; - padding: 2px 4px; - white-space: nowrap; - border-radius: 2px; - z-index: 9999; - pointer-events: none; -} diff --git a/components/admin/jobs-table.tsx b/components/admin/jobs-table.tsx index a236bed..3b1c312 100644 --- a/components/admin/jobs-table.tsx +++ b/components/admin/jobs-table.tsx @@ -12,7 +12,6 @@ import { TableRow, } from '@/components/ui/table' import { cancelJobAction, deleteJobAction } from '@/actions/admin/jobs' -import { debugProps } from '@/lib/debug' type Job = { id: string @@ -25,10 +24,6 @@ type Job = { pr_url: string | null error: string | null model_id: string | null - actual_thinking_tokens: number | null - requested_model: string | null - requested_thinking_budget: number | null - requested_permission_mode: string | null cost_usd: number | null } @@ -101,7 +96,7 @@ function JobRow({ job }: { job: Job }) { function StatusTable({ jobs }: { jobs: Job[] }) { return ( - +
ID @@ -136,24 +131,13 @@ function CostRow({ job }: { job: Job }) { function handleCancel() { startTransition(() => cancelJobAction(job.id)) } function handleDelete() { startTransition(() => deleteJobAction(job.id)) } const costLabel = job.cost_usd != null ? `$${job.cost_usd.toFixed(4)}` : '—' - const thinkingLabel = job.actual_thinking_tokens != null ? job.actual_thinking_tokens.toLocaleString('nl-NL') : '—' - const modelMismatch = job.requested_model != null && job.model_id != null && job.requested_model !== job.model_id - const modelTitle = job.requested_model - ? `Aangevraagd: ${job.requested_model}${modelMismatch ? ' (mismatch met actueel)' : ''}` - : undefined return ( {job.id.slice(0, 8)} {job.user.username} {job.product.name} {KIND_LABEL[job.kind] ?? job.kind} - - {job.model_id ?? '—'} - - {thinkingLabel} + {job.model_id ?? '—'} {costLabel} {new Date(job.created_at).toLocaleString('nl-NL', { dateStyle: 'short', timeStyle: 'short' })} @@ -172,7 +156,7 @@ function CostRow({ job }: { job: Job }) { function CostsTable({ jobs }: { jobs: Job[] }) { return ( -
+
ID @@ -180,7 +164,6 @@ function CostsTable({ jobs }: { jobs: Job[] }) { Product Type Model - Thinking Kosten (USD) Aangemaakt Acties @@ -189,7 +172,7 @@ function CostsTable({ jobs }: { jobs: Job[] }) { {jobs.length === 0 && ( - + Geen jobs gevonden @@ -204,8 +187,8 @@ export function JobsTable({ jobs }: { jobs: Job[] }) { const [view, setView] = useState<'status' | 'costs'>('status') return ( -
-
+
+
+ Naam Eigenaar @@ -91,7 +90,7 @@ export function ProductsTable({ products }: { products: Product[] }) { Acties - + {products.length === 0 && ( diff --git a/components/admin/users-table.tsx b/components/admin/users-table.tsx index 32161c5..172cd41 100644 --- a/components/admin/users-table.tsx +++ b/components/admin/users-table.tsx @@ -26,7 +26,6 @@ import { updateUserRolesAction, setMustResetPasswordAction, } from '@/actions/admin/users' -import { debugProps } from '@/lib/debug' type UserWithRoles = { id: string @@ -191,8 +190,8 @@ export function UsersTable({ currentUserId: string }) { return ( -
- +
+ Gebruiker Email @@ -202,7 +201,7 @@ export function UsersTable({ Acties - + {users.map(user => ( {user.username} diff --git a/components/auth/auth-form.tsx b/components/auth/auth-form.tsx index 4879c57..6ec179b 100644 --- a/components/auth/auth-form.tsx +++ b/components/auth/auth-form.tsx @@ -4,14 +4,13 @@ import { useActionState } from 'react' import { useFormStatus } from 'react-dom' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { debugProps } from '@/lib/debug' type ActionResult = { error: string | Record } | undefined function SubmitButton({ label }: { label: string }) { const { pending } = useFormStatus() return ( - ) @@ -35,7 +34,7 @@ export function AuthForm({ action, submitLabel }: AuthFormProps) { const errorMessage = getErrorMessage(state) return ( -
+
@@ -66,7 +64,6 @@ export function AuthForm({ action, submitLabel }: AuthFormProps) { minLength={8} placeholder="••••••••" className="bg-input-background border-border focus-visible:ring-primary" - data-debug-id="auth-form__password" /> diff --git a/components/backlog/active-selection-hydrator.tsx b/components/backlog/active-selection-hydrator.tsx deleted file mode 100644 index 966b672..0000000 --- a/components/backlog/active-selection-hydrator.tsx +++ /dev/null @@ -1,53 +0,0 @@ -'use client' - -import { useEffect } from 'react' -import { useUserSettingsStore } from '@/stores/user-settings/store' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' - -interface ActiveSelectionHydratorProps { - productId: string -} - -/** - * PBI-79: hydrateert de workspace-store met de actieve PBI/story die in - * user-settings staan opgeslagen. Loopt na elke (re)hydratatie en bij - * mutaties van de user-settings (bv. na sprint-switch). Wint van de - * localStorage hint-restore — user-settings is de cross-device source of - * truth. - */ -export function ActiveSelectionHydrator({ productId }: ActiveSelectionHydratorProps) { - const hydrated = useUserSettingsStore((s) => s.context.hydrated) - const persistedPbiId = useUserSettingsStore( - (s) => s.entities.settings.layout?.activePbis?.[productId] ?? undefined, - ) - const persistedStoryId = useUserSettingsStore( - (s) => s.entities.settings.layout?.activeStories?.[productId] ?? undefined, - ) - - useEffect(() => { - if (!hydrated) return - const store = useProductWorkspaceStore.getState() - // Schrijf alleen wanneer user-settings expliciet iets gekozen heeft - // (key aanwezig met string-waarde). null-key betekent 'bewust leeg' → - // we wissen lokale state. undefined-key (geen voorkeur) → niets doen. - if (persistedPbiId === undefined && persistedStoryId === undefined) return - - if (persistedPbiId === null) { - store.setActivePbi(null) - return - } - if (persistedPbiId && store.context.activePbiId !== persistedPbiId) { - store.setActivePbi(persistedPbiId) - } - if (persistedStoryId && store.context.activeStoryId !== persistedStoryId) { - // setActivePbi triggert async cascade-restore die de oude hint kan - // herstellen; de daarop volgende setActiveStory bumpt activeRequestId - // en ongeldigt de cascade. - store.setActiveStory(persistedStoryId) - } else if (persistedStoryId === null) { - store.setActiveStory(null) - } - }, [hydrated, persistedPbiId, persistedStoryId]) - - return null -} diff --git a/components/backlog/backlog-card.tsx b/components/backlog/backlog-card.tsx index 26fab89..7a93910 100644 --- a/components/backlog/backlog-card.tsx +++ b/components/backlog/backlog-card.tsx @@ -3,7 +3,6 @@ import { forwardRef } from 'react' import { cn } from '@/lib/utils' import { CodeBadge } from '@/components/shared/code-badge' -import { debugProps } from '@/lib/debug' export const PRIORITY_BORDER: Record = { 1: 'border-l-4 border-l-priority-critical', @@ -39,10 +38,9 @@ export const BacklogCard = forwardRef(function className, )} {...rest} - {...debugProps('backlog-card', 'BacklogCard', 'components/backlog/backlog-card.tsx')} >
-

{title}

+

{title}

{code && }
{(badge || actions) && ( diff --git a/components/backlog/backlog-hydration-wrapper.tsx b/components/backlog/backlog-hydration-wrapper.tsx index 30124d3..4bc5731 100644 --- a/components/backlog/backlog-hydration-wrapper.tsx +++ b/components/backlog/backlog-hydration-wrapper.tsx @@ -1,15 +1,8 @@ 'use client' -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' +import { useBacklogStore, type BacklogPbi, type BacklogStory, type BacklogTask } from '@/stores/backlog-store' import { useBacklogRealtime } from '@/lib/realtime/use-backlog-realtime' -import { useWorkspaceResync } from '@/lib/realtime/use-workspace-resync' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' -import type { - BacklogPbi, - BacklogStory, - BacklogTask, - ProductBacklogSnapshot, -} from '@/stores/product-workspace/types' interface InitialData { pbis: BacklogPbi[] @@ -20,55 +13,18 @@ interface InitialData { interface BacklogHydrationWrapperProps { initialData: InitialData productId: string - productName?: string children: React.ReactNode } -function fingerprint(data: InitialData): string { - const pbiPart = data.pbis.map((p) => `${p.id}:${p.status}:${p.priority}`).join(',') - const storyPart = Object.entries(data.storiesByPbi) - .flatMap(([, list]) => list.map((s) => `${s.id}:${s.status}:${s.sprint_id ?? 'null'}`)) - .join(',') - const taskPart = Object.entries(data.tasksByStory) - .flatMap(([, list]) => list.map((t) => `${t.id}:${t.status}`)) - .join(',') - return `${pbiPart}|${storyPart}|${taskPart}` -} - -// PBI-74 / Story 8: workspace-store is nu enige bron — dual-dispatch weg. -function toWorkspaceSnapshot( - data: InitialData, - productId: string, - productName: string | undefined, -): ProductBacklogSnapshot { - return { - product: { id: productId, name: productName ?? '' }, - pbis: data.pbis, - storiesByPbi: data.storiesByPbi, - tasksByStory: data.tasksByStory, - } -} - -export function BacklogHydrationWrapper({ - initialData, - productId, - productName, - children, -}: BacklogHydrationWrapperProps) { - const lastFingerprint = useRef('') +export function BacklogHydrationWrapper({ initialData, productId, children }: BacklogHydrationWrapperProps) { + const setInitialData = useBacklogStore((s) => s.setInitialData) useEffect(() => { - const fp = fingerprint(initialData) - if (fp !== lastFingerprint.current) { - lastFingerprint.current = fp - useProductWorkspaceStore - .getState() - .hydrateSnapshot(toWorkspaceSnapshot(initialData, productId, productName)) - } - }, [initialData, productId, productName]) + setInitialData(initialData) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) useBacklogRealtime(productId) - useWorkspaceResync() return <>{children} } diff --git a/components/backlog/backlog-split-pane.tsx b/components/backlog/backlog-split-pane.tsx index 8a82a95..882f13b 100644 --- a/components/backlog/backlog-split-pane.tsx +++ b/components/backlog/backlog-split-pane.tsx @@ -1,16 +1,13 @@ 'use client' import { useState } from 'react' -import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import { useSelectionStore } from '@/stores/selection-store' import { SplitPane, type SplitPaneProps } from '@/components/split-pane/split-pane' type Props = Omit -// PBI-74 / T-848: leest active PBI/story-ids uit workspace-store. Primitives, -// dus geen useShallow nodig. export function BacklogSplitPane(props: Props) { - const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId) - const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId) + const { selectedPbiId, selectedStoryId } = useSelectionStore() const [activeTab, setActiveTab] = useState(0) // React-recommended "derived state from props" pattern: update state during render diff --git a/components/backlog/empty-panel.tsx b/components/backlog/empty-panel.tsx index 6fd531b..e48f688 100644 --- a/components/backlog/empty-panel.tsx +++ b/components/backlog/empty-panel.tsx @@ -2,7 +2,6 @@ import { Button } from '@/components/ui/button' import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { debugProps } from '@/lib/debug' interface EmptyPanelProps { title?: string @@ -16,8 +15,8 @@ interface EmptyPanelProps { export function EmptyPanel({ title, message, action }: EmptyPanelProps) { return ( -
- {title &&

{title}

} +
+ {title &&

{title}

}

{message}

{action && ( @@ -26,7 +25,6 @@ export function EmptyPanel({ title, message, action }: EmptyPanelProps) { variant="outline" disabled={action.disabled} onClick={action.disabled ? undefined : action.onClick} - {...debugProps('empty-panel__cta')} > {action.label} diff --git a/components/backlog/new-sprint-metadata-dialog.tsx b/components/backlog/new-sprint-metadata-dialog.tsx deleted file mode 100644 index cccf9a9..0000000 --- a/components/backlog/new-sprint-metadata-dialog.tsx +++ /dev/null @@ -1,203 +0,0 @@ -'use client' - -import { useRef, useState, useTransition } from 'react' -import { toast } from 'sonner' -import { Button } from '@/components/ui/button' -import { Textarea } from '@/components/ui/textarea' -import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' -import { - useDirtyCloseGuard, - DirtyCloseGuardDialog, -} from '@/components/shared/use-dirty-close-guard' -import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut' -import { - entityDialogContentClasses, - entityDialogFooterClasses, - entityDialogHeaderClasses, -} from '@/components/shared/entity-dialog-layout' -import { useUserSettingsStore } from '@/stores/user-settings/store' -import { debugProps } from '@/lib/debug' - -interface NewSprintMetadataDialogProps { - open: boolean - productId: string - onOpenChange: (open: boolean) => void -} - -function todayLocalDate(): string { - return new Date().toLocaleDateString('en-CA') -} - -function plusWeeks(weeks: number): string { - const d = new Date() - d.setDate(d.getDate() + weeks * 7) - return d.toLocaleDateString('en-CA') -} - -export function NewSprintMetadataDialog({ - open, - productId, - onOpenChange, -}: NewSprintMetadataDialogProps) { - const [sprintGoal, setSprintGoal] = useState('') - const [startDate, setStartDate] = useState(todayLocalDate()) - const [endDate, setEndDate] = useState(plusWeeks(2)) - const [error, setError] = useState(null) - const [dirty, setDirty] = useState(false) - const [isPending, startTransition] = useTransition() - const formRef = useRef(null) - const setPendingSprintDraft = useUserSettingsStore( - (s) => s.setPendingSprintDraft, - ) - - function reset() { - setSprintGoal('') - setStartDate(todayLocalDate()) - setEndDate(plusWeeks(2)) - setError(null) - setDirty(false) - } - - const closeGuard = useDirtyCloseGuard(dirty, () => { - onOpenChange(false) - reset() - }) - - function handleSubmit(e: React.FormEvent) { - e.preventDefault() - const goal = sprintGoal.trim() - if (!goal) return - setError(null) - startTransition(async () => { - try { - await setPendingSprintDraft(productId, { - goal, - startAt: startDate || undefined, - endAt: endDate || undefined, - pbiIntent: {}, - storyOverrides: {}, - }) - reset() - onOpenChange(false) - } catch (err) { - const message = - err instanceof Error ? err.message : 'Onbekende fout bij opslaan' - setError(message) - toast.error(message) - } - }) - } - - const handleKeyDown = useDialogSubmitShortcut(() => - formRef.current?.requestSubmit(), - ) - - return ( - <> - { - if (!o) closeGuard.attemptClose() - else onOpenChange(o) - }} - > - -
- - Nieuwe sprint - -

- Geef het sprint-doel en periode op. Je selecteert daarna PBI's - en stories via vinkjes in de backlog. -

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