diff --git a/__tests__/stores/product-workspace/store.test.ts b/__tests__/stores/product-workspace/store.test.ts index 0f36b74..e53cd65 100644 --- a/__tests__/stores/product-workspace/store.test.ts +++ b/__tests__/stores/product-workspace/store.test.ts @@ -629,6 +629,98 @@ describe('resyncActiveScopes', () => { // Optimistic mutations // ───────────────────────────────────────────────────────────────────────── +// ───────────────────────────────────────────────────────────────────────── +// Restore-hint integratie (Story 4) +// ───────────────────────────────────────────────────────────────────────── + +describe('restore-hint flow — setters persisteren hints', () => { + it('setActiveProduct schrijft lastActiveProductId', () => { + useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) + const raw = localStorage.getItem('product-workspace-hints') + expect(raw).not.toBeNull() + const hints = JSON.parse(raw!) + expect(hints.lastActiveProductId).toBe('prod-1') + }) + + it('setActivePbi schrijft lastActivePbiId per product', () => { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'P1' } + }) + useProductWorkspaceStore.getState().setActivePbi('pbi-a') + const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!) + expect(hints.perProduct['prod-1'].lastActivePbiId).toBe('pbi-a') + }) + + it('setActiveStory schrijft lastActiveStoryId per product', () => { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'P1' } + }) + useProductWorkspaceStore.getState().setActiveStory('story-a') + const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!) + expect(hints.perProduct['prod-1'].lastActiveStoryId).toBe('story-a') + }) + + it('setActiveTask schrijft lastActiveTaskId per product', () => { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'P1' } + }) + useProductWorkspaceStore.getState().setActiveTask('task-a') + const hints = JSON.parse(localStorage.getItem('product-workspace-hints')!) + expect(hints.perProduct['prod-1'].lastActiveTaskId).toBe('task-a') + }) +}) + +describe('restore-hint flow — chain triggert na ensure*Loaded', () => { + it('hint die NIET in entities zit wordt genegeerd', async () => { + // Schrijf een hint voor een PBI die niet bestaat + localStorage.setItem( + 'product-workspace-hints', + JSON.stringify({ + lastActiveProductId: 'prod-1', + perProduct: { 'prod-1': { lastActivePbiId: 'ghost-pbi' } }, + }), + ) + // Mock ensureProductLoaded zodat hij een lege snapshot terugstuurt — geen + // ghost-pbi in entities. + mockFetchSequence([ + { product: { id: 'prod-1', name: 'P1' }, pbis: [], storiesByPbi: {}, tasksByStory: {} }, + ]) + + useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) + // Wacht tot async restore-flow afgewikkeld is. + await new Promise((r) => setTimeout(r, 20)) + + expect(useProductWorkspaceStore.getState().context.activePbiId).toBeNull() + }) + + it('hint die wel in entities zit wordt toegepast', async () => { + const validPbi = makePbi({ id: 'pbi-known' }) + localStorage.setItem( + 'product-workspace-hints', + JSON.stringify({ + lastActiveProductId: 'prod-1', + perProduct: { 'prod-1': { lastActivePbiId: 'pbi-known' } }, + }), + ) + mockFetchSequence([ + // ensureProductLoaded levert pbi-known + { + product: { id: 'prod-1', name: 'P1' }, + pbis: [validPbi], + storiesByPbi: {}, + tasksByStory: {}, + }, + // ensurePbiLoaded triggered door setActivePbi(hint) — geen stories + [], + ]) + + useProductWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) + await new Promise((r) => setTimeout(r, 30)) + + expect(useProductWorkspaceStore.getState().context.activePbiId).toBe('pbi-known') + }) +}) + describe('optimistic mutations', () => { it('rollback herstelt vorige pbi-order', () => { useProductWorkspaceStore.getState().hydrateSnapshot( diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index 8731a53..30b0f46 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -11,6 +11,7 @@ import { StoryPanel } from '@/components/backlog/story-panel' import type { Story } from '@/components/backlog/story-panel' import { TaskPanel } from '@/components/backlog/task-panel' import { BacklogHydrationWrapper } from '@/components/backlog/backlog-hydration-wrapper' +import { UrlTaskSync } from '@/components/backlog/url-task-sync' import { TaskDialog } from '@/app/_components/tasks/task-dialog' import { EditTaskLoader } from '@/app/_components/tasks/edit-task-loader' import { TaskDialogSkeleton } from '@/app/_components/tasks/task-dialog-skeleton' @@ -155,6 +156,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props tasksByStory, }} > + + ` draagt, wint dat boven de localStorage-hint +// die de restore-flow normaal zou toepassen. We schrijven de URL-id direct +// naar de task-hint en roepen setActiveTask aan; de restore-flow leest de +// task-hint pas na drie ensure*Loaded-awaits, dus onze schrijfactie wint +// in de praktijk altijd. + +import { useEffect } from 'react' +import { useSearchParams } from 'next/navigation' +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import { writeTaskHint } from '@/stores/product-workspace/restore' + +export function UrlTaskSync() { + const searchParams = useSearchParams() + const editTask = searchParams.get('editTask') + + useEffect(() => { + if (!editTask) return + const productId = useProductWorkspaceStore.getState().context.activeProduct?.id + if (productId) { + // Hint overschrijven zodat restore-flow's setActiveTask op deze id eindigt + // (mocht hij na onze directe call komen). + writeTaskHint(productId, editTask) + } + useProductWorkspaceStore.getState().setActiveTask(editTask) + }, [editTask]) + + return null +} diff --git a/stores/product-workspace/store.ts b/stores/product-workspace/store.ts index ebe70c3..4571310 100644 --- a/stores/product-workspace/store.ts +++ b/stores/product-workspace/store.ts @@ -15,6 +15,13 @@ import { type ResyncReason, type TaskDetail, } from './types' +import { + readHints, + writePbiHint, + writeProductHint, + writeStoryHint, + writeTaskHint, +} from './restore' interface ContextSlice { activeProduct: ActiveProduct | null @@ -232,13 +239,28 @@ export const useProductWorkspaceStore = create()( } }) + // 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) { - void get().ensureProductLoaded(product.id, requestId) + 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 @@ -247,13 +269,27 @@ export const useProductWorkspaceStore = create()( 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 get().ensurePbiLoaded(pbiId, requestId) + void (async () => { + await get().ensurePbiLoaded(pbiId, requestId) + if (get().loading.activeRequestId !== requestId) return + if (!productId) return + // T-857: cascade-restore + const hint = readHints().perProduct[productId]?.lastActiveStoryId + if (hint && get().entities.storiesById[hint]) { + get().setActiveStory(hint) + } + })() } }, setActiveStory(storyId) { const requestId = newRequestId() + const productId = get().context.activeProduct?.id ?? null set((s) => { s.context.activeStoryId = storyId @@ -261,16 +297,30 @@ export const useProductWorkspaceStore = create()( s.loading.activeRequestId = requestId }) + if (productId) writeStoryHint(productId, storyId) + if (storyId) { - void get().ensureStoryLoaded(storyId, requestId) + 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) }