Merge branch 'main' into feat/demo-prefs

This commit is contained in:
Janpeter Visser 2026-05-12 20:03:31 +02:00 committed by GitHub
commit 14f539441f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 6871 additions and 191 deletions

View file

@ -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/plans/v1-readiness.md`.
- v1.0 readiness checklist in `docs/old/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/backlog/index.md](./docs/backlog/index.md).
Voor de volledige milestone-historie zie [docs/old/backlog/index.md](./docs/old/backlog/index.md).
---

View file

@ -3,7 +3,7 @@ title: "CLAUDE.md — Scrum4Me"
status: active
audience: [ai-agent]
language: nl
last_updated: 2026-05-08
last_updated: 2026-05-11
---
# CLAUDE.md — Scrum4Me
@ -19,19 +19,16 @@ 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/plans/<key>-*.md` | Implementatieplan per milestone |
| `docs/adr/` | Architecture Decision Records — tech-keuzes (base-ui vs Radix, sort-order, demo-policy, …) |
| `docs/manual/` | 7-delige gebruiks- en operationele handleiding (workflow, git, docker, troubleshooting) |
| `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 |
---
## Hoe werk vinden
**Track A — MCP (aanbevolen):**
1. Branch aanmaken: `git checkout -b feat/<batch-slug>` — nog **geen** `gh pr create`
2. `mcp__scrum4me__get_claude_context` → pak de next story
3. Voer taken uit in `sort_order`; update status per taak
@ -41,11 +38,6 @@ Desktop-first Scrum-app voor solo developers en kleine teams. Hiërarchie: produ
7. Herhaal stap 26 per story; branch blijft dezelfde
8. Queue leeg → `git push -u origin <branch>` + `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)
---
@ -56,6 +48,7 @@ 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
@ -69,11 +62,11 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo
| Laag | Technologie |
|---|---|
| Framework | Next.js 16 (App Router) + React 19 |
| Framework | Next.js 16.2 (App Router) + React 19.2 — PPR/Cache Components beschikbaar |
| Taal | TypeScript strict |
| Styling | Tailwind CSS + shadcn/ui + MD3 via `app/styles/theme.css` |
| Styling | Tailwind CSS v4 + shadcn/ui + MD3 via `app/styles/theme.css` |
| State | Zustand + dnd-kit |
| DB | Prisma v7 + PostgreSQL (Neon) |
| DB | Prisma v7.8 + PostgreSQL (Neon) |
| Auth | iron-session + bcryptjs |
| Test | Vitest (`__tests__/`, config in `vitest.config.ts`) |
| Utilities | Zod, Sonner, Sharp, Vercel Analytics |
@ -121,6 +114,7 @@ Volledig schema: `lib/env.ts`. Canonieke lijst: `.env.example` — bevat ook web
## 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
@ -141,3 +135,20 @@ npm run verify && npm run build # verify = lint + typecheck + test
```
Worker job-status protocol (wanneer `DONE` / `SKIPPED` / `FAILED`): zie [docs/runbooks/worker-idempotency.md](./docs/runbooks/worker-idempotency.md).
### Scripts
| Commando | Doel |
|---|---|
| `npm run dev` | Next dev op poort 3000 (`predev` kill-port draait automatisch) |
| `npm test` | Vitest eenmalig (`vitest run`) |
| `npm run test:watch` | Vitest watch-mode |
| `npm test -- <pad>` | Eén bestand draaien — bv. `npm test -- lib/env` |
| `npm run seed` | Prisma seed via `prisma/seed.ts` |
| `npm run create-admin` | Admin-user toevoegen (`scripts/create-admin.ts`) |
| `npm run db:insert-milestone` | Milestone-script (`scripts/insert-milestone.ts`) |
| `npm run db:sync-model-prices` | Sync Anthropic-model-prijzen — vereist `ANTHROPIC_API_KEY` |
| `npm run docs` | Regenereer `docs/INDEX.md` + check links |
| `npm run diagrams` | Mermaid → SVG (`public/diagrams/architecture-{light,dark}.svg`) |
> Vitest sluit `.claude/**` uit (relevant voor worktrees). `server-only` wordt via alias gemockt naar `tests/stubs/server-only.ts`, zodat `*-server.ts` modules laadbaar zijn in jsdom-tests.

View file

@ -287,5 +287,4 @@ 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)

View file

@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/headers', () => ({
cookies: vi.fn().mockResolvedValue({
set: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
}),
}))
vi.mock('iron-session', () => ({
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
}))
vi.mock('@/lib/session', () => ({
sessionOptions: { cookieName: 'test', password: 'test' },
}))
vi.mock('@/lib/product-access', () => ({
productAccessFilter: vi.fn().mockReturnValue({}),
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
sprint: { findFirst: vi.fn() },
product: { findFirst: vi.fn() },
user: {
findUnique: vi.fn(),
update: vi.fn().mockResolvedValue({}),
},
$executeRaw: vi.fn().mockResolvedValue(1),
},
}))
import { prisma } from '@/lib/prisma'
import { clearActiveSprintAction } from '@/actions/active-sprint'
const mockPrisma = prisma as unknown as {
product: { findFirst: ReturnType<typeof vi.fn> }
user: {
findUnique: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
}
describe('clearActiveSprintAction', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('writes null instead of deleting the key', async () => {
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
mockPrisma.user.findUnique.mockResolvedValueOnce({
settings: { layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } } },
})
const result = await clearActiveSprintAction('p1')
expect(result).toEqual({ success: true })
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
data: { settings: { layout?: { activeSprints?: Record<string, string | null> } } }
}
expect(updateArg.data.settings.layout?.activeSprints).toEqual({
p1: null,
p2: 'sprint-2',
})
})
it('preserves other product keys when clearing one', async () => {
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
mockPrisma.user.findUnique.mockResolvedValueOnce({
settings: {
layout: {
activeSprints: { p1: 'sprint-1', p2: 'sprint-2', p3: null },
},
},
})
await clearActiveSprintAction('p1')
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
data: { settings: { layout?: { activeSprints?: Record<string, string | null> } } }
}
expect(updateArg.data.settings.layout?.activeSprints).toEqual({
p1: null,
p2: 'sprint-2',
p3: null,
})
})
it('rejects when product is not accessible', async () => {
mockPrisma.product.findFirst.mockResolvedValueOnce(null)
const result = await clearActiveSprintAction('p1')
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
expect(mockPrisma.user.update).not.toHaveBeenCalled()
})
it('rejects invalid productId', async () => {
const result = await clearActiveSprintAction('')
expect(result).toEqual({ error: 'Ongeldig product-id' })
expect(mockPrisma.user.update).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,290 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/headers', () => ({
cookies: vi.fn().mockResolvedValue({
set: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
}),
}))
vi.mock('iron-session', () => ({
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
}))
vi.mock('@/lib/session', () => ({
sessionOptions: { cookieName: 'test', password: 'test' },
}))
vi.mock('@/lib/product-access', () => ({
productAccessFilter: vi.fn().mockReturnValue({}),
getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }),
}))
vi.mock('@/lib/rate-limit', () => ({
enforceUserRateLimit: vi.fn().mockReturnValue(null),
}))
vi.mock('@/lib/code-server', () => ({
createWithCodeRetry: vi.fn(),
generateNextSprintCode: vi.fn(),
}))
vi.mock('@/lib/active-sprint', () => ({
setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/prisma', () => {
const txClient = {
sprint: { create: vi.fn() },
story: { updateMany: vi.fn() },
task: { updateMany: vi.fn() },
}
return {
prisma: {
sprint: { findFirst: vi.fn() },
story: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
task: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
$transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) => fn(txClient)),
__txClient: txClient,
},
}
})
import { prisma } from '@/lib/prisma'
import { commitSprintMembershipAction } from '@/actions/sprints'
type Mocked = {
sprint: { findFirst: ReturnType<typeof vi.fn> }
story: {
findMany: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
}
task: {
findMany: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
}
$transaction: ReturnType<typeof vi.fn>
__txClient: {
sprint: { create: ReturnType<typeof vi.fn> }
story: { updateMany: ReturnType<typeof vi.fn> }
task: { updateMany: ReturnType<typeof vi.fn> }
}
}
const mockPrisma = prisma as unknown as Mocked
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.sprint.findFirst.mockReset().mockResolvedValue({
id: 'sprint-active',
product_id: 'product-1',
})
mockPrisma.story.findMany.mockReset()
mockPrisma.story.updateMany.mockReset()
mockPrisma.task.findMany.mockReset()
mockPrisma.task.updateMany.mockReset()
mockPrisma.$transaction.mockImplementation(
async (fn: (tx: typeof mockPrisma.__txClient) => unknown) =>
fn(mockPrisma.__txClient),
)
mockPrisma.__txClient.story.updateMany.mockReset().mockResolvedValue({ count: 0 })
mockPrisma.__txClient.task.updateMany.mockReset().mockResolvedValue({ count: 0 })
})
describe('commitSprintMembershipAction', () => {
it('happy path: eligible adds + valid removes → transactie commits', async () => {
// adds-partition: alle eligible (sprint_id=null + niet DONE)
mockPrisma.story.findMany
// partition lookup
.mockResolvedValueOnce([
{ id: 's-add-1', sprint_id: null, status: 'OPEN', sprint: null },
])
// removes-filter (sprint_id == activeSprintId)
.mockResolvedValueOnce([{ id: 's-rem-1' }])
// affectedStories
.mockResolvedValueOnce([
{ pbi_id: 'pbiA' },
{ pbi_id: 'pbiB' },
])
mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }])
const result = await commitSprintMembershipAction({
activeSprintId: 'sprint-active',
adds: ['s-add-1'],
removes: ['s-rem-1'],
})
expect('success' in result).toBe(true)
if ('success' in result) {
expect(result.affectedStoryIds.sort()).toEqual(['s-add-1', 's-rem-1'])
expect(result.affectedPbiIds.sort()).toEqual(['pbiA', 'pbiB'])
expect(result.affectedTaskIds).toEqual(['t1'])
expect(result.conflicts.notEligible).toEqual([])
expect(result.conflicts.alreadyRemoved).toEqual([])
}
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1)
expect(mockPrisma.__txClient.story.updateMany).toHaveBeenCalledTimes(2)
expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledTimes(2)
})
it('add met status=DONE → conflicts.notEligible, story niet ge-update', async () => {
mockPrisma.story.findMany
.mockResolvedValueOnce([
{ id: 's-done', sprint_id: null, status: 'DONE', sprint: null },
])
// removes-filter (geen removes)
.mockResolvedValueOnce([])
const result = await commitSprintMembershipAction({
activeSprintId: 'sprint-active',
adds: ['s-done'],
removes: [],
})
expect('success' in result).toBe(true)
if ('success' in result) {
expect(result.affectedStoryIds).toEqual([])
expect(result.conflicts.notEligible).toEqual([
{ storyId: 's-done', reason: 'DONE' },
])
}
// Geen transaction omdat er niets te commiten valt.
expect(mockPrisma.$transaction).not.toHaveBeenCalled()
})
it('add met sprint_id in andere OPEN sprint → conflicts.notEligible IN_OTHER_SPRINT', async () => {
mockPrisma.story.findMany
.mockResolvedValueOnce([
{
id: 's-elsewhere',
sprint_id: 'sprint-other',
status: 'IN_SPRINT',
sprint: { id: 'sprint-other', code: 'SP-O', status: 'OPEN' },
},
])
.mockResolvedValueOnce([])
const result = await commitSprintMembershipAction({
activeSprintId: 'sprint-active',
adds: ['s-elsewhere'],
removes: [],
})
if ('success' in result) {
expect(result.conflicts.notEligible).toEqual([
{ storyId: 's-elsewhere', reason: 'IN_OTHER_SPRINT' },
])
}
})
it('remove voor story die niet in actieve sprint zit → conflicts.alreadyRemoved', async () => {
mockPrisma.story.findMany
// adds-partition (geen adds)
.mockResolvedValueOnce([])
// removes-filter — race scenario: story zit niet meer in active sprint
.mockResolvedValueOnce([])
const result = await commitSprintMembershipAction({
activeSprintId: 'sprint-active',
adds: [],
removes: ['s-was-removed'],
})
if ('success' in result) {
expect(result.affectedStoryIds).toEqual([])
expect(result.conflicts.alreadyRemoved).toEqual(['s-was-removed'])
}
})
it('transactie: story.status=IN_SPRINT bij add, =OPEN bij remove', async () => {
mockPrisma.story.findMany
.mockResolvedValueOnce([
{ id: 's-add', sprint_id: null, status: 'OPEN', sprint: null },
])
.mockResolvedValueOnce([{ id: 's-rem' }])
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }])
mockPrisma.task.findMany.mockResolvedValueOnce([])
await commitSprintMembershipAction({
activeSprintId: 'sprint-active',
adds: ['s-add'],
removes: ['s-rem'],
})
const calls = mockPrisma.__txClient.story.updateMany.mock.calls
// Add: status=IN_SPRINT + sprint_id=sprint-active
expect(calls[0][0].data).toEqual({
sprint_id: 'sprint-active',
status: 'IN_SPRINT',
})
// Remove: status=OPEN + sprint_id=null
expect(calls[1][0].data).toEqual({ sprint_id: null, status: 'OPEN' })
})
it('task.sprint_id wordt in dezelfde transactie ge-update', async () => {
mockPrisma.story.findMany
.mockResolvedValueOnce([
{ id: 's-add', sprint_id: null, status: 'OPEN', sprint: null },
])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }])
mockPrisma.task.findMany.mockResolvedValueOnce([])
await commitSprintMembershipAction({
activeSprintId: 'sprint-active',
adds: ['s-add'],
removes: [],
})
expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { story_id: { in: ['s-add'] } },
data: { sprint_id: 'sprint-active' },
}),
)
})
it('return: affectedStoryIds + affectedPbiIds + affectedTaskIds + conflicts', async () => {
mockPrisma.story.findMany
.mockResolvedValueOnce([
{ id: 's-add', sprint_id: null, status: 'OPEN', sprint: null },
])
.mockResolvedValueOnce([{ id: 's-rem' }])
.mockResolvedValueOnce([
{ pbi_id: 'pbiA' },
{ pbi_id: 'pbiB' },
])
mockPrisma.task.findMany.mockResolvedValueOnce([
{ id: 't1' },
{ id: 't2' },
])
const result = await commitSprintMembershipAction({
activeSprintId: 'sprint-active',
adds: ['s-add'],
removes: ['s-rem'],
})
expect(result).toMatchObject({
success: true,
affectedStoryIds: expect.arrayContaining(['s-add', 's-rem']),
affectedPbiIds: expect.arrayContaining(['pbiA', 'pbiB']),
affectedTaskIds: expect.arrayContaining(['t1', 't2']),
})
})
it('rejects when sprint is not accessible', async () => {
mockPrisma.sprint.findFirst.mockResolvedValue(null)
const result = await commitSprintMembershipAction({
activeSprintId: 'sprint-active',
adds: [],
removes: [],
})
expect('error' in result).toBe(true)
if ('error' in result) {
expect(result.code).toBe(403)
}
})
})

View file

@ -0,0 +1,300 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/headers', () => ({
cookies: vi.fn().mockResolvedValue({
set: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
}),
}))
vi.mock('iron-session', () => ({
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
}))
vi.mock('@/lib/session', () => ({
sessionOptions: { cookieName: 'test', password: 'test' },
}))
vi.mock('@/lib/product-access', () => ({
productAccessFilter: vi.fn().mockReturnValue({}),
getAccessibleProduct: vi.fn().mockResolvedValue({
id: 'product-1',
user_id: 'user-1',
}),
}))
vi.mock('@/lib/rate-limit', () => ({
enforceUserRateLimit: vi.fn().mockReturnValue(null),
}))
vi.mock('@/lib/code-server', () => ({
createWithCodeRetry: vi.fn(async (_gen, fn) => fn('SP-1')),
generateNextSprintCode: vi.fn().mockResolvedValue('SP-1'),
}))
vi.mock('@/lib/active-sprint', () => ({
setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/prisma', () => {
const txClient = {
sprint: { create: vi.fn() },
story: { updateMany: vi.fn() },
task: { updateMany: vi.fn() },
}
return {
prisma: {
sprint: {
create: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
},
story: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
task: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
pbi: { findMany: vi.fn() },
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
$transaction: vi.fn(async (fn: (tx: typeof txClient) => unknown) => fn(txClient)),
__txClient: txClient,
},
}
})
import { prisma } from '@/lib/prisma'
import {
createSprintWithSelectionAction,
type CreateSprintWithSelectionInput,
} from '@/actions/sprints'
type Mocked = {
sprint: {
create: ReturnType<typeof vi.fn>
findFirst: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
story: {
findMany: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
}
task: {
findMany: ReturnType<typeof vi.fn>
updateMany: ReturnType<typeof vi.fn>
}
$transaction: ReturnType<typeof vi.fn>
__txClient: {
sprint: { create: ReturnType<typeof vi.fn> }
story: { updateMany: ReturnType<typeof vi.fn> }
task: { updateMany: ReturnType<typeof vi.fn> }
}
}
const mockPrisma = prisma as unknown as Mocked
function baseInput(
overrides: Partial<CreateSprintWithSelectionInput> = {},
): CreateSprintWithSelectionInput {
return {
productId: 'product-1',
metadata: { goal: 'Sprint 1' },
pbiIntent: {},
storyOverrides: {},
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.sprint.create.mockReset()
mockPrisma.story.findMany.mockReset()
mockPrisma.story.updateMany.mockReset()
mockPrisma.task.findMany.mockReset()
mockPrisma.task.updateMany.mockReset()
mockPrisma.$transaction.mockImplementation(
async (fn: (tx: typeof mockPrisma.__txClient) => unknown) =>
fn(mockPrisma.__txClient),
)
mockPrisma.__txClient.sprint.create
.mockReset()
.mockResolvedValue({ id: 'sprint-1', code: 'SP-1' })
mockPrisma.__txClient.story.updateMany
.mockReset()
.mockResolvedValue({ count: 0 })
mockPrisma.__txClient.task.updateMany
.mockReset()
.mockResolvedValue({ count: 0 })
})
describe('createSprintWithSelectionAction', () => {
it('resolves intent=all naar alle child-stories en weert overrides.remove', async () => {
// Stap 1: stories voor PBI-A (intent=all). Plus eligibility-fetch.
mockPrisma.story.findMany
// resolve step (only for pbis with intent='all')
.mockResolvedValueOnce([
{ id: 's1', pbi_id: 'pbiA' },
{ id: 's2', pbi_id: 'pbiA' },
{ id: 's3', pbi_id: 'pbiA' },
])
// partitionByEligibility — alle eligible
.mockResolvedValueOnce([
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
{ id: 's3', sprint_id: null, status: 'OPEN', sprint: null },
])
// affectedStories
.mockResolvedValueOnce([
{ pbi_id: 'pbiA' },
{ pbi_id: 'pbiA' },
])
mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }])
const result = await createSprintWithSelectionAction(
baseInput({
pbiIntent: { pbiA: 'all' },
storyOverrides: { pbiA: { add: [], remove: ['s2'] } },
}),
)
expect('success' in result).toBe(true)
if ('success' in result) {
expect(result.affectedStoryIds).toEqual(['s1', 's3'])
expect(result.conflicts.notEligible).toEqual([])
}
})
it('voegt storyOverrides.add toe over PBI heen (zelfs intent=none)', async () => {
// Geen PBI met intent=all → stap 1 wordt niet uitgevoerd.
mockPrisma.story.findMany
// partition
.mockResolvedValueOnce([
{ id: 's10', sprint_id: null, status: 'OPEN', sprint: null },
])
// affectedStories
.mockResolvedValueOnce([{ pbi_id: 'pbiB' }])
mockPrisma.task.findMany.mockResolvedValueOnce([])
const result = await createSprintWithSelectionAction(
baseInput({
pbiIntent: { pbiB: 'none' },
storyOverrides: { pbiB: { add: ['s10'], remove: [] } },
}),
)
expect('success' in result).toBe(true)
if ('success' in result) {
expect(result.affectedStoryIds).toEqual(['s10'])
}
})
it('eligibility-filter classificeert DONE en cross-sprint stories', async () => {
mockPrisma.story.findMany
// resolve
.mockResolvedValueOnce([
{ id: 's1', pbi_id: 'pbiA' },
{ id: 's2', pbi_id: 'pbiA' },
{ id: 's3', pbi_id: 'pbiA' },
])
// partition: s1=DONE, s2=eligible, s3=in andere OPEN sprint
.mockResolvedValueOnce([
{ id: 's1', sprint_id: null, status: 'DONE', sprint: null },
{ id: 's2', sprint_id: null, status: 'OPEN', sprint: null },
{
id: 's3',
sprint_id: 'sprint-other',
status: 'IN_SPRINT',
sprint: { id: 'sprint-other', code: 'SP-O', status: 'OPEN' },
},
])
// affectedStories
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }])
mockPrisma.task.findMany.mockResolvedValueOnce([])
const result = await createSprintWithSelectionAction(
baseInput({ pbiIntent: { pbiA: 'all' } }),
)
expect('success' in result).toBe(true)
if ('success' in result) {
expect(result.affectedStoryIds).toEqual(['s2'])
expect(result.conflicts.notEligible.map((n) => n.storyId).sort()).toEqual(
['s1', 's3'],
)
expect(result.conflicts.crossSprint.map((c) => c.storyId)).toEqual(['s3'])
}
})
it('zet story.status=IN_SPRINT en task.sprint_id mee in dezelfde transactie', async () => {
mockPrisma.story.findMany
.mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }])
.mockResolvedValueOnce([
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
])
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }])
mockPrisma.task.findMany.mockResolvedValueOnce([{ id: 't1' }])
await createSprintWithSelectionAction(
baseInput({ pbiIntent: { pbiA: 'all' } }),
)
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1)
expect(mockPrisma.__txClient.story.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
sprint_id: 'sprint-1',
status: 'IN_SPRINT',
}),
}),
)
expect(mockPrisma.__txClient.task.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
data: { sprint_id: 'sprint-1' },
}),
)
})
it('returnt affectedStoryIds + affectedPbiIds + affectedTaskIds', async () => {
mockPrisma.story.findMany
.mockResolvedValueOnce([
{ id: 's1', pbi_id: 'pbiA' },
{ id: 's2', pbi_id: 'pbiB' },
])
.mockResolvedValueOnce([
{ id: 's1', sprint_id: null, status: 'OPEN', sprint: null },
{ id: 's2', sprint_id: null, status: 'OPEN', sprint: null },
])
.mockResolvedValueOnce([{ pbi_id: 'pbiA' }, { pbi_id: 'pbiB' }])
mockPrisma.task.findMany.mockResolvedValueOnce([
{ id: 't1' },
{ id: 't2' },
])
const result = await createSprintWithSelectionAction(
baseInput({ pbiIntent: { pbiA: 'all', pbiB: 'all' } }),
)
expect('success' in result).toBe(true)
if ('success' in result) {
expect(result.affectedStoryIds.sort()).toEqual(['s1', 's2'])
expect(result.affectedPbiIds.sort()).toEqual(['pbiA', 'pbiB'])
expect(result.affectedTaskIds.sort()).toEqual(['t1', 't2'])
}
})
it('returnt error wanneer geen eligible stories overblijven', async () => {
mockPrisma.story.findMany
.mockResolvedValueOnce([{ id: 's1', pbi_id: 'pbiA' }])
// s1 is DONE → notEligible
.mockResolvedValueOnce([
{ id: 's1', sprint_id: null, status: 'DONE', sprint: null },
])
const result = await createSprintWithSelectionAction(
baseInput({ pbiIntent: { pbiA: 'all' } }),
)
expect('error' in result).toBe(true)
if ('error' in result) {
expect(result.code).toBe(422)
}
})
})

View file

@ -0,0 +1,167 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/headers', () => ({
cookies: vi.fn().mockResolvedValue({
set: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
}),
}))
vi.mock('iron-session', () => ({
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
}))
vi.mock('@/lib/session', () => ({
sessionOptions: { cookieName: 'test', password: 'test' },
}))
vi.mock('@/lib/product-access', () => ({
productAccessFilter: vi.fn().mockReturnValue({}),
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
product: { findFirst: vi.fn() },
user: {
findUnique: vi.fn(),
update: vi.fn().mockResolvedValue({}),
},
$executeRaw: vi.fn().mockResolvedValue(1),
},
}))
import { prisma } from '@/lib/prisma'
import {
clearPendingSprintDraftAction,
setPendingSprintDraftAction,
} from '@/actions/sprint-draft'
import type { PendingSprintDraft, UserSettings } from '@/lib/user-settings'
const mockPrisma = prisma as unknown as {
product: { findFirst: ReturnType<typeof vi.fn> }
user: {
findUnique: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
}
const validDraft: PendingSprintDraft = {
goal: 'Sprint 1',
pbiIntent: { pbiA: 'all' },
storyOverrides: { pbiA: { add: [], remove: ['story-1'] } },
}
describe('setPendingSprintDraftAction', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.product.findFirst.mockReset()
mockPrisma.user.findUnique.mockReset()
mockPrisma.user.update.mockReset().mockResolvedValue({})
})
it('persists draft for accessible product', async () => {
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} })
const result = await setPendingSprintDraftAction('p1', validDraft)
expect(result).toEqual({ success: true })
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
data: { settings: UserSettings }
}
expect(updateArg.data.settings.workflow?.pendingSprintDraft?.p1).toMatchObject({
goal: 'Sprint 1',
pbiIntent: { pbiA: 'all' },
})
})
it('preserves drafts for other products', async () => {
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
mockPrisma.user.findUnique.mockResolvedValueOnce({
settings: {
workflow: {
pendingSprintDraft: {
p2: { goal: 'P2 draft', pbiIntent: {}, storyOverrides: {} },
},
},
},
})
await setPendingSprintDraftAction('p1', validDraft)
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
data: { settings: UserSettings }
}
const drafts = updateArg.data.settings.workflow?.pendingSprintDraft
expect(Object.keys(drafts ?? {})).toEqual(expect.arrayContaining(['p1', 'p2']))
})
it('rejects invalid draft (empty goal)', async () => {
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
const result = await setPendingSprintDraftAction('p1', {
...validDraft,
goal: '',
} as PendingSprintDraft)
expect(result).toHaveProperty('error')
expect(mockPrisma.user.update).not.toHaveBeenCalled()
})
it('rejects when product not accessible', async () => {
mockPrisma.product.findFirst.mockResolvedValueOnce(null)
const result = await setPendingSprintDraftAction('p1', validDraft)
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
expect(mockPrisma.user.update).not.toHaveBeenCalled()
})
})
describe('clearPendingSprintDraftAction', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.product.findFirst.mockReset()
mockPrisma.user.findUnique.mockReset()
mockPrisma.user.update.mockReset().mockResolvedValue({})
})
it('removes draft key for product', async () => {
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
mockPrisma.user.findUnique.mockResolvedValueOnce({
settings: {
workflow: {
pendingSprintDraft: {
p1: { goal: 'gone', pbiIntent: {}, storyOverrides: {} },
p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} },
},
},
},
})
await clearPendingSprintDraftAction('p1')
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
data: { settings: UserSettings }
}
expect(updateArg.data.settings.workflow?.pendingSprintDraft).toEqual({
p2: { goal: 'keep', pbiIntent: {}, storyOverrides: {} },
})
})
it('is a no-op when there is no draft for the product', async () => {
mockPrisma.product.findFirst.mockResolvedValueOnce({ id: 'p1' })
mockPrisma.user.findUnique.mockResolvedValueOnce({ settings: {} })
const result = await clearPendingSprintDraftAction('p1')
expect(result).toEqual({ success: true })
expect(mockPrisma.user.update).not.toHaveBeenCalled()
})
it('rejects when product not accessible', async () => {
mockPrisma.product.findFirst.mockResolvedValueOnce(null)
const result = await clearPendingSprintDraftAction('p1')
expect(result).toEqual({ error: 'Product niet gevonden of niet toegankelijk' })
})
})

View file

@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
vi.mock('next/headers', () => ({
cookies: vi.fn().mockResolvedValue({
set: vi.fn(),
get: vi.fn(),
delete: vi.fn(),
}),
}))
vi.mock('iron-session', () => ({
getIronSession: vi.fn().mockResolvedValue({ userId: 'user-1', isDemo: false }),
}))
vi.mock('@/lib/session', () => ({
sessionOptions: { cookieName: 'test', password: 'test' },
}))
vi.mock('@/lib/product-access', () => ({
productAccessFilter: vi.fn().mockReturnValue({}),
getAccessibleProduct: vi.fn().mockResolvedValue({ id: 'product-1' }),
}))
vi.mock('@/lib/rate-limit', () => ({
enforceUserRateLimit: vi.fn().mockReturnValue(null),
}))
vi.mock('@/lib/code-server', () => ({
createWithCodeRetry: vi.fn(),
generateNextSprintCode: vi.fn(),
}))
vi.mock('@/lib/active-sprint', () => ({
setActiveSprintInSettings: vi.fn().mockResolvedValue(undefined),
}))
vi.mock('@/lib/prisma', () => ({
prisma: {
sprint: {
findFirst: vi.fn(),
update: vi.fn(),
},
story: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
task: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
$transaction: vi.fn(),
},
}))
import { prisma } from '@/lib/prisma'
import { updateSprintAction } from '@/actions/sprints'
type Mocked = {
sprint: {
findFirst: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
}
const mockPrisma = prisma as unknown as Mocked
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.sprint.findFirst.mockReset().mockResolvedValue({
id: 'sprint-1',
product_id: 'product-1',
})
mockPrisma.sprint.update.mockReset().mockResolvedValue({})
})
describe('updateSprintAction', () => {
it('updates sprint_goal alone', async () => {
const result = await updateSprintAction({
sprintId: 'sprint-1',
fields: { goal: 'Nieuw doel' },
})
expect('success' in result).toBe(true)
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
where: { id: 'sprint-1' },
data: { sprint_goal: 'Nieuw doel' },
})
})
it('updates dates only', async () => {
await updateSprintAction({
sprintId: 'sprint-1',
fields: { startAt: '2026-06-01', endAt: '2026-06-14' },
})
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
where: { id: 'sprint-1' },
data: {
start_date: new Date('2026-06-01'),
end_date: new Date('2026-06-14'),
},
})
})
it('accepts null to clear a date', async () => {
await updateSprintAction({
sprintId: 'sprint-1',
fields: { startAt: null },
})
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
where: { id: 'sprint-1' },
data: { start_date: null },
})
})
it('rejects when sprint not accessible', async () => {
mockPrisma.sprint.findFirst.mockResolvedValue(null)
const result = await updateSprintAction({
sprintId: 'sprint-1',
fields: { goal: 'x' },
})
expect('error' in result).toBe(true)
if ('error' in result) {
expect(result.code).toBe(403)
}
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
})
it('rejects empty goal', async () => {
const result = await updateSprintAction({
sprintId: 'sprint-1',
fields: { goal: '' },
})
expect('error' in result).toBe(true)
expect(mockPrisma.sprint.update).not.toHaveBeenCalled()
})
it('rejects when no fields are supplied', async () => {
const result = await updateSprintAction({
sprintId: 'sprint-1',
fields: {},
})
// Schema-refine should reject; OR action treats empty data as no-op success.
// Current implementation: refine forces minstens één veld → 422 error.
expect('error' in result).toBe(true)
if ('error' in result) {
expect(result.code).toBe(422)
}
})
})

View file

