From ccaae75468ec0fce1949a6ecd5aa5c8f471e2e23 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 27 Apr 2026 02:16:23 +0200 Subject: [PATCH] feat(ST-805): wire useSoloRealtime + live indicator + column-move animatie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SoloBoard roept useSoloRealtime(productId) aan, zo komt elke task/ story-mutatie uit web/REST/MCP binnen via SSE en wordt door de store-dispatcher (ST-804) verwerkt - markPending/clearPending rond de drag-drop optimistic write zodat de echo van de eigen Server Action de store niet dubbel beweegt - RealtimeIndicator: kleine status-dot in de header - groen ('Live') wanneer SSE-stream open OF tijdens de eerste 2s grace-period (animatie B in de hook — voorkomt micro-flikker) - grijs ('Verbinden…') na 2s in connecting-state - rood ('Verbroken') na 2s in disconnected-state - Animatie A (kolom-move): bij task UPDATE-events wikkelt de hook de store-dispatch in document.startViewTransition + flushSync. SoloTask- cards krijgen view-transition-name `solo-task-` (alleen wanneer niet aan het draggen) zodat de browser de positie-shift soepel animeert van bezig naar klaar (en omgekeerd) Bestaande 89 tests blijven groen. Co-Authored-By: Claude Opus 4.7 (1M context) --- components/solo/solo-board.tsx | 76 ++++++++++++++++++++++++++---- components/solo/solo-task-card.tsx | 9 +++- lib/realtime/use-solo-realtime.ts | 30 +++++++++++- 3 files changed, 105 insertions(+), 10 deletions(-) diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx index 60cddc4..9d6ddc5 100644 --- a/components/solo/solo-board.tsx +++ b/components/solo/solo-board.tsx @@ -8,11 +8,53 @@ import { import { toast } from 'sonner' import { useSoloStore } from '@/stores/solo-store' import { updateTaskStatusAction } from '@/actions/tasks' +import { useSoloRealtime, type RealtimeStatus } from '@/lib/realtime/use-solo-realtime' +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 ( + + + + } + /> + {label} + + + ) +} + export interface SoloTask { id: string title: string @@ -47,13 +89,15 @@ function getColumnStatus(status: SoloTask['status']): ColumnStatus { export function SoloBoard({ productId, productName, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, }: SoloBoardProps) { - const { tasks, initTasks, optimisticMove, rollback } = useSoloStore() + const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore() const [activeDragId, setActiveDragId] = useState(null) const [selectedTask, setSelectedTask] = useState(null) const [sheetOpen, setSheetOpen] = useState(false) const [unassignedStories, setUnassignedStories] = useState(initialUnassigned) const [, startTransition] = useTransition() + const { status: realtimeStatus, showConnectingIndicator } = useSoloRealtime(productId) + const taskKey = initialTasks.map(t => t.id).join(',') useEffect(() => { initTasks(initialTasks) @@ -82,18 +126,28 @@ export function SoloBoard({ const toStatus = over.id as ColumnStatus if (!COLUMN_STATUSES.includes(toStatus)) return - const task = tasks[active.id as string] + const taskId = active.id as string + const task = tasks[taskId] if (!task) return if (getColumnStatus(task.status) === toStatus) return - const prevStatus = optimisticMove(active.id as string, toStatus) + 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 Server Action (succes of fail). + markPending(taskId) startTransition(async () => { - const result = await updateTaskStatusAction(active.id as string, toStatus) - if (result && 'error' in result) { - rollback(active.id as string, prevStatus) - toast.error('Status bijwerken mislukt — taak teruggeplaatst') + try { + const result = await updateTaskStatusAction(taskId, toStatus) + if (result && 'error' in result) { + rollback(taskId, prevStatus) + toast.error('Status bijwerken mislukt — taak teruggeplaatst') + } + } finally { + clearPending(taskId) } }) } @@ -118,7 +172,13 @@ export function SoloBoard({
-

{productName}

+
+

{productName}

+ +
{sprintGoal && (

{sprintGoal}

)} diff --git a/components/solo/solo-task-card.tsx b/components/solo/solo-task-card.tsx index 6378010..61135c8 100644 --- a/components/solo/solo-task-card.tsx +++ b/components/solo/solo-task-card.tsx @@ -1,5 +1,6 @@ 'use client' +import type React from 'react' import { useDraggable } from '@dnd-kit/core' import { CSS } from '@dnd-kit/utilities' import { cn } from '@/lib/utils' @@ -25,7 +26,13 @@ export function SoloTaskCard({ task, isDemo, onClick }: SoloTaskCardProps) { disabled: isDemo, }) - const style = transform ? { transform: CSS.Translate.toString(transform) } : undefined + // view-transition-name laat de browser deze card snapshotten zodat hij + // soepel van kolom naar kolom animeert wanneer de status realtime wijzigt + // (ST-805 animatie A). Tijdens drag uit zetten — dnd-kit beheert de + // transform dan zelf en dubbele transitions willen we niet. + const style: React.CSSProperties | undefined = transform + ? { transform: CSS.Translate.toString(transform) } + : { viewTransitionName: `solo-task-${task.id}` } return (
void) => unknown + } + ).startViewTransition(() => { + flushSync(() => handleEvent(payload)) + }) + } else { + handleEvent(payload) + } } catch (err) { if (process.env.NODE_ENV !== 'production') { console.error('[realtime] failed to parse event', err, e.data)