diff --git a/__tests__/stores/product-workspace/store.test.ts b/__tests__/stores/product-workspace/store.test.ts index 9f3cea7..beb61f1 100644 --- a/__tests__/stores/product-workspace/store.test.ts +++ b/__tests__/stores/product-workspace/store.test.ts @@ -90,7 +90,7 @@ function makeStory(overrides: Partial & { id: string; pbi_id: stri acceptance_criteria: overrides.acceptance_criteria ?? null, priority: overrides.priority ?? 2, sort_order: overrides.sort_order ?? 1, - status: overrides.status ?? 'open', + status: overrides.status ?? 'OPEN', pbi_id: overrides.pbi_id, sprint_id: overrides.sprint_id ?? null, created_at: overrides.created_at ?? new Date('2026-01-01'), @@ -104,7 +104,7 @@ function makeTask(overrides: Partial & { id: string; story_id: stri description: overrides.description ?? null, priority: overrides.priority ?? 2, sort_order: overrides.sort_order ?? 1, - status: overrides.status ?? 'todo', + status: overrides.status ?? 'TO_DO', story_id: overrides.story_id, created_at: overrides.created_at ?? new Date('2026-01-01'), } @@ -168,6 +168,27 @@ describe('hydrateSnapshot', () => { expect(s.loading.loadedProductId).toBe('prod-1') }) + it('normaliseert API-statussen naar het interne store-contract', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + [makePbi({ id: 'pbi-1', status: 'READY' as BacklogPbi['status'] })], + { + 'pbi-1': [ + makeStory({ id: 'st-1', pbi_id: 'pbi-1', status: 'in_sprint' }), + ], + }, + { + 'st-1': [makeTask({ id: 'tk-1', story_id: 'st-1', status: 'todo' })], + }, + ), + ) + + const s = useProductWorkspaceStore.getState() + expect(s.entities.pbisById['pbi-1'].status).toBe('ready') + expect(s.entities.storiesById['st-1'].status).toBe('IN_SPRINT') + expect(s.entities.tasksById['tk-1'].status).toBe('TO_DO') + }) + it('reset bestaande entities en relations bij her-hydratie', () => { useProductWorkspaceStore.getState().hydrateSnapshot( snapshotWith([makePbi({ id: 'old-pbi' })]), @@ -624,6 +645,7 @@ describe('ensureTaskLoaded — zet detail-flag', () => { await useProductWorkspaceStore.getState().ensureTaskLoaded('t-1') const task = useProductWorkspaceStore.getState().entities.tasksById['t-1'] as TaskDetail expect(task._detail).toBe(true) + expect(task.status).toBe('TO_DO') expect(task.implementation_plan).toBe('detailed plan here') expect(useProductWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true) }) diff --git a/__tests__/stores/sprint-workspace/store.test.ts b/__tests__/stores/sprint-workspace/store.test.ts index baf16f2..6e7757a 100644 --- a/__tests__/stores/sprint-workspace/store.test.ts +++ b/__tests__/stores/sprint-workspace/store.test.ts @@ -96,7 +96,7 @@ function makeStory( acceptance_criteria: overrides.acceptance_criteria ?? null, priority: overrides.priority ?? 2, sort_order: overrides.sort_order ?? 1, - status: overrides.status ?? 'open', + status: overrides.status ?? 'OPEN', pbi_id: overrides.pbi_id, sprint_id: overrides.sprint_id ?? null, created_at: overrides.created_at ?? new Date('2026-01-01'), @@ -113,7 +113,7 @@ function makeTask( description: overrides.description ?? null, priority: overrides.priority ?? 2, sort_order: overrides.sort_order ?? 1, - status: overrides.status ?? 'todo', + status: overrides.status ?? 'TO_DO', story_id: overrides.story_id, sprint_id: overrides.sprint_id ?? null, created_at: overrides.created_at ?? new Date('2026-01-01'), @@ -174,6 +174,20 @@ describe('hydrateSnapshot', () => { expect(s.context.activeProduct).toEqual({ id: 'prod-1', name: 'Product 1' }) expect(s.loading.loadedSprintIds['sp-1']).toBe(true) }) + + it('normaliseert API-statussen naar het interne store-contract', () => { + useSprintWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + makeSprint({ id: 'sp-1', product_id: 'prod-1' }), + [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1', status: 'in_sprint' })], + { 's-1': [makeTask({ id: 't-1', story_id: 's-1', status: 'todo' })] }, + ), + ) + + const s = useSprintWorkspaceStore.getState() + expect(s.entities.storiesById['s-1'].status).toBe('IN_SPRINT') + expect(s.entities.tasksById['t-1'].status).toBe('TO_DO') + }) }) describe('hydrateProductSprints', () => { @@ -692,6 +706,7 @@ describe('ensureTaskLoaded — zet detail-flag', () => { 't-1' ] as SprintWorkspaceTaskDetail expect(task._detail).toBe(true) + expect(task.status).toBe('TO_DO') expect(task.implementation_plan).toBe('detailed plan here') expect(useSprintWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true) }) diff --git a/stores/product-workspace/store.ts b/stores/product-workspace/store.ts index 4571310..7ca381c 100644 --- a/stores/product-workspace/store.ts +++ b/stores/product-workspace/store.ts @@ -22,6 +22,14 @@ import { writeStoryHint, writeTaskHint, } from './restore' +import { + normalizeBacklogStory, + normalizeBacklogTask, + normalizeProductBacklogSnapshot, + normalizePbiStatusForStore, + normalizeStoryStatusForStore, + normalizeTaskStatusForStore, +} from '@/stores/workspace-status-adapter' interface ContextSlice { activeProduct: ActiveProduct | null @@ -174,7 +182,8 @@ export const useProductWorkspaceStore = create()( immer((set, get) => ({ ...initialState, - hydrateSnapshot(snapshot) { + hydrateSnapshot(inputSnapshot) { + const snapshot = normalizeProductBacklogSnapshot(inputSnapshot) set((s) => { if (snapshot.product) s.context.activeProduct = snapshot.product @@ -358,11 +367,12 @@ export const useProductWorkspaceStore = create()( ) if (requestId && get().loading.activeRequestId !== requestId) return if (!Array.isArray(stories)) return + const normalizedStories = stories.map(normalizeBacklogStory) set((s) => { - for (const story of stories) { + for (const story of normalizedStories) { s.entities.storiesById[story.id] = story } - s.relations.storyIdsByPbi[pbiId] = [...stories] + s.relations.storyIdsByPbi[pbiId] = [...normalizedStories] .sort(compareStory) .map((st) => st.id) s.loading.loadedPbiIds[pbiId] = true @@ -375,8 +385,9 @@ export const useProductWorkspaceStore = create()( ) if (requestId && get().loading.activeRequestId !== requestId) return if (!Array.isArray(tasks)) return + const normalizedTasks = tasks.map(normalizeBacklogTask) set((s) => { - for (const task of tasks) { + for (const task of normalizedTasks) { const existing = s.entities.tasksById[task.id] if (existing && isDetail(existing)) { s.entities.tasksById[task.id] = { ...existing, ...task } @@ -384,7 +395,7 @@ export const useProductWorkspaceStore = create()( s.entities.tasksById[task.id] = task } } - s.relations.taskIdsByStory[storyId] = [...tasks] + s.relations.taskIdsByStory[storyId] = [...normalizedTasks] .sort(compareTask) .map((t) => t.id) s.loading.loadedStoryIds[storyId] = true @@ -397,8 +408,9 @@ export const useProductWorkspaceStore = create()( ) if (requestId && get().loading.activeRequestId !== requestId) return if (!detail || typeof detail !== 'object') return + const normalizedDetail = normalizeBacklogTask(detail) set((s) => { - s.entities.tasksById[taskId] = { ...detail, _detail: true } + s.entities.tasksById[taskId] = { ...normalizedDetail, _detail: true } s.loading.loadedTaskIds[taskId] = true }) }, @@ -772,20 +784,65 @@ 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, ...rest } = p + 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, ...rest } = p + 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 } @@ -801,20 +858,24 @@ function coercePbiPayload(id: string, p: Record): BacklogPbi { p.created_at instanceof Date ? p.created_at : new Date(String(p.created_at ?? Date.now())), - status: (p.status as BacklogPbi['status']) ?? 'ready', + 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: (p.code as string | null) ?? null, - title: String(p.title ?? ''), + 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(p.sort_order ?? 0), - status: String(p.status ?? 'open'), + 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: @@ -825,13 +886,16 @@ function coerceStoryPayload(id: string, p: Record): BacklogStor } 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, - title: String(p.title ?? ''), + title: String(title), description: (p.description as string | null | undefined) ?? null, priority: Number(p.priority ?? 4), - sort_order: Number(p.sort_order ?? 0), - status: String(p.status ?? 'todo'), + sort_order: Number(sortOrder), + status: normalizeTaskStatusForStore(String(status)), story_id: String(p.story_id ?? ''), created_at: p.created_at instanceof Date diff --git a/stores/sprint-workspace/store.ts b/stores/sprint-workspace/store.ts index e49afce..81e878f 100644 --- a/stores/sprint-workspace/store.ts +++ b/stores/sprint-workspace/store.ts @@ -21,6 +21,12 @@ import { writeStoryHint, writeTaskHint, } from './restore' +import { + normalizeSprintTask, + normalizeSprintWorkspaceSnapshot, + normalizeStoryStatusForStore, + normalizeTaskStatusForStore, +} from '@/stores/workspace-status-adapter' interface ContextSlice { activeProduct: ActiveProductRef | null @@ -180,7 +186,8 @@ export const useSprintWorkspaceStore = create()( immer((set, get) => ({ ...initialState, - hydrateSnapshot(snapshot) { + hydrateSnapshot(inputSnapshot) { + const snapshot = normalizeSprintWorkspaceSnapshot(inputSnapshot) set((s) => { if (snapshot.product) s.context.activeProduct = snapshot.product @@ -387,8 +394,9 @@ export const useSprintWorkspaceStore = create()( ) if (requestId && get().loading.activeRequestId !== requestId) return if (!Array.isArray(tasks)) return + const normalizedTasks = tasks.map(normalizeSprintTask) set((s) => { - for (const task of tasks) { + for (const task of normalizedTasks) { const existing = s.entities.tasksById[task.id] if (existing && isDetail(existing)) { s.entities.tasksById[task.id] = { ...existing, ...task } @@ -396,7 +404,7 @@ export const useSprintWorkspaceStore = create()( s.entities.tasksById[task.id] = task } } - s.relations.taskIdsByStory[storyId] = [...tasks] + s.relations.taskIdsByStory[storyId] = [...normalizedTasks] .sort(compareTask) .map((t) => t.id) s.loading.loadedStoryIds[storyId] = true @@ -409,8 +417,9 @@ export const useSprintWorkspaceStore = create()( ) if (requestId && get().loading.activeRequestId !== requestId) return if (!detail || typeof detail !== 'object') return + const normalizedDetail = normalizeSprintTask(detail) set((s) => { - s.entities.tasksById[taskId] = { ...detail, _detail: true } + s.entities.tasksById[taskId] = { ...normalizedDetail, _detail: true } s.loading.loadedTaskIds[taskId] = true }) }, @@ -839,16 +848,58 @@ function sanitizeSprintPayload(p: Record): Partial): Partial { - const { entity: _e, op: _o, ...rest } = p + 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, ...rest } = p + 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 } @@ -881,15 +932,19 @@ 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: (p.code as string | null) ?? null, - title: String(p.title ?? ''), + 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(p.sort_order ?? 0), - status: String(p.status ?? 'open'), + 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: @@ -900,14 +955,17 @@ function coerceStoryPayload( } 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(p.title ?? ''), + title: String(title), description: (p.description as string | null | undefined) ?? null, priority: Number(p.priority ?? 4), - sort_order: Number(p.sort_order ?? 0), - status: String(p.status ?? 'todo'), + 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: diff --git a/stores/workspace-status-adapter.ts b/stores/workspace-status-adapter.ts new file mode 100644 index 0000000..8900058 --- /dev/null +++ b/stores/workspace-status-adapter.ts @@ -0,0 +1,88 @@ +import { + pbiStatusFromApi, + pbiStatusToApi, + storyStatusFromApi, + taskStatusFromApi, +} from '@/lib/task-status' +import type { + BacklogPbi, + BacklogStory, + BacklogTask, + ProductBacklogSnapshot, + TaskDetail, +} from '@/stores/product-workspace/types' +import type { + SprintWorkspaceSnapshot, + SprintWorkspaceStory, + SprintWorkspaceTask, + SprintWorkspaceTaskDetail, +} from '@/stores/sprint-workspace/types' + +export function normalizePbiStatusForStore(status: string): BacklogPbi['status'] { + const dbStatus = pbiStatusFromApi(status) + return dbStatus ? pbiStatusToApi(dbStatus) : (status as BacklogPbi['status']) +} + +export function normalizeStoryStatusForStore(status: string): string { + return storyStatusFromApi(status) ?? status +} + +export function normalizeTaskStatusForStore(status: string): string { + return taskStatusFromApi(status) ?? status +} + +export function normalizeBacklogPbi(pbi: T): T { + const status = normalizePbiStatusForStore(pbi.status) + return status === pbi.status ? pbi : { ...pbi, status } +} + +export function normalizeBacklogStory(story: T): T { + const status = normalizeStoryStatusForStore(story.status) + return status === story.status ? story : { ...story, status } +} + +export function normalizeBacklogTask(task: T): T { + const status = normalizeTaskStatusForStore(task.status) + return status === task.status ? task : { ...task, status } +} + +export function normalizeSprintStory(story: T): T { + const status = normalizeStoryStatusForStore(story.status) + return status === story.status ? story : { ...story, status } +} + +export function normalizeSprintTask( + task: T, +): T { + const status = normalizeTaskStatusForStore(task.status) + return status === task.status ? task : { ...task, status } +} + +export function normalizeProductBacklogSnapshot( + snapshot: ProductBacklogSnapshot, +): ProductBacklogSnapshot { + return { + ...snapshot, + pbis: snapshot.pbis.map(normalizeBacklogPbi), + storiesByPbi: mapRecordLists(snapshot.storiesByPbi, normalizeBacklogStory), + tasksByStory: mapRecordLists(snapshot.tasksByStory, normalizeBacklogTask), + } +} + +export function normalizeSprintWorkspaceSnapshot( + snapshot: SprintWorkspaceSnapshot, +): SprintWorkspaceSnapshot { + return { + ...snapshot, + stories: snapshot.stories.map(normalizeSprintStory), + tasksByStory: mapRecordLists(snapshot.tasksByStory, normalizeSprintTask), + } +} + +function mapRecordLists(record: Record, normalize: (item: T) => T): Record { + const next: Record = {} + for (const [id, list] of Object.entries(record)) { + next[id] = list.map(normalize) + } + return next +}