Merge remote-tracking branch 'origin/main' into claude/focused-poincare-26165a

# Conflicts:
#	docs/INDEX.md
This commit is contained in:
Janpeter Visser 2026-05-11 19:45:00 +02:00
commit 6440a0f75b
39 changed files with 5403 additions and 132 deletions

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,12 @@ 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'
@ -45,6 +50,13 @@ export function SprintSwitcher({
const [showClosed, setShowClosed] = useState(false)
const buildingSet = new Set(buildingSprintIds)
// 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
@ -54,13 +66,43 @@ export function SprintSwitcher({
function handleSwitchSprint(sprintId: string) {
if (sprintId === activeSprint?.id) 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()
}
@ -127,6 +169,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

@ -45,6 +45,7 @@ Auto-generated on 2026-05-11 from front-matter and headings.
| [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-79: Product Backlog workflow — sprint-membership via vinkjes](./plans/PBI-79-backlog-sprint-workflow.md) | — | — |
| [Queue-loop verplaatsen van Claude naar runner](./plans/queue-loop-extraction.md) | — | — |
| [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 |

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

@ -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

@ -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)