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>
165 lines
5.5 KiB
TypeScript
165 lines
5.5 KiB
TypeScript
'use client'
|
|
|
|
import { useRef, useState, useTransition } from 'react'
|
|
import Link from 'next/link'
|
|
import { toast } from 'sonner'
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
|
import { useSoloStore } from '@/stores/solo-store'
|
|
import { cn } from '@/lib/utils'
|
|
import type { SoloTask } from './solo-board'
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
TO_DO: 'bg-status-todo/15 text-status-todo border-status-todo/30',
|
|
IN_PROGRESS: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
|
|
REVIEW: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
|
|
DONE: 'bg-status-done/15 text-status-done border-status-done/30',
|
|
}
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
TO_DO: 'To Do',
|
|
IN_PROGRESS: 'Bezig',
|
|
REVIEW: 'Review',
|
|
DONE: 'Klaar',
|
|
}
|
|
|
|
interface TaskDetailDialogProps {
|
|
task: SoloTask | null
|
|
productId: string
|
|
isDemo: boolean
|
|
onClose: () => void
|
|
}
|
|
|
|
interface TaskDetailContentProps {
|
|
task: SoloTask
|
|
productId: string
|
|
isDemo: boolean
|
|
onClose: () => void
|
|
}
|
|
|
|
type SaveState = 'idle' | 'saving' | 'saved'
|
|
|
|
function TaskDetailContent({ task, productId, isDemo, onClose }: TaskDetailContentProps) {
|
|
const { updatePlan } = useSoloStore()
|
|
const [localPlan, setLocalPlan] = useState(task.implementation_plan ?? '')
|
|
const [saveState, setSaveState] = useState<SaveState>('idle')
|
|
const [, startTransition] = useTransition()
|
|
const fadeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const savedPlanRef = useRef(task.implementation_plan ?? '')
|
|
|
|
function handleBlur() {
|
|
if (isDemo || localPlan === savedPlanRef.current) return
|
|
|
|
setSaveState('saving')
|
|
if (fadeTimer.current) clearTimeout(fadeTimer.current)
|
|
|
|
// fetch naar Route Handler i.p.v. Server Action — Server Actions
|
|
// kappen anders de open SSE-stream van het Solo Paneel af. Zie
|
|
// notitie in solo-board.tsx handleDragEnd.
|
|
startTransition(async () => {
|
|
try {
|
|
const res = await fetch(`/api/tasks/${task.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'include',
|
|
body: JSON.stringify({ implementation_plan: localPlan }),
|
|
})
|
|
if (!res.ok) {
|
|
setSaveState('idle')
|
|
toast.error('Implementatieplan opslaan mislukt')
|
|
return
|
|
}
|
|
savedPlanRef.current = localPlan
|
|
updatePlan(task.id, localPlan || null)
|
|
setSaveState('saved')
|
|
fadeTimer.current = setTimeout(() => setSaveState('idle'), 2000)
|
|
} catch {
|
|
setSaveState('idle')
|
|
toast.error('Implementatieplan opslaan mislukt')
|
|
}
|
|
})
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<DialogHeader>
|
|
<div className="flex items-start gap-3 pr-8">
|
|
<DialogTitle className="text-sm font-medium leading-snug flex-1">
|
|
{task.title}
|
|
</DialogTitle>
|
|
{task.task_code && (
|
|
<span className="font-mono text-[11px] text-muted-foreground border border-border rounded-md bg-surface-container px-1.5 py-0.5 shrink-0">
|
|
{task.task_code}
|
|
</span>
|
|
)}
|
|
<Badge className={cn('text-xs border shrink-0', STATUS_COLORS[task.status])}>
|
|
{STATUS_LABELS[task.status]}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{task.story_code && <span className="font-mono mr-1">{task.story_code}</span>}
|
|
{task.story_title}
|
|
</p>
|
|
</DialogHeader>
|
|
|
|
{task.description && (
|
|
<div>
|
|
<p className="text-xs font-medium text-muted-foreground mb-1.5">Beschrijving</p>
|
|
<p className="text-sm text-foreground whitespace-pre-wrap">{task.description}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<p className="text-xs font-medium text-muted-foreground mb-1.5">Implementatieplan</p>
|
|
<DemoTooltip show={isDemo}>
|
|
<Textarea
|
|
value={localPlan}
|
|
onChange={(e) => setLocalPlan(e.target.value)}
|
|
onBlur={handleBlur}
|
|
placeholder="Voeg een implementatieplan toe…"
|
|
className="resize-none text-sm min-h-[120px]"
|
|
readOnly={isDemo}
|
|
/>
|
|
</DemoTooltip>
|
|
<div className="flex justify-end mt-1 h-4">
|
|
{saveState === 'saving' && (
|
|
<span className="text-xs text-muted-foreground">Bezig met opslaan…</span>
|
|
)}
|
|
{saveState === 'saved' && (
|
|
<span className="text-xs text-status-done">Opgeslagen</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="-mx-4 -mb-4 flex items-center border-t bg-muted/50 px-4 py-3 rounded-b-xl">
|
|
<Link
|
|
href={`/products/${productId}/sprint/planning`}
|
|
className="text-xs text-primary hover:underline"
|
|
onClick={onClose}
|
|
>
|
|
Open in Sprint Board ↗
|
|
</Link>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function TaskDetailDialog({ task, productId, isDemo, onClose }: TaskDetailDialogProps) {
|
|
return (
|
|
<Dialog open={!!task} onOpenChange={(open) => { if (!open) onClose() }}>
|
|
<DialogContent className="sm:max-w-lg">
|
|
{task && (
|
|
<TaskDetailContent
|
|
key={task.id}
|
|
task={task}
|
|
productId={productId}
|
|
isDemo={isDemo}
|
|
onClose={onClose}
|
|
/>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|