diff --git a/__tests__/stores/product-workspace/sprint-membership.test.ts b/__tests__/stores/product-workspace/sprint-membership.test.ts new file mode 100644 index 0000000..6f271de --- /dev/null +++ b/__tests__/stores/product-workspace/sprint-membership.test.ts @@ -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 + } + }) +}) diff --git a/__tests__/stores/product-workspace/store.test.ts b/__tests__/stores/product-workspace/store.test.ts index bfa6cfd..f2db43b 100644 --- a/__tests__/stores/product-workspace/store.test.ts +++ b/__tests__/stores/product-workspace/store.test.ts @@ -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) }) } diff --git a/stores/product-workspace/selectors.ts b/stores/product-workspace/selectors.ts index 9d9e364..8a40d53 100644 --- a/stores/product-workspace/selectors.ts +++ b/stores/product-workspace/selectors.ts @@ -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 + ) +} diff --git a/stores/product-workspace/store.ts b/stores/product-workspace/store.ts index 103eb28..54955bb 100644 --- a/stores/product-workspace/store.ts +++ b/stores/product-workspace/store.ts @@ -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 + sprintMembership: SprintMembershipSlice } interface Actions { @@ -100,6 +104,22 @@ interface Actions { settleMutation(mutationId: string): void setRealtimeStatus(status: RealtimeStatus): void + + // PBI-79 / ST-1336: sprint-membership acties. + setPbiSummary(summary: Record): void + setCrossSprintBlocks(blocks: Record): void + toggleStorySprintMembership(storyId: string, currentlyInSprint: boolean): void + resetSprintMembershipPending(): void + fetchSprintMembershipSummary( + productId: string, + sprintId: string, + pbiIds: string[], + ): Promise + fetchCrossSprintBlocks( + productId: string, + excludeSprintId: string | null, + pbiIds: string[], + ): Promise } export type ProductWorkspaceStore = State & Actions @@ -136,6 +156,12 @@ const initialState: State = { resyncReason: null, }, pendingMutations: {}, + sprintMembership: { + pbiSummary: {}, + crossSprintBlocks: {}, + pending: { adds: [], removes: [] }, + loadedSummaryForSprintId: null, + }, } function comparePbi(a: BacklogPbi, b: BacklogPbi): number { @@ -194,6 +220,12 @@ export const useProductWorkspaceStore = create()( s.entities.storiesById = {} s.entities.tasksById = {} s.relations.pbiIds = [] + s.sprintMembership = { + pbiSummary: {}, + crossSprintBlocks: {}, + pending: { adds: [], removes: [] }, + loadedSummaryForSprintId: null, + } s.relations.storyIdsByPbi = {} s.relations.taskIdsByStory = {} @@ -566,6 +598,75 @@ export const useProductWorkspaceStore = create()( 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>(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>(url) + set((s) => { + for (const [storyId, info] of Object.entries(blocks)) { + s.sprintMembership.crossSprintBlocks[storyId] = info + } + }) + }, })), ) diff --git a/stores/product-workspace/types.ts b/stores/product-workspace/types.ts index d261316..1407a95 100644 --- a/stores/product-workspace/types.ts +++ b/stores/product-workspace/types.ts @@ -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 + crossSprintBlocks: Record + pending: { adds: string[]; removes: string[] } + loadedSummaryForSprintId: string | null +}