import { create } from 'zustand' import type { ClaudeJobEvent, JobState, RealtimeEvent, RealtimeStatus, ResyncReason, SoloColumnStatus, SoloTask, SoloTaskStatus, SoloUnassignedStory, SoloWorkspaceProduct, SoloWorkspaceSnapshot, SoloWorkspaceSprint, SoloVerifyRequired, } from './types' interface ContextSlice { activeProduct: SoloWorkspaceProduct | null activeSprint: SoloWorkspaceSprint | null activeUserId: string | null } interface EntitiesSlice { tasksById: Record unassignedStoriesById: Record jobsByTaskId: Record } interface RelationsSlice { taskIdsByColumn: Record unassignedStoryIds: string[] } interface LoadingSlice { loadedProductId: string | null loadedSprintId: string | null loadingSprintId: string | null activeRequestId: string | null } interface SyncSlice { realtimeStatus: RealtimeStatus showConnectingIndicator: boolean lastEventAt: number | null lastResyncAt: number | null resyncReason: ResyncReason | null } interface State { context: ContextSlice entities: EntitiesSlice relations: RelationsSlice loading: LoadingSlice sync: SyncSlice pendingOps: Set tasks: Record unassignedStoriesById: Record claudeJobsByTaskId: Record realtimeStatus: RealtimeStatus showConnectingIndicator: boolean connectedWorkers: number workerQuotaPct: number | null workerQuotaCheckAt: string | null } interface Actions { hydrateSnapshot(snapshot: SoloWorkspaceSnapshot): void initTasks(tasks: SoloTask[]): void hydrateUnassignedStories(stories: SoloUnassignedStory[]): void removeUnassignedStory(storyId: string): void optimisticMove(taskId: string, toStatus: SoloTaskStatus): SoloTaskStatus | null rollback(taskId: string, prevStatus: SoloTaskStatus): void updatePlan(taskId: string, plan: string | null): void updateVerifyOnly(taskId: string, value: boolean): void updateVerifyRequired(taskId: string, value: SoloVerifyRequired): 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 setWorkerQuota(pct: number, checkAt: string): void handleRealtimeEvent(event: RealtimeEvent): void ensureWorkspaceLoaded(productId: string, sprintId?: string, requestId?: string): Promise resyncActiveScopes(reason: ResyncReason): Promise } export type SoloWorkspaceStore = State & Actions const EMPTY_COLUMNS: Record = { TO_DO: [], IN_PROGRESS: [], DONE: [], } const initialState: State = { context: { activeProduct: null, activeSprint: null, activeUserId: null, }, entities: { tasksById: {}, unassignedStoriesById: {}, jobsByTaskId: {}, }, relations: { taskIdsByColumn: EMPTY_COLUMNS, unassignedStoryIds: [], }, loading: { loadedProductId: null, loadedSprintId: null, loadingSprintId: null, activeRequestId: null, }, sync: { realtimeStatus: 'connecting', showConnectingIndicator: false, lastEventAt: null, lastResyncAt: null, resyncReason: null, }, pendingOps: new Set(), tasks: {}, unassignedStoriesById: {}, claudeJobsByTaskId: {}, realtimeStatus: 'connecting', showConnectingIndicator: false, connectedWorkers: 0, workerQuotaPct: null, workerQuotaCheckAt: null, } function getColumnStatus(status: SoloTaskStatus): SoloColumnStatus { if (status === 'REVIEW') return 'IN_PROGRESS' return status } function compareTask(a: SoloTask, b: SoloTask): number { if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order if (a.priority !== b.priority) return a.priority - b.priority const aCode = a.task_code ?? '' const bCode = b.task_code ?? '' const codeCompare = aCode.localeCompare(bCode, 'nl', { numeric: true }) if (codeCompare !== 0) return codeCompare return a.id.localeCompare(b.id) } function compareUnassignedStory(a: SoloUnassignedStory, b: SoloUnassignedStory): number { const aCode = a.code ?? '' const bCode = b.code ?? '' const codeCompare = aCode.localeCompare(bCode, 'nl', { numeric: true }) if (codeCompare !== 0) return codeCompare return a.title.localeCompare(b.title, 'nl', { numeric: true }) } function buildTaskRelations(tasksById: Record): Record { const next: Record = { TO_DO: [], IN_PROGRESS: [], DONE: [], } const tasks = Object.values(tasksById).sort(compareTask) for (const task of tasks) { next[getColumnStatus(task.status)].push(task.id) } return next } function buildUnassignedRelations(storiesById: Record): string[] { return Object.values(storiesById) .sort(compareUnassignedStory) .map((story) => story.id) } function normalizeTask(input: SoloTask): SoloTask { return { ...input, status: normalizeTaskStatus(input.status), } } function normalizeTaskStatus(status: string): SoloTaskStatus { if (status === 'IN_PROGRESS' || status === 'REVIEW' || status === 'DONE') return status return 'TO_DO' } function mapTasks(tasks: SoloTask[]): Record { return Object.fromEntries(tasks.map((task) => [task.id, normalizeTask(task)])) } function mapUnassignedStories(stories: SoloUnassignedStory[]): Record { return Object.fromEntries(stories.map((story) => [story.id, story])) } function newRequestId(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID() } return `${Date.now()}-${Math.random().toString(36).slice(2)}` } async function fetchJson(url: string, init?: RequestInit): Promise { const response = await fetch(url, { cache: 'no-store', ...init }) if (!response.ok) { throw new Error(`Fetch ${url} failed with ${response.status}`) } return (await response.json()) as T } function taskPatchFromEvent(event: RealtimeEvent): Partial { const status = event.status ?? event.task_status return { ...(status && { status: normalizeTaskStatus(status) }), ...((event.sort_order ?? event.task_sort_order) !== undefined && { sort_order: event.sort_order ?? event.task_sort_order, }), ...((event.title ?? event.task_title) !== undefined && { title: event.title ?? event.task_title, }), ...(event.description !== undefined && { description: event.description }), ...(event.priority !== undefined && { priority: event.priority }), ...(event.story_id !== undefined && { story_id: event.story_id }), } } function storyTitleFromEvent(event: RealtimeEvent): string | undefined { return event.title ?? event.story_title } function storyCodeFromEvent(event: RealtimeEvent): string | null | undefined { return event.code ?? event.story_code } export const useSoloWorkspaceStore = create((set, get) => ({ ...initialState, hydrateSnapshot(snapshot) { const tasksById = mapTasks(snapshot.tasks) const unassignedStoriesById = mapUnassignedStories(snapshot.unassignedStories) set((s) => ({ context: { activeProduct: snapshot.product, activeSprint: snapshot.sprint, activeUserId: snapshot.activeUserId, }, entities: { ...s.entities, tasksById, unassignedStoriesById, }, relations: { taskIdsByColumn: buildTaskRelations(tasksById), unassignedStoryIds: buildUnassignedRelations(unassignedStoriesById), }, loading: { ...s.loading, loadedProductId: snapshot.product.id, loadedSprintId: snapshot.sprint.id, loadingSprintId: null, }, tasks: tasksById, unassignedStoriesById, })) }, initTasks(tasks) { const tasksById = mapTasks(tasks) set((s) => ({ entities: { ...s.entities, tasksById }, relations: { ...s.relations, taskIdsByColumn: buildTaskRelations(tasksById), }, tasks: tasksById, })) }, hydrateUnassignedStories(stories) { const unassignedStoriesById = mapUnassignedStories(stories) set((s) => ({ entities: { ...s.entities, unassignedStoriesById }, relations: { ...s.relations, unassignedStoryIds: buildUnassignedRelations(unassignedStoriesById), }, unassignedStoriesById, })) }, removeUnassignedStory(storyId) { set((s) => { if (!s.entities.unassignedStoriesById[storyId]) return s const unassignedStoriesById = { ...s.entities.unassignedStoriesById } delete unassignedStoriesById[storyId] return { entities: { ...s.entities, unassignedStoriesById }, relations: { ...s.relations, unassignedStoryIds: buildUnassignedRelations(unassignedStoriesById), }, unassignedStoriesById, } }) }, optimisticMove(taskId, toStatus) { const prev = get().tasks[taskId]?.status ?? null if (!prev) return null const task = { ...get().tasks[taskId], status: toStatus } const tasksById = { ...get().tasks, [taskId]: task } set((s) => ({ entities: { ...s.entities, tasksById }, relations: { ...s.relations, taskIdsByColumn: buildTaskRelations(tasksById) }, tasks: tasksById, })) return prev }, rollback(taskId, prevStatus) { const existing = get().tasks[taskId] if (!existing) return const tasksById = { ...get().tasks, [taskId]: { ...existing, status: prevStatus } } set((s) => ({ entities: { ...s.entities, tasksById }, relations: { ...s.relations, taskIdsByColumn: buildTaskRelations(tasksById) }, tasks: tasksById, })) }, updatePlan(taskId, plan) { const existing = get().tasks[taskId] if (!existing) return const tasksById = { ...get().tasks, [taskId]: { ...existing, implementation_plan: plan } } set((s) => ({ entities: { ...s.entities, tasksById }, tasks: tasksById })) }, updateVerifyOnly(taskId, value) { const existing = get().tasks[taskId] if (!existing) return const tasksById = { ...get().tasks, [taskId]: { ...existing, verify_only: value } } set((s) => ({ entities: { ...s.entities, tasksById }, tasks: tasksById })) }, updateVerifyRequired(taskId, value) { const existing = get().tasks[taskId] if (!existing) return const tasksById = { ...get().tasks, [taskId]: { ...existing, verify_required: value } } set((s) => ({ entities: { ...s.entities, tasksById }, tasks: tasksById })) }, markPending(taskId) { set((s) => { if (s.pendingOps.has(taskId)) return s const pendingOps = new Set(s.pendingOps) pendingOps.add(taskId) return { pendingOps } }) }, clearPending(taskId) { set((s) => { if (!s.pendingOps.has(taskId)) return s const pendingOps = new Set(s.pendingOps) pendingOps.delete(taskId) return { pendingOps } }) }, setRealtimeStatus(status, showConnectingIndicator) { set((s) => { if (s.realtimeStatus === status && s.showConnectingIndicator === showConnectingIndicator) { return s } return { sync: { ...s.sync, realtimeStatus: status, showConnectingIndicator }, realtimeStatus: status, showConnectingIndicator, } }) }, initJobs(jobs) { const jobsByTaskId = Object.fromEntries(jobs.map((job) => [job.task_id, job])) set((s) => ({ entities: { ...s.entities, jobsByTaskId }, claudeJobsByTaskId: jobsByTaskId, })) }, handleJobEvent(event) { const { job_id, task_id } = event if (event.type === 'claude_job_enqueued') { set((s) => { const jobsByTaskId = { ...s.claudeJobsByTaskId, [task_id]: { job_id, task_id, status: 'queued' as const }, } return { entities: { ...s.entities, jobsByTaskId }, claudeJobsByTaskId: jobsByTaskId, } }) return } const { status, branch, pushed_at, pr_url, verify_result, summary, error } = event if (status === 'cancelled') { set((s) => { const jobsByTaskId = { ...s.claudeJobsByTaskId } delete jobsByTaskId[task_id] return { entities: { ...s.entities, jobsByTaskId }, claudeJobsByTaskId: jobsByTaskId, } }) return } set((s) => { const jobsByTaskId = { ...s.claudeJobsByTaskId, [task_id]: { job_id, task_id, status, branch, pushed_at, pr_url, verify_result, summary, error, }, } return { entities: { ...s.entities, jobsByTaskId }, claudeJobsByTaskId: jobsByTaskId, } }) }, 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), workerQuotaPct: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaPct, workerQuotaCheckAt: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaCheckAt, })) }, setWorkerQuota(pct, checkAt) { set({ workerQuotaPct: pct, workerQuotaCheckAt: checkAt }) }, handleRealtimeEvent(event) { set((s) => ({ sync: { ...s.sync, lastEventAt: Date.now() } })) const ctx = get().context if (ctx.activeProduct?.id && event.product_id !== ctx.activeProduct.id) return if (event.entity === 'task') { if (event.op === 'D') { const existing = get().tasks[event.id] if (!existing) return const tasksById = { ...get().tasks } delete tasksById[event.id] set((s) => ({ entities: { ...s.entities, tasksById }, relations: { ...s.relations, taskIdsByColumn: buildTaskRelations(tasksById) }, tasks: tasksById, })) return } const existing = get().tasks[event.id] if (!existing) { if ( event.assignee_id === ctx.activeUserId && event.sprint_id === ctx.activeSprint?.id ) { void get().resyncActiveScopes('unknown-event') } return } if ( event.assignee_id !== null && ctx.activeUserId && event.assignee_id !== ctx.activeUserId ) { const tasksById = { ...get().tasks } delete tasksById[event.id] set((s) => ({ entities: { ...s.entities, tasksById }, relations: { ...s.relations, taskIdsByColumn: buildTaskRelations(tasksById) }, tasks: tasksById, })) return } if (get().pendingOps.has(event.id)) return const patch = taskPatchFromEvent(event) if (Object.keys(patch).length === 0) return const tasksById = { ...get().tasks, [event.id]: { ...existing, ...patch }, } set((s) => ({ entities: { ...s.entities, tasksById }, relations: { ...s.relations, taskIdsByColumn: buildTaskRelations(tasksById) }, tasks: tasksById, })) return } if (event.op === 'D') { const tasksById = Object.fromEntries( Object.entries(get().tasks).filter(([, task]) => task.story_id !== event.id), ) const unassignedStoriesById = { ...get().entities.unassignedStoriesById } delete unassignedStoriesById[event.id] set((s) => ({ entities: { ...s.entities, tasksById, unassignedStoriesById }, relations: { taskIdsByColumn: buildTaskRelations(tasksById), unassignedStoryIds: buildUnassignedRelations(unassignedStoriesById), }, tasks: tasksById, unassignedStoriesById, })) return } const affectedIds = Object.entries(get().tasks) .filter(([, task]) => task.story_id === event.id) .map(([taskId]) => taskId) const newTitle = storyTitleFromEvent(event) const newCode = storyCodeFromEvent(event) if (affectedIds.length > 0 && (newTitle !== undefined || newCode !== undefined)) { const tasksById = { ...get().tasks } for (const taskId of affectedIds) { const task = tasksById[taskId] tasksById[taskId] = { ...task, ...(newTitle !== undefined && { story_title: newTitle }), ...(newCode !== undefined && { story_code: newCode }), } } set((s) => ({ entities: { ...s.entities, tasksById }, relations: { ...s.relations, taskIdsByColumn: buildTaskRelations(tasksById) }, tasks: tasksById, })) } if ( event.sprint_id === ctx.activeSprint?.id && (event.assignee_id === null || event.assignee_id === ctx.activeUserId) ) { void get().resyncActiveScopes('unknown-event') } }, async ensureWorkspaceLoaded(productId, sprintId, requestId) { const activeRequestId = requestId ?? newRequestId() set((s) => ({ loading: { ...s.loading, loadingSprintId: sprintId ?? s.context.activeSprint?.id ?? null, activeRequestId, }, })) try { const params = sprintId ? `?sprint_id=${encodeURIComponent(sprintId)}` : '' const snapshot = await fetchJson( `/api/products/${encodeURIComponent(productId)}/solo-workspace${params}`, ) if (get().loading.activeRequestId !== activeRequestId) return if (!snapshot) return get().hydrateSnapshot(snapshot) } finally { set((s) => ({ loading: { ...s.loading, loadingSprintId: s.loading.activeRequestId === activeRequestId ? null : s.loading.loadingSprintId, }, })) } }, async resyncActiveScopes(reason) { const ctx = get().context if (!ctx.activeProduct?.id) return set((s) => ({ sync: { ...s.sync, lastResyncAt: Date.now(), resyncReason: reason }, })) await get().ensureWorkspaceLoaded(ctx.activeProduct.id, ctx.activeSprint?.id) }, }))