import { create } from 'zustand' import { immer } from 'zustand/middleware/immer' import { isDetail, type ActiveProduct, 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 { readHints, writePbiHint, writeProductHint, writeStoryHint, writeTaskHint, } from './restore' import { normalizeBacklogStory, normalizeBacklogTask, normalizeProductBacklogSnapshot, normalizePbiStatusForStore, normalizeStoryStatusForStore, normalizeTaskStatusForStore, } from '@/stores/workspace-status-adapter' interface ContextSlice { activeProduct: ActiveProduct | null activePbiId: string | null activeStoryId: string | null activeTaskId: string | null } interface EntitiesSlice { pbisById: Record storiesById: Record tasksById: Record } interface RelationsSlice { pbiIds: string[] storyIdsByPbi: Record taskIdsByStory: Record } interface LoadingSlice { loadedProductId: string | null loadingProductId: string | null loadedPbiIds: Record loadedStoryIds: Record loadedTaskIds: Record activeRequestId: string | null } interface SyncSlice { realtimeStatus: RealtimeStatus lastEventAt: number | null lastResyncAt: number | null resyncReason: ResyncReason | null } interface State { context: ContextSlice entities: EntitiesSlice relations: RelationsSlice loading: LoadingSlice sync: SyncSlice pendingMutations: Record sprintMembership: SprintMembershipSlice } interface Actions { hydrateSnapshot(snapshot: ProductBacklogSnapshot): void setActiveProduct( product: ActiveProduct | null, options?: { load?: boolean; preserveSelection?: boolean }, ): void setActivePbi(pbiId: string | null): void setActiveStory(storyId: string | null): void setActiveTask(taskId: string | null): void ensureProductLoaded(productId: string, requestId?: string): Promise ensurePbiLoaded(pbiId: string, requestId?: string): Promise ensureStoryLoaded(storyId: string, requestId?: string): Promise ensureTaskLoaded(taskId: string, requestId?: string): Promise applyRealtimeEvent(event: ProductRealtimeEvent | Record): void resyncActiveScopes(reason: ResyncReason): Promise resyncLoadedScopes(reason: ResyncReason): Promise applyOptimisticMutation(mutation: OptimisticMutation): string rollbackMutation(mutationId: string): void 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 // PBI-79 / ST-1340: gericht patchen na server-action commit. Tasks in // de client-store hebben geen sprint_id-veld dus alleen story-records // worden gemuteerd. applyMembershipCommitResult(input: { activeSprintId: string addedStoryIds: string[] removedStoryIds: string[] }): void } export type ProductWorkspaceStore = State & Actions const initialState: State = { context: { activeProduct: null, activePbiId: null, activeStoryId: null, activeTaskId: null, }, entities: { pbisById: {}, storiesById: {}, tasksById: {}, }, relations: { pbiIds: [], storyIdsByPbi: {}, taskIdsByStory: {}, }, loading: { loadedProductId: null, loadingProductId: null, loadedPbiIds: {}, loadedStoryIds: {}, loadedTaskIds: {}, activeRequestId: null, }, sync: { realtimeStatus: 'connecting', lastEventAt: null, lastResyncAt: null, resyncReason: null, }, pendingMutations: {}, sprintMembership: { pbiSummary: {}, crossSprintBlocks: {}, pending: { adds: [], removes: [] }, loadedSummaryForSprintId: null, }, } function comparePbi(a: BacklogPbi, b: BacklogPbi): number { if (a.priority !== b.priority) return a.priority - b.priority if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order return new Date(a.created_at).getTime() - new Date(b.created_at).getTime() } function compareStory(a: BacklogStory, b: BacklogStory): number { if (a.priority !== b.priority) return a.priority - b.priority if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order return new Date(a.created_at).getTime() - new Date(b.created_at).getTime() } function compareTask(a: BacklogTask, b: BacklogTask): number { if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order return new Date(a.created_at).getTime() - new Date(b.created_at).getTime() } function newRequestId(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID() } return `${Date.now()}-${Math.random().toString(36).slice(2)}` } function isKnownEntity(entity: unknown): entity is 'pbi' | 'story' | 'task' { return entity === 'pbi' || entity === 'story' || entity === 'task' } function isUnknownEntityEvent(p: Record): boolean { if (typeof p.entity !== 'string') return false if (isKnownEntity(p.entity)) return false if ('type' in p) return false return true } async function fetchJson(url: string, init?: RequestInit): Promise { const response = await fetch(url, { cache: 'no-store', ...init }) if (!response.ok) { throw new Error(`Fetch ${url} failed with ${response.status}`) } return (await response.json()) as T } export const useProductWorkspaceStore = create()( immer((set, get) => ({ ...initialState, hydrateSnapshot(inputSnapshot) { const snapshot = normalizeProductBacklogSnapshot(inputSnapshot) set((s) => { if (snapshot.product) s.context.activeProduct = snapshot.product s.entities.pbisById = {} s.entities.storiesById = {} s.entities.tasksById = {} s.relations.pbiIds = [] s.sprintMembership = { pbiSummary: {}, crossSprintBlocks: {}, pending: { adds: [], removes: [] }, loadedSummaryForSprintId: null, } s.relations.storyIdsByPbi = {} s.relations.taskIdsByStory = {} for (const pbi of snapshot.pbis) { s.entities.pbisById[pbi.id] = pbi } s.relations.pbiIds = [...snapshot.pbis].sort(comparePbi).map((p) => p.id) for (const [pbiId, stories] of Object.entries(snapshot.storiesByPbi)) { for (const story of stories) { s.entities.storiesById[story.id] = story } s.relations.storyIdsByPbi[pbiId] = [...stories] .sort(compareStory) .map((st) => st.id) } for (const [storyId, tasks] of Object.entries(snapshot.tasksByStory)) { for (const task of tasks) { s.entities.tasksById[task.id] = task } s.relations.taskIdsByStory[storyId] = [...tasks] .sort(compareTask) .map((t) => t.id) } if (snapshot.product) { s.loading.loadedProductId = snapshot.product.id } }) }, setActiveProduct(product, options) { const requestId = newRequestId() const productChanged = get().context.activeProduct?.id !== product?.id const shouldResetSelection = productChanged || !options?.preserveSelection set((s) => { s.context.activeProduct = product if (shouldResetSelection) { s.context.activePbiId = null s.context.activeStoryId = null s.context.activeTaskId = null } s.loading.activeRequestId = requestId if (productChanged) { s.entities.pbisById = {} s.entities.storiesById = {} s.entities.tasksById = {} s.relations.pbiIds = [] s.relations.storyIdsByPbi = {} s.relations.taskIdsByStory = {} s.loading.loadedProductId = null s.loading.loadedPbiIds = {} s.loading.loadedStoryIds = {} s.loading.loadedTaskIds = {} } }) // T-858: persisteer product-hint zodat een volgende cold reload deze // selectie kan herstellen. T-857: restore-flow start na ensureProductLoaded. writeProductHint(product?.id ?? null) if (product && options?.load !== false) { const productId = product.id void (async () => { await get().ensureProductLoaded(productId, requestId) if (get().loading.activeRequestId !== requestId) return // T-857: cascade-restore — alleen toepassen als hint-id nog in // entities zit (entiteit accessible). const hint = readHints().perProduct[productId]?.lastActivePbiId if (hint && get().entities.pbisById[hint]) { get().setActivePbi(hint) } })() } }, setActivePbi(pbiId) { const requestId = newRequestId() const productId = get().context.activeProduct?.id ?? null set((s) => { s.context.activePbiId = pbiId s.context.activeStoryId = null s.context.activeTaskId = null s.loading.activeRequestId = requestId }) // T-858: persisteer pbi-hint per product. null wist child-hints (zie // restore.ts writePbiHint). if (productId) writePbiHint(productId, pbiId) if (pbiId) { void (async () => { await get().ensurePbiLoaded(pbiId, requestId) if (get().loading.activeRequestId !== requestId) return if (!productId) return // T-857: cascade-restore. Alleen herstellen als de hint-story // bij de nieuw-geselecteerde PBI hoort — anders blijft een task- // selectie van een vorige PBI hangen (PBI-79 bugfix). const hint = readHints().perProduct[productId]?.lastActiveStoryId if (hint) { const hintStory = get().entities.storiesById[hint] if (hintStory && hintStory.pbi_id === pbiId) { get().setActiveStory(hint) } } })() } }, setActiveStory(storyId) { const requestId = newRequestId() const productId = get().context.activeProduct?.id ?? null set((s) => { s.context.activeStoryId = storyId s.context.activeTaskId = null s.loading.activeRequestId = requestId }) if (productId) writeStoryHint(productId, storyId) if (storyId) { void (async () => { await get().ensureStoryLoaded(storyId, requestId) if (get().loading.activeRequestId !== requestId) return if (!productId) return const hint = readHints().perProduct[productId]?.lastActiveTaskId if (hint && get().entities.tasksById[hint]) { get().setActiveTask(hint) } })() } }, setActiveTask(taskId) { const productId = get().context.activeProduct?.id ?? null set((s) => { s.context.activeTaskId = taskId }) if (productId) writeTaskHint(productId, taskId) if (taskId) { void get().ensureTaskLoaded(taskId) } }, async ensureProductLoaded(productId, requestId) { set((s) => { s.loading.loadingProductId = productId }) try { const snapshot = await fetchJson( `/api/products/${encodeURIComponent(productId)}/backlog`, ) if (requestId && get().loading.activeRequestId !== requestId) return if (!snapshot || !Array.isArray(snapshot.pbis)) return get().hydrateSnapshot(snapshot) set((s) => { s.loading.loadedProductId = productId for (const pbi of snapshot.pbis) { s.loading.loadedPbiIds[pbi.id] = true } }) } finally { set((s) => { if (s.loading.loadingProductId === productId) { s.loading.loadingProductId = null } }) } }, async ensurePbiLoaded(pbiId, requestId) { const stories = await fetchJson( `/api/pbis/${encodeURIComponent(pbiId)}/stories`, ) if (requestId && get().loading.activeRequestId !== requestId) return if (!Array.isArray(stories)) return const normalizedStories = stories.map(normalizeBacklogStory) set((s) => { for (const story of normalizedStories) { s.entities.storiesById[story.id] = story } s.relations.storyIdsByPbi[pbiId] = [...normalizedStories] .sort(compareStory) .map((st) => st.id) s.loading.loadedPbiIds[pbiId] = true }) }, async ensureStoryLoaded(storyId, requestId) { const tasks = await fetchJson( `/api/stories/${encodeURIComponent(storyId)}/tasks`, ) if (requestId && get().loading.activeRequestId !== requestId) return if (!Array.isArray(tasks)) return const normalizedTasks = tasks.map(normalizeBacklogTask) set((s) => { for (const task of normalizedTasks) { const existing = s.entities.tasksById[task.id] if (existing && isDetail(existing)) { s.entities.tasksById[task.id] = { ...existing, ...task } } else { s.entities.tasksById[task.id] = task } } s.relations.taskIdsByStory[storyId] = [...normalizedTasks] .sort(compareTask) .map((t) => t.id) s.loading.loadedStoryIds[storyId] = true }) }, async ensureTaskLoaded(taskId, requestId) { const detail = await fetchJson( `/api/tasks/${encodeURIComponent(taskId)}`, ) if (requestId && get().loading.activeRequestId !== requestId) return if (!detail || typeof detail !== 'object') return const normalizedDetail = normalizeBacklogTask(detail) set((s) => { s.entities.tasksById[taskId] = { ...normalizedDetail, _detail: true } s.loading.loadedTaskIds[taskId] = true }) }, applyRealtimeEvent(event) { const payload = event as Record const activeProductId = get().context.activeProduct?.id ?? null set((s) => { s.sync.lastEventAt = Date.now() }) if ( typeof payload.product_id === 'string' && activeProductId && payload.product_id !== activeProductId ) { return } if (isUnknownEntityEvent(payload)) { if (payload.product_id === activeProductId) { void get().resyncActiveScopes('unknown-event') } return } const entity = payload.entity const op = payload.op if (!isKnownEntity(entity)) return if (op !== 'I' && op !== 'U' && op !== 'D') return const id = payload.id if (typeof id !== 'string') return if (entity === 'pbi') { applyPbiEvent(id, op, payload, set, get) } else if (entity === 'story') { applyStoryEvent(id, op, payload, set, get) } else if (entity === 'task') { applyTaskEvent(id, op, payload, set, get) } }, async resyncActiveScopes(reason) { const ctx = get().context const tasks: Promise[] = [] if (ctx.activeProduct?.id) { tasks.push(get().ensureProductLoaded(ctx.activeProduct.id)) } if (ctx.activePbiId) tasks.push(get().ensurePbiLoaded(ctx.activePbiId)) if (ctx.activeStoryId) tasks.push(get().ensureStoryLoaded(ctx.activeStoryId)) if (ctx.activeTaskId) tasks.push(get().ensureTaskLoaded(ctx.activeTaskId)) set((s) => { s.sync.lastResyncAt = Date.now() s.sync.resyncReason = reason }) await Promise.allSettled(tasks) }, async resyncLoadedScopes(reason) { const loading = get().loading const tasks: Promise[] = [] if (loading.loadedProductId) { tasks.push(get().ensureProductLoaded(loading.loadedProductId)) } for (const pbiId of Object.keys(loading.loadedPbiIds)) { tasks.push(get().ensurePbiLoaded(pbiId)) } for (const storyId of Object.keys(loading.loadedStoryIds)) { tasks.push(get().ensureStoryLoaded(storyId)) } for (const taskId of Object.keys(loading.loadedTaskIds)) { tasks.push(get().ensureTaskLoaded(taskId)) } set((s) => { s.sync.lastResyncAt = Date.now() s.sync.resyncReason = reason }) await Promise.allSettled(tasks) }, applyOptimisticMutation(mutation) { const id = newRequestId() set((s) => { s.pendingMutations[id] = { id, mutation, createdAt: Date.now(), } switch (mutation.kind) { case 'pbi-order': // store-call passes new order via separate set, snapshot is prevPbiIds break case 'entity-patch': break } }) return id }, rollbackMutation(mutationId) { const pending = get().pendingMutations[mutationId] if (!pending) return const { mutation } = pending set((s) => { switch (mutation.kind) { case 'pbi-order': s.relations.pbiIds = [...mutation.prevPbiIds] break case 'entity-patch': { const { entity, id, prev } = mutation if (prev) { if (entity === 'pbi') s.entities.pbisById[id] = prev as BacklogPbi else if (entity === 'story') s.entities.storiesById[id] = prev as BacklogStory else s.entities.tasksById[id] = prev as BacklogTask | TaskDetail } else { if (entity === 'pbi') delete s.entities.pbisById[id] else if (entity === 'story') delete s.entities.storiesById[id] else delete s.entities.tasksById[id] } break } } delete s.pendingMutations[mutationId] }) }, settleMutation(mutationId) { set((s) => { delete s.pendingMutations[mutationId] }) }, setRealtimeStatus(status) { set((s) => { 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 } }) }, applyMembershipCommitResult({ activeSprintId, addedStoryIds, removedStoryIds, }) { // Task-records in de client-store hebben geen sprint_id-veld (alleen // story_id); de sprint-membership wordt afgeleid via story.sprint_id. // Hier patchen we daarom alleen story-entities + de pending buffer. set((s) => { for (const id of addedStoryIds) { const story = s.entities.storiesById[id] if (story) { story.sprint_id = activeSprintId story.status = 'IN_SPRINT' } } for (const id of removedStoryIds) { const story = s.entities.storiesById[id] if (story) { story.sprint_id = null story.status = 'OPEN' } } s.sprintMembership.pending = { adds: [], removes: [] } }) }, })), ) type ImmerSet = Parameters>[0]>[0] type ImmerGet = () => ProductWorkspaceStore function applyPbiEvent( id: string, op: 'I' | 'U' | 'D', payload: Record, set: ImmerSet, get: ImmerGet, ) { if (op === 'D') { set((s) => { const childStoryIds = s.relations.storyIdsByPbi[id] ?? [] for (const sid of childStoryIds) { const childTaskIds = s.relations.taskIdsByStory[sid] ?? [] for (const tid of childTaskIds) { delete s.entities.tasksById[tid] } delete s.relations.taskIdsByStory[sid] delete s.entities.storiesById[sid] } delete s.relations.storyIdsByPbi[id] delete s.entities.pbisById[id] s.relations.pbiIds = s.relations.pbiIds.filter((p) => p !== id) if (s.context.activePbiId === id) { s.context.activePbiId = null s.context.activeStoryId = null s.context.activeTaskId = null } }) return } if (op === 'U') { if (!get().entities.pbisById[id]) return set((s) => { const existing = s.entities.pbisById[id] if (!existing) return Object.assign(existing, sanitizePbiPayload(payload)) s.relations.pbiIds = sortPbiIds(s.entities.pbisById, s.relations.pbiIds) }) return } // I if (get().entities.pbisById[id]) return set((s) => { const pbi = coercePbiPayload(id, payload) s.entities.pbisById[id] = pbi s.relations.pbiIds.push(id) s.relations.pbiIds = sortPbiIds(s.entities.pbisById, s.relations.pbiIds) }) } function applyStoryEvent( id: string, op: 'I' | 'U' | 'D', payload: Record, set: ImmerSet, get: ImmerGet, ) { if (op === 'D') { set((s) => { const childTaskIds = s.relations.taskIdsByStory[id] ?? [] for (const tid of childTaskIds) { delete s.entities.tasksById[tid] } delete s.relations.taskIdsByStory[id] const story = s.entities.storiesById[id] delete s.entities.storiesById[id] if (story) { const ids = s.relations.storyIdsByPbi[story.pbi_id] if (ids) { s.relations.storyIdsByPbi[story.pbi_id] = ids.filter((sid) => sid !== id) } } else { for (const pbiId of Object.keys(s.relations.storyIdsByPbi)) { s.relations.storyIdsByPbi[pbiId] = s.relations.storyIdsByPbi[pbiId].filter( (sid) => sid !== id, ) } } if (s.context.activeStoryId === id) { s.context.activeStoryId = null s.context.activeTaskId = null } }) return } if (op === 'U') { const existing = get().entities.storiesById[id] if (!existing) return set((s) => { const story = s.entities.storiesById[id] if (!story) return const oldPbiId = story.pbi_id Object.assign(story, sanitizeStoryPayload(payload)) const newPbiId = story.pbi_id if (oldPbiId !== newPbiId) { const oldList = s.relations.storyIdsByPbi[oldPbiId] if (oldList) { s.relations.storyIdsByPbi[oldPbiId] = oldList.filter((sid) => sid !== id) } const targetList = s.relations.storyIdsByPbi[newPbiId] ?? [] if (!targetList.includes(id)) targetList.push(id) s.relations.storyIdsByPbi[newPbiId] = sortStoryIds(s.entities.storiesById, targetList) } else if (s.relations.storyIdsByPbi[oldPbiId]) { s.relations.storyIdsByPbi[oldPbiId] = sortStoryIds( s.entities.storiesById, s.relations.storyIdsByPbi[oldPbiId], ) } }) return } // I if (get().entities.storiesById[id]) return set((s) => { const story = coerceStoryPayload(id, payload) s.entities.storiesById[id] = story const list = s.relations.storyIdsByPbi[story.pbi_id] ?? [] list.push(id) s.relations.storyIdsByPbi[story.pbi_id] = sortStoryIds(s.entities.storiesById, list) }) } function applyTaskEvent( id: string, op: 'I' | 'U' | 'D', payload: Record, set: ImmerSet, get: ImmerGet, ) { if (op === 'D') { set((s) => { const task = s.entities.tasksById[id] delete s.entities.tasksById[id] if (task) { const ids = s.relations.taskIdsByStory[task.story_id] if (ids) { s.relations.taskIdsByStory[task.story_id] = ids.filter((tid) => tid !== id) } } else { for (const storyId of Object.keys(s.relations.taskIdsByStory)) { s.relations.taskIdsByStory[storyId] = s.relations.taskIdsByStory[storyId].filter( (tid) => tid !== id, ) } } if (s.context.activeTaskId === id) { s.context.activeTaskId = null } }) return } if (op === 'U') { const existing = get().entities.tasksById[id] if (!existing) return set((s) => { const task = s.entities.tasksById[id] if (!task) return const oldStoryId = task.story_id Object.assign(task, sanitizeTaskPayload(payload)) const newStoryId = task.story_id if (oldStoryId !== newStoryId) { const oldList = s.relations.taskIdsByStory[oldStoryId] if (oldList) { s.relations.taskIdsByStory[oldStoryId] = oldList.filter((tid) => tid !== id) } const targetList = s.relations.taskIdsByStory[newStoryId] ?? [] if (!targetList.includes(id)) targetList.push(id) s.relations.taskIdsByStory[newStoryId] = sortTaskIds(s.entities.tasksById, targetList) } else if (s.relations.taskIdsByStory[oldStoryId]) { s.relations.taskIdsByStory[oldStoryId] = sortTaskIds( s.entities.tasksById, s.relations.taskIdsByStory[oldStoryId], ) } }) return } // I if (get().entities.tasksById[id]) return set((s) => { const task = coerceTaskPayload(id, payload) s.entities.tasksById[id] = task const list = s.relations.taskIdsByStory[task.story_id] ?? [] list.push(id) s.relations.taskIdsByStory[task.story_id] = sortTaskIds(s.entities.tasksById, list) }) } function sortPbiIds(byId: Record, ids: string[]): string[] { return [...new Set(ids)] .filter((id) => byId[id] !== undefined) .sort((a, b) => comparePbi(byId[a], byId[b])) } function sortStoryIds(byId: Record, ids: string[]): string[] { return [...new Set(ids)] .filter((id) => byId[id] !== undefined) .sort((a, b) => compareStory(byId[a], byId[b])) } function sortTaskIds( byId: Record, ids: string[], ): string[] { return [...new Set(ids)] .filter((id) => byId[id] !== undefined) .sort((a, b) => compareTask(byId[a], byId[b])) } function sanitizePbiPayload(p: Record): Partial { const { entity: _e, op: _o, ...rest } = p void _e void _o if (typeof rest.status === 'string') { rest.status = normalizePbiStatusForStore(rest.status) } return rest as Partial } function sanitizeStoryPayload(p: Record): Partial { const { entity: _e, op: _o, story_status, story_sort_order, story_title, story_code, ...rest } = p void _e void _o if (rest.status === undefined && typeof story_status === 'string') { rest.status = story_status } if (rest.sort_order === undefined && typeof story_sort_order === 'number') { rest.sort_order = story_sort_order } if (rest.title === undefined && typeof story_title === 'string') { rest.title = story_title } if (rest.code === undefined && (typeof story_code === 'string' || story_code === null)) { rest.code = story_code } if (typeof rest.status === 'string') { rest.status = normalizeStoryStatusForStore(rest.status) } return rest as Partial } function sanitizeTaskPayload(p: Record): Partial { const { entity: _e, op: _o, task_status, task_sort_order, task_title, ...rest } = p void _e void _o if (rest.status === undefined && typeof task_status === 'string') { rest.status = task_status } if (rest.sort_order === undefined && typeof task_sort_order === 'number') { rest.sort_order = task_sort_order } if (rest.title === undefined && typeof task_title === 'string') { rest.title = task_title } if (typeof rest.status === 'string') { rest.status = normalizeTaskStatusForStore(rest.status) } return rest as Partial } function coercePbiPayload(id: string, p: Record): BacklogPbi { return { id, code: (p.code as string | null) ?? null, title: String(p.title ?? ''), priority: Number(p.priority ?? 4), sort_order: Number(p.sort_order ?? 0), description: (p.description as string | null | undefined) ?? null, created_at: p.created_at instanceof Date ? p.created_at : new Date(String(p.created_at ?? Date.now())), status: normalizePbiStatusForStore(String(p.status ?? 'ready')), } } function coerceStoryPayload(id: string, p: Record): BacklogStory { const status = p.status ?? p.story_status ?? 'OPEN' const sortOrder = p.sort_order ?? p.story_sort_order ?? 0 const title = p.title ?? p.story_title ?? '' const code = p.code ?? p.story_code ?? null return { id, code: (code as string | null) ?? null, title: String(title), description: (p.description as string | null | undefined) ?? null, acceptance_criteria: (p.acceptance_criteria as string | null | undefined) ?? null, priority: Number(p.priority ?? 4), sort_order: Number(sortOrder), status: normalizeStoryStatusForStore(String(status)), pbi_id: String(p.pbi_id ?? ''), sprint_id: (p.sprint_id as string | null | undefined) ?? null, created_at: p.created_at instanceof Date ? p.created_at : new Date(String(p.created_at ?? Date.now())), } } function coerceTaskPayload(id: string, p: Record): BacklogTask { const status = p.status ?? p.task_status ?? 'TO_DO' const sortOrder = p.sort_order ?? p.task_sort_order ?? 0 const title = p.title ?? p.task_title ?? '' return { id, code: (p.code as string | null | undefined) ?? null, title: String(title), description: (p.description as string | null | undefined) ?? null, priority: Number(p.priority ?? 4), sort_order: Number(sortOrder), status: normalizeTaskStatusForStore(String(status)), story_id: String(p.story_id ?? ''), created_at: p.created_at instanceof Date ? p.created_at : new Date(String(p.created_at ?? Date.now())), } }