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