Scrum4Me/components/solo/solo-board.tsx
Janpeter Visser 73087e9705
M13: Claude job queue — 'Voer uit'-knop + worker presence (ST-1111) (#18)
* feat(ST-1111.1): add ClaudeJob model and state-machine enum

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.2): add ClaudeJob status API mappers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.3): add enqueue/cancel ClaudeJob server actions with idempotency + NOTIFY

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.4): forward ClaudeJob events on solo SSE stream + initial state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.6): add 'Voer uit' + cancel buttons to task detail dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.7): add job status pill with spinner on solo task cards

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(ST-1111.8): cover job-status mappers and enqueue/cancel actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(ST-1111.9): document Claude job queue architecture and agent flow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.10a): add ClaudeWorker presence model

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.10c): forward worker presence events on solo SSE + initial count

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ST-1111.10d): show worker presence indicator and gate 'Voer uit' on connected workers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 19:51:48 +02:00

246 lines
8.2 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 >4s niet open is (animatie B
// zit in useSoloRealtime). Default groen tijdens de eerste 4s 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 connectedWorkers = useSoloStore((s) => s.connectedWorkers)
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 className="flex items-center gap-1 text-xs text-muted-foreground ml-1">
<span className={cn(
'size-2 rounded-full',
connectedWorkers > 0 ? 'bg-status-done' : 'bg-muted-foreground/40'
)} />
{connectedWorkers > 0 ? 'Agent verbonden' : 'Geen agent'}
</div>
</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>
)
}