@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/prisma', () => ({
prisma: {
product: { findFirst: vi.fn() },
story: { findMany: vi.fn() },
},
}))
vi.mock('@/lib/api-auth', () => ({
authenticateApiRequest: vi.fn(),
}))
vi.mock('@/lib/product-access', () => ({
productAccessFilter: vi.fn().mockReturnValue({}),
}))
import { prisma } from '@/lib/prisma'
import { authenticateApiRequest } from '@/lib/api-auth'
import { GET } from '@/app/api/products/[id]/cross-sprint-blocks/route'
const mockPrisma = prisma as unknown as {
product: { findFirst: ReturnType<typeof vi.fn> }
story: { findMany: ReturnType<typeof vi.fn> }
}
const mockAuth = authenticateApiRequest as unknown as ReturnType<typeof vi.fn>
function makeRequest(url: string) {
return new Request(url)
}
describe('GET /api/products/[id]/cross-sprint-blocks', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.product.findFirst.mockReset()
mockPrisma.story.findMany.mockReset()
mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' })
})
it('returns blocking sprint info per story for happy path', async () => {
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
mockPrisma.story.findMany.mockResolvedValue([
{
id: 'story-1',
sprint: { id: 'sprint-x', code: 'SP-X' },
},
{
id: 'story-2',
sprint: { id: 'sprint-y', code: 'SP-Y' },
},
])
const req = makeRequest(
'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1&pbiIds=pbiA',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toEqual({
'story-1': { sprintId: 'sprint-x', sprintName: 'SP-X' },
'story-2': { sprintId: 'sprint-y', sprintName: 'SP-Y' },
})
})
it('rejects when pbiIds is missing', async () => {
const req = makeRequest(
'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-1',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(400)
})
it('rejects when pbiIds is empty', async () => {
const req = makeRequest(
'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(400)
})
it('returns 404 when product is not accessible', async () => {
mockPrisma.product.findFirst.mockResolvedValue(null)
const req = makeRequest(
'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(404)
})
it('returns auth error when authenticate fails', async () => {
mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 })
const req = makeRequest(
'http://localhost/api/products/p1/cross-sprint-blocks?pbiIds=pbiA',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(401)
})
it('passes NOT excludeSprintId to prisma when provided', async () => {
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
mockPrisma.story.findMany.mockResolvedValue([])
const req = makeRequest(
'http://localhost/api/products/p1/cross-sprint-blocks?excludeSprintId=sp-active&pbiIds=pbiA',
)
await GET(req, { params: Promise.resolve({ id: 'p1' }) })
const callArg = mockPrisma.story.findMany.mock.calls[0][0] as {
where: Record<string, unknown>
}
expect(callArg.where).toMatchObject({
pbi_id: { in: ['pbiA'] },
product_id: 'p1',
sprint_id: { not: null },
NOT: { sprint_id: 'sp-active' },
sprint: { status: 'OPEN' },
})
})
})

View file

@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/prisma', () => ({
prisma: {
product: { findFirst: vi.fn() },
story: { groupBy: vi.fn() },
},
}))
vi.mock('@/lib/api-auth', () => ({
authenticateApiRequest: vi.fn(),
}))
vi.mock('@/lib/product-access', () => ({
productAccessFilter: vi.fn().mockReturnValue({}),
}))
import { prisma } from '@/lib/prisma'
import { authenticateApiRequest } from '@/lib/api-auth'
import { GET } from '@/app/api/products/[id]/sprint-membership-summary/route'
const mockPrisma = prisma as unknown as {
product: { findFirst: ReturnType<typeof vi.fn> }
story: { groupBy: ReturnType<typeof vi.fn> }
}
const mockAuth = authenticateApiRequest as unknown as ReturnType<typeof vi.fn>
function makeRequest(url: string) {
return new Request(url)
}
describe('GET /api/products/[id]/sprint-membership-summary', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPrisma.product.findFirst.mockReset()
mockPrisma.story.groupBy.mockReset()
mockAuth.mockReset().mockResolvedValue({ userId: 'user-1' })
})
it('returns counts per PBI for happy path', async () => {
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
mockPrisma.story.groupBy
.mockResolvedValueOnce([
{ pbi_id: 'pbiA', _count: { _all: 5 } },
{ pbi_id: 'pbiB', _count: { _all: 3 } },
])
.mockResolvedValueOnce([{ pbi_id: 'pbiA', _count: { _all: 2 } }])
const req = makeRequest(
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toEqual({
pbiA: { total: 5, inSprint: 2 },
pbiB: { total: 3, inSprint: 0 },
})
})
it('rejects when pbiIds is missing', async () => {
const req = makeRequest(
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(400)
})
it('rejects when pbiIds is empty', async () => {
const req = makeRequest(
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(400)
})
it('rejects when sprintId is missing', async () => {
const req = makeRequest(
'http://localhost/api/products/p1/sprint-membership-summary?pbiIds=pbiA',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(400)
})
it('returns 404 when product is not accessible', async () => {
mockPrisma.product.findFirst.mockResolvedValue(null)
const req = makeRequest(
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(404)
})
it('returns auth error when authenticate fails', async () => {
mockAuth.mockResolvedValue({ error: 'Niet ingelogd', status: 401 })
const req = makeRequest(
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
expect(res.status).toBe(401)
})
it('returns zero counts for PBIs without stories', async () => {
mockPrisma.product.findFirst.mockResolvedValue({ id: 'p1' })
mockPrisma.story.groupBy
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
const req = makeRequest(
'http://localhost/api/products/p1/sprint-membership-summary?sprintId=sp-1&pbiIds=pbiA,pbiB',
)
const res = await GET(req, { params: Promise.resolve({ id: 'p1' }) })
const body = await res.json()
expect(body).toEqual({
pbiA: { total: 0, inSprint: 0 },
pbiB: { total: 0, inSprint: 0 },
})
})
})

View file

@ -0,0 +1,190 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/prisma', () => ({
prisma: {
sprint: { findFirst: vi.fn() },
user: {
findUnique: vi.fn(),
update: vi.fn().mockResolvedValue({}),
},
$executeRaw: vi.fn().mockResolvedValue(1),
},
}))
import { prisma } from '@/lib/prisma'
import type { UserSettings } from '@/lib/user-settings'
import {
clearActiveSprintInSettings,
readStoredActiveSprintState,
resolveActiveSprint,
} from '@/lib/active-sprint'
const mockPrisma = prisma as unknown as {
sprint: { findFirst: ReturnType<typeof vi.fn> }
user: {
findUnique: ReturnType<typeof vi.fn>
update: ReturnType<typeof vi.fn>
}
$executeRaw: ReturnType<typeof vi.fn>
}
function withSettings(settings: UserSettings) {
mockPrisma.user.findUnique.mockResolvedValueOnce({ settings })
}
describe('readStoredActiveSprintState', () => {
it('returns unset when activeSprints map is absent', () => {
expect(readStoredActiveSprintState({}, 'p1')).toEqual({ kind: 'unset' })
})
it('returns unset when productId key is absent', () => {
const settings: UserSettings = {
layout: { activeSprints: { p2: 'sprint-2' } },
}
expect(readStoredActiveSprintState(settings, 'p1')).toEqual({
kind: 'unset',
})
})
it('returns cleared when key is present with null value', () => {
const settings: UserSettings = {
layout: { activeSprints: { p1: null } },
}
expect(readStoredActiveSprintState(settings, 'p1')).toEqual({
kind: 'cleared',
})
})
it('returns set when key is present with string value', () => {
const settings: UserSettings = {
layout: { activeSprints: { p1: 'sprint-1' } },
}
expect(readStoredActiveSprintState(settings, 'p1')).toEqual({
kind: 'set',
sprintId: 'sprint-1',
})
})
})
describe('resolveActiveSprint', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns null without fallback when key is explicitly null (cleared)', async () => {
withSettings({ layout: { activeSprints: { p1: null } } })
const result = await resolveActiveSprint('p1', 'user-1')
expect(result).toBeNull()
expect(mockPrisma.sprint.findFirst).not.toHaveBeenCalled()
})
it('returns the stored sprint when key is set and sprint exists', async () => {
withSettings({ layout: { activeSprints: { p1: 'sprint-1' } } })
mockPrisma.sprint.findFirst.mockResolvedValueOnce({
id: 'sprint-1',
code: 'SP-1',
status: 'OPEN',
})
const result = await resolveActiveSprint('p1', 'user-1')
expect(result).toEqual({ id: 'sprint-1', code: 'SP-1', status: 'OPEN' })
expect(mockPrisma.sprint.findFirst).toHaveBeenCalledTimes(1)
})
it('falls back when stored sprint is not found in DB', async () => {
withSettings({ layout: { activeSprints: { p1: 'stale-id' } } })
mockPrisma.sprint.findFirst
.mockResolvedValueOnce(null) // stored lookup misses
.mockResolvedValueOnce({ id: 'sprint-open', code: 'SP-O', status: 'OPEN' })
const result = await resolveActiveSprint('p1', 'user-1')
expect(result).toEqual({
id: 'sprint-open',
code: 'SP-O',
status: 'OPEN',
})
})
it('falls back to first OPEN sprint when key is absent', async () => {
withSettings({})
mockPrisma.sprint.findFirst.mockResolvedValueOnce({
id: 'sprint-open',
code: 'SP-O',
status: 'OPEN',
})
const result = await resolveActiveSprint('p1', 'user-1')
expect(result).toEqual({
id: 'sprint-open',
code: 'SP-O',
status: 'OPEN',
})
})
it('falls back to recent CLOSED sprint when no OPEN exists', async () => {
withSettings({})
mockPrisma.sprint.findFirst
.mockResolvedValueOnce(null) // no OPEN
.mockResolvedValueOnce({
id: 'sprint-closed',
code: 'SP-C',
status: 'CLOSED',
})
const result = await resolveActiveSprint('p1', 'user-1')
expect(result).toEqual({
id: 'sprint-closed',
code: 'SP-C',
status: 'CLOSED',
})
})
it('returns null when key absent and no sprints exist', async () => {
withSettings({})
mockPrisma.sprint.findFirst.mockResolvedValue(null)
const result = await resolveActiveSprint('p1', 'user-1')
expect(result).toBeNull()
})
})
describe('clearActiveSprintInSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('writes null instead of deleting the key', async () => {
withSettings({
layout: { activeSprints: { p1: 'sprint-1', p2: 'sprint-2' } },
})
await clearActiveSprintInSettings('user-1', 'p1')
expect(mockPrisma.user.update).toHaveBeenCalledTimes(1)
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
data: { settings: UserSettings }
}
expect(updateArg.data.settings.layout?.activeSprints).toEqual({
p1: null,
p2: 'sprint-2',
})
})
it('adds the key with null when previously unset', async () => {
withSettings({})
await clearActiveSprintInSettings('user-1', 'p1')
const updateArg = mockPrisma.user.update.mock.calls[0][0] as {
data: { settings: UserSettings }
}
expect(updateArg.data.settings.layout?.activeSprints).toEqual({ p1: null })
})
})

View file

@ -0,0 +1,195 @@
import { describe, it, expect, vi } from 'vitest'
import type { StoryStatus } from '@prisma/client'
import {
getBlockingSprintMap,
isEligibleForSprint,
partitionByEligibility,
} from '@/lib/sprint-conflicts'
function mockPrisma(stories: Array<Record<string, unknown>>) {
return {
story: {
findMany: vi.fn().mockResolvedValue(stories),
},
} as unknown as Parameters<typeof partitionByEligibility>[0]
}
describe('isEligibleForSprint', () => {
it('returns true for OPEN story without sprint', () => {
expect(
isEligibleForSprint({ sprint_id: null, status: 'OPEN' as StoryStatus }),
).toBe(true)
})
it('returns true for IN_SPRINT story without sprint_id (edge: restoration)', () => {
expect(
isEligibleForSprint({
sprint_id: null,
status: 'IN_SPRINT' as StoryStatus,
}),
).toBe(true)
})
it('returns false for DONE story without sprint', () => {
expect(
isEligibleForSprint({ sprint_id: null, status: 'DONE' as StoryStatus }),
).toBe(false)
})
it('returns false when story is in any sprint (open status)', () => {
expect(
isEligibleForSprint({
sprint_id: 'abc',
status: 'OPEN' as StoryStatus,
}),
).toBe(false)
})
it('returns false when story is in any sprint (done status)', () => {
expect(
isEligibleForSprint({
sprint_id: 'abc',
status: 'DONE' 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('does NOT mark crossSprint for stories in CLOSED other sprint', async () => {
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: 'IN_OTHER_SPRINT' },
])
})
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)
})
})

View file

@ -122,4 +122,65 @@ describe('UserSettingsSchema', () => {
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 unknown intent value', () => {
const result = UserSettingsSchema.safeParse({
workflow: {
pendingSprintDraft: {
p: { goal: 'x', pbiIntent: { a: 'partial' } },
},
},
})
expect(result.success).toBe(false)
})
})

View file

@ -0,0 +1,341 @@
// @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
}
})
})

View file

@ -56,6 +56,12 @@ function resetStore() {
s.sync.lastResyncAt = null
s.sync.resyncReason = null
s.pendingMutations = {}
s.sprintMembership = {
pbiSummary: {},
crossSprintBlocks: {},
pending: { adds: [], removes: [] },
loadedSummaryForSprintId: null,
}
Object.assign(s, originalActions)
})
}

View file

@ -1,12 +1,21 @@
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) => {
@ -20,6 +29,8 @@ function resetStore() {
beforeEach(() => {
resetStore()
updateAction.mockReset()
setDraftAction.mockReset()
clearDraftAction.mockReset()
})
afterEach(() => {
@ -85,6 +96,130 @@ describe('useUserSettingsStore', () => {
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' } } },

View file

@ -7,7 +7,11 @@ import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
import { productAccessFilter } from '@/lib/product-access'
import { setActiveSprintInSettings } from '@/lib/active-sprint'
import {
clearActiveSprintInSettings,
setActiveSelectionInSettings,
setActiveSprintInSettings,
} from '@/lib/active-sprint'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
@ -18,6 +22,10 @@ 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' }
@ -41,6 +49,99 @@ export async function setActiveSprintAction(productId: string, sprintId: string)
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

121
actions/sprint-draft.ts Normal file
View file

@ -0,0 +1,121 @@
'use server'
import { revalidatePath } from 'next/cache'
import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session'
import { z } from 'zod'
import type { Prisma } from '@prisma/client'
import { prisma } from '@/lib/prisma'
import { SessionData, sessionOptions } from '@/lib/session'
import { productAccessFilter } from '@/lib/product-access'
import {
mergeSettings,
parseUserSettings,
type PendingSprintDraft,
type UserSettings,
} from '@/lib/user-settings'
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
}
const StoryOverridesSchema = z.object({
add: z.array(z.string()),
remove: z.array(z.string()),
}).strict()
const DraftSchema = z.object({
goal: z.string().min(1),
startAt: z.string().date().optional(),
endAt: z.string().date().optional(),
pbiIntent: z.record(z.string(), z.enum(['all', 'none'])).default({}),
storyOverrides: z.record(z.string(), StoryOverridesSchema).default({}),
}).strict()
const SetSchema = z.object({
productId: z.string().min(1),
draft: DraftSchema,
})
const ClearSchema = z.object({
productId: z.string().min(1),
})
async function ensureProductAccess(userId: string, productId: string) {
return prisma.product.findFirst({
where: { id: productId, ...productAccessFilter(userId) },
select: { id: true },
})
}
async function readUserSettings(userId: string): Promise<UserSettings> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { settings: true },
})
return parseUserSettings(user?.settings)
}
async function writeUserSettings(userId: string, next: UserSettings) {
await prisma.user.update({
where: { id: userId },
data: { settings: next as unknown as Prisma.InputJsonValue },
})
}
export async function setPendingSprintDraftAction(
productId: string,
draft: PendingSprintDraft,
) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const parsed = SetSchema.safeParse({ productId, draft })
if (!parsed.success) {
return { error: 'Ongeldige draft', issues: parsed.error.issues }
}
const product = await ensureProductAccess(session.userId, parsed.data.productId)
if (!product) return { error: 'Product niet gevonden of niet toegankelijk' }
const current = await readUserSettings(session.userId)
const patch: Partial<UserSettings> = {
workflow: {
pendingSprintDraft: {
...(current.workflow?.pendingSprintDraft ?? {}),
[parsed.data.productId]: parsed.data.draft,
},
},
}
await writeUserSettings(session.userId, mergeSettings(current, patch))
revalidatePath('/', 'layout')
return { success: true }
}
export async function clearPendingSprintDraftAction(productId: string) {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd' }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
const parsed = ClearSchema.safeParse({ productId })
if (!parsed.success) return { error: 'Ongeldig product-id' }
const product = await ensureProductAccess(session.userId, parsed.data.productId)
if (!product) return { error: 'Product niet gevonden of niet toegankelijk' }
const current = await readUserSettings(session.userId)
const existingMap = current.workflow?.pendingSprintDraft
if (!existingMap || !(parsed.data.productId in existingMap)) {
return { success: true }
}
const nextMap = { ...existingMap }
delete nextMap[parsed.data.productId]
const next: UserSettings = {
...current,
workflow: { ...current.workflow, pendingSprintDraft: nextMap },
}
await writeUserSettings(session.userId, next)
revalidatePath('/', 'layout')
return { success: true }
}

View file

@ -15,8 +15,358 @@ import { enforceUserRateLimit } from '@/lib/rate-limit'
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
import { createWithCodeRetry, generateNextSprintCode } from '@/lib/code-server'
import { setActiveSprintInSettings } from '@/lib/active-sprint'
import { partitionByEligibility } from '@/lib/sprint-conflicts'
import { z } from 'zod'
const StoryOverrideSchema = z.object({
add: z.array(z.string()),
remove: z.array(z.string()),
})
const createSprintWithSelectionSchema = z.object({
productId: z.string().min(1),
metadata: z.object({
goal: z.string().min(1).max(2000),
startAt: z.string().date().optional(),
endAt: z.string().date().optional(),
}),
pbiIntent: z.record(z.string(), z.enum(['all', 'none'])).default({}),
storyOverrides: z.record(z.string(), StoryOverrideSchema).default({}),
})
export type CreateSprintWithSelectionInput = z.infer<
typeof createSprintWithSelectionSchema
>
type SprintCreateConflicts = {
notEligible: { storyId: string; reason: 'DONE' | 'IN_OTHER_SPRINT' }[]
crossSprint: { storyId: string; sprintId: string; sprintName: string }[]
}
export type CreateSprintWithSelectionResult =
| {
success: true
sprintId: string
affectedStoryIds: string[]
affectedPbiIds: string[]
affectedTaskIds: string[]
conflicts: SprintCreateConflicts
}
| { error: string; code: number }
const updateSprintSchema = z.object({
sprintId: z.string().min(1),
fields: z
.object({
goal: z.string().min(1).max(2000).optional(),
startAt: z.string().date().nullable().optional(),
endAt: z.string().date().nullable().optional(),
})
.refine(
(data) => Object.keys(data).length > 0,
'Minstens één veld vereist',
),
})
export type UpdateSprintInput = z.infer<typeof updateSprintSchema>
export type UpdateSprintResult =
| { success: true; sprintId: string }
| { error: string; code: number }
export async function updateSprintAction(
input: UpdateSprintInput,
): Promise<UpdateSprintResult> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = updateSprintSchema.safeParse(input)
if (!parsed.success) return { error: 'Validatie mislukt', code: 422 }
const sprint = await prisma.sprint.findFirst({
where: {
id: parsed.data.sprintId,
product: productAccessFilter(session.userId),
},
select: { id: true, product_id: true },
})
if (!sprint) return { error: 'Sprint niet gevonden', code: 403 }
const data: { sprint_goal?: string; start_date?: Date | null; end_date?: Date | null } = {}
if (parsed.data.fields.goal !== undefined) {
data.sprint_goal = parsed.data.fields.goal
}
if (parsed.data.fields.startAt !== undefined) {
data.start_date = parseDate(parsed.data.fields.startAt)
}
if (parsed.data.fields.endAt !== undefined) {
data.end_date = parseDate(parsed.data.fields.endAt)
}
await prisma.sprint.update({
where: { id: parsed.data.sprintId },
data,
})
revalidatePath(`/products/${sprint.product_id}`, 'layout')
return { success: true, sprintId: parsed.data.sprintId }
}
const commitSprintMembershipSchema = z.object({
activeSprintId: z.string().min(1),
adds: z.array(z.string()),
removes: z.array(z.string()),
})
export type CommitSprintMembershipInput = z.infer<
typeof commitSprintMembershipSchema
>
type CommitConflicts = {
notEligible: { storyId: string; reason: 'DONE' | 'IN_OTHER_SPRINT' }[]
alreadyRemoved: string[]
}
export type CommitSprintMembershipResult =
| {
success: true
affectedStoryIds: string[]
affectedPbiIds: string[]
affectedTaskIds: string[]
conflicts: CommitConflicts
}
| { error: string; code: number }
export async function commitSprintMembershipAction(
input: CommitSprintMembershipInput,
): Promise<CommitSprintMembershipResult> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const parsed = commitSprintMembershipSchema.safeParse(input)
if (!parsed.success) return { error: 'Validatie mislukt', code: 422 }
// Sprint moet bestaan en bereikbaar zijn via product-access.
const sprint = await prisma.sprint.findFirst({
where: {
id: parsed.data.activeSprintId,
product: productAccessFilter(session.userId),
},
select: { id: true, product_id: true },
})
if (!sprint) {
return { error: 'Sprint niet gevonden of niet toegankelijk', code: 403 }
}
// Filter adds via eligibility (sprint_id IS NULL en niet DONE; andere OPEN
// sprint → conflicts.notEligible + crossSprint).
const addPartition = await partitionByEligibility(
prisma,
parsed.data.adds,
parsed.data.activeSprintId,
)
const eligibleAdds = addPartition.eligible
const notEligibleAdds = addPartition.notEligible
// Race-safety voor removes: alleen stories die feitelijk in de actieve
// sprint zitten worden verwijderd.
const removeRows =
parsed.data.removes.length > 0
? await prisma.story.findMany({
where: {
id: { in: parsed.data.removes },
sprint_id: parsed.data.activeSprintId,
},
select: { id: true },
})
: []
const validRemoves = removeRows.map((r) => r.id)
const validRemoveSet = new Set(validRemoves)
const alreadyRemoved = parsed.data.removes.filter(
(id) => !validRemoveSet.has(id),
)
if (eligibleAdds.length === 0 && validRemoves.length === 0) {
// Geen werk te doen — geef toch een success-shape terug zodat de client
// pending buffer kan resetten + conflicts kan tonen.
return {
success: true,
affectedStoryIds: [],
affectedPbiIds: [],
affectedTaskIds: [],
conflicts: { notEligible: notEligibleAdds, alreadyRemoved },
}
}
await prisma.$transaction(async (tx) => {
if (eligibleAdds.length > 0) {
await tx.story.updateMany({
where: { id: { in: eligibleAdds } },
data: { sprint_id: parsed.data.activeSprintId, status: 'IN_SPRINT' },
})
await tx.task.updateMany({
where: { story_id: { in: eligibleAdds } },
data: { sprint_id: parsed.data.activeSprintId },
})
}
if (validRemoves.length > 0) {
await tx.story.updateMany({
where: { id: { in: validRemoves } },
data: { sprint_id: null, status: 'OPEN' },
})
await tx.task.updateMany({
where: { story_id: { in: validRemoves } },
data: { sprint_id: null },
})
}
})
const affectedStoryIds = [...eligibleAdds, ...validRemoves]
const affectedStories =
affectedStoryIds.length > 0
? await prisma.story.findMany({
where: { id: { in: affectedStoryIds } },
select: { pbi_id: true },
})
: []
const affectedPbiIds = Array.from(
new Set(affectedStories.map((s) => s.pbi_id)),
)
const affectedTasks =
affectedStoryIds.length > 0
? await prisma.task.findMany({
where: { story_id: { in: affectedStoryIds } },
select: { id: true },
})
: []
const affectedTaskIds = affectedTasks.map((t) => t.id)
revalidatePath(`/products/${sprint.product_id}`, 'layout')
return {
success: true,
affectedStoryIds,
affectedPbiIds,
affectedTaskIds,
conflicts: { notEligible: notEligibleAdds, alreadyRemoved },
}
}
export async function createSprintWithSelectionAction(
input: CreateSprintWithSelectionInput,
): Promise<CreateSprintWithSelectionResult> {
const session = await getSession()
if (!session.userId) return { error: 'Niet ingelogd', code: 403 }
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus', code: 403 }
const limited = enforceUserRateLimit('create-sprint', session.userId)
if (limited) return { error: limited.error, code: limited.code }
const parsed = createSprintWithSelectionSchema.safeParse(input)
if (!parsed.success) return { error: 'Validatie mislukt', code: 422 }
const product = await getAccessibleProduct(parsed.data.productId, session.userId)
if (!product) return { error: 'Product niet gevonden', code: 403 }
// Resolveer intent + per-PBI overrides naar concrete story-IDs.
const allPbiAllIds = Object.entries(parsed.data.pbiIntent)
.filter(([, intent]) => intent === 'all')
.map(([pbiId]) => pbiId)
// Stap 1: alle child-stories voor PBI's met intent='all'.
let candidate: string[] = []
if (allPbiAllIds.length > 0) {
const rows = await prisma.story.findMany({
where: { pbi_id: { in: allPbiAllIds }, product_id: parsed.data.productId },
select: { id: true, pbi_id: true },
})
const removedSet = new Set<string>()
for (const [pbiId, override] of Object.entries(parsed.data.storyOverrides)) {
for (const id of override.remove) removedSet.add(`${pbiId}:${id}`)
}
candidate = rows
.filter((row) => !removedSet.has(`${row.pbi_id}:${row.id}`))
.map((row) => row.id)
}
// Stap 2: storyOverrides.add — werkt voor zowel intent='none' als 'all' (extra
// toevoegingen). Dedupliceren met candidates uit stap 1.
const candidateSet = new Set(candidate)
for (const override of Object.values(parsed.data.storyOverrides)) {
for (const id of override.add) candidateSet.add(id)
}
const candidateIds = Array.from(candidateSet)
// Eligibility-filter (incl. cross-sprint guard).
const partition = await partitionByEligibility(prisma, candidateIds)
if (partition.eligible.length === 0) {
return {
error: 'Geen eligible stories voor deze sprint',
code: 422,
}
}
const sprint = await createWithCodeRetry(
() => generateNextSprintCode(parsed.data.productId),
(code) =>
prisma.$transaction(async (tx) => {
const created = await tx.sprint.create({
data: {
product_id: parsed.data.productId,
code,
sprint_goal: parsed.data.metadata.goal,
status: 'OPEN',
start_date: parseDate(parsed.data.metadata.startAt),
end_date: parseDate(parsed.data.metadata.endAt),
},
})
await tx.story.updateMany({
where: { id: { in: partition.eligible } },
data: { sprint_id: created.id, status: 'IN_SPRINT' },
})
await tx.task.updateMany({
where: { story_id: { in: partition.eligible } },
data: { sprint_id: created.id },
})
return created
}),
)
// Snapshot affected pbi/task IDs voor client-store patches.
const affectedStories = await prisma.story.findMany({
where: { id: { in: partition.eligible } },
select: { pbi_id: true },
})
const affectedPbiIds = Array.from(new Set(affectedStories.map((s) => s.pbi_id)))
const affectedTasks = await prisma.task.findMany({
where: { story_id: { in: partition.eligible } },
select: { id: true },
})
const affectedTaskIds = affectedTasks.map((t) => t.id)
await setActiveSprintInSettings(
session.userId,
parsed.data.productId,
sprint.id,
)
revalidatePath(`/products/${parsed.data.productId}`, 'layout')
return {
success: true,
sprintId: sprint.id,
affectedStoryIds: partition.eligible,
affectedPbiIds,
affectedTaskIds,
conflicts: {
notEligible: partition.notEligible,
crossSprint: partition.crossSprint,
},
}
}
async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions)
}
@ -53,10 +403,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 }
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 }
// 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 sprint = await createWithCodeRetry(
() => generateNextSprintCode(parsed.data.productId),

View file

@ -15,7 +15,11 @@ 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 { StartSprintButton } from '@/components/sprint/start-sprint-button'
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 { ActivateProductButton } from '@/components/shared/activate-product-button'
import { EditProductButton } from '@/components/products/edit-product-button'
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
@ -118,13 +122,15 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
{!isActiveProduct && (
<ActivateProductButton productId={id} isDemo={isDemo} redirectTo={`/products/${id}`} />
)}
{hasOpenSprint ? (
{hasOpenSprint && (
<Link href={`/products/${id}/sprint`} className="text-xs text-primary hover:underline font-medium">
Sprint actief
</Link>
) : (
!isDemo && <StartSprintButton productId={id} />
)}
{activeSprintItem && !isDemo && (
<SaveSprintButton activeSprintId={activeSprintItem.id} />
)}
{!isDemo && <NewSprintTrigger productId={id} isDemo={isDemo} />}
{!isDemo && product.user_id === session.userId && (
<EditProductButton
product={{
@ -147,6 +153,10 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
</div>
</div>
{/* Sprint definition banner (state A) + beforeunload-guard */}
<SprintDraftBanner productId={id} />
<SprintDraftLeaveGuard productId={id} />
{/* Split pane */}
<div className="flex-1 overflow-hidden">
<BacklogHydrationWrapper
@ -159,6 +169,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
}}
>
<UrlTaskSync />
<ActiveSelectionHydrator productId={id} />
<BacklogSplitPane
cookieKey={`backlog-${id}`}
defaultSplit={[20, 45, 35]}
@ -168,11 +179,13 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
key="pbi"
productId={id}
isDemo={isDemo}
activeSprintId={activeSprintItem?.id ?? null}
/>,
<StoryPanel
key="story"
productId={id}
isDemo={isDemo}
activeSprintId={activeSprintItem?.id ?? null}
/>,
<TaskPanel
key="tasks"

View file

@ -0,0 +1,74 @@
// PBI-79 / T-929: GET /api/products/:id/cross-sprint-blocks
//
// Lichte UX-hint voor disabled-vinkjes: welke stories binnen pbiIds zitten in
// een andere OPEN sprint (excludeSprintId expliciet uitgesloten). Server-side
// commit-actions blijven autoritatief — dit endpoint is alleen voor UI.
import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
export const dynamic = 'force-dynamic'
function parsePbiIds(raw: string | null): string[] | null {
if (!raw) return null
const ids = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
return ids.length === 0 ? null : ids
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await authenticateApiRequest(request)
if ('error' in auth) {
return Response.json({ error: auth.error }, { status: auth.status })
}
const { id: productId } = await params
const url = new URL(request.url)
const excludeSprintId = url.searchParams.get('excludeSprintId') ?? undefined
const pbiIds = parsePbiIds(url.searchParams.get('pbiIds'))
if (!pbiIds) {
return Response.json(
{ error: 'pbiIds is verplicht (comma-separated)' },
{ status: 400 },
)
}
const product = await prisma.product.findFirst({
where: { id: productId, ...productAccessFilter(auth.userId) },
select: { id: true },
})
if (!product) {
return Response.json({ error: 'Product niet gevonden' }, { status: 404 })
}
const stories = await prisma.story.findMany({
where: {
pbi_id: { in: pbiIds },
product_id: productId,
sprint_id: { not: null },
...(excludeSprintId ? { NOT: { sprint_id: excludeSprintId } } : {}),
sprint: { status: 'OPEN' },
},
select: {
id: true,
sprint: { select: { id: true, code: true } },
},
})
const result: Record<string, { sprintId: string; sprintName: string }> = {}
for (const story of stories) {
if (!story.sprint) continue
result[story.id] = {
sprintId: story.sprint.id,
sprintName: story.sprint.code,
}
}
return Response.json(result)
}

View file

@ -0,0 +1,87 @@
// PBI-79 / T-928: GET /api/products/:id/sprint-membership-summary
//
// Levert per PBI {total, inSprint} counts, gescoped op de doorgegeven pbiIds.
// Endpoint weigert product-brede aanroepen (pbiIds is verplicht). Eén groupBy
// + één count-by-sprint waar pbi_id IN (pbiIds).
import { authenticateApiRequest } from '@/lib/api-auth'
import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
export const dynamic = 'force-dynamic'
function parsePbiIds(raw: string | null): string[] | null {
if (!raw) return null
const ids = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
return ids.length === 0 ? null : ids
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await authenticateApiRequest(request)
if ('error' in auth) {
return Response.json({ error: auth.error }, { status: auth.status })
}
const { id: productId } = await params
const url = new URL(request.url)
const sprintId = url.searchParams.get('sprintId')
const pbiIds = parsePbiIds(url.searchParams.get('pbiIds'))
if (!sprintId) {
return Response.json({ error: 'sprintId is verplicht' }, { status: 400 })
}
if (!pbiIds) {
return Response.json(
{ error: 'pbiIds is verplicht (comma-separated)' },
{ status: 400 },
)
}
const product = await prisma.product.findFirst({
where: { id: productId, ...productAccessFilter(auth.userId) },
select: { id: true },
})
if (!product) {
return Response.json({ error: 'Product niet gevonden' }, { status: 404 })
}
const [totals, inSprint] = await Promise.all([
prisma.story.groupBy({
by: ['pbi_id'],
where: { pbi_id: { in: pbiIds }, product_id: productId },
_count: { _all: true },
}),
prisma.story.groupBy({
by: ['pbi_id'],
where: {
pbi_id: { in: pbiIds },
product_id: productId,
sprint_id: sprintId,
},
_count: { _all: true },
}),
])
const inSprintByPbi = new Map<string, number>()
for (const row of inSprint) {
inSprintByPbi.set(row.pbi_id, row._count._all)
}
const result: Record<string, { total: number; inSprint: number }> = {}
for (const pbiId of pbiIds) {
result[pbiId] = { total: 0, inSprint: inSprintByPbi.get(pbiId) ?? 0 }
}
for (const row of totals) {
result[row.pbi_id] = {
total: row._count._all,
inSprint: inSprintByPbi.get(row.pbi_id) ?? 0,
}
}
return Response.json(result)
}

View file

@ -0,0 +1,53 @@
'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
}

View file

@ -0,0 +1,203 @@
'use client'
import { useRef, useState, useTransition } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import {
useDirtyCloseGuard,
DirtyCloseGuardDialog,
} from '@/components/shared/use-dirty-close-guard'
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
import {
entityDialogContentClasses,
entityDialogFooterClasses,
entityDialogHeaderClasses,
} from '@/components/shared/entity-dialog-layout'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import { debugProps } from '@/lib/debug'
interface NewSprintMetadataDialogProps {
open: boolean
productId: string
onOpenChange: (open: boolean) => void
}
function todayLocalDate(): string {
return new Date().toLocaleDateString('en-CA')
}
function plusWeeks(weeks: number): string {
const d = new Date()
d.setDate(d.getDate() + weeks * 7)
return d.toLocaleDateString('en-CA')
}
export function NewSprintMetadataDialog({
open,
productId,
onOpenChange,
}: NewSprintMetadataDialogProps) {
const [sprintGoal, setSprintGoal] = useState('')
const [startDate, setStartDate] = useState(todayLocalDate())
const [endDate, setEndDate] = useState(plusWeeks(2))
const [error, setError] = useState<string | null>(null)
const [dirty, setDirty] = useState(false)
const [isPending, startTransition] = useTransition()
const formRef = useRef<HTMLFormElement>(null)
const setPendingSprintDraft = useUserSettingsStore(
(s) => s.setPendingSprintDraft,
)
function reset() {
setSprintGoal('')
setStartDate(todayLocalDate())
setEndDate(plusWeeks(2))
setError(null)
setDirty(false)
}
const closeGuard = useDirtyCloseGuard(dirty, () => {
onOpenChange(false)
reset()
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const goal = sprintGoal.trim()
if (!goal) return
setError(null)
startTransition(async () => {
try {
await setPendingSprintDraft(productId, {
goal,
startAt: startDate || undefined,
endAt: endDate || undefined,
pbiIntent: {},
storyOverrides: {},
})
reset()
onOpenChange(false)
} catch (err) {
const message =
err instanceof Error ? err.message : 'Onbekende fout bij opslaan'
setError(message)
toast.error(message)
}
})
}
const handleKeyDown = useDialogSubmitShortcut(() =>
formRef.current?.requestSubmit(),
)
return (
<>
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) closeGuard.attemptClose()
else onOpenChange(o)
}}
>
<DialogContent
showCloseButton={false}
onKeyDown={handleKeyDown}
className={entityDialogContentClasses}
{...debugProps(
'new-sprint-metadata-dialog',
'NewSprintMetadataDialog',
'components/backlog/new-sprint-metadata-dialog.tsx',
)}
>
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">
Nieuwe sprint
</DialogTitle>
<p className="text-xs text-muted-foreground mt-1">
Geef het sprint-doel en periode op. Je selecteert daarna PBI&apos;s
en stories via vinkjes in de backlog.
</p>
</div>
<form
ref={formRef}
id="new-sprint-metadata-form"
onSubmit={handleSubmit}
onChange={() => setDirty(true)}
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
Sprint Goal <span className="text-error">*</span>
</label>
<Textarea
value={sprintGoal}
onChange={(e) => setSprintGoal(e.target.value)}
required
rows={3}
placeholder="Wat wil je aan het einde van deze Sprint bereikt hebben?"
autoFocus
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
Startdatum
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
Einddatum
</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
{error && (
<div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error">
{error}
</div>
)}
</form>
<div className={entityDialogFooterClasses}>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={closeGuard.attemptClose}
disabled={isPending}
>
Annuleren
</Button>
<Button
type="submit"
form="new-sprint-metadata-form"
disabled={isPending || !sprintGoal.trim()}
data-debug-id="new-sprint-metadata-dialog__submit"
>
{isPending ? 'Opslaan…' : 'Verder'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<DirtyCloseGuardDialog guard={closeGuard} />
</>
)
}

View file

@ -0,0 +1,46 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import { NewSprintMetadataDialog } from './new-sprint-metadata-dialog'
interface NewSprintTriggerProps {
productId: string
isDemo: boolean
}
/**
* PBI-79 / ST-1337: trigger-knop voor de nieuwe sprint-flow.
* Verbergt zichzelf wanneer er al een pendingSprintDraft loopt dan
* staat de SprintDefinitionBanner zelf de afronding te regelen.
*/
export function NewSprintTrigger({ productId, isDemo }: NewSprintTriggerProps) {
const [open, setOpen] = useState(false)
const hasDraft = useUserSettingsStore(
(s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId],
)
if (hasDraft) return null
return (
<>
<DemoTooltip show={isDemo}>
<Button
size="sm"
onClick={() => setOpen(true)}
disabled={isDemo}
data-debug-id="new-sprint-trigger"
>
Nieuwe sprint
</Button>
</DemoTooltip>
<NewSprintMetadataDialog
open={open}
productId={productId}
onOpenChange={setOpen}
/>
</>
)
}

View file

@ -21,7 +21,7 @@ import {
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
import { CheckSquare, Square } from 'lucide-react'
import { CheckSquare, MinusSquare, Square } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
@ -32,7 +32,11 @@ import {
import { useShallow } from 'zustand/react/shallow'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import { selectVisiblePbis } from '@/stores/product-workspace/selectors'
import {
selectPbiTriState,
selectVisiblePbis,
type PbiTriState,
} from '@/stores/product-workspace/selectors'
import type { BacklogPbi as WorkspacePbi } from '@/stores/product-workspace/types'
import { deletePbiAction } from '@/actions/pbis'
import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories'
@ -41,7 +45,6 @@ import { debugProps } from '@/lib/debug'
import { PbiDialog, type PbiDialogState } from './pbi-dialog'
import { BacklogCard } from './backlog-card'
import { EmptyPanel } from './empty-panel'
import { NewSprintDialog } from '@/components/sprint/new-sprint-dialog'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { PRIORITY_COLORS } from '@/components/shared/priority-select'
import { PBI_STATUS_LABELS, PBI_STATUS_COLORS } from '@/components/shared/pbi-status-select'
@ -77,15 +80,24 @@ interface Pbi {
interface PbiListProps {
productId: string
isDemo: boolean
activeSprintId?: string | null
}
// --- Sortable PBI row ---
function TriStateIcon({ state }: { state: PbiTriState }) {
if (state === 'full')
return <CheckSquare size={18} className="text-primary" />
if (state === 'partial')
return <MinusSquare size={18} className="text-primary" />
return <Square size={18} />
}
function SortablePbiRow({
pbi,
isSelected,
isDemo,
selectionMode,
isChecked,
triState,
onSelect,
onToggleCheck,
onEdit,
@ -95,7 +107,7 @@ function SortablePbiRow({
isSelected: boolean
isDemo: boolean
selectionMode: boolean
isChecked: boolean
triState: PbiTriState
onSelect: () => void
onToggleCheck: () => void
onEdit: () => void
@ -119,24 +131,39 @@ function SortablePbiRow({
title={pbi.title}
code={pbi.code}
priority={pbi.priority}
isSelected={isChecked}
isSelected={isSelected}
role="button"
tabIndex={0}
aria-pressed={isChecked}
onClick={onToggleCheck}
onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggleCheck() } }}
aria-pressed={isSelected}
onClick={onSelect}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect()
}
}}
badge={
<Badge className={cn('text-xs font-normal', PBI_STATUS_COLORS[pbi.status])}>
{PBI_STATUS_LABELS[pbi.status]}
</Badge>
}
actions={
<div
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground"
aria-hidden="true"
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onToggleCheck()
}}
aria-pressed={triState !== 'empty'}
aria-label={
triState === 'full'
? 'Stories uit sprint halen'
: 'Stories aan sprint toevoegen'
}
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-foreground rounded transition-colors"
>
{isChecked ? <CheckSquare size={18} className="text-primary" /> : <Square size={18} />}
</div>
<TriStateIcon state={triState} />
</button>
}
/>
)
@ -194,7 +221,7 @@ function SortablePbiRow({
// --- Main component ---
// PBI-74 / T-849: leest pbis + actieve selectie uit workspace-store via
// useShallow-selector. DnD-mutaties via applyOptimisticMutation/rollback/settle.
export function PbiList({ productId, isDemo }: PbiListProps) {
export function PbiList({ productId, isDemo, activeSprintId = null }: PbiListProps) {
// selectVisiblePbis is gesorteerd op priority/sort_order; useShallow
// voorkomt re-render op ongerelateerde store-mutaties (G2).
const pbis = useProductWorkspaceStore(useShallow(selectVisiblePbis)) as WorkspacePbi[]
@ -216,23 +243,49 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
const [filterPopoverOpen, setFilterPopoverOpen] = useState(false)
const [dialogState, setDialogState] = useState<PbiDialogState | null>(null)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [selectionMode, setSelectionMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [newSprintOpen, setNewSprintOpen] = useState(false)
const [, startTransition] = useTransition()
function exitSelection() {
setSelectionMode(false)
setSelectedIds(new Set())
}
// PBI-79 / ST-1337+ST-1338: selectionMode is afgeleid uit drie staten:
// A (pendingSprintDraft) → vinkjes muteren de draft via upsertPbiIntent.
// B (activeSprintId zonder draft) → vinkjes muteren de membership-buffer
// via toggleStorySprintMembership per child story (bulk).
// A (geen sprint, geen draft) → geen vinkjes.
const hasDraft = useUserSettingsStore(
(s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId],
)
const upsertPbiIntent = useUserSettingsStore((s) => s.upsertPbiIntent)
const toggleStorySprintMembership = useProductWorkspaceStore(
(s) => s.toggleStorySprintMembership,
)
const stateBMode = !hasDraft && !!activeSprintId
const selectionMode = hasDraft || stateBMode
function toggleCheck(id: string) {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
function togglePbiInDraft(id: string, currentState: PbiTriState) {
if (hasDraft) {
// A: empty/partial → all; full → none.
const nextIntent = currentState === 'full' ? 'none' : 'all'
void upsertPbiIntent(productId, id, nextIntent)
return
}
if (stateBMode && activeSprintId) {
// State B: bulk-toggle alle child-stories naar/uit de pending buffer.
const store = useProductWorkspaceStore.getState()
const storyIds = store.relations.storyIdsByPbi[id] ?? []
const goingFull = currentState !== 'full'
for (const storyId of storyIds) {
const story = store.entities.storiesById[storyId]
if (!story) continue
const blocked = store.sprintMembership.crossSprintBlocks[storyId]
if (blocked) continue
const inSprint = story.sprint_id === activeSprintId
if (goingFull && !inSprint) {
toggleStorySprintMembership(storyId, false)
}
if (!goingFull && inSprint) {
toggleStorySprintMembership(storyId, true)
}
}
}
}
// pbis komen al gesorteerd binnen via selectVisiblePbis (priority + sort_order).
@ -398,21 +451,6 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
setSortDir('asc')
}}
/>
<DemoTooltip show={isDemo}>
<Button
size="sm"
variant={selectionMode ? 'default' : 'outline'}
className="h-7 text-xs"
disabled={isDemo}
onClick={() => {
if (isDemo) return
if (selectionMode) exitSelection()
else setSelectionMode(true)
}}
>
{selectionMode ? 'Selecteren stoppen' : "Selecteer PBI's"}
</Button>
</DemoTooltip>
<DemoTooltip show={isDemo}>
<Button
size="sm"
@ -445,15 +483,15 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
>
<div className="p-3 flex flex-col gap-2" {...debugProps('pbi-list__items')}>
{filtered.map(pbi => (
<SortablePbiRow
<SortablePbiRowWithTriState
key={pbi.id}
pbi={pbi}
isSelected={selectedPbiId === pbi.id}
isDemo={isDemo}
selectionMode={selectionMode}
isChecked={selectedIds.has(pbi.id)}
productId={productId}
onSelect={() => useProductWorkspaceStore.getState().setActivePbi(pbi.id)}
onToggleCheck={() => toggleCheck(pbi.id)}
onToggle={togglePbiInDraft}
onEdit={() => setDialogState({ mode: 'edit', productId, pbi })}
onDelete={() => handleDelete(pbi.id)}
/>
@ -474,53 +512,72 @@ export function PbiList({ productId, isDemo }: PbiListProps) {
)}
</div>
{selectionMode && (
<div className="border-t border-border bg-surface-container px-4 py-2 flex items-center justify-between gap-2 shrink-0">
<span className="text-sm text-foreground">
{selectedIds.size} geselecteerd
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={exitSelection}
>
Annuleer
</Button>
<Button
size="sm"
className="h-7 text-xs"
disabled={selectedIds.size === 0}
onClick={() => setNewSprintOpen(true)}
>
Nieuwe sprint
</Button>
</div>
</div>
)}
<PbiDialog
state={dialogState}
onClose={() => setDialogState(null)}
isDemo={isDemo}
/>
<NewSprintDialog
open={newSprintOpen}
productId={productId}
pbiIds={Array.from(selectedIds)}
onOpenChange={(open) => {
setNewSprintOpen(open)
if (!open) {
// Sluit selectie bij geslaagde aanmaak; bij annuleren laat de selectie staan
}
}}
onCreated={() => {
setNewSprintOpen(false)
exitSelection()
}}
/>
</div>
)
}
// PBI-79 / ST-1337: wrapper rond SortablePbiRow die zijn tri-state uit de
// workspace-store leest. Subscribed per PBI zodat alleen de relevante rij
// re-rendert bij pbiIntent/storyOverrides-mutaties.
function SortablePbiRowWithTriState({
pbi,
isSelected,
isDemo,
selectionMode,
productId,
onSelect,
onToggle,
onEdit,
onDelete,
}: {
pbi: Pbi
isSelected: boolean
isDemo: boolean
selectionMode: boolean
productId: string
onSelect: () => void
onToggle: (id: string, currentState: PbiTriState) => void
onEdit: () => void
onDelete: () => void
}) {
// Tri-state uit pendingSprintDraft (state A) of pbiSummary (state B).
// Wanneer geen draft: leid af van pbiSummary; wanneer wel: uit pbiIntent.
const triState = useUserSettingsStore((s) => {
const draft = s.entities.settings.workflow?.pendingSprintDraft?.[productId]
if (draft) {
const intent = draft.pbiIntent[pbi.id] ?? 'none'
const override = draft.storyOverrides[pbi.id]
if (intent === 'all') {
if (override?.remove.length) return 'partial'
return 'full'
}
if (override?.add.length) return 'partial'
return 'empty'
}
return null
})
const summaryTriState = useProductWorkspaceStore((s) =>
selectPbiTriState(s, pbi.id),
)
const effectiveTriState: PbiTriState =
triState ?? (selectionMode ? summaryTriState : 'empty')
return (
<SortablePbiRow
pbi={pbi}
isSelected={isSelected}
isDemo={isDemo}
selectionMode={selectionMode}
triState={effectiveTriState}
onSelect={onSelect}
onToggleCheck={() => onToggle(pbi.id, effectiveTriState)}
onEdit={onEdit}
onDelete={onDelete}
/>
)
}

View file

@ -0,0 +1,89 @@
'use client'
import { useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import {
selectIsDirty,
selectPendingCount,
} from '@/stores/product-workspace/selectors'
import { commitSprintMembershipAction } from '@/actions/sprints'
interface SaveSprintButtonProps {
activeSprintId: string
}
/**
* PBI-79 / ST-1338 / T-940: 'Sprint opslaan'-knop voor state B.
* Altijd zichtbaar zolang er een actieve sprint is. Disabled bij clean,
* enabled met teller bij dirty. Commit gebeurt via
* commitSprintMembershipAction; client patcht gericht via
* applyMembershipCommitResult. Geen router.refresh.
*/
export function SaveSprintButton({ activeSprintId }: SaveSprintButtonProps) {
const router = useRouter()
const isDirty = useProductWorkspaceStore(selectIsDirty)
const count = useProductWorkspaceStore(selectPendingCount)
const adds = useProductWorkspaceStore((s) => s.sprintMembership.pending.adds)
const removes = useProductWorkspaceStore(
(s) => s.sprintMembership.pending.removes,
)
const applyMembershipCommitResult = useProductWorkspaceStore(
(s) => s.applyMembershipCommitResult,
)
const [isPending, startTransition] = useTransition()
function handleSave() {
startTransition(async () => {
const result = await commitSprintMembershipAction({
activeSprintId,
adds: [...adds],
removes: [...removes],
})
if ('error' in result) {
toast.error(result.error)
return
}
applyMembershipCommitResult({
activeSprintId,
addedStoryIds: adds.filter((id) =>
result.affectedStoryIds.includes(id),
),
removedStoryIds: removes.filter((id) =>
result.affectedStoryIds.includes(id),
),
})
const skipped =
result.conflicts.notEligible.length +
result.conflicts.alreadyRemoved.length
if (skipped > 0) {
toast.warning(
`${skipped} wijziging${skipped === 1 ? '' : 'en'} overgeslagen — story al in andere sprint of inmiddels verwijderd.`,
)
} else {
toast.success('Sprint opgeslagen')
}
// Gericht patchen voldoende voor lokale UI; refresh haalt server-side
// counts opnieuw op zodat tri-state in volgende renders klopt.
router.refresh()
})
}
return (
<Button
type="button"
size="sm"
onClick={handleSave}
disabled={!isDirty || isPending}
data-debug-id="save-sprint-button"
>
{isPending
? 'Opslaan…'
: isDirty
? `Sprint opslaan (${count})`
: 'Sprint opslaan'}
</Button>
)
}

View file

@ -0,0 +1,202 @@
'use client'
import { useMemo, useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import type { PendingSprintDraft } from '@/lib/user-settings'
import { createSprintWithSelectionAction } from '@/actions/sprints'
import { debugProps } from '@/lib/debug'
interface SprintDefinitionBannerProps {
productId: string
draft: PendingSprintDraft
}
type DraftCounts = {
pbiCount: number
storyCount: number
hasUnknownTotal: boolean
}
function computeCounts(
draft: PendingSprintDraft,
pbiSummary: Record<
string,
{ totalStoryCount: number; inActiveSprintStoryCount: number }
>,
): DraftCounts {
let pbiCount = 0
let storyCount = 0
let hasUnknownTotal = false
const seenPbis = new Set<string>()
for (const [pbiId, intent] of Object.entries(draft.pbiIntent)) {
if (intent === 'all') {
seenPbis.add(pbiId)
const summary = pbiSummary[pbiId]
const override = draft.storyOverrides[pbiId]
if (!summary) {
hasUnknownTotal = true
continue
}
const removed = override?.remove.length ?? 0
storyCount += Math.max(0, summary.totalStoryCount - removed)
}
}
for (const [pbiId, override] of Object.entries(draft.storyOverrides)) {
if (override.add.length === 0) continue
seenPbis.add(pbiId)
storyCount += override.add.length
}
pbiCount = seenPbis.size
return { pbiCount, storyCount, hasUnknownTotal }
}
export function SprintDefinitionBanner({
productId,
draft,
}: SprintDefinitionBannerProps) {
const clearPendingSprintDraft = useUserSettingsStore(
(s) => s.clearPendingSprintDraft,
)
const pbiSummary = useProductWorkspaceStore((s) => s.sprintMembership.pbiSummary)
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [confirmCancel, setConfirmCancel] = useState(false)
const counts = useMemo(
() => computeCounts(draft, pbiSummary),
[draft, pbiSummary],
)
function handleCancel() {
setConfirmCancel(true)
}
function confirmCancelAction() {
setConfirmCancel(false)
startTransition(async () => {
try {
await clearPendingSprintDraft(productId)
} catch (err) {
const message =
err instanceof Error ? err.message : 'Annuleren mislukt'
toast.error(message)
}
})
}
function handleCreate() {
startTransition(async () => {
const result = await createSprintWithSelectionAction({
productId,
metadata: {
goal: draft.goal,
startAt: draft.startAt,
endAt: draft.endAt,
},
pbiIntent: draft.pbiIntent,
storyOverrides: draft.storyOverrides,
})
if ('error' in result) {
toast.error(result.error)
return
}
const { conflicts } = result
if (conflicts.notEligible.length > 0) {
toast.warning(
`${conflicts.notEligible.length} stor${
conflicts.notEligible.length === 1 ? 'y is' : 'ies zijn'
} overgeslagen (al in een andere sprint of afgerond).`,
)
} else {
toast.success('Sprint aangemaakt')
}
router.refresh()
})
}
const storyLabel = counts.hasUnknownTotal
? `${counts.storyCount}+`
: counts.storyCount
const pbiSuffix = counts.pbiCount === 1 ? '' : "'s"
return (
<div
className="sticky top-0 z-30 bg-tertiary-container text-tertiary-container-foreground border-b border-tertiary px-4 py-2.5 flex items-center gap-4"
{...debugProps('sprint-definition-banner')}
>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="text-sm font-medium shrink-0">
Sprint definiëren
</span>
<span className="text-sm truncate" title={draft.goal}>
{draft.goal}
</span>
</div>
<div className="text-xs opacity-80 mt-0.5">
{counts.pbiCount} PBI{pbiSuffix} · {storyLabel} stor
{counts.storyCount === 1 ? 'y' : 'ies'} geselecteerd
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
type="button"
variant="ghost"
onClick={handleCancel}
disabled={isPending}
data-debug-id="sprint-definition-banner__cancel"
>
Annuleren
</Button>
<Button
type="button"
onClick={handleCreate}
disabled={isPending || counts.pbiCount === 0}
data-debug-id="sprint-definition-banner__create"
>
Sprint aanmaken
</Button>
</div>
<AlertDialog open={confirmCancel} onOpenChange={setConfirmCancel}>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>Sprint-definitie annuleren?</AlertDialogTitle>
<AlertDialogDescription>
Je conceptselectie gaat verloren. Het sprint-doel en de
gemarkeerde PBI/stories worden verwijderd.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConfirmCancel(false)}>
Doorgaan
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={confirmCancelAction}
>
Ja, annuleren
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View file

@ -0,0 +1,22 @@
'use client'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import { SprintDefinitionBanner } from './sprint-definition-banner'
interface SprintDraftBannerProps {
productId: string
}
/**
* PBI-79 / ST-1337: client-wrapper die de SprintDefinitionBanner alleen rendert
* als er een pendingSprintDraft voor dit product staat. Hydratatie loopt via
* UserSettingsBridge dit component subscribt op die store en is daarmee
* automatisch reactief op draft-mutaties (set/clear).
*/
export function SprintDraftBanner({ productId }: SprintDraftBannerProps) {
const draft = useUserSettingsStore(
(s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId],
)
if (!draft) return null
return <SprintDefinitionBanner productId={productId} draft={draft} />
}

View file

@ -0,0 +1,37 @@
'use client'
import { useEffect } from 'react'
import { useUserSettingsStore } from '@/stores/user-settings/store'
interface SprintDraftLeaveGuardProps {
productId: string
}
/**
* PBI-79: window.beforeunload-waarschuwing zolang er een pendingSprintDraft
* loopt voor dit product. De draft is session-only en gaat verloren bij
* refresh/close deze guard zorgt dat de gebruiker dat eerst bevestigt.
* Voor in-app route-changes (klikken op een andere product) doet Next.js
* geen onbeforeunload; daar vangen we het op via de banner-Annuleren-flow.
*/
export function SprintDraftLeaveGuard({
productId,
}: SprintDraftLeaveGuardProps) {
const hasDraft = useUserSettingsStore(
(s) => !!s.entities.settings.workflow?.pendingSprintDraft?.[productId],
)
useEffect(() => {
if (!hasDraft) return
function handler(e: BeforeUnloadEvent) {
e.preventDefault()
// Moderne browsers tonen een eigen vertaalde tekst; returnValue is
// alleen nodig voor legacy compat.
e.returnValue = ''
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [hasDraft])
return null
}

View file

@ -0,0 +1,217 @@
'use client'
import { useRef, useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import {
useDirtyCloseGuard,
DirtyCloseGuardDialog,
} from '@/components/shared/use-dirty-close-guard'
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
import {
entityDialogContentClasses,
entityDialogFooterClasses,
entityDialogHeaderClasses,
} from '@/components/shared/entity-dialog-layout'
import { updateSprintAction } from '@/actions/sprints'
import { debugProps } from '@/lib/debug'
interface SprintEditDialogProps {
open: boolean
productId: string
sprint: {
id: string
code: string
sprint_goal: string
start_date?: string | null
end_date?: string | null
}
onOpenChange: (open: boolean) => void
}
function toDateInput(value: string | null | undefined): string {
if (!value) return ''
// Accept ISO datetime or YYYY-MM-DD; output YYYY-MM-DD.
const d = new Date(value)
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleDateString('en-CA')
}
export function SprintEditDialog({
open,
productId,
sprint,
onOpenChange,
}: SprintEditDialogProps) {
const [goal, setGoal] = useState(sprint.sprint_goal)
const [startDate, setStartDate] = useState(toDateInput(sprint.start_date))
const [endDate, setEndDate] = useState(toDateInput(sprint.end_date))
const [error, setError] = useState<string | null>(null)
const [dirty, setDirty] = useState(false)
const [isPending, startTransition] = useTransition()
const formRef = useRef<HTMLFormElement>(null)
const router = useRouter()
function reset() {
setGoal(sprint.sprint_goal)
setStartDate(toDateInput(sprint.start_date))
setEndDate(toDateInput(sprint.end_date))
setError(null)
setDirty(false)
}
const closeGuard = useDirtyCloseGuard(dirty, () => {
onOpenChange(false)
reset()
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const trimmed = goal.trim()
if (!trimmed) return
setError(null)
startTransition(async () => {
const result = await updateSprintAction({
sprintId: sprint.id,
fields: {
goal: trimmed,
startAt: startDate || null,
endAt: endDate || null,
},
})
if ('error' in result) {
setError(result.error)
toast.error(result.error)
return
}
toast.success('Sprint bijgewerkt')
onOpenChange(false)
router.refresh()
})
}
const handleKeyDown = useDialogSubmitShortcut(() =>
formRef.current?.requestSubmit(),
)
return (
<>
<Dialog
open={open}
onOpenChange={(o) => {
if (!o) closeGuard.attemptClose()
else onOpenChange(o)
}}
>
<DialogContent
showCloseButton={false}
onKeyDown={handleKeyDown}
className={entityDialogContentClasses}
{...debugProps(
'sprint-edit-dialog',
'SprintEditDialog',
'components/backlog/sprint-edit-dialog.tsx',
)}
>
<div className={entityDialogHeaderClasses}>
<DialogTitle className="text-xl font-semibold">
Sprint {sprint.code} bewerken
</DialogTitle>
<p className="text-xs text-muted-foreground mt-1">
Wijzig sprint-doel en datums. Voor afronding (per-story DONE/OPEN
beslissing) ga naar de sprint-pagina.
</p>
</div>
<form
ref={formRef}
id="sprint-edit-form"
onSubmit={handleSubmit}
onChange={() => setDirty(true)}
className="flex-1 overflow-y-auto px-6 py-6 space-y-6"
>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
Sprint Goal <span className="text-error">*</span>
</label>
<Textarea
value={goal}
onChange={(e) => setGoal(e.target.value)}
required
rows={3}
autoFocus
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
Startdatum
</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
Einddatum
</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full rounded-md border border-border bg-surface-container px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
<div className="pt-2 border-t border-border">
<Link
href={`/products/${productId}/sprint/${sprint.id}`}
className="text-sm text-primary hover:underline"
>
Sprint afronden
</Link>
</div>
{error && (
<div className="bg-error-container text-error-container-foreground rounded-lg px-3 py-2 text-sm border-l-4 border-error">
{error}
</div>
)}
</form>
<div className={entityDialogFooterClasses}>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="ghost"
onClick={closeGuard.attemptClose}
disabled={isPending}
>
Annuleren
</Button>
<Button
type="submit"
form="sprint-edit-form"
disabled={isPending || !goal.trim()}
data-debug-id="sprint-edit-dialog__submit"
>
{isPending ? 'Opslaan…' : 'Opslaan'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<DirtyCloseGuardDialog guard={closeGuard} />
</>
)
}

View file

@ -21,6 +21,13 @@ import {
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { toast } from 'sonner'
import { CheckSquare, Square } from 'lucide-react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@ -28,7 +35,10 @@ import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { useShallow } from 'zustand/react/shallow'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import { selectStoriesForActivePbi } from '@/stores/product-workspace/selectors'
import {
selectStoriesForActivePbi,
selectStoryIsBlocked,
} from '@/stores/product-workspace/selectors'
import type { BacklogStory as WorkspaceStory } from '@/stores/product-workspace/types'
import { reorderStoriesAction } from '@/actions/stories'
import { StoryDialog, type StoryDialogState } from './story-dialog'
@ -67,17 +77,24 @@ export interface Story {
interface StoryPanelProps {
productId: string
isDemo: boolean
activeSprintId?: string | null
}
// --- Sortable story block ---
function SortableStoryBlock({
story,
isSelected,
cherrypick,
onSelect,
onEdit,
}: {
story: Story
isSelected: boolean
cherrypick: {
checked: boolean
blocked: { sprintName: string } | null
onToggle: () => void
} | null
onSelect: () => void
onEdit: () => void
}) {
@ -109,25 +126,79 @@ function SortableStoryBlock({
</Badge>
}
actions={
<button
onClick={(e) => { e.stopPropagation(); onEdit() }}
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-foreground rounded transition-colors"
aria-label="Story bewerken"
>
<svg className="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<div className="flex items-center gap-1">
{cherrypick && <StoryCherrypickButton {...cherrypick} />}
<button
onClick={(e) => { e.stopPropagation(); onEdit() }}
className="inline-flex items-center justify-center min-h-7 min-w-7 text-muted-foreground hover:text-foreground rounded transition-colors"
aria-label="Story bewerken"
>
<svg className="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
</div>
}
/>
)
}
function StoryCherrypickButton({
checked,
blocked,
onToggle,
}: {
checked: boolean
blocked: { sprintName: string } | null
onToggle: () => void
}) {
const icon = checked ? (
<CheckSquare size={16} className="text-primary" />
) : (
<Square size={16} />
)
if (blocked) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
data-disabled="true"
aria-disabled="true"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center justify-center min-h-7 min-w-7 rounded opacity-40 cursor-not-allowed text-muted-foreground"
>
{icon}
</TooltipTrigger>
<TooltipContent>Zit in sprint {blocked.sprintName}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
return (
<button
onClick={(e) => {
e.stopPropagation()
onToggle()
}}
aria-pressed={checked}
aria-label={
checked ? 'Story uit sprint halen' : 'Story aan sprint toevoegen'
}
className={cn(
'inline-flex items-center justify-center min-h-7 min-w-7 rounded transition-colors',
'text-muted-foreground hover:text-foreground',
)}
>
{icon}
</button>
)
}
// --- Main component ---
// PBI-74 / T-850: leest stories voor active PBI via selectStoriesForActivePbi
// (useShallow). DnD via applyOptimisticMutation('story-order').
export function StoryPanel({ productId, isDemo }: StoryPanelProps) {
export function StoryPanel({ productId, isDemo, activeSprintId = null }: StoryPanelProps) {
const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId)
const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId)
const rawStories = useProductWorkspaceStore(useShallow(selectStoriesForActivePbi)) as WorkspaceStory[]
@ -300,9 +371,11 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) {
<SortableContext items={filtered.map(s => s.id)} strategy={rectSortingStrategy}>
<div className="grid grid-cols-3 gap-2">
{filtered.map(story => (
<SortableStoryBlock
<StoryBlockWithCherrypick
key={story.id}
story={story}
productId={productId}
activeSprintId={activeSprintId}
isSelected={selectedStoryId === story.id}
onSelect={() => useProductWorkspaceStore.getState().setActiveStory(story.id)}
onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })}
@ -332,3 +405,96 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) {
</div>
)
}
// PBI-79 / ST-1337: wrapper rond SortableStoryBlock met cherrypick-handling.
// Subscribed per story zodat enkel de relevante rij re-rendert bij draft- of
// crossSprintBlocks-mutaties.
function StoryBlockWithCherrypick({
story,
productId,
activeSprintId,
isSelected,
onSelect,
onEdit,
}: {
story: Story
productId: string
activeSprintId: string | null
isSelected: boolean
onSelect: () => void
onEdit: () => void
}) {
const draft = useUserSettingsStore(
(s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId],
)
const upsertStoryOverride = useUserSettingsStore((s) => s.upsertStoryOverride)
const toggleStorySprintMembership = useProductWorkspaceStore(
(s) => s.toggleStorySprintMembership,
)
const pending = useProductWorkspaceStore((s) => s.sprintMembership.pending)
const blocked = useProductWorkspaceStore((s) =>
selectStoryIsBlocked(s, story.id),
)
let cherrypick: {
checked: boolean
blocked: { sprintName: string } | null
onToggle: () => void
} | null = null
if (draft) {
// State A: muteer draft via per-PBI overrides.
const intent = draft.pbiIntent[story.pbi_id] ?? 'none'
const override = draft.storyOverrides[story.pbi_id] ?? {
add: [],
remove: [],
}
const checked =
(intent === 'all' && !override.remove.includes(story.id)) ||
override.add.includes(story.id)
cherrypick = {
checked,
blocked: blocked ? { sprintName: blocked.sprintName } : null,
onToggle: () => {
if (intent === 'all') {
void upsertStoryOverride(
productId,
story.pbi_id,
story.id,
checked ? 'remove' : 'clear',
)
} else {
void upsertStoryOverride(
productId,
story.pbi_id,
story.id,
checked ? 'clear' : 'add',
)
}
},
}
} else if (activeSprintId) {
// State B: muteer pending buffer via toggleStorySprintMembership.
const inSprintDb = story.sprint_id === activeSprintId
const inAdds = pending.adds.includes(story.id)
const inRemoves = pending.removes.includes(story.id)
const checked = inAdds || (inSprintDb && !inRemoves)
cherrypick = {
checked,
blocked: blocked ? { sprintName: blocked.sprintName } : null,
onToggle: () => {
toggleStorySprintMembership(story.id, inSprintDb)
},
}
}
return (
<SortableStoryBlock
story={story}
isSelected={isSelected}
cherrypick={cherrypick}
onSelect={onSelect}
onEdit={onEdit}
/>
)
}

View file

@ -13,7 +13,11 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { cn } from '@/lib/utils'
import { setActiveSprintAction } from '@/actions/active-sprint'
import {
clearActiveSprintAction,
switchActiveSprintAction,
} from '@/actions/active-sprint'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import { useUserSettingsStore } from '@/stores/user-settings/store'
import type { SprintStatusApi } from '@/lib/task-status'
import { debugProps } from '@/lib/debug'
@ -47,6 +51,13 @@ export function SprintSwitcher({
const buildingSet = new Set(buildingSprintIds)
const isDemo = useUserSettingsStore(s => s.context.isDemo)
// PBI-79: zolang er een sprint-draft loopt tonen we 'Concept — [goal]'
// bovenaan de dropdown. De draft staat alleen in deze session-store; bij
// page-refresh/leave is hij weg.
const draftGoal = useUserSettingsStore(
(s) => s.entities.settings.workflow?.pendingSprintDraft?.[productId]?.goal ?? null,
)
const visibleSprints = sprints.filter(s => {
if (showClosed) return true
if (s.id === activeSprint?.id) return true
@ -60,13 +71,43 @@ export function SprintSwitcher({
return
}
startTransition(async () => {
const result = await setActiveSprintAction(productId, sprintId)
const result = await switchActiveSprintAction(productId, sprintId)
if ('error' in result) {
toast.error(
typeof result.error === 'string' ? result.error : 'Wisselen mislukt',
)
return
}
// Synchroniseer de client-side workspace-store met de auto-select die
// server-side is bepaald — voorkomt korte flash van vorige selectie
// voordat router.refresh de SSR-render binnenhaalt.
const store = useProductWorkspaceStore.getState()
if (result.pbiId) {
store.setActivePbi(result.pbiId)
if (result.storyId) {
store.setActiveStory(result.storyId)
}
} else {
store.setActivePbi(null)
}
if (pathname.includes('/sprint')) {
router.push(`/products/${productId}/sprint/${sprintId}`)
} else {
router.refresh()
}
})
}
function handleClearActiveSprint() {
if (!activeSprint) return
startTransition(async () => {
const result = await clearActiveSprintAction(productId)
if (result?.error) {
toast.error(typeof result.error === 'string' ? result.error : 'Wisselen mislukt')
return
}
if (pathname.includes('/sprint')) {
router.push(`/products/${productId}/sprint/${sprintId}`)
router.push(`/products/${productId}`)
} else {
router.refresh()
}
@ -133,6 +174,30 @@ export function SprintSwitcher({
Toon afgeronde sprints
</button>
<DropdownMenuSeparator />
{draftGoal && (
<>
<DropdownMenuItem
disabled
className="italic text-tertiary opacity-90 cursor-default"
data-debug-id="sprint-switcher__concept"
>
<span className="shrink-0"> Concept </span>
<span className="truncate">{draftGoal}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
onClick={handleClearActiveSprint}
disabled={!activeSprint || isPending}
className={cn(
'italic text-muted-foreground',
!activeSprint && 'opacity-50 cursor-not-allowed',
)}
>
Geen actieve sprint
</DropdownMenuItem>
<DropdownMenuSeparator />
{visibleSprints.length === 0 ? (
<div className="px-2 py-2 text-sm text-muted-foreground/70 italic">
Geen open sprints

View file

@ -2,7 +2,7 @@
# Documentation Index
Auto-generated on 2026-05-12 from front-matter and headings.
Auto-generated on 2026-05-11 from front-matter and headings.
## Architecture Decision Records
@ -39,44 +39,21 @@ Auto-generated on 2026-05-12 from front-matter and headings.
| Title | Status | Updated |
|---|---|---|
| [Plan — Auto-PR + selectieve deploy-controle + sync-zicht (end-to-end batch flow)](./plans/auto-pr-deploy-sync.md) | — | — |
| [Docs-restructuur — geoptimaliseerd voor AI-lookup](./plans/docs-restructure-ai-lookup.md) | proposal | 2026-05-02 |
| [PBI Bulk-Create Spec — Docs-Restructure for AI-Optimized Lookup](./plans/docs-restructure-pbi-spec.md) | done | 2026-05-03 |
| [Plan: model + mode-selectie per ClaudeJob-kind](./plans/job-model-selection.md) | — | — |
| [Landing v2 — lokaal & veilig + architectuurdiagram](./plans/landing-local-first.md) | active | 2026-05-03 |
| [Landing v3 — van idee tot pull request](./plans/landing-v3-idea-flow.md) | active | 2026-05-04 |
| [Scrum4Me-Research — Zustand rearchitecture (reset + execute)](./plans/lees-de-readme-md-validated-book.md) | — | — |
| [Verbeterplan load/render Product Backlog, Sprint en Solo](./plans/load-render-improvement-plan-2026-05-10.md) | draft | 2026-05-10 |
| [Advies — Zelf een Git-platform hosten naast of in plaats van GitHub](./plans/Local github setup.md) | — | — |
| [M10 — Password-loze inlog via QR-pairing](./plans/M10-qr-pairing-login.md) | active | 2026-05-03 |
| [M11 — Claude vraagt, gebruiker antwoordt](./plans/M11-claude-questions.md) | active | 2026-05-03 |
| [M12 — Idea entity + Grill/Plan Claude jobs](./plans/M12-ideas.md) | planned | — |
| [M9 — Actief Product Backlog](./plans/M9-active-product-backlog.md) | active | 2026-05-03 |
| [PBI-11 — Mobile-shell met landscape-lock (settings + backlog + solo)](./plans/PBI-11-mobile-shell.md) | — | — |
| [PBI-75 — Sprint task-edit client-side via workspace-store](./plans/PBI-75-sprint-task-edit-store.md) | — | — |
| [PBI-78 — Cost-analyse widget op Insights-pagina](./plans/PBI-78-cost-analysis-widget.md) | — | — |
| [PBI-80 — Demo-gebruiker mag eigen UI-voorkeuren wijzigen](./plans/PBI-80-demo-prefs.md) | — | — |
| [Queue-loop verplaatsen van Claude naar runner](./plans/queue-loop-extraction.md) | — | — |
| [Sprint MCP-tools — create_sprint & update_sprint](./plans/sprint-mcp-tools.md) | draft | 2026-05-11 |
| [Advies - SprintRun, PR en worktree lifecycle als state machines](./plans/sprint-pr-worktree-state-machines.md) | draft | 2026-05-06 |
| [ST-1109 — PBI krijgt een status (Ready / Blocked / Done)](./plans/ST-1109-pbi-status.md) | active | 2026-05-03 |
| [ST-1110 — Demo gebruiker read-only](./plans/ST-1110-demo-readonly.md) | active | 2026-05-03 |
| [ST-1111 — Voer uit-knop met Claude Code job queue](./plans/ST-1111-claude-job-trigger.md) | active | 2026-05-03 |
| [ST-1114 — Copilot reviews op dashboard](./plans/ST-1114-copilot-reviews.md) | active | 2026-05-03 |
| [Plan: wekelijkse sync van `model_prices` (PBI-66 / ST-1296)](./plans/sync-model-prices.md) | — | — |
| [Tweede Claude Agent — Planning Agent](./plans/tweede-claude-agent-planning.md) | proposal | 2026-05-03 |
| [User-settings store (DB-backed user prefs)](./plans/user-settings-store.md) | draft | 2026-05-10 |
| [Scrum4Me — v1.0 readiness](./plans/v1-readiness.md) | active | 2026-05-04 |
| [Zustand store rearchitecture - active context, realtime en resync](./plans/zustand-store-rearchitecture.md) | ready-to-execute | 2026-05-09 |
| [Zustand workspace-store implementatieplan (PBI-74)](./plans/zustand-workspace-store-implementation.md) | in-progress | 2026-05-10 |
### Archive
| Title | Updated |
|---|---|
| [CLAUDE.md workflow-update na M7 + ST-509/511/512/513](./plans/archive/2026-04-27-claude-md-workflow-update.md) | 2026-05-03 |
| [Herbruikbaar scripts/insert-milestone.ts](./plans/archive/2026-04-27-insert-milestone-tool.md) | 2026-05-03 |
| [Realtime updates voor Solo Paneel (M8)](./plans/archive/2026-04-27-m8-realtime-solo.md) | 2026-05-03 |
## Patterns
| Title | Status | Updated |
@ -113,15 +90,15 @@ Auto-generated on 2026-05-12 from front-matter and headings.
| [Project Structure, Stores, Realtime & Job Queue](./architecture/project-structure.md) | `architecture/project-structure.md` | active | 2026-05-08 |
| [QR-pairing Login Flow](./architecture/qr-pairing.md) | `architecture/qr-pairing.md` | active | 2026-05-03 |
| [Sprint execution modes — PER_TASK vs SPRINT_BATCH](./architecture/sprint-execution-modes.md) | `architecture/sprint-execution-modes.md` | active | 2026-05-07 |
| [Scrum4Me — Implementatie Backlog](./backlog.md) | `backlog.md` | active | 2026-05-03 |
| [Scrum4Me — Implementatie Backlog](./backlog/index.md) | `backlog/index.md` | active | 2026-05-03 |
| [DevPlanner — Product Backlog](./backlog/product-historical.md) | `backlog/product-historical.md` | active | 2026-05-03 |
| [Agent Instruction Audit](./decisions/agent-instructions-history.md) | `decisions/agent-instructions-history.md` | active | 2026-05-03 |
| [Scrum4Me — Styling & Design System](./design/styling.md) | `design/styling.md` | active | 2026-05-03 |
| [Docker smoke test — task 1](./docker-smoke/2-mei-task-1.md) | `docker-smoke/2-mei-task-1.md` | done | 2026-05-03 |
| [Docker smoke test — task 2](./docker-smoke/2-mei-task-2.md) | `docker-smoke/2-mei-task-2.md` | done | 2026-05-03 |
| [Scrum4Me — Functionele Specificatie](./functional.md) | `functional.md` | active | 2026-05-03 |
| [Scrum4Me — Glossary](./glossary.md) | `glossary.md` | active | 2026-05-08 |
| [Onderzoek — AI-gedreven programmeren en Scrum planning](./Ideas/ai-driven-scrum-planning-research.md) | `Ideas/ai-driven-scrum-planning-research.md` | draft | 2026-05-11 |
| [Installatieplan — Beelink Ubuntu Scrum4Me server en worker-aanpassingen](./Ideas/beelink-scrum4me-server-install-and-worker-plan.md) | `Ideas/beelink-scrum4me-server-install-and-worker-plan.md` | draft | 2026-05-10 |
| [Advies — Product Backlog en Sprint-pagina workflow](./Ideas/sprint-page-backlog-relationship-research.md) | `Ideas/sprint-page-backlog-relationship-research.md` | draft | 2026-05-11 |
| [ST-1114 — Copilot reviews op dashboard](./Ideas/ST-1114-copilot-reviews.md) | `Ideas/ST-1114-copilot-reviews.md` | active | 2026-05-03 |
| [Overview](./manual/01-overview.md) | `manual/01-overview.md` | active | 2026-05-07 |
| [Statuses & Transitions](./manual/02-statuses-and-transitions.md) | `manual/02-statuses-and-transitions.md` | active | 2026-05-07 |
| [Git Workflow](./manual/03-git-workflow.md) | `manual/03-git-workflow.md` | active | 2026-05-07 |
@ -131,9 +108,7 @@ Auto-generated on 2026-05-12 from front-matter and headings.
| [Scrum4Me Developer Manual](./manual/index.md) | `manual/index.md` | active | 2026-05-07 |
| [Scrum4Me — Styling & Design System](./md3-color-scheme.md) | `md3-color-scheme.md` | active | 2026-05-03 |
| [Obsidian as Personal Authoring Layer](./obsidian-authoring.md) | `obsidian-authoring.md` | active | 2026-05-02 |
| [PbiDialog Profiel](./pbi-dialog.md) | `pbi-dialog.md` | active | 2026-05-03 |
| [DevPlanner — User Personas](./personas.md) | `personas.md` | active | 2026-05-03 |
| [DevPlanner — Product Backlog](./product-backlog.md) | `product-backlog.md` | active | 2026-05-03 |
| [Scrum4Me — API Test Plan](./qa/api-test-plan.md) | `qa/api-test-plan.md` | active | 2026-05-03 |
| [Realtime smoke-checklist — PBI / Story / Task](./realtime-smoke.md) | `realtime-smoke.md` | active | 2026-05-03 |
| [Caveman plan — Beelink naar Ubuntu Scrum4Me server](./recommendations/beelink-ubuntu-scrum4me-server-caveman-plan.md) | `recommendations/beelink-ubuntu-scrum4me-server-caveman-plan.md` | draft | 2026-05-09 |
@ -146,8 +121,7 @@ Auto-generated on 2026-05-12 from front-matter and headings.
| [Vercel Deployment](./runbooks/deploy-vercel.md) | `runbooks/deploy-vercel.md` | active | 2026-05-03 |
| [Job-model-selectie per ClaudeJob-kind](./runbooks/job-model-selection.md) | `runbooks/job-model-selection.md` | active | 2026-05-09 (idea-kinds + PLAN_CHAT permission_mode → acceptEdits) |
| [MCP Integration — Scrum4Me Tools](./runbooks/mcp-integration.md) | `runbooks/mcp-integration.md` | active | 2026-05-08 |
| [Plan → Sprint/PBI/Story/Task workflow](./runbooks/plan-to-pbi-flow.md) | `runbooks/plan-to-pbi-flow.md` | active | 2026-05-11 |
| [v1.0 Smoke Test Checklist](./runbooks/v1-smoke-test.md) | `runbooks/v1-smoke-test.md` | active | 2026-05-04 |
| [Worker idempotency & job-status protocol](./runbooks/worker-idempotency.md) | `runbooks/worker-idempotency.md` | active | 2026-05-09 |
| [StoryDialog Profiel](./story-dialog.md) | `story-dialog.md` | active | 2026-05-03 |
| [TaskDialog Profiel](./task-dialog.md) | `task-dialog.md` | active | 2026-05-03 |
| [Scrum4Me — API Test Plan](./test-plan.md) | `test-plan.md` | active | 2026-05-03 |

View file

@ -0,0 +1,306 @@
---
title: "Onderzoek — AI-gedreven programmeren en Scrum planning"
status: draft
audience: [product, ai-agent]
language: nl
last_updated: 2026-05-11
---
# Onderzoek — AI-gedreven programmeren en Scrum planning
## Vraag
Wat is het effect van AI-assisted / AI-driven programming op de manier waarop we Scrum toepassen, als werk niet meer in 1-2 weekse sprints hoeft te worden gepland maar in meerdere uitvoercycli per dag kan worden gerealiseerd? Wat gebeurt er met burndown, velocity en planning als tokengebruik, verificatie en agent-efficiency belangrijker worden?
## Korte conclusie
AI maakt Scrum niet overbodig, maar verandert waar Scrum op moet sturen.
Traditionele sprintplanning gebruikt vaak velocity/story points als proxy voor menselijke uitvoercapaciteit. In AI-gedreven ontwikkeling wordt menselijke typ-/bouwtijd veel minder voorspelbaar als bottleneck. De nieuwe bottlenecks zijn:
- helderheid van productbeslissingen;
- kwaliteit van backlog-items en acceptatiecriteria;
- beschikbare context voor agents;
- verificatiecapaciteit: tests, review, security, deploy;
- tokenkosten en modelkeuze;
- rework door foutieve of half-passende AI-output.
Daarom moet planning verschuiven van "hoeveel story points kunnen we in twee weken doen?" naar "welke waardevolle hypothese kunnen we nu veilig laten uitvoeren, verifieren en leren, binnen een token- en reviewbudget?"
## Wat de bronnen laten zien
### 1. Het empirische bewijs is gemengd
Onderzoek naar Copilot liet in een gecontroleerde programmeertaak zien dat deelnemers met Copilot de taak 55,8% sneller voltooiden. Een latere Microsoft-studie met drie field experiments bij 4.867 developers vond gemiddeld 26,08% meer voltooide taken bij developers met AI-code-completion.
Tegelijk vond METR in 2025 in een realistische RCT met ervaren open-source developers dat AI-tools taken juist 19% langzamer maakten. De taken waren echte issues in grote codebases die developers goed kenden. METR waarschuwt zelf tegen te brede generalisatie, maar het resultaat is belangrijk: AI-snelheid hangt sterk af van taaktype, codebase-context, kwaliteitslat en meetmethode. In 2026 gaf METR bovendien aan dat het meten van AI-uplift lastiger wordt doordat developers liever niet meer zonder AI werken en doordat sommige developers meerdere agents tegelijk gebruiken.
Implicatie: Scrum4Me moet geen vaste productiviteitsfactor aannemen. Meet per product, per agent-run en per taaktype.
### 2. DORA: AI is een versterker, geen oplossing
DORA 2025 concludeert dat AI vooral een amplifier is: het versterkt bestaande sterke en zwakke punten. Google rapporteert bijna universele adoptie, veel ervaren individuele productiviteitswinst, maar ook een vertrouwensparadox en complexere effecten op organisatorische performance.
DORA's AI Capabilities Model noemt zeven randvoorwaarden die AI-effecten versterken:
- duidelijke AI-stance/policy;
- gezonde data-ecosystemen;
- AI-toegankelijke interne data;
- sterke version-control-praktijken;
- kleine batches;
- user-centric focus;
- kwalitatieve interne platforms.
Voor Scrum betekent dit: de sprint moet niet groter worden omdat agents sneller code schrijven. De batch moet kleiner worden, met scherpere feedback.
### 3. Agile verschuift naar outcome- en governance-gedreven planning
Digital.ai's 18th State of Agile beschrijft AI als een verschuiving van ondersteunende tool naar orchestrator van de delivery lifecycle. Tegelijk noemt het rapport hogere ROI-druk, governance-lag en de noodzaak om Agile-investeringen aan meetbare business outcomes te koppelen.
Implicatie: velocity en burndown zijn onvoldoende als hoofdmetrics. Ze laten activiteit zien, geen waarde, geen kosten en geen risico.
### 4. Tokengebruik wordt economisch relevant, maar is geen doel op zichzelf
Stanford Digital Economy Lab vond in 2026 dat agentic coding tasks veel token-intensiever zijn dan code chat/reasoning, dat inputtokens de kosten domineren, dat runs op dezelfde taak tot 30x kunnen verschillen in tokengebruik, en dat meer tokens niet automatisch meer accuracy opleveren.
Jellyfish analyseerde 12.000 developers bij 200 bedrijven en vond dat meer tokengebruik wel met meer output correleert, maar disproportioneel duurder wordt. De topgebruikers haalden ongeveer twee keer zoveel PR-throughput, maar met ongeveer tien keer zoveel tokens per PR.
Implicatie: tokenusage is een cost/efficiency-signaal, geen prestatiebadge. "Tokenmaxxing" is net zo gevaarlijk als velocity maximaliseren.
## Wat verandert aan Scrum?
### Sprint
De Sprint blijft nuttig als container voor focus, inspectie en adaptatie. Maar bij AI-gedreven werk is een sprint minder een capaciteitsmandje voor menselijke arbeid en meer een beslis- en leerhorizon.
Advies:
- Gebruik "Sprint" voor productfocus en Sprint Goal.
- Gebruik "AI runs" of "execution cycles" binnen de sprint voor 1-4 uitvoercycli per dag.
- Noem 4 cycli per dag liever geen 4 Scrum-sprints, tenzij je ook echt 4 keer Sprint Planning, Review en Retrospective wilt doen. Dat levert ceremonie-overhead op.
### Sprint Planning
Planning verschuift van effort forecast naar control loop.
Oude vraag:
- Hoeveel werk kunnen we deze sprint doen?
Nieuwe vraag:
- Welke waardevolle slice is klaar voor agent-uitvoering?
- Welke context en tests maken het veilig?
- Welk model/mode/budget past bij risico en complexiteit?
- Hoe weten we binnen 30-120 minuten of dit goed genoeg is?
- Wat is de maximale token- en reviewspend voor deze poging?
### Daily Scrum
Daily Scrum wordt minder statusmeeting en meer flow-control:
- Welke agent-runs zijn afgerond, geblokkeerd of failed?
- Waar zit de verificatiequeue?
- Welke Product Owner-beslissing ontbreekt?
- Welke context ontbreekt waardoor tokens of rework oplopen?
- Moeten we scope heronderhandelen zonder het Sprint Goal te beschadigen?
Bij 4 runs per dag kan een korte "run review" na elke run de Daily Scrum deels vervangen.
### Sprint Review
Review wordt frequenter en meer outcome-gericht:
- Wat is echt geaccepteerd en bruikbaar?
- Welke hypothese is gevalideerd?
- Wat is alleen code-output maar nog geen waarde?
- Welke user feedback of runtime data hebben we?
### Retrospective
Retrospective moet expliciet AI-systemen verbeteren:
- Welke prompts, contextbestanden of specs verminderden rework?
- Welke modelkeuzes waren te duur of te zwak?
- Waar faalden tests/reviews te laat?
- Welke taken waren slecht voorbereid voor agents?
- Waar waren menselijke beslissingen de bottleneck?
## Nieuwe planningshiërarchie
Een praktisch model voor Scrum4Me:
| Laag | Cadans | Doel | Output |
|---|---:|---|---|
| Product Goal / roadmap | weken-maanden | richting en waarde | product outcomes, prioriteiten |
| Sprint | 1 dag tot 1 week | focus en leerdoel | Sprint Goal, selected PBIs/stories, budget |
| AI execution run | 1-3 uur | concrete slice bouwen/verifieren | PR/diff, testresultaat, token/cost telemetry |
| Agent job | minuten-uren | taak uitvoeren | logs, patch, status, vragen |
Voor solo/kleine teams met Scrum4Me is een dag-sprint of week-sprint met meerdere AI runs realistischer dan 4 volledige Scrum-sprints per dag.
## Nieuwe metrics
### Behoud, maar herinterpreteer
- Lead time: idee/story -> geaccepteerde productie-wijziging.
- Cycle time: taak/run start -> done.
- Deployment frequency.
- Change failure rate.
- MTTR.
- Escaped defects.
Deze blijven belangrijker dan velocity.
### Vervang velocity als hoofdmetric
Velocity/story points kunnen nog gespreksmateriaal zijn voor complexiteit en onzekerheid, maar niet meer als centrale capaciteitsmetric.
Betere hoofdmetrics:
- accepted increments per dag/week;
- validated outcomes per week;
- lead time per PBI/story;
- verification queue time;
- change failure rate na AI-runs;
- rework rate na review;
- human intervention rate;
- agent first-pass success rate.
### Voeg token-economie toe
Nuttige tokenmetrics:
- tokens per accepted task;
- tokens per merged PR;
- tokens per validated outcome;
- tokens per failed/abandoned run;
- input/output/cache-token mix;
- cost per accepted task;
- cost per defect fixed;
- review minutes per 1M tokens;
- token spend by model/mode/job-kind;
- wasted tokens: output niet gebruikt, failed loops, repeated context discovery.
Belangrijke waarschuwing: tokens zijn een input-cost, geen output-value. Gebruik ze als budget en efficiency-signaal, niet als score.
### Nieuwe samengestelde metric
Voor Scrum4Me zou een nuttige metric kunnen zijn:
```text
AI Delivery Efficiency =
accepted value units
/ (token cost + human review time + elapsed time + rework penalty)
```
In de praktijk kan dit simpeler:
```text
accepted_tasks_per_euro
accepted_tasks_per_1M_tokens
merged_PRs_per_review_hour
validated_outcomes_per_day
```
## Definition of Ready voor AI
Een story/task is AI-ready als:
- het gewenste gedrag concreet is;
- acceptatiecriteria testbaar zijn;
- relevante files, patronen en docs bekend of vindbaar zijn;
- non-goals en scopegrenzen expliciet zijn;
- risico duidelijk is: laag/middel/hoog;
- vereiste verificatie bekend is;
- tokenbudget/model/mode is gekozen;
- open productvragen zijn beantwoord of expliciet buiten scope gezet.
## Definition of Done voor AI
Done betekent niet "agent heeft code geschreven". Done betekent:
- diff/PR is geaccepteerd;
- tests/lint/typecheck/build passend bij risico zijn groen;
- security/privacy/demo-mode checks zijn gedaan waar relevant;
- menselijke review is gedaan voor risicovolle of user-facing wijzigingen;
- tokenusage/cost is gelogd;
- rework/lessons zijn teruggekoppeld naar prompt, docs of backlog;
- productwaarde is zichtbaar of meetbaar.
## Voorstel voor Scrum4Me planning
### 1. Sprint als focuscontainer
Maak een sprint niet langer primair een verzameling werk voor 1-2 weken, maar een focuscontainer:
- Sprint Goal;
- geselecteerde PBI's/stories;
- AI-run budget;
- verificatie-WIP-limiet;
- risico-policy.
### 2. AI Runs binnen de sprint
Voeg of gebruik een concept als `SprintRun`:
- `PLANNED -> RUNNING -> VERIFYING -> ACCEPTED | REWORK | FAILED | ABANDONED`
- gekoppelde `ClaudeJob`s / agent jobs;
- model/mode snapshot;
- tokenbudget en werkelijk tokengebruik;
- affected stories/tasks;
- testresultaat;
- reviewbeslissing.
### 3. Planningproces per run
1. Selecteer een kleine slice uit de Sprint Backlog.
2. Controleer AI-ready criteria.
3. Kies model/mode/tokenbudget.
4. Start agent jobs.
5. Verzamel patch, logs, testresultaten en tokenusage.
6. Verifieer.
7. Accepteer, stuur terug voor rework, of stop de run.
8. Update backlog en metrics.
### 4. Dashboard-shift
Vervang klassieke burndown als primaire grafiek door:
- token burnup vs accepted outcomes;
- verification queue;
- accepted/rework/failed runs;
- lead time distribution;
- cost per accepted task;
- change failure / rollback rate;
- remaining uncertainty per Sprint Goal.
Burndown kan blijven als "remaining selected stories/tasks", maar niet als performance-meter.
## Productimplicaties voor Scrum4Me
1. Voeg token/cost telemetry toe aan `ClaudeJob` en `SprintRun`.
2. Maak AI-run planning zichtbaar op de Sprint-pagina.
3. Voeg `AI Ready` checks toe aan story/task dialogs of een planning pane.
4. Maak verification WIP expliciet: niet meer agents starten dan je kunt verifieren.
5. Voeg budgetrails toe: per sprint, per run, per task, per model.
6. Rapporteer tokenusage altijd naast outcome: token-only dashboards sturen verkeerd gedrag.
7. Maak retrospectives data-driven: prompt/context/model/test-strategie verbeteren.
## Bronnen
- Scrum Guide 2020 — Sprint Planning, Sprint Backlog, Sprint Goal: https://scrumguides.org/scrum-guide.html
- Microsoft Research — GitHub Copilot controlled experiment: https://www.microsoft.com/en-us/research/publication/the-impact-of-ai-on-developer-productivity-evidence-from-github-copilot/
- Microsoft Research — three field experiments, 4.867 developers: https://www.microsoft.com/en-us/research/publication/the-effects-of-generative-ai-on-high-skilled-work-evidence-from-three-field-experiments-with-software-developers/
- METR 2025 — experienced open-source developer RCT: https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/
- METR 2026 — measurement redesign and concurrent-agent measurement issues: https://metr.org/blog/2026-02-24-uplift-update/
- DORA 2025 — State of AI-assisted Software Development: https://dora.dev/research/2025/dora-report/
- Google Research publication page for DORA 2025: https://research.google/pubs/dora-2025-state-of-ai-assisted-software-development-report/
- Google Cloud — DORA AI Capabilities Model: https://cloud.google.com/blog/products/ai-machine-learning/introducing-doras-inaugural-ai-capabilities-model/
- Google blog summary of DORA 2025: https://blog.google/innovation-and-ai/technology/developers-tools/dora-report-2025/
- Digital.ai — 18th State of Agile press release: https://digital.ai/press-releases/digital-ais-18th-state-of-agile-report-marks-the-start-of-the-fourth-wave-of-software-delivery/
- Stanford Digital Economy Lab — token consumption in agentic coding tasks: https://digitaleconomy.stanford.edu/publication/how-do-ai-agents-spend-your-money-analyzing-and-predicting-token-consumption-in-agentic-coding-tasks/
- GitHub Docs — Copilot usage metrics: https://docs.github.com/en/enterprise-cloud@latest/copilot/reference/copilot-usage-metrics/copilot-usage-metrics
- Jellyfish — tokenmaxxing and token ROI analysis: https://jellyfish.co/blog/is-tokenmaxxing-cost-effective-new-data-from-jellyfish-explains/
- Microsoft Research — LLM metric framework and token utilization: https://www.microsoft.com/en-us/research/articles/how-to-evaluate-llms-a-complete-metric-framework/

View file

@ -0,0 +1,605 @@
---
title: "Installatieplan — Beelink Ubuntu Scrum4Me server en worker-aanpassingen"
status: draft
audience: [maintainer, operator, ai-agent]
language: nl
last_updated: 2026-05-10
---
# Installatieplan — Beelink Ubuntu Scrum4Me server en worker-aanpassingen
## Doel
Deze notitie beschrijft de huidige Beelink-installatie en het vervolgplan om de Scrum4Me workers geschikt te maken voor drie rollen:
```text
worker-idea
worker-implementation
worker-orchestrator
```
De server draait nu als LAN-host voor Scrum4Me. Productie-internettoegang met domein en HTTPS is nog een latere stap.
## Hardware
| Onderdeel | Waarde |
|---|---|
| Machine | Beelink mini-PC |
| CPU | Intel Core i5-12450H |
| RAM zichtbaar in Ubuntu | 16 GB |
| Max RAM volgens hardware | 32 GB |
| Disk | 468 GB bruikbaar na Ubuntu-installatie |
| IP | `192.168.0.154` |
Opmerking: Ubuntu ziet momenteel ongeveer 16 GB RAM. De hardware meldt een maximum van 32 GB, maar dat betekent niet dat 32 GB bruikbaar/geplaatst is.
## Huidige Installatie
### Ubuntu
Ubuntu Server is geïnstalleerd op de hele disk.
Belangrijke keuzes:
- Ubuntu Server 24.04 LTS.
- Geen Ubuntu Desktop.
- Geen LVM.
- Geen aparte GPU-drivers.
- Geen Windows dual boot meer.
- Hostname: `scrum4me-server`.
- Sleep/hibernate uitgeschakeld.
- Swapfile vergroot naar 16 GB.
Controle:
```bash
hostnamectl
free -h
swapon --show
df -h
```
### Directorystructuur
Alle service-data staat onder:
```text
/srv/scrum4me
```
Structuur:
```text
/srv/scrum4me/postgres database data
/srv/scrum4me/repos GitHub clones
/srv/scrum4me/worker-cache worker caches
/srv/scrum4me/worker-logs worker logs
/srv/scrum4me/worker-state worker state
/srv/scrum4me/backups Postgres backups
/srv/scrum4me/compose Docker Compose files
/srv/scrum4me/caddy Caddy config/data
```
### Docker
Docker Engine draait native op Ubuntu.
Controle:
```bash
docker run hello-world
docker compose version
```
### Postgres
Postgres draait als Docker container:
```text
container: scrum4me-postgres
image: postgres:17
```
Host mapping:
```text
127.0.0.1:5432 -> postgres:5432
```
Host-app gebruikt:
```env
DATABASE_URL="postgresql://scrum4me:<password>@127.0.0.1:5432/scrum4me"
DIRECT_URL="postgresql://scrum4me:<password>@127.0.0.1:5432/scrum4me"
```
Containers gebruiken:
```env
DATABASE_URL=postgresql://scrum4me:<password>@postgres:5432/scrum4me
DIRECT_URL=postgresql://scrum4me:<password>@postgres:5432/scrum4me
```
DB-test:
```bash
docker exec -e PGPASSWORD="$DBPASS" scrum4me-postgres \
psql -h 127.0.0.1 -U scrum4me -d scrum4me \
-c "select current_user, current_database();"
```
### Scrum4Me Web
Repo:
```text
/srv/scrum4me/repos/Scrum4Me
```
Build:
```bash
cd /srv/scrum4me/repos/Scrum4Me
rm -rf .next
npm run build
```
Runtime:
```text
systemd service: scrum4me-web
```
Service startcommand:
```bash
npm run start -- -H 0.0.0.0
```
Controle:
```bash
systemctl status scrum4me-web --no-pager
curl -I http://127.0.0.1:3000/login
```
### Caddy
Caddy draait als Docker container:
```text
container: scrum4me-caddy
```
Caddy reverse proxyt:
```text
http://192.168.0.154 -> Caddy -> 172.18.0.1:3000 -> Scrum4Me web
```
Caddyfile:
```caddyfile
:80 {
reverse_proxy 172.18.0.1:3000
}
```
Controle:
```bash
curl -I http://192.168.0.154/login
docker logs --tail=50 scrum4me-caddy
```
### LAN Session Config
Omdat de server nu via HTTP op LAN draait, is secure session cookie tijdelijk uitgezet.
Env:
```env
SESSION_COOKIE_SECURE="false"
```
Code-aanpassing:
```ts
secure: process.env.SESSION_COOKIE_SECURE === 'true',
```
Later, bij domein + HTTPS:
```env
SESSION_COOKIE_SECURE="true"
```
Daarna:
```bash
rm -rf .next
npm run build
sudo systemctl restart scrum4me-web
```
### Migrations
De database is gemigreerd.
Belangrijke migration-notitie:
`20260506101436_restore_todos_table` kan op een bestaande DB falen met:
```text
relation "todos" already exists
```
Voor deze server is de juiste aanpak:
```bash
npx prisma migrate resolve --applied 20260506101436_restore_todos_table
npx prisma migrate deploy
```
Controle:
```bash
npx prisma migrate status
docker exec -it scrum4me-postgres psql -U scrum4me -d scrum4me -c "\dt public.users"
```
### Admin en Product
Admin user is aangemaakt via:
```bash
npx tsx scripts/create-admin.ts janpeter '<password>'
```
Login werkt.
Product aanmaken werkt.
### Backups
Backup-script:
```text
/srv/scrum4me/backup-postgres.sh
```
Script:
```bash
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR="/srv/scrum4me/backups"
STAMP="$(date +%Y%m%d-%H%M%S)"
FILE="$BACKUP_DIR/scrum4me-$STAMP.sql.gz"
mkdir -p "$BACKUP_DIR"
docker exec scrum4me-postgres pg_dump -U scrum4me scrum4me | gzip > "$FILE"
find "$BACKUP_DIR" -type f -name 'scrum4me-*.sql.gz' -mtime +14 -delete
echo "backup written: $FILE"
```
Test:
```bash
/srv/scrum4me/backup-postgres.sh
ls -lh /srv/scrum4me/backups
```
Cron:
```cron
15 3 * * * /srv/scrum4me/backup-postgres.sh >> /srv/scrum4me/backups/backup.log 2>&1
```
## Worker-Idea Installatie
Worker compose-service:
```text
worker-idea
container: scrum4me-worker-idea
health: http://127.0.0.1:18081/health
```
Belangrijke env-waarden:
```env
SCRUM4ME_BASE_URL=http://caddy
SCRUM4ME_TOKEN=<raw Scrum4Me API token>
DATABASE_URL=postgresql://scrum4me:<password>@postgres:5432/scrum4me
DIRECT_URL=postgresql://scrum4me:<password>@postgres:5432/scrum4me
GH_TOKEN=<GitHub token>
GH_PRECLONE_REPOS=madhura68/Scrum4Me,madhura68/scrum4me-mcp,madhura68/scrum4me-docker
CLAUDE_CODE_OAUTH_TOKEN=<Claude Code OAuth token>
```
Token-validatie:
```bash
read -s -p "Scrum4Me token: " TOKEN; echo
curl -i -H "Authorization: Bearer $TOKEN" http://127.0.0.1:3000/api/products
unset TOKEN
```
Verwacht:
```text
HTTP/1.1 200 OK
```
Worker health:
```bash
curl http://127.0.0.1:18081/health
```
Gezonde idle-output bevat:
```json
{
"status": "idle",
"heartbeatAgeSeconds": 1,
"consecutiveFailures": 0
}
```
## Huidige Worker-Beperking
De Docker worker is gezond, maar Scrum4Me UI toont mogelijk nog:
```text
geen Claude worker actief
```
Oorzaak:
- De Docker health-server draait altijd.
- De daemon-loop draait altijd.
- Maar de DB-tabel `claude_workers` wordt nu alleen bijgewerkt door de MCP stdio-server.
- Die MCP stdio-server start pas binnen een echte Claude/MCP job-run.
- Bij een lege queue is de Docker worker dus idle en gezond, maar verschijnt hij niet als actieve worker in de UI.
Controle:
```bash
docker exec -it scrum4me-postgres psql -U scrum4me -d scrum4me \
-c "select t.id, t.label, w.id as worker_id, w.last_seen_at from api_tokens t left join claude_workers w on w.token_id=t.id order by t.created_at desc;"
```
Gezonde Docker-worker maar lege presence:
```text
label | worker_id | last_seen_at
worker-idea | |
```
## Worker Aanpassingsplan
### Doelrollen
```text
worker-idea
IDEA_GRILL
IDEA_MAKE_PLAN
PLAN_CHAT
worker-implementation
TASK_IMPLEMENTATION
SPRINT_IMPLEMENTATION
later STORY_IMPLEMENTATION
worker-orchestrator
PR_REVIEW
CI_TRIAGE
MERGE_CONFLICT_RESOLUTION
REPAIR_FAILED_JOB
CONTEXT_SUMMARY
```
## Fase 1 — Presence Fix
### Probleem
Worker-health is nu container-lokaal, maar UI-presence is DB-gebaseerd.
Nu:
```text
curl :18081/health -> online
claude_workers -> leeg
UI -> offline
```
### Gewenst gedrag
Zolang de Docker daemon-loop draait, moet `claude_workers.last_seen_at` vers blijven, ook als de queue leeg is.
### Aanpassing
Verplaats worker-presence naar `scrum4me-docker/bin/run-one-job.ts` of naar een kleine runner-level heartbeat naast `run-agent.sh`.
Aanbevolen: in `run-one-job.ts`, direct na `getAuth()`:
```ts
const { userId, tokenId } = await getAuth()
await registerWorker({ userId, tokenId })
const heartbeat = startHeartbeat({ userId, tokenId, intervalMs: 10_000 })
```
In `finally`:
```ts
heartbeat.stop()
```
Niet unregisteren bij normale idle-exit. Anders gaat de UI-indicator flikkeren tussen iteraties.
### Acceptatie
Bij lege queue:
```bash
curl http://127.0.0.1:18081/health
```
toont:
```text
status idle
```
En:
```sql
select token_id, last_seen_at, now() - last_seen_at from claude_workers;
```
toont een recente `last_seen_at`.
## Fase 2 — Role-Aware Workers
### Probleem
De huidige worker claimt elke job die beschikbaar is. Daardoor kan `worker-idea` ook implementation jobs claimen.
### Nieuwe env
```env
SCRUM4ME_WORKER_ROLE=idea
```
Toegestane waarden:
```text
idea
implementation
orchestrator
```
### Claimfilter
`tryClaimJob` krijgt een role/capability-filter.
Mapping:
```text
idea:
IDEA_GRILL
IDEA_MAKE_PLAN
PLAN_CHAT
implementation:
TASK_IMPLEMENTATION
SPRINT_IMPLEMENTATION
orchestrator:
PR_REVIEW
CI_TRIAGE
MERGE_CONFLICT_RESOLUTION
REPAIR_FAILED_JOB
CONTEXT_SUMMARY
```
### Acceptatie
Test:
- Queue bevat één `IDEA_GRILL` en één `TASK_IMPLEMENTATION`.
- Alleen `worker-idea` actief: claimt alleen `IDEA_GRILL`.
- Alleen `worker-implementation` actief: claimt alleen `TASK_IMPLEMENTATION`.
- Beide actief: ieder claimt eigen jobtype.
## Fase 3 — DB/UI Uitbreiding
Breid `claude_workers` uit met:
```text
role
worker_name
container_name
last_status
last_job_id
last_error
```
UI toont dan:
```text
Idea worker online / idle
Implementation worker offline
Orchestrator online / idle
```
## Fase 4 — Orchestrator Jobs
Nieuwe job kinds:
```text
PR_REVIEW
CI_TRIAGE
MERGE_CONFLICT_RESOLUTION
REPAIR_FAILED_JOB
CONTEXT_SUMMARY
```
Orchestrator mag:
- PR's inspecteren.
- CI-fouten samenvatten.
- Merge conflicts analyseren.
- Repair jobs aanmaken.
- Context capsules schrijven.
- Human escalation vragen.
- Draft PR naar ready begeleiden.
Orchestrator mag niet:
- Vrij featurewerk implementeren.
- Dezelfde branch tegelijk wijzigen als implementation-worker.
- Auto-mergen zonder checks.
- Secrets of tokens loggen.
## Fase 5 — Deployment
Na code-aanpassing:
```bash
cd /srv/scrum4me/repos/scrum4me-docker
git pull
cd /srv/scrum4me/compose
docker compose build worker-idea
docker compose up -d --force-recreate worker-idea
```
Checks:
```bash
curl http://127.0.0.1:18081/health
docker logs -f scrum4me-worker-idea
docker exec -it scrum4me-postgres psql -U scrum4me -d scrum4me \
-c "select token_id, last_seen_at from claude_workers;"
```
## Aanbevolen Volgorde Vanaf Nu
1. Test één `IDEA_GRILL` job met de huidige worker.
2. Implementeer Fase 1: runner-level presence.
3. Rebuild `worker-idea`.
4. Verifieer UI online/idle bij lege queue.
5. Implementeer Fase 2: role-aware claiming.
6. Voeg `worker-implementation` toe.
7. Voeg pas daarna `worker-orchestrator` toe.
Niet meteen drie workers starten zonder role-aware claimfilter.

View file

@ -0,0 +1,135 @@
---
title: "Advies — Product Backlog en Sprint-pagina workflow"
status: draft
audience: [product, ai-agent]
language: nl
last_updated: 2026-05-11
---
# Advies — Product Backlog en Sprint-pagina workflow
## Aanleiding
Het bestaande plan `dit-verhaal-gaat-over-dazzling-mccarthy.md` beschrijft een nieuwe Product Backlog-workflow waarin sprint-membership via vinkjes wordt beheerd. De vraag is hoe de Sprint-pagina daarop moet aansluiten, met als doel om de huidige sprint verder samen te stellen.
## Korte conclusie
Maak de Product Backlog-pagina de brede plek voor backlog-refinement en sprint-scope selectie, en maak de Sprint-pagina de werkomgeving voor de huidige sprint: scope bijstellen, volgorde bepalen, taken uitwerken, assignees zetten, capaciteit bewaken en afronden.
De twee pagina's mogen dezelfde onderliggende membership-acties gebruiken, maar ze moeten niet dezelfde primaire UI dupliceren. De Product Backlog is de product-brede selectie- en overzichtslaag. De Sprint-pagina is de sprint-specifieke uitwerkingslaag.
## Verhouding tussen de pagina's
| Pagina | Primaire vraag | Scope | Hoofdhandeling |
|---|---|---|---|
| Product Backlog `/products/[id]` | Wat is waardevol, wat is klaar, wat hoort bij welke sprint? | Alle PBI's/stories van het product | Refinen, ordenen, nieuwe sprintdraft maken, sprint-membership bulk selecteren |
| Sprint-pagina `/products/[id]/sprint/[sprintId]` | Hoe maken we deze sprint uitvoerbaar en af? | Een gekozen sprint | Sprint Backlog ordenen, stories aanvullen/verwijderen, taken maken, eigenaarschap/capaciteit/voortgang beheren |
## Aanbevolen workflow
1. Product Backlog zonder actieve sprint: klassieke refinement-view zonder vinkjes.
2. Product Backlog met nieuwe sprintdraft: metadata invullen, PBI's/stories selecteren via vinkjes, sprint aanmaken.
3. Product Backlog met actieve sprint: product-breed zien welke PBI's/stories in de sprint zitten en membership in batches aanpassen.
4. Sprint-pagina: huidige sprint verder samenstellen en uitvoeren. Het middenpaneel is de waarheid voor de huidige Sprint Backlog. Het backlogpaneel is alleen toevoer/context.
## Consequenties voor de Sprint-pagina
Aanbevolen aanpassing:
- Header toont Sprint Goal, dates, status, switcher, scope-dirty teller en afronden-flow.
- Linkerpaneel hernoemen van `Product Backlog` naar iets als `Aanvullen uit backlog`; standaard filter op eligible stories: `OPEN`, niet `DONE`, geen andere `OPEN` sprint.
- Middenpaneel blijft `Sprint Backlog`: geselecteerde stories, sortering, assignee, task-progress, remove.
- Rechterpaneel blijft `Taken`: taakdecompositie, taakvolgorde, status, implementatieplan.
- Membership-mutaties op de Sprint-pagina gebruiken dezelfde serveractie als de Product Backlog: `commitSprintMembershipAction(activeSprintId, adds, removes)`.
- Scopewijzigingen zijn pending/dirty tot `Scope opslaan (N)`. Story/task-field edits blijven direct opslaan.
- Cross-sprint conflicts tonen als disabled story met tooltip. Server blijft autoritatief.
- Na start van een sprint markeer je add/remove visueel als scopewijziging, omdat dat gevolgen heeft voor burndown/rapportage.
Wat je juist niet moet doen:
- Geen tweede volledige PBI-tri-state bulkselectie bouwen op de Sprint-pagina. Dat hoort op de Product Backlog.
- Geen aparte sprint-membership semantiek naast het plan. `story.sprint_id` blijft unit-of-truth.
- Geen nieuwe afrondactie. De bestaande `completeSprintAction` blijft de sprint-completion-flow.
## Andere methoden uit websearch
### 1. Scrum Guide: why / what / how
De Scrum Guide beschrijft Sprint Planning als drie onderwerpen: waarom is deze sprint waardevol, wat kan deze sprint gedaan worden, en hoe wordt het gekozen werk gedaan. De Sprint Backlog bestaat uit Sprint Goal, geselecteerde PBIs en een uitvoerbaar plan.
Impliceert voor Scrum4Me:
- Product Backlog-pagina: vooral `what`.
- Sprint-pagina: vooral `how`, plus gecontroleerde bijstelling van `what`.
Bron: https://scrumguides.org/scrum-guide.html
### 2. Jira-methode: sprints plannen vanuit backlog, board voor active sprint
Jira plant sprints op het Backlog-scherm. De backlog toont werk gegroepeerd in backlog en sprints; items kunnen naar sprints worden gesleept. Na start verschijnt de sprint op het board. Jira waarschuwt ook dat add/remove in een actieve sprint scope change is.
Impliceert voor Scrum4Me:
- De richting van het plan is marktconform: sprint-samenstelling vanuit de backlog.
- De Sprint-pagina mag scope aanpassen, maar moet dat als scopewijziging behandelen.
Bronnen:
- https://support.atlassian.com/jira-software-cloud/docs/use-your-scrum-backlog/
- https://support.atlassian.com/jira-software-cloud/docs/enable-sprints/
- https://support.atlassian.com/jira-software-cloud/docs/plan-a-sprint/
### 3. Azure Boards-methode: eerst items toewijzen, daarna capaciteit checken
Azure Boards beschrijft sprintplanning in twee delen: eerst backlog items selecteren, daarna bepalen hoe het team ontwikkelt/test, taken definiëren en capaciteit controleren. Azure toont geplande effort en capaciteit om onder- of overbelasting zichtbaar te maken.
Impliceert voor Scrum4Me:
- Voeg op de Sprint-pagina een lichte capacity/forecast-strip toe zodra story points, effort of taakminuten beschikbaar zijn.
- Laat de Sprint-pagina na selectie vooral helpen met taakdecompositie en load balancing.
Bronnen:
- https://learn.microsoft.com/en-us/azure/devops/boards/sprints/assign-work-sprint
- https://learn.microsoft.com/en-us/azure/devops/boards/sprints/adjust-work
### 4. Backlog refinement als aparte continue praktijk
Atlassian beschrijft refinement als doorlopend reviewen, rangschikken en verduidelijken van de Product Backlog zodat Sprint Planning soepeler loopt.
Impliceert voor Scrum4Me:
- Houd refinement-controls op de Product Backlog: PBI/story details, status, priority, split/merge later.
- Maak de Sprint-pagina niet de primaire plek voor product-brede refinement.
Bron: https://www.atlassian.com/agile/scrum/backlog-refinement
### 5. Linear cycles / Scrumban-achtige methode
Linear cycles zijn time-boxed perioden met een vooraf bepaalde set werk, inclusief automatiseringen zoals rollover en auto-add van actieve issues. Dit is minder strikt Scrum, maar nuttig voor solo/kleine teams.
Impliceert voor Scrum4Me:
- Eventueel later: optionele automation "carry over unfinished stories to next sprint".
- Niet als basis voor de huidige Scrum4Me-flow, omdat Scrum4Me al Sprint Goal, Sprint Backlog en completion-semantiek heeft.
Bron: https://linear.app/docs/use-cycles
## Aanbevolen ontwerpkeuze
Kies voor een hybride die dicht bij Scrum/Jira/Azure ligt:
- Product Backlog = refinement + sprint-scope bulkselectie.
- Sprint-pagina = huidige sprint afmaken: ordenen, decomponeren, capaciteit en uitvoering.
- Eén gedeelde membership-laag in code: dezelfde conflictregels, task cascade, statusmutaties en affected-id returns.
Dit houdt het mentale model simpel: je kunt overal zien wat in de sprint zit, maar elke pagina heeft een eigen reden om te bestaan.
## Implementatie-notities
1. Hergebruik `commitSprintMembershipAction` op de Sprint-pagina voor add/remove.
2. Vervang directe `addStoryToSprintAction` / `removeStoryFromSprintAction` in `SprintBoardClient` geleidelijk door een pending scope-buffer, of laat ze intern dezelfde transactiesemantiek gebruiken als tijdelijke tussenstap.
3. Fix bij die refactor ook task-cascade consistentie: remove moet `task.sprint_id = null` zetten voor taken onder verwijderde stories.
4. Gebruik de nieuwe `cross-sprint-blocks` en `sprint-membership-summary` endpoints ook op de Sprint-pagina, maar gescoped op zichtbare PBI's.
5. Voeg later capacity toe als aparte story, niet als voorwaarde voor de eerste workflow-migratie.

View file

@ -61,6 +61,6 @@ that supersedes the old one rather than editing the original.
not enforced through review.
- Backfilling existing decisions requires writing 58 retrospective ADRs
for choices that were never recorded (planned in fase 6 of
[`../plans/docs-restructure-ai-lookup.md`](../plans/docs-restructure-ai-lookup.md)).
[`../old/plans/docs-restructure-ai-lookup.md`](../old/plans/docs-restructure-ai-lookup.md)).
- Two templates means a per-decision choice about which to use. Mitigated
by making Nygard the explicit default in `README.md`.

View file

@ -41,7 +41,7 @@ Synonym for **ClaudeJob** — used in agent-facing docs because Claude Code cons
## PBI (Product Backlog Item)
The second level of the work hierarchy: `Product → PBI → Story → Task`. A PBI groups related stories under a single theme or capability. Status enum: `READY | BLOCKED | FAILED | DONE`. Has a stable `code` (`PBI-N`) per product. Do not use "Epic", "Feature", or "Issue" as synonyms. See [backlog index](./backlog/index.md).
The second level of the work hierarchy: `Product → PBI → Story → Task`. A PBI groups related stories under a single theme or capability. Status enum: `READY | BLOCKED | FAILED | DONE`. Has a stable `code` (`PBI-N`) per product. Do not use "Epic", "Feature", or "Issue" as synonyms.
## Solo Panel

View file

@ -0,0 +1,649 @@
# PBI-79: Product Backlog workflow — sprint-membership via vinkjes
> **MCP:** PBI-79 (`cmp13vrxd0001m017ta9aflg9`) in Scrum4Me product (`cmohrysyj0000rd17clnjy4tc`).
>
> **Review verwerkt:** Dit plan is een herziene versie na de review in [`product-backlog-workflow-plan-review.md`](product-backlog-workflow-plan-review.md). De vier P1-bevindingen zijn allemaal geadresseerd, evenals de vijf P2-punten. Zie de sectie *"Reactie op review"* onderaan voor de mapping.
---
## Implementatie-stand & scope-aanpassingen (post-testing)
> Deze sectie documenteert wat er sinds de eerste implementatie-pass is bijgewerkt op basis van gebruikerstests + nieuwe inzichten. De rest van het plan beneden geldt **behalve waar dit kopje dat overrulet**.
### Gerealiseerde commits (in volgorde)
| # | Commit | Story | Inhoud |
|---|---|---|---|
| 1 | 2af6f24 | ST-1333 | Active-sprint null-contract + clearActiveSprintAction |
| 2 | 56c55e1 | ST-1334 | pendingSprintDraft slot (compacte intent-shape) |
| 3 | b4a515e | ST-1343 | `lib/sprint-conflicts.ts` eligibility helpers |
| 4 | e89fb71 | ST-1335 | Gescoped endpoints (`sprint-membership-summary`, `cross-sprint-blocks`) |
| 5 | 89c2356 | ST-1336 | `sprintMembership`-slice + selectors in product-workspace-store |
| 6 | 947d970 | ST-1337 | State A UI (metadata-dialog + sticky banner + PbiList ombouw) |
| 7 | d21011c | ST-1339 | `createSprintWithSelectionAction` + banner wire-up |
| 8 | 4c6e999 | ST-1340 | `commitSprintMembershipAction` + gerichte client-store patches |
| 9 | 117616f | ST-1338 | State B vinkjes-UI + "Sprint opslaan"-knop |
| 10 | b91d92a | ST-1341+1342 | `SprintEditDialog` + multi-OPEN sprints |
| 11 | 0c36f4e | ST-1344 | `updateSprintAction` regression tests |
| 12 | 8d6fbdf | bugfix | PBI-rij weer klikbaar voor selectie; vinkje als aparte trigger |
| 13 | 35c6404 | bugfix | Cascade-restore alleen wanneer hint-story bij nieuwe PBI hoort |
| 14 | d7d1112 | feat | Sprint-switch auto-select PBI/story + user-settings persist (3 keys) |
### Bugs gevonden tijdens testen (afgehandeld)
1. **Hele PBI-rij was de toggle in selectionMode.** Gevolg: rij-klik bulk-toggled stories en update de teller, maar PBI werd niet als focus geselecteerd → story-kolom bleef leeg.
*Fix (8d6fbdf):* in `SortablePbiRow` selectionMode-branch wordt onClick weer `onSelect`; het tri-state icoon zit in een eigen `<button>` met `stopPropagation`.
2. **Cascade-restore overschrijft PBI-switch.** Bij wisselen naar een andere PBI bleef de oude story (en dus zijn taken) zichtbaar omdat `setActivePbi`'s async hint-restore de vorige story-id terugzette zonder PBI-validatie.
*Fix (35c6404):* hint wordt alleen toegepast als `storiesById[hint].pbi_id === pbiId`.
3. **Tooltip-API mismatch.** `TooltipTrigger` van base-ui accepteert geen `asChild`; geprobeerd via render-prop maar uiteindelijk de hele knop in selectionMode in de Tooltip gewikkeld.
### Nieuwe feature (na implementatie toegevoegd) — sprint-switch auto-select
Bij wisselen van sprint via de switcher wordt **server-side** de inhoud van de sprint geresolved en als deze precies één PBI heeft (en die PBI exact één story binnen de sprint), worden beide automatisch geselecteerd. Alle drie selectie-velden worden atomair in user-settings weggeschreven zodat cross-device-restore klopt.
- Schema: `layout.activePbis` + `layout.activeStories` per product (beide nullable).
- Helper: `setActiveSelectionInSettings(userId, productId, { sprintId, pbiId?, storyId? })`.
- Server-action: `switchActiveSprintAction(productId, sprintId)` doet de auto-select-resolutie en returnt het tripel.
- Sprint-switcher: roept de nieuwe action aan en synchroniseert de client-store gelijk (geen flash).
- `ActiveSelectionHydrator` (nieuw): client-side effect dat user-settings-activePbi/activeStory naar de workspace-store spiegelt; wint van de bestaande localStorage hint-restore.
### Scope-aanpassing — pendingSprintDraft wordt **session-only**
**Was:** de draft (sprint-doel + per-PBI intent + per-PBI overrides) staat persistent in `user-settings.workflow.pendingSprintDraft` zodat de gebruiker na navigatie kan hervatten.
**Wordt:** de draft leeft alleen in de Zustand-store van de sessie. Bij wegnavigeren krijgt de gebruiker een `useDirtyCloseGuard`-confirm; bij doorgaan wordt de draft **weggegooid** (niet hervat-baar). Reden: de user geeft expliciet aan dat ongeslagen sprints geen rest-state mogen achterlaten in de DB.
Concrete wijzigingen:
- `lib/user-settings.ts`: `workflow.pendingSprintDraft` kan blijven bestaan voor type-compatibiliteit maar wordt niet meer geschreven door de UI.
- Actions `setPendingSprintDraftAction` + `clearPendingSprintDraftAction` worden gedeprecieerd (of behouden voor migratie van eventueel oude entries) maar **niet meer aangeroepen** door de UI.
- Store `useUserSettingsStore.setPendingSprintDraft` / `upsertPbiIntent` / `upsertStoryOverride` blijven bestaan maar de server-roundtrip eruit; lokale state-only.
- `useDirtyCloseGuard` op het banner-niveau triggert een confirm bij browser-back / route-wissel; bevestigen → `clearPendingSprintDraftAction` (om eventuele oude DB-entries op te ruimen) **+** lokale state-reset.
### Nieuwe feature — draft-sprint zichtbaar in sprint-switcher
Tijdens state A (er is een draft) toont de sprint-switcher de **draft-naam** (= `draft.goal`, ingekort) als extra entry bovenaan de dropdown met markering "Concept" of italic-styling. Hij is niet selecteerbaar als "actieve" sprint (want geen sprintId); klikken erop opent de banner-actie of doet niets bijzonders. Doel: visueel feedback geven dat er een onafgemaakte sprint loopt zonder die in de DB op te slaan.
Concreet:
- Sprint-switcher krijgt prop `pendingDraftGoal?: string | null` (server-side leesbaar via user-settings store na hydration, of via `useUserSettingsStore` in de switcher-component).
- Render bovenaan de dropdown (boven "— Geen actieve sprint —") wanneer aanwezig: *"⚙ Concept — [goal-prefix]"*.
### Wat blijft staan uit de oorspronkelijke ontwerpkeuzes
- Schema `layout.activeSprints` blijft nullable (key+null = bewust geen sprint).
- Drie-states-model (A / A / B) blijft.
- Tri-state PBI-vinkje, story-binair-vinkje, cross-sprint disabled blijven.
- "Sprint opslaan"-knop met teller (state B) blijft.
- Eligibility-filter + status-mutaties in dezelfde transactie blijven.
- Endpoints gescoped op `pbiIds` blijven.
- Multi-OPEN sprints toegestaan blijft.
### Wat nog te doen (na deze plan-update)
> Alle drie punten **afgerond** in commit `2a4ee6a`.
1. ~~**Implementeer scope-aanpassing**~~`setPendingSprintDraft` / `clearPendingSprintDraft` zijn nu local-only; `hydrate()` strip eventuele legacy DB-entries.
2. ~~**Sprint-switcher concept-entry**~~`⚙ Concept — [goal]` verschijnt bovenaan de dropdown zodra er een draft loopt.
3. ~~**Verifieer**~~`npm run verify` groen (826 tests). `SprintDraftLeaveGuard` registreert `beforeunload`-listener voor browser-refresh/close. In-app route-changes blijven via banner-Annuleren lopen.
### Bewust niet geïmplementeerd
- **Server-side persist van manuele PBI/story-klikken.** Vraag: "wordt de geselecteerde pbi ook opgeslagen". Antwoord: nee, momenteel alleen via sprint-switch auto-select. Manuele klikken gaan naar localStorage. Cross-device parity voor manuele klikken vereist extra server-roundtrips per klik; de helpers `setActivePbiInSettings` / `setActiveStoryInSettings` zijn voorbereid maar niet gewired. Op verzoek opnieuw oppakken in een vervolg-PBI.
### localStorage-gebruik (overzicht)
| Locatie | Doel |
|---|---|
| [stores/product-workspace/restore.ts](stores/product-workspace/restore.ts) | Per-browser hints `lastActivePbiId` / `lastActiveStoryId` / `lastActiveTaskId` per product. |
| [stores/sprint-workspace/restore.ts](stores/sprint-workspace/restore.ts) | Idem voor de sprint-pagina. |
| [lib/user-settings-migration.ts](lib/user-settings-migration.ts) | One-shot migratie van legacy prefs (PBI-76) naar user-settings. |
| [components/ideas/idea-md-editor.tsx](components/ideas/idea-md-editor.tsx) | Auto-save van idee-markdown-draft (niet PBI-79-gerelateerd). |
`ActiveSelectionHydrator` (PBI-79) wint van de localStorage-hints voor PBI/story-selectie zodra user-settings expliciet iets bevat.
---
## Context
De Product Backlog-pagina (`/products/[id]`) is het hart van Scrum4Me. De **lazy-load-basis bestaat al** (filter-first/background-remaining-PBI's + lazy stories/tasks per klik via [lib/product-backlog-pbis.ts](lib/product-backlog-pbis.ts), `ensurePbiLoaded`, `ensureStoryLoaded`). Dit plan bouwt daarop voort, het herontwerpt dat fundament niet.
Wat nog ontbreekt:
1. **Geen uniforme sprint-samenstelling-UI**. Sprint-aanmaak loopt nu via twee flows: `createSprintAction` (één pbi_id) en `createSprintWithPbisAction` (array, via `NewSprintDialog`). Geen UI-feedback over welke PBI's al in welke mate "in de huidige sprint zitten".
2. **Stories aan/uit sprint per stuk** kan alleen via de Sprint-pagina, niet vanuit de backlog.
3. **Geen pending/dirty-flow** voor sprint-mutaties — alle huidige acties zijn direct gecommit, wat zware multi-toggle-flows omslachtig maakt.
We bouwen een vinkje-gebaseerde workflow met drie states. Geen schemamutatie op de DB — `sprint_id` blijft op Story en Task. PBI-vinkjes zijn puur afgeleid. `task.sprint_id` blijft denormalisatie van `story.sprint_id` en wordt cascade-meeg­e­update bij bulk-mutaties.
---
## Beslissingen (samenvatting)
| Onderdeel | Keuze |
|---|---|
| **Datamodel** | Ongewijzigd. `story.sprint_id` is unit-of-truth; PBI/task vinkjes afgeleid |
| **Cross-sprint conflict** | Disabled vinkje + tooltip; **alleen** tegen andere OPEN sprints |
| **State A** (geen sprint) | Alle PBI's, geen vinkjes, klassieke 3-koloms inspect |
| **State A vorm** | Two-step: kleine modal (metadata) → sticky banner + inline vinkjes |
| **State A annuleren** | Dirty-close confirm (`useDirtyCloseGuard`-pattern) |
| **State A persistentie** | `user-settings.pendingSprintDraft[productId]` — compacte intent (zie hieronder), niet alle story-IDs |
| **Lege sprint** | Toegestaan |
| **State B vinkjes** | Tri-state op PBI (selector-afgeleid), binair op story; klikken muteert pending buffer |
| **State B pending scope** | Alleen sprint-membership toggles |
| **State B dirty-UI** | "Sprint opslaan"-knop altijd zichtbaar, disabled bij clean, met teller bij dirty |
| **State B navigatie bij dirty** | Confirm-dialog |
| **Sprint-switcher** | OPEN sprints + "Geen actieve sprint"-optie. CLOSED via bestaande sprint-pagina |
| **Sprint-scope** | Per-user (huidig `user-settings.activeSprints[productId]`) |
| **Multiple OPEN sprints** | Toegestaan — `createSprintAction`-uniqueness-check vervalt |
| **Nieuwe story in state B** | `sprint_id = activeSprintId` direct bij aanmaak |
| **Tasks-niveau** | Geen vinkjes. Cascade-meeg­e­updated met story |
| **Sprint metadata edit** | `SprintEditDialog` (goal, dates) via edit-icoon |
| **Sprint afsluiten** | Hergebruik bestaande `completeSprintAction` (per-story DONE/OPEN beslissing + PBI-promotie) — **niet** een nieuwe `closeSprintAction` |
| **`story.status` bij membership-mutaties** | Add: `status='IN_SPRINT'` (én `sprint_id` gezet). Remove: `status='OPEN'` (én `sprint_id=NULL`). `task.sprint_id` cascadeert in **dezelfde transactie** |
| **Eligibility voor toevoegen** | Server-resolve mag alleen stories met `sprint_id IS NULL` **en** `status != 'DONE'` toevoegen. Stories uit CLOSED/ARCHIVED/FAILED sprints met DONE-status zijn dus niet eligible — moeten eerst handmatig op OPEN gezet worden (of via re-open flow) |
| **Active-sprint null-contract** | Schema nullable maken — `activeSprints[productId]: string \| null`. **Key-aanwezigheid heeft betekenis**: key ontbreekt → fallback-cascade (eerste OPEN, dan recent CLOSED). Key met `null`-waarde → expliciet *geen* actieve sprint, géén fallback |
| **PBI-selectie-flow migratie** | Bestaande `selectionMode` + `NewSprintDialog` + `createSprintWithPbisAction` worden **omgebouwd** tot A-draft-mode. Eén flow, geen feature-flag-parallellisme |
| **Initial server-side load** | Bestaande `getProductBacklogPbis(productId, query, 'matching')` blijft basis — geen counts in deze call. Geen stories, geen taken |
| **Background remaining-load** | Behoud huidige patroon: client laadt `?mode=remaining` via route handler |
| **PBI-counts (state B tri-state)** | Aparte lazy summary-endpoint `GET /api/products/[id]/sprint-membership-summary?sprintId=X&pbiIds=<ids>`**expliciet gescoped op pbiIds** (visible/loaded batch), nooit product-breed. Alleen aangeroepen in state B |
| **Story-detail (description + taken)** | Lazy bij PBI-klik via bestaande `ensurePbiLoaded`/`ensureStoryLoaded` route handlers |
| **Story-IDs voor A tri-state** | **Niet** brede `getStoryIdsByPbi(productId)`-fetch. Per PBI lazy via dezelfde `ensurePbiLoaded` als state A |
| **Cross-sprint conflict-detectie** | Server-side bij commit (autoritatief). Client-hint via lichte `GET /api/products/[id]/cross-sprint-blocks?excludeSprintId=X&pbiIds=<ids>`**gescoped op pbiIds** voor disabled-vinkjes |
| **Data-access stijl** | Blijven bij **route handlers + `cache: 'no-store'` + `revalidatePath`** (huidige stijl). Géén Cache Components / `'use cache'` / `cacheTag` in dit plan |
| **Sync na commit** | Server action retourneert affected ids → client patcht workspace-store gericht. **Geen `router.refresh()` of full page rehydration** |
---
## State A — geen actieve sprint geselecteerd
**UI:** bestaande 3-koloms layout uit [components/backlog/backlog-split-pane.tsx](components/backlog/backlog-split-pane.tsx) onveranderd. PBI-lijst | Story-panel | Task-panel. Geen vinkjes.
**Header-acties:** sprint-switcher toont "Geen actieve sprint" + dropdown van OPEN sprints + "— Geen actieve sprint —"-optie. Naast switcher: knop **"Nieuwe sprint"** → start A door metadata-modal te openen.
**Wijzigingen t.o.v. huidig gedrag:**
- Sprint-switcher in [components/shared/sprint-switcher.tsx](components/shared/sprint-switcher.tsx) krijgt expliciete optie "— Geen actieve sprint —"; selectie roept (nieuwe) `clearActiveSprintAction(productId)` aan → schrijft `null` in user-settings.
- De huidige "Start Sprint"-knop in [app/(app)/products/[id]/page.tsx](app/(app)/products/[id]/page.tsx) wordt "Nieuwe sprint" en triggert A-flow i.p.v. direct `NewSprintDialog`.
---
## State A — sprint definiëren (ombouw van huidige selectionMode)
### Migratie-uitgangspunt
De bestaande PBI-selectie-flow in [components/backlog/pbi-list.tsx:219-523](components/backlog/pbi-list.tsx) heeft al:
- `selectionMode` boolean en `selectedIds: Set<string>`
- `toggleCheck(id)` voor PBI-toggles
- `exitSelection()` voor cleanup
- `NewSprintDialog` aanroep met `pbiIds`-array
- Server-action `createSprintWithPbisAction` die alle stories van geselecteerde PBI's bulk-update
We **bouwen dit om** tot A. Het oude `NewSprintDialog` wordt vervangen door de two-step flow (metadata-modal → banner). De selectie-state wordt uitgebreid van "PBI's only" naar "PBI's én individuele stories (overrides)". `createSprintWithPbisAction` wordt aangepast om óók override-lijsten te accepteren.
### Stap 1: metadata-modal
Klik "Nieuwe sprint" → kleine `Dialog` (Entity-Dialog-pattern uit [docs/patterns/dialog.md](docs/patterns/dialog.md)):
- **Sprint-doel** (`sprint_goal`, verplicht)
- **Startdatum** (optioneel, default = vandaag)
- **Einddatum** (optioneel, default = +2 weken)
- Knoppen: "Annuleren" | "Verder"
"Verder" valideert (Zod) en schrijft via `setPendingSprintDraftAction` naar user-settings. **Geen sprint in DB.**
### Stap 2: vinkjes + sticky banner (compacte intent-state)
Op de pagina verschijnt een **sticky banner**:
```
┌──────────────────────────────────────────────────────────────────┐
│ Sprint definiëren — [doel] · X PBI's, Y stories │
│ [Annuleren] [Sprint aanmaken] │
└──────────────────────────────────────────────────────────────────┘
```
Op alle PBI-rijen en story-rijen verschijnen vinkjes — story-vinkjes pas zichtbaar als de PBI is geopend (via bestaande `ensurePbiLoaded`).
**Pending draft-state (compact, overrides per PBI):**
```ts
pendingSprintDraft: {
goal: string
startAt?: string
endAt?: string
// Per-PBI bulk-intent:
pbiIntent: {
[pbiId]: 'all' | 'none' // default 'none' tot user PBI aanvinkt
}
// Per-PBI overrides (story-ids die afwijken van de PBI-intent):
storyOverrides: {
[pbiId]: {
add: string[] // expliciet aan, ook al staat PBI op 'none'
remove: string[] // expliciet uit, ook al staat PBI op 'all'
}
}
}
```
**Waarom per-PBI overrides (i.p.v. één globale add/remove):** bij PBI-toggle (`'all' → 'none'`) of bij sessie-restore moet je zonder brede story-fetch betrouwbaar weten welke overrides bij welke PBI horen. Globale lijsten dwingen je tot een product-breed `getStoryIdsByPbi` om op te schonen — dat is precies wat we niet willen. Met per-PBI overrides is opruimen lokaal: bij PBI-toggle wis je `storyOverrides[pbiId]`, klaar.
**Tri-state-resolutie (selector, niet opgeslagen):**
- PBI-vinkje weergave: bereken uit `pbiIntent[pbiId]` + de subset van zijn child-stories die geladen is + `storyOverrides[pbiId]`. Bij `intent='all'` en geen `remove` → ✓. Bij `intent='none'` en geen `add` → ☐. Anders ◐.
- Story-vinkje: `(pbiIntent[pbiId] == 'all' || storyOverrides[pbiId]?.add?.includes(storyId)) && !storyOverrides[pbiId]?.remove?.includes(storyId)`.
**Toggle-semantiek:**
- Klik PBI-vinkje ☐→✓: `pbiIntent[pbi] = 'all'`, wis `storyOverrides[pbi]`.
- Klik PBI-vinkje ✓→☐: `pbiIntent[pbi] = 'none'`, wis `storyOverrides[pbi]`.
- Klik story-vinkje (in geopende PBI): voeg toe aan `storyOverrides[pbi].add` of `.remove`, met cancel-out tegen de tegenoverliggende lijst van diezelfde PBI.
**Voordelen:** geen N×K JSON-blob per draft. Per-PBI scoping maakt cleanup lokaal en restore deterministisch.
**Annuleren** → dirty-close confirm → `clearPendingSprintDraftAction` → banner verdwijnt.
**Sprint aanmaken** → server action `createSprintWithSelectionAction(productId, metadata, pbiIntent, storyOverrides)`:
1. Server resolveert intent → concrete `storyIdsToAddToSprint: string[]`:
- Voor elke PBI met `intent = 'all'`: alle child-stories minus `storyOverrides[pbi].remove`
- Plus alle stories in `storyOverrides[pbi].add` (over alle PBI's)
2. **Eligibility-filter (server, autoritatief):** behoud alleen stories waarvoor `sprint_id IS NULL` **en** `status != 'DONE'`. Stories die niet voldoen (in andere sprint, of al DONE) komen in `conflicts.notEligible[]` met reden.
3. **Cross-sprint-check** (gedekt door eligibility, maar separately rapporteren): geblokkeerde stories → `conflicts.crossSprint[]` met `{ storyId, sprintId, sprintName }`.
4. Transactie:
- Insert Sprint (status=OPEN)
- `story.sprint_id = newSprintId, story.status = 'IN_SPRINT' WHERE id IN (eligibleStoryIds)`
- `task.sprint_id = newSprintId WHERE story_id IN (eligibleStoryIds)` (cascade — task.status onveranderd)
5. `clearPendingSprintDraftAction` + `setActiveSprintInSettings(productId, newSprintId)`
6. Realtime-event broadcasting
7. **Return:** `{ sprintId, affectedStoryIds, affectedPbiIds, conflicts: { notEligible, crossSprint } }`
8. Client patcht workspace-store gericht: voeg sprintId toe aan stories/tasks, zet `story.status = 'IN_SPRINT'`, invalidate `pbiSummary`-counts voor affected PBI's via lazy summary-refetch (gescoped). Toast voor conflicts. **Geen page-refresh.**
### Persistent draft
Verlaten van de pagina/sessie tijdens A`pendingSprintDraft` blijft in user-settings. Volgende bezoek: pagina detecteert draft → banner + vinkjes verschijnen automatisch.
---
## State B — actieve sprint geselecteerd
### UI
- **Header**: sprint-switcher toont actieve sprint. Edit-icoon ernaast → opent `SprintEditDialog` (alleen metadata: goal + dates).
- **"Sprint opslaan"-knop**: altijd zichtbaar, disabled bij clean, geactiveerd met teller bij dirty: *"Sprint opslaan (3)"*.
- **Sprint afsluiten**: bestaande `completeSprintAction`-flow blijft op de sprint-pagina (`/products/[id]/sprint/[sprintId]`); SprintEditDialog krijgt een link "Sprint afronden…" die naar die pagina navigeert. Geen duplicate flow.
- **3-koloms layout**: ongewijzigd. PBI-vinkjes (tri-state via selector), story-vinkjes (binair, disabled-bij-conflict), geen task-vinkjes.
### Pending buffer (state B)
In [stores/product-workspace/store.ts](stores/product-workspace/store.ts) toevoegen — **arrays, niet Sets**:
```ts
sprintMembershipPending: {
adds: string[] // story-ids die in actieve sprint moeten
removes: string[] // story-ids die uit actieve sprint moeten
}
```
- `isDirty` selector: `adds.length + removes.length > 0`
- Teller selector: `adds.length + removes.length`
- Cancel-out: bij toggle terug wordt het ID uit de tegenoverliggende lijst gehaald
Arrays zijn JSON-serialiseerbaar (handig voor debugging/devtools) en spelen netjes met Zustand/Immer (geen mutable Set-valkuil).
### Tri-state vinkjes via selectors (geen opgeslagen state)
In [stores/product-workspace/store.ts](stores/product-workspace/store.ts):
```ts
// Primitieven (opgeslagen):
pbiSummary: {
[pbiId]: {
totalStoryCount: number // uit summary-endpoint
inActiveSprintStoryCount: number // uit summary-endpoint, of 0 in state A
}
}
loadedStoryIdsByPbi: { [pbiId]: string[] } // alleen voor stories die al geladen zijn
storiesByPbi: { [pbiId]: Story[] | undefined }
tasksByStory: { [storyId]: Task[] | undefined }
sprintMembershipPending: { adds: string[], removes: string[] }
crossSprintBlocks: { [storyId]: { sprintId: string, sprintName: string } } // lazy
// Selectors (afgeleid, gememoized):
selectPbiTriState(pbiId): 'empty' | 'partial' | 'full'
selectStoryEffectiveInSprint(storyId): boolean
selectStoryIsBlocked(storyId): { sprintId, sprintName } | null
```
`selectPbiTriState` rekent met `inActiveSprintStoryCount` + pending adds/removes voor stories van deze PBI (waarvan we de mapping kennen via `loadedStoryIdsByPbi` of via een lichte query bij PBI-load). Als de PBI niet geladen is, kan tri-state worden afgeleid uit de counts alleen (full = count==total, empty = count==0, partial = anders).
### Sprint opslaan
Server action `commitSprintMembershipAction(activeSprintId, adds[], removes[])`:
1. **Eligibility-filter voor `adds` (server, autoritatief):** behoud alleen stories met `sprint_id IS NULL` **en** `status != 'DONE'`. Niet-eligible stories (cross-sprint-conflict, of DONE) komen in `conflicts.notEligible[]`.
2. **`removes`-filter:** behoud alleen stories die feitelijk `sprint_id = activeSprintId` hebben (race-safety; story kan ondertussen al ergens anders heen verplaatst zijn).
3. Transactie:
- **Add**: `story.sprint_id = activeSprintId, story.status = 'IN_SPRINT' WHERE id IN (eligibleAdds)`
- **Add**: `task.sprint_id = activeSprintId WHERE story_id IN (eligibleAdds)` (cascade, task.status onveranderd)
- **Remove**: `story.sprint_id = NULL, story.status = 'OPEN' WHERE id IN (validRemoves)`
- **Remove**: `task.sprint_id = NULL WHERE story_id IN (validRemoves)` (cascade)
4. Realtime-events broadcasten
5. **Return:** `{ affectedStoryIds, affectedPbiIds, affectedTaskIds, conflicts: { notEligible, alreadyRemoved } }`
6. Client patcht store gericht:
- Update `story.sprint_id` + `story.status` voor affected stories in `storiesById` / `storiesByPbi`
- Update `task.sprint_id` voor affected tasks
- Debounced refetch van `sprint-membership-summary` voor affected PBI's (**gescoped op `pbiIds=affectedPbiIds`**)
- Wis pending buffer
- Toast voor conflicts
- **Geen `router.refresh()`.**
### Andere mutaties in state B
- **Story aanmaken** (StoryDialog): `sprint_id = activeSprintId` direct bij create. Verschijnt direct in sprint.
- **PBI/Story/Task field-edit** (bestaande Entity Dialogs): onveranderd.
- **Sprint-switcher wisselt bij dirty**: confirm-dialog.
- **Wegnavigeren met dirty**: `useDirtyCloseGuard` → confirm-dialog.
---
## Cross-sprint conflict — afhandeling
**Client (hint-laag):** lazy fetch `GET /api/products/[id]/cross-sprint-blocks?excludeSprintId=X` bij state-B-load. Vult `crossSprintBlocks` in de store. Story-rij met `crossSprintBlocks[storyId] != null` → vinkje disabled, tooltip "Zit in Sprint [naam]".
**Server (autoritatieve check):** in `commitSprintMembershipAction` en `createSprintWithSelectionAction` opnieuw checken — race-conditie wordt afgevangen, conflicts worden geretourneerd als warning. Client toont toast voor geskippte stories.
Helper `lib/sprint-conflicts.ts` (nieuw) doet de check op een set story-IDs en geeft `{ allowed: string[], blocked: { storyId, sprintId, sprintName }[] }`.
---
## SprintEditDialog (nieuw)
`components/backlog/sprint-edit-dialog.tsx` — Entity-Dialog-pattern:
- Velden: `sprint_goal`, `start_at`, `end_at`
- Knop "Opslaan" → `updateSprintAction(sprintId, fields)`
- Link "Sprint afronden…" → navigeert naar `/products/[id]/sprint/[sprintId]` (bestaande sprint-page met `completeSprintAction`)
- **Geen** "Sprint afsluiten"-knop hier — hergebruik bestaande completion-flow met per-story DONE/OPEN beslissing en PBI-promotie.
Server action `updateSprintAction(sprintId, { goal?, start_at?, end_at? })`: validate met Zod, update Sprint-record, `revalidatePath('/products/[id]')`, retourneert affected sprint. Client patcht sprint-record in store.
---
## Dataflow
### Uitgangspunten
- **Blijf bij route handlers + `cache: 'no-store'`** (huidige patroon). Geen `'use cache'`/`cacheTag` in deze migratie — review's P2 zegt: meng deze stijlen niet half. Migratie naar Cache Components is een eigen project.
- **Filter-first respecteren**: initial render levert alleen *matching* PBI-metadata; *remaining* op de achtergrond — beide via bestaande [getProductBacklogPbis](lib/product-backlog-pbis.ts).
- **Geen aggregaten in initial query**: dat zou bij groei alsnog brede story-aggregaties bij elke render forceren.
- **Counts apart via lazy endpoint**: alleen voor state B, alleen voor zichtbare PBI's (of bulk per sprint — beheerbaar omdat #PBI's per product bescheiden blijft).
- **Geen brede `getStoryIdsByPbi`**: hergebruik bestaande `ensurePbiLoaded`/`ensureStoryLoaded` lazy-loads. Tri-state werkt op counts (uit summary-endpoint) zolang de PBI dichtgeklapt is; pas bij open-klik komen story-IDs in beeld voor accurate selector-state.
- **Sync-model**: SSE-patches (al aanwezig) voor reactieve updates + `revalidatePath` na server-actions (huidige patroon) + gerichte client-store patches met de affected-IDs uit action-returns.
### Initial server-side load (page render)
Onveranderd t.o.v. huidige flow — geen nieuwe loader:
```ts
// app/(app)/products/[id]/page.tsx (huidige code, behouden):
const initialPbiQuery = productBacklogPbiQueryFromSettings(...)
const pbis = await getProductBacklogPbis(id, initialPbiQuery, 'matching')
// Geen stories, geen taken in initial render.
```
Plus parallel:
- `activeSprint = resolveActiveSprint(productId, userId)` — gewijzigd om explicit `null` te respecteren (zie hieronder).
- `pendingSprintDraft = getUserSettings(userId).pendingSprintDraft?.[productId] ?? null`.
### Background remaining-load
Bestaande route handler `GET /api/products/[id]/backlog?mode=remaining` blijft. Client triggert na initial render om de overige PBI-metadata in de store te krijgen (zonder stories/tasks).
### Lazy per PBI-klik
Bestaande `ensurePbiLoaded(pbiId)` in [stores/product-workspace/store.ts](stores/product-workspace/store.ts) blijft. Fetch via route handler met `cache: 'no-store'`. Vult `storiesByPbi[pbiId]` + `loadedStoryIdsByPbi[pbiId]`.
### Lazy per story-klik
Bestaande `ensureStoryLoaded(storyId)` blijft (laadt taken).
### Sprint-membership summary (NIEUW — alleen state B, gescoped)
Nieuw route handler `GET /api/products/[id]/sprint-membership-summary?sprintId=X&pbiIds=<comma-separated>`:
```ts
// Response:
{
[pbiId: string]: { total: number, inSprint: number }
}
```
- **`pbiIds` is verplicht** — endpoint weigert product-brede aanroepen. Client geeft alleen visible/loaded PBI-IDs door.
- Eén `groupBy` op `Story` waar `pbi_id IN (pbiIds)` (matching-filter werkt nog: we vragen alleen counts voor PBI's die al in viewport-batch staan).
- Verwaarloosbare belasting omdat de query begrensd is op de doorgegeven set.
Aangeroepen door client wanneer state B actief wordt OF na sprint-switch, OF na een commit (gescoped op affected pbi-ids). Vult `pbiSummary` in de store.
In state A wordt **niet** aangeroepen.
### Cross-sprint blocks (NIEUW — alleen state B, gescoped)
Nieuw route handler `GET /api/products/[id]/cross-sprint-blocks?excludeSprintId=X&pbiIds=<comma-separated>`:
```ts
{
[storyId: string]: { sprintId: string, sprintName: string }
}
```
- **`pbiIds` verplicht** — endpoint weigert product-brede scans. Begrenzing op visible/loaded batch.
- Aangeroepen bij state B-load + na elke PBI-batch-load (zodat nieuwe PBI's hun blocks krijgen).
- Vult `crossSprintBlocks` in de store voor disabled-vinkjes.
- Server-side check bij commit blijft autoritatief — dit endpoint is alleen UX-hint.
### Active-sprint resolver (gewijzigd)
**Schema-contract (cruciaal, zit in [lib/user-settings.ts](lib/user-settings.ts)):**
```ts
// Zod schema wijziging:
activeSprints: z.record(z.string(), z.string().nullable()).optional()
```
**Drie distincte states per `productId`:**
| Settings-staat | Betekenis |
|---|---|
| Key ontbreekt | Geen voorkeur ingesteld — fallback-cascade actief (eerste OPEN, dan recent CLOSED, dan `null`) |
| Key bestaat met `string` | Die specifieke sprint is gekozen (mits gevonden in DB; anders fallback) |
| Key bestaat met `null` | **Bewust geen actieve sprint** — geen fallback, blijft "Geen actieve sprint" |
**Wijzigingen in [lib/active-sprint.ts](lib/active-sprint.ts):**
- `resolveActiveSprint(productId, userId)` checkt `key in activeSprints` (niet alleen truthy):
- Key niet aanwezig → fallback-cascade
- Key aanwezig, value=null → return null
- Key aanwezig, value=string → die sprint
- `setActiveSprintInSettings(productId, sprintId)` ongewijzigd (schrijft string).
- **`clearActiveSprintInSettings(productId)` wordt aangepast**: i.p.v. de key te `delete`, schrijft het nu `null`. Dat is het verschil tussen "geen voorkeur" en "expliciet geen actieve sprint".
**[actions/active-sprint.ts](actions/active-sprint.ts):**
- Nieuw: `clearActiveSprintAction(productId)` — gebruikt de aangepaste `clearActiveSprintInSettings` (schrijft null).
- Bestaande `setActiveSprintAction` ongewijzigd.
### Sync na commit — gerichte client-store patches
Server actions retourneren expliciet affected IDs:
```ts
return { affectedStoryIds, affectedPbiIds, affectedTaskIds, conflicts }
```
Client (na await):
1. Patch `storiesById` + `tasksById` met nieuwe `sprint_id`-waarden.
2. Voor elke `affectedPbiId`: fire-and-forget refetch van `sprint-membership-summary` (debounced 100ms) om counts te actualiseren.
3. Wis pending buffer.
4. **Geen `router.refresh()`.**
`revalidatePath` blijft in de server-actie voor andere users / lossely-coupled views, maar de huidige user's UI updateert via de gerichte patches.
### Data-load-volgorde overzicht
| Moment | Wat | Wie |
|---|---|---|
| Page render | Matching PBI's (metadata) + activeSprint + draft | Server (SSR) — bestaande flow |
| Na hydratie | Remaining PBI's (metadata) | Client → bestaande `/api/.../backlog?mode=remaining` |
| State B activeert | Sprint-membership-summary + cross-sprint-blocks | Client → nieuwe endpoints |
| PBI-klik | Stories voor die PBI (full) | Client → bestaande `ensurePbiLoaded` |
| Story-klik | Taken voor die story | Client → bestaande `ensureStoryLoaded` |
| A→A start | Geen extra fetch — werk met `pendingSprintDraft` (compact) | |
| A stories cherrypicken | Klik PBI → bestaande lazy-load voor die PBI | |
| Sprint-switch | Refetch membership-summary + cross-sprint-blocks voor nieuwe sprint | Client |
| SSE event | Patch lokale store | Client |
| Na server-action commit | Affected IDs uit return → gerichte store-patches + debounced summary-refetch | Client |
---
## Critical files
### Te wijzigen
- [app/(app)/products/[id]/page.tsx](app/(app)/products/[id]/page.tsx) — state-detectie (A/A/B); banner-rendering; "Nieuwe sprint"-knop opent metadata-modal (i.p.v. direct `NewSprintDialog`). **Initial query blijft `getProductBacklogPbis(id, query, 'matching')`** — geen counts hier.
- [components/backlog/pbi-list.tsx](components/backlog/pbi-list.tsx) — bestaande `selectionMode` ombouwen tot A-modus: vinkjes worden tri-state, lezen uit `pendingSprintDraft.pbiIntent` of (in state B) uit `selectPbiTriState`-selector. Verwijder de directe `NewSprintDialog`-trigger.
- [components/backlog/story-panel.tsx](components/backlog/story-panel.tsx) — vinkje per story; lees uit selectors (`selectStoryEffectiveInSprint`, `selectStoryIsBlocked`); klik muteert `pendingSprintDraft.storyOverrides` of `sprintMembershipPending`.
- [components/backlog/task-panel.tsx](components/backlog/task-panel.tsx) — geen wijzigingen aan task-flow.
- [components/shared/sprint-switcher.tsx](components/shared/sprint-switcher.tsx) — "— Geen actieve sprint —"-optie; dirty-check bij wissel.
- [stores/product-workspace/store.ts](stores/product-workspace/store.ts) — uitbreidingen: `pbiSummary`, `loadedStoryIdsByPbi`, `crossSprintBlocks`, `sprintMembershipPending` (arrays), selectors voor tri-state, gerichte patch-helpers voor server-action-returns.
- [stores/user-settings/store.ts](stores/user-settings/store.ts) — `pendingSprintDraft[productId]: { goal, startAt?, endAt?, pbiIntent, storyOverrides: { [pbiId]: { add, remove } } } | null`; `activeSprints[productId]: string | null` (zie ook user-settings.ts hieronder).
- **[lib/user-settings.ts](lib/user-settings.ts)** — Zod-schema strictness: `activeSprints` value nullable; `pendingSprintDraft` als optionele key per productId met de hier-gespecificeerde shape; migratie-tests aanpassen.
- [actions/sprints.ts](actions/sprints.ts):
- `createSprintAction` — drop OPEN-uniqueness-check (multi-OPEN toegestaan)
- **`createSprintWithPbisAction` → uitbreiden naar `createSprintWithSelectionAction(productId, metadata, pbiIntent, storyOverrides)`**. Server resolveert intent → concrete story-IDs. Returnt affected IDs.
- Nieuw: `commitSprintMembershipAction(sprintId, adds[], removes[])` — transactional, retourneert affected + conflicts.
- Nieuw: `updateSprintAction(sprintId, { goal?, startAt?, endAt? })` — alleen metadata.
- **GEEN** nieuwe `closeSprintAction``completeSprintAction` blijft de afrond-flow.
- [actions/active-sprint.ts](actions/active-sprint.ts) — nieuwe `clearActiveSprintAction(productId)` (schrijft null). `setActiveSprintAction` ongewijzigd voor non-null.
- [lib/active-sprint.ts](lib/active-sprint.ts) — `resolveActiveSprint` checkt key-aanwezigheid (niet truthy): key+null → return null zonder fallback; key+string → sprint; key ontbreekt → fallback-cascade. **`clearActiveSprintInSettings` schrijft nu `null` i.p.v. key te verwijderen** (essentieel voor het null-contract).
### Nieuw
- `app/api/products/[id]/sprint-membership-summary/route.ts` — lazy counts endpoint
- `app/api/products/[id]/cross-sprint-blocks/route.ts` — lazy cross-sprint hint endpoint
- `components/backlog/sprint-definition-banner.tsx` — sticky banner voor A
- `components/backlog/new-sprint-metadata-dialog.tsx` — stap 1 van A
- `components/backlog/sprint-edit-dialog.tsx` — metadata-edit in B
- `lib/sprint-conflicts.ts` — cross-sprint check helpers
- `actions/sprint-draft.ts``setPendingSprintDraftAction`, `clearPendingSprintDraftAction`
### Niet aangeraakt
- [prisma/schema.prisma](prisma/schema.prisma) — geen schemawijziging
- Bestaande `completeSprintAction` en de sprint-pagina `/products/[id]/sprint/[sprintId]` — sprint-afronding-flow blijft daar
- [components/backlog/task-panel.tsx](components/backlog/task-panel.tsx), task-dialog, pbi-dialog, story-dialog — Entity Dialogs onveranderd
---
## Hergebruik bestaande patronen
- **Entity-Dialog-pattern**: metadata-modal + sprint-edit-dialog
- **useDirtyCloseGuard**: A-annulering, B-navigatie
- **Zustand optimistic pattern**: pending buffer + gerichte server-action-return-patches
- **Realtime NOTIFY-payload**: sprint-membership events
- **Server-action-pattern**: auth + Zod
- **Filter-first/background-remaining**: blijft via [getProductBacklogPbis](lib/product-backlog-pbis.ts) en bestaande `/api/products/[id]/backlog?mode=X` route handler
- **MD3-tokens + shadcn `<Checkbox>`** (tri-state via custom mapping)
---
## Verificatie
### End-to-end checks (handmatig + dev-server)
1. **State A pad**: zonder actieve sprint → geen vinkjes, switcher toont "Geen actieve sprint", klik PBI → stories tonen, klik story → taken tonen, Entity-Dialog edits direct gecommit.
2. **A → A → B happy path**: "Nieuwe sprint" → metadata-modal → "Verder" → banner verschijnt, vinkjes verschijnen op PBI's. Vink 2 PBI's met 5 child-stories totaal → banner toont "2 PBI's, 5 stories". Open één PBI en deselecteer 1 story (storyOverride.remove). Banner: "2 PBI's, 4 stories". Klik "Sprint aanmaken" → sprint actief, state B met afgeleide vinkjes, **geen page refresh** (controle via DevTools Network: alleen affected updates).
3. **A persistente draft**: start A, vink dingen aan, navigeer weg → confirm-dialog → bevestig. Kom terug op pagina → banner + vinkjes hersteld.
4. **State B pending buffer**: vink een story aan → "Sprint opslaan (1)". Vink een story in sprint weg → "Sprint opslaan (2)". Vink eerste weer uit → "Sprint opslaan (1)" (cancel-out). Klik opslaan → store-patches, geen full reload.
5. **Cross-sprint blokkade**: maak twee OPEN sprints, story X in sprint A. Switch naar sprint B → story X heeft disabled vinkje, tooltip "Zit in Sprint [A]". Verplaats story X via sprint A's sprint-page → cross-sprint-blocks updaten via SSE-patch.
6. **Sprint metadata-edit**: edit-icoon → SprintEditDialog → wijzig goal → opslaan → direct gecommit, geen page-state-wijziging.
7. **Sprint afronden**: SprintEditDialog toont link "Sprint afronden…" → navigeert naar `/products/[id]/sprint/[sprintId]` → bestaande completion-flow ongewijzigd.
8. **Switcher-wissel bij dirty**: state B met pending toggles → wissel sprint → confirm-dialog. Cancel → blijft, buffer intact. Bevestig → buffer leeg, switch.
9. **"Geen actieve sprint" persistentie**: kies "— Geen actieve sprint —" in switcher → schrijf null. Refresh pagina → blijft state A, valt **niet** terug op nieuwste OPEN sprint.
### Geautomatiseerde tests (Vitest)
- `lib/sprint-conflicts.test.ts`: vrij, in-zelfde-sprint, in-andere-OPEN, in-CLOSED (niet blokkerend voor commit-laag).
- `stores/product-workspace.test.ts`: pending buffer (arrays) toggle-cancel-out; tri-state-selector op verschillende load-staten (PBI niet geladen / geladen / met per-PBI overrides).
- `actions/sprints.test.ts`:
- `createSprintWithSelectionAction` resolve van per-PBI intent + per-PBI storyOverrides
- **Eligibility-filter**: stories met `status='DONE'` of `sprint_id != NULL` worden geweigerd en komen in `conflicts.notEligible`
- **Status-mutatie**: na add zijn betroffen stories `IN_SPRINT`; na remove zijn ze `OPEN`
- **Task.sprint_id in dezelfde transactie** — assert via mock prisma dat beide updates één tx delen
- Returns met `affectedStoryIds`, `affectedPbiIds`, `affectedTaskIds`, `conflicts`
- `actions/commit-sprint-membership.test.ts`:
- Race-conditie: story die ondertussen in andere sprint zit, eindigt in conflicts en wordt niet ge-update
- Removes met onverwachte sprint_id (al verwijderd) eindigen in `conflicts.alreadyRemoved`
- `lib/active-sprint.test.ts`:
- Key+null → return null (geen fallback)
- Key+string → die sprint (mits gevonden)
- Key ontbreekt → fallback-cascade actief
- `lib/user-settings.test.ts`:
- Zod-schema accepteert nullable values in `activeSprints`
- `pendingSprintDraft` met per-PBI overrides round-trippt
- `actions/active-sprint.test.ts`:
- `clearActiveSprintAction` schrijft `null`, **delete niet** de key — assert dat key blijft bestaan met null-value
- Endpoint-tests voor de twee nieuwe route handlers:
- `sprint-membership-summary` zonder `pbiIds`-param → 400
- `cross-sprint-blocks` zonder `pbiIds`-param → 400
- **Initial render doet géén story/task query** — assert via mock dat alleen `getProductBacklogPbis(_, _, 'matching')` is aangeroepen
- **A start doet géén brede story-ID query** — assert dat geen call met product-wide scope uitgaat; per-PBI overrides cleanup werkt zonder fetch
### Code-validatie
```bash
npm run verify && npm run build
```
---
## Reactie op review
### Eerste review
| Review-punt | Hoe geadresseerd |
|---|---|
| **P1 — Initial summary kan te zwaar worden** | Geen counts in initial render. Bestaande `getProductBacklogPbis(_, _, 'matching')` blijft. Counts apart via lazy summary-endpoint, alleen in state B, gescoped op `pbiIds`. |
| **P1 — `getStoryIdsByPbi(productId)` breekt lazy-loading** | Verwijderd. Hergebruik `ensurePbiLoaded` lazy per PBI. Pending draft-state is compact (per-PBI `pbiIntent` + per-PBI `storyOverrides`), niet alle story-IDs. |
| **P1 — "Page herhydrateert" introduceert dure refresh** | Server actions retourneren `affectedStoryIds`/`affectedPbiIds`/`affectedTaskIds`. Client patcht workspace-store gericht. Geen `router.refresh()`. |
| **P1 — `Sprint afsluiten` mag completion-semantiek niet overslaan** | `closeSprintAction` geschrapt. SprintEditDialog doet alleen metadata. Sprint-afronden gaat via bestaande `completeSprintAction` op sprint-page; SprintEditDialog krijgt link daarheen. |
| **P2 — "Geen actieve sprint"-contract** | Schema nullable: `activeSprints[productId]: string \| null`. Sleutel-aanwezigheid heeft betekenis (key ontbreekt = fallback; key=null = bewust geen). `clearActiveSprintInSettings` schrijft null. |
| **P2 — Cache Components vs huidige stijl** | Beslist: blijven bij route handlers + `cache: 'no-store'` + `revalidatePath`. Géén `'use cache'`/`cacheTag` in dit plan. |
| **P2 — Bestaande PBI-selectieflow** | Ombouwen naar A-mode. Eén flow, geen feature-flag-parallellisme. `createSprintWithPbisAction` wordt `createSprintWithSelectionAction`. |
| **P2 — Store moet primitives bewaren** | `pbiSummary` slaat alleen `totalStoryCount`/`inActiveSprintStoryCount` op. Tri-state is een selector. `sprintMembershipPending` gebruikt arrays, geen Sets. |
| **P2 — Filter-first/background-remaining ontbreekt** | Expliciet opgenomen: initial = matching, background = remaining via bestaand route-handler-patroon. |
| **Tests die review zou toevoegen** | Allemaal opgenomen in test-sectie hierboven. |
### Tweede review (deze ronde)
| Punt | Hoe geadresseerd |
|---|---|
| **P1 — `story.status` bij membership-mutaties** | Add: `sprint_id=X` **én** `status='IN_SPRINT'`. Remove: `sprint_id=NULL` **én** `status='OPEN'`. Task.sprint_id mee in **dezelfde transactie**. Expliciet in pseudocode van `commitSprintMembershipAction` en `createSprintWithSelectionAction`. |
| **P1 — Eligibility voor toevoegen** | Server-resolve filtert vóór mutatie: alleen stories met `sprint_id IS NULL` **en** `status != 'DONE'`. Niet-eligible → `conflicts.notEligible[]` in return, toast op client. Stories uit CLOSED/ARCHIVED/FAILED sprints met DONE-status zijn dus geblokkeerd. |
| **P1 — A draft-shape moet per-PBI** | `storyOverrides` herstructureerd naar `{ [pbiId]: { add, remove } }`. Cleanup bij PBI-toggle is lokaal; restore is deterministisch zonder brede story-fetch. |
| **P1 — Endpoint scoping** | `sprint-membership-summary` en `cross-sprint-blocks` vereisen verplichte `pbiIds`-query-parameter. Server weigert product-brede aanroepen. |
| **P2 — `lib/user-settings.ts` expliciet** | Opgenomen in critical files. Zod-schema wijzigt: `activeSprints` nullable; `pendingSprintDraft` als optionele key. |
| **P2 — `clearActiveSprintInSettings`-semantiek** | Schrijft nu `null` i.p.v. key te `delete`. Onderscheid: key ontbreekt = fallback; key=null = bewust geen actieve sprint. |
| **P2 — Context-tekst stale** | Context-sectie herschreven: lazy-load-basis bestaat al; dit plan bouwt erop voort. |
---
## Volgende stap (na goedkeuring)
Per project-memory: PBI + stories + taken aanmaken via Scrum4Me-MCP, daarna implementatieplan koppelen, taken pas uitvoeren op verzoek.
Werk-splitsing (laag-voor-laag, met dataflow eerst maar zonder onnodige eager loads):
1. **Story 1 — Active-sprint null-contract** + `clearActiveSprintAction` + `resolveActiveSprint`-aanpassing + sprint-switcher uitbreiding ("— Geen actieve sprint —"-optie)
2. **Story 2 — User-settings draft-slot** + `setPendingSprintDraftAction` / `clearPendingSprintDraftAction` (compacte intent-shape)
3. **Story 3 — Sprint-membership-summary endpoint** + `crossSprintBlocks` endpoint + store-uitbreidingen (`pbiSummary`, `loadedStoryIdsByPbi`, `crossSprintBlocks`)
4. **Story 4 — State B pending-buffer-slice** (arrays) + selectors voor tri-state + `selectStoryEffectiveInSprint` / `selectStoryIsBlocked`
5. **Story 5 — A UI** (metadata-modal + sticky banner) + ombouw `selectionMode` in `PbiList` + persistente draft-restore
6. **Story 6 — State B vinkjes-UI** (PBI tri-state, story binair, disabled-bij-conflict) + "Sprint opslaan"-knop met teller
7. **Story 7 — `createSprintWithSelectionAction`** (uitbreiding van bestaande `createSprintWithPbisAction`) + server-side intent-resolve + cross-sprint guard + return-affected-IDs
8. **Story 8 — `commitSprintMembershipAction`** + cross-sprint guard + gerichte client-store patches + SSE-broadcast
9. **Story 9 — SprintEditDialog** (metadata) + `updateSprintAction` + link naar afrondings-flow
10. **Story 10 — Multi-OPEN sprints** (drop uniqueness-check in `createSprintAction`)
11. **Story 11 — Verificatie + tests** (Vitest + handmatige checklist)

View file

@ -73,7 +73,7 @@ Belangrijk maar niet-blokkerend voor v1.
### Backlog-index sync
[docs/backlog/index.md](../backlog/index.md) toont M10 (ST-1001 t/m 1008) en M11 (ST-1101 t/m 1108) als unchecked, terwijl ze allemaal gemerged zijn. Loop één keer door en zet `[x]`. Is een 5-min-job die de doc weer betrouwbaar maakt voor wie 'm leest.
[docs/old/backlog/index.md](../old/backlog/index.md) toont M10 (ST-1001 t/m 1008) en M11 (ST-1101 t/m 1108) als unchecked, terwijl ze allemaal gemerged zijn. Loop één keer door en zet `[x]`. Is een 5-min-job die de doc weer betrouwbaar maakt voor wie 'm leest.
### Solo observaties (todo `cmohuu5h8`)

View file

@ -28,7 +28,7 @@ notes: |
## 1. Context (this becomes the PBI description)
This PBI executes the docs-restructure plan
([`docs/plans/docs-restructure-ai-lookup.md`](./docs-restructure-ai-lookup.md))
([`docs/old/plans/docs-restructure-ai-lookup.md`](../old/plans/docs-restructure-ai-lookup.md))
over eight phases, mapped here as eight stories with three to eight tasks
each. The goal is to cut the documentation surface an AI agent has to read
to find the right reference, without breaking existing workflows.
@ -81,7 +81,7 @@ in parallel with Stories 35 if you want.
### Where to look first
- This file (the PBI context block above).
- [`docs/plans/docs-restructure-ai-lookup.md`](./docs-restructure-ai-lookup.md)
- [`docs/old/plans/docs-restructure-ai-lookup.md`](../old/plans/docs-restructure-ai-lookup.md)
— the full plan, especially §3 (Goals), §4 (Target structure), §6
(Front-matter spec), §8 (Phased migration).
- [`docs/adr/README.md`](../adr/README.md) — when writing an ADR in
@ -143,7 +143,7 @@ pbi:
- One commit per logical layer (`docs(<story-slug>):` prefix).
- No pushes without user approval.
- Update every internal link in the same commit as a rename.
Read docs/plans/docs-restructure-ai-lookup.md §3, §4, §6, §8 first.
Read docs/old/plans/docs-restructure-ai-lookup.md §3, §4, §6, §8 first.
priority: 2
stories:
@ -337,7 +337,7 @@ pbi:
acceptance_criteria: |
- docs/ root contains only INDEX.md and (later) glossary.md.
- All existing docs moved into the right folder per
docs/plans/docs-restructure-ai-lookup.md §4.
docs/old/plans/docs-restructure-ai-lookup.md §4.
- Internal links updated in the same commit as each move.
- `npm run docs:index` shows docs grouped correctly.
priority: 2

View file

@ -7,6 +7,9 @@ last_updated: 2026-05-03
applies_to: [SCRUM4ME]
story_id: cmoq2qoik0001qa175iynfnaa
pbi_id: cmoq2q50s0000qa174rmrjove
archived: true
archived_reason: niet-uitgevoerd, uit standaard sessiecontext gehouden
archived_at: 2026-05-11
---
# Landing v2 — lokaal & veilig + architectuurdiagram

View file

@ -8,6 +8,9 @@ applies_to: [SCRUM4ME]
story_id: cmot8226500017h174z5qpphx
story_code: ST-1224
pbi_id: cmoq2q50s0000qa174rmrjove
archived: true
archived_reason: niet-uitgevoerd, uit standaard sessiecontext gehouden
archived_at: 2026-05-11
---
# Landing v3 — van idee tot pull request

View file

@ -0,0 +1,153 @@
---
title: "Sprint MCP-tools — create_sprint & update_sprint"
status: draft
audience: [maintainer, ai-agent]
language: nl
last_updated: 2026-05-11
applies_to: [scrum4me-mcp]
---
# Plan — `create_sprint` + `update_sprint` in scrum4me-mcp
## Context
Het runbook [docs/runbooks/plan-to-pbi-flow.md](../runbooks/plan-to-pbi-flow.md) (draft) beschrijft een sprint-lifecycle als onderdeel van de plan→PBI→story→task workflow:
- **Bij plan-goedkeuring** opent Claude een nieuwe sprint (`status: OPEN`)
- **Na PR-merge + verify groen** sluit Claude die sprint (`status: CLOSED`)
- **Cron** mag stale/falende sprints later op `FAILED` zetten
Hiervoor zijn twee MCP-tools nodig die nog **niet** bestaan in `~/Development/scrum4me-mcp/`:
| Tool | Wat | Wie roept aan |
|---|---|---|
| `create_sprint` | Maakt nieuwe sprint, status `OPEN` | Claude bij plan-goedkeuring |
| `update_sprint` | Wijzigt status / dates / sprint_goal | Claude bij PR-close & cron bij stale-detect |
Door één generieke `update_sprint` te bouwen (i.p.v. losse `close_sprint`/`fail_sprint`) is de tool-oppervlakte minimaal en zijn alle transities tussen `OPEN | CLOSED | ARCHIVED | FAILED` mogelijk.
## Bestaande conventies (te respecteren)
- **Toolpattern:** elk tool is één bestand onder `~/Development/scrum4me-mcp/src/tools/`, registreert via `register{ToolName}Tool(server: McpServer)` in `src/index.ts`. Voorbeeld-template: [scrum4me-mcp/src/tools/create-pbi.ts](https://github.com/madhura68/scrum4me-mcp/blob/main/src/tools/create-pbi.ts)
- **DB-toegang:** direct via `import { prisma } from '../prisma.js'`**geen** REST-tussenstap, geen Next-deps
- **Auth:** `requireWriteAccess(token)` + `userCanAccessProduct(userId, productId)` zoals in `create-pbi.ts`
- **Error-pad:** `withToolErrors(...)`, `toolError(...)`, `toolJson(...)` uit `../errors.js`
- **Zod-input** apart gedefinieerd, status-enum gespiegeld uit Prisma
- **Schema-sync:** Prisma-schema is een git-submodule in `vendor/scrum4me`; geen schema-wijzigingen nodig (Sprint-model heeft alle statussen al)
## Scope
### A. `create_sprint`
**Bestand:** `~/Development/scrum4me-mcp/src/tools/create-sprint.ts`
**Input-schema:**
```ts
const inputSchema = z.object({
product_id: z.string().min(1),
code: z.string().min(1).max(30).optional(), // auto-generate als leeg
sprint_goal: z.string().min(1).max(500),
start_date: z.string().date().optional(), // ISO YYYY-MM-DD; default = today
})
```
**Gedrag:**
1. `requireWriteAccess(token)` → user_id
2. `userCanAccessProduct(user_id, product_id)`
3. **Code-generatie** (als niet meegegeven): `S-{YYYY-MM-DD}-{N}` waarbij `N` = `count(sprints van product op datum) + 1`. Dezelfde retry-on-unique-conflict pattern als `generateNextPbiCode()`.
4. `prisma.sprint.create({ data: { product_id, code, sprint_goal, status: 'OPEN', start_date } })`
5. Return: `{ id, code, status, start_date }`
**Niet doen:** géén check op bestaande OPEN-sprints (per runbook-beslissing: "altijd nieuwe sprint").
### B. `update_sprint`
**Bestand:** `~/Development/scrum4me-mcp/src/tools/update-sprint.ts`
**Input-schema:**
```ts
const inputSchema = z.object({
sprint_id: z.string().min(1),
status: z.enum(['OPEN', 'CLOSED', 'ARCHIVED', 'FAILED']).optional(),
sprint_goal: z.string().min(1).max(500).optional(),
end_date: z.string().date().optional(),
start_date: z.string().date().optional(),
}).refine(d =>
d.status !== undefined || d.sprint_goal !== undefined ||
d.end_date !== undefined || d.start_date !== undefined,
{ message: 'Minstens één veld vereist' }
)
```
**Gedrag:**
1. `requireWriteAccess(token)` → user_id
2. Laad sprint → check `userCanAccessProduct(user_id, sprint.product_id)`
3. **Geen state-machine validatie** in deze tool — elke status-transitie is toegestaan. Het resubmit/heropen-pad wordt elders (buiten deze MCP-tool) afgehandeld.
4. **Auto-`end_date`:** als status naar `CLOSED`/`FAILED`/`ARCHIVED` gaat en `end_date` is niet meegegeven → set op `today()`.
5. `prisma.sprint.update({ where: { id }, data: {...} })`
6. Return: `{ id, code, status, start_date, end_date }`
### C. `index.ts` — tool-registratie
Twee regels toevoegen aan `~/Development/scrum4me-mcp/src/index.ts`:
```ts
import { registerCreateSprintTool } from './tools/create-sprint.js'
import { registerUpdateSprintTool } from './tools/update-sprint.js'
// …
registerCreateSprintTool(server)
registerUpdateSprintTool(server)
```
## Out-of-scope (apart op te pakken)
- **Cron auto-close/fail:** een Vercel cron-route (`/api/cron/sprint-lifecycle`) die OPEN-sprints scant, PR-status + verify check, en `update_sprint` aanroept met `CLOSED` of `FAILED`. Drempels: PR mergedAt → CLOSED, PR closed && !merged → FAILED, PR stale > 14d → FAILED. **Apart PBI** want vereist GitHub-API-koppeling en threshold-policy-besluiten.
- **Sprint-koppeling bij `create_story`:** runbook merkt op dat als er meerdere OPEN-sprints zijn de gebruiker moet bevestigen welke. Schoner is `create_story` uitbreiden met optionele `sprint_id`-param. Klein patch in `create-story.ts`, maar **niet** in deze PBI — eerst de basis-tools werkend hebben.
- **Sprint-events / SSE:** elke status-transitie zou een NOTIFY moeten emiteren zodat de UI live update. Bestaande pattern in [docs/patterns/realtime-notify-payload.md](../patterns/realtime-notify-payload.md). **Niet** in v1 van deze PBI — handmatige refresh acceptabel tot cron-flow er is.
- **REST-endpoints:** `POST /api/sprints` en `PATCH /api/sprints/[id]` in de Scrum4Me-app voor UI-pariteit. **Niet** in deze PBI — MCP gaat direct via Prisma, UI kan dat later naadloos volgen.
## Testen
In `scrum4me-mcp` zelf (Vitest):
- `create-sprint.test.ts`: happy-path (alle velden + minimal), code-auto-generatie, code-conflict-retry, user-access-denied
- `update-sprint.test.ts`: legal transities (×3), illegal transities (×3), auto-`end_date` bij CLOSE/FAIL/ARCHIVE, multi-field update, access-denied
In Scrum4Me-app: één integration-test in `__tests__/sprint-lifecycle.test.ts` die via een geseed token de MCP-tools aanroept en het Prisma-record verifieert.
## Implementatie-stappen (volgorde)
1. **`create-sprint.ts`** schrijven + registreren in `index.ts`
2. **`update-sprint.ts`** schrijven + registreren in `index.ts`
3. Unit-tests in scrum4me-mcp
4. `npm run verify` in scrum4me-mcp (typecheck + tests)
5. **Sync naar Scrum4Me-app:** `sync-schema.sh` is voor Prisma-schema; voor tool-discovery hoeft niets — MCP is een aparte service en de Scrum4Me-app importeert niets uit `scrum4me-mcp/src/tools/`
6. Update [docs/runbooks/mcp-integration.md](../runbooks/mcp-integration.md): voeg de twee tools toe aan de tool-lijst
7. Update [docs/runbooks/plan-to-pbi-flow.md](../runbooks/plan-to-pbi-flow.md): verwijder de ⚠️-tooling-banner; status van `draft``active`
8. PR-flow zoals gewend (branch-and-commit-runbook)
## Open vragen — uitgesteld tot later
Bewust pas later beslissen (niet blokkerend voor de eerste implementatie):
- **`code`-conventie** — voor v1 default `S-{YYYY-MM-DD}-{N}`; later evalueren of `S-{N}` doorlopend per product (zoals PBI-N) beter past
- **Cron-drempels** — pas relevant in de vervolg-PBI voor de cron zelf
- **`update_sprint` zonder status-wijziging** — toegestaan (alle velden optioneel; refine eist minstens één)
## Risico's
- **Multi-sprint-context** bij `create_story`: nu impliciet (server resolveert "active sprint"). Met meerdere OPEN-sprints kan dit fout gaan. Mitigatie: korte termijn → het runbook waarschuwt, gebruiker bevestigt; lange termijn → expliciete `sprint_id` param in `create_story`.
- **Cron racet met handmatige close:** als gebruiker `update_sprint(CLOSED)` doet vóór de cron, en cron daarna `FAILED` zet, overschrijft cron de eerdere status. Acceptabel voor v1 — last-write-wins. Het externe resubmit-mechanisme bepaalt of een sprint überhaupt nog door cron geraakt mag worden.
- **Demo-modus:** demo-users mogen geen schrijfacties; `requireWriteAccess` checkt al op `isDemo`, dus geen extra werk.
## Klaar wanneer
- [ ] Beide tools live in scrum4me-mcp `main`
- [ ] Tests groen
- [ ] mcp-integration.md tool-lijst bijgewerkt
- [ ] plan-to-pbi-flow.md banner weg + status `active`
- [ ] Eén end-to-end smoke-test gedraaid: create_sprint → create_pbi → ... → update_sprint(CLOSED) op een lokale dev-DB

View file

@ -163,6 +163,6 @@ push direct naar main.
- Workflow: `.github/workflows/ci.yml`
- Vercel-config: `vercel.json`
- Plan: `docs/plans/auto-pr-deploy-sync.md` Deel A
- Plan: `docs/old/plans/auto-pr-deploy-sync.md` Deel A
- Branch- & commit-strategie: [`docs/runbooks/branch-and-commit.md`](./branch-and-commit.md)
- Auto-PR-flow (toekomstig): `docs/plans/auto-pr-deploy-sync.md` Deel B
- Auto-PR-flow (toekomstig): `docs/old/plans/auto-pr-deploy-sync.md` Deel B

View file

@ -23,6 +23,12 @@ Scrum4Me heeft een eigen MCP-server in repo [`madhura68/scrum4me-mcp`](https://g
- `mcp__scrum4me__create_story``{ pbi_id, title, description?, acceptance_criteria?, priority, sort_order? }`; product_id afgeleid uit PBI; status=OPEN
- `mcp__scrum4me__create_task``{ story_id, title, description?, implementation_plan?, priority, sort_order? }`; sprint_id geërfd van story; status=TO_DO
**Sprint-lifecycle (PBI-12):**
- `mcp__scrum4me__create_sprint``{ product_id, code?, sprint_goal, start_date? }`; status start altijd op `OPEN`; code auto-gegenereerd als `S-{YYYY-MM-DD}-{N}` per product per dag als niet meegegeven; géén reuse-check op bestaande OPEN-sprints
- `mcp__scrum4me__update_sprint``{ sprint_id, status?, sprint_goal?, start_date?, end_date? }`; minimaal één veld vereist; **géén state-machine validatie** (last-write-wins, het resubmit/heropen-pad zit elders); auto-`end_date=vandaag` bij status → `CLOSED`/`FAILED`/`ARCHIVED` zonder expliciete end_date
> Wanneer en hoe deze sprint-tools in de plan-flow gebruikt worden: zie [docs/runbooks/plan-to-pbi-flow.md](./plan-to-pbi-flow.md).
> Idea-aanmaak loopt niet via MCP maar via de UI of `POST /api/ideas`. De voormalige `create_todo`-tool is verwijderd; idea-mutaties gaan via de Idea-tools onder *Idea-laag (M12)* hieronder.
**Task / story writes:**

View file

@ -0,0 +1,206 @@
---
title: "Plan → Sprint/PBI/Story/Task workflow"
status: active
audience: [ai-agent, maintainer]
language: nl
last_updated: 2026-05-11
when_to_read: "Wanneer de gebruiker een plan goedkeurt en je het werk via Scrum4Me-MCP wilt vastleggen — inclusief sprint-lifecycle."
---
# Plan → Sprint / PBI / Story / Task workflow
Hoe je een **goedgekeurd plan** omzet naar een hiërarchie van Sprint + PBI + Story + Task(s) via de Scrum4Me-MCP, zónder de taken meteen uit te voeren. Eén PBI = één increment = één sprint.
Dit is de **creatie-kant** van het werk. De **uitvoer-kant** staat in [CLAUDE.md → "Hoe werk vinden"](../../CLAUDE.md) en [docs/runbooks/mcp-integration.md → Batch-loop](./mcp-integration.md).
> Sprint-tools `create_sprint` en `update_sprint` zijn live in scrum4me-mcp (PBI-12). Tool-reference: [mcp-integration.md](./mcp-integration.md).
---
## Wanneer wel — wanneer niet
| Type werk | Sprint + PBI maken? |
|---|---|
| Nieuwe feature, refactor, UX-aanpassing, performance-fix | **Ja** |
| Bug-fix die meer dan een trivial-edit vereist | **Ja** |
| Doc-only edit (CLAUDE.md, runbook, README) | Nee — direct edit, commit, klaar |
| Typo, format-fix, dead-code verwijdering (<10 regels) | Nee |
| Spike / verkenning zonder concrete output | Nee — log eventueel als Idea (M12) |
Sprint volgt PBI: **geen PBI → geen sprint**. Twijfel? Vraag het.
---
## De vier-laagse flow
```
plan goedgekeurd
├─ create_sprint → Sprint-record (status=OPEN, start_date=vandaag)
│ │
│ └─ create_pbi → PBI-record onder dat product
│ │
│ └─ create_story → Story-record, koppelt aan de actieve sprint
│ │
│ └─ create_task (× N) → sprint_id geërfd van story
├─ stop — wachten op uitvoer-instructie
├─ … execution-fase via "Hoe werk vinden" …
└─ PR merged + verify groen → update_sprint(status=CLOSED, end_date=vandaag)
```
### 0. `create_sprint` (vóór alle andere create-calls)
```
{ product_id, code, sprint_goal, status: 'OPEN', start_date? }
```
- **`code`** — kort label, max 30 chars. Suggestie: `S-{YYYY-MM-DD}-{kebab-PBI-titel}` of een lopende teller (`S-2026-05-11-web-push`).
- **`sprint_goal`** — één regel, het increment in mensen-taal (komt uit de "Context" van het goedgekeurde plan).
- **`status`** — start op `OPEN`.
- **`start_date`** — vandaag; leeg laten als de server dit zelf invult.
- **Geen reuse:** altijd nieuw record. Bestaande OPEN-sprints van eerder werk blijven naast deze nieuwe leven; niet automatisch sluiten.
> Eén PBI per sprint is de afgesproken één-op-één-koppeling. Als een plan logisch in meerdere onafhankelijke PBI's uiteenvalt, maak je ook meerdere sprints.
### 1. `create_pbi`
```
{ product_id, title, description?, priority, sort_order? }
```
- **`title`** — korte feature-naam, geen PBI-nummer als prefix (DB kent al een id)
- **`description`** — markdown, het "wat & waarom" uit de Context-sectie van het plan
- **`priority`** — `LOW | NORMAL | HIGH` (default `NORMAL`); pas op `HIGH` zetten als de gebruiker het zelf zegt
- **`sort_order`** — leeg laten; server zet `last + 1` binnen de priority-groep
- Status start automatisch op `OPEN`
### 2. `create_story`
```
{ pbi_id, title, description?, acceptance_criteria?, priority, sort_order? }
```
- **`title`** — concreet, in user-story stijl als dat past ("Als developer wil ik …")
- **`description`** — technische context, scope-grenzen, niet-doelen
- **`acceptance_criteria`** — markdown checklist (`- [ ] …`); bepaalt wanneer de Story `DONE` is
- `product_id` wordt afgeleid uit de PBI — niet meegeven
- **Sprint-koppeling:** de story wordt aan de actieve OPEN-sprint gehangen (de zojuist aangemaakte). Als er meerdere OPEN-sprints bestaan: bevestig eerst met de gebruiker welke sprint geldt, of breidt `create_story` uit met een expliciete `sprint_id` parameter (apart PBI).
- Status start op `OPEN`
> **Eén story per PBI** is de gebruikelijke verhouding. Splits alleen op in meerdere stories als het plan logisch in onafhankelijk-shipbare delen valt — let op dat dit dan ook meerdere sprints betekent.
### 3. `create_task` (één call per taak)
```
{ story_id, title, description?, implementation_plan?, priority, sort_order? }
```
- **`title`** — werkwoord-vorm: "Implementeer …", "Verplaats …", "Voeg test toe voor …"
- **`description`** — wat de taak afdekt, in 1-3 zinnen
- **`implementation_plan`** — **belangrijk**: markdown met de daadwerkelijke stappen + file-paths + reuse-pointers; dit is wat de Implementation-agent later inleest
- **`sort_order`** — leeg laten voor de eerste call; daarna server-side `last + 1`, of expliciet meegeven om volgorde af te dwingen
- `sprint_id` wordt geërfd van de Story — niet meegeven
- Status start op `TO_DO`
> De gebruiker werkt taken af in **sort_order**. Zet voorbereidende taken (data-model, types) vóór UI-taken; tests komen ná de feature-implementatie tenzij TDD expliciet is afgesproken.
---
## Hardstop — wachten op uitvoer-instructie
Na `create_task` (de laatste): **stop**. Niet:
- ❌ Branch aanmaken
- ❌ Code wijzigen
- ❌ `update_task_status` naar `IN_PROGRESS` zetten
- ❌ `get_claude_context` aanroepen om "vast te beginnen"
De gebruiker leest de aangemaakte items, eventueel via de UI, en geeft expliciet de instructie *"voer de taken uit"* / *"pak deze story"* / *"begin met taak 1"*. Pas dan schakelt de flow over naar de **execution-loop** uit [CLAUDE.md → "Hoe werk vinden"](../../CLAUDE.md).
---
## Sprint sluiten — `update_sprint`
Na de execution-fase (laatste taak `DONE` → branch gepusht → PR aangemaakt) wordt de sprint pas `CLOSED` als **beide** condities waar zijn:
1. **PR merged op `main`** — detecteer met `gh pr view <num> --json mergedAt` (niet-leeg = merged) of een GitHub-merge-webhook
2. **Verify groen**`gh pr checks <num>` allemaal ✅, óf de bestaande [`mcp__scrum4me__verify_sprint_task`](./mcp-integration.md) tool slaagt voor de laatste taak
Pas dan:
```
update_sprint({ sprint_id, status: 'CLOSED', end_date: today })
→ status = CLOSED
```
**Wat als één van beide rood is?**
| Situatie | Handmatige flow | Cron-flow (auto) |
|---|---|---|
| PR merged, verify rood | Sprint blijft `OPEN`. Hot-fix taak/PR, daarna close-check herhalen. | Cron mag de sprint na *N* mislukte verify-runs (drempel TBD) op `FAILED` zetten. |
| Verify groen, PR niet merged | Sprint blijft `OPEN`. Wacht op review/merge. | Cron mag na *X* dagen zonder merge op `FAILED` (stale-detectie). |
| PR gesloten zonder merge | Sprint blijft `OPEN` totdat gebruiker beslist. | Cron mag direct op `FAILED` zetten — PR-`closed && !merged` is een eindstatus. |
| Werk geannuleerd door gebruiker | Sprint → `ARCHIVED` (handmatig). | Niet door cron — vereist gebruikersactie. |
> **Cron-trigger:** een geplande job mag dus zowel `CLOSED` zetten (happy-path: merge + verify groen) als `FAILED` (sad-path: stale PR, blijvend rode verify, PR-closed zonder merge). De drempels (*N*, *X*) en transitie-policy komen in het vervolg-PBI voor de MCP-tools — zie [docs/plans/sprint-mcp-tools.md](../plans/sprint-mcp-tools.md).
---
## Verhouding tot de planning-agent
[docs/plans/tweede-claude-agent-planning.md](../plans/tweede-claude-agent-planning.md) (status: `proposal`) beschrijft een **automatische planning-agent** die deze flow uit een `PLANNING`-job zelfstandig uitvoert. Tot die agent er is, doet de Claude-Code-sessie het handmatig zoals hierboven. De toolchain (`create_sprint`/`create_pbi`/`create_story`/`create_task` + `update_sprint`) blijft identiek — de agent zal dezelfde MCP-tools gebruiken.
---
## Korte voorbeeld-sessie
```
Gebruiker: "plan goedgekeurd"
Claude: create_sprint({ product_id, code: "S-2026-05-11-web-push",
sprint_goal: "Web-Push end-to-end voor open vragen",
status: "OPEN" })
→ sprint_id: 73
create_pbi({ product_id, title: "Web-Push notifications voor open vragen",
description: "<plan-context>", priority: "NORMAL" })
→ pbi_id: 142
create_story({ pbi_id: 142,
title: "PBI-142: web-push end-to-end",
description: "<scope>",
acceptance_criteria: "- [ ] Service worker geregistreerd\n- [ ] …",
priority: "NORMAL" })
→ story_id: 988 (gekoppeld aan sprint 73)
create_task({ story_id: 988, title: "Voeg VAPID-keys toe aan env-schema",
implementation_plan: "1. lib/env.ts uitbreiden …",
priority: "NORMAL" })
create_task({ story_id: 988, title: "Server action: subscribe-endpoint",
implementation_plan: "…", priority: "NORMAL" })
create_task({ story_id: 988, title: "Vitest: subscribe-endpoint smoke",
implementation_plan: "…", priority: "NORMAL" })
"Sprint 73 (OPEN), PBI 142, Story 988 met 3 taken aangemaakt.
Klaar om uit te voeren zodra je 'voer uit' zegt."
Gebruiker: "voer uit"
Claude: <execution-loop volgens 'Hoe werk vinden' branch, code, commit, push, PR>
…na PR-merge + verify groen…
Claude: update_sprint({ sprint_id: 73, status: "CLOSED", end_date: "2026-05-12" })
→ sprint 73 status = CLOSED
"Sprint 73 gesloten. Increment shipped."
```
---
## Verwante docs
- [docs/runbooks/mcp-integration.md](./mcp-integration.md) — volledige MCP-tool reference + execution-loop
- [docs/runbooks/branch-and-commit.md](./branch-and-commit.md) — git-discipline bij uitvoer
- [docs/runbooks/worker-idempotency.md](./worker-idempotency.md) — job-status protocol (uitvoer-fase)
- [docs/plans/tweede-claude-agent-planning.md](../plans/tweede-claude-agent-planning.md) — toekomstige planning-agent (proposal)

View file

@ -189,6 +189,6 @@ Volledige resolver-uitleg + override-cascade staat in
- Status-data-cleanup: `app/api/cron/cleanup-agent-artifacts/route.ts`
- KPI-aggregatie: `lib/insights/agent-throughput.ts` (terminal_7d
inclusief SKIPPED)
- Gerelateerd plan: `docs/plans/auto-pr-deploy-sync.md` Deel D
- Gerelateerd plan: `docs/old/plans/auto-pr-deploy-sync.md` Deel D
- PBI-67 resolver: `scrum4me-mcp/src/lib/job-config.ts` + `lib/job-config.ts`
(Sync-tab toont per-Story job-status incl. SKIPPED)

View file

@ -40,12 +40,20 @@ async function notifyUserSettings(
`
}
export async function getActiveSprintIdFromSettings(
userId: string,
type StoredActiveSprintState =
| { kind: 'unset' }
| { kind: 'cleared' }
| { kind: 'set'; sprintId: string }
export function readStoredActiveSprintState(
settings: UserSettings,
productId: string,
): Promise<string | null> {
const settings = await readSettings(userId)
return settings.layout?.activeSprints?.[productId] ?? null
): StoredActiveSprintState {
const map = settings.layout?.activeSprints
if (!map || !(productId in map)) return { kind: 'unset' }
const value = map[productId]
if (value === null) return { kind: 'cleared' }
return { kind: 'set', sprintId: value }
}
export async function setActiveSprintInSettings(
@ -71,10 +79,10 @@ export async function clearActiveSprintInSettings(
productId: string,
): Promise<void> {
const current = await readSettings(userId)
const existing = current.layout?.activeSprints
if (!existing || !(productId in existing)) return
const nextActiveSprints = { ...existing }
delete nextActiveSprints[productId]
const nextActiveSprints: Record<string, string | null> = {
...(current.layout?.activeSprints ?? {}),
[productId]: null,
}
const next: UserSettings = {
...current,
layout: { ...current.layout, activeSprints: nextActiveSprints },
@ -85,14 +93,69 @@ export async function clearActiveSprintInSettings(
})
}
/**
* PBI-79: persisteer sprint-keuze + bijbehorende PBI/story-selectie atomair.
* Sprintkeuze blijft 'sleutel met null = bewust geen sprint'-contract trouw;
* activePbi/activeStory volgen dezelfde semantiek (null = expliciet leeg).
*/
export async function setActiveSelectionInSettings(
userId: string,
productId: string,
selection: {
sprintId: string | null
pbiId?: string | null
storyId?: string | null
},
): Promise<void> {
const current = await readSettings(userId)
const nextActiveSprints: Record<string, string | null> = {
...(current.layout?.activeSprints ?? {}),
[productId]: selection.sprintId,
}
const nextActivePbis: Record<string, string | null> = {
...(current.layout?.activePbis ?? {}),
}
if (selection.pbiId !== undefined) {
nextActivePbis[productId] = selection.pbiId
}
const nextActiveStories: Record<string, string | null> = {
...(current.layout?.activeStories ?? {}),
}
if (selection.storyId !== undefined) {
nextActiveStories[productId] = selection.storyId
}
const next: UserSettings = {
...current,
layout: {
...current.layout,
activeSprints: nextActiveSprints,
activePbis: nextActivePbis,
activeStories: nextActiveStories,
},
}
await writeSettings(userId, next)
await notifyUserSettings(userId, {
layout: {
activeSprints: nextActiveSprints,
activePbis: nextActivePbis,
activeStories: nextActiveStories,
},
})
}
export async function resolveActiveSprint(
productId: string,
userId: string,
): Promise<ActiveSprint | null> {
const stored = await getActiveSprintIdFromSettings(userId, productId)
if (stored) {
const settings = await readSettings(userId)
const state = readStoredActiveSprintState(settings, productId)
if (state.kind === 'cleared') return null
if (state.kind === 'set') {
const sprint = await prisma.sprint.findFirst({
where: { id: stored, product_id: productId },
where: { id: state.sprintId, product_id: productId },
select: { id: true, code: true, status: true },
})
if (sprint) return sprint

116
lib/sprint-conflicts.ts Normal file
View file

@ -0,0 +1,116 @@
import type { Prisma, PrismaClient, StoryStatus } from '@prisma/client'
export type EligibilityReason = 'DONE' | 'IN_OTHER_SPRINT'
export type CrossSprintBlock = {
storyId: string
sprintId: string
sprintName: string
}
export type EligibilityPartition = {
eligible: string[]
notEligible: { storyId: string; reason: EligibilityReason }[]
crossSprint: CrossSprintBlock[]
}
type StoryEligibilityInput = {
sprint_id: string | null
status: StoryStatus
}
export function isEligibleForSprint(story: StoryEligibilityInput): boolean {
return story.sprint_id === null && story.status !== 'DONE'
}
type PrismaLike = Pick<PrismaClient, 'story'> | Prisma.TransactionClient
export async function partitionByEligibility(
prisma: PrismaLike,
storyIds: string[],
excludeSprintId?: string,
): Promise<EligibilityPartition> {
if (storyIds.length === 0) {
return { eligible: [], notEligible: [], crossSprint: [] }
}
const stories = await prisma.story.findMany({
where: { id: { in: storyIds } },
select: {
id: true,
sprint_id: true,
status: true,
sprint: { select: { id: true, code: true, status: true } },
},
})
const eligible: string[] = []
const notEligible: { storyId: string; reason: EligibilityReason }[] = []
const crossSprint: CrossSprintBlock[] = []
for (const story of stories) {
const inOtherSprint = story.sprint_id !== null && story.sprint_id !== excludeSprintId
const inSameSprint = excludeSprintId !== undefined && story.sprint_id === excludeSprintId
if (inOtherSprint) {
if (story.sprint && story.sprint.status === 'OPEN') {
crossSprint.push({
storyId: story.id,
sprintId: story.sprint.id,
sprintName: story.sprint.code,
})
}
notEligible.push({ storyId: story.id, reason: 'IN_OTHER_SPRINT' })
continue
}
if (story.status === 'DONE') {
notEligible.push({ storyId: story.id, reason: 'DONE' })
continue
}
if (inSameSprint) {
eligible.push(story.id)
continue
}
eligible.push(story.id)
}
return { eligible, notEligible, crossSprint }
}
export async function getBlockingSprintMap(
prisma: PrismaLike,
productId: string,
storyIds: string[],
excludeSprintId?: string,
): Promise<Map<string, { sprintId: string; sprintName: string }>> {
const out = new Map<string, { sprintId: string; sprintName: string }>()
if (storyIds.length === 0) return out
const stories = await prisma.story.findMany({
where: {
id: { in: storyIds },
product_id: productId,
sprint_id: { not: null },
sprint: { status: 'OPEN' },
},
select: {
id: true,
sprint_id: true,
sprint: { select: { id: true, code: true, status: true } },
},
})
for (const story of stories) {
if (!story.sprint) continue
if (excludeSprintId !== undefined && story.sprint.id === excludeSprintId) continue
out.set(story.id, {
sprintId: story.sprint.id,
sprintName: story.sprint.code,
})
}
return out
}

View file

@ -45,16 +45,41 @@ const DevToolsPrefs = z.object({
const LayoutPrefs = z.object({
splitPanePositions: z.record(z.string(), z.array(z.number())).optional(),
activeSprints: z.record(z.string(), z.string()).optional(),
activeSprints: z.record(z.string(), z.string().nullable()).optional(),
activePbis: z.record(z.string(), z.string().nullable()).optional(),
activeStories: z.record(z.string(), z.string().nullable()).optional(),
}).strict()
const PbiIntent = z.enum(['all', 'none'])
const StoryOverrides = z.object({
add: z.array(z.string()),
remove: z.array(z.string()),
}).strict()
const PendingSprintDraftSchema = z.object({
goal: z.string().min(1),
startAt: z.string().date().optional(),
endAt: z.string().date().optional(),
pbiIntent: z.record(z.string(), PbiIntent).default({}),
storyOverrides: z.record(z.string(), StoryOverrides).default({}),
}).strict()
const WorkflowPrefs = z.object({
pendingSprintDraft: z.record(z.string(), PendingSprintDraftSchema).optional(),
}).strict()
export const UserSettingsSchema = z.object({
views: ViewsPrefs.optional(),
devTools: DevToolsPrefs.optional(),
layout: LayoutPrefs.optional(),
workflow: WorkflowPrefs.optional(),
}).strict()
export type UserSettings = z.infer<typeof UserSettingsSchema>
export type PendingSprintDraft = z.infer<typeof PendingSprintDraftSchema>
export type PbiIntent = z.infer<typeof PbiIntent>
export type StoryOverrides = z.infer<typeof StoryOverrides>
export const DEFAULT_USER_SETTINGS: UserSettings = {}

View file

@ -14,8 +14,16 @@ import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(__dirname, '..');
// Directories under docs/ that are archived and may contain stale links by design.
// Their original-as-written paths are kept for historical reference, but the
// targets have since moved/been deleted. Skip them from link-checking.
const EXCLUDE_DIRS = new Set([
resolve(__dirname, '..', 'docs', 'old'),
]);
// Collect all .md files under a directory recursively
function collectMd(dir) {
if (EXCLUDE_DIRS.has(dir)) return [];
const results = [];
for (const entry of readdirSync(dir)) {
const full = resolve(dir, entry);

View file

@ -25,6 +25,7 @@ const EXCLUDE_PATTERNS = [
/^docs\/adr\/README\.md$/,
/\/_[^/]+\.md$/,
/^docs\/INDEX\.md$/,
/^docs\/old\//,
];
async function walk(dir) {
@ -121,6 +122,7 @@ async function main() {
const content = await readFile(full, 'utf8');
const { data, body } = parseFrontMatter(content);
if (data.archived === 'true') continue;
const title =
data.title || extractFirstH1(body) || basename(full, '.md');

View file

@ -1,5 +1,13 @@
import type { ProductWorkspaceStore } from './store'
import type { BacklogPbi, BacklogStory, BacklogTask, TaskDetail } from './types'
import type {
BacklogPbi,
BacklogStory,
BacklogTask,
CrossSprintBlock,
TaskDetail,
} from './types'
export type PbiTriState = 'empty' | 'partial' | 'full'
// G1: stable EMPTY-references zodat selectors geen nieuwe array per call retourneren.
const EMPTY_PBIS: BacklogPbi[] = []
@ -100,3 +108,72 @@ export function selectStoriesForPbi(
}
return out.length === 0 ? EMPTY_STORIES : out
}
// PBI-79 / ST-1336 — sprint-membership selectors.
//
// Tri-state PBI-vinkje. Werkt op counts uit het summary-endpoint zolang
// de PBI dichtgeklapt is (relations.storyIdsByPbi leeg). Wanneer stories
// geladen zijn rekenen we ook de pending-buffer mee per-story.
export function selectPbiTriState(
s: ProductWorkspaceStore,
pbiId: string,
): PbiTriState {
const summary = s.sprintMembership.pbiSummary[pbiId]
if (!summary || summary.totalStoryCount === 0) return 'empty'
const storyIds = s.relations.storyIdsByPbi[pbiId]
let inSprintAfterPending = summary.inActiveSprintStoryCount
if (storyIds && storyIds.length > 0) {
const idSet = new Set(storyIds)
const adds = s.sprintMembership.pending.adds
const removes = s.sprintMembership.pending.removes
for (const id of adds) if (idSet.has(id)) inSprintAfterPending++
for (const id of removes) if (idSet.has(id)) inSprintAfterPending--
}
if (inSprintAfterPending <= 0) return 'empty'
if (inSprintAfterPending >= summary.totalStoryCount) return 'full'
return 'partial'
}
/**
* Effectief membership van een story rekening houdend met de pending buffer.
* `activeSprintId` is de gekozen sprint (state B); zonder die context valt de
* selector terug op de DB-waarde.
*/
export function selectStoryEffectiveInSprint(
s: ProductWorkspaceStore,
storyId: string,
activeSprintId: string | null,
): boolean {
const story = s.entities.storiesById[storyId]
const inSprintDb = story?.sprint_id === activeSprintId && activeSprintId !== null
const inAdds = s.sprintMembership.pending.adds.includes(storyId)
const inRemoves = s.sprintMembership.pending.removes.includes(storyId)
if (inAdds) return true
if (inRemoves) return false
return inSprintDb
}
export function selectStoryIsBlocked(
s: ProductWorkspaceStore,
storyId: string,
): CrossSprintBlock | null {
return s.sprintMembership.crossSprintBlocks[storyId] ?? null
}
export function selectIsDirty(s: ProductWorkspaceStore): boolean {
return (
s.sprintMembership.pending.adds.length +
s.sprintMembership.pending.removes.length >
0
)
}
export function selectPendingCount(s: ProductWorkspaceStore): number {
return (
s.sprintMembership.pending.adds.length +
s.sprintMembership.pending.removes.length
)
}

View file

@ -7,12 +7,15 @@ import {
type BacklogPbi,
type BacklogStory,
type BacklogTask,
type CrossSprintBlock,
type OptimisticMutation,
type PbiSummaryEntry,
type PendingOptimisticMutation,
type ProductBacklogSnapshot,
type ProductRealtimeEvent,
type RealtimeStatus,
type ResyncReason,
type SprintMembershipSlice,
type TaskDetail,
} from './types'
import {
@ -73,6 +76,7 @@ interface State {
loading: LoadingSlice
sync: SyncSlice
pendingMutations: Record<string, PendingOptimisticMutation>
sprintMembership: SprintMembershipSlice
}
interface Actions {
@ -100,6 +104,31 @@ interface Actions {
settleMutation(mutationId: string): void
setRealtimeStatus(status: RealtimeStatus): void
// PBI-79 / ST-1336: sprint-membership acties.
setPbiSummary(summary: Record<string, PbiSummaryEntry>): void
setCrossSprintBlocks(blocks: Record<string, CrossSprintBlock>): void
toggleStorySprintMembership(storyId: string, currentlyInSprint: boolean): void
resetSprintMembershipPending(): void
fetchSprintMembershipSummary(
productId: string,
sprintId: string,
pbiIds: string[],
): Promise<void>
fetchCrossSprintBlocks(
productId: string,
excludeSprintId: string | null,
pbiIds: string[],
): Promise<void>
// PBI-79 / ST-1340: gericht patchen na server-action commit. Tasks in
// de client-store hebben geen sprint_id-veld dus alleen story-records
// worden gemuteerd.
applyMembershipCommitResult(input: {
activeSprintId: string
addedStoryIds: string[]
removedStoryIds: string[]
}): void
}
export type ProductWorkspaceStore = State & Actions
@ -136,6 +165,12 @@ const initialState: State = {
resyncReason: null,
},
pendingMutations: {},
sprintMembership: {
pbiSummary: {},
crossSprintBlocks: {},
pending: { adds: [], removes: [] },
loadedSummaryForSprintId: null,
},
}
function comparePbi(a: BacklogPbi, b: BacklogPbi): number {
@ -194,6 +229,12 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
s.entities.storiesById = {}
s.entities.tasksById = {}
s.relations.pbiIds = []
s.sprintMembership = {
pbiSummary: {},
crossSprintBlocks: {},
pending: { adds: [], removes: [] },
loadedSummaryForSprintId: null,
}
s.relations.storyIdsByPbi = {}
s.relations.taskIdsByStory = {}
@ -293,10 +334,15 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
await get().ensurePbiLoaded(pbiId, requestId)
if (get().loading.activeRequestId !== requestId) return
if (!productId) return
// T-857: cascade-restore
// T-857: cascade-restore. Alleen herstellen als de hint-story
// bij de nieuw-geselecteerde PBI hoort — anders blijft een task-
// selectie van een vorige PBI hangen (PBI-79 bugfix).
const hint = readHints().perProduct[productId]?.lastActiveStoryId
if (hint && get().entities.storiesById[hint]) {
get().setActiveStory(hint)
if (hint) {
const hintStory = get().entities.storiesById[hint]
if (hintStory && hintStory.pbi_id === pbiId) {
get().setActiveStory(hint)
}
}
})()
}
@ -566,6 +612,102 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
s.sync.realtimeStatus = status
})
},
setPbiSummary(summary) {
set((s) => {
s.sprintMembership.pbiSummary = summary
})
},
setCrossSprintBlocks(blocks) {
set((s) => {
s.sprintMembership.crossSprintBlocks = blocks
})
},
toggleStorySprintMembership(storyId, currentlyInSprint) {
set((s) => {
const pending = s.sprintMembership.pending
if (currentlyInSprint) {
const inRemoves = pending.removes.indexOf(storyId)
if (inRemoves >= 0) {
pending.removes.splice(inRemoves, 1)
} else {
const inAdds = pending.adds.indexOf(storyId)
if (inAdds >= 0) pending.adds.splice(inAdds, 1)
pending.removes.push(storyId)
}
} else {
const inAdds = pending.adds.indexOf(storyId)
if (inAdds >= 0) {
pending.adds.splice(inAdds, 1)
} else {
const inRemoves = pending.removes.indexOf(storyId)
if (inRemoves >= 0) pending.removes.splice(inRemoves, 1)
pending.adds.push(storyId)
}
}
})
},
resetSprintMembershipPending() {
set((s) => {
s.sprintMembership.pending = { adds: [], removes: [] }
})
},
async fetchSprintMembershipSummary(productId, sprintId, pbiIds) {
if (pbiIds.length === 0) return
const url = `/api/products/${productId}/sprint-membership-summary?sprintId=${encodeURIComponent(sprintId)}&pbiIds=${pbiIds.map(encodeURIComponent).join(',')}`
const summary = await fetchJson<Record<string, PbiSummaryEntry>>(url)
set((s) => {
for (const [pbiId, entry] of Object.entries(summary)) {
s.sprintMembership.pbiSummary[pbiId] = entry
}
s.sprintMembership.loadedSummaryForSprintId = sprintId
})
},
async fetchCrossSprintBlocks(productId, excludeSprintId, pbiIds) {
if (pbiIds.length === 0) return
const params = new URLSearchParams()
if (excludeSprintId) params.set('excludeSprintId', excludeSprintId)
params.set('pbiIds', pbiIds.join(','))
const url = `/api/products/${productId}/cross-sprint-blocks?${params.toString()}`
const blocks = await fetchJson<Record<string, CrossSprintBlock>>(url)
set((s) => {
for (const [storyId, info] of Object.entries(blocks)) {
s.sprintMembership.crossSprintBlocks[storyId] = info
}
})
},
applyMembershipCommitResult({
activeSprintId,
addedStoryIds,
removedStoryIds,
}) {
// Task-records in de client-store hebben geen sprint_id-veld (alleen
// story_id); de sprint-membership wordt afgeleid via story.sprint_id.
// Hier patchen we daarom alleen story-entities + de pending buffer.
set((s) => {
for (const id of addedStoryIds) {
const story = s.entities.storiesById[id]
if (story) {
story.sprint_id = activeSprintId
story.status = 'IN_SPRINT'
}
}
for (const id of removedStoryIds) {
const story = s.entities.storiesById[id]
if (story) {
story.sprint_id = null
story.status = 'OPEN'
}
}
s.sprintMembership.pending = { adds: [], removes: [] }
})
},
})),
)

View file

@ -138,3 +138,21 @@ export interface PendingOptimisticMutation {
mutation: OptimisticMutation
createdAt: number
}
// PBI-79 / ST-1336: sprint-membership state voor backlog-page.
export interface PbiSummaryEntry {
totalStoryCount: number
inActiveSprintStoryCount: number
}
export interface CrossSprintBlock {
sprintId: string
sprintName: string
}
export interface SprintMembershipSlice {
pbiSummary: Record<string, PbiSummaryEntry>
crossSprintBlocks: Record<string, CrossSprintBlock>
pending: { adds: string[]; removes: string[] }
loadedSummaryForSprintId: string | null
}

View file

@ -4,6 +4,8 @@ import { immer } from 'zustand/middleware/immer'
import {
DEFAULT_USER_SETTINGS,
mergeSettings,
type PbiIntent,
type PendingSprintDraft,
type UserSettings,
} from '@/lib/user-settings'
import { updateUserSettingsAction } from '@/actions/user-settings'
@ -28,6 +30,22 @@ interface UserSettingsActions {
hydrate: (initial: UserSettings, isDemo: boolean) => void
setPref: (path: SettingsPath, value: unknown) => Promise<void>
applyServerPatch: (patch: Partial<UserSettings>) => void
setPendingSprintDraft: (
productId: string,
draft: PendingSprintDraft,
) => Promise<void>
clearPendingSprintDraft: (productId: string) => Promise<void>
upsertPbiIntent: (
productId: string,
pbiId: string,
intent: PbiIntent,
) => Promise<void>
upsertStoryOverride: (
productId: string,
pbiId: string,
storyId: string,
kind: 'add' | 'remove' | 'clear',
) => Promise<void>
}
let nextMutationId = 1
@ -58,7 +76,15 @@ export const useUserSettingsStore = create<UserSettingsState & UserSettingsActio
hydrate: (initial, isDemo) => {
set((draft) => {
draft.entities.settings = initial as UserSettings
// PBI-79 scope-aanpassing: pendingSprintDraft is session-only;
// eventuele legacy DB-entries van vóór deze aanpassing worden bij
// hydratatie weggegooid zodat de draft niet 'spookt'.
const stripped: UserSettings = { ...initial }
if (stripped.workflow?.pendingSprintDraft) {
stripped.workflow = { ...stripped.workflow }
delete stripped.workflow.pendingSprintDraft
}
draft.entities.settings = stripped
draft.context.hydrated = true
draft.context.isDemo = isDemo
})
@ -73,6 +99,79 @@ export const useUserSettingsStore = create<UserSettingsState & UserSettingsActio
})
},
setPendingSprintDraft: async (productId, draft) => {
// PBI-79 scope-aanpassing: session-only. Geen server-roundtrip;
// de draft leeft uitsluitend in deze store-instantie en is bij
// page-refresh/leave weg (zie SprintDraftLeaveGuard voor de
// beforeunload-warning).
set((s) => {
if (!s.entities.settings.workflow) s.entities.settings.workflow = {}
if (!s.entities.settings.workflow.pendingSprintDraft) {
s.entities.settings.workflow.pendingSprintDraft = {}
}
s.entities.settings.workflow.pendingSprintDraft[productId] = draft
})
},
clearPendingSprintDraft: async (productId) => {
// PBI-79 scope-aanpassing: session-only — lokale delete is voldoende.
set((s) => {
const map = s.entities.settings.workflow?.pendingSprintDraft
if (map) delete map[productId]
})
},
upsertPbiIntent: async (productId, pbiId, intent) => {
const current =
get().entities.settings.workflow?.pendingSprintDraft?.[productId]
if (!current) return
const nextOverrides = { ...current.storyOverrides }
delete nextOverrides[pbiId]
const next: PendingSprintDraft = {
...current,
pbiIntent: { ...current.pbiIntent, [pbiId]: intent },
storyOverrides: nextOverrides,
}
await get().setPendingSprintDraft(productId, next)
},
upsertStoryOverride: async (productId, pbiId, storyId, kind) => {
const current =
get().entities.settings.workflow?.pendingSprintDraft?.[productId]
if (!current) return
const existing = current.storyOverrides[pbiId] ?? { add: [], remove: [] }
const dropFrom = (arr: string[]) => arr.filter((id) => id !== storyId)
let nextEntry: { add: string[]; remove: string[] }
switch (kind) {
case 'add':
nextEntry = {
add: existing.add.includes(storyId) ? existing.add : [...existing.add, storyId],
remove: dropFrom(existing.remove),
}
break
case 'remove':
nextEntry = {
add: dropFrom(existing.add),
remove: existing.remove.includes(storyId)
? existing.remove
: [...existing.remove, storyId],
}
break
case 'clear':
default:
nextEntry = { add: dropFrom(existing.add), remove: dropFrom(existing.remove) }
break
}
const nextOverrides = { ...current.storyOverrides }
if (nextEntry.add.length === 0 && nextEntry.remove.length === 0) {
delete nextOverrides[pbiId]
} else {
nextOverrides[pbiId] = nextEntry
}
const next: PendingSprintDraft = { ...current, storyOverrides: nextOverrides }
await get().setPendingSprintDraft(productId, next)
},
setPref: async (path, value) => {
const patch = patchFromPath(path, value)