Scrum4Me/stores/product-workspace/selectors.ts
Madhura68 89c2356ff9 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>
2026-05-11 13:54:08 +02:00

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
)
}