From 98ee05d458b291916653a96eee86f7d4918c39f3 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Sun, 10 May 2026 06:53:04 +0200 Subject: [PATCH] feat(PBI-74): sprint-workspace-store (Story 9) (#181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(PBI-74): sprint-workspace-store skelet (Story 9 / T-879) - stores/sprint-workspace/{types,store,selectors,restore}.ts conform product-workspace blueprint - ContextSlice: activeProduct, activeSprintId, activeStoryId, activeTaskId - EntitiesSlice: sprintsById, storiesById, tasksById - RelationsSlice: sprintIdsByProduct, storyIdsBySprint, taskIdsByStory - LoadingSlice met activeRequestId voor race-safe ensure*Loaded - SyncSlice: realtimeStatus, lastResyncAt, resyncReason - Realtime applyRealtimeEvent voor sprint/story/task entities + unknown-event fallback, parent-move handling, child-cleanup bij D op sprint/story - Optimistic mutations: sprint-story-order, sprint-task-order, entity-patch - LocalStorage hints (storage key sprint-workspace-hints) per product/sprint - 45 unit-tests groen — verplicht 13 cases uit workspace-store.md §Tests * feat(PBI-74): sprint hydratie + realtime SSE (Story 9 / T-880) - app/api/realtime/sprint/route.ts: SSE-stream LISTEN/NOTIFY op scrum4me_changes, filter entity ∈ {sprint, story, task} per product_id; ready-event, heartbeat 25s, hard-close 240s - lib/realtime/use-sprint-realtime.ts: client-hook met backoff-reconnect; ready-cycle telt; geen close op hidden; setRealtimeStatus - lib/realtime/use-sprint-workspace-resync.ts: visibility + online triggers resyncActiveScopes('visible' | 'reconnect') - components/sprint/sprint-hydration-wrapper.tsx: hydrateSnapshot via useEffect met fingerprint-check; mount realtime + resync - app/(app)/products/[id]/sprint/[sprintId]/page.tsx: wrap SprintBoardClient in SprintHydrationWrapper; bouw SprintWorkspaceTask-shape voor tasksByStoryWorkspace en SprintHydrationData voor de wrapper Schaduw-fase: useSprintStore blijft parallel werken in board components totdat T-881 die migreert en T-883 de oude store opruimt. * feat(PBI-74): migreer sprint-board componenten naar workspace-store (Story 9 / T-881) - TaskList: leest tasks via selectTasksForStory met useShallow; DnD via applyOptimisticMutation('sprint-task-order') + settle/rollback - SprintBacklogLeft: leest stories via selectStoriesForActiveSprint met useShallow; props 'stories' verwijderd - SprintBoardClient: leest sprintStories uit selector i.p.v. lokale state; add/remove via direct setState met manuele snapshot-rollback; reorder via applyOptimisticMutation('sprint-story-order'); assignee- change via store entity-mutation; tasksByStory en sprintStoryIdList props weg - app/(app)/.../sprint/[sprintId]/page.tsx: bouwt SprintHydrationData voor wrapper; geeft alleen non-store props door aan SprintBoardClient useSprintStore wordt nergens meer geïmporteerd — alleen comment-referentie in SprintHydrationWrapper. Cleanup van het bestand zelf in T-883. Verify groen (671 tests, typecheck, lint clean). * feat(PBI-74): read-routes voor sprint-workspace + cache-headers (Story 9 / T-882) - GET /api/products/[id]/sprints — lijst sprints per product (ensureProductSprintsLoaded). force-dynamic, productAccessFilter, start_date/end_date naar ISO-date string. - GET /api/sprints/[id]/workspace — sprint snapshot met sprint-meta, stories (incl. taskCount/doneCount/assignee), tasks gegroepeerd per story (ensureSprintLoaded). force-dynamic, productAccessFilter via product, status-vertaling via taskStatusToApi/storyStatusToApi. Race-safe loaders (activeRequestId-guard), restore-flow (cascade-restore via writeProductHint/writeSprintHint/writeStoryHint/writeTaskHint), resync-laag (useSprintWorkspaceResync visibility + online), unknown-event filter (isUnknownEntityEvent → resyncActiveScopes('unknown-event')) zijn allemaal in T-879/T-880 al ingebouwd; T-882 sluit het loop met de ontbrekende API-endpoints + cache-headers (cache: 'no-store' op fetches, force-dynamic op routes). * feat(PBI-74): cleanup oude sprint-store (Story 9 / T-883) - rm stores/sprint-store.ts — alle componenten lezen nu via useSprintWorkspaceStore (T-881 voltooide imports-migratie) - update SprintHydrationWrapper-comment: schaduw-fase referenties verwijderd Verify: 671 tests groen, typecheck clean, build groen. Grep useSprintStore = 0. * docs(PBI-74): update Story 9 status in implementatieplan (T-884) - Frontmatter: ready-to-execute → in-progress; revision 1 → 2; last_updated 2026-05-09 → 2026-05-10 - Stories-tabel: kolom Status toegevoegd (Stories 1-8 DONE via PR #180, Story 9 met T-884 op review) - §Story 9: per-taak status + acceptatie-checklist voor T-884 manuele staging-checks - Aanbeveling-blokje: noteert dat Story 9 vroeger gestart is dan het ontwerpdoc adviseerde --- .../stores/sprint-workspace/restore.test.ts | 119 +++ .../stores/sprint-workspace/store.test.ts | 910 +++++++++++++++++ .../products/[id]/sprint/[sprintId]/page.tsx | 54 +- app/api/products/[id]/sprints/route.ts | 59 ++ app/api/realtime/sprint/route.ts | 141 +++ app/api/sprints/[id]/workspace/route.ts | 110 +++ components/sprint/sprint-backlog.tsx | 19 +- components/sprint/sprint-board-client.tsx | 214 ++-- .../sprint/sprint-hydration-wrapper.tsx | 84 ++ components/sprint/task-list.tsx | 66 +- docs/INDEX.md | 2 +- .../zustand-workspace-store-implementation.md | 48 +- lib/realtime/use-sprint-realtime.ts | 96 ++ lib/realtime/use-sprint-workspace-resync.ts | 36 + stores/sprint-store.ts | 57 -- stores/sprint-workspace/restore.ts | 128 +++ stores/sprint-workspace/selectors.ts | 115 +++ stores/sprint-workspace/store.ts | 918 ++++++++++++++++++ stores/sprint-workspace/types.ts | 158 +++ 19 files changed, 3115 insertions(+), 219 deletions(-) create mode 100644 __tests__/stores/sprint-workspace/restore.test.ts create mode 100644 __tests__/stores/sprint-workspace/store.test.ts create mode 100644 app/api/products/[id]/sprints/route.ts create mode 100644 app/api/realtime/sprint/route.ts create mode 100644 app/api/sprints/[id]/workspace/route.ts create mode 100644 components/sprint/sprint-hydration-wrapper.tsx create mode 100644 lib/realtime/use-sprint-realtime.ts create mode 100644 lib/realtime/use-sprint-workspace-resync.ts delete mode 100644 stores/sprint-store.ts create mode 100644 stores/sprint-workspace/restore.ts create mode 100644 stores/sprint-workspace/selectors.ts create mode 100644 stores/sprint-workspace/store.ts create mode 100644 stores/sprint-workspace/types.ts diff --git a/__tests__/stores/sprint-workspace/restore.test.ts b/__tests__/stores/sprint-workspace/restore.test.ts new file mode 100644 index 0000000..66c626f --- /dev/null +++ b/__tests__/stores/sprint-workspace/restore.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' + +import { + clearHints, + readHints, + writeProductHint, + writeSprintHint, + writeStoryHint, + writeTaskHint, +} from '@/stores/sprint-workspace/restore' + +describe('readHints', () => { + it('retourneert lege defaults wanneer localStorage leeg is', () => { + const hints = readHints() + expect(hints.lastActiveProductId).toBeNull() + expect(hints.perProduct).toEqual({}) + expect(hints.perSprint).toEqual({}) + }) + + it('herstelt hints uit localStorage', () => { + localStorage.setItem( + 'sprint-workspace-hints', + JSON.stringify({ + lastActiveProductId: 'p1', + perProduct: { p1: { lastActiveSprintId: 'sp-1' } }, + perSprint: { 'sp-1': { lastActiveStoryId: 's-1' } }, + }), + ) + const hints = readHints() + expect(hints.lastActiveProductId).toBe('p1') + expect(hints.perProduct.p1.lastActiveSprintId).toBe('sp-1') + expect(hints.perSprint['sp-1'].lastActiveStoryId).toBe('s-1') + }) + + it('valt terug op defaults bij ongeldige JSON', () => { + localStorage.setItem('sprint-workspace-hints', '{not-json') + const hints = readHints() + expect(hints.lastActiveProductId).toBeNull() + expect(hints.perProduct).toEqual({}) + expect(hints.perSprint).toEqual({}) + }) + + it('valt terug op defaults bij verkeerde shape', () => { + localStorage.setItem('sprint-workspace-hints', '"just a string"') + const hints = readHints() + expect(hints.perProduct).toEqual({}) + expect(hints.perSprint).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('writeSprintHint', () => { + it('schrijft lastActiveSprintId per productId', () => { + writeSprintHint('prod-1', 'sp-a') + writeSprintHint('prod-2', 'sp-b') + const hints = readHints() + expect(hints.perProduct['prod-1'].lastActiveSprintId).toBe('sp-a') + expect(hints.perProduct['prod-2'].lastActiveSprintId).toBe('sp-b') + }) + + it('accepteert null om sprint-hint te wissen', () => { + writeSprintHint('prod-1', 'sp-a') + writeSprintHint('prod-1', null) + expect(readHints().perProduct['prod-1'].lastActiveSprintId).toBeNull() + }) +}) + +describe('writeStoryHint', () => { + it('schrijft lastActiveStoryId per sprintId', () => { + writeStoryHint('sp-1', 's-1') + expect(readHints().perSprint['sp-1'].lastActiveStoryId).toBe('s-1') + }) + + it('null wist child task-hint', () => { + writeStoryHint('sp-1', 's-1') + writeTaskHint('sp-1', 't-1') + writeStoryHint('sp-1', null) + expect(readHints().perSprint['sp-1'].lastActiveStoryId).toBeNull() + expect(readHints().perSprint['sp-1'].lastActiveTaskId).toBeNull() + }) +}) + +describe('writeTaskHint', () => { + it('schrijft lastActiveTaskId per sprintId', () => { + writeTaskHint('sp-1', 't-1') + expect(readHints().perSprint['sp-1'].lastActiveTaskId).toBe('t-1') + }) +}) + +describe('clearHints', () => { + it('verwijdert alle hints', () => { + writeProductHint('p1') + writeSprintHint('p1', 'sp-1') + writeStoryHint('sp-1', 's-1') + clearHints() + const hints = readHints() + expect(hints.lastActiveProductId).toBeNull() + expect(hints.perProduct).toEqual({}) + expect(hints.perSprint).toEqual({}) + }) +}) diff --git a/__tests__/stores/sprint-workspace/store.test.ts b/__tests__/stores/sprint-workspace/store.test.ts new file mode 100644 index 0000000..baf16f2 --- /dev/null +++ b/__tests__/stores/sprint-workspace/store.test.ts @@ -0,0 +1,910 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' +import type { + SprintWorkspaceSnapshot, + SprintWorkspaceSprint, + SprintWorkspaceStory, + SprintWorkspaceTask, + SprintWorkspaceTaskDetail, +} from '@/stores/sprint-workspace/types' + +// G5: snapshot original actions on module-load; restore in beforeEach. +const originalActions = (() => { + const s = useSprintWorkspaceStore.getState() + return { + hydrateSnapshot: s.hydrateSnapshot, + hydrateProductSprints: s.hydrateProductSprints, + setActiveProduct: s.setActiveProduct, + setActiveSprint: s.setActiveSprint, + setActiveStory: s.setActiveStory, + setActiveTask: s.setActiveTask, + ensureProductSprintsLoaded: s.ensureProductSprintsLoaded, + ensureSprintLoaded: s.ensureSprintLoaded, + 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() { + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = null + s.context.activeSprintId = null + s.context.activeStoryId = null + s.context.activeTaskId = null + s.entities.sprintsById = {} + s.entities.storiesById = {} + s.entities.tasksById = {} + s.relations.sprintIdsByProduct = {} + s.relations.storyIdsBySprint = {} + s.relations.taskIdsByStory = {} + s.loading.loadedProductSprintsIds = {} + s.loading.loadingProductId = null + s.loading.loadedSprintIds = {} + s.loading.loadingSprintId = null + 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 makeSprint( + overrides: Partial & { id: string; product_id: string }, +): SprintWorkspaceSprint { + return { + id: overrides.id, + product_id: overrides.product_id, + code: overrides.code ?? `S-${overrides.id}`, + sprint_goal: overrides.sprint_goal ?? `Goal ${overrides.id}`, + status: overrides.status ?? 'OPEN', + start_date: overrides.start_date ?? '2026-04-01', + end_date: overrides.end_date ?? '2026-04-14', + created_at: overrides.created_at ?? new Date('2026-03-15'), + completed_at: overrides.completed_at ?? null, + } +} + +function makeStory( + overrides: Partial & { id: string; pbi_id: string }, +): SprintWorkspaceStory { + 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 }, +): SprintWorkspaceTask { + return { + id: overrides.id, + code: overrides.code ?? null, + 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, + sprint_id: overrides.sprint_id ?? null, + created_at: overrides.created_at ?? new Date('2026-01-01'), + } +} + +function snapshotWith( + sprint: SprintWorkspaceSprint | undefined, + stories: SprintWorkspaceStory[] = [], + tasksByStory: Record = {}, + product?: { id: string; name: string }, +): SprintWorkspaceSnapshot { + return { product, sprint, stories, tasksByStory } +} + +// G7/G8: mock fetch via mockImplementation (vers Response per call) +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, relations en loaded-marker', () => { + const sprint = makeSprint({ id: 'sp-1', product_id: 'prod-1' }) + const storyA = makeStory({ id: 's-a', pbi_id: 'pbi-1', sprint_id: 'sp-1', sort_order: 2 }) + const storyB = makeStory({ id: 's-b', pbi_id: 'pbi-1', sprint_id: 'sp-1', sort_order: 1 }) + const taskA = makeTask({ id: 't-a', story_id: 's-a', sort_order: 2 }) + const taskB = makeTask({ id: 't-b', story_id: 's-a', sort_order: 1 }) + + useSprintWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + sprint, + [storyA, storyB], + { 's-a': [taskA, taskB] }, + { id: 'prod-1', name: 'Product 1' }, + ), + ) + + const s = useSprintWorkspaceStore.getState() + expect(s.entities.sprintsById['sp-1']).toEqual(sprint) + expect(s.entities.storiesById['s-a']).toEqual(storyA) + expect(s.entities.storiesById['s-b']).toEqual(storyB) + // sort_order: storyB (1) before storyA (2) + expect(s.relations.storyIdsBySprint['sp-1']).toEqual(['s-b', 's-a']) + // sort_order: taskB (1) before taskA (2) + expect(s.relations.taskIdsByStory['s-a']).toEqual(['t-b', 't-a']) + expect(s.context.activeProduct).toEqual({ id: 'prod-1', name: 'Product 1' }) + expect(s.loading.loadedSprintIds['sp-1']).toBe(true) + }) +}) + +describe('hydrateProductSprints', () => { + it('sorteert OPEN voor CLOSED, dan op start_date desc', () => { + const closedOld = makeSprint({ + id: 'sp-closed-old', + product_id: 'prod-1', + status: 'CLOSED', + start_date: '2026-01-01', + }) + const openNew = makeSprint({ + id: 'sp-open-new', + product_id: 'prod-1', + status: 'OPEN', + start_date: '2026-04-01', + }) + const openOld = makeSprint({ + id: 'sp-open-old', + product_id: 'prod-1', + status: 'OPEN', + start_date: '2026-02-01', + }) + + useSprintWorkspaceStore + .getState() + .hydrateProductSprints('prod-1', [closedOld, openOld, openNew]) + + const s = useSprintWorkspaceStore.getState() + expect(s.relations.sprintIdsByProduct['prod-1']).toEqual([ + 'sp-open-new', + 'sp-open-old', + 'sp-closed-old', + ]) + expect(s.loading.loadedProductSprintsIds['prod-1']).toBe(true) + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// Selection cascade +// ───────────────────────────────────────────────────────────────────────── + +describe('selection cascade', () => { + it('setActiveSprint reset story+task; setActiveStory reset task', () => { + useSprintWorkspaceStore.setState((s) => { + s.context.activeSprintId = 'sp-old' + s.context.activeStoryId = 's-old' + s.context.activeTaskId = 't-old' + }) + + useSprintWorkspaceStore.getState().setActiveSprint('sp-new') + let s = useSprintWorkspaceStore.getState() + expect(s.context.activeSprintId).toBe('sp-new') + expect(s.context.activeStoryId).toBeNull() + expect(s.context.activeTaskId).toBeNull() + + useSprintWorkspaceStore.setState((draft) => { + draft.context.activeStoryId = 's-old' + draft.context.activeTaskId = 't-old' + }) + useSprintWorkspaceStore.getState().setActiveStory('s-new') + s = useSprintWorkspaceStore.getState() + expect(s.context.activeStoryId).toBe('s-new') + expect(s.context.activeTaskId).toBeNull() + }) + + it('setActiveProduct(null) ruimt entities en relations op', () => { + useSprintWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + makeSprint({ id: 'sp-1', product_id: 'prod-1' }), + [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], + { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] }, + { id: 'prod-1', name: 'Product 1' }, + ), + ) + + useSprintWorkspaceStore.getState().setActiveProduct(null) + const s = useSprintWorkspaceStore.getState() + expect(s.context.activeProduct).toBeNull() + expect(s.context.activeSprintId).toBeNull() + expect(s.entities.sprintsById).toEqual({}) + expect(s.entities.storiesById).toEqual({}) + expect(s.entities.tasksById).toEqual({}) + expect(s.relations.sprintIdsByProduct).toEqual({}) + expect(s.relations.storyIdsBySprint).toEqual({}) + expect(s.relations.taskIdsByStory).toEqual({}) + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// applyRealtimeEvent +// ───────────────────────────────────────────────────────────────────────── + +describe('applyRealtimeEvent — sprint', () => { + beforeEach(() => { + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + }) + + it('I — voegt sprint toe aan product-lijst', () => { + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'sprint', + op: 'I', + id: 'sp-new', + product_id: 'prod-1', + code: 'S-1', + sprint_goal: 'New Sprint', + status: 'OPEN', + start_date: '2026-05-01', + end_date: '2026-05-14', + created_at: new Date('2026-04-15').toISOString(), + }) + const s = useSprintWorkspaceStore.getState() + expect(s.entities.sprintsById['sp-new']).toBeDefined() + expect(s.relations.sprintIdsByProduct['prod-1']).toContain('sp-new') + }) + + it('I — idempotent voor bestaande id', () => { + useSprintWorkspaceStore + .getState() + .hydrateProductSprints('prod-1', [ + makeSprint({ id: 'sp-1', product_id: 'prod-1', sprint_goal: 'Origineel' }), + ]) + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'sprint', + op: 'I', + id: 'sp-1', + product_id: 'prod-1', + sprint_goal: 'echo', + }) + expect(useSprintWorkspaceStore.getState().entities.sprintsById['sp-1'].sprint_goal).toBe( + 'Origineel', + ) + }) + + it('U — patch + her-sorteert', () => { + useSprintWorkspaceStore + .getState() + .hydrateProductSprints('prod-1', [ + makeSprint({ + id: 'sp-a', + product_id: 'prod-1', + status: 'OPEN', + start_date: '2026-04-01', + }), + makeSprint({ + id: 'sp-b', + product_id: 'prod-1', + status: 'OPEN', + start_date: '2026-03-01', + }), + ]) + // sp-a (newer) komt eerst + expect( + useSprintWorkspaceStore.getState().relations.sprintIdsByProduct['prod-1'], + ).toEqual(['sp-a', 'sp-b']) + + // Sluit sp-a → moet naar achteren + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'sprint', + op: 'U', + id: 'sp-a', + product_id: 'prod-1', + status: 'CLOSED', + }) + expect( + useSprintWorkspaceStore.getState().relations.sprintIdsByProduct['prod-1'], + ).toEqual(['sp-b', 'sp-a']) + }) + + it('D — verwijdert sprint inclusief child stories en tasks', () => { + useSprintWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + makeSprint({ id: 'sp-1', product_id: 'prod-1' }), + [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], + { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] }, + { id: 'prod-1', name: 'Product 1' }, + ), + ) + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'sprint', + op: 'D', + id: 'sp-1', + product_id: 'prod-1', + }) + const s = useSprintWorkspaceStore.getState() + expect(s.entities.sprintsById['sp-1']).toBeUndefined() + expect(s.entities.storiesById['s-1']).toBeUndefined() + expect(s.entities.tasksById['t-1']).toBeUndefined() + expect(s.relations.storyIdsBySprint['sp-1']).toBeUndefined() + expect(s.relations.taskIdsByStory['s-1']).toBeUndefined() + }) + + it('D — clear actieve sprint selectie als die de verwijderde sprint was', () => { + useSprintWorkspaceStore.getState().hydrateSnapshot( + snapshotWith(makeSprint({ id: 'sp-1', product_id: 'prod-1' }), []), + ) + useSprintWorkspaceStore.setState((s) => { + s.context.activeSprintId = 'sp-1' + s.context.activeStoryId = 's-x' + s.context.activeTaskId = 't-x' + }) + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'sprint', + op: 'D', + id: 'sp-1', + product_id: 'prod-1', + }) + const s = useSprintWorkspaceStore.getState() + expect(s.context.activeSprintId).toBeNull() + expect(s.context.activeStoryId).toBeNull() + expect(s.context.activeTaskId).toBeNull() + }) +}) + +describe('applyRealtimeEvent — story sprint-move', () => { + beforeEach(() => { + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + useSprintWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + makeSprint({ id: 'sp-1', product_id: 'prod-1' }), + [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], + ), + ) + }) + + it('U met andere sprint_id verplaatst story uit sprint-relatie', () => { + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'story', + op: 'U', + id: 's-1', + product_id: 'prod-1', + pbi_id: 'pbi-1', + sprint_id: 'sp-other', + }) + const s = useSprintWorkspaceStore.getState() + expect(s.relations.storyIdsBySprint['sp-1']).toEqual([]) + expect(s.relations.storyIdsBySprint['sp-other']).toEqual(['s-1']) + expect(s.entities.storiesById['s-1'].sprint_id).toBe('sp-other') + }) + + it('U met sprint_id=null haalt story uit sprint-relatie', () => { + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'story', + op: 'U', + id: 's-1', + product_id: 'prod-1', + pbi_id: 'pbi-1', + sprint_id: null, + }) + expect(useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1']).toEqual([]) + expect(useSprintWorkspaceStore.getState().entities.storiesById['s-1'].sprint_id).toBeNull() + }) +}) + +describe('applyRealtimeEvent — task parent-move', () => { + beforeEach(() => { + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + useSprintWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + makeSprint({ id: 'sp-1', product_id: 'prod-1' }), + [ + makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' }), + makeStory({ id: 's-2', pbi_id: 'pbi-1', sprint_id: 'sp-1' }), + ], + { + 's-1': [makeTask({ id: 't-1', story_id: 's-1' })], + 's-2': [], + }, + ), + ) + }) + + it('U met andere story_id verplaatst task naar nieuwe parent', () => { + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'task', + op: 'U', + id: 't-1', + product_id: 'prod-1', + story_id: 's-2', + }) + const s = useSprintWorkspaceStore.getState() + expect(s.relations.taskIdsByStory['s-1']).toEqual([]) + expect(s.relations.taskIdsByStory['s-2']).toEqual(['t-1']) + expect(s.entities.tasksById['t-1'].story_id).toBe('s-2') + }) +}) + +describe('applyRealtimeEvent — andere product genegeerd', () => { + it('event met ander product_id raakt de store niet', () => { + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + useSprintWorkspaceStore + .getState() + .hydrateProductSprints('prod-1', [makeSprint({ id: 'sp-1', product_id: 'prod-1' })]) + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'sprint', + op: 'I', + id: 'sp-other', + product_id: 'prod-2', + code: 'X', + }) + const s = useSprintWorkspaceStore.getState() + expect(s.entities.sprintsById['sp-other']).toBeUndefined() + }) +}) + +describe('applyRealtimeEvent — unknown entity → resync trigger', () => { + function withSpy(): ReturnType { + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + }) + const spy = vi.fn().mockResolvedValue(undefined) + useSprintWorkspaceStore.setState((s) => { + s.resyncActiveScopes = spy as unknown as typeof s.resyncActiveScopes + }) + return spy + } + + it('unknown entity met matching product triggert resync', () => { + const spy = withSpy() + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'comment', + op: 'I', + id: 'cm-1', + product_id: 'prod-1', + }) + expect(spy).toHaveBeenCalledWith('unknown-event') + }) + + it('unknown entity met ander product_id triggert geen resync', () => { + const spy = withSpy() + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'comment', + op: 'I', + id: 'cm-1', + product_id: 'prod-2', + }) + expect(spy).not.toHaveBeenCalled() + }) + + it('claude_job_status (type-veld) triggert geen resync', () => { + const spy = withSpy() + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + type: 'claude_job_status', + job_id: 'job-1', + product_id: 'prod-1', + status: 'queued', + }) + expect(spy).not.toHaveBeenCalled() + }) + + it('worker_heartbeat (type-veld) triggert geen resync', () => { + const spy = withSpy() + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + type: 'worker_heartbeat', + worker_id: 'w-1', + product_id: 'prod-1', + }) + expect(spy).not.toHaveBeenCalled() + }) + + it('payload zonder entity en zonder type wordt genegeerd', () => { + const spy = withSpy() + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + product_id: 'prod-1', + something: 'else', + }) + expect(spy).not.toHaveBeenCalled() + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// ensure*Loaded +// ───────────────────────────────────────────────────────────────────────── + +describe('ensureProductSprintsLoaded', () => { + it('fetcht sprint-list en hydreert met sortering', async () => { + const sprints = [ + makeSprint({ + id: 'sp-old', + product_id: 'prod-1', + status: 'CLOSED', + start_date: '2026-01-01', + }), + makeSprint({ + id: 'sp-new', + product_id: 'prod-1', + status: 'OPEN', + start_date: '2026-04-01', + }), + ] + const fetchSpy = mockFetchSequence([sprints]) + + await useSprintWorkspaceStore.getState().ensureProductSprintsLoaded('prod-1') + + expect(fetchSpy).toHaveBeenCalledWith( + '/api/products/prod-1/sprints', + expect.objectContaining({ cache: 'no-store' }), + ) + const s = useSprintWorkspaceStore.getState() + expect(s.relations.sprintIdsByProduct['prod-1']).toEqual(['sp-new', 'sp-old']) + expect(s.loading.loadedProductSprintsIds['prod-1']).toBe(true) + }) +}) + +describe('ensureSprintLoaded', () => { + it('fetcht sprint-snapshot en hydreert', async () => { + const sprint = makeSprint({ id: 'sp-1', product_id: 'prod-1' }) + const snapshot: SprintWorkspaceSnapshot = { + product: { id: 'prod-1', name: 'P1' }, + sprint, + stories: [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1' })], + tasksByStory: { + 's-1': [makeTask({ id: 't-1', story_id: 's-1' })], + }, + } + const fetchSpy = mockFetchSequence([snapshot]) + + await useSprintWorkspaceStore.getState().ensureSprintLoaded('sp-1') + + expect(fetchSpy).toHaveBeenCalledWith( + '/api/sprints/sp-1/workspace', + expect.objectContaining({ cache: 'no-store' }), + ) + const s = useSprintWorkspaceStore.getState() + expect(s.entities.sprintsById['sp-1']).toBeDefined() + expect(s.relations.storyIdsBySprint['sp-1']).toEqual(['s-1']) + expect(s.relations.taskIdsByStory['s-1']).toEqual(['t-1']) + expect(s.loading.loadedSprintIds['sp-1']).toBe(true) + }) +}) + +describe('race-safe ensure*Loaded — activeRequestId guard', () => { + it('oudere in-flight ensureSprintLoaded mag nieuwere selectie niet overschrijven', async () => { + let resolveOld: ((snap: SprintWorkspaceSnapshot) => void) | null = null + + vi.spyOn(globalThis, 'fetch').mockImplementation((async (url: string) => { + if (url === '/api/sprints/sp-old/workspace') { + const snap = await new Promise((resolve) => { + resolveOld = resolve + }) + return new Response(JSON.stringify(snap), { status: 200 }) + } + if (url === '/api/sprints/sp-new/workspace') { + return new Response( + JSON.stringify({ + sprint: makeSprint({ id: 'sp-new', product_id: 'prod-1' }), + stories: [makeStory({ id: 'new-st', pbi_id: 'p', sprint_id: 'sp-new' })], + tasksByStory: {}, + }), + { status: 200 }, + ) + } + return new Response('null', { status: 200 }) + }) as unknown as typeof fetch) + + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'Product 1' } + s.context.activeSprintId = 'sp-old' + s.loading.activeRequestId = 'req-old' + }) + const oldPromise = useSprintWorkspaceStore + .getState() + .ensureSprintLoaded('sp-old', 'req-old') + + useSprintWorkspaceStore.setState((s) => { + s.context.activeSprintId = 'sp-new' + s.loading.activeRequestId = 'req-new' + }) + await useSprintWorkspaceStore + .getState() + .ensureSprintLoaded('sp-new', 'req-new') + + expect(useSprintWorkspaceStore.getState().entities.storiesById['new-st']).toBeDefined() + + resolveOld!({ + sprint: makeSprint({ id: 'sp-old', product_id: 'prod-1' }), + stories: [makeStory({ id: 'old-st', pbi_id: 'p', sprint_id: 'sp-old' })], + tasksByStory: {}, + }) + await oldPromise + + const s = useSprintWorkspaceStore.getState() + expect(s.context.activeSprintId).toBe('sp-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', + code: 'C1', + title: 'Task 1', + description: 'desc', + priority: 1, + sort_order: 1, + status: 'todo', + story_id: 's-1', + sprint_id: 'sp-1', + created_at: new Date('2026-02-01').toISOString(), + implementation_plan: 'detailed plan here', + }, + ]) + + await useSprintWorkspaceStore.getState().ensureTaskLoaded('t-1') + const task = useSprintWorkspaceStore.getState().entities.tasksById[ + 't-1' + ] as SprintWorkspaceTaskDetail + expect(task._detail).toBe(true) + expect(task.implementation_plan).toBe('detailed plan here') + expect(useSprintWorkspaceStore.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([ + // ensureProductSprintsLoaded + [], + // ensureSprintLoaded + { + sprint: makeSprint({ id: 'sp-1', product_id: 'prod-1' }), + stories: [], + tasksByStory: {}, + }, + // ensureStoryLoaded + [], + // ensureTaskLoaded + { + id: 't-1', + title: 'T', + description: null, + priority: 1, + sort_order: 1, + status: 'todo', + story_id: 's-1', + sprint_id: 'sp-1', + created_at: '2026-02-01', + }, + ]) + + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'P' } + s.context.activeSprintId = 'sp-1' + s.context.activeStoryId = 's-1' + s.context.activeTaskId = 't-1' + }) + + await useSprintWorkspaceStore.getState().resyncActiveScopes('manual') + + const calls = fetchSpy.mock.calls.map(([url]) => url) + expect(calls).toContain('/api/products/prod-1/sprints') + expect(calls).toContain('/api/sprints/sp-1/workspace') + expect(calls).toContain('/api/stories/s-1/tasks') + expect(calls).toContain('/api/tasks/t-1') + + const s = useSprintWorkspaceStore.getState() + expect(s.sync.lastResyncAt).toBeTypeOf('number') + expect(s.sync.resyncReason).toBe('manual') + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// Restore-hint flow +// ───────────────────────────────────────────────────────────────────────── + +describe('restore-hint flow — setters persisteren hints', () => { + it('setActiveProduct schrijft lastActiveProductId', () => { + useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) + const raw = localStorage.getItem('sprint-workspace-hints') + expect(raw).not.toBeNull() + const hints = JSON.parse(raw!) + expect(hints.lastActiveProductId).toBe('prod-1') + }) + + it('setActiveSprint schrijft lastActiveSprintId per product', () => { + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'P1' } + }) + useSprintWorkspaceStore.getState().setActiveSprint('sp-a') + const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!) + expect(hints.perProduct['prod-1'].lastActiveSprintId).toBe('sp-a') + }) + + it('setActiveStory schrijft lastActiveStoryId per sprint', () => { + useSprintWorkspaceStore.setState((s) => { + s.context.activeSprintId = 'sp-1' + }) + useSprintWorkspaceStore.getState().setActiveStory('s-a') + const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!) + expect(hints.perSprint['sp-1'].lastActiveStoryId).toBe('s-a') + }) + + it('setActiveTask schrijft lastActiveTaskId per sprint', () => { + useSprintWorkspaceStore.setState((s) => { + s.context.activeSprintId = 'sp-1' + }) + useSprintWorkspaceStore.getState().setActiveTask('t-a') + const hints = JSON.parse(localStorage.getItem('sprint-workspace-hints')!) + expect(hints.perSprint['sp-1'].lastActiveTaskId).toBe('t-a') + }) +}) + +describe('restore-hint flow — chain triggert na ensure*Loaded', () => { + it('hint die NIET in entities zit wordt genegeerd', async () => { + localStorage.setItem( + 'sprint-workspace-hints', + JSON.stringify({ + lastActiveProductId: 'prod-1', + perProduct: { 'prod-1': { lastActiveSprintId: 'ghost-sprint' } }, + perSprint: {}, + }), + ) + mockFetchSequence([[]]) + + useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) + await new Promise((r) => setTimeout(r, 20)) + + expect(useSprintWorkspaceStore.getState().context.activeSprintId).toBeNull() + }) + + it('hint die wel in entities zit wordt toegepast', async () => { + const validSprint = makeSprint({ id: 'sp-known', product_id: 'prod-1' }) + localStorage.setItem( + 'sprint-workspace-hints', + JSON.stringify({ + lastActiveProductId: 'prod-1', + perProduct: { 'prod-1': { lastActiveSprintId: 'sp-known' } }, + perSprint: {}, + }), + ) + mockFetchSequence([ + // ensureProductSprintsLoaded — levert sp-known + [validSprint], + // ensureSprintLoaded triggered door setActiveSprint(hint) + { sprint: validSprint, stories: [], tasksByStory: {} }, + ]) + + useSprintWorkspaceStore.getState().setActiveProduct({ id: 'prod-1', name: 'P1' }) + await new Promise((r) => setTimeout(r, 30)) + + expect(useSprintWorkspaceStore.getState().context.activeSprintId).toBe('sp-known') + }) +}) + +// ───────────────────────────────────────────────────────────────────────── +// Optimistic mutations +// ───────────────────────────────────────────────────────────────────────── + +describe('optimistic mutations', () => { + it('rollback herstelt vorige sprint-story-order', () => { + useSprintWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + makeSprint({ id: 'sp-1', product_id: 'prod-1' }), + [ + makeStory({ id: 'a', pbi_id: 'p', sprint_id: 'sp-1', sort_order: 1 }), + makeStory({ id: 'b', pbi_id: 'p', sprint_id: 'sp-1', sort_order: 2 }), + ], + ), + ) + const prevOrder = [ + ...useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1'], + ] + + const id = useSprintWorkspaceStore.getState().applyOptimisticMutation({ + kind: 'sprint-story-order', + sprintId: 'sp-1', + prevStoryIds: prevOrder, + }) + useSprintWorkspaceStore.setState((s) => { + s.relations.storyIdsBySprint['sp-1'] = ['b', 'a'] + }) + + useSprintWorkspaceStore.getState().rollbackMutation(id) + expect(useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1']).toEqual( + prevOrder, + ) + expect(useSprintWorkspaceStore.getState().pendingMutations[id]).toBeUndefined() + }) + + it('settle ruimt pending op zonder state te wijzigen', () => { + useSprintWorkspaceStore.getState().hydrateSnapshot( + snapshotWith(makeSprint({ id: 'sp-1', product_id: 'prod-1' }), [ + makeStory({ id: 'a', pbi_id: 'p', sprint_id: 'sp-1' }), + ]), + ) + const id = useSprintWorkspaceStore.getState().applyOptimisticMutation({ + kind: 'sprint-story-order', + sprintId: 'sp-1', + prevStoryIds: ['a'], + }) + expect(useSprintWorkspaceStore.getState().pendingMutations[id]).toBeDefined() + + useSprintWorkspaceStore.getState().settleMutation(id) + expect(useSprintWorkspaceStore.getState().pendingMutations[id]).toBeUndefined() + expect(useSprintWorkspaceStore.getState().relations.storyIdsBySprint['sp-1']).toEqual([ + 'a', + ]) + }) + + it('SSE-echo van een al-bestaande sprint is idempotent', () => { + useSprintWorkspaceStore.setState((s) => { + s.context.activeProduct = { id: 'prod-1', name: 'P' } + }) + useSprintWorkspaceStore + .getState() + .hydrateProductSprints('prod-1', [ + makeSprint({ id: 'sp-1', product_id: 'prod-1', sprint_goal: 'Origineel' }), + ]) + useSprintWorkspaceStore.getState().applyRealtimeEvent({ + entity: 'sprint', + op: 'I', + id: 'sp-1', + product_id: 'prod-1', + sprint_goal: 'echo', + }) + expect( + useSprintWorkspaceStore.getState().entities.sprintsById['sp-1'].sprint_goal, + ).toBe('Origineel') + }) +}) diff --git a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx index c7d1707..84ec08e 100644 --- a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx +++ b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx @@ -5,6 +5,10 @@ import { getAccessibleProduct } from '@/lib/product-access' import { prisma } from '@/lib/prisma' import { pbiStatusToApi } from '@/lib/task-status' import { SprintBoardClient } from '@/components/sprint/sprint-board-client' +import { + SprintHydrationWrapper, + type SprintHydrationData, +} from '@/components/sprint/sprint-hydration-wrapper' import { SyncActiveSprintCookie } from '@/components/sprint/sync-active-sprint-cookie' import { SprintSwitcher } from '@/components/shared/sprint-switcher' import { getSprintSwitcherData } from '@/lib/sprint-switcher-data' @@ -12,7 +16,7 @@ import { SprintHeader } from '@/components/sprint/sprint-header' import { SprintRunControls } from '@/components/sprint/sprint-run-controls' import { parsePauseContext } from '@/lib/pause-context' import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog' -import type { Task } from '@/components/sprint/task-list' +import type { SprintWorkspaceTask } from '@/stores/sprint-workspace/types' 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' @@ -105,17 +109,19 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { assignee_username: s.assignee?.username ?? null, })) - const tasksByStory: Record = {} + const tasksByStoryWorkspace: Record = {} for (const story of sprintStories) { - tasksByStory[story.id] = story.tasks.map(t => ({ + tasksByStoryWorkspace[story.id] = story.tasks.map(t => ({ id: t.id, code: t.code, title: t.title, description: t.description, priority: t.priority, + sort_order: t.sort_order, status: t.status, story_id: t.story_id, sprint_id: t.sprint_id, + created_at: t.created_at, })) } @@ -158,10 +164,25 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { })), })) - const sprintStoryIdList = sprintStories.map(s => s.id) const isDemo = session.isDemo ?? false const closePath = `/products/${id}/sprint/${sprint.id}` + const hydrationData: SprintHydrationData = { + sprint: { + id: sprint.id, + product_id: id, + code: sprint.code, + sprint_goal: sprint.sprint_goal, + status: sprint.status as 'OPEN' | 'CLOSED', + start_date: sprint.start_date ? sprint.start_date.toISOString().slice(0, 10) : null, + end_date: sprint.end_date ? sprint.end_date.toISOString().slice(0, 10) : null, + created_at: new Date(), + completed_at: null, + }, + stories: sprintStoryItems, + tasksByStory: tasksByStoryWorkspace, + } + return (
@@ -194,18 +215,21 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
- + productName={product.name} + > + +
diff --git a/app/api/products/[id]/sprints/route.ts b/app/api/products/[id]/sprints/route.ts new file mode 100644 index 0000000..50f8bb8 --- /dev/null +++ b/app/api/products/[id]/sprints/route.ts @@ -0,0 +1,59 @@ +// PBI-74 / Story 9 / T-882: GET /api/products/:id/sprints +// +// Levert een lijst sprints voor een product (sprint-workspace +// ensureProductSprintsLoaded). Auth + access-control consistent met andere +// product-routes. + +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' + +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 }, + }) + if (!product) { + return Response.json({ error: 'Product niet gevonden' }, { status: 404 }) + } + + const sprints = await prisma.sprint.findMany({ + where: { product_id: id }, + orderBy: [ + { status: 'asc' }, // OPEN < CLOSED alfabetisch — workspace-store her-sorteert + { start_date: 'desc' }, + { created_at: 'desc' }, + ], + select: { + id: true, + product_id: true, + code: true, + sprint_goal: true, + status: true, + start_date: true, + end_date: true, + created_at: true, + completed_at: true, + }, + }) + + return Response.json( + sprints.map((s) => ({ + ...s, + start_date: s.start_date ? s.start_date.toISOString().slice(0, 10) : null, + end_date: s.end_date ? s.end_date.toISOString().slice(0, 10) : null, + })), + ) +} diff --git a/app/api/realtime/sprint/route.ts b/app/api/realtime/sprint/route.ts new file mode 100644 index 0000000..aaaf34c --- /dev/null +++ b/app/api/realtime/sprint/route.ts @@ -0,0 +1,141 @@ +// SSE endpoint for the sprint workspace (sprint / story / task changes). +// Mirrors /api/realtime/backlog but with entity filter ∈ {sprint, story, task} +// scoped per product. PBI-74 / Story 9. +// +// Auth: iron-session cookie. Demo users may read. + +import { NextRequest } from 'next/server' +import { Client } from 'pg' +import { getSession } from '@/lib/auth' +import { getAccessibleProduct } from '@/lib/product-access' +import { closePgClientSafely } from '@/lib/realtime/pg-client-cleanup' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 300 + +const CHANNEL = 'scrum4me_changes' +const HEARTBEAT_MS = 25_000 +const HARD_CLOSE_MS = 240_000 + +type NotifyPayload = Record + +function shouldEmit(payload: NotifyPayload, productId: string): boolean { + if ('type' in payload) return false + const entity = payload.entity as string | undefined + if (!entity || !['sprint', 'story', 'task'].includes(entity)) return false + return payload.product_id === productId +} + +export async function GET(request: NextRequest) { + const session = await getSession() + if (!session.userId) { + return Response.json({ error: 'Niet ingelogd' }, { status: 401 }) + } + + const productId = request.nextUrl.searchParams.get('product_id') + if (!productId) { + return Response.json({ error: 'product_id is verplicht' }, { status: 400 }) + } + + const product = await getAccessibleProduct(productId, session.userId) + if (!product) { + return Response.json({ error: 'Geen toegang tot dit product' }, { status: 403 }) + } + + const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL + if (!directUrl) { + return Response.json( + { error: 'DIRECT_URL/DATABASE_URL niet geconfigureerd' }, + { status: 500 }, + ) + } + + const encoder = new TextEncoder() + const pgClient = new Client({ connectionString: directUrl }) + + let heartbeatTimer: ReturnType | null = null + let hardCloseTimer: ReturnType | null = null + let closed = false + + const stream = new ReadableStream({ + async start(controller) { + const enqueue = (chunk: string) => { + if (closed) return + try { + controller.enqueue(encoder.encode(chunk)) + } catch { + // stream already closed + } + } + + const cleanup = async (reason: string) => { + if (closed) return + closed = true + if (heartbeatTimer) clearInterval(heartbeatTimer) + if (hardCloseTimer) clearTimeout(hardCloseTimer) + await closePgClientSafely(pgClient, 'realtime/sprint') + try { + controller.close() + } catch { + // already closed + } + if (process.env.NODE_ENV !== 'production') { + console.log(`[realtime/sprint] closed: ${reason}`) + } + } + + try { + await pgClient.connect() + await pgClient.query(`LISTEN ${CHANNEL}`) + } catch (err) { + console.error('[realtime/sprint] pg connect/listen failed:', err) + enqueue( + `event: error\ndata: ${JSON.stringify({ message: 'pg connect failed' })}\n\n`, + ) + await cleanup('pg connect failed') + return + } + + pgClient.on('notification', (msg) => { + if (!msg.payload) return + let payload: NotifyPayload + try { + payload = JSON.parse(msg.payload) as NotifyPayload + } catch { + return + } + if (!shouldEmit(payload, productId)) return + enqueue(`data: ${msg.payload}\n\n`) + }) + + pgClient.on('error', async (err) => { + console.error('[realtime/sprint] pg client error:', err) + await cleanup('pg error') + }) + + enqueue(`event: ready\ndata: ${JSON.stringify({ product_id: productId })}\n\n`) + + heartbeatTimer = setInterval(() => { + enqueue(`: heartbeat\n\n`) + }, HEARTBEAT_MS) + + hardCloseTimer = setTimeout(() => { + cleanup('hard close 240s') + }, HARD_CLOSE_MS) + + request.signal.addEventListener('abort', () => { + cleanup('client aborted') + }) + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }) +} diff --git a/app/api/sprints/[id]/workspace/route.ts b/app/api/sprints/[id]/workspace/route.ts new file mode 100644 index 0000000..e3a19ab --- /dev/null +++ b/app/api/sprints/[id]/workspace/route.ts @@ -0,0 +1,110 @@ +// PBI-74 / Story 9 / T-882: GET /api/sprints/:id/workspace +// +// Levert een SprintWorkspaceSnapshot (sprint + stories + tasksByStory) voor +// de sprint-workspace-store (ensureSprintLoaded). Auth + access-control via +// product-membership. + +import { authenticateApiRequest } from '@/lib/api-auth' +import { prisma } from '@/lib/prisma' +import { productAccessFilter } from '@/lib/product-access' +import { 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 sprint = await prisma.sprint.findFirst({ + where: { id, product: productAccessFilter(auth.userId) }, + select: { + id: true, + product_id: true, + code: true, + sprint_goal: true, + status: true, + start_date: true, + end_date: true, + created_at: true, + completed_at: true, + product: { select: { id: true, name: true } }, + }, + }) + if (!sprint) { + return Response.json({ error: 'Sprint niet gevonden' }, { status: 404 }) + } + + const [stories, tasks] = await Promise.all([ + prisma.story.findMany({ + where: { sprint_id: id }, + orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }, { created_at: 'asc' }], + include: { + tasks: { select: { id: true, status: true } }, + assignee: { select: { id: true, username: true } }, + }, + }), + prisma.task.findMany({ + where: { sprint_id: id }, + orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }], + select: { + id: true, + code: true, + title: true, + description: true, + priority: true, + sort_order: true, + status: true, + story_id: true, + sprint_id: true, + created_at: true, + }, + }), + ]) + + 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: sprint.product, + sprint: { + id: sprint.id, + product_id: sprint.product_id, + code: sprint.code, + sprint_goal: sprint.sprint_goal, + status: sprint.status, + start_date: sprint.start_date ? sprint.start_date.toISOString().slice(0, 10) : null, + end_date: sprint.end_date ? sprint.end_date.toISOString().slice(0, 10) : null, + created_at: sprint.created_at, + completed_at: sprint.completed_at, + }, + stories: stories.map((s) => ({ + id: s.id, + code: s.code, + title: s.title, + description: s.description, + acceptance_criteria: s.acceptance_criteria, + priority: s.priority, + sort_order: s.sort_order, + status: storyStatusToApi(s.status), + pbi_id: s.pbi_id, + sprint_id: s.sprint_id, + created_at: s.created_at, + taskCount: s.tasks.length, + doneCount: s.tasks.filter((t) => t.status === 'DONE').length, + assignee_id: s.assignee_id, + assignee_username: s.assignee?.username ?? null, + })), + tasksByStory, + }) +} diff --git a/components/sprint/sprint-backlog.tsx b/components/sprint/sprint-backlog.tsx index d121269..bc3bc51 100644 --- a/components/sprint/sprint-backlog.tsx +++ b/components/sprint/sprint-backlog.tsx @@ -6,6 +6,7 @@ import { useDroppable, useDraggable } from '@dnd-kit/core' import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { toast } from 'sonner' +import { useShallow } from 'zustand/react/shallow' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' @@ -20,7 +21,8 @@ import { DemoTooltip } from '@/components/shared/demo-tooltip' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' import { PRIORITY_BORDER } from '@/components/backlog/backlog-card' import { PRIORITY_COLORS } from '@/components/shared/priority-select' -import { useSprintStore } from '@/stores/sprint-store' +import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' +import { selectStoriesForActiveSprint } from '@/stores/sprint-workspace/selectors' import { claimStoryAction, unclaimStoryAction, reassignStoryAction, claimAllUnassignedInActiveSprintAction } from '@/actions/stories' import { PbiDialog, type PbiDialogState } from '@/components/backlog/pbi-dialog' import { StoryDialog, type StoryDialogState } from '@/components/backlog/story-dialog' @@ -236,7 +238,6 @@ function SortableSprintRow({ interface SprintBacklogLeftProps { sprintId: string - stories: SprintStory[] isDemo: boolean onRemove: (storyId: string) => void onSelect: (storyId: string) => void @@ -248,19 +249,21 @@ interface SprintBacklogLeftProps { } export function SprintBacklogLeft({ - sprintId, stories, isDemo, onRemove, onSelect, selectedStoryId, + sprintId: _sprintId, isDemo, onRemove, onSelect, selectedStoryId, currentUserId, productId, members, onAssigneeChange, }: SprintBacklogLeftProps) { - const { sprintStoryOrder } = useSprintStore() + const orderedStories = useSprintWorkspaceStore( + useShallow((s) => selectStoriesForActiveSprint(s) as SprintStory[]), + ) const { setNodeRef, isOver } = useDroppable({ id: 'sprint-zone' }) const [isPending, startTransition] = useTransition() const [storyDialogState, setStoryDialogState] = useState(null) - const unassignedCount = stories.filter(s => s.assignee_id === null).length + const unassignedCount = orderedStories.filter(s => (s.assignee_id ?? null) === null).length const currentUserUsername = members.find(m => m.userId === currentUserId)?.username ?? null function handleClaimAll() { - const unassigned = stories.filter(s => s.assignee_id === null) + const unassigned = orderedStories.filter(s => (s.assignee_id ?? null) === null) unassigned.forEach(s => onAssigneeChange(s.id, currentUserId, currentUserUsername)) startTransition(async () => { const result = await claimAllUnassignedInActiveSprintAction(productId) @@ -273,10 +276,6 @@ export function SprintBacklogLeft({ }) } - const storyMap = Object.fromEntries(stories.map(s => [s.id, s])) - const order = sprintStoryOrder[sprintId] ?? stories.map(s => s.id) - const orderedStories = order.map(id => storyMap[id]).filter(Boolean) - return (
isDemo: boolean currentUserId: string members: ProductMember[] } +function toWorkspaceStory(story: SprintStory, sprintId: string): SprintWorkspaceStory { + return { + id: story.id, + code: story.code, + title: story.title, + description: story.description, + acceptance_criteria: story.acceptance_criteria, + priority: story.priority, + sort_order: story.sort_order, + status: story.status, + pbi_id: story.pbi_id, + sprint_id: sprintId, + created_at: story.created_at, + taskCount: story.taskCount, + doneCount: story.doneCount, + assignee_id: story.assignee_id, + assignee_username: story.assignee_username, + } +} + export function SprintBoardClient({ productId, sprintId, - stories, pbisWithStories, - sprintStoryIdList, - tasksByStory, isDemo, currentUserId, members, }: SprintBoardClientProps) { - const [sprintStories, setSprintStories] = useState(stories) - const [sprintStoryIds, setSprintStoryIds] = useState>(() => new Set(sprintStoryIdList)) + const sprintStories = useSprintWorkspaceStore( + useShallow((s) => selectStoriesForActiveSprint(s) as SprintStory[]), + ) + const sprintStoryIds = new Set(sprintStories.map(s => s.id)) const [selectedStoryId, setSelectedStoryId] = useState(null) - const { - sprintStoryOrder, - initSprint, - addStoryToSprint, - removeStoryFromSprint, - reorderSprintStories, - rollbackSprint, - } = useSprintStore() const [activeDragStory, setActiveDragStory] = useState(null) const [, startTransition] = useTransition() - useEffect(() => { - initSprint(sprintId, stories.map(s => s.id)) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sprintId]) - const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) @@ -82,9 +87,8 @@ export function SprintBoardClient({ const activeId = active.id.toString() const overId = over.id.toString() - const order = sprintStoryOrder[sprintId] ?? sprintStories.map(s => s.id) - // Drag from left (product backlog) → add to sprint (middle) + // Drag from product backlog (left) → add to sprint (middle) if (activeId.startsWith('pb:')) { const storyId = activeId.slice(3) const droppingOnSprint = @@ -92,106 +96,119 @@ export function SprintBoardClient({ (!overId.startsWith('pb:') && overId !== 'backlog-zone') if (droppingOnSprint && !sprintStoryIds.has(storyId)) { const storyData = pbisWithStories.flatMap(p => p.stories).find(s => s.id === storyId) - if (!storyData) return - setSprintStoryIds(prev => new Set([...prev, storyId])) - setSprintStories(prev => [...prev, storyData]) - addStoryToSprint(sprintId, storyId) - startTransition(async () => { - const result = await addStoryToSprintAction(sprintId, storyId) - if (!result.success) { - setSprintStoryIds(prev => { const n = new Set(prev); n.delete(storyId); return n }) - setSprintStories(prev => prev.filter(s => s.id !== storyId)) - removeStoryFromSprint(sprintId, storyId) - toast.error(result.error ?? 'Toevoegen mislukt') - } - }) + if (storyData) handleAdd(storyId, storyData) } return } - // Drag from middle (sprint backlog) → left (product backlog) → remove + // Drag from sprint (middle) → product backlog (left) → remove if (overId === 'backlog-zone') { - const storyData = sprintStories.find(s => s.id === activeId) - setSprintStoryIds(prev => { const n = new Set(prev); n.delete(activeId); return n }) - setSprintStories(prev => prev.filter(s => s.id !== activeId)) - removeStoryFromSprint(sprintId, activeId) - if (selectedStoryId === activeId) setSelectedStoryId(null) - startTransition(async () => { - const result = await removeStoryFromSprintAction(activeId) - if (!result.success) { - if (storyData) { - setSprintStoryIds(prev => new Set([...prev, activeId])) - setSprintStories(prev => [...prev, storyData]) - } - addStoryToSprint(sprintId, activeId) - toast.error('Verwijderen mislukt') - } - }) + handleRemove(activeId) return } - // Reorder within sprint (middle panel) + // Reorder within sprint if (activeId !== overId && !activeId.startsWith('pb:')) { - const prevOrder = [...order] - const newOrder = order.includes(overId) - ? arrayMove([...order], order.indexOf(activeId), order.indexOf(overId)) - : [...order.filter(id => id !== activeId), activeId] - - reorderSprintStories(sprintId, newOrder) - startTransition(async () => { - const result = await reorderSprintStoriesAction(sprintId, newOrder) - if (!result.success) { - rollbackSprint(sprintId, prevOrder) - toast.error('Volgorde opslaan mislukt') - } - }) + handleReorder(activeId, overId) } } - function handleAdd(storyId: string) { + function handleAdd(storyId: string, storyData: SprintStory) { if (sprintStoryIds.has(storyId)) return - const storyData = pbisWithStories.flatMap(p => p.stories).find(s => s.id === storyId) - if (!storyData) return - setSprintStoryIds(prev => new Set([...prev, storyId])) - setSprintStories(prev => [...prev, storyData]) - addStoryToSprint(sprintId, storyId) + + const store = useSprintWorkspaceStore.getState() + const prevStory = store.entities.storiesById[storyId] + const prevSprintStoryIds = [...(store.relations.storyIdsBySprint[sprintId] ?? [])] + + useSprintWorkspaceStore.setState((s) => { + s.entities.storiesById[storyId] = toWorkspaceStory(storyData, sprintId) + const list = s.relations.storyIdsBySprint[sprintId] ?? [] + if (!list.includes(storyId)) list.push(storyId) + s.relations.storyIdsBySprint[sprintId] = list + }) + startTransition(async () => { const result = await addStoryToSprintAction(sprintId, storyId) if (!result.success) { - setSprintStoryIds(prev => { const n = new Set(prev); n.delete(storyId); return n }) - setSprintStories(prev => prev.filter(s => s.id !== storyId)) - removeStoryFromSprint(sprintId, storyId) + useSprintWorkspaceStore.setState((s) => { + if (prevStory === undefined) { + delete s.entities.storiesById[storyId] + } else { + s.entities.storiesById[storyId] = prevStory + } + s.relations.storyIdsBySprint[sprintId] = prevSprintStoryIds + }) toast.error(result.error ?? 'Toevoegen mislukt') } }) } - function handleAssigneeChange(storyId: string, assigneeId: string | null, assigneeUsername: string | null) { - setSprintStories(prev => - prev.map(s => s.id === storyId ? { ...s, assignee_id: assigneeId, assignee_username: assigneeUsername } : s) - ) - } - function handleRemove(storyId: string) { - const storyData = sprintStories.find(s => s.id === storyId) - setSprintStoryIds(prev => { const n = new Set(prev); n.delete(storyId); return n }) - setSprintStories(prev => prev.filter(s => s.id !== storyId)) - removeStoryFromSprint(sprintId, storyId) + const store = useSprintWorkspaceStore.getState() + const prevStory = store.entities.storiesById[storyId] + const prevSprintStoryIds = [...(store.relations.storyIdsBySprint[sprintId] ?? [])] + + useSprintWorkspaceStore.setState((s) => { + const list = s.relations.storyIdsBySprint[sprintId] + if (list) { + s.relations.storyIdsBySprint[sprintId] = list.filter((id) => id !== storyId) + } + const story = s.entities.storiesById[storyId] + if (story) story.sprint_id = null + }) + if (selectedStoryId === storyId) setSelectedStoryId(null) + startTransition(async () => { const result = await removeStoryFromSprintAction(storyId) if (!result.success) { - if (storyData) { - setSprintStoryIds(prev => new Set([...prev, storyId])) - setSprintStories(prev => [...prev, storyData]) - } - addStoryToSprint(sprintId, storyId) + useSprintWorkspaceStore.setState((s) => { + if (prevStory) s.entities.storiesById[storyId] = prevStory + s.relations.storyIdsBySprint[sprintId] = prevSprintStoryIds + }) toast.error('Verwijderen mislukt') } }) } - const selectedTasks = selectedStoryId ? (tasksByStory[selectedStoryId] ?? []) : [] + function handleReorder(activeId: string, overId: string) { + const store = useSprintWorkspaceStore.getState() + const order = store.relations.storyIdsBySprint[sprintId] ?? [] + const prevOrder = [...order] + const newOrder = order.includes(overId) + ? arrayMove([...order], order.indexOf(activeId), order.indexOf(overId)) + : [...order.filter(id => id !== activeId), activeId] + + const mutationId = store.applyOptimisticMutation({ + kind: 'sprint-story-order', + sprintId, + prevStoryIds: prevOrder, + }) + useSprintWorkspaceStore.setState((s) => { + s.relations.storyIdsBySprint[sprintId] = newOrder + }) + + startTransition(async () => { + const result = await reorderSprintStoriesAction(sprintId, newOrder) + const st = useSprintWorkspaceStore.getState() + if (result.success) { + st.settleMutation(mutationId) + } else { + st.rollbackMutation(mutationId) + toast.error('Volgorde opslaan mislukt') + } + }) + } + + function handleAssigneeChange(storyId: string, assigneeId: string | null, assigneeUsername: string | null) { + useSprintWorkspaceStore.setState((s) => { + const story = s.entities.storiesById[storyId] + if (story) { + story.assignee_id = assigneeId + story.assignee_username = assigneeUsername + } + }) + } return (
@@ -213,12 +230,14 @@ export function SprintBoardClient({ sprintStoryIds={sprintStoryIds} isDemo={isDemo} productId={productId} - onAdd={handleAdd} + onAdd={(storyId) => { + const storyData = pbisWithStories.flatMap(p => p.stories).find(s => s.id === storyId) + if (storyData) handleAdd(storyId, storyData) + }} />, ) : ( diff --git a/components/sprint/sprint-hydration-wrapper.tsx b/components/sprint/sprint-hydration-wrapper.tsx new file mode 100644 index 0000000..47bebb0 --- /dev/null +++ b/components/sprint/sprint-hydration-wrapper.tsx @@ -0,0 +1,84 @@ +'use client' + +// PBI-74 / Story 9: Sprint workspace hydration wrapper. +// +// Server-component (sprint page) fetcht initial sprint snapshot; deze wrapper +// hydreert useSprintWorkspaceStore op client-mount, mount de SSE-hook en de +// resync-laag. + +import { useEffect, useRef } from 'react' +import { useSprintRealtime } from '@/lib/realtime/use-sprint-realtime' +import { useSprintWorkspaceResync } from '@/lib/realtime/use-sprint-workspace-resync' +import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' +import type { + SprintWorkspaceSnapshot, + SprintWorkspaceSprint, + SprintWorkspaceStory, + SprintWorkspaceTask, +} from '@/stores/sprint-workspace/types' + +export interface SprintHydrationData { + sprint: SprintWorkspaceSprint + stories: SprintWorkspaceStory[] + tasksByStory: Record +} + +interface SprintHydrationWrapperProps { + initialData: SprintHydrationData + productId: string + productName?: string + children: React.ReactNode +} + +function fingerprint(data: SprintHydrationData): string { + const sprintPart = `${data.sprint.id}:${data.sprint.status}` + const storyPart = data.stories + .map((s) => `${s.id}:${s.status}:${s.sprint_id ?? 'null'}:${s.sort_order}`) + .join(',') + const taskPart = Object.entries(data.tasksByStory) + .flatMap(([, list]) => list.map((t) => `${t.id}:${t.status}:${t.sort_order}`)) + .join(',') + return `${sprintPart}|${storyPart}|${taskPart}` +} + +function toWorkspaceSnapshot( + data: SprintHydrationData, + productId: string, + productName: string | undefined, +): SprintWorkspaceSnapshot { + return { + product: { id: productId, name: productName ?? '' }, + sprint: data.sprint, + stories: data.stories, + tasksByStory: data.tasksByStory, + } +} + +export function SprintHydrationWrapper({ + initialData, + productId, + productName, + children, +}: SprintHydrationWrapperProps) { + const lastFingerprint = useRef('') + + useEffect(() => { + const fp = fingerprint(initialData) + if (fp !== lastFingerprint.current) { + lastFingerprint.current = fp + useSprintWorkspaceStore + .getState() + .hydrateSnapshot(toWorkspaceSnapshot(initialData, productId, productName)) + // T-880 schaduw-fase: zet activeSprintId zodat selectors meteen werken + useSprintWorkspaceStore.setState((s) => { + s.context.activeSprintId = initialData.sprint.id + s.context.activeProduct = { id: productId, name: productName ?? '' } + }) + } + }, [initialData, productId, productName]) + + useSprintRealtime(productId) + useSprintWorkspaceResync() + + return <>{children} +} diff --git a/components/sprint/task-list.tsx b/components/sprint/task-list.tsx index 0b078f3..1750536 100644 --- a/components/sprint/task-list.tsx +++ b/components/sprint/task-list.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useTransition, useEffect } from 'react' +import { useState, useTransition } from 'react' import { useRouter, usePathname } from 'next/navigation' import { DndContext, DragEndEvent, DragOverlay, @@ -13,12 +13,18 @@ import { import { CSS } from '@dnd-kit/utilities' import { Pencil } from 'lucide-react' import { toast } from 'sonner' +import { useShallow } from 'zustand/react/shallow' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { CodeBadge } from '@/components/shared/code-badge' import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { PRIORITY_BORDER } from '@/components/backlog/backlog-card' -import { useSprintStore } from '@/stores/sprint-store' +import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' +import { selectTasksForStory } from '@/stores/sprint-workspace/selectors' +import type { + SprintWorkspaceTask, + SprintWorkspaceTaskDetail, +} from '@/stores/sprint-workspace/types' import { updateTaskStatusAction, reorderTasksAction } from '@/actions/tasks' import { DemoTooltip } from '@/components/shared/demo-tooltip' import { debugProps } from '@/lib/debug' @@ -48,9 +54,11 @@ const STATUS_LABELS: Record = { } +// Behouden voor type-compat met SprintBoardClient props (verdwijnt zodra +// SprintBoardClient ook geen tasks-prop meer doorgeeft — T-883). export interface Task { id: string - code: string + code: string | null title: string description: string | null priority: number @@ -59,18 +67,19 @@ export interface Task { sprint_id: string | null } +type WorkspaceTask = SprintWorkspaceTask | SprintWorkspaceTaskDetail + interface TaskListProps { storyId: string sprintId: string productId: string - tasks: Task[] isDemo: boolean } function SortableTaskRow({ task, code, isDemo, onStatusToggle, onEdit, }: { - task: Task + task: WorkspaceTask code: string | null isDemo: boolean onStatusToggle: () => void @@ -149,22 +158,17 @@ function SortableTaskRow({ ) } -export function TaskList({ storyId, sprintId: _sprintId, productId: _productId, tasks, isDemo }: TaskListProps) { - const { taskOrder, initTasks, reorderTasks, rollbackTasks } = useSprintStore() +export function TaskList({ storyId, sprintId: _sprintId, productId: _productId, isDemo }: TaskListProps) { + const orderedTasks = useSprintWorkspaceStore( + useShallow((s) => selectTasksForStory(s, storyId)), + ) const [activeDragId, setActiveDragId] = useState(null) const [, startTransition] = useTransition() const router = useRouter() const pathname = usePathname() - const idKey = tasks.map(t => t.id).join(',') - useEffect(() => { - initTasks(storyId, idKey ? idKey.split(',') : []) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [storyId, idKey]) - - const taskMap = Object.fromEntries(tasks.map(t => [t.id, t])) - const order = taskOrder[storyId] ?? tasks.map(t => t.id) - const orderedTasks = order.map(id => taskMap[id]).filter(Boolean) + const taskMap: Record = {} + for (const t of orderedTasks) taskMap[t.id] = t const doneCount = orderedTasks.filter(t => t.status === 'DONE').length @@ -176,17 +180,37 @@ export function TaskList({ storyId, sprintId: _sprintId, productId: _productId, function handleDragEnd(event: DragEndEvent) { const { active, over } = event if (!over || active.id === over.id) return - const prevOrder = [...order] - const newOrder = arrayMove([...order], order.indexOf(active.id as string), order.indexOf(over.id as string)) - reorderTasks(storyId, newOrder) + const store = useSprintWorkspaceStore.getState() + const prevOrder = [...(store.relations.taskIdsByStory[storyId] ?? [])] + const newOrder = arrayMove( + [...prevOrder], + prevOrder.indexOf(active.id as string), + prevOrder.indexOf(over.id as string), + ) + + const mutationId = store.applyOptimisticMutation({ + kind: 'sprint-task-order', + storyId, + prevTaskIds: prevOrder, + }) + useSprintWorkspaceStore.setState((s) => { + s.relations.taskIdsByStory[storyId] = newOrder + }) setActiveDragId(null) + startTransition(async () => { const result = await reorderTasksAction(storyId, newOrder) - if (!result.success) { rollbackTasks(storyId, prevOrder); toast.error('Volgorde opslaan mislukt') } + const st = useSprintWorkspaceStore.getState() + if (result.success) { + st.settleMutation(mutationId) + } else { + st.rollbackMutation(mutationId) + toast.error('Volgorde opslaan mislukt') + } }) } - function handleStatusToggle(task: Task) { + function handleStatusToggle(task: WorkspaceTask) { startTransition(async () => { await updateTaskStatusAction(task.id, STATUS_CYCLE[task.status] ?? 'TO_DO') }) diff --git a/docs/INDEX.md b/docs/INDEX.md index 599e83c..17e34ae 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -62,7 +62,7 @@ Auto-generated on 2026-05-10 from front-matter and headings. | [Tweede Claude Agent — Planning Agent](./plans/tweede-claude-agent-planning.md) | proposal | 2026-05-03 | | [Scrum4Me — v1.0 readiness](./plans/v1-readiness.md) | active | 2026-05-04 | | [Zustand store rearchitecture - active context, realtime en resync](./plans/zustand-store-rearchitecture.md) | ready-to-execute | 2026-05-09 | -| [Zustand workspace-store implementatieplan (PBI-74)](./plans/zustand-workspace-store-implementation.md) | ready-to-execute | 2026-05-09 | +| [Zustand workspace-store implementatieplan (PBI-74)](./plans/zustand-workspace-store-implementation.md) | in-progress | 2026-05-10 | ### Archive diff --git a/docs/plans/zustand-workspace-store-implementation.md b/docs/plans/zustand-workspace-store-implementation.md index d22a731..78de9e2 100644 --- a/docs/plans/zustand-workspace-store-implementation.md +++ b/docs/plans/zustand-workspace-store-implementation.md @@ -1,10 +1,10 @@ --- title: "Zustand workspace-store implementatieplan (PBI-74)" -status: ready-to-execute +status: in-progress audience: [maintainer, contributor, ai-agent] language: nl -last_updated: 2026-05-09 -revision: 1 +last_updated: 2026-05-10 +revision: 2 --- # Zustand workspace-store implementatieplan @@ -13,7 +13,9 @@ PBI in Scrum4Me-MCP: **PBI-74** — _Zustand store rearchitecture — product- e Bron-ontwerp (architectuur en gotchas): [zustand-store-rearchitecture.md](./zustand-store-rearchitecture.md) revisie 3. -Dit document koppelt de stories en taken in MCP aan de implementatie. Per story acceptatiecriteria; per taak een concrete deliverable. Alle items staan in MCP op `OPEN`/`TO_DO`. Geen executie tot expliciete trigger ("voer Story 1 uit"). +Dit document koppelt de stories en taken in MCP aan de implementatie. Per story acceptatiecriteria; per taak een concrete deliverable. + +**Status (2026-05-10):** Stories 1-8 merged via PR #180 (product-workspace-store productie). Story 9 (sprint-workspace-store) uitgevoerd op `feat/sprint-workspace-store` — automatische verify+build groen, manuele E2E-staging-checks van T-884 nog te doen voor merge. ## Context @@ -38,17 +40,17 @@ De rearchitecture lost dit op via één `product-workspace-store` (en analoog `s ## Stories en taken -| # | Story | MCP | Taken | -|---|---|---|---| -| 1 | Skelet + test-infrastructuur | [ST-1318](./zustand-store-rearchitecture.md) | T-837 … T-843 (7) | -| 2 | Hydratie overstappen (parallel-running) | ST-1319 | T-844 … T-847 (4) | -| 3 | Componenten omzetten naar workspace-store | ST-1320 | T-848 … T-855 (8) | -| 4 | Race-safe loaders en restore-hints | ST-1321 | T-856 … T-860 (5) | -| 5 | Hidden-tab + reconnect resync (één PR) | ST-1322 | T-861 … T-864 (4) | -| 6 | Unknown-event fallback | ST-1323 | T-865 … T-867 (3) | -| 7 | Cache-headers en read-routes | ST-1324 | T-868 … T-871 (4) | -| 8 | Oude stores opruimen | ST-1325 | T-872 … T-878 (7) | -| 9 | Sprint-workspace-store | ST-1326 | T-879 … T-884 (6) | +| # | Story | MCP | Taken | Status | +|---|---|---|---|---| +| 1 | Skelet + test-infrastructuur | ST-1318 | T-837 … T-843 (7) | DONE (PR #180) | +| 2 | Hydratie overstappen (parallel-running) | ST-1319 | T-844 … T-847 (4) | DONE (PR #180) | +| 3 | Componenten omzetten naar workspace-store | ST-1320 | T-848 … T-855 (8) | DONE (PR #180) | +| 4 | Race-safe loaders en restore-hints | ST-1321 | T-856 … T-860 (5) | DONE (PR #180) | +| 5 | Hidden-tab + reconnect resync (één PR) | ST-1322 | T-861 … T-864 (4) | DONE (PR #180) | +| 6 | Unknown-event fallback | ST-1323 | T-865 … T-867 (3) | DONE (PR #180) | +| 7 | Cache-headers en read-routes | ST-1324 | T-868 … T-871 (4) | DONE (PR #180) | +| 8 | Oude stores opruimen | ST-1325 | T-872 … T-878 (7) | DONE (PR #180) | +| 9 | Sprint-workspace-store | ST-1326 | T-879 … T-884 (6) | T-879…T-883 DONE; T-884 review | Totaal: 48 taken. @@ -124,9 +126,21 @@ Totaal: 48 taken. **Doel:** zelfde patroon op sprint-workflow toegepast. -**Taken:** T-879 (skelet), T-880 (hydratie+realtime), T-881 (componenten), T-882 (race-safe + restore + resync + unknown-event in één keer), T-883 (cleanup oude sprint-state), T-884 (E2E sprint-board verificatie). +**Taken:** +- **T-879 — Skelet** (DONE): `stores/sprint-workspace/{types,store,selectors,restore}.ts` + 45 unit-tests groen. Mirrort product-workspace blueprint met sprint-specifieke aanpassingen (sprintIdsByProduct, storyIdsBySprint, sprint-story-membership semantiek). +- **T-880 — Hydratie + realtime** (DONE): `app/api/realtime/sprint/route.ts` SSE-endpoint, `lib/realtime/use-sprint-realtime.ts`, `lib/realtime/use-sprint-workspace-resync.ts`, `components/sprint/sprint-hydration-wrapper.tsx`. Wrapper hydreert via fingerprint-check; SSE blijft open op hidden, ready-cycle triggert reconnect-resync. +- **T-881 — Componenten** (DONE): TaskList, SprintBacklogLeft, SprintBoardClient lezen via selectors uit `useSprintWorkspaceStore` met `useShallow`. DnD via `applyOptimisticMutation('sprint-story-order' | 'sprint-task-order')` met settle/rollback; add/remove via direct setState met manuele snapshot-rollback. +- **T-882 — Race-safe + restore + resync + unknown-event + read-routes** (DONE): `GET /api/products/[id]/sprints` en `GET /api/sprints/[id]/workspace` toegevoegd; activeRequestId-guard + restore-flow + useSprintWorkspaceResync + isUnknownEntityEvent waren al geïmplementeerd in T-879/T-880. +- **T-883 — Cleanup** (DONE): `stores/sprint-store.ts` verwijderd. Grep `useSprintStore` = 0. Verify (671 tests) + build groen. +- **T-884 — E2E sprint-board verificatie** (REVIEW — manuele staging-checks): + - [ ] Cold reload → laatste sprint hersteld + - [ ] Tab hidden > 30s + terug → resync + - [ ] Netwerk uit/aan → reconnect + resync + - [ ] DnD reorder → optimistic UI; SSE-echo idempotent + - [ ] DB UPDATE story zonder delta → unknown-event resync binnen 1 cycle + - [ ] Twee tabs open → mutatie zichtbaar in beide binnen ~2s -> **Aanbeveling per ontwerpdoc:** start Story 9 pas nadat product-workspace enkele weken stabiel in productie staat. PBI-74 sluit pas wanneer Story 9 ook merged is. +> **Aanbeveling per ontwerpdoc:** Story 9 was bedoeld om pas te starten nadat product-workspace enkele weken stabiel in productie staat. PR #180 merged 2026-05-10; Story 9 vervolgens diezelfde dag uitgevoerd op gebruikersverzoek. Stabiliteit van product-workspace + impact van Story 9 op sprint-workflow nog te observeren in staging/productie. ## Critical files diff --git a/lib/realtime/use-sprint-realtime.ts b/lib/realtime/use-sprint-realtime.ts new file mode 100644 index 0000000..c4a70cd --- /dev/null +++ b/lib/realtime/use-sprint-realtime.ts @@ -0,0 +1,96 @@ +'use client' + +// PBI-74 / Story 9 / T-880: Client hook for the sprint workspace SSE stream. +// Mounts in SprintHydrationWrapper so it survives Server Action refreshes. +// Dispatches sprint/story/task change events into useSprintWorkspaceStore. +// +// Mirrors use-backlog-realtime.ts: +// - Stream blijft open op tab hidden — gemiste events worden opgehaald via +// resyncActiveScopes('visible') uit useSprintWorkspaceResync. +// - Latere 'ready'-events (post-reconnect) triggeren +// resyncActiveScopes('reconnect') zodat events tijdens disconnect alsnog +// binnenkomen. + +import { useEffect, useRef } from 'react' +import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' + +const BACKOFF_START_MS = 1_000 +const BACKOFF_MAX_MS = 30_000 + +export function useSprintRealtime(productId: string | null) { + const sourceRef = useRef(null) + const backoffRef = useRef(BACKOFF_START_MS) + const reconnectTimerRef = useRef | null>(null) + const readyCountRef = useRef(0) + + useEffect(() => { + if (!productId) return + + const close = () => { + if (sourceRef.current) { + sourceRef.current.close() + sourceRef.current = null + } + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current) + reconnectTimerRef.current = null + } + } + + const connect = () => { + close() + const source = new EventSource( + `/api/realtime/sprint?product_id=${encodeURIComponent(productId)}`, + ) + sourceRef.current = source + useSprintWorkspaceStore.getState().setRealtimeStatus('connecting') + + source.addEventListener('ready', () => { + backoffRef.current = BACKOFF_START_MS + readyCountRef.current += 1 + useSprintWorkspaceStore.getState().setRealtimeStatus('open') + if (readyCountRef.current > 1) { + void useSprintWorkspaceStore.getState().resyncActiveScopes('reconnect') + } + }) + + source.onmessage = (e) => { + if (!e.data) return + try { + const payload = JSON.parse(e.data) as Record + useSprintWorkspaceStore.getState().applyRealtimeEvent(payload) + } catch (err) { + if (process.env.NODE_ENV !== 'production') { + console.error('[realtime/sprint] failed to parse event', err, e.data) + } + } + } + + source.onerror = () => { + if (sourceRef.current !== source) return + close() + useSprintWorkspaceStore.getState().setRealtimeStatus('disconnected') + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return + const delay = backoffRef.current + backoffRef.current = Math.min(backoffRef.current * 2, BACKOFF_MAX_MS) + reconnectTimerRef.current = setTimeout(connect, delay) + } + } + + const onVisibility = () => { + if (document.visibilityState === 'visible' && sourceRef.current === null) { + backoffRef.current = BACKOFF_START_MS + connect() + } + } + + connect() + document.addEventListener('visibilitychange', onVisibility) + + return () => { + document.removeEventListener('visibilitychange', onVisibility) + close() + readyCountRef.current = 0 + } + }, [productId]) +} diff --git a/lib/realtime/use-sprint-workspace-resync.ts b/lib/realtime/use-sprint-workspace-resync.ts new file mode 100644 index 0000000..b21460b --- /dev/null +++ b/lib/realtime/use-sprint-workspace-resync.ts @@ -0,0 +1,36 @@ +'use client' + +// PBI-74 / Story 9 / T-880: useSprintWorkspaceResync. +// +// Trigger resyncActiveScopes bij: +// - hidden→visible (browser-throttled events kunnen gemist zijn) +// - online (netwerk hersteld na disconnect) +// +// Hoort gemount te worden naast useSprintRealtime in SprintHydrationWrapper. + +import { useEffect } from 'react' +import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' + +export function useSprintWorkspaceResync(): void { + useEffect(() => { + if (typeof document === 'undefined') return + + const onVisibility = () => { + if (document.visibilityState === 'visible') { + void useSprintWorkspaceStore.getState().resyncActiveScopes('visible') + } + } + + const onOnline = () => { + void useSprintWorkspaceStore.getState().resyncActiveScopes('reconnect') + } + + document.addEventListener('visibilitychange', onVisibility) + window.addEventListener('online', onOnline) + + return () => { + document.removeEventListener('visibilitychange', onVisibility) + window.removeEventListener('online', onOnline) + } + }, []) +} diff --git a/stores/sprint-store.ts b/stores/sprint-store.ts deleted file mode 100644 index c332eea..0000000 --- a/stores/sprint-store.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { create } from 'zustand' - -interface SprintStore { - // sprintId → storyId[] - sprintStoryOrder: Record - // storyId → taskId[] - taskOrder: Record - - initSprint: (sprintId: string, storyIds: string[]) => void - addStoryToSprint: (sprintId: string, storyId: string) => void - removeStoryFromSprint: (sprintId: string, storyId: string) => void - reorderSprintStories: (sprintId: string, storyIds: string[]) => void - rollbackSprint: (sprintId: string, storyIds: string[]) => void - - initTasks: (storyId: string, taskIds: string[]) => void - reorderTasks: (storyId: string, taskIds: string[]) => void - rollbackTasks: (storyId: string, taskIds: string[]) => void -} - -export const useSprintStore = create((set) => ({ - sprintStoryOrder: {}, - taskOrder: {}, - - initSprint: (sprintId, storyIds) => - set((s) => ({ sprintStoryOrder: { ...s.sprintStoryOrder, [sprintId]: storyIds } })), - - addStoryToSprint: (sprintId, storyId) => - set((s) => ({ - sprintStoryOrder: { - ...s.sprintStoryOrder, - [sprintId]: [...(s.sprintStoryOrder[sprintId] ?? []), storyId], - }, - })), - - removeStoryFromSprint: (sprintId, storyId) => - set((s) => ({ - sprintStoryOrder: { - ...s.sprintStoryOrder, - [sprintId]: (s.sprintStoryOrder[sprintId] ?? []).filter((id) => id !== storyId), - }, - })), - - reorderSprintStories: (sprintId, storyIds) => - set((s) => ({ sprintStoryOrder: { ...s.sprintStoryOrder, [sprintId]: storyIds } })), - - rollbackSprint: (sprintId, storyIds) => - set((s) => ({ sprintStoryOrder: { ...s.sprintStoryOrder, [sprintId]: storyIds } })), - - initTasks: (storyId, taskIds) => - set((s) => ({ taskOrder: { ...s.taskOrder, [storyId]: taskIds } })), - - reorderTasks: (storyId, taskIds) => - set((s) => ({ taskOrder: { ...s.taskOrder, [storyId]: taskIds } })), - - rollbackTasks: (storyId, taskIds) => - set((s) => ({ taskOrder: { ...s.taskOrder, [storyId]: taskIds } })), -})) diff --git a/stores/sprint-workspace/restore.ts b/stores/sprint-workspace/restore.ts new file mode 100644 index 0000000..c81f857 --- /dev/null +++ b/stores/sprint-workspace/restore.ts @@ -0,0 +1,128 @@ +const STORAGE_KEY = 'sprint-workspace-hints' + +interface PerProductHint { + lastActiveSprintId?: string | null +} + +interface PerSprintHint { + lastActiveStoryId?: string | null + lastActiveTaskId?: string | null +} + +export interface SprintWorkspaceHints { + lastActiveProductId: string | null + perProduct: Record + perSprint: Record +} + +const EMPTY_HINTS: SprintWorkspaceHints = { + lastActiveProductId: null, + perProduct: {}, + perSprint: {}, +} + +function safeStorage(): Storage | null { + if (typeof globalThis === 'undefined') return null + try { + const ls = (globalThis as { localStorage?: Storage }).localStorage + return ls ?? null + } catch { + return null + } +} + +export function readHints(): SprintWorkspaceHints { + const storage = safeStorage() + if (!storage) return { ...EMPTY_HINTS, perProduct: {}, perSprint: {} } + try { + const raw = storage.getItem(STORAGE_KEY) + if (!raw) return { ...EMPTY_HINTS, perProduct: {}, perSprint: {} } + const parsed = JSON.parse(raw) as Partial | null + if (!parsed || typeof parsed !== 'object') { + return { ...EMPTY_HINTS, perProduct: {}, perSprint: {} } + } + return { + lastActiveProductId: parsed.lastActiveProductId ?? null, + perProduct: + parsed.perProduct && typeof parsed.perProduct === 'object' + ? parsed.perProduct + : {}, + perSprint: + parsed.perSprint && typeof parsed.perSprint === 'object' + ? parsed.perSprint + : {}, + } + } catch { + return { ...EMPTY_HINTS, perProduct: {}, perSprint: {} } + } +} + +function writeHints(hints: SprintWorkspaceHints): void { + const storage = safeStorage() + if (!storage) return + try { + storage.setItem(STORAGE_KEY, JSON.stringify(hints)) + } catch { + // ignore quota or serialization errors + } +} + +export function writeProductHint(productId: string | null): void { + const hints = readHints() + hints.lastActiveProductId = productId + writeHints(hints) +} + +function ensurePerProduct( + hints: SprintWorkspaceHints, + productId: string, +): PerProductHint { + if (!hints.perProduct[productId]) { + hints.perProduct[productId] = {} + } + return hints.perProduct[productId] +} + +function ensurePerSprint( + hints: SprintWorkspaceHints, + sprintId: string, +): PerSprintHint { + if (!hints.perSprint[sprintId]) { + hints.perSprint[sprintId] = {} + } + return hints.perSprint[sprintId] +} + +export function writeSprintHint(productId: string, sprintId: string | null): void { + const hints = readHints() + const entry = ensurePerProduct(hints, productId) + entry.lastActiveSprintId = sprintId + writeHints(hints) +} + +export function writeStoryHint(sprintId: string, storyId: string | null): void { + const hints = readHints() + const entry = ensurePerSprint(hints, sprintId) + entry.lastActiveStoryId = storyId + if (storyId === null) { + entry.lastActiveTaskId = null + } + writeHints(hints) +} + +export function writeTaskHint(sprintId: string, taskId: string | null): void { + const hints = readHints() + const entry = ensurePerSprint(hints, sprintId) + entry.lastActiveTaskId = taskId + writeHints(hints) +} + +export function clearHints(): void { + const storage = safeStorage() + if (!storage) return + try { + storage.removeItem(STORAGE_KEY) + } catch { + // ignore + } +} diff --git a/stores/sprint-workspace/selectors.ts b/stores/sprint-workspace/selectors.ts new file mode 100644 index 0000000..12f79d9 --- /dev/null +++ b/stores/sprint-workspace/selectors.ts @@ -0,0 +1,115 @@ +import type { SprintWorkspaceStore } from './store' +import type { + SprintWorkspaceSprint, + SprintWorkspaceStory, + SprintWorkspaceTask, + SprintWorkspaceTaskDetail, +} from './types' + +// G1: stable EMPTY-references zodat selectors geen nieuwe array per call retourneren. +const EMPTY_SPRINTS: SprintWorkspaceSprint[] = [] +const EMPTY_STORIES: SprintWorkspaceStory[] = [] +const EMPTY_TASKS: (SprintWorkspaceTask | SprintWorkspaceTaskDetail)[] = [] + +/** + * Lijst-selector. Vereist `useShallow` in componenten (G2). + */ +export function selectVisibleSprints(s: SprintWorkspaceStore): SprintWorkspaceSprint[] { + const productId = s.context.activeProduct?.id + if (!productId) return EMPTY_SPRINTS + const ids = s.relations.sprintIdsByProduct[productId] + if (!ids || ids.length === 0) return EMPTY_SPRINTS + const out: SprintWorkspaceSprint[] = [] + for (const id of ids) { + const sprint = s.entities.sprintsById[id] + if (sprint) out.push(sprint) + } + return out.length === 0 ? EMPTY_SPRINTS : out +} + +/** + * Lijst-selector. Vereist `useShallow` in componenten (G2). + */ +export function selectStoriesForActiveSprint( + s: SprintWorkspaceStore, +): SprintWorkspaceStory[] { + const sprintId = s.context.activeSprintId + if (!sprintId) return EMPTY_STORIES + const ids = s.relations.storyIdsBySprint[sprintId] + if (!ids || ids.length === 0) return EMPTY_STORIES + const out: SprintWorkspaceStory[] = [] + for (const id of ids) { + const story = s.entities.storiesById[id] + if (story) out.push(story) + } + return out.length === 0 ? EMPTY_STORIES : out +} + +/** + * Lijst-selector. Vereist `useShallow` in componenten (G2). + */ +export function selectTasksForActiveStory( + s: SprintWorkspaceStore, +): (SprintWorkspaceTask | SprintWorkspaceTaskDetail)[] { + const storyId = s.context.activeStoryId + if (!storyId) return EMPTY_TASKS + const ids = s.relations.taskIdsByStory[storyId] + if (!ids || ids.length === 0) return EMPTY_TASKS + const out: (SprintWorkspaceTask | SprintWorkspaceTaskDetail)[] = [] + for (const id of ids) { + const task = s.entities.tasksById[id] + if (task) out.push(task) + } + return out.length === 0 ? EMPTY_TASKS : out +} + +/** + * Single-value selector. `useShallow` niet vereist. + */ +export function selectActiveSprint( + s: SprintWorkspaceStore, +): SprintWorkspaceSprint | null { + const id = s.context.activeSprintId + if (!id) return null + return s.entities.sprintsById[id] ?? null +} + +/** + * Single-value selector. `useShallow` niet vereist. + */ +export function selectActiveStory( + s: SprintWorkspaceStore, +): SprintWorkspaceStory | null { + const id = s.context.activeStoryId + if (!id) return null + return s.entities.storiesById[id] ?? null +} + +/** + * Single-value selector. `useShallow` niet vereist. + */ +export function selectActiveTask( + s: SprintWorkspaceStore, +): SprintWorkspaceTask | SprintWorkspaceTaskDetail | null { + const id = s.context.activeTaskId + if (!id) return null + return s.entities.tasksById[id] ?? null +} + +/** + * Lijst-selector voor tasks binnen een specifieke story (niet per se actief). + * Vereist `useShallow` in componenten (G2). + */ +export function selectTasksForStory( + s: SprintWorkspaceStore, + storyId: string, +): (SprintWorkspaceTask | SprintWorkspaceTaskDetail)[] { + const ids = s.relations.taskIdsByStory[storyId] + if (!ids || ids.length === 0) return EMPTY_TASKS + const out: (SprintWorkspaceTask | SprintWorkspaceTaskDetail)[] = [] + for (const id of ids) { + const task = s.entities.tasksById[id] + if (task) out.push(task) + } + return out.length === 0 ? EMPTY_TASKS : out +} diff --git a/stores/sprint-workspace/store.ts b/stores/sprint-workspace/store.ts new file mode 100644 index 0000000..e49afce --- /dev/null +++ b/stores/sprint-workspace/store.ts @@ -0,0 +1,918 @@ +import { create } from 'zustand' +import { immer } from 'zustand/middleware/immer' + +import { + isDetail, + type ActiveProductRef, + type OptimisticMutation, + type PendingOptimisticMutation, + type RealtimeStatus, + type ResyncReason, + type SprintWorkspaceSnapshot, + type SprintWorkspaceSprint, + type SprintWorkspaceStory, + type SprintWorkspaceTask, + type SprintWorkspaceTaskDetail, +} from './types' +import { + readHints, + writeProductHint, + writeSprintHint, + writeStoryHint, + writeTaskHint, +} from './restore' + +interface ContextSlice { + activeProduct: ActiveProductRef | null + activeSprintId: string | null + activeStoryId: string | null + activeTaskId: string | null +} + +interface EntitiesSlice { + sprintsById: Record + storiesById: Record + tasksById: Record +} + +interface RelationsSlice { + sprintIdsByProduct: Record + storyIdsBySprint: Record + taskIdsByStory: Record +} + +interface LoadingSlice { + loadedProductSprintsIds: Record + loadingProductId: string | null + loadedSprintIds: Record + loadingSprintId: string | null + loadedStoryIds: Record + loadedTaskIds: Record + activeRequestId: string | null +} + +interface SyncSlice { + realtimeStatus: RealtimeStatus + lastEventAt: number | null + lastResyncAt: number | null + resyncReason: ResyncReason | null +} + +interface State { + context: ContextSlice + entities: EntitiesSlice + relations: RelationsSlice + loading: LoadingSlice + sync: SyncSlice + pendingMutations: Record +} + +interface Actions { + hydrateSnapshot(snapshot: SprintWorkspaceSnapshot): void + hydrateProductSprints(productId: string, sprints: SprintWorkspaceSprint[]): void + + setActiveProduct(product: ActiveProductRef | null): void + setActiveSprint(sprintId: string | null): void + setActiveStory(storyId: string | null): void + setActiveTask(taskId: string | null): void + + ensureProductSprintsLoaded(productId: string, requestId?: string): Promise + ensureSprintLoaded(sprintId: string, requestId?: string): Promise + ensureStoryLoaded(storyId: string, requestId?: string): Promise + ensureTaskLoaded(taskId: string, requestId?: string): Promise + + applyRealtimeEvent(event: Record): void + resyncActiveScopes(reason: ResyncReason): Promise + resyncLoadedScopes(reason: ResyncReason): Promise + + applyOptimisticMutation(mutation: OptimisticMutation): string + rollbackMutation(mutationId: string): void + settleMutation(mutationId: string): void + + setRealtimeStatus(status: RealtimeStatus): void +} + +export type SprintWorkspaceStore = State & Actions + +const initialState: State = { + context: { + activeProduct: null, + activeSprintId: null, + activeStoryId: null, + activeTaskId: null, + }, + entities: { + sprintsById: {}, + storiesById: {}, + tasksById: {}, + }, + relations: { + sprintIdsByProduct: {}, + storyIdsBySprint: {}, + taskIdsByStory: {}, + }, + loading: { + loadedProductSprintsIds: {}, + loadingProductId: null, + loadedSprintIds: {}, + loadingSprintId: null, + loadedStoryIds: {}, + loadedTaskIds: {}, + activeRequestId: null, + }, + sync: { + realtimeStatus: 'connecting', + lastEventAt: null, + lastResyncAt: null, + resyncReason: null, + }, + pendingMutations: {}, +} + +function compareSprint(a: SprintWorkspaceSprint, b: SprintWorkspaceSprint): number { + // OPEN sprints first, then CLOSED + if (a.status !== b.status) return a.status === 'OPEN' ? -1 : 1 + // Newest start_date first within same status + const aStart = a.start_date ? new Date(a.start_date).getTime() : 0 + const bStart = b.start_date ? new Date(b.start_date).getTime() : 0 + if (aStart !== bStart) return bStart - aStart + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime() +} + +function compareStory(a: SprintWorkspaceStory, b: SprintWorkspaceStory): number { + if (a.priority !== b.priority) return a.priority - b.priority + if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order + return new Date(a.created_at).getTime() - new Date(b.created_at).getTime() +} + +function compareTask(a: SprintWorkspaceTask, b: SprintWorkspaceTask): number { + if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order + return new Date(a.created_at).getTime() - new Date(b.created_at).getTime() +} + +function newRequestId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + return `${Date.now()}-${Math.random().toString(36).slice(2)}` +} + +function isKnownEntity(entity: unknown): entity is 'sprint' | 'story' | 'task' { + return entity === 'sprint' || entity === 'story' || entity === 'task' +} + +function isUnknownEntityEvent(p: Record): boolean { + if (typeof p.entity !== 'string') return false + if (isKnownEntity(p.entity)) return false + if ('type' in p) return false + return true +} + +async function fetchJson(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { cache: 'no-store', ...init }) + if (!response.ok) { + throw new Error(`Fetch ${url} failed with ${response.status}`) + } + return (await response.json()) as T +} + +export const useSprintWorkspaceStore = create()( + immer((set, get) => ({ + ...initialState, + + hydrateSnapshot(snapshot) { + set((s) => { + if (snapshot.product) s.context.activeProduct = snapshot.product + + const sprintId = snapshot.sprint?.id ?? null + const productId = snapshot.product?.id ?? snapshot.sprint?.product_id ?? null + + if (snapshot.sprint) { + s.entities.sprintsById[snapshot.sprint.id] = snapshot.sprint + if (productId) { + const list = s.relations.sprintIdsByProduct[productId] ?? [] + if (!list.includes(snapshot.sprint.id)) { + list.push(snapshot.sprint.id) + s.relations.sprintIdsByProduct[productId] = sortSprintIds( + s.entities.sprintsById, + list, + ) + } + } + } + + for (const story of snapshot.stories) { + s.entities.storiesById[story.id] = story + } + if (sprintId) { + s.relations.storyIdsBySprint[sprintId] = [...snapshot.stories] + .sort(compareStory) + .map((st) => st.id) + } + + for (const [storyId, tasks] of Object.entries(snapshot.tasksByStory)) { + for (const task of tasks) { + s.entities.tasksById[task.id] = task + } + s.relations.taskIdsByStory[storyId] = [...tasks] + .sort(compareTask) + .map((t) => t.id) + } + + if (sprintId) { + s.loading.loadedSprintIds[sprintId] = true + } + }) + }, + + hydrateProductSprints(productId, sprints) { + set((s) => { + for (const sprint of sprints) { + s.entities.sprintsById[sprint.id] = sprint + } + s.relations.sprintIdsByProduct[productId] = [...sprints] + .sort(compareSprint) + .map((sp) => sp.id) + s.loading.loadedProductSprintsIds[productId] = true + }) + }, + + setActiveProduct(product) { + const requestId = newRequestId() + const productChanged = get().context.activeProduct?.id !== product?.id + + set((s) => { + s.context.activeProduct = product + s.context.activeSprintId = null + s.context.activeStoryId = null + s.context.activeTaskId = null + s.loading.activeRequestId = requestId + + if (productChanged) { + s.entities.sprintsById = {} + s.entities.storiesById = {} + s.entities.tasksById = {} + s.relations.sprintIdsByProduct = {} + s.relations.storyIdsBySprint = {} + s.relations.taskIdsByStory = {} + s.loading.loadedProductSprintsIds = {} + s.loading.loadedSprintIds = {} + s.loading.loadedStoryIds = {} + s.loading.loadedTaskIds = {} + } + }) + + writeProductHint(product?.id ?? null) + + if (product) { + const productId = product.id + void (async () => { + await get().ensureProductSprintsLoaded(productId, requestId) + if (get().loading.activeRequestId !== requestId) return + const hint = readHints().perProduct[productId]?.lastActiveSprintId + if (hint && get().entities.sprintsById[hint]) { + get().setActiveSprint(hint) + } + })() + } + }, + + setActiveSprint(sprintId) { + const requestId = newRequestId() + const productId = get().context.activeProduct?.id ?? null + + set((s) => { + s.context.activeSprintId = sprintId + s.context.activeStoryId = null + s.context.activeTaskId = null + s.loading.activeRequestId = requestId + }) + + if (productId) writeSprintHint(productId, sprintId) + + if (sprintId) { + void (async () => { + await get().ensureSprintLoaded(sprintId, requestId) + if (get().loading.activeRequestId !== requestId) return + const hint = readHints().perSprint[sprintId]?.lastActiveStoryId + if (hint && get().entities.storiesById[hint]) { + get().setActiveStory(hint) + } + })() + } + }, + + setActiveStory(storyId) { + const requestId = newRequestId() + const sprintId = get().context.activeSprintId + + set((s) => { + s.context.activeStoryId = storyId + s.context.activeTaskId = null + s.loading.activeRequestId = requestId + }) + + if (sprintId) writeStoryHint(sprintId, storyId) + + if (storyId) { + void (async () => { + await get().ensureStoryLoaded(storyId, requestId) + if (get().loading.activeRequestId !== requestId) return + if (!sprintId) return + const hint = readHints().perSprint[sprintId]?.lastActiveTaskId + if (hint && get().entities.tasksById[hint]) { + get().setActiveTask(hint) + } + })() + } + }, + + setActiveTask(taskId) { + const sprintId = get().context.activeSprintId + + set((s) => { + s.context.activeTaskId = taskId + }) + + if (sprintId) writeTaskHint(sprintId, taskId) + + if (taskId) { + void get().ensureTaskLoaded(taskId) + } + }, + + async ensureProductSprintsLoaded(productId, requestId) { + set((s) => { + s.loading.loadingProductId = productId + }) + try { + const sprints = await fetchJson( + `/api/products/${encodeURIComponent(productId)}/sprints`, + ) + if (requestId && get().loading.activeRequestId !== requestId) return + if (!Array.isArray(sprints)) return + get().hydrateProductSprints(productId, sprints) + } finally { + set((s) => { + if (s.loading.loadingProductId === productId) { + s.loading.loadingProductId = null + } + }) + } + }, + + async ensureSprintLoaded(sprintId, requestId) { + set((s) => { + s.loading.loadingSprintId = sprintId + }) + try { + const snapshot = await fetchJson( + `/api/sprints/${encodeURIComponent(sprintId)}/workspace`, + ) + if (requestId && get().loading.activeRequestId !== requestId) return + if (!snapshot || !Array.isArray(snapshot.stories)) return + get().hydrateSnapshot(snapshot) + } finally { + set((s) => { + if (s.loading.loadingSprintId === sprintId) { + s.loading.loadingSprintId = null + } + }) + } + }, + + async ensureStoryLoaded(storyId, requestId) { + const tasks = await fetchJson( + `/api/stories/${encodeURIComponent(storyId)}/tasks`, + ) + if (requestId && get().loading.activeRequestId !== requestId) return + if (!Array.isArray(tasks)) return + set((s) => { + for (const task of tasks) { + const existing = s.entities.tasksById[task.id] + if (existing && isDetail(existing)) { + s.entities.tasksById[task.id] = { ...existing, ...task } + } else { + s.entities.tasksById[task.id] = task + } + } + s.relations.taskIdsByStory[storyId] = [...tasks] + .sort(compareTask) + .map((t) => t.id) + s.loading.loadedStoryIds[storyId] = true + }) + }, + + async ensureTaskLoaded(taskId, requestId) { + const detail = await fetchJson( + `/api/tasks/${encodeURIComponent(taskId)}`, + ) + if (requestId && get().loading.activeRequestId !== requestId) return + if (!detail || typeof detail !== 'object') return + set((s) => { + s.entities.tasksById[taskId] = { ...detail, _detail: true } + s.loading.loadedTaskIds[taskId] = true + }) + }, + + applyRealtimeEvent(event) { + const payload = event as Record + const activeProductId = get().context.activeProduct?.id ?? null + + set((s) => { + s.sync.lastEventAt = Date.now() + }) + + if ( + typeof payload.product_id === 'string' && + activeProductId && + payload.product_id !== activeProductId + ) { + return + } + + if (isUnknownEntityEvent(payload)) { + if (payload.product_id === activeProductId) { + void get().resyncActiveScopes('unknown-event') + } + return + } + + const entity = payload.entity + const op = payload.op + if (!isKnownEntity(entity)) return + if (op !== 'I' && op !== 'U' && op !== 'D') return + + const id = payload.id + if (typeof id !== 'string') return + + if (entity === 'sprint') { + applySprintEvent(id, op, payload, set, get) + } else if (entity === 'story') { + applyStoryEvent(id, op, payload, set, get) + } else if (entity === 'task') { + applyTaskEvent(id, op, payload, set, get) + } + }, + + async resyncActiveScopes(reason) { + const ctx = get().context + const tasks: Promise[] = [] + if (ctx.activeProduct?.id) { + tasks.push(get().ensureProductSprintsLoaded(ctx.activeProduct.id)) + } + if (ctx.activeSprintId) tasks.push(get().ensureSprintLoaded(ctx.activeSprintId)) + if (ctx.activeStoryId) tasks.push(get().ensureStoryLoaded(ctx.activeStoryId)) + if (ctx.activeTaskId) tasks.push(get().ensureTaskLoaded(ctx.activeTaskId)) + set((s) => { + s.sync.lastResyncAt = Date.now() + s.sync.resyncReason = reason + }) + await Promise.allSettled(tasks) + }, + + async resyncLoadedScopes(reason) { + const loading = get().loading + const tasks: Promise[] = [] + for (const productId of Object.keys(loading.loadedProductSprintsIds)) { + tasks.push(get().ensureProductSprintsLoaded(productId)) + } + for (const sprintId of Object.keys(loading.loadedSprintIds)) { + tasks.push(get().ensureSprintLoaded(sprintId)) + } + for (const storyId of Object.keys(loading.loadedStoryIds)) { + tasks.push(get().ensureStoryLoaded(storyId)) + } + for (const taskId of Object.keys(loading.loadedTaskIds)) { + tasks.push(get().ensureTaskLoaded(taskId)) + } + set((s) => { + s.sync.lastResyncAt = Date.now() + s.sync.resyncReason = reason + }) + await Promise.allSettled(tasks) + }, + + applyOptimisticMutation(mutation) { + const id = newRequestId() + set((s) => { + s.pendingMutations[id] = { + id, + mutation, + createdAt: Date.now(), + } + }) + return id + }, + + rollbackMutation(mutationId) { + const pending = get().pendingMutations[mutationId] + if (!pending) return + const { mutation } = pending + set((s) => { + switch (mutation.kind) { + case 'sprint-story-order': + s.relations.storyIdsBySprint[mutation.sprintId] = [...mutation.prevStoryIds] + break + case 'sprint-task-order': + s.relations.taskIdsByStory[mutation.storyId] = [...mutation.prevTaskIds] + break + case 'entity-patch': { + const { entity, id, prev } = mutation + if (prev) { + if (entity === 'sprint') + s.entities.sprintsById[id] = prev as SprintWorkspaceSprint + else if (entity === 'story') + s.entities.storiesById[id] = prev as SprintWorkspaceStory + else + s.entities.tasksById[id] = prev as + | SprintWorkspaceTask + | SprintWorkspaceTaskDetail + } else { + if (entity === 'sprint') delete s.entities.sprintsById[id] + else if (entity === 'story') delete s.entities.storiesById[id] + else delete s.entities.tasksById[id] + } + break + } + } + delete s.pendingMutations[mutationId] + }) + }, + + settleMutation(mutationId) { + set((s) => { + delete s.pendingMutations[mutationId] + }) + }, + + setRealtimeStatus(status) { + set((s) => { + s.sync.realtimeStatus = status + }) + }, + })), +) + +type ImmerSet = Parameters>[0]>[0] +type ImmerGet = () => SprintWorkspaceStore + +function applySprintEvent( + id: string, + op: 'I' | 'U' | 'D', + payload: Record, + set: ImmerSet, + get: ImmerGet, +) { + if (op === 'D') { + set((s) => { + const sprint = s.entities.sprintsById[id] + const productId = sprint?.product_id + // Cascade: stories binnen deze sprint, tasks binnen die stories + const childStoryIds = s.relations.storyIdsBySprint[id] ?? [] + for (const sid of childStoryIds) { + const childTaskIds = s.relations.taskIdsByStory[sid] ?? [] + for (const tid of childTaskIds) { + delete s.entities.tasksById[tid] + } + delete s.relations.taskIdsByStory[sid] + delete s.entities.storiesById[sid] + } + delete s.relations.storyIdsBySprint[id] + delete s.entities.sprintsById[id] + if (productId) { + const list = s.relations.sprintIdsByProduct[productId] + if (list) { + s.relations.sprintIdsByProduct[productId] = list.filter((sid) => sid !== id) + } + } else { + for (const pid of Object.keys(s.relations.sprintIdsByProduct)) { + s.relations.sprintIdsByProduct[pid] = s.relations.sprintIdsByProduct[pid].filter( + (sid) => sid !== id, + ) + } + } + if (s.context.activeSprintId === id) { + s.context.activeSprintId = null + s.context.activeStoryId = null + s.context.activeTaskId = null + } + }) + return + } + + if (op === 'U') { + if (!get().entities.sprintsById[id]) return + set((s) => { + const existing = s.entities.sprintsById[id] + if (!existing) return + Object.assign(existing, sanitizeSprintPayload(payload)) + const productId = existing.product_id + if (productId && s.relations.sprintIdsByProduct[productId]) { + s.relations.sprintIdsByProduct[productId] = sortSprintIds( + s.entities.sprintsById, + s.relations.sprintIdsByProduct[productId], + ) + } + }) + return + } + + // I + if (get().entities.sprintsById[id]) return + set((s) => { + const sprint = coerceSprintPayload(id, payload) + s.entities.sprintsById[id] = sprint + const productId = sprint.product_id + const list = s.relations.sprintIdsByProduct[productId] ?? [] + list.push(id) + s.relations.sprintIdsByProduct[productId] = sortSprintIds(s.entities.sprintsById, list) + }) +} + +function applyStoryEvent( + id: string, + op: 'I' | 'U' | 'D', + payload: Record, + set: ImmerSet, + get: ImmerGet, +) { + const activeSprintId = get().context.activeSprintId + + if (op === 'D') { + set((s) => { + const childTaskIds = s.relations.taskIdsByStory[id] ?? [] + for (const tid of childTaskIds) { + delete s.entities.tasksById[tid] + } + delete s.relations.taskIdsByStory[id] + const story = s.entities.storiesById[id] + delete s.entities.storiesById[id] + if (story?.sprint_id) { + const ids = s.relations.storyIdsBySprint[story.sprint_id] + if (ids) { + s.relations.storyIdsBySprint[story.sprint_id] = ids.filter((sid) => sid !== id) + } + } else { + for (const sprintId of Object.keys(s.relations.storyIdsBySprint)) { + s.relations.storyIdsBySprint[sprintId] = s.relations.storyIdsBySprint[sprintId].filter( + (sid) => sid !== id, + ) + } + } + if (s.context.activeStoryId === id) { + s.context.activeStoryId = null + s.context.activeTaskId = null + } + }) + return + } + + if (op === 'U') { + const existing = get().entities.storiesById[id] + if (!existing) { + // Story moved into our active sprint? If sprint_id matches active, treat as I + if ( + activeSprintId && + payload.sprint_id === activeSprintId && + get().context.activeProduct?.id === payload.product_id + ) { + set((s) => { + const story = coerceStoryPayload(id, payload) + s.entities.storiesById[id] = story + if (story.sprint_id) { + const list = s.relations.storyIdsBySprint[story.sprint_id] ?? [] + if (!list.includes(id)) list.push(id) + s.relations.storyIdsBySprint[story.sprint_id] = sortStoryIds( + s.entities.storiesById, + list, + ) + } + }) + } + return + } + set((s) => { + const story = s.entities.storiesById[id] + if (!story) return + const oldSprintId = story.sprint_id + Object.assign(story, sanitizeStoryPayload(payload)) + const newSprintId = story.sprint_id + if (oldSprintId !== newSprintId) { + if (oldSprintId) { + const oldList = s.relations.storyIdsBySprint[oldSprintId] + if (oldList) { + s.relations.storyIdsBySprint[oldSprintId] = oldList.filter((sid) => sid !== id) + } + } + if (newSprintId) { + const targetList = s.relations.storyIdsBySprint[newSprintId] ?? [] + if (!targetList.includes(id)) targetList.push(id) + s.relations.storyIdsBySprint[newSprintId] = sortStoryIds( + s.entities.storiesById, + targetList, + ) + } + } else if (oldSprintId && s.relations.storyIdsBySprint[oldSprintId]) { + s.relations.storyIdsBySprint[oldSprintId] = sortStoryIds( + s.entities.storiesById, + s.relations.storyIdsBySprint[oldSprintId], + ) + } + }) + return + } + + // I + if (get().entities.storiesById[id]) return + set((s) => { + const story = coerceStoryPayload(id, payload) + s.entities.storiesById[id] = story + if (story.sprint_id) { + const list = s.relations.storyIdsBySprint[story.sprint_id] ?? [] + list.push(id) + s.relations.storyIdsBySprint[story.sprint_id] = sortStoryIds(s.entities.storiesById, list) + } + }) +} + +function applyTaskEvent( + id: string, + op: 'I' | 'U' | 'D', + payload: Record, + set: ImmerSet, + get: ImmerGet, +) { + if (op === 'D') { + set((s) => { + const task = s.entities.tasksById[id] + delete s.entities.tasksById[id] + if (task) { + const ids = s.relations.taskIdsByStory[task.story_id] + if (ids) { + s.relations.taskIdsByStory[task.story_id] = ids.filter((tid) => tid !== id) + } + } else { + for (const storyId of Object.keys(s.relations.taskIdsByStory)) { + s.relations.taskIdsByStory[storyId] = s.relations.taskIdsByStory[storyId].filter( + (tid) => tid !== id, + ) + } + } + if (s.context.activeTaskId === id) { + s.context.activeTaskId = null + } + }) + return + } + + if (op === 'U') { + const existing = get().entities.tasksById[id] + if (!existing) return + set((s) => { + const task = s.entities.tasksById[id] + if (!task) return + const oldStoryId = task.story_id + Object.assign(task, sanitizeTaskPayload(payload)) + const newStoryId = task.story_id + if (oldStoryId !== newStoryId) { + const oldList = s.relations.taskIdsByStory[oldStoryId] + if (oldList) { + s.relations.taskIdsByStory[oldStoryId] = oldList.filter((tid) => tid !== id) + } + const targetList = s.relations.taskIdsByStory[newStoryId] ?? [] + if (!targetList.includes(id)) targetList.push(id) + s.relations.taskIdsByStory[newStoryId] = sortTaskIds(s.entities.tasksById, targetList) + } else if (s.relations.taskIdsByStory[oldStoryId]) { + s.relations.taskIdsByStory[oldStoryId] = sortTaskIds( + s.entities.tasksById, + s.relations.taskIdsByStory[oldStoryId], + ) + } + }) + return + } + + // I + if (get().entities.tasksById[id]) return + set((s) => { + const task = coerceTaskPayload(id, payload) + s.entities.tasksById[id] = task + const list = s.relations.taskIdsByStory[task.story_id] ?? [] + list.push(id) + s.relations.taskIdsByStory[task.story_id] = sortTaskIds(s.entities.tasksById, list) + }) +} + +function sortSprintIds( + byId: Record, + ids: string[], +): string[] { + return [...new Set(ids)] + .filter((id) => byId[id] !== undefined) + .sort((a, b) => compareSprint(byId[a], byId[b])) +} + +function sortStoryIds( + byId: Record, + ids: string[], +): string[] { + return [...new Set(ids)] + .filter((id) => byId[id] !== undefined) + .sort((a, b) => compareStory(byId[a], byId[b])) +} + +function sortTaskIds( + byId: Record, + ids: string[], +): string[] { + return [...new Set(ids)] + .filter((id) => byId[id] !== undefined) + .sort((a, b) => compareTask(byId[a], byId[b])) +} + +function sanitizeSprintPayload(p: Record): Partial { + const { entity: _e, op: _o, ...rest } = p + void _e + void _o + return rest as Partial +} + +function sanitizeStoryPayload(p: Record): Partial { + const { entity: _e, op: _o, ...rest } = p + void _e + void _o + return rest as Partial +} + +function sanitizeTaskPayload(p: Record): Partial { + const { entity: _e, op: _o, ...rest } = p + void _e + void _o + return rest as Partial +} + +function coerceSprintPayload( + id: string, + p: Record, +): SprintWorkspaceSprint { + return { + id, + product_id: String(p.product_id ?? ''), + code: String(p.code ?? ''), + sprint_goal: String(p.sprint_goal ?? ''), + status: (p.status as SprintWorkspaceSprint['status']) ?? 'OPEN', + start_date: (p.start_date as string | null | undefined) ?? null, + end_date: (p.end_date as string | null | undefined) ?? null, + created_at: + p.created_at instanceof Date + ? p.created_at + : new Date(String(p.created_at ?? Date.now())), + completed_at: + p.completed_at instanceof Date + ? p.completed_at + : p.completed_at + ? new Date(String(p.completed_at)) + : null, + } +} + +function coerceStoryPayload( + id: string, + p: Record, +): SprintWorkspaceStory { + return { + id, + code: (p.code as string | null) ?? null, + title: String(p.title ?? ''), + description: (p.description as string | null | undefined) ?? null, + acceptance_criteria: (p.acceptance_criteria as string | null | undefined) ?? null, + priority: Number(p.priority ?? 4), + sort_order: Number(p.sort_order ?? 0), + status: String(p.status ?? 'open'), + pbi_id: String(p.pbi_id ?? ''), + sprint_id: (p.sprint_id as string | null | undefined) ?? null, + created_at: + p.created_at instanceof Date + ? p.created_at + : new Date(String(p.created_at ?? Date.now())), + } +} + +function coerceTaskPayload(id: string, p: Record): SprintWorkspaceTask { + return { + id, + code: (p.code as string | null) ?? null, + title: String(p.title ?? ''), + description: (p.description as string | null | undefined) ?? null, + priority: Number(p.priority ?? 4), + sort_order: Number(p.sort_order ?? 0), + status: String(p.status ?? 'todo'), + story_id: String(p.story_id ?? ''), + sprint_id: (p.sprint_id as string | null | undefined) ?? null, + created_at: + p.created_at instanceof Date + ? p.created_at + : new Date(String(p.created_at ?? Date.now())), + } +} diff --git a/stores/sprint-workspace/types.ts b/stores/sprint-workspace/types.ts new file mode 100644 index 0000000..1ff6802 --- /dev/null +++ b/stores/sprint-workspace/types.ts @@ -0,0 +1,158 @@ +import type { TaskStatusApi } from '@/lib/task-status' + +export type SprintStatus = 'OPEN' | 'CLOSED' + +export interface SprintWorkspaceSprint { + id: string + product_id: string + code: string + sprint_goal: string + status: SprintStatus + start_date: string | null + end_date: string | null + created_at: Date + completed_at: Date | null +} + +export interface SprintWorkspaceStory { + id: string + code: string | null + title: string + description: string | null + acceptance_criteria: string | null + priority: number + sort_order: number + status: string + pbi_id: string + sprint_id: string | null + created_at: Date + taskCount?: number + doneCount?: number + assignee_id?: string | null + assignee_username?: string | null +} + +export interface SprintWorkspaceTask { + id: string + code: string | null + title: string + description: string | null + priority: number + sort_order: number + status: TaskStatusApi | string + story_id: string + sprint_id: string | null + created_at: Date +} + +export interface SprintWorkspaceTaskDetail extends SprintWorkspaceTask { + _detail: true + implementation_plan?: string | null + acceptance_criteria?: string | null + requires_opus?: boolean + verify_only?: boolean + estimated_minutes?: number | null +} + +export function isDetail( + task: SprintWorkspaceTask | SprintWorkspaceTaskDetail, +): task is SprintWorkspaceTaskDetail { + return (task as SprintWorkspaceTaskDetail)._detail === true +} + +export interface ActiveProductRef { + id: string + name: string +} + +export interface SprintWorkspaceSnapshot { + product?: ActiveProductRef + sprint?: SprintWorkspaceSprint + stories: SprintWorkspaceStory[] + tasksByStory: Record +} + +export interface ProductSprintsList { + productId: string + sprints: SprintWorkspaceSprint[] +} + +export type Op = 'I' | 'U' | 'D' + +export interface SprintEntityRealtimeEvent { + entity: 'sprint' + op: Op + id: string + product_id: string + [key: string]: unknown +} + +export interface SprintStoryRealtimeEvent { + entity: 'story' + op: Op + id: string + product_id: string + pbi_id?: string + sprint_id?: string | null + [key: string]: unknown +} + +export interface SprintTaskRealtimeEvent { + entity: 'task' + op: Op + id: string + product_id: string + story_id?: string + sprint_id?: string | null + [key: string]: unknown +} + +export type SprintRealtimeEvent = + | SprintEntityRealtimeEvent + | SprintStoryRealtimeEvent + | SprintTaskRealtimeEvent + +export type ResyncReason = + | 'visible' + | 'reconnect' + | 'manual' + | 'unknown-event' + | 'stale-scope' + | 'mutation-settled' + +export type RealtimeStatus = 'connecting' | 'open' | 'disconnected' + +export interface OptimisticSprintStoryOrderMutation { + kind: 'sprint-story-order' + sprintId: string + prevStoryIds: string[] +} + +export interface OptimisticSprintTaskOrderMutation { + kind: 'sprint-task-order' + storyId: string + prevTaskIds: string[] +} + +export interface OptimisticEntityPatchMutation { + kind: 'entity-patch' + entity: 'sprint' | 'story' | 'task' + id: string + prev: + | SprintWorkspaceSprint + | SprintWorkspaceStory + | SprintWorkspaceTask + | SprintWorkspaceTaskDetail + | undefined +} + +export type OptimisticMutation = + | OptimisticSprintStoryOrderMutation + | OptimisticSprintTaskOrderMutation + | OptimisticEntityPatchMutation + +export interface PendingOptimisticMutation { + id: string + mutation: OptimisticMutation + createdAt: number +}