feat(ST-805): wire useSoloRealtime + live indicator + column-move animatie
- 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-<id>` (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) <noreply@anthropic.com>
This commit is contained in:
parent
562735b98b
commit
ccaae75468
3 changed files with 105 additions and 10 deletions
|
|
@ -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 (
|
||||
<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
|
||||
|
|
@ -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<string | null>(null)
|
||||
const [selectedTask, setSelectedTask] = useState<SoloTask | null>(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({
|
|||
<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">
|
||||
<h1 className="text-base font-semibold text-foreground truncate">{productName}</h1>
|
||||
<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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useSoloStore } from '@/stores/solo-store'
|
||||
import type { RealtimeEvent } from '@/stores/solo-store'
|
||||
|
||||
|
|
@ -81,7 +82,34 @@ export function useSoloRealtime(productId: string) {
|
|||
if (!e.data) return
|
||||
try {
|
||||
const payload = JSON.parse(e.data) as RealtimeEvent
|
||||
handleEvent(payload)
|
||||
// ST-805 animatie A: kanban-move animeren via View Transitions API.
|
||||
// Voor task UPDATE-events wrap'en we de store-update in een view
|
||||
// transition. De browser snapshot't de DOM voor en na, en animeert
|
||||
// het verschil — vereist `view-transition-name` op de cards.
|
||||
// Andere events (task INSERT/DELETE, story-events) krijgen geen
|
||||
// animatie; die zijn niet zichtbaar als positie-shift in de
|
||||
// kanban-kolommen.
|
||||
const animate =
|
||||
payload.entity === 'task' &&
|
||||
payload.op === 'U' &&
|
||||
typeof document !== 'undefined' &&
|
||||
typeof (document as Document & { startViewTransition?: unknown }).startViewTransition ===
|
||||
'function'
|
||||
if (animate) {
|
||||
// flushSync forceert React om de re-render synchroon te doen
|
||||
// tijdens de view-transition callback, zodat de nieuwe DOM-state
|
||||
// wordt gesnapshot voor de animatie. Zonder flushSync rendert
|
||||
// React asynchroon en captured de browser nog de oude state.
|
||||
;(
|
||||
document as Document & {
|
||||
startViewTransition: (cb: () => 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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue