diff --git a/CLAUDE.md b/CLAUDE.md index 4ed8cdc..40245da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,6 +88,7 @@ Volledige MCP-tool documentatie: [docs/runbooks/mcp-integration.md](./docs/runbo | Prisma singleton | `docs/patterns/prisma-client.md` | | Server Action (auth + Zod) | `docs/patterns/server-action.md` | | Route Handler (REST) | `docs/patterns/route-handler.md` | +| Workspace-store + realtime (PBI-74) | `docs/patterns/workspace-store.md` | | Zustand optimistic update | `docs/patterns/zustand-optimistic.md` | | Float sort_order / drag-and-drop | `docs/patterns/sort-order.md` | | Proxy / route protection | `docs/patterns/proxy.md` | diff --git a/__tests__/api/backlog-realtime.test.ts b/__tests__/api/backlog-realtime.test.ts deleted file mode 100644 index f9d0bfe..0000000 --- a/__tests__/api/backlog-realtime.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -const { mockGetSession } = vi.hoisted(() => ({ mockGetSession: vi.fn() })) - -vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) -vi.mock('@/lib/product-access', () => ({ - getAccessibleProduct: vi.fn(), -})) - -import { getAccessibleProduct } from '@/lib/product-access' -import type { NextRequest } from 'next/server' -import { GET } from '@/app/api/realtime/backlog/route' -import { useBacklogStore } from '@/stores/backlog-store' - -const mockGetAccessibleProduct = getAccessibleProduct as ReturnType - -function makeReq(productId?: string): NextRequest { - const url = productId - ? `http://localhost/api/realtime/backlog?product_id=${productId}` - : 'http://localhost/api/realtime/backlog' - return { - signal: new AbortController().signal, - nextUrl: new URL(url), - } as unknown as NextRequest -} - -beforeEach(() => { - vi.clearAllMocks() -}) - -describe('GET /api/realtime/backlog', () => { - it('401 when not authenticated', async () => { - mockGetSession.mockResolvedValue({ userId: undefined, isDemo: false }) - const res = await GET(makeReq('prod-1')) - expect(res.status).toBe(401) - expect(mockGetAccessibleProduct).not.toHaveBeenCalled() - }) - - it('400 when product_id is missing', async () => { - mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) - const res = await GET(makeReq()) - expect(res.status).toBe(400) - }) - - it('403 when user has no access to the product', async () => { - mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) - mockGetAccessibleProduct.mockResolvedValue(null) - const res = await GET(makeReq('prod-1')) - expect(res.status).toBe(403) - expect(mockGetAccessibleProduct).toHaveBeenCalledWith('prod-1', 'user-1') - }) - - it('500 when DIRECT_URL and DATABASE_URL are absent', async () => { - mockGetSession.mockResolvedValue({ userId: 'user-1', isDemo: false }) - mockGetAccessibleProduct.mockResolvedValue({ id: 'prod-1' }) - - const before = { DIRECT_URL: process.env.DIRECT_URL, DATABASE_URL: process.env.DATABASE_URL } - delete process.env.DIRECT_URL - delete process.env.DATABASE_URL - try { - const res = await GET(makeReq('prod-1')) - expect(res.status).toBe(500) - } finally { - if (before.DIRECT_URL !== undefined) process.env.DIRECT_URL = before.DIRECT_URL - if (before.DATABASE_URL !== undefined) process.env.DATABASE_URL = before.DATABASE_URL - } - }) - - it('demo user is allowed (no 403) when product is accessible', async () => { - mockGetSession.mockResolvedValue({ userId: 'demo-user', isDemo: true }) - mockGetAccessibleProduct.mockResolvedValue({ id: 'prod-1' }) - - const before = { DIRECT_URL: process.env.DIRECT_URL, DATABASE_URL: process.env.DATABASE_URL } - delete process.env.DIRECT_URL - delete process.env.DATABASE_URL - try { - const res = await GET(makeReq('prod-1')) - // Fails at 500 (no DB URL) — not 403, confirming demo user is not blocked - expect(res.status).toBe(500) - } finally { - if (before.DIRECT_URL !== undefined) process.env.DIRECT_URL = before.DIRECT_URL - if (before.DATABASE_URL !== undefined) process.env.DATABASE_URL = before.DATABASE_URL - } - }) -}) - -// shouldEmit scope filter — white-box unit tests -describe('shouldEmit scope filter (via backlog-store reducer)', () => { - it('applyChange: pbi INSERT adds to pbis array', () => { - useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} }) - const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Test', priority: 2, created_at: new Date(), status: 'ready' as const } - useBacklogStore.getState().applyChange('pbi', 'I', pbi) - expect(useBacklogStore.getState().pbis).toHaveLength(1) - expect(useBacklogStore.getState().pbis[0].id).toBe('pbi-1') - }) - - it('applyChange: pbi UPDATE patches existing pbi', () => { - const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Old', priority: 2, created_at: new Date(), status: 'ready' as const } - useBacklogStore.setState({ pbis: [pbi], storiesByPbi: {}, tasksByStory: {} }) - useBacklogStore.getState().applyChange('pbi', 'U', { id: 'pbi-1', title: 'New' }) - expect(useBacklogStore.getState().pbis[0].title).toBe('New') - }) - - it('applyChange: pbi DELETE removes pbi', () => { - const pbi = { id: 'pbi-1', code: 'PBI-1', title: 'Test', priority: 2, created_at: new Date(), status: 'ready' as const } - useBacklogStore.setState({ pbis: [pbi], storiesByPbi: {}, tasksByStory: {} }) - useBacklogStore.getState().applyChange('pbi', 'D', { id: 'pbi-1' }) - expect(useBacklogStore.getState().pbis).toHaveLength(0) - }) - - it('applyChange: story INSERT adds to storiesByPbi', () => { - useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} }) - const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', sprint_id: null, created_at: new Date() } - useBacklogStore.getState().applyChange('story', 'I', story) - expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1) - }) - - it('applyChange: story DELETE removes from correct pbi bucket', () => { - const story = { id: 'story-1', code: 'ST-1', title: 'S', description: null, acceptance_criteria: null, priority: 2, status: 'OPEN', pbi_id: 'pbi-1', sprint_id: null, created_at: new Date() } - useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [story] }, tasksByStory: {} }) - useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' }) - expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0) - }) - - it('applyChange: task UPDATE patches task across story buckets', () => { - const task = { id: 'task-1', title: 'Old', description: null, priority: 2, status: 'TO_DO', sort_order: 1, story_id: 'story-1', created_at: new Date() } - useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [task] } }) - useBacklogStore.getState().applyChange('task', 'U', { id: 'task-1', status: 'IN_PROGRESS' }) - expect(useBacklogStore.getState().tasksByStory['story-1'][0].status).toBe('IN_PROGRESS') - }) -}) diff --git a/__tests__/components/backlog/backlog-split-pane.test.tsx b/__tests__/components/backlog/backlog-split-pane.test.tsx index f57e53f..4505a80 100644 --- a/__tests__/components/backlog/backlog-split-pane.test.tsx +++ b/__tests__/components/backlog/backlog-split-pane.test.tsx @@ -1,9 +1,16 @@ // @vitest-environment jsdom import { describe, it, expect, beforeEach } from 'vitest' import { render, screen } from '@testing-library/react' -import { useSelectionStore } from '@/stores/selection-store' +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' import { BacklogSplitPane } from '@/components/backlog/backlog-split-pane' +function setSelection(pbiId: string | null, storyId: string | null) { + useProductWorkspaceStore.setState((s) => { + s.context.activePbiId = pbiId + s.context.activeStoryId = storyId + }) +} + const PANES = [
PBI pane
,
Stories pane
, @@ -22,7 +29,7 @@ function renderPane() { } beforeEach(() => { - useSelectionStore.setState({ selectedPbiId: null, selectedStoryId: null }) + setSelection(null, null) // Force mobile viewport Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 600 }) window.dispatchEvent(new Event('resize')) @@ -37,7 +44,7 @@ describe('BacklogSplitPane auto-switch', () => { it('auto-switches to tab 1 when PBI is selected', () => { const { rerender } = renderPane() - useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: null }) + setSelection('pbi-1', null) rerender( { it('auto-switches to tab 2 when story is selected', () => { const { rerender } = renderPane() - useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' }) + setSelection('pbi-1', 'story-1') rerender( { it('switches to tab 1 on cascade-reset (story cleared when new PBI selected)', () => { // Start with story selected (tab 2) - useSelectionStore.setState({ selectedPbiId: 'pbi-1', selectedStoryId: 'story-1' }) + setSelection('pbi-1', 'story-1') const { rerender } = renderPane() // Cascade-reset: new PBI → story clears - useSelectionStore.setState({ selectedPbiId: 'pbi-2', selectedStoryId: null }) + setSelection('pbi-2', null) rerender( { + s.context.activeProduct = null + s.context.activePbiId = null + s.context.activeStoryId = null + s.context.activeTaskId = null + s.entities.pbisById = {} + s.entities.storiesById = Object.fromEntries(STORIES.map((st) => [st.id, st])) + s.entities.tasksById = Object.fromEntries(TASKS.map((t) => [t.id, t])) + s.relations.pbiIds = [] + s.relations.storyIdsByPbi = { [PBI_ID]: STORIES.map((st) => st.id) } + s.relations.taskIdsByStory = { [STORY_ID]: TASKS.map((t) => t.id) } + }) +} + +function selectPbi(pbiId: string | null) { + useProductWorkspaceStore.setState((s) => { + s.context.activePbiId = pbiId + s.context.activeStoryId = null + s.context.activeTaskId = null + }) +} + +function selectStory(pbiId: string | null, storyId: string | null) { + useProductWorkspaceStore.setState((s) => { + s.context.activePbiId = pbiId + s.context.activeStoryId = storyId }) } @@ -89,42 +113,40 @@ describe('Backlog 3-pane integration', () => { }) it('StoryPanel shows stories when PBI is selected', () => { - useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null }) + selectPbi(PBI_ID) render() expect(screen.getByText('Eerste story')).toBeTruthy() }) - it('clicking a story dispatches selectStory to the store', () => { - useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: null }) + it('clicking a story dispatches setActiveStory to the workspace-store', () => { + selectPbi(PBI_ID) render() fireEvent.click(screen.getByText('Eerste story')) - expect(useSelectionStore.getState().selectedStoryId).toBe(STORY_ID) + expect(useProductWorkspaceStore.getState().context.activeStoryId).toBe(STORY_ID) }) - it('cascade-reset: selecting different PBI clears selectedStoryId', () => { - useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) - useSelectionStore.getState().selectPbi(ALT_PBI_ID) - expect(useSelectionStore.getState().selectedStoryId).toBeNull() + it('cascade-reset: selecting different PBI clears activeStoryId', () => { + selectStory(PBI_ID, STORY_ID) + useProductWorkspaceStore.getState().setActivePbi(ALT_PBI_ID) + expect(useProductWorkspaceStore.getState().context.activeStoryId).toBeNull() }) it('TaskPanel shows tasks after story is selected', () => { - useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) + selectStory(PBI_ID, STORY_ID) render() expect(screen.getByText('Eerste taak')).toBeTruthy() }) it('TaskPanel shows empty state after cascade-reset', () => { - useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) + selectStory(PBI_ID, STORY_ID) render() - // Reset via selectPbi - useSelectionStore.getState().selectPbi(ALT_PBI_ID) - // Re-render reflects new store state + useProductWorkspaceStore.getState().setActivePbi(ALT_PBI_ID) render() expect(screen.getAllByText('Selecteer een story om de taken te bekijken.').length).toBeGreaterThan(0) }) it('selected story card has isSelected highlight class applied', () => { - useSelectionStore.setState({ selectedPbiId: PBI_ID, selectedStoryId: STORY_ID }) + selectStory(PBI_ID, STORY_ID) const { container } = render() // bg-primary-container is applied when isSelected const selected = container.querySelector('.bg-primary-container') diff --git a/__tests__/components/backlog/task-panel.test.tsx b/__tests__/components/backlog/task-panel.test.tsx index 97a5894..69b844c 100644 --- a/__tests__/components/backlog/task-panel.test.tsx +++ b/__tests__/components/backlog/task-panel.test.tsx @@ -1,8 +1,33 @@ // @vitest-environment jsdom import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' -import { useSelectionStore } from '@/stores/selection-store' -import { useBacklogStore } from '@/stores/backlog-store' +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import type { BacklogTask } from '@/stores/product-workspace/types' + +function resetWorkspace() { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = null + s.context.activePbiId = null + s.context.activeStoryId = null + s.context.activeTaskId = null + s.entities.pbisById = {} + s.entities.storiesById = {} + s.entities.tasksById = {} + s.relations.pbiIds = [] + s.relations.storyIdsByPbi = {} + s.relations.taskIdsByStory = {} + }) +} + +function setActiveStoryAndTasks(storyId: string | null, tasks: BacklogTask[] = []) { + useProductWorkspaceStore.setState((s) => { + s.context.activeStoryId = storyId + if (storyId) { + s.relations.taskIdsByStory[storyId] = tasks.map((t) => t.id) + for (const task of tasks) s.entities.tasksById[task.id] = task + } + }) +} // Mock next/navigation const mockPush = vi.fn() @@ -57,8 +82,7 @@ function renderPanel(isDemo = false) { describe('TaskPanel', () => { beforeEach(() => { mockPush.mockClear() - useSelectionStore.setState({ selectedStoryId: null, selectedPbiId: null }) - useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} }) + resetWorkspace() }) it('shows empty state when no story is selected', () => { @@ -67,40 +91,35 @@ describe('TaskPanel', () => { }) it('shows empty state with action when story selected but no tasks', () => { - useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) - useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } }) + setActiveStoryAndTasks(STORY_ID, []) renderPanel() expect(screen.getByText('Nog geen taken voor deze story.')).toBeTruthy() expect(screen.getAllByText('+ Nieuwe taak').length).toBeGreaterThanOrEqual(1) }) it('renders task cards when tasks are present', () => { - useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) - useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + setActiveStoryAndTasks(STORY_ID, TASKS) renderPanel() expect(screen.getByText('Eerste taak')).toBeTruthy() expect(screen.getByText('Tweede taak')).toBeTruthy() }) it('renders status badges on task cards', () => { - useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) - useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + setActiveStoryAndTasks(STORY_ID, TASKS) renderPanel() expect(screen.getByText('To Do')).toBeTruthy() expect(screen.getByText('Bezig')).toBeTruthy() }) it('task cards are rendered inside a grid container', () => { - useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) - useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + setActiveStoryAndTasks(STORY_ID, TASKS) const { container } = renderPanel() const grid = container.querySelector('.grid') expect(grid).toBeTruthy() }) it('clicking + button calls router.push with newTask params', () => { - useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) - useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } }) + setActiveStoryAndTasks(STORY_ID, []) renderPanel() const buttons = screen.getAllByText('+ Nieuwe taak') fireEvent.click(buttons[0]) @@ -108,16 +127,14 @@ describe('TaskPanel', () => { }) it('clicking task card calls router.push with editTask param', () => { - useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) - useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + setActiveStoryAndTasks(STORY_ID, TASKS) renderPanel() fireEvent.click(screen.getByText('Eerste taak')) expect(mockPush).toHaveBeenCalledWith(`${CLOSE_PATH}?editTask=task-1`) }) it('+ button is disabled in demo mode', () => { - useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) - useBacklogStore.setState({ tasksByStory: { [STORY_ID]: [] } }) + setActiveStoryAndTasks(STORY_ID, []) renderPanel(true) const btn = screen.getAllByText('+ Nieuwe taak')[0].closest('button') expect(btn).toBeTruthy() @@ -125,8 +142,7 @@ describe('TaskPanel', () => { }) it('cards have no drag listeners in demo mode (whole-card drag disabled)', () => { - useSelectionStore.setState({ selectedStoryId: STORY_ID, selectedPbiId: null }) - useBacklogStore.setState({ tasksByStory: { [STORY_ID]: TASKS } }) + setActiveStoryAndTasks(STORY_ID, TASKS) // In demo mode, listeners ({} from useSortable mock) are not spread onto the card. // The mock always returns empty listeners, so we just verify the cards render without error. renderPanel(true) diff --git a/__tests__/realtime/payload-contract.test.ts b/__tests__/realtime/payload-contract.test.ts deleted file mode 100644 index 3835903..0000000 --- a/__tests__/realtime/payload-contract.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest' -import { useBacklogStore } from '@/stores/backlog-store' -import type { BacklogPbi, BacklogStory, BacklogTask } from '@/stores/backlog-store' - -const PBI: BacklogPbi = { - id: 'pbi-1', - code: 'PBI-1', - title: 'Realtime PBI', - priority: 2, - description: 'desc', - created_at: new Date('2024-01-01T00:00:00Z'), - status: 'ready', -} - -const STORY: BacklogStory = { - id: 'story-1', - code: 'ST-1', - title: 'Realtime story', - description: null, - acceptance_criteria: null, - priority: 2, - status: 'OPEN', - pbi_id: 'pbi-1', - sprint_id: null, - created_at: new Date('2024-01-01T00:00:00Z'), -} - -const TASK: BacklogTask = { - id: 'task-1', - title: 'Realtime task', - description: null, - priority: 2, - status: 'TO_DO', - sort_order: 1, - story_id: 'story-1', - created_at: new Date('2024-01-01T00:00:00Z'), -} - -beforeEach(() => { - useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: {} }) -}) - -// --------------------------------------------------------------------------- -// PBI -// --------------------------------------------------------------------------- - -describe('PBI payload contract', () => { - it('INSERT: entity appears in pbis with correct title and status', () => { - useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI }) - const state = useBacklogStore.getState() - expect(state.pbis).toHaveLength(1) - expect(state.pbis[0].id).toBe('pbi-1') - expect(state.pbis[0].title).toBe('Realtime PBI') - expect(state.pbis[0].status).toBe('ready') - }) - - it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => { - useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI }) - useBacklogStore.getState().applyChange('pbi', 'I', { ...PBI }) - expect(useBacklogStore.getState().pbis).toHaveLength(1) - }) - - it('UPDATE: changed_fields partial merges into existing entity', () => { - useBacklogStore.setState({ pbis: [{ ...PBI }], storiesByPbi: {}, tasksByStory: {} }) - useBacklogStore.getState().applyChange('pbi', 'U', { id: 'pbi-1', title: 'Updated PBI', status: 'in_sprint' as const }) - const pbi = useBacklogStore.getState().pbis[0] - expect(pbi.title).toBe('Updated PBI') - expect(pbi.status).toBe('in_sprint') - expect(pbi.priority).toBe(2) // unchanged field retained - }) - - it('DELETE: entity is removed from pbis', () => { - useBacklogStore.setState({ pbis: [{ ...PBI }], storiesByPbi: {}, tasksByStory: {} }) - useBacklogStore.getState().applyChange('pbi', 'D', { id: 'pbi-1' }) - expect(useBacklogStore.getState().pbis).toHaveLength(0) - }) -}) - -// --------------------------------------------------------------------------- -// Story -// --------------------------------------------------------------------------- - -describe('Story payload contract', () => { - it('INSERT: entity appears in storiesByPbi[pbi_id] with correct title and status', () => { - useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [] }, tasksByStory: {} }) - useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) - const bucket = useBacklogStore.getState().storiesByPbi['pbi-1'] - expect(bucket).toHaveLength(1) - expect(bucket[0].id).toBe('story-1') - expect(bucket[0].title).toBe('Realtime story') - expect(bucket[0].status).toBe('OPEN') - }) - - it('INSERT: creates bucket when pbi_id was not yet in storiesByPbi', () => { - useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) - expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1) - }) - - it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => { - useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) - useBacklogStore.getState().applyChange('story', 'I', { ...STORY }) - expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(1) - }) - - it('UPDATE: changed_fields partial merges into existing story', () => { - useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [{ ...STORY }] }, tasksByStory: {} }) - useBacklogStore.getState().applyChange('story', 'U', { id: 'story-1', title: 'Updated story', status: 'IN_SPRINT' }) - const story = useBacklogStore.getState().storiesByPbi['pbi-1'][0] - expect(story.title).toBe('Updated story') - expect(story.status).toBe('IN_SPRINT') - expect(story.priority).toBe(2) // unchanged field retained - }) - - it('DELETE: entity is removed from its pbi bucket', () => { - useBacklogStore.setState({ pbis: [], storiesByPbi: { 'pbi-1': [{ ...STORY }] }, tasksByStory: {} }) - useBacklogStore.getState().applyChange('story', 'D', { id: 'story-1' }) - expect(useBacklogStore.getState().storiesByPbi['pbi-1']).toHaveLength(0) - }) -}) - -// --------------------------------------------------------------------------- -// Task -// --------------------------------------------------------------------------- - -describe('Task payload contract', () => { - it('INSERT: entity appears in tasksByStory[story_id] with correct title and status', () => { - useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [] } }) - useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) - const bucket = useBacklogStore.getState().tasksByStory['story-1'] - expect(bucket).toHaveLength(1) - expect(bucket[0].id).toBe('task-1') - expect(bucket[0].title).toBe('Realtime task') - expect(bucket[0].status).toBe('TO_DO') - }) - - it('INSERT: creates bucket when story_id was not yet in tasksByStory', () => { - useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) - expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(1) - }) - - it('INSERT is idempotent: duplicate SSE-event does not add a second entry', () => { - useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) - useBacklogStore.getState().applyChange('task', 'I', { ...TASK }) - expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(1) - }) - - it('UPDATE: changed_fields partial merges into existing task', () => { - useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [{ ...TASK }] } }) - useBacklogStore.getState().applyChange('task', 'U', { id: 'task-1', title: 'Updated task', status: 'IN_PROGRESS' }) - const task = useBacklogStore.getState().tasksByStory['story-1'][0] - expect(task.title).toBe('Updated task') - expect(task.status).toBe('IN_PROGRESS') - expect(task.sort_order).toBe(1) // unchanged field retained - }) - - it('DELETE: entity is removed from its story bucket', () => { - useBacklogStore.setState({ pbis: [], storiesByPbi: {}, tasksByStory: { 'story-1': [{ ...TASK }] } }) - useBacklogStore.getState().applyChange('task', 'D', { id: 'task-1' }) - expect(useBacklogStore.getState().tasksByStory['story-1']).toHaveLength(0) - }) -}) diff --git a/__tests__/realtime/use-workspace-resync.test.tsx b/__tests__/realtime/use-workspace-resync.test.tsx new file mode 100644 index 0000000..cbc50a5 --- /dev/null +++ b/__tests__/realtime/use-workspace-resync.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHook } from '@testing-library/react' + +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import { useWorkspaceResync } from '@/lib/realtime/use-workspace-resync' + +let resyncSpy: ReturnType + +beforeEach(() => { + resyncSpy = vi.fn().mockResolvedValue(undefined) + useProductWorkspaceStore.setState((s) => { + s.resyncActiveScopes = resyncSpy as unknown as typeof s.resyncActiveScopes + }) + // visibilitychange handler leest document.visibilityState — default is 'visible' + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('useWorkspaceResync', () => { + it('triggert resyncActiveScopes("visible") op visibilitychange hidden→visible', () => { + renderHook(() => useWorkspaceResync()) + + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }) + document.dispatchEvent(new Event('visibilitychange')) + + expect(resyncSpy).toHaveBeenCalledWith('visible') + }) + + it('triggert resyncActiveScopes("reconnect") op online-event', () => { + renderHook(() => useWorkspaceResync()) + window.dispatchEvent(new Event('online')) + expect(resyncSpy).toHaveBeenCalledWith('reconnect') + }) + + it('triggert geen resync bij visibilitychange naar hidden', () => { + renderHook(() => useWorkspaceResync()) + + Object.defineProperty(document, 'visibilityState', { + value: 'hidden', + writable: true, + configurable: true, + }) + document.dispatchEvent(new Event('visibilitychange')) + + expect(resyncSpy).not.toHaveBeenCalled() + }) + + it('cleanup verwijdert listeners bij unmount', () => { + const { unmount } = renderHook(() => useWorkspaceResync()) + unmount() + + window.dispatchEvent(new Event('online')) + document.dispatchEvent(new Event('visibilitychange')) + + expect(resyncSpy).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/stores/product-workspace/restore.test.ts b/__tests__/stores/product-workspace/restore.test.ts new file mode 100644 index 0000000..baa8120 --- /dev/null +++ b/__tests__/stores/product-workspace/restore.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest' + +import { + clearHints, + readHints, + writePbiHint, + writeProductHint, + writeStoryHint, + writeTaskHint, +} from '@/stores/product-workspace/restore' + +describe('readHints', () => { + it('retourneert lege defaults wanneer localStorage leeg is', () => { + const hints = readHints() + expect(hints.lastActiveProductId).toBeNull() + expect(hints.perProduct).toEqual({}) + }) + + it('herstelt hints uit localStorage', () => { + localStorage.setItem( + 'product-workspace-hints', + JSON.stringify({ + lastActiveProductId: 'p1', + perProduct: { p1: { lastActivePbiId: 'pbi-1' } }, + }), + ) + const hints = readHints() + expect(hints.lastActiveProductId).toBe('p1') + expect(hints.perProduct.p1.lastActivePbiId).toBe('pbi-1') + }) + + it('valt terug op defaults bij ongeldige JSON', () => { + localStorage.setItem('product-workspace-hints', '{not-json') + const hints = readHints() + expect(hints.lastActiveProductId).toBeNull() + expect(hints.perProduct).toEqual({}) + }) + + it('valt terug op defaults bij verkeerde shape', () => { + localStorage.setItem('product-workspace-hints', '"just a string"') + const hints = readHints() + expect(hints.perProduct).toEqual({}) + }) +}) + +describe('writeProductHint', () => { + it('schrijft lastActiveProductId', () => { + writeProductHint('p1') + expect(readHints().lastActiveProductId).toBe('p1') + }) + + it('overschrijft bestaande waarde', () => { + writeProductHint('p1') + writeProductHint('p2') + expect(readHints().lastActiveProductId).toBe('p2') + }) + + it('accepteert null om hint te wissen', () => { + writeProductHint('p1') + writeProductHint(null) + expect(readHints().lastActiveProductId).toBeNull() + }) +}) + +describe('writePbiHint', () => { + it('schrijft lastActivePbiId per productId', () => { + writePbiHint('prod-1', 'pbi-a') + writePbiHint('prod-2', 'pbi-b') + const hints = readHints() + expect(hints.perProduct['prod-1'].lastActivePbiId).toBe('pbi-a') + expect(hints.perProduct['prod-2'].lastActivePbiId).toBe('pbi-b') + }) + + it('null wist child story- en task-hints', () => { + writePbiHint('prod-1', 'pbi-1') + writeStoryHint('prod-1', 's-1') + writeTaskHint('prod-1', 't-1') + writePbiHint('prod-1', null) + const hints = readHints() + expect(hints.perProduct['prod-1'].lastActivePbiId).toBeNull() + expect(hints.perProduct['prod-1'].lastActiveStoryId).toBeNull() + expect(hints.perProduct['prod-1'].lastActiveTaskId).toBeNull() + }) +}) + +describe('writeStoryHint', () => { + it('schrijft lastActiveStoryId per productId', () => { + writeStoryHint('prod-1', 's-1') + expect(readHints().perProduct['prod-1'].lastActiveStoryId).toBe('s-1') + }) + + it('null wist child task-hint', () => { + writeStoryHint('prod-1', 's-1') + writeTaskHint('prod-1', 't-1') + writeStoryHint('prod-1', null) + expect(readHints().perProduct['prod-1'].lastActiveStoryId).toBeNull() + expect(readHints().perProduct['prod-1'].lastActiveTaskId).toBeNull() + }) +}) + +describe('writeTaskHint', () => { + it('schrijft lastActiveTaskId per productId', () => { + writeTaskHint('prod-1', 't-1') + expect(readHints().perProduct['prod-1'].lastActiveTaskId).toBe('t-1') + }) +}) + +describe('clearHints', () => { + it('verwijdert alle hints', () => { + writeProductHint('p1') + writePbiHint('p1', 'pbi-1') + clearHints() + const hints = readHints() + expect(hints.lastActiveProductId).toBeNull() + expect(hints.perProduct).toEqual({}) + }) +}) diff --git a/__tests__/stores/product-workspace/store.test.ts b/__tests__/stores/product-workspace/store.test.ts new file mode 100644 index 0000000..9f3cea7 --- /dev/null +++ b/__tests__/stores/product-workspace/store.test.ts @@ -0,0 +1,832 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import type { + BacklogPbi, + BacklogStory, + BacklogTask, + ProductBacklogSnapshot, + TaskDetail, +} from '@/stores/product-workspace/types' + +// G5: snapshot original actions on module-load; restore in beforeEach. +// vi.fn-spies on actions could leak across tests otherwise. +const originalActions = (() => { + const s = useProductWorkspaceStore.getState() + return { + hydrateSnapshot: s.hydrateSnapshot, + setActiveProduct: s.setActiveProduct, + setActivePbi: s.setActivePbi, + setActiveStory: s.setActiveStory, + setActiveTask: s.setActiveTask, + ensureProductLoaded: s.ensureProductLoaded, + ensurePbiLoaded: s.ensurePbiLoaded, + ensureStoryLoaded: s.ensureStoryLoaded, + ensureTaskLoaded: s.ensureTaskLoaded, + applyRealtimeEvent: s.applyRealtimeEvent, + resyncActiveScopes: s.resyncActiveScopes, + resyncLoadedScopes: s.resyncLoadedScopes, + applyOptimisticMutation: s.applyOptimisticMutation, + rollbackMutation: s.rollbackMutation, + settleMutation: s.settleMutation, + setRealtimeStatus: s.setRealtimeStatus, + } +})() + +function resetStore() { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = null + s.context.activePbiId = null + s.context.activeStoryId = null + s.context.activeTaskId = null + s.entities.pbisById = {} + s.entities.storiesById = {} + s.entities.tasksById = {} + s.relations.pbiIds = [] + s.relations.storyIdsByPbi = {} + s.relations.taskIdsByStory = {} + s.loading.loadedProductId = null + s.loading.loadingProductId = null + s.loading.loadedPbiIds = {} + s.loading.loadedStoryIds = {} + s.loading.loadedTaskIds = {} + s.loading.activeRequestId = null + s.sync.realtimeStatus = 'connecting' + s.sync.lastEventAt = null + s.sync.lastResyncAt = null + s.sync.resyncReason = null + s.pendingMutations = {} + Object.assign(s, originalActions) + }) +} + +beforeEach(() => { + resetStore() +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +function makePbi(overrides: Partial & { id: string }): BacklogPbi { + return { + id: overrides.id, + code: overrides.code ?? overrides.id, + title: overrides.title ?? `PBI ${overrides.id}`, + priority: overrides.priority ?? 2, + sort_order: overrides.sort_order ?? 1, + description: overrides.description ?? null, + created_at: overrides.created_at ?? new Date('2026-01-01'), + status: overrides.status ?? 'ready', + } +} + +function makeStory(overrides: Partial & { id: string; pbi_id: string }): BacklogStory { + return { + id: overrides.id, + code: overrides.code ?? overrides.id, + title: overrides.title ?? `Story ${overrides.id}`, + description: overrides.description ?? null, + acceptance_criteria: overrides.acceptance_criteria ?? null, + priority: overrides.priority ?? 2, + sort_order: overrides.sort_order ?? 1, + 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'), + } +} + +function makeTask(overrides: Partial & { id: string; story_id: string }): BacklogTask { + return { + id: overrides.id, + title: overrides.title ?? `Task ${overrides.id}`, + description: overrides.description ?? null, + priority: overrides.priority ?? 2, + sort_order: overrides.sort_order ?? 1, + status: overrides.status ?? 'todo', + story_id: overrides.story_id, + created_at: overrides.created_at ?? new Date('2026-01-01'), + } +} + +function snapshotWith( + pbis: BacklogPbi[], + storiesByPbi: Record = {}, + tasksByStory: Record = {}, + product?: { id: string; name: string }, +): ProductBacklogSnapshot { + return { product, pbis, storiesByPbi, tasksByStory } +} + +// G7: mock fetch — never let it fall through to real network +// G8: mockImplementation per call so each fetch gets a fresh Response +function mockFetchSequence( + responses: Array unknown)>, +) { + let i = 0 + return vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string, init?: RequestInit) => { + const r = responses[Math.min(i, responses.length - 1)] + i += 1 + const body = typeof r === 'function' ? (r as (u: string, i?: RequestInit) => unknown)(url, init) : r + return new Response(JSON.stringify(body ?? null), { status: 200 }) + }) as unknown as typeof fetch) +} + +// ───────────────────────────────────────────────────────────────────────── +// hydrateSnapshot +// ───────────────────────────────────────────────────────────────────────── + +describe('hydrateSnapshot', () => { + it('vult entities en relations met gesorteerde id-lijsten', () => { + const pbiA = makePbi({ id: 'pbi-a', priority: 2, sort_order: 2 }) + const pbiB = makePbi({ id: 'pbi-b', priority: 1, sort_order: 5 }) + const pbiC = makePbi({ id: 'pbi-c', priority: 2, sort_order: 1 }) + const storyB1 = makeStory({ id: 'st-1', pbi_id: 'pbi-b', sort_order: 2 }) + const storyB2 = makeStory({ id: 'st-2', pbi_id: 'pbi-b', sort_order: 1 }) + const taskA = makeTask({ id: 'tk-2', story_id: 'st-1', sort_order: 2 }) + const taskB = makeTask({ id: 'tk-1', story_id: 'st-1', sort_order: 1 }) + + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + [pbiA, pbiB, pbiC], + { 'pbi-b': [storyB1, storyB2] }, + { 'st-1': [taskA, taskB] }, + { id: 'prod-1', name: 'Product 1' }, + ), + ) + + const s = useProductWorkspaceStore.getState() + expect(s.entities.pbisById['pbi-a']).toBe(pbiA) + expect(s.entities.pbisById['pbi-b']).toBe(pbiB) + expect(s.entities.pbisById['pbi-c']).toBe(pbiC) + // pbi-b heeft priority 1 (komt eerst), dan pbi-c (sort_order 1) en pbi-a (sort_order 2) + expect(s.relations.pbiIds).toEqual(['pbi-b', 'pbi-c', 'pbi-a']) + expect(s.relations.storyIdsByPbi['pbi-b']).toEqual(['st-2', 'st-1']) + expect(s.relations.taskIdsByStory['st-1']).toEqual(['tk-1', 'tk-2']) + expect(s.context.activeProduct).toEqual({ id: 'prod-1', name: 'Product 1' }) + expect(s.loading.loadedProductId).toBe('prod-1') + }) + + it('reset bestaande entities en relations bij her-hydratie', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith([makePbi({ id: 'old-pbi' })]), + ) + expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['old-pbi']) + + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith([makePbi({ id: 'new-pbi' })]), + ) + const s = useProductWorkspaceStore.getState() + expect(s.entities.pbisById['old-pbi']).toBeUndefined() + expect(s.entities.pbisById['new-pbi']).toBeDefined() + expect(s.relations.pbiIds).toEqual(['new-pbi']) + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// Selection cascade +// ───────────────────────────────────────────────────────────────────────── + +describe('selection cascade', () => { + it('setActivePbi reset story+task; setActiveStory reset task', () => { + useProductWorkspaceStore.setState((s) => { + s.context.activePbiId = 'pbi-old' + s.context.activeStoryId = 'st-old' + s.context.activeTaskId = 'tk-old' + }) + + useProductWorkspaceStore.getState().setActivePbi('pbi-new') + let s = useProductWorkspaceStore.getState() + expect(s.context.activePbiId).toBe('pbi-new') + expect(s.context.activeStoryId).toBeNull() + expect(s.context.activeTaskId).toBeNull() + + useProductWorkspaceStore.setState((draft) => { + draft.context.activeStoryId = 'st-old' + draft.context.activeTaskId = 'tk-old' + }) + useProductWorkspaceStore.getState().setActiveStory('st-new') + s = useProductWorkspaceStore.getState() + expect(s.context.activeStoryId).toBe('st-new') + expect(s.context.activeTaskId).toBeNull() + }) + + it('setActiveProduct(null) ruimt entities en relations op', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + [makePbi({ id: 'p-1' })], + { 'p-1': [makeStory({ id: 's-1', pbi_id: 'p-1' })] }, + { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] }, + { id: 'prod-1', name: 'Product 1' }, + ), + ) + + useProductWorkspaceStore.getState().setActiveProduct(null) + const s = useProductWorkspaceStore.getState() + expect(s.context.activeProduct).toBeNull() + expect(s.context.activePbiId).toBeNull() + expect(s.context.activeStoryId).toBeNull() + expect(s.context.activeTaskId).toBeNull() + expect(s.entities.pbisById).toEqual({}) + expect(s.entities.storiesById).toEqual({}) + expect(s.entities.tasksById).toEqual({}) + expect(s.relations.pbiIds).toEqual([]) + expect(s.relations.storyIdsByPbi).toEqual({}) + expect(s.relations.taskIdsByStory).toEqual({}) + expect(s.loading.loadedProductId).toBeNull() + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// applyRealtimeEvent +// ───────────────────────────────────────────────────────────────────────── + +describe('applyRealtimeEvent — pbi', () => { + beforeEach(() => { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + }) + + it('I — voegt PBI toe en sorteert', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith([makePbi({ id: 'a', priority: 2, sort_order: 5 })]), + ) + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'pbi', + op: 'I', + id: 'b', + product_id: 'prod-1', + code: 'B', + title: 'New PBI', + priority: 1, + sort_order: 1, + created_at: new Date('2026-02-01').toISOString(), + status: 'ready', + }) + const s = useProductWorkspaceStore.getState() + expect(s.entities.pbisById['b']).toBeDefined() + expect(s.relations.pbiIds).toEqual(['b', 'a']) + }) + + it('I — idempotent voor bestaande id', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith([makePbi({ id: 'a' })]), + ) + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'pbi', + op: 'I', + id: 'a', + product_id: 'prod-1', + title: 'mutated', + }) + const s = useProductWorkspaceStore.getState() + expect(s.entities.pbisById['a'].title).toBe('PBI a') // niet overschreven + expect(s.relations.pbiIds).toEqual(['a']) + }) + + it('U — patch + her-sorteert', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith([ + makePbi({ id: 'a', priority: 2, sort_order: 1 }), + makePbi({ id: 'b', priority: 2, sort_order: 2 }), + ]), + ) + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'pbi', + op: 'U', + id: 'b', + product_id: 'prod-1', + priority: 1, + }) + const s = useProductWorkspaceStore.getState() + expect(s.relations.pbiIds).toEqual(['b', 'a']) + }) + + it('D — verwijdert PBI inclusief child stories en tasks', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + [makePbi({ id: 'p1' })], + { p1: [makeStory({ id: 's1', pbi_id: 'p1' })] }, + { s1: [makeTask({ id: 't1', story_id: 's1' })] }, + ), + ) + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'pbi', + op: 'D', + id: 'p1', + product_id: 'prod-1', + }) + const s = useProductWorkspaceStore.getState() + expect(s.entities.pbisById['p1']).toBeUndefined() + expect(s.entities.storiesById['s1']).toBeUndefined() + expect(s.entities.tasksById['t1']).toBeUndefined() + expect(s.relations.pbiIds).toEqual([]) + expect(s.relations.storyIdsByPbi['p1']).toBeUndefined() + expect(s.relations.taskIdsByStory['s1']).toBeUndefined() + }) + + it('D — clear actieve PBI selectie als die onder de verwijderde PBI viel', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith([makePbi({ id: 'p1' })]), + ) + useProductWorkspaceStore.setState((s) => { + s.context.activePbiId = 'p1' + s.context.activeStoryId = 's-x' + s.context.activeTaskId = 't-x' + }) + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'pbi', + op: 'D', + id: 'p1', + product_id: 'prod-1', + }) + const s = useProductWorkspaceStore.getState() + expect(s.context.activePbiId).toBeNull() + expect(s.context.activeStoryId).toBeNull() + expect(s.context.activeTaskId).toBeNull() + }) +}) + +describe('applyRealtimeEvent — story parent-move', () => { + beforeEach(() => { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + [makePbi({ id: 'p1' }), makePbi({ id: 'p2' })], + { + p1: [makeStory({ id: 's1', pbi_id: 'p1' })], + p2: [], + }, + ), + ) + }) + + it('U met andere pbi_id verplaatst story naar nieuwe parent-lijst', () => { + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'story', + op: 'U', + id: 's1', + product_id: 'prod-1', + pbi_id: 'p2', + }) + const s = useProductWorkspaceStore.getState() + expect(s.relations.storyIdsByPbi['p1']).toEqual([]) + expect(s.relations.storyIdsByPbi['p2']).toEqual(['s1']) + expect(s.entities.storiesById['s1'].pbi_id).toBe('p2') + }) +}) + +describe('applyRealtimeEvent — task parent-move', () => { + beforeEach(() => { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + [makePbi({ id: 'p1' })], + { p1: [makeStory({ id: 's1', pbi_id: 'p1' }), makeStory({ id: 's2', pbi_id: 'p1' })] }, + { + s1: [makeTask({ id: 't1', story_id: 's1' })], + s2: [], + }, + ), + ) + }) + + it('U met andere story_id verplaatst task naar nieuwe parent', () => { + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'task', + op: 'U', + id: 't1', + product_id: 'prod-1', + story_id: 's2', + }) + const s = useProductWorkspaceStore.getState() + expect(s.relations.taskIdsByStory['s1']).toEqual([]) + expect(s.relations.taskIdsByStory['s2']).toEqual(['t1']) + expect(s.entities.tasksById['t1'].story_id).toBe('s2') + }) +}) + +describe('applyRealtimeEvent — andere product genegeerd', () => { + it('event met ander product_id raakt de store niet', () => { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith([makePbi({ id: 'a' })]), + ) + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'pbi', + op: 'I', + id: 'b', + product_id: 'prod-2', + title: 'Other product', + priority: 1, + sort_order: 1, + }) + const s = useProductWorkspaceStore.getState() + expect(s.entities.pbisById['b']).toBeUndefined() + expect(s.relations.pbiIds).toEqual(['a']) + }) +}) + +describe('applyRealtimeEvent — unknown entity → resync trigger', () => { + function withSpy(): ReturnType { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + const spy = vi.fn().mockResolvedValue(undefined) + useProductWorkspaceStore.setState((s) => { + s.resyncActiveScopes = spy as unknown as typeof s.resyncActiveScopes + }) + return spy + } + + it('unknown entity (b.v. comment) met matching product triggert resync', () => { + const spy = withSpy() + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'comment', + op: 'I', + id: 'cm-1', + product_id: 'prod-1', + } as unknown as Record) + expect(spy).toHaveBeenCalledWith('unknown-event') + }) + + it('unknown entity met ander product_id triggert geen resync', () => { + const spy = withSpy() + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'comment', + op: 'I', + id: 'cm-1', + product_id: 'prod-2', + } as unknown as Record) + expect(spy).not.toHaveBeenCalled() + }) + + it('claude_job_status (type-veld) triggert geen resync', () => { + const spy = withSpy() + useProductWorkspaceStore.getState().applyRealtimeEvent({ + type: 'claude_job_status', + job_id: 'job-1', + product_id: 'prod-1', + status: 'queued', + } as unknown as Record) + expect(spy).not.toHaveBeenCalled() + }) + + it('worker_heartbeat (type-veld) triggert geen resync', () => { + const spy = withSpy() + useProductWorkspaceStore.getState().applyRealtimeEvent({ + type: 'worker_heartbeat', + worker_id: 'w-1', + product_id: 'prod-1', + } as unknown as Record) + expect(spy).not.toHaveBeenCalled() + }) + + it('claude_job_enqueued (type-veld) triggert geen resync', () => { + const spy = withSpy() + useProductWorkspaceStore.getState().applyRealtimeEvent({ + type: 'claude_job_enqueued', + job_id: 'job-2', + product_id: 'prod-1', + kind: 'PER_TASK', + } as unknown as Record) + expect(spy).not.toHaveBeenCalled() + }) + + it('payload zonder entity en zonder type wordt genegeerd', () => { + const spy = withSpy() + useProductWorkspaceStore.getState().applyRealtimeEvent({ + product_id: 'prod-1', + something: 'else', + } as unknown as Record) + expect(spy).not.toHaveBeenCalled() + }) + + it('question-event met entity-veld maar zonder pbi/story/task triggert resync', () => { + // question is geen pbi/story/task entity dus telt als unknown wanneer + // hij geen 'type' draagt — dat zou een nieuwe entiteit kunnen zijn die + // we nog niet kennen. + const spy = withSpy() + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'question', + op: 'I', + id: 'q-1', + product_id: 'prod-1', + } as unknown as Record) + expect(spy).toHaveBeenCalledWith('unknown-event') + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// ensure*Loaded fetches + race-safe guard + sortering +// ───────────────────────────────────────────────────────────────────────── + +describe('ensureProductLoaded', () => { + it('fetcht backlog snapshot en hydreert met sortering', async () => { + const snapshot: ProductBacklogSnapshot = { + product: { id: 'prod-1', name: 'Product 1' }, + pbis: [ + makePbi({ id: 'a', priority: 2, sort_order: 5 }), + makePbi({ id: 'b', priority: 1, sort_order: 9 }), + ], + storiesByPbi: {}, + tasksByStory: {}, + } + const fetchSpy = mockFetchSequence([snapshot]) + + await useProductWorkspaceStore.getState().ensureProductLoaded('prod-1') + + expect(fetchSpy).toHaveBeenCalledWith( + '/api/products/prod-1/backlog', + expect.objectContaining({ cache: 'no-store' }), + ) + const s = useProductWorkspaceStore.getState() + expect(s.relations.pbiIds).toEqual(['b', 'a']) + expect(s.loading.loadedProductId).toBe('prod-1') + expect(s.loading.loadedPbiIds['a']).toBe(true) + expect(s.loading.loadedPbiIds['b']).toBe(true) + }) +}) + +describe('race-safe ensure*Loaded — activeRequestId guard', () => { + it('oudere in-flight ensurePbiLoaded mag nieuwere selectie niet overschrijven', async () => { + let resolveOld: ((stories: BacklogStory[]) => void) | null = null + + vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string) => { + if (url === '/api/pbis/pbi-old/stories') { + const stories = await new Promise((resolve) => { + resolveOld = resolve + }) + return new Response(JSON.stringify(stories), { status: 200 }) + } + if (url === '/api/pbis/pbi-new/stories') { + return new Response( + JSON.stringify([makeStory({ id: 'new-st', pbi_id: 'pbi-new' })]), + { status: 200 }, + ) + } + return new Response('null', { status: 200 }) + }) as unknown as typeof fetch) + + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + s.context.activePbiId = 'pbi-old' + s.loading.activeRequestId = 'req-old' + }) + const oldPromise = useProductWorkspaceStore + .getState() + .ensurePbiLoaded('pbi-old', 'req-old') + + // gebruiker selecteert ondertussen pbi-new + useProductWorkspaceStore.setState((s) => { + s.context.activePbiId = 'pbi-new' + s.loading.activeRequestId = 'req-new' + }) + await useProductWorkspaceStore.getState().ensurePbiLoaded('pbi-new', 'req-new') + + expect(useProductWorkspaceStore.getState().entities.storiesById['new-st']).toBeDefined() + + // resolve de oude fetch — guard moet de stale data weigeren + resolveOld!([makeStory({ id: 'old-st', pbi_id: 'pbi-old' })]) + await oldPromise + + const s = useProductWorkspaceStore.getState() + expect(s.context.activePbiId).toBe('pbi-new') + expect(s.entities.storiesById['old-st']).toBeUndefined() + expect(s.entities.storiesById['new-st']).toBeDefined() + }) +}) + +describe('ensureTaskLoaded — zet detail-flag', () => { + it('verrijkt task naar TaskDetail met _detail: true', async () => { + mockFetchSequence([ + { + id: 't-1', + title: 'Task 1', + description: 'desc', + priority: 1, + sort_order: 1, + status: 'todo', + story_id: 's-1', + created_at: new Date('2026-02-01').toISOString(), + implementation_plan: 'detailed plan here', + }, + ]) + + await useProductWorkspaceStore.getState().ensureTaskLoaded('t-1') + const task = useProductWorkspaceStore.getState().entities.tasksById['t-1'] as TaskDetail + expect(task._detail).toBe(true) + expect(task.implementation_plan).toBe('detailed plan here') + expect(useProductWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true) + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// resyncActiveScopes +// ───────────────────────────────────────────────────────────────────────── + +describe('resyncActiveScopes', () => { + it('triggert ensure-keten voor alle actieve scopes en zet sync velden', async () => { + const fetchSpy = mockFetchSequence([ + // ensureProductLoaded + { product: { id: 'prod-1', name: 'P' }, pbis: [], storiesByPbi: {}, tasksByStory: {} }, + // ensurePbiLoaded + [], + // ensureStoryLoaded + [], + // ensureTaskLoaded + { + id: 't-1', + title: 'T', + description: null, + priority: 1, + sort_order: 1, + status: 'todo', + story_id: 's-1', + created_at: '2026-02-01', + }, + ]) + + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'P' } + s.context.activePbiId = 'pbi-1' + s.context.activeStoryId = 's-1' + s.context.activeTaskId = 't-1' + }) + + await useProductWorkspaceStore.getState().resyncActiveScopes('manual') + + const calls = fetchSpy.mock.calls.map(([url]) => url) + expect(calls).toContain('/api/products/prod-1/backlog') + expect(calls).toContain('/api/pbis/pbi-1/stories') + expect(calls).toContain('/api/stories/s-1/tasks') + expect(calls).toContain('/api/tasks/t-1') + + const s = useProductWorkspaceStore.getState() + expect(s.sync.lastResyncAt).toBeTypeOf('number') + expect(s.sync.resyncReason).toBe('manual') + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// 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( + snapshotWith([ + makePbi({ id: 'a', priority: 2, sort_order: 1 }), + makePbi({ id: 'b', priority: 2, sort_order: 2 }), + ]), + ) + const prevOrder = [...useProductWorkspaceStore.getState().relations.pbiIds] + + const id = useProductWorkspaceStore.getState().applyOptimisticMutation({ + kind: 'pbi-order', + prevPbiIds: prevOrder, + }) + // simuleer de optimistic order-wijziging buiten de mutation + useProductWorkspaceStore.setState((s) => { + s.relations.pbiIds = ['b', 'a'] + }) + expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['b', 'a']) + + useProductWorkspaceStore.getState().rollbackMutation(id) + expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(prevOrder) + expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeUndefined() + }) + + it('settle ruimt pending op zonder state te wijzigen', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith([makePbi({ id: 'a' })]), + ) + const id = useProductWorkspaceStore.getState().applyOptimisticMutation({ + kind: 'pbi-order', + prevPbiIds: ['a'], + }) + expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeDefined() + + useProductWorkspaceStore.getState().settleMutation(id) + expect(useProductWorkspaceStore.getState().pendingMutations[id]).toBeUndefined() + expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['a']) + }) + + it('SSE-echo van een al-bestaande PBI is idempotent', () => { + useProductWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'P' } + }) + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith([makePbi({ id: 'a', title: 'Origineel' })]), + ) + useProductWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'pbi', + op: 'I', + id: 'a', + product_id: 'prod-1', + title: 'echo', + }) + expect(useProductWorkspaceStore.getState().entities.pbisById['a'].title).toBe('Origineel') + expect(useProductWorkspaceStore.getState().relations.pbiIds).toEqual(['a']) + }) +}) diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index 8731a53..6acf005 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' @@ -59,6 +60,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props description: true, acceptance_criteria: true, priority: true, + sort_order: true, status: true, pbi_id: true, sprint_id: true, @@ -81,7 +83,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props }), ]) - // Group stories by PBI id + // Group stories by PBI id (status uit DB blijft UPPER_SNAKE in dit hydratie-pad) const storiesByPbi: Record = {} for (const story of stories) { if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = [] @@ -150,11 +152,12 @@ export default async function ProductBacklogPage({ params, searchParams }: Props ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), + pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, sort_order: p.sort_order, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), storiesByPbi, tasksByStory, }} > + t.status === 'DONE').length, @@ -148,6 +149,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { sprint_id: s.sprint_id, created_at: s.created_at, priority: s.priority, + sort_order: s.sort_order, status: s.status, taskCount: 0, doneCount: 0, diff --git a/app/(mobile)/m/products/[id]/page.tsx b/app/(mobile)/m/products/[id]/page.tsx index 620b891..7d33f06 100644 --- a/app/(mobile)/m/products/[id]/page.tsx +++ b/app/(mobile)/m/products/[id]/page.tsx @@ -15,6 +15,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' @@ -49,6 +50,7 @@ export default async function MobileProductBacklogPage({ params, searchParams }: description: true, acceptance_criteria: true, priority: true, + sort_order: true, status: true, pbi_id: true, sprint_id: true, @@ -90,11 +92,12 @@ export default async function MobileProductBacklogPage({ params, searchParams }: ({ id: p.id, code: p.code, title: p.title, priority: p.priority, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), + pbis: pbis.map((p) => ({ id: p.id, code: p.code, title: p.title, priority: p.priority, sort_order: p.sort_order, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), storiesByPbi, tasksByStory, }} > + }, +) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + + const { id } = await params + + const pbi = await prisma.pbi.findFirst({ + where: { id, product: productAccessFilter(auth.userId) }, + select: { id: true }, + }) + if (!pbi) { + return Response.json({ error: 'PBI niet gevonden' }, { status: 404 }) + } + + const stories = await prisma.story.findMany({ + where: { pbi_id: id }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }, { created_at: 'asc' }], + select: { + id: true, + code: true, + title: true, + description: true, + acceptance_criteria: true, + priority: true, + sort_order: true, + status: true, + pbi_id: true, + sprint_id: true, + created_at: true, + }, + }) + + return Response.json( + stories.map((s) => ({ ...s, status: storyStatusToApi(s.status) })), + ) +} diff --git a/app/api/products/[id]/backlog/route.ts b/app/api/products/[id]/backlog/route.ts new file mode 100644 index 0000000..14ef956 --- /dev/null +++ b/app/api/products/[id]/backlog/route.ts @@ -0,0 +1,100 @@ +// PBI-74 / T-870: GET /api/products/:id/backlog +// +// Levert een volledige ProductBacklogSnapshot voor de workspace-store +// (ensureProductLoaded). Auth + access-control consistent met andere +// product-routes (authenticateApiRequest + productAccessFilter). +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' +import { pbiStatusToApi, storyStatusToApi, taskStatusToApi } from '@/lib/task-status' + +export const dynamic = 'force-dynamic' + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + + const { id } = await params + + const product = await prisma.product.findFirst({ + where: { id, ...productAccessFilter(auth.userId) }, + select: { id: true, name: true }, + }) + if (!product) { + return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) + } + + const [pbis, stories, tasks] = await Promise.all([ + prisma.pbi.findMany({ + where: { product_id: id }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }, { created_at: 'asc' }], + select: { + id: true, + code: true, + title: true, + priority: true, + sort_order: true, + description: true, + created_at: true, + status: true, + }, + }), + prisma.story.findMany({ + where: { product_id: id }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }, { created_at: 'asc' }], + select: { + id: true, + code: true, + title: true, + description: true, + acceptance_criteria: true, + priority: true, + sort_order: true, + status: true, + pbi_id: true, + sprint_id: true, + created_at: true, + }, + }), + prisma.task.findMany({ + where: { story: { product_id: id } }, + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + select: { + id: true, + title: true, + description: true, + priority: true, + sort_order: true, + status: true, + story_id: true, + created_at: true, + }, + }), + ]) + + const storiesByPbi: Record = {} + for (const story of stories) { + const apiStory = { ...story, status: storyStatusToApi(story.status) } + if (!storiesByPbi[story.pbi_id]) storiesByPbi[story.pbi_id] = [] + storiesByPbi[story.pbi_id].push(apiStory) + } + + const tasksByStory: Record = {} + for (const task of tasks) { + const apiTask = { ...task, status: taskStatusToApi(task.status) } + if (!tasksByStory[task.story_id]) tasksByStory[task.story_id] = [] + tasksByStory[task.story_id].push(apiTask) + } + + return Response.json({ + product, + pbis: pbis.map((p) => ({ ...p, status: pbiStatusToApi(p.status) })), + storiesByPbi, + tasksByStory, + }) +} diff --git a/app/api/stories/[id]/tasks/route.ts b/app/api/stories/[id]/tasks/route.ts new file mode 100644 index 0000000..9e437a4 --- /dev/null +++ b/app/api/stories/[id]/tasks/route.ts @@ -0,0 +1,49 @@ +// PBI-74 / T-870: GET /api/stories/:id/tasks +// +// Levert tasks binnen een story voor ensureStoryLoaded. Access-control via +// product-eigenaarschap van de bovenliggende story. +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' +import { taskStatusToApi } from '@/lib/task-status' + +export const dynamic = 'force-dynamic' + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + + const { id } = await params + + const story = await prisma.story.findFirst({ + where: { id, product: productAccessFilter(auth.userId) }, + select: { id: true }, + }) + if (!story) { + return Response.json({ error: 'Story niet gevonden' }, { status: 404 }) + } + + const tasks = await prisma.task.findMany({ + where: { story_id: id }, + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + select: { + id: true, + title: true, + description: true, + priority: true, + sort_order: true, + status: true, + story_id: true, + created_at: true, + }, + }) + + return Response.json( + tasks.map((t) => ({ ...t, status: taskStatusToApi(t.status) })), + ) +} diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts index 4bb2611..52cce01 100644 --- a/app/api/tasks/[id]/route.ts +++ b/app/api/tasks/[id]/route.ts @@ -3,6 +3,56 @@ import { prisma } from '@/lib/prisma' import { z } from 'zod' import { TASK_STATUS_API_VALUES, taskStatusFromApi, taskStatusToApi } from '@/lib/task-status' import { propagateStatusUpwards } from '@/lib/tasks-status-update' +import { productAccessFilter } from '@/lib/product-access' + +// PBI-74 / T-869: force-dynamic zodat Next geen response-cache hangt aan +// deze route — workspace-store leest hier verse data via ensureTaskLoaded. +export const dynamic = 'force-dynamic' + +// PBI-74 / T-870: GET-handler voor ensureTaskLoaded. Levert TaskDetail-shape +// (extends BacklogTask met implementation_plan etc.). Access-control via +// product van de parent-story. +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const auth = await authenticateApiRequest(request) + if ('error' in auth) { + return Response.json({ error: auth.error }, { status: auth.status }) + } + + const { id } = await params + + const task = await prisma.task.findFirst({ + where: { + id, + story: { product: productAccessFilter(auth.userId) }, + }, + select: { + id: true, + title: true, + description: true, + priority: true, + sort_order: true, + status: true, + story_id: true, + created_at: true, + implementation_plan: true, + requires_opus: true, + verify_only: true, + verify_required: true, + }, + }) + if (!task) { + return Response.json({ error: 'Task niet gevonden' }, { status: 404 }) + } + + return Response.json({ + ...task, + status: taskStatusToApi(task.status), + _detail: true, + }) +} // `review` is a valid TaskStatus in the DB and the kanban-board UI, but the // sprint task list (components/sprint/task-list.tsx) does not yet render it. diff --git a/components/backlog/backlog-hydration-wrapper.tsx b/components/backlog/backlog-hydration-wrapper.tsx index f2c6cf0..30124d3 100644 --- a/components/backlog/backlog-hydration-wrapper.tsx +++ b/components/backlog/backlog-hydration-wrapper.tsx @@ -1,8 +1,15 @@ 'use client' import { useEffect, useRef } from 'react' -import { useBacklogStore, type BacklogPbi, type BacklogStory, type BacklogTask } from '@/stores/backlog-store' import { useBacklogRealtime } from '@/lib/realtime/use-backlog-realtime' +import { useWorkspaceResync } from '@/lib/realtime/use-workspace-resync' +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import type { + BacklogPbi, + BacklogStory, + BacklogTask, + ProductBacklogSnapshot, +} from '@/stores/product-workspace/types' interface InitialData { pbis: BacklogPbi[] @@ -13,6 +20,7 @@ interface InitialData { interface BacklogHydrationWrapperProps { initialData: InitialData productId: string + productName?: string children: React.ReactNode } @@ -27,19 +35,40 @@ function fingerprint(data: InitialData): string { return `${pbiPart}|${storyPart}|${taskPart}` } -export function BacklogHydrationWrapper({ initialData, productId, children }: BacklogHydrationWrapperProps) { - const setInitialData = useBacklogStore((s) => s.setInitialData) +// PBI-74 / Story 8: workspace-store is nu enige bron — dual-dispatch weg. +function toWorkspaceSnapshot( + data: InitialData, + productId: string, + productName: string | undefined, +): ProductBacklogSnapshot { + return { + product: { id: productId, name: productName ?? '' }, + pbis: data.pbis, + storiesByPbi: data.storiesByPbi, + tasksByStory: data.tasksByStory, + } +} + +export function BacklogHydrationWrapper({ + initialData, + productId, + productName, + children, +}: BacklogHydrationWrapperProps) { const lastFingerprint = useRef('') useEffect(() => { const fp = fingerprint(initialData) if (fp !== lastFingerprint.current) { lastFingerprint.current = fp - setInitialData(initialData) + useProductWorkspaceStore + .getState() + .hydrateSnapshot(toWorkspaceSnapshot(initialData, productId, productName)) } - }, [initialData, setInitialData]) + }, [initialData, productId, productName]) useBacklogRealtime(productId) + useWorkspaceResync() return <>{children} } diff --git a/components/backlog/backlog-split-pane.tsx b/components/backlog/backlog-split-pane.tsx index 882f13b..8a82a95 100644 --- a/components/backlog/backlog-split-pane.tsx +++ b/components/backlog/backlog-split-pane.tsx @@ -1,13 +1,16 @@ 'use client' import { useState } from 'react' -import { useSelectionStore } from '@/stores/selection-store' +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' import { SplitPane, type SplitPaneProps } from '@/components/split-pane/split-pane' type Props = Omit +// PBI-74 / T-848: leest active PBI/story-ids uit workspace-store. Primitives, +// dus geen useShallow nodig. export function BacklogSplitPane(props: Props) { - const { selectedPbiId, selectedStoryId } = useSelectionStore() + const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId) + const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId) const [activeTab, setActiveTab] = useState(0) // React-recommended "derived state from props" pattern: update state during render diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx index 77d8511..d51a838 100644 --- a/components/backlog/pbi-list.tsx +++ b/components/backlog/pbi-list.tsx @@ -25,9 +25,10 @@ import { CheckSquare, Square } from 'lucide-react' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { useSelectionStore } from '@/stores/selection-store' -import { usePlannerStore } from '@/stores/planner-store' -import { useBacklogStore } from '@/stores/backlog-store' +import { useShallow } from 'zustand/react/shallow' +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import { selectVisiblePbis } from '@/stores/product-workspace/selectors' +import type { BacklogPbi as WorkspacePbi } from '@/stores/product-workspace/types' import { deletePbiAction } from '@/actions/pbis' import { reorderPbisAction, updatePbiPriorityAction } from '@/actions/stories' import { cn } from '@/lib/utils' @@ -235,10 +236,13 @@ function SortablePbiRow({ } // --- Main component --- +// PBI-74 / T-849: leest pbis + actieve selectie uit workspace-store via +// useShallow-selector. DnD-mutaties via applyOptimisticMutation/rollback/settle. export function PbiList({ productId, isDemo }: PbiListProps) { - const pbis = useBacklogStore((s) => s.pbis) - const { selectedPbiId, selectPbi } = useSelectionStore() - const { pbiOrder, pbiPriority, initPbis, reorderPbis, rollbackPbis, updatePbiPriority } = usePlannerStore() + // selectVisiblePbis is gesorteerd op priority/sort_order; useShallow + // voorkomt re-render op ongerelateerde store-mutaties (G2). + const pbis = useProductWorkspaceStore(useShallow(selectVisiblePbis)) as WorkspacePbi[] + const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId) // Defaults match SSR; persisted values applied post-mount in the loader effect below. // This avoids hydration mismatch when localStorage holds non-default values. const [filterPriority, setFilterPriority] = useState('all') @@ -295,22 +299,10 @@ export function PbiList({ productId, isDemo }: PbiListProps) { useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_filter_status', filterStatus) }, [filterStatus, prefsLoaded]) useEffect(() => { if (prefsLoaded) localStorage.setItem('scrum4me:pbi_sort_dir', sortDir) }, [sortDir, prefsLoaded]) - // Sync server data into store — use stable string dep to avoid infinite loop - const pbiIdKey = pbis.map(p => p.id).join(',') - useEffect(() => { - initPbis(productId, pbiIdKey ? pbiIdKey.split(',') : []) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [productId, pbiIdKey]) - - // Build ordered PBI list from store (or fall back to server order) - const order = pbiOrder[productId] ?? pbis.map(p => p.id) + // pbis komen al gesorteerd binnen via selectVisiblePbis (priority + sort_order). + // Geen aparte order/priority maps meer — workspace-store entities zijn de waarheid. const pbiMap = Object.fromEntries(pbis.map(p => [p.id, p])) - - // Apply priority overrides from store - const orderedPbis = order - .map(id => pbiMap[id]) - .filter(Boolean) - .map(p => ({ ...p, priority: pbiPriority[p.id] ?? p.priority })) + const orderedPbis = pbis const base = orderedPbis.filter(p => { if (filterPriority !== 'all' && p.priority !== filterPriority) return false @@ -353,30 +345,58 @@ export function PbiList({ productId, isDemo }: PbiListProps) { const overPbi = pbiMap[over.id as string] if (!activePbi || !overPbi) return - const prevOrder = [...order] - const oldIndex = order.indexOf(active.id as string) - const newIndex = order.indexOf(over.id as string) - const newOrder = arrayMove([...order], oldIndex, newIndex) + const store = useProductWorkspaceStore.getState() + const prevOrder = [...store.relations.pbiIds] + const oldIndex = prevOrder.indexOf(active.id as string) + const newIndex = prevOrder.indexOf(over.id as string) + if (oldIndex === -1 || newIndex === -1) return + const newOrder = arrayMove([...prevOrder], oldIndex, newIndex) - // Optimistic update - reorderPbis(productId, newOrder) + // Snapshot rollback-info en pas optimistisch toe. + const orderMutationId = store.applyOptimisticMutation({ + kind: 'pbi-order', + prevPbiIds: prevOrder, + }) + useProductWorkspaceStore.setState((s) => { + s.relations.pbiIds = newOrder + }) const priorityChanged = activePbi.priority !== overPbi.priority + let priorityMutationId: string | null = null + if (priorityChanged) { + priorityMutationId = store.applyOptimisticMutation({ + kind: 'entity-patch', + entity: 'pbi', + id: active.id as string, + prev: store.entities.pbisById[active.id as string], + }) + useProductWorkspaceStore.setState((s) => { + const pbi = s.entities.pbisById[active.id as string] + if (pbi) pbi.priority = overPbi.priority + }) + } startTransition(async () => { + const settle = () => { + const st = useProductWorkspaceStore.getState() + if (priorityMutationId) st.settleMutation(priorityMutationId) + st.settleMutation(orderMutationId) + } + const rollback = (msg: string) => { + const st = useProductWorkspaceStore.getState() + if (priorityMutationId) st.rollbackMutation(priorityMutationId) + st.rollbackMutation(orderMutationId) + toast.error(msg) + } + if (priorityChanged) { - updatePbiPriority(active.id as string, overPbi.priority) const result = await updatePbiPriorityAction(active.id as string, overPbi.priority, productId) - if (!result.success) { - rollbackPbis(productId, prevOrder) - toast.error('Prioriteit opslaan mislukt') - } + if (result.success) settle() + else rollback('Prioriteit opslaan mislukt') } else { const result = await reorderPbisAction(productId, newOrder) - if (!result.success) { - rollbackPbis(productId, prevOrder) - toast.error('Volgorde opslaan mislukt') - } + if (result.success) settle() + else rollback('Volgorde opslaan mislukt') } }) } @@ -384,7 +404,9 @@ export function PbiList({ productId, isDemo }: PbiListProps) { function handleDelete(id: string) { startTransition(async () => { await deletePbiAction(id) - if (selectedPbiId === id) selectPbi(null) + if (selectedPbiId === id) { + useProductWorkspaceStore.getState().setActivePbi(null) + } }) } @@ -561,7 +583,7 @@ export function PbiList({ productId, isDemo }: PbiListProps) { isDemo={isDemo} selectionMode={selectionMode} isChecked={selectedIds.has(pbi.id)} - onSelect={() => selectPbi(pbi.id)} + onSelect={() => useProductWorkspaceStore.getState().setActivePbi(pbi.id)} onToggleCheck={() => toggleCheck(pbi.id)} onEdit={() => setDialogState({ mode: 'edit', productId, pbi })} onDelete={() => handleDelete(pbi.id)} diff --git a/components/backlog/story-panel.tsx b/components/backlog/story-panel.tsx index c1dd2c1..87db38d 100644 --- a/components/backlog/story-panel.tsx +++ b/components/backlog/story-panel.tsx @@ -25,9 +25,10 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { PanelNavBar } from '@/components/shared/panel-nav-bar' -import { useSelectionStore } from '@/stores/selection-store' -import { usePlannerStore } from '@/stores/planner-store' -import { useBacklogStore } from '@/stores/backlog-store' +import { useShallow } from 'zustand/react/shallow' +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import { selectStoriesForActivePbi } from '@/stores/product-workspace/selectors' +import type { BacklogStory as WorkspaceStory } from '@/stores/product-workspace/types' import { reorderStoriesAction } from '@/actions/stories' import { StoryDialog, type StoryDialogState } from './story-dialog' import { debugProps } from '@/lib/debug' @@ -55,6 +56,7 @@ export interface Story { description: string | null acceptance_criteria: string | null priority: number + sort_order: number status: string pbi_id: string sprint_id: string | null @@ -122,10 +124,12 @@ function SortableStoryBlock({ } // --- Main component --- +// PBI-74 / T-850: leest stories voor active PBI via selectStoriesForActivePbi +// (useShallow). DnD via applyOptimisticMutation('story-order'). export function StoryPanel({ productId, isDemo }: StoryPanelProps) { - const { selectedPbiId, selectedStoryId, selectStory } = useSelectionStore() - const storiesByPbi = useBacklogStore((s) => s.storiesByPbi) - const { storyOrder, initStories, reorderStories, rollbackStories } = usePlannerStore() + const selectedPbiId = useProductWorkspaceStore((s) => s.context.activePbiId) + const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId) + const rawStories = useProductWorkspaceStore(useShallow(selectStoriesForActivePbi)) as WorkspaceStory[] const [filterStatus, setFilterStatus] = useState(null) const [filterPriority, setFilterPriority] = useState(null) const [sortMode, setSortMode] = useState(() => { @@ -138,20 +142,9 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) { useEffect(() => { localStorage.setItem('scrum4me:story_sort', sortMode) }, [sortMode]) - const rawStories = selectedPbiId ? (storiesByPbi[selectedPbiId] ?? []) : [] - - // Sync into store — use stable string dep to avoid infinite loop - const storyIdKey = rawStories.map(s => s.id).join(',') - useEffect(() => { - if (selectedPbiId) { - initStories(selectedPbiId, storyIdKey ? storyIdKey.split(',') : []) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedPbiId, storyIdKey]) - + // rawStories komt al gesorteerd binnen via selectStoriesForActivePbi. const storyMap = Object.fromEntries(rawStories.map(s => [s.id, s])) - const order = (selectedPbiId ? storyOrder[selectedPbiId] : null) ?? rawStories.map(s => s.id) - const orderedStories = order.map(id => storyMap[id]).filter(Boolean) + const orderedStories = rawStories const base = orderedStories .filter(s => !filterStatus || s.status === filterStatus) @@ -185,14 +178,36 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) { const overStory = storyMap[over.id as string] if (!activeStory || !overStory) return - const prevOrder = [...order] - const oldIndex = order.indexOf(active.id as string) - const newIndex = order.indexOf(over.id as string) - const newOrder = arrayMove([...order], oldIndex, newIndex) + const store = useProductWorkspaceStore.getState() + const prevOrder = [...(store.relations.storyIdsByPbi[selectedPbiId] ?? [])] + const oldIndex = prevOrder.indexOf(active.id as string) + const newIndex = prevOrder.indexOf(over.id as string) + if (oldIndex === -1 || newIndex === -1) return + const newOrder = arrayMove([...prevOrder], oldIndex, newIndex) - reorderStories(selectedPbiId, newOrder) + const orderMutationId = store.applyOptimisticMutation({ + kind: 'story-order', + pbiId: selectedPbiId, + prevStoryIds: prevOrder, + }) + useProductWorkspaceStore.setState((s) => { + s.relations.storyIdsByPbi[selectedPbiId] = newOrder + }) const priorityChanged = activeStory.priority !== overStory.priority + let priorityMutationId: string | null = null + if (priorityChanged) { + priorityMutationId = store.applyOptimisticMutation({ + kind: 'entity-patch', + entity: 'story', + id: active.id as string, + prev: store.entities.storiesById[active.id as string], + }) + useProductWorkspaceStore.setState((s) => { + const story = s.entities.storiesById[active.id as string] + if (story) story.priority = overStory.priority + }) + } startTransition(async () => { const result = await reorderStoriesAction( @@ -201,8 +216,13 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) { newOrder, priorityChanged ? overStory.priority : undefined ) - if (!result.success) { - rollbackStories(selectedPbiId, prevOrder) + const st = useProductWorkspaceStore.getState() + if (result.success) { + if (priorityMutationId) st.settleMutation(priorityMutationId) + st.settleMutation(orderMutationId) + } else { + if (priorityMutationId) st.rollbackMutation(priorityMutationId) + st.rollbackMutation(orderMutationId) toast.error('Volgorde opslaan mislukt') } }) @@ -284,7 +304,7 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) { key={story.id} story={story} isSelected={selectedStoryId === story.id} - onSelect={() => selectStory(story.id)} + onSelect={() => useProductWorkspaceStore.getState().setActiveStory(story.id)} onEdit={() => setStoryDialogState({ mode: 'edit', story, productId })} /> ))} diff --git a/components/backlog/task-panel.tsx b/components/backlog/task-panel.tsx index 4f4f524..622286e 100644 --- a/components/backlog/task-panel.tsx +++ b/components/backlog/task-panel.tsx @@ -26,8 +26,13 @@ import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { DemoTooltip } from '@/components/shared/demo-tooltip' -import { useSelectionStore } from '@/stores/selection-store' -import { useBacklogStore, type BacklogTask } from '@/stores/backlog-store' +import { useShallow } from 'zustand/react/shallow' +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import { selectTasksForActiveStory } from '@/stores/product-workspace/selectors' +import type { + BacklogTask, + TaskDetail, +} from '@/stores/product-workspace/types' import { reorderTasksAction } from '@/actions/tasks' import { BacklogCard } from './backlog-card' import { debugProps } from '@/lib/debug' @@ -52,7 +57,7 @@ function SortableTaskCard({ isDemo, onClick, }: { - task: BacklogTask + task: BacklogTask | TaskDetail isDemo: boolean onClick: () => void }) { @@ -94,22 +99,20 @@ interface TaskPanelProps { closePath: string } +// PBI-74 / T-851: leest tasks voor active story via selectTasksForActiveStory +// (useShallow). DnD via applyOptimisticMutation('task-order'). Detail-view +// (ensureTaskLoaded + isDetail()) zit in de task-dialog, niet in deze lijst. export function TaskPanel({ isDemo, closePath }: TaskPanelProps) { const router = useRouter() const [, startTransition] = useTransition() - const selectedStoryId = useSelectionStore((s) => s.selectedStoryId) - const tasksByStory = useBacklogStore((s) => s.tasksByStory) + const selectedStoryId = useProductWorkspaceStore((s) => s.context.activeStoryId) + const rawTasks = useProductWorkspaceStore(useShallow(selectTasksForActiveStory)) as + | (BacklogTask | TaskDetail)[] const [activeDragId, setActiveDragId] = useState(null) - const [localOrder, setLocalOrder] = useState(null) - const rawTasks = selectedStoryId ? (tasksByStory[selectedStoryId] ?? []) : null - - // Merge local order with rawTasks for optimistic reorder - const tasks: BacklogTask[] | null = rawTasks === null - ? null - : localOrder - ? localOrder.map((id) => rawTasks.find((t) => t.id === id)).filter(Boolean) as BacklogTask[] - : rawTasks + const tasks: (BacklogTask | TaskDetail)[] | null = selectedStoryId + ? rawTasks + : null const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), @@ -126,19 +129,30 @@ export function TaskPanel({ isDemo, closePath }: TaskPanelProps) { const { active, over } = event if (!over || active.id === over.id) return - const ids = tasks.map((t) => t.id) - const oldIndex = ids.indexOf(active.id as string) - const newIndex = ids.indexOf(over.id as string) + const store = useProductWorkspaceStore.getState() + const prevOrder = [...(store.relations.taskIdsByStory[selectedStoryId] ?? [])] + const oldIndex = prevOrder.indexOf(active.id as string) + const newIndex = prevOrder.indexOf(over.id as string) if (oldIndex === -1 || newIndex === -1) return + const newOrder = arrayMove([...prevOrder], oldIndex, newIndex) - const newOrder = arrayMove(ids, oldIndex, newIndex) - setLocalOrder(newOrder) + const orderMutationId = store.applyOptimisticMutation({ + kind: 'task-order', + storyId: selectedStoryId, + prevTaskIds: prevOrder, + }) + useProductWorkspaceStore.setState((s) => { + s.relations.taskIdsByStory[selectedStoryId] = newOrder + }) startTransition(async () => { const result = await reorderTasksAction(selectedStoryId, newOrder) + const st = useProductWorkspaceStore.getState() if (result?.error) { - setLocalOrder(null) + st.rollbackMutation(orderMutationId) toast.error(result.error) + } else { + st.settleMutation(orderMutationId) } }) } diff --git a/components/backlog/url-task-sync.tsx b/components/backlog/url-task-sync.tsx new file mode 100644 index 0000000..70e4c76 --- /dev/null +++ b/components/backlog/url-task-sync.tsx @@ -0,0 +1,32 @@ +'use client' + +// PBI-74 / T-859: URL-prioriteit boven restore-hint. +// +// Als de route `?editTask=` 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/components/shared/set-current-product.tsx b/components/shared/set-current-product.tsx index 1ff4684..63aa151 100644 --- a/components/shared/set-current-product.tsx +++ b/components/shared/set-current-product.tsx @@ -1,16 +1,18 @@ 'use client' import { useEffect } from 'react' -import { useProductStore } from '@/stores/product-store' +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' import { debugProps } from '@/lib/debug' +// PBI-74 / T-853: workspace-store is nu enige bron voor active product. +// De voorganger (stores/product-store.ts) wordt in Story 8 (T-876) verwijderd. export function SetCurrentProduct({ id, name }: { id: string; name: string }) { - const { setCurrentProduct, clearCurrentProduct } = useProductStore() - useEffect(() => { - setCurrentProduct(id, name) - return () => clearCurrentProduct() - }, [id, name, setCurrentProduct, clearCurrentProduct]) + useProductWorkspaceStore.getState().setActiveProduct({ id, name }) + return () => { + useProductWorkspaceStore.getState().setActiveProduct(null) + } + }, [id, name]) return