feat(PBI-79/ST-1336): product-workspace sprint-membership slice + selectors

Datalaag voor de vinkje-UI van state A′ en state B.

types.ts:
- PbiSummaryEntry, CrossSprintBlock, SprintMembershipSlice toegevoegd.

store.ts:
- Nieuwe slice `sprintMembership` met pbiSummary, crossSprintBlocks,
  pending: { adds[], removes[] }, loadedSummaryForSprintId.
- Acties: setPbiSummary, setCrossSprintBlocks, toggleStorySprintMembership
  (cancel-out logic), resetSprintMembershipPending, fetchSprintMembershipSummary,
  fetchCrossSprintBlocks.
- hydrateSnapshot reset óók de membership-slice.

selectors.ts:
- selectPbiTriState (aggregate-only zolang stories niet geladen; rekent
  pending mee bij loaded PBI's).
- selectStoryEffectiveInSprint (DB ⊕ pending).
- selectStoryIsBlocked (cross-sprint hint).
- selectIsDirty, selectPendingCount.

Tests: 25 cases in nieuwe sprint-membership.test.ts dekken alle selector-
paden, toggle-cancel-out, fetch-helpers, en pbiId-scoping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-11 13:54:08 +02:00
parent e89fb7149f
commit 89c2356ff9
5 changed files with 544 additions and 1 deletions

View file

@ -0,0 +1,341 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useProductWorkspaceStore } from '@/stores/product-workspace/store'
import {
selectIsDirty,
selectPbiTriState,
selectPendingCount,
selectStoryEffectiveInSprint,
selectStoryIsBlocked,
} from '@/stores/product-workspace/selectors'
import type { BacklogStory } from '@/stores/product-workspace/types'
function resetMembership() {
useProductWorkspaceStore.setState((s) => {
s.entities.storiesById = {}
s.relations.storyIdsByPbi = {}
s.sprintMembership = {
pbiSummary: {},
crossSprintBlocks: {},
pending: { adds: [], removes: [] },
loadedSummaryForSprintId: null,
}
})
}
function seedStory(id: string, pbiId: string, sprintId: string | null): BacklogStory {
return {
id,
code: id,
title: id,
description: null,
acceptance_criteria: null,
priority: 2,
sort_order: 1,
status: sprintId ? 'IN_SPRINT' : 'OPEN',
pbi_id: pbiId,
sprint_id: sprintId,
created_at: new Date('2026-01-01'),
}
}
beforeEach(() => {
resetMembership()
})
describe('toggleStorySprintMembership', () => {
it('adds storyId to pending.adds when currently not in sprint', () => {
useProductWorkspaceStore.getState().toggleStorySprintMembership('s1', false)
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
expect(pending.adds).toEqual(['s1'])
expect(pending.removes).toEqual([])
})
it('adds storyId to pending.removes when currently in sprint', () => {
useProductWorkspaceStore.getState().toggleStorySprintMembership('s1', true)
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
expect(pending.removes).toEqual(['s1'])
expect(pending.adds).toEqual([])
})
it('cancels out: toggle add → toggle remove same story (in-sprint) clears pending', () => {
const store = useProductWorkspaceStore.getState()
store.toggleStorySprintMembership('s1', false) // adds
// Story now appears to be "in sprint" via pending; calling with true should cancel
store.toggleStorySprintMembership('s1', false) // second click with same baseline
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
expect(pending.adds).toEqual([])
expect(pending.removes).toEqual([])
})
it('removes from pending.removes when toggled back', () => {
const store = useProductWorkspaceStore.getState()
store.toggleStorySprintMembership('s1', true)
store.toggleStorySprintMembership('s1', true)
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
expect(pending.removes).toEqual([])
expect(pending.adds).toEqual([])
})
it('resetSprintMembershipPending empties both arrays', () => {
const store = useProductWorkspaceStore.getState()
store.toggleStorySprintMembership('s1', false)
store.toggleStorySprintMembership('s2', true)
store.resetSprintMembershipPending()
const pending = useProductWorkspaceStore.getState().sprintMembership.pending
expect(pending.adds).toEqual([])
expect(pending.removes).toEqual([])
})
})
describe('selectPbiTriState', () => {
function seedSummary(pbiId: string, total: number, inSprint: number) {
useProductWorkspaceStore.setState((s) => {
s.sprintMembership.pbiSummary[pbiId] = {
totalStoryCount: total,
inActiveSprintStoryCount: inSprint,
}
})
}
it('returns empty for PBI without summary', () => {
expect(
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
).toBe('empty')
})
it('returns empty when totalStoryCount == 0', () => {
seedSummary('pbi-1', 0, 0)
expect(
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
).toBe('empty')
})
it('returns full when all stories in sprint (no pending)', () => {
seedSummary('pbi-1', 3, 3)
expect(
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
).toBe('full')
})
it('returns partial when some stories in sprint', () => {
seedSummary('pbi-1', 3, 2)
expect(
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
).toBe('partial')
})
it('returns empty when inSprint == 0', () => {
seedSummary('pbi-1', 3, 0)
expect(
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
).toBe('empty')
})
it('applies pending adds when stories are loaded for the PBI', () => {
seedSummary('pbi-1', 3, 1)
useProductWorkspaceStore.setState((s) => {
s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3']
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-1')
s.entities.storiesById['s2'] = seedStory('s2', 'pbi-1', null)
s.entities.storiesById['s3'] = seedStory('s3', 'pbi-1', null)
s.sprintMembership.pending.adds = ['s2', 's3']
})
expect(
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
).toBe('full')
})
it('applies pending removes when stories are loaded for the PBI', () => {
seedSummary('pbi-1', 3, 3)
useProductWorkspaceStore.setState((s) => {
s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3']
s.sprintMembership.pending.removes = ['s2']
})
expect(
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
).toBe('partial')
})
it('ignores pending entries for stories of other PBIs', () => {
seedSummary('pbi-1', 3, 3)
useProductWorkspaceStore.setState((s) => {
s.relations.storyIdsByPbi['pbi-1'] = ['s1', 's2', 's3']
s.sprintMembership.pending.removes = ['s99'] // not in pbi-1
})
expect(
selectPbiTriState(useProductWorkspaceStore.getState(), 'pbi-1'),
).toBe('full')
})
})
describe('selectStoryEffectiveInSprint', () => {
it('returns true when story.sprint_id matches activeSprintId and no pending', () => {
useProductWorkspaceStore.setState((s) => {
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A')
})
expect(
selectStoryEffectiveInSprint(
useProductWorkspaceStore.getState(),
's1',
'sprint-A',
),
).toBe(true)
})
it('returns false when story.sprint_id is null', () => {
useProductWorkspaceStore.setState((s) => {
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', null)
})
expect(
selectStoryEffectiveInSprint(
useProductWorkspaceStore.getState(),
's1',
'sprint-A',
),
).toBe(false)
})
it('returns true when story in pending.adds even if DB says no', () => {
useProductWorkspaceStore.setState((s) => {
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', null)
s.sprintMembership.pending.adds = ['s1']
})
expect(
selectStoryEffectiveInSprint(
useProductWorkspaceStore.getState(),
's1',
'sprint-A',
),
).toBe(true)
})
it('returns false when story in pending.removes even if DB says yes', () => {
useProductWorkspaceStore.setState((s) => {
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A')
s.sprintMembership.pending.removes = ['s1']
})
expect(
selectStoryEffectiveInSprint(
useProductWorkspaceStore.getState(),
's1',
'sprint-A',
),
).toBe(false)
})
it('returns false when activeSprintId is null', () => {
useProductWorkspaceStore.setState((s) => {
s.entities.storiesById['s1'] = seedStory('s1', 'pbi-1', 'sprint-A')
})
expect(
selectStoryEffectiveInSprint(
useProductWorkspaceStore.getState(),
's1',
null,
),
).toBe(false)
})
})
describe('selectStoryIsBlocked', () => {
it('returns null when no block', () => {
expect(
selectStoryIsBlocked(useProductWorkspaceStore.getState(), 's1'),
).toBeNull()
})
it('returns block info when story is in another sprint', () => {
useProductWorkspaceStore.setState((s) => {
s.sprintMembership.crossSprintBlocks['s1'] = {
sprintId: 'sprint-x',
sprintName: 'SP-X',
}
})
expect(
selectStoryIsBlocked(useProductWorkspaceStore.getState(), 's1'),
).toEqual({ sprintId: 'sprint-x', sprintName: 'SP-X' })
})
})
describe('selectIsDirty + selectPendingCount', () => {
it('clean by default', () => {
expect(selectIsDirty(useProductWorkspaceStore.getState())).toBe(false)
expect(selectPendingCount(useProductWorkspaceStore.getState())).toBe(0)
})
it('counts adds + removes', () => {
useProductWorkspaceStore.setState((s) => {
s.sprintMembership.pending = {
adds: ['a1', 'a2'],
removes: ['r1'],
}
})
expect(selectIsDirty(useProductWorkspaceStore.getState())).toBe(true)
expect(selectPendingCount(useProductWorkspaceStore.getState())).toBe(3)
})
})
describe('fetch helpers', () => {
it('fetchSprintMembershipSummary populates store and gates by sprintId', async () => {
const originalFetch = globalThis.fetch
const responseBody = {
pbiA: { totalStoryCount: 5, inActiveSprintStoryCount: 2 },
}
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify(responseBody), { status: 200 }),
) as unknown as typeof fetch
try {
await useProductWorkspaceStore
.getState()
.fetchSprintMembershipSummary('prod-1', 'sprint-A', ['pbiA'])
const slice = useProductWorkspaceStore.getState().sprintMembership
expect(slice.pbiSummary.pbiA).toEqual({
totalStoryCount: 5,
inActiveSprintStoryCount: 2,
})
expect(slice.loadedSummaryForSprintId).toBe('sprint-A')
} finally {
globalThis.fetch = originalFetch
}
})
it('fetchCrossSprintBlocks populates store', async () => {
const originalFetch = globalThis.fetch
const responseBody = {
's1': { sprintId: 'sprint-x', sprintName: 'SP-X' },
}
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(JSON.stringify(responseBody), { status: 200 }),
) as unknown as typeof fetch
try {
await useProductWorkspaceStore
.getState()
.fetchCrossSprintBlocks('prod-1', 'sprint-A', ['pbiA'])
const slice = useProductWorkspaceStore.getState().sprintMembership
expect(slice.crossSprintBlocks['s1']).toEqual({
sprintId: 'sprint-x',
sprintName: 'SP-X',
})
} finally {
globalThis.fetch = originalFetch
}
})
it('fetchSprintMembershipSummary is a no-op for empty pbiIds', async () => {
const fetchSpy = vi.fn()
const originalFetch = globalThis.fetch
globalThis.fetch = fetchSpy as unknown as typeof fetch
try {
await useProductWorkspaceStore
.getState()
.fetchSprintMembershipSummary('prod-1', 'sprint-A', [])
expect(fetchSpy).not.toHaveBeenCalled()
} finally {
globalThis.fetch = originalFetch
}
})
})

View file

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

View file

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

View file

@ -7,12 +7,15 @@ import {
type BacklogPbi,
type BacklogStory,
type BacklogTask,
type CrossSprintBlock,
type OptimisticMutation,
type PbiSummaryEntry,
type PendingOptimisticMutation,
type ProductBacklogSnapshot,
type ProductRealtimeEvent,
type RealtimeStatus,
type ResyncReason,
type SprintMembershipSlice,
type TaskDetail,
} from './types'
import {
@ -73,6 +76,7 @@ interface State {
loading: LoadingSlice
sync: SyncSlice
pendingMutations: Record<string, PendingOptimisticMutation>
sprintMembership: SprintMembershipSlice
}
interface Actions {
@ -100,6 +104,22 @@ 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>
}
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<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 = {}
@ -566,6 +598,75 @@ 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
}
})
},
})),
)

View file

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