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>
179 lines
5.5 KiB
TypeScript
179 lines
5.5 KiB
TypeScript
import type { ProductWorkspaceStore } from './store'
|
|
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[] = []
|
|
const EMPTY_STORIES: BacklogStory[] = []
|
|
const EMPTY_TASKS: (BacklogTask | TaskDetail)[] = []
|
|
|
|
/**
|
|
* Lijst-selector. Vereist `useShallow` in componenten (G2) — anders re-rendert
|
|
* elke ongerelateerde store-mutatie het component.
|
|
*/
|
|
export function selectVisiblePbis(s: ProductWorkspaceStore): BacklogPbi[] {
|
|
if (s.relations.pbiIds.length === 0) return EMPTY_PBIS
|
|
const out: BacklogPbi[] = []
|
|
for (const id of s.relations.pbiIds) {
|
|
const pbi = s.entities.pbisById[id]
|
|
if (pbi) out.push(pbi)
|
|
}
|
|
return out.length === 0 ? EMPTY_PBIS : out
|
|
}
|
|
|
|
/**
|
|
* Lijst-selector. Vereist `useShallow` in componenten (G2).
|
|
*/
|
|
export function selectStoriesForActivePbi(s: ProductWorkspaceStore): BacklogStory[] {
|
|
const pbiId = s.context.activePbiId
|
|
if (!pbiId) return EMPTY_STORIES
|
|
const ids = s.relations.storyIdsByPbi[pbiId]
|
|
if (!ids || ids.length === 0) return EMPTY_STORIES
|
|
const out: BacklogStory[] = []
|
|
for (const id of ids) {
|
|
const story = s.entities.storiesById[id]
|
|
if (story) out.push(story)
|
|
}
|
|
return out.length === 0 ? EMPTY_STORIES : out
|
|
}
|
|
|
|
/**
|
|
* Lijst-selector. Vereist `useShallow` in componenten (G2).
|
|
*/
|
|
export function selectTasksForActiveStory(
|
|
s: ProductWorkspaceStore,
|
|
): (BacklogTask | TaskDetail)[] {
|
|
const storyId = s.context.activeStoryId
|
|
if (!storyId) return EMPTY_TASKS
|
|
const ids = s.relations.taskIdsByStory[storyId]
|
|
if (!ids || ids.length === 0) return EMPTY_TASKS
|
|
const out: (BacklogTask | TaskDetail)[] = []
|
|
for (const id of ids) {
|
|
const task = s.entities.tasksById[id]
|
|
if (task) out.push(task)
|
|
}
|
|
return out.length === 0 ? EMPTY_TASKS : out
|
|
}
|
|
|
|
/**
|
|
* Single-value selector. `useShallow` niet vereist — retourneert stable
|
|
* entity-reference (zelfde object zolang entity ongewijzigd).
|
|
*/
|
|
export function selectActivePbi(s: ProductWorkspaceStore): BacklogPbi | null {
|
|
const id = s.context.activePbiId
|
|
if (!id) return null
|
|
return s.entities.pbisById[id] ?? null
|
|
}
|
|
|
|
/**
|
|
* Single-value selector. `useShallow` niet vereist.
|
|
*/
|
|
export function selectActiveStory(s: ProductWorkspaceStore): BacklogStory | null {
|
|
const id = s.context.activeStoryId
|
|
if (!id) return null
|
|
return s.entities.storiesById[id] ?? null
|
|
}
|
|
|
|
/**
|
|
* Single-value selector. `useShallow` niet vereist.
|
|
*/
|
|
export function selectActiveTask(
|
|
s: ProductWorkspaceStore,
|
|
): BacklogTask | TaskDetail | null {
|
|
const id = s.context.activeTaskId
|
|
if (!id) return null
|
|
return s.entities.tasksById[id] ?? null
|
|
}
|
|
|
|
/**
|
|
* Single-value selector voor stories binnen een specifiek PBI (niet per se actief).
|
|
*/
|
|
export function selectStoriesForPbi(
|
|
s: ProductWorkspaceStore,
|
|
pbiId: string,
|
|
): BacklogStory[] {
|
|
const ids = s.relations.storyIdsByPbi[pbiId]
|
|
if (!ids || ids.length === 0) return EMPTY_STORIES
|
|
const out: BacklogStory[] = []
|
|
for (const id of ids) {
|
|
const story = s.entities.storiesById[id]
|
|
if (story) out.push(story)
|
|
}
|
|
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
|
|
)
|
|
}
|