diff --git a/__tests__/realtime/use-workspace-resync.test.tsx b/__tests__/realtime/use-workspace-resync.test.tsx new file mode 100644 index 0000000..cbc50a5 --- /dev/null +++ b/__tests__/realtime/use-workspace-resync.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHook } from '@testing-library/react' + +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' +import { useWorkspaceResync } from '@/lib/realtime/use-workspace-resync' + +let resyncSpy: ReturnType + +beforeEach(() => { + resyncSpy = vi.fn().mockResolvedValue(undefined) + useProductWorkspaceStore.setState((s) => { + s.resyncActiveScopes = resyncSpy as unknown as typeof s.resyncActiveScopes + }) + // visibilitychange handler leest document.visibilityState — default is 'visible' + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('useWorkspaceResync', () => { + it('triggert resyncActiveScopes("visible") op visibilitychange hidden→visible', () => { + renderHook(() => useWorkspaceResync()) + + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + configurable: true, + }) + document.dispatchEvent(new Event('visibilitychange')) + + expect(resyncSpy).toHaveBeenCalledWith('visible') + }) + + it('triggert resyncActiveScopes("reconnect") op online-event', () => { + renderHook(() => useWorkspaceResync()) + window.dispatchEvent(new Event('online')) + expect(resyncSpy).toHaveBeenCalledWith('reconnect') + }) + + it('triggert geen resync bij visibilitychange naar hidden', () => { + renderHook(() => useWorkspaceResync()) + + Object.defineProperty(document, 'visibilityState', { + value: 'hidden', + writable: true, + configurable: true, + }) + document.dispatchEvent(new Event('visibilitychange')) + + expect(resyncSpy).not.toHaveBeenCalled() + }) + + it('cleanup verwijdert listeners bij unmount', () => { + const { unmount } = renderHook(() => useWorkspaceResync()) + unmount() + + window.dispatchEvent(new Event('online')) + document.dispatchEvent(new Event('visibilitychange')) + + expect(resyncSpy).not.toHaveBeenCalled() + }) +}) diff --git a/components/backlog/backlog-hydration-wrapper.tsx b/components/backlog/backlog-hydration-wrapper.tsx index 10b996a..136f703 100644 --- a/components/backlog/backlog-hydration-wrapper.tsx +++ b/components/backlog/backlog-hydration-wrapper.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react' import { useBacklogStore, type BacklogPbi, type BacklogStory, type BacklogTask } from '@/stores/backlog-store' import { useBacklogRealtime } from '@/lib/realtime/use-backlog-realtime' +import { useWorkspaceResync } from '@/lib/realtime/use-workspace-resync' import { useProductWorkspaceStore } from '@/stores/product-workspace/store' import type { BacklogPbi as WorkspacePbi, @@ -77,6 +78,7 @@ export function BacklogHydrationWrapper({ }, [initialData, productId, productName, setInitialData]) useBacklogRealtime(productId) + useWorkspaceResync() return <>{children} } diff --git a/components/backlog/pbi-list.tsx b/components/backlog/pbi-list.tsx index a21260e..d51a838 100644 --- a/components/backlog/pbi-list.tsx +++ b/components/backlog/pbi-list.tsx @@ -302,7 +302,6 @@ export function PbiList({ productId, isDemo }: PbiListProps) { // pbis komen al gesorteerd binnen via selectVisiblePbis (priority + sort_order). // Geen aparte order/priority maps meer — workspace-store entities zijn de waarheid. const pbiMap = Object.fromEntries(pbis.map(p => [p.id, p])) - const order = pbis.map(p => p.id) const orderedPbis = pbis const base = orderedPbis.filter(p => { diff --git a/components/backlog/story-panel.tsx b/components/backlog/story-panel.tsx index 539e277..5967030 100644 --- a/components/backlog/story-panel.tsx +++ b/components/backlog/story-panel.tsx @@ -143,7 +143,6 @@ export function StoryPanel({ productId, isDemo }: StoryPanelProps) { // rawStories komt al gesorteerd binnen via selectStoriesForActivePbi. const storyMap = Object.fromEntries(rawStories.map(s => [s.id, s])) - const order = rawStories.map(s => s.id) const orderedStories = rawStories const base = orderedStories diff --git a/lib/realtime/use-backlog-realtime.ts b/lib/realtime/use-backlog-realtime.ts index f6659cb..0ebb653 100644 --- a/lib/realtime/use-backlog-realtime.ts +++ b/lib/realtime/use-backlog-realtime.ts @@ -7,6 +7,12 @@ // PBI-74 / T-845: dual-dispatch — events worden ook naar de nieuwe // product-workspace-store gestuurd. De oude store blijft leidend totdat // Story 3 de UI-consumers heeft omgezet en Story 8 de oude store opruimt. +// PBI-74 / T-861: stream blijft open op tab hidden. Per spec werkt +// EventSource gewoon door als de browser het toelaat — gemiste events +// worden opgehaald via resyncActiveScopes('visible') uit useWorkspaceResync. +// PBI-74 / T-862: bij latere 'ready' events (post-reconnect) triggeren we +// resyncActiveScopes('reconnect') zodat events die tijdens disconnect zijn +// gemist, alsnog binnenkomen. import { useEffect, useRef } from 'react' import { useBacklogStore } from '@/stores/backlog-store' @@ -27,6 +33,7 @@ export function useBacklogRealtime(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 @@ -51,6 +58,13 @@ export function useBacklogRealtime(productId: string | null) { source.addEventListener('ready', () => { backoffRef.current = BACKOFF_START_MS + readyCountRef.current += 1 + // T-862: eerste ready = initial connect; latere ready = reconnect. + if (readyCountRef.current > 1) { + void useProductWorkspaceStore + .getState() + .resyncActiveScopes('reconnect') + } }) source.onmessage = (e) => { @@ -83,23 +97,22 @@ export function useBacklogRealtime(productId: string | null) { } } + // T-861: stream blijft open op hidden. Reconnect alleen als source weg + // is (b.v. na netwerkfout) en de tab visible is. const onVisibility = () => { - if (document.visibilityState === 'hidden') { - close() - } else if (sourceRef.current === null) { + if (document.visibilityState === 'visible' && sourceRef.current === null) { backoffRef.current = BACKOFF_START_MS connect() } } - if (document.visibilityState === 'visible') { - connect() - } + connect() document.addEventListener('visibilitychange', onVisibility) return () => { document.removeEventListener('visibilitychange', onVisibility) close() + readyCountRef.current = 0 } }, [productId]) } diff --git a/lib/realtime/use-workspace-resync.ts b/lib/realtime/use-workspace-resync.ts new file mode 100644 index 0000000..844fa48 --- /dev/null +++ b/lib/realtime/use-workspace-resync.ts @@ -0,0 +1,40 @@ +'use client' + +// PBI-74 / T-863: useWorkspaceResync hook. +// +// Trigger resyncActiveScopes bij: +// - hidden→visible (browser-throttled events kunnen gemist zijn) +// - online (netwerk hersteld na disconnect) +// +// Hoort gemount te worden naast useBacklogRealtime in BacklogHydrationWrapper. + +import { useEffect } from 'react' +import { useProductWorkspaceStore } from '@/stores/product-workspace/store' + +export function useWorkspaceResync(): void { + useEffect(() => { + if (typeof document === 'undefined') return + + const onVisibility = () => { + if (document.visibilityState === 'visible') { + void useProductWorkspaceStore + .getState() + .resyncActiveScopes('visible') + } + } + + const onOnline = () => { + void useProductWorkspaceStore + .getState() + .resyncActiveScopes('reconnect') + } + + document.addEventListener('visibilitychange', onVisibility) + window.addEventListener('online', onOnline) + + return () => { + document.removeEventListener('visibilitychange', onVisibility) + window.removeEventListener('online', onOnline) + } + }, []) +}