Scrum4Me/stores/solo-workspace/store.ts
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

619 lines
18 KiB
TypeScript

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<string, SoloTask>
unassignedStoriesById: Record<string, SoloUnassignedStory>
jobsByTaskId: Record<string, JobState>
}
interface RelationsSlice {
taskIdsByColumn: Record<SoloColumnStatus, string[]>
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<string>
tasks: Record<string, SoloTask>
unassignedStoriesById: Record<string, SoloUnassignedStory>
claudeJobsByTaskId: Record<string, JobState>
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<void>
resyncActiveScopes(reason: ResyncReason): Promise<void>
}
export type SoloWorkspaceStore = State & Actions
const EMPTY_COLUMNS: Record<SoloColumnStatus, string[]> = {
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<string>(),
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<string, SoloTask>): Record<SoloColumnStatus, string[]> {
const next: Record<SoloColumnStatus, string[]> = {
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, SoloUnassignedStory>): 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<string, SoloTask> {
return Object.fromEntries(tasks.map((task) => [task.id, normalizeTask(task)]))
}
function mapUnassignedStories(stories: SoloUnassignedStory[]): Record<string, SoloUnassignedStory> {
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<T>(url: string, init?: RequestInit): Promise<T> {
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<SoloTask> {
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<SoloWorkspaceStore>((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<SoloWorkspaceSnapshot | null>(
`/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)
},
}))