import { create } from 'zustand' import type { SoloTask } from '@/components/solo/solo-board' import type { ClaudeJobStatusApi } from '@/lib/job-status' type TaskStatus = SoloTask['status'] export type VerifyResultApi = 'aligned' | 'partial' | 'empty' | 'divergent' export interface JobState { job_id: string task_id: string status: ClaudeJobStatusApi branch?: string pushed_at?: string | null pr_url?: string | null verify_result?: VerifyResultApi | null summary?: string error?: string } export type ClaudeJobEvent = | { type: 'claude_job_enqueued'; job_id: string; task_id: string; user_id: string; product_id: string; status: 'queued' } | { type: 'claude_job_status'; job_id: string; task_id: string; user_id: string; product_id: string; status: ClaudeJobStatusApi; branch?: string; pushed_at?: string; pr_url?: string; verify_result?: VerifyResultApi; summary?: string; error?: string } // Payload-shape gepubliceerd door de Postgres-trigger via pg_notify (ST-801 // + ST-804 prereq). Komt het Solo Paneel binnen via de SSE-stream uit // /api/realtime/solo (ST-802). export interface RealtimeEvent { op: 'I' | 'U' | 'D' entity: 'task' | 'story' id: string story_id?: string product_id: string sprint_id: string | null assignee_id: string | null // Task-specifieke velden (alleen aanwezig als entity === 'task') task_status?: TaskStatus task_sort_order?: number task_title?: string // Story-specifieke velden (alleen aanwezig als entity === 'story') story_status?: 'OPEN' | 'IN_SPRINT' | 'DONE' story_sort_order?: number story_title?: string story_code?: string | null // Op UPDATE: lijst van kolommen die zijn veranderd changed_fields?: string[] } export type RealtimeStatus = 'connecting' | 'open' | 'disconnected' interface SoloStore { tasks: Record /** Task-ids die op dit moment een eigen optimistic write in de lucht hebben. * Realtime echos voor deze ids worden onderdrukt zodat de eigen update niet * twee keer toegepast wordt of door een latere echo overschreven. */ pendingOps: Set /** Realtime-connection state, beheerd door useSoloRealtime in de * (app)-layout. Hier in de store omdat de UI-indicator in SoloBoard zit en * de hook niet direct in dezelfde subtree draait. */ realtimeStatus: RealtimeStatus showConnectingIndicator: boolean claudeJobsByTaskId: Record connectedWorkers: number lowQuotaTokenIds: Set initTasks: (tasks: SoloTask[]) => void optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null rollback: (taskId: string, prevStatus: TaskStatus) => void updatePlan: (taskId: string, plan: string | null) => void updateVerifyOnly: (taskId: string, value: boolean) => void updateVerifyRequired: (taskId: string, value: 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY') => void markPending: (taskId: string) => void clearPending: (taskId: string) => void setRealtimeStatus: (status: RealtimeStatus, showConnectingIndicator: boolean) => void initJobs: (jobs: JobState[]) => void handleJobEvent: (event: ClaudeJobEvent) => void setWorkers: (count: number) => void incrementWorkers: () => void decrementWorkers: () => void setWorkerLowQuota: (tokenId: string, isLow: boolean) => void handleRealtimeEvent: (event: RealtimeEvent) => void } export const useSoloStore = create((set, get) => ({ tasks: {}, pendingOps: new Set(), realtimeStatus: 'connecting', showConnectingIndicator: false, claudeJobsByTaskId: {}, connectedWorkers: 0, lowQuotaTokenIds: new Set(), initTasks: (tasks) => set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }), optimisticMove: (taskId, toStatus) => { const prev = get().tasks[taskId]?.status ?? null if (!prev) return null set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], status: toStatus } } })) return prev }, rollback: (taskId, prevStatus) => set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], status: prevStatus } } })), updatePlan: (taskId, plan) => set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], implementation_plan: plan } } })), updateVerifyOnly: (taskId, value) => set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_only: value } } })), updateVerifyRequired: (taskId, value) => set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_required: value } } })), markPending: (taskId) => set((s) => { if (s.pendingOps.has(taskId)) return s const next = new Set(s.pendingOps) next.add(taskId) return { pendingOps: next } }), clearPending: (taskId) => set((s) => { if (!s.pendingOps.has(taskId)) return s const next = new Set(s.pendingOps) next.delete(taskId) return { pendingOps: next } }), setRealtimeStatus: (status, showConnectingIndicator) => set((s) => { if (s.realtimeStatus === status && s.showConnectingIndicator === showConnectingIndicator) { return s } return { realtimeStatus: status, showConnectingIndicator } }), initJobs: (jobs) => set({ claudeJobsByTaskId: Object.fromEntries(jobs.map(j => [j.task_id, j])) }), setWorkers: (count) => set({ connectedWorkers: Math.max(0, count) }), incrementWorkers: () => set(s => ({ connectedWorkers: s.connectedWorkers + 1 })), decrementWorkers: () => set(s => ({ connectedWorkers: Math.max(0, s.connectedWorkers - 1) })), setWorkerLowQuota: (tokenId, isLow) => set(s => { const next = new Set(s.lowQuotaTokenIds) if (isLow) next.add(tokenId) else next.delete(tokenId) return { lowQuotaTokenIds: next } }), handleJobEvent: (event) => { const { job_id, task_id } = event if (event.type === 'claude_job_enqueued') { set((s) => ({ claudeJobsByTaskId: { ...s.claudeJobsByTaskId, [task_id]: { job_id, task_id, status: 'queued' }, }, })) return } if (event.type === 'claude_job_status') { const { status, branch, pushed_at, pr_url, verify_result, summary, error } = event if (status === 'cancelled') { set((s) => { const next = { ...s.claudeJobsByTaskId } delete next[task_id] return { claudeJobsByTaskId: next } }) return } set((s) => ({ claudeJobsByTaskId: { ...s.claudeJobsByTaskId, [task_id]: { job_id, task_id, status, branch, pushed_at, pr_url, verify_result, summary, error }, }, })) } }, handleRealtimeEvent: (event) => { if (event.entity === 'task') { const { id, op } = event if (op === 'D') { set((s) => { if (!(id in s.tasks)) return s const next = { ...s.tasks } delete next[id] return { tasks: next } }) return } // INSERT en UPDATE: alleen bestaande taken bijwerken. Nieuwe taken // zonder story-context (story_title, story_code) renderen we niet // — gebruiker ziet ze pas na een refresh. Acceptabel voor v1. const existing = get().tasks[id] if (!existing) return if (get().pendingOps.has(id)) { // Echo van een eigen optimistic move — laat de optimistic-state staan return } const updates: Partial = {} if (event.task_status !== undefined && event.task_status !== existing.status) { updates.status = event.task_status } if ( event.task_sort_order !== undefined && event.task_sort_order !== existing.sort_order ) { updates.sort_order = event.task_sort_order } if (event.task_title !== undefined && event.task_title !== existing.title) { updates.title = event.task_title } if (Object.keys(updates).length === 0) return set((s) => ({ tasks: { ...s.tasks, [id]: { ...s.tasks[id], ...updates } } })) return } if (event.entity === 'story') { const { id, op } = event if (op === 'D') { // Story-cascade pakt tasks ook in de DB; verwijder de bijbehorende // SoloTask-records uit de store. set((s) => { const next: Record = {} for (const [taskId, task] of Object.entries(s.tasks)) { if (task.story_id !== id) next[taskId] = task } return { tasks: next } }) return } const tasks = get().tasks const affectedIds = Object.entries(tasks) .filter(([, t]) => t.story_id === id) .map(([taskId]) => taskId) if (affectedIds.length === 0) return const newTitle = event.story_title const newCode = event.story_code ?? null set((s) => { const next = { ...s.tasks } for (const taskId of affectedIds) { const t = next[taskId] const titleChanged = newTitle !== undefined && t.story_title !== newTitle const codeChanged = newCode !== t.story_code if (!titleChanged && !codeChanged) continue next[taskId] = { ...t, ...(titleChanged && newTitle !== undefined && { story_title: newTitle }), ...(codeChanged && { story_code: newCode }), } } return { tasks: next } }) } }, }))