import { create } from 'zustand' import { immer } from 'zustand/middleware/immer' import { isDetail, type ActiveProductRef, type OptimisticMutation, type PendingOptimisticMutation, type RealtimeStatus, type ResyncReason, type SprintWorkspaceSnapshot, type SprintWorkspaceSprint, type SprintWorkspaceStory, type SprintWorkspaceTask, type SprintWorkspaceTaskDetail, } from './types' import { readHints, writeProductHint, writeSprintHint, writeStoryHint, writeTaskHint, } from './restore' import { normalizeSprintTask, normalizeSprintWorkspaceSnapshot, normalizeStoryStatusForStore, normalizeTaskStatusForStore, } from '@/stores/workspace-status-adapter' interface ContextSlice { activeProduct: ActiveProductRef | null activeSprintId: string | null activeStoryId: string | null activeTaskId: string | null } interface EntitiesSlice { sprintsById: Record storiesById: Record tasksById: Record } interface RelationsSlice { sprintIdsByProduct: Record storyIdsBySprint: Record taskIdsByStory: Record } interface LoadingSlice { loadedProductSprintsIds: Record loadingProductId: string | null loadedSprintIds: Record loadingSprintId: string | null 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 } interface Actions { hydrateSnapshot(snapshot: SprintWorkspaceSnapshot): void hydrateProductSprints(productId: string, sprints: SprintWorkspaceSprint[]): void setActiveProduct(product: ActiveProductRef | null): void setActiveSprint(sprintId: string | null): void setActiveStory(storyId: string | null): void setActiveTask(taskId: string | null): void ensureProductSprintsLoaded(productId: string, requestId?: string): Promise ensureSprintLoaded(sprintId: string, requestId?: string): Promise ensureStoryLoaded(storyId: string, requestId?: string): Promise ensureTaskLoaded(taskId: string, requestId?: string): Promise applyRealtimeEvent(event: 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 } export type SprintWorkspaceStore = State & Actions const initialState: State = { context: { activeProduct: null, activeSprintId: null, activeStoryId: null, activeTaskId: null, }, entities: { sprintsById: {}, storiesById: {}, tasksById: {}, }, relations: { sprintIdsByProduct: {}, storyIdsBySprint: {}, taskIdsByStory: {}, }, loading: { loadedProductSprintsIds: {}, loadingProductId: null, loadedSprintIds: {}, loadingSprintId: null, loadedStoryIds: {}, loadedTaskIds: {}, activeRequestId: null, }, sync: { realtimeStatus: 'connecting', lastEventAt: null, lastResyncAt: null, resyncReason: null, }, pendingMutations: {}, } function compareSprint(a: SprintWorkspaceSprint, b: SprintWorkspaceSprint): number { // OPEN sprints first, then CLOSED if (a.status !== b.status) return a.status === 'OPEN' ? -1 : 1 // Newest start_date first within same status const aStart = a.start_date ? new Date(a.start_date).getTime() : 0 const bStart = b.start_date ? new Date(b.start_date).getTime() : 0 if (aStart !== bStart) return bStart - aStart return new Date(b.created_at).getTime() - new Date(a.created_at).getTime() } function compareStory(a: SprintWorkspaceStory, b: SprintWorkspaceStory): 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 compareTask(a: SprintWorkspaceTask, b: SprintWorkspaceTask): 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 'sprint' | 'story' | 'task' { return entity === 'sprint' || 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 useSprintWorkspaceStore = create()( immer((set, get) => ({ ...initialState, hydrateSnapshot(inputSnapshot) { const snapshot = normalizeSprintWorkspaceSnapshot(inputSnapshot) set((s) => { if (snapshot.product) s.context.activeProduct = snapshot.product const sprintId = snapshot.sprint?.id ?? null const productId = snapshot.product?.id ?? snapshot.sprint?.product_id ?? null if (snapshot.sprint) { s.entities.sprintsById[snapshot.sprint.id] = snapshot.sprint if (productId) { const list = s.relations.sprintIdsByProduct[productId] ?? [] if (!list.includes(snapshot.sprint.id)) { list.push(snapshot.sprint.id) s.relations.sprintIdsByProduct[productId] = sortSprintIds( s.entities.sprintsById, list, ) } } } for (const story of snapshot.stories) { s.entities.storiesById[story.id] = story } if (sprintId) { s.relations.storyIdsBySprint[sprintId] = [...snapshot.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 (sprintId) { s.loading.loadedSprintIds[sprintId] = true } }) }, hydrateProductSprints(productId, sprints) { set((s) => { for (const sprint of sprints) { s.entities.sprintsById[sprint.id] = sprint } s.relations.sprintIdsByProduct[productId] = [...sprints] .sort(compareSprint) .map((sp) => sp.id) s.loading.loadedProductSprintsIds[productId] = true }) }, setActiveProduct(product) { const requestId = newRequestId() const productChanged = get().context.activeProduct?.id !== product?.id set((s) => { s.context.activeProduct = product s.context.activeSprintId = null s.context.activeStoryId = null s.context.activeTaskId = null s.loading.activeRequestId = requestId if (productChanged) { s.entities.sprintsById = {} s.entities.storiesById = {} s.entities.tasksById = {} s.relations.sprintIdsByProduct = {} s.relations.storyIdsBySprint = {} s.relations.taskIdsByStory = {} s.loading.loadedProductSprintsIds = {} s.loading.loadedSprintIds = {} s.loading.loadedStoryIds = {} s.loading.loadedTaskIds = {} } }) writeProductHint(product?.id ?? null) if (product) { const productId = product.id void (async () => { await get().ensureProductSprintsLoaded(productId, requestId) if (get().loading.activeRequestId !== requestId) return const hint = readHints().perProduct[productId]?.lastActiveSprintId if (hint && get().entities.sprintsById[hint]) { get().setActiveSprint(hint) } })() } }, setActiveSprint(sprintId) { const requestId = newRequestId() const productId = get().context.activeProduct?.id ?? null set((s) => { s.context.activeSprintId = sprintId s.context.activeStoryId = null s.context.activeTaskId = null s.loading.activeRequestId = requestId }) if (productId) writeSprintHint(productId, sprintId) if (sprintId) { void (async () => { await get().ensureSprintLoaded(sprintId, requestId) if (get().loading.activeRequestId !== requestId) return const hint = readHints().perSprint[sprintId]?.lastActiveStoryId if (hint && get().entities.storiesById[hint]) { get().setActiveStory(hint) } })() } }, setActiveStory(storyId) { const requestId = newRequestId() const sprintId = get().context.activeSprintId set((s) => { s.context.activeStoryId = storyId s.context.activeTaskId = null s.loading.activeRequestId = requestId }) if (sprintId) writeStoryHint(sprintId, storyId) if (storyId) { void (async () => { await get().ensureStoryLoaded(storyId, requestId) if (get().loading.activeRequestId !== requestId) return if (!sprintId) return const hint = readHints().perSprint[sprintId]?.lastActiveTaskId if (hint && get().entities.tasksById[hint]) { get().setActiveTask(hint) } })() } }, setActiveTask(taskId) { const sprintId = get().context.activeSprintId set((s) => { s.context.activeTaskId = taskId }) if (sprintId) writeTaskHint(sprintId, taskId) if (taskId) { void get().ensureTaskLoaded(taskId) } }, async ensureProductSprintsLoaded(productId, requestId) { set((s) => { s.loading.loadingProductId = productId }) try { const sprints = await fetchJson( `/api/products/${encodeURIComponent(productId)}/sprints`, ) if (requestId && get().loading.activeRequestId !== requestId) return if (!Array.isArray(sprints)) return get().hydrateProductSprints(productId, sprints) } finally { set((s) => { if (s.loading.loadingProductId === productId) { s.loading.loadingProductId = null } }) } }, async ensureSprintLoaded(sprintId, requestId) { set((s) => { s.loading.loadingSprintId = sprintId }) try { const snapshot = await fetchJson( `/api/sprints/${encodeURIComponent(sprintId)}/workspace`, ) if (requestId && get().loading.activeRequestId !== requestId) return if (!snapshot || !Array.isArray(snapshot.stories)) return get().hydrateSnapshot(snapshot) } finally { set((s) => { if (s.loading.loadingSprintId === sprintId) { s.loading.loadingSprintId = null } }) } }, 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(normalizeSprintTask) 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 = normalizeSprintTask(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 === 'sprint') { applySprintEvent(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().ensureProductSprintsLoaded(ctx.activeProduct.id)) } if (ctx.activeSprintId) tasks.push(get().ensureSprintLoaded(ctx.activeSprintId)) 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[] = [] for (const productId of Object.keys(loading.loadedProductSprintsIds)) { tasks.push(get().ensureProductSprintsLoaded(productId)) } for (const sprintId of Object.keys(loading.loadedSprintIds)) { tasks.push(get().ensureSprintLoaded(sprintId)) } 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(), } }) return id }, rollbackMutation(mutationId) { const pending = get().pendingMutations[mutationId] if (!pending) return const { mutation } = pending set((s) => { switch (mutation.kind) { case 'entity-patch': { const { entity, id, prev } = mutation if (prev) { if (entity === 'sprint') s.entities.sprintsById[id] = prev as SprintWorkspaceSprint else if (entity === 'story') s.entities.storiesById[id] = prev as SprintWorkspaceStory else s.entities.tasksById[id] = prev as | SprintWorkspaceTask | SprintWorkspaceTaskDetail } else { if (entity === 'sprint') delete s.entities.sprintsById[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 }) }, })), ) type ImmerSet = Parameters>[0]>[0] type ImmerGet = () => SprintWorkspaceStore function applySprintEvent( id: string, op: 'I' | 'U' | 'D', payload: Record, set: ImmerSet, get: ImmerGet, ) { if (op === 'D') { set((s) => { const sprint = s.entities.sprintsById[id] const productId = sprint?.product_id // Cascade: stories binnen deze sprint, tasks binnen die stories const childStoryIds = s.relations.storyIdsBySprint[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.storyIdsBySprint[id] delete s.entities.sprintsById[id] if (productId) { const list = s.relations.sprintIdsByProduct[productId] if (list) { s.relations.sprintIdsByProduct[productId] = list.filter((sid) => sid !== id) } } else { for (const pid of Object.keys(s.relations.sprintIdsByProduct)) { s.relations.sprintIdsByProduct[pid] = s.relations.sprintIdsByProduct[pid].filter( (sid) => sid !== id, ) } } if (s.context.activeSprintId === id) { s.context.activeSprintId = null s.context.activeStoryId = null s.context.activeTaskId = null } }) return } if (op === 'U') { if (!get().entities.sprintsById[id]) return set((s) => { const existing = s.entities.sprintsById[id] if (!existing) return Object.assign(existing, sanitizeSprintPayload(payload)) const productId = existing.product_id if (productId && s.relations.sprintIdsByProduct[productId]) { s.relations.sprintIdsByProduct[productId] = sortSprintIds( s.entities.sprintsById, s.relations.sprintIdsByProduct[productId], ) } }) return } // I if (get().entities.sprintsById[id]) return set((s) => { const sprint = coerceSprintPayload(id, payload) s.entities.sprintsById[id] = sprint const productId = sprint.product_id const list = s.relations.sprintIdsByProduct[productId] ?? [] list.push(id) s.relations.sprintIdsByProduct[productId] = sortSprintIds(s.entities.sprintsById, list) }) } function applyStoryEvent( id: string, op: 'I' | 'U' | 'D', payload: Record, set: ImmerSet, get: ImmerGet, ) { const activeSprintId = get().context.activeSprintId 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?.sprint_id) { const ids = s.relations.storyIdsBySprint[story.sprint_id] if (ids) { s.relations.storyIdsBySprint[story.sprint_id] = ids.filter((sid) => sid !== id) } } else { for (const sprintId of Object.keys(s.relations.storyIdsBySprint)) { s.relations.storyIdsBySprint[sprintId] = s.relations.storyIdsBySprint[sprintId].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) { // Story moved into our active sprint? If sprint_id matches active, treat as I if ( activeSprintId && payload.sprint_id === activeSprintId && get().context.activeProduct?.id === payload.product_id ) { set((s) => { const story = coerceStoryPayload(id, payload) s.entities.storiesById[id] = story if (story.sprint_id) { const list = s.relations.storyIdsBySprint[story.sprint_id] ?? [] if (!list.includes(id)) list.push(id) s.relations.storyIdsBySprint[story.sprint_id] = sortStoryIds( s.entities.storiesById, list, ) } }) } return } set((s) => { const story = s.entities.storiesById[id] if (!story) return const oldSprintId = story.sprint_id Object.assign(story, sanitizeStoryPayload(payload)) const newSprintId = story.sprint_id if (oldSprintId !== newSprintId) { if (oldSprintId) { const oldList = s.relations.storyIdsBySprint[oldSprintId] if (oldList) { s.relations.storyIdsBySprint[oldSprintId] = oldList.filter((sid) => sid !== id) } } if (newSprintId) { const targetList = s.relations.storyIdsBySprint[newSprintId] ?? [] if (!targetList.includes(id)) targetList.push(id) s.relations.storyIdsBySprint[newSprintId] = sortStoryIds( s.entities.storiesById, targetList, ) } } else if (oldSprintId && s.relations.storyIdsBySprint[oldSprintId]) { s.relations.storyIdsBySprint[oldSprintId] = sortStoryIds( s.entities.storiesById, s.relations.storyIdsBySprint[oldSprintId], ) } }) return } // I if (get().entities.storiesById[id]) return set((s) => { const story = coerceStoryPayload(id, payload) s.entities.storiesById[id] = story if (story.sprint_id) { const list = s.relations.storyIdsBySprint[story.sprint_id] ?? [] list.push(id) s.relations.storyIdsBySprint[story.sprint_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 sortSprintIds( byId: Record, ids: string[], ): string[] { return [...new Set(ids)] .filter((id) => byId[id] !== undefined) .sort((a, b) => compareSprint(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 sanitizeSprintPayload(p: Record): Partial { const { entity: _e, op: _o, ...rest } = p void _e void _o 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 coerceSprintPayload( id: string, p: Record, ): SprintWorkspaceSprint { return { id, product_id: String(p.product_id ?? ''), code: String(p.code ?? ''), sprint_goal: String(p.sprint_goal ?? ''), status: (p.status as SprintWorkspaceSprint['status']) ?? 'OPEN', start_date: (p.start_date as string | null | undefined) ?? null, end_date: (p.end_date as string | null | undefined) ?? null, created_at: p.created_at instanceof Date ? p.created_at : new Date(String(p.created_at ?? Date.now())), completed_at: p.completed_at instanceof Date ? p.completed_at : p.completed_at ? new Date(String(p.completed_at)) : null, } } function coerceStoryPayload( id: string, p: Record, ): SprintWorkspaceStory { 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): SprintWorkspaceTask { 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) ?? 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 ?? ''), 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())), } }