Merge remote-tracking branch 'origin/main' into claude/focused-poincare-26165a
# Conflicts: # docs/INDEX.md
This commit is contained in:
commit
6440a0f75b
39 changed files with 5403 additions and 132 deletions
103
__tests__/actions/active-sprint-action.test.ts
Normal file
103
__tests__/actions/active-sprint-action.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
290
__tests__/actions/commit-sprint-membership.test.ts
Normal file
290
__tests__/actions/commit-sprint-membership.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
300
__tests__/actions/create-sprint-with-selection.test.ts
Normal file
300
__tests__/actions/create-sprint-with-selection.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
167
__tests__/actions/sprint-draft.test.ts
Normal file
167
__tests__/actions/sprint-draft.test.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
148
__tests__/actions/update-sprint.test.ts
Normal file
148
__tests__/actions/update-sprint.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
120
__tests__/api/cross-sprint-blocks.test.ts
Normal file
120
__tests__/api/cross-sprint-blocks.test.ts
Normal 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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
121
__tests__/api/sprint-membership-summary.test.ts
Normal file
121
__tests__/api/sprint-membership-summary.test.ts
Normal 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 },
|
||||
})
|
||||
})
|
||||
})
|
||||
190
__tests__/lib/active-sprint.test.ts
Normal file
190
__tests__/lib/active-sprint.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
195
__tests__/lib/sprint-conflicts.test.ts
Normal file
195
__tests__/lib/sprint-conflicts.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
341
__tests__/stores/product-workspace/sprint-membership.test.ts
Normal file
341
__tests__/stores/product-workspace/sprint-membership.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' } } },
|
||||
|
|
|
|||
|
|
@ -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
121
actions/sprint-draft.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
74
app/api/products/[id]/cross-sprint-blocks/route.ts
Normal file
74
app/api/products/[id]/cross-sprint-blocks/route.ts
Normal 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)
|
||||
}
|
||||
87
app/api/products/[id]/sprint-membership-summary/route.ts
Normal file
87
app/api/products/[id]/sprint-membership-summary/route.ts
Normal 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)
|
||||
}
|
||||
53
components/backlog/active-selection-hydrator.tsx
Normal file
53
components/backlog/active-selection-hydrator.tsx
Normal 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
|
||||
}
|
||||
203
components/backlog/new-sprint-metadata-dialog.tsx
Normal file
203
components/backlog/new-sprint-metadata-dialog.tsx
Normal 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'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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
46
components/backlog/new-sprint-trigger.tsx
Normal file
46
components/backlog/new-sprint-trigger.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
89
components/backlog/save-sprint-button.tsx
Normal file
89
components/backlog/save-sprint-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
202
components/backlog/sprint-definition-banner.tsx
Normal file
202
components/backlog/sprint-definition-banner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
22
components/backlog/sprint-draft-banner.tsx
Normal file
22
components/backlog/sprint-draft-banner.tsx
Normal 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} />
|
||||
}
|
||||
37
components/backlog/sprint-draft-leave-guard.tsx
Normal file
37
components/backlog/sprint-draft-leave-guard.tsx
Normal 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
|
||||
}
|
||||
217
components/backlog/sprint-edit-dialog.tsx
Normal file
217
components/backlog/sprint-edit-dialog.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
649
docs/plans/PBI-79-backlog-sprint-workflow.md
Normal file
649
docs/plans/PBI-79-backlog-sprint-workflow.md
Normal 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-meegeupdate 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-meegeupdated 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)
|
||||
|
|
@ -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
116
lib/sprint-conflicts.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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 = {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [] }
|
||||
})
|
||||
},
|
||||
})),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue