Scrum4Me/components/solo/solo-board.tsx
Madhura68 847fc84faf fix(M8): make SSE-stream survive Solo Paneel mutations
Symptoom op feat/ST-801-realtime-triggers initial implementation:
elke task-update sloot de open SSE-stream af en triggerde een
herverbinding met backoff. In de tussentijd gemiste events.

Oorzaak: Server Actions in App Router doen een impliciete
route-tree refresh die client components remount; daarmee killt
React de useEffect die de EventSource beheert.

Fix in twee delen:

1. Hef de realtime-hook op naar de (app)-layout via een nieuwe
   `SoloRealtimeBridge`-component. Layouts overleven Server-
   Action-refreshes beter dan pages, en de bridge leest het
   product-id uit de URL via usePathname. Connection-status
   (status, showConnectingIndicator) gaat naar de solo-store
   zodat SoloBoard 'm uit een gedeelde plek kan lezen.

2. Vervang updateTaskStatusAction en updateTaskPlanAction in de
   Solo-componenten door fetch naar de bestaande Route Handler
   `PATCH /api/tasks/[id]`. Route Handlers triggeren geen
   page-refresh, dus de SSE-stream blijft staan. lib/api-auth.ts
   accepteert nu naast Bearer-tokens ook iron-session cookies
   zodat browser-fetches zonder token werken.

Bijkomend: actions/tasks.ts laat /solo bewust niet meer
revalideren (wordt nu via realtime gedekt). Sprint/planning blijft
wel revalidaten — geen realtime daar.

Toegevoegd:
- components/solo/realtime-bridge.tsx — mount in (app) layout
- scripts/realtime-mutate.ts — handige test-helper voor externe
  mutaties (alsof MCP/REST schrijft) tijdens acceptance

Debug-logs in app/api/realtime/solo/route.ts staan nog aan voor
ST-806 acceptance; worden later gestript.

Bekend issue: Chrome op localhost (HTTP/1.1) cycle't EventSource
om de paar seconden vanwege de 6-connectie-limiet en retry-
heuristiek. Safari werkt stabiel. Productie op Vercel (HTTP/2
multiplexing) zou beide browsers stabiel moeten houden — Vercel
preview test is volgende stap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 04:35:12 +02:00

238 lines
7.8 KiB
TypeScript

'use client'
import { useEffect, useState, useTransition } from 'react'
import {
DndContext, DragEndEvent, DragOverlay, DragStartEvent,
PointerSensor, useSensor, useSensors, closestCorners,
} from '@dnd-kit/core'
import { toast } from 'sonner'
import { useSoloStore } from '@/stores/solo-store'
import type { RealtimeStatus } from '@/stores/solo-store'
import { taskStatusToApi } from '@/lib/task-status'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { SoloColumn, type ColumnStatus } from './solo-column'
import { SoloTaskCardOverlay } from './solo-task-card'
import { TaskDetailDialog } from './task-detail-dialog'
import { UnassignedStoriesSheet, type UnassignedStory } from './unassigned-stories-sheet'
// ST-805: kleine status-dot in de header — groen wanneer SSE-stream open
// is, grijs/rood pas zichtbaar als de connectie >2s niet open is (animatie B
// zit in useSoloRealtime). Default groen tijdens de eerste 2s zodat micro-
// disconnects geen flikker geven.
function RealtimeIndicator({
status,
showConnectingIndicator,
}: {
status: RealtimeStatus
showConnectingIndicator: boolean
}) {
let color = 'bg-status-done'
let label = 'Live'
if (showConnectingIndicator) {
if (status === 'disconnected') {
color = 'bg-priority-critical'
label = 'Verbroken — opnieuw proberen…'
} else {
color = 'bg-muted-foreground'
label = 'Verbinden…'
}
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<span
aria-label={label}
className={cn('inline-block h-2 w-2 rounded-full shrink-0 transition-colors', color)}
/>
}
/>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
export interface SoloTask {
id: string
title: string
description: string | null
implementation_plan: string | null
priority: number
sort_order: number
status: 'TO_DO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE'
story_id: string
story_code: string | null
story_title: string
task_code: string | null
}
export interface SoloBoardProps {
productId: string
productName: string
sprintGoal: string
tasks: SoloTask[]
unassignedStories: UnassignedStory[]
isDemo: boolean
currentUserId: string
}
const COLUMN_STATUSES: ColumnStatus[] = ['TO_DO', 'IN_PROGRESS', 'DONE']
function getColumnStatus(status: SoloTask['status']): ColumnStatus {
if (status === 'REVIEW') return 'IN_PROGRESS'
return status
}
export function SoloBoard({
productId, productName, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo,
}: SoloBoardProps) {
const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore()
const realtimeStatus = useSoloStore((s) => s.realtimeStatus)
const showConnectingIndicator = useSoloStore((s) => s.showConnectingIndicator)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [selectedTask, setSelectedTask] = useState<SoloTask | null>(null)
const [sheetOpen, setSheetOpen] = useState(false)
const [unassignedStories, setUnassignedStories] = useState(initialUnassigned)
const [, startTransition] = useTransition()
const taskKey = initialTasks.map(t => t.id).join(',')
useEffect(() => {
initTasks(initialTasks)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskKey])
const pointerSensor = useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
const sensors = useSensors(...(isDemo ? [] : [pointerSensor]))
const taskList = Object.values(tasks)
const columnTasks: Record<ColumnStatus, SoloTask[]> = {
TO_DO: taskList.filter(t => getColumnStatus(t.status) === 'TO_DO'),
IN_PROGRESS: taskList.filter(t => getColumnStatus(t.status) === 'IN_PROGRESS'),
DONE: taskList.filter(t => getColumnStatus(t.status) === 'DONE'),
}
function handleDragStart(event: DragStartEvent) {
setActiveDragId(event.active.id as string)
}
function handleDragEnd(event: DragEndEvent) {
setActiveDragId(null)
const { active, over } = event
if (!over) return
const toStatus = over.id as ColumnStatus
if (!COLUMN_STATUSES.includes(toStatus)) return
const taskId = active.id as string
const task = tasks[taskId]
if (!task) return
if (getColumnStatus(task.status) === toStatus) return
const prevStatus = optimisticMove(taskId, toStatus)
if (!prevStatus) return
// Onderdruk realtime-echo van onze eigen write — de Postgres-trigger
// vuurt en die NOTIFY komt zo terug via SSE; zonder pending-marker
// zou de store nogmaals een set() doen of de optimistic state
// overschrijven. clearPending na de fetch (succes of fail).
//
// We gebruiken bewust een fetch-based Route Handler in plaats van
// de updateTaskStatusAction Server Action — Server Actions
// triggeren een full route-tree refresh die de open SSE-stream van
// /api/realtime/solo zou afkappen, waardoor we elke 5s reconnecten
// en realtime-events missen.
markPending(taskId)
startTransition(async () => {
try {
const res = await fetch(`/api/tasks/${taskId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ status: taskStatusToApi(toStatus) }),
})
if (!res.ok) {
rollback(taskId, prevStatus)
toast.error('Status bijwerken mislukt — taak teruggeplaatst')
}
} catch {
rollback(taskId, prevStatus)
toast.error('Status bijwerken mislukt — taak teruggeplaatst')
} finally {
clearPending(taskId)
}
})
}
const activeTask = activeDragId ? tasks[activeDragId] : null
const columns = (
<div className="grid grid-cols-3 gap-4 flex-1 min-h-0">
{COLUMN_STATUSES.map(status => (
<SoloColumn
key={status}
status={status}
tasks={columnTasks[status]}
isDemo={isDemo}
onTaskClick={(t) => setSelectedTask(t)}
/>
))}
</div>
)
return (
<div className="flex flex-col h-full p-4 gap-4 min-h-0">
<div className="flex items-start justify-between gap-4 shrink-0">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-base font-semibold text-foreground truncate">{productName}</h1>
<RealtimeIndicator
status={realtimeStatus}
showConnectingIndicator={showConnectingIndicator}
/>
</div>
{sprintGoal && (
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{sprintGoal}</p>
)}
</div>
<button
className="text-sm text-primary hover:underline whitespace-nowrap shrink-0 disabled:text-muted-foreground disabled:cursor-default"
disabled={unassignedStories.length === 0}
onClick={() => setSheetOpen(true)}
>
Toon openstaande stories ({unassignedStories.length})
</button>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{columns}
<DragOverlay>
{activeTask && <SoloTaskCardOverlay task={activeTask} />}
</DragOverlay>
</DndContext>
<TaskDetailDialog
task={selectedTask}
productId={productId}
isDemo={isDemo}
onClose={() => setSelectedTask(null)}
/>
<UnassignedStoriesSheet
stories={unassignedStories}
productId={productId}
isDemo={isDemo}
open={sheetOpen}
onOpenChange={setSheetOpen}
onClaim={(id) => setUnassignedStories(prev => prev.filter(s => s.id !== id))}
/>
</div>
)
}