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:
parent
e89fb7149f
commit
89c2356ff9
5 changed files with 544 additions and 1 deletions
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,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,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
|
||||
}
|
||||
})
|
||||
},
|
||||
})),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue