diff --git a/app/(app)/products/[id]/sprint/[sprintId]/page.tsx b/app/(app)/products/[id]/sprint/[sprintId]/page.tsx index c7d1707..fa862ef 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' @@ -13,6 +17,7 @@ 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' @@ -106,6 +111,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { })) const tasksByStory: Record = {} + const tasksByStoryWorkspace: Record = {} for (const story of sprintStories) { tasksByStory[story.id] = story.tasks.map(t => ({ id: t.id, @@ -117,6 +123,18 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { story_id: t.story_id, sprint_id: t.sprint_id, })) + 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, + })) } // All PBIs with their stories for the left (product backlog) panel @@ -162,6 +180,22 @@ export default async function SprintBoardPage({ params, searchParams }: Props) { 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 +228,24 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
- + productName={product.name} + > + +
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/components/sprint/sprint-hydration-wrapper.tsx b/components/sprint/sprint-hydration-wrapper.tsx new file mode 100644 index 0000000..625d634 --- /dev/null +++ b/components/sprint/sprint-hydration-wrapper.tsx @@ -0,0 +1,86 @@ +'use client' + +// PBI-74 / Story 9 / T-880: 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. Tijdens T-880 (schaduw-fase) blijft useSprintStore parallel +// werken in sprint-board components — beide stores naast elkaar tot T-881 +// componenten omzet en T-883 de oude store opruimt. + +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/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) + } + }, []) +}