Scrum4Me/components/solo/solo-board.tsx
Janpeter Visser 3b5cee823c
Load/render workspace alignment (#182)
* docs: plan load render workspace alignment

* fix: normalize workspace status hydration

* fix: avoid duplicate backlog hydration load

* refactor: use sprint store active story

* refactor: migrate solo to workspace store

* chore: stabilize verification ignores
2026-05-10 07:34:58 +02:00

304 lines
11 KiB
TypeScript

'use client'
import { useEffect, useState, useTransition } from 'react'
import { useShallow } from 'zustand/react/shallow'
import {
DndContext, DragEndEvent, DragOverlay, DragStartEvent,
PointerSensor, useSensor, useSensors, closestCorners,
} from '@dnd-kit/core'
import { toast } from 'sonner'
import { useSoloStore } from '@/stores/solo-store'
import {
selectSoloTaskById,
selectSoloTasksForColumn,
selectSoloUnassignedStories,
} from '@/stores/solo-workspace/selectors'
import type { SoloTask, SoloUnassignedStory, SoloWorkspaceSnapshot } from '@/stores/solo-workspace/types'
import { taskStatusToApi } from '@/lib/task-status'
import { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs'
import { BatchEnqueueBlockerDialog } from './batch-enqueue-blocker-dialog'
import { debugProps } from '@/lib/debug'
import { Button } from '@/components/ui/button'
import { DemoTooltip } from '@/components/shared/demo-tooltip'
import { SplitPane } from '@/components/split-pane/split-pane'
import { SoloColumn, type ColumnStatus } from './solo-column'
import { SoloTaskCardOverlay } from './solo-task-card'
import { TaskDetailDialog } from './task-detail-dialog'
import { UnassignedStoriesSheet } from './unassigned-stories-sheet'
export type { SoloTask } from '@/stores/solo-workspace/types'
export interface SoloBoardProps {
productId: string
sprintGoal: string
tasks?: SoloTask[]
unassignedStories?: SoloUnassignedStory[]
isDemo: boolean
currentUserId?: string
repoUrl?: string | null
}
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, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, repoUrl, currentUserId,
}: SoloBoardProps) {
const {
tasks,
hydrateSnapshot,
optimisticMove,
rollback,
markPending,
clearPending,
removeUnassignedStory,
} = useSoloStore()
const claudeJobsByTaskId = useSoloStore((s) => s.claudeJobsByTaskId)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null)
const selectedTask = useSoloStore(selectSoloTaskById(selectedTaskId))
const [sheetOpen, setSheetOpen] = useState(false)
const [, startTransition] = useTransition()
const [batchPending, startBatchTransition] = useTransition()
const [confirmPending, startConfirmTransition] = useTransition()
type BlockerDialogState = {
prefixCount: number
blockerReason: 'task-review' | 'pbi-blocked'
blockerLabel: string
prefixIds: string[]
}
const [blockerDialog, setBlockerDialog] = useState<BlockerDialogState | null>(null)
useEffect(() => {
if (!initialTasks || !initialUnassigned || !currentUserId) return
const snapshot: SoloWorkspaceSnapshot = {
product: { id: productId, name: '' },
sprint: { id: `compat:${productId}`, sprint_goal: sprintGoal },
activeUserId: currentUserId,
tasks: initialTasks,
unassignedStories: initialUnassigned,
}
hydrateSnapshot(snapshot)
}, [currentUserId, hydrateSnapshot, initialTasks, initialUnassigned, productId, sprintGoal])
const pointerSensor = useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
const sensors = useSensors(...(isDemo ? [] : [pointerSensor]))
const columnTasks: Record<ColumnStatus, SoloTask[]> = {
TO_DO: useSoloStore(useShallow(selectSoloTasksForColumn('TO_DO'))),
IN_PROGRESS: useSoloStore(useShallow(selectSoloTasksForColumn('IN_PROGRESS'))),
DONE: useSoloStore(useShallow(selectSoloTasksForColumn('DONE'))),
}
const unassignedStories = useSoloStore(useShallow(selectSoloUnassignedStories))
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 queueableCount = columnTasks.TO_DO.filter(t => {
const job = claudeJobsByTaskId[t.id]
return !job || (job.status !== 'queued' && job.status !== 'claimed' && job.status !== 'running')
}).length
function handleStartAll() {
if (queueableCount === 0) return
startBatchTransition(async () => {
const preview = await previewEnqueueAllAction(productId)
if ('error' in preview) {
toast.error(preview.error)
return
}
if (preview.blockerIndex === null) {
const todoIds = preview.tasks.filter(t => t.status === 'TO_DO').map(t => t.id)
const result = await enqueueClaudeJobsBatchAction(productId, todoIds)
if ('error' in result) {
toast.error(result.error)
} else if (result.count === 0) {
toast.info('Geen taken om te starten')
} else {
toast.success(`${result.count} ${result.count === 1 ? 'agent' : 'agents'} ingeschakeld`)
}
} else {
const blockerTask = preview.tasks[preview.blockerIndex]
const blockerLabel = preview.blockerReason === 'task-review'
? `${blockerTask.story_title}${blockerTask.title}`
: blockerTask.story_title
setBlockerDialog({
prefixCount: preview.blockerIndex,
blockerReason: preview.blockerReason!,
blockerLabel,
prefixIds: preview.tasks.slice(0, preview.blockerIndex).map(t => t.id),
})
}
})
}
function handleBlockerConfirm() {
if (!blockerDialog) return
const { prefixIds } = blockerDialog
setBlockerDialog(null)
startConfirmTransition(async () => {
const result = await enqueueClaudeJobsBatchAction(productId, prefixIds)
if ('error' in result) {
toast.error(result.error)
} else if (result.count === 0) {
toast.info('Geen taken om te starten')
} else {
toast.success(`${result.count} ${result.count === 1 ? 'agent' : 'agents'} ingeschakeld`)
}
})
}
return (
<div className="flex flex-col h-full p-4 gap-4 min-h-0" {...debugProps('solo-board', 'SoloBoard', 'components/solo/solo-board.tsx')}>
<div className="flex items-start justify-between gap-4 shrink-0" data-debug-id="solo-board__header">
<div className="min-w-0 flex items-center gap-3">
<DemoTooltip show={isDemo}>
<Button
size="sm"
onClick={handleStartAll}
disabled={isDemo || batchPending || confirmPending || queueableCount === 0}
>
{batchPending || confirmPending ? 'Starten…' : `Start agents (${queueableCount})`}
</Button>
</DemoTooltip>
{sprintGoal && (
<p className="text-sm text-muted-foreground line-clamp-2 min-w-0">{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}
>
<div className="flex-1 min-h-0" data-debug-id="solo-board__columns">
<SplitPane
cookieKey={`solo-${productId}`}
defaultSplit={[33, 33, 34]}
tabLabels={['Te doen', 'Bezig', 'Klaar']}
panes={[
<SoloColumn
key="TO_DO"
status="TO_DO"
tasks={columnTasks.TO_DO}
isDemo={isDemo}
onTaskClick={(t) => setSelectedTaskId(t.id)}
/>,
<SoloColumn
key="IN_PROGRESS"
status="IN_PROGRESS"
tasks={columnTasks.IN_PROGRESS}
isDemo={isDemo}
onTaskClick={(t) => setSelectedTaskId(t.id)}
/>,
<SoloColumn
key="DONE"
status="DONE"
tasks={columnTasks.DONE}
isDemo={isDemo}
onTaskClick={(t) => setSelectedTaskId(t.id)}
/>,
]}
/>
</div>
<DragOverlay>
{activeTask && <SoloTaskCardOverlay task={activeTask} />}
</DragOverlay>
</DndContext>
<TaskDetailDialog
task={selectedTask}
productId={productId}
isDemo={isDemo}
repoUrl={repoUrl}
onClose={() => setSelectedTaskId(null)}
/>
<UnassignedStoriesSheet
stories={unassignedStories}
productId={productId}
isDemo={isDemo}
open={sheetOpen}
onOpenChange={setSheetOpen}
onClaim={removeUnassignedStory}
/>
{blockerDialog && (
<BatchEnqueueBlockerDialog
open
onOpenChange={(v) => { if (!v) setBlockerDialog(null) }}
prefixCount={blockerDialog.prefixCount}
blockerReason={blockerDialog.blockerReason}
blockerLabel={blockerDialog.blockerLabel}
onConfirm={handleBlockerConfirm}
onCancel={() => setBlockerDialog(null)}
/>
)}
</div>
)
}