refactor: migrate solo to workspace store

This commit is contained in:
Janpeter Visser 2026-05-10 07:27:43 +02:00
parent 90c68ef8de
commit 2d42e2b954
13 changed files with 1191 additions and 526 deletions

View file

@ -0,0 +1,131 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSoloStore } from '@/stores/solo-store'
import type { RealtimeEvent } from '@/stores/solo-store'
import type { SoloTask, SoloWorkspaceSnapshot } from '@/stores/solo-workspace/types'
function baseTask(id: string, overrides: Partial<SoloTask> = {}): SoloTask {
return {
id,
title: `Task ${id}`,
description: null,
implementation_plan: null,
priority: 1,
sort_order: 1,
status: 'TO_DO',
verify_only: false,
verify_required: 'ALIGNED_OR_PARTIAL',
story_id: 'story-1',
story_code: 'ST-1',
story_title: 'Story 1',
task_code: `ST-1.${id}`,
pbi_code: null,
pbi_title: null,
pbi_description: null,
...overrides,
}
}
function snapshot(tasks: SoloTask[]): SoloWorkspaceSnapshot {
return {
product: { id: 'prod-1', name: 'Product 1' },
sprint: { id: 'sprint-1', sprint_goal: 'Goal' },
activeUserId: 'user-1',
tasks,
unassignedStories: [
{ id: 'story-b', code: 'ST-2', title: 'Story B', tasks: [] },
{ id: 'story-a', code: 'ST-1', title: 'Story A', tasks: [] },
],
}
}
function taskEvent(overrides: Partial<RealtimeEvent>): RealtimeEvent {
return {
op: 'U',
entity: 'task',
id: 'task-1',
story_id: 'story-1',
product_id: 'prod-1',
sprint_id: 'sprint-1',
assignee_id: 'user-1',
...overrides,
}
}
beforeEach(() => {
useSoloStore.setState({
context: { activeProduct: null, activeSprint: null, activeUserId: null },
entities: { tasksById: {}, unassignedStoriesById: {}, jobsByTaskId: {} },
relations: {
taskIdsByColumn: { TO_DO: [], IN_PROGRESS: [], DONE: [] },
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: {},
})
vi.restoreAllMocks()
})
describe('solo workspace store', () => {
it('hydrateert genormaliseerde taken, kolomrelaties en unassigned stories', () => {
useSoloStore.getState().hydrateSnapshot(
snapshot([
baseTask('task-2', { status: 'DONE', sort_order: 2 }),
baseTask('task-1', { status: 'TO_DO', sort_order: 1 }),
baseTask('task-3', { status: 'REVIEW', sort_order: 3 }),
]),
)
const s = useSoloStore.getState()
expect(s.context.activeSprint?.id).toBe('sprint-1')
expect(s.relations.taskIdsByColumn.TO_DO).toEqual(['task-1'])
expect(s.relations.taskIdsByColumn.IN_PROGRESS).toEqual(['task-3'])
expect(s.relations.taskIdsByColumn.DONE).toEqual(['task-2'])
expect(s.relations.unassignedStoryIds).toEqual(['story-a', 'story-b'])
})
it('past realtime task updates toe en herbouwt kolomrelaties', () => {
useSoloStore.getState().hydrateSnapshot(snapshot([baseTask('task-1')]))
useSoloStore.getState().handleRealtimeEvent(
taskEvent({ status: 'DONE', sort_order: 5, title: 'Done task' }),
)
const s = useSoloStore.getState()
expect(s.tasks['task-1'].status).toBe('DONE')
expect(s.tasks['task-1'].title).toBe('Done task')
expect(s.relations.taskIdsByColumn.TO_DO).toEqual([])
expect(s.relations.taskIdsByColumn.DONE).toEqual(['task-1'])
})
it('resynct actieve scopes via de solo-workspace route', async () => {
useSoloStore.getState().hydrateSnapshot(snapshot([baseTask('task-1')]))
const next = snapshot([baseTask('task-1', { status: 'IN_PROGRESS' })])
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(next), { status: 200 }),
)
await useSoloStore.getState().resyncActiveScopes('manual')
expect(fetchSpy).toHaveBeenCalledWith(
'/api/products/prod-1/solo-workspace?sprint_id=sprint-1',
expect.objectContaining({ cache: 'no-store' }),
)
const s = useSoloStore.getState()
expect(s.tasks['task-1'].status).toBe('IN_PROGRESS')
expect(s.sync.resyncReason).toBe('manual')
})
})

View file

@ -1,14 +1,12 @@
import { notFound, redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getAccessibleProduct } from '@/lib/product-access'
import { prisma } from '@/lib/prisma'
import { resolveActiveSprint } from '@/lib/active-sprint'
import { getSprintSwitcherData } from '@/lib/sprint-switcher-data'
import { getSoloWorkspaceSnapshot } from '@/lib/solo-workspace-server'
import { SoloBoard } from '@/components/solo/solo-board'
import { SoloHydrationWrapper } from '@/components/solo/solo-hydration-wrapper'
import { NoActiveSprint } from '@/components/solo/no-active-sprint'
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
import type { SoloTask } from '@/components/solo/solo-board'
import type { UnassignedStory } from '@/components/solo/unassigned-stories-sheet'
interface Props {
params: Promise<{ id: string }>
@ -22,12 +20,10 @@ export default async function SoloProductPage({ params }: Props) {
const product = await getAccessibleProduct(id, session.userId)
if (!product) notFound()
const active = await resolveActiveSprint(id)
const sprint = active
? await prisma.sprint.findFirst({ where: { id: active.id, product_id: id } })
: null
const switcherData = await getSprintSwitcherData(id, { activeSprintId: sprint?.id ?? null })
const initialData = await getSoloWorkspaceSnapshot(id, session.userId)
const switcherData = await getSprintSwitcherData(id, {
activeSprintId: initialData?.sprint.id ?? null,
})
const switcherBar = (
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-center">
@ -40,7 +36,7 @@ export default async function SoloProductPage({ params }: Props) {
</div>
)
if (!sprint) {
if (!initialData) {
return (
<div className="flex flex-col h-full">
{switcherBar}
@ -49,94 +45,19 @@ export default async function SoloProductPage({ params }: Props) {
)
}
const [rawTasks, rawUnassigned] = await Promise.all([
prisma.task.findMany({
where: {
story: {
sprint_id: sprint.id,
assignee_id: session.userId,
},
},
include: {
story: {
select: {
id: true,
code: true,
title: true,
tasks: { select: { id: true }, orderBy: { sort_order: 'asc' } },
pbi: { select: { code: true, title: true, description: true } },
},
},
},
orderBy: [
{ story: { pbi: { priority: 'asc' } } },
{ story: { pbi: { sort_order: 'asc' } } },
{ story: { sort_order: 'asc' } },
{ priority: 'asc' },
{ sort_order: 'asc' },
],
}),
prisma.story.findMany({
where: { sprint_id: sprint.id, assignee_id: null },
select: {
id: true,
code: true,
title: true,
tasks: {
select: { id: true, title: true, description: true, priority: true, status: true },
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
},
},
orderBy: { sort_order: 'asc' },
}),
])
const tasks: SoloTask[] = rawTasks.map(t => ({
id: t.id,
title: t.title,
description: t.description,
implementation_plan: t.implementation_plan,
priority: t.priority,
sort_order: t.sort_order,
status: t.status as SoloTask['status'],
verify_only: t.verify_only,
verify_required: t.verify_required as SoloTask['verify_required'],
story_id: t.story.id,
story_code: t.story.code,
story_title: t.story.title,
task_code: t.code,
pbi_code: t.story.pbi?.code ?? null,
pbi_title: t.story.pbi?.title ?? null,
pbi_description: t.story.pbi?.description ?? null,
}))
const unassignedStories: UnassignedStory[] = rawUnassigned.map(s => ({
id: s.id,
code: s.code,
title: s.title,
tasks: s.tasks.map(t => ({
id: t.id,
title: t.title,
description: t.description,
priority: t.priority,
status: t.status,
})),
}))
return (
<div className="flex flex-col h-full">
{switcherBar}
<div className="flex-1 min-h-0">
<SoloBoard
key={sprint.id}
productId={id}
sprintGoal={sprint.sprint_goal}
tasks={tasks}
unassignedStories={unassignedStories}
isDemo={session.isDemo ?? false}
currentUserId={session.userId}
repoUrl={product.repo_url}
/>
<SoloHydrationWrapper initialData={initialData}>
<SoloBoard
key={initialData.sprint.id}
productId={id}
sprintGoal={initialData.sprint.sprint_goal}
isDemo={session.isDemo ?? false}
repoUrl={product.repo_url}
/>
</SoloHydrationWrapper>
</div>
</div>
)

View file

@ -6,13 +6,11 @@
import { notFound } from 'next/navigation'
import { getAccessibleProduct } from '@/lib/product-access'
import { prisma } from '@/lib/prisma'
import { requireSession } from '@/lib/auth-guard'
import { resolveActiveSprint } from '@/lib/active-sprint'
import { getSoloWorkspaceSnapshot } from '@/lib/solo-workspace-server'
import { SoloBoard } from '@/components/solo/solo-board'
import { SoloHydrationWrapper } from '@/components/solo/solo-hydration-wrapper'
import { NoActiveSprint } from '@/components/solo/no-active-sprint'
import type { SoloTask } from '@/components/solo/solo-board'
import type { UnassignedStory } from '@/components/solo/unassigned-stories-sheet'
interface Props {
params: Promise<{ id: string }>
@ -25,12 +23,9 @@ export default async function MobileSoloProductPage({ params }: Props) {
const product = await getAccessibleProduct(id, session.userId)
if (!product) notFound()
const active = await resolveActiveSprint(id)
const sprint = active
? await prisma.sprint.findFirst({ where: { id: active.id, product_id: id } })
: null
const initialData = await getSoloWorkspaceSnapshot(id, session.userId)
if (!sprint) {
if (!initialData) {
return (
<div className="flex flex-col h-full">
<NoActiveSprint productId={id} productName={product.name} />
@ -38,90 +33,15 @@ export default async function MobileSoloProductPage({ params }: Props) {
)
}
const [rawTasks, rawUnassigned] = await Promise.all([
prisma.task.findMany({
where: {
story: {
sprint_id: sprint.id,
assignee_id: session.userId,
},
},
include: {
story: {
select: {
id: true,
code: true,
title: true,
tasks: { select: { id: true }, orderBy: { sort_order: 'asc' } },
pbi: { select: { code: true, title: true, description: true } },
},
},
},
orderBy: [
{ story: { pbi: { priority: 'asc' } } },
{ story: { pbi: { sort_order: 'asc' } } },
{ story: { sort_order: 'asc' } },
{ priority: 'asc' },
{ sort_order: 'asc' },
],
}),
prisma.story.findMany({
where: { sprint_id: sprint.id, assignee_id: null },
select: {
id: true,
code: true,
title: true,
tasks: {
select: { id: true, title: true, description: true, priority: true, status: true },
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
},
},
orderBy: { sort_order: 'asc' },
}),
])
const tasks: SoloTask[] = rawTasks.map(t => ({
id: t.id,
title: t.title,
description: t.description,
implementation_plan: t.implementation_plan,
priority: t.priority,
sort_order: t.sort_order,
status: t.status as SoloTask['status'],
verify_only: t.verify_only,
verify_required: t.verify_required as SoloTask['verify_required'],
story_id: t.story.id,
story_code: t.story.code,
story_title: t.story.title,
task_code: t.code,
pbi_code: t.story.pbi?.code ?? null,
pbi_title: t.story.pbi?.title ?? null,
pbi_description: t.story.pbi?.description ?? null,
}))
const unassignedStories: UnassignedStory[] = rawUnassigned.map(s => ({
id: s.id,
code: s.code,
title: s.title,
tasks: s.tasks.map(t => ({
id: t.id,
title: t.title,
description: t.description,
priority: t.priority,
status: t.status,
})),
}))
return (
<SoloBoard
key={sprint.id}
productId={id}
sprintGoal={sprint.sprint_goal}
tasks={tasks}
unassignedStories={unassignedStories}
isDemo={session.isDemo ?? false}
currentUserId={session.userId}
repoUrl={product.repo_url}
/>
<SoloHydrationWrapper initialData={initialData}>
<SoloBoard
key={initialData.sprint.id}
productId={id}
sprintGoal={initialData.sprint.sprint_goal}
isDemo={session.isDemo ?? false}
repoUrl={product.repo_url}
/>
</SoloHydrationWrapper>
)
}

View file

@ -0,0 +1,25 @@
import { authenticateApiRequest } from '@/lib/api-auth'
import { getSoloWorkspaceSnapshot } from '@/lib/solo-workspace-server'
export const dynamic = 'force-dynamic'
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const auth = await authenticateApiRequest(request)
if ('error' in auth) {
return Response.json({ error: auth.error }, { status: auth.status })
}
const { id } = await params
const url = new URL(request.url)
const sprintId = url.searchParams.get('sprint_id')
const snapshot = await getSoloWorkspaceSnapshot(id, auth.userId, sprintId)
if (!snapshot) {
return Response.json({ error: 'Solo workspace niet gevonden' }, { status: 404 })
}
return Response.json(snapshot)
}

View file

@ -1,12 +1,19 @@
'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'
@ -17,34 +24,17 @@ 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, type UnassignedStory } from './unassigned-stories-sheet'
import { UnassignedStoriesSheet } from './unassigned-stories-sheet'
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'
verify_only: boolean
verify_required: 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY'
story_id: string
story_code: string | null
story_title: string
task_code: string | null
pbi_code: string | null
pbi_title: string | null
pbi_description: string | null
}
export type { SoloTask } from '@/stores/solo-workspace/types'
export interface SoloBoardProps {
productId: string
sprintGoal: string
tasks: SoloTask[]
unassignedStories: UnassignedStory[]
tasks?: SoloTask[]
unassignedStories?: SoloUnassignedStory[]
isDemo: boolean
currentUserId: string
currentUserId?: string
repoUrl?: string | null
}
@ -56,14 +46,22 @@ function getColumnStatus(status: SoloTask['status']): ColumnStatus {
}
export function SoloBoard({
productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, repoUrl,
productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, repoUrl, currentUserId,
}: SoloBoardProps) {
const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore()
const {
tasks,
hydrateSnapshot,
optimisticMove,
rollback,
markPending,
clearPending,
removeUnassignedStory,
} = useSoloStore()
const claudeJobsByTaskId = useSoloStore((s) => s.claudeJobsByTaskId)
const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [selectedTask, setSelectedTask] = useState<SoloTask | null>(null)
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null)
const selectedTask = useSoloStore(selectSoloTaskById(selectedTaskId))
const [sheetOpen, setSheetOpen] = useState(false)
const [unassignedStories, setUnassignedStories] = useState(initialUnassigned)
const [, startTransition] = useTransition()
const [batchPending, startBatchTransition] = useTransition()
const [confirmPending, startConfirmTransition] = useTransition()
@ -76,21 +74,27 @@ export function SoloBoard({
}
const [blockerDialog, setBlockerDialog] = useState<BlockerDialogState | null>(null)
const taskKey = initialTasks.map(t => t.id).join(',')
useEffect(() => {
initTasks(initialTasks)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskKey])
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 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'),
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)
@ -243,21 +247,21 @@ export function SoloBoard({
status="TO_DO"
tasks={columnTasks.TO_DO}
isDemo={isDemo}
onTaskClick={(t) => setSelectedTask(t)}
onTaskClick={(t) => setSelectedTaskId(t.id)}
/>,
<SoloColumn
key="IN_PROGRESS"
status="IN_PROGRESS"
tasks={columnTasks.IN_PROGRESS}
isDemo={isDemo}
onTaskClick={(t) => setSelectedTask(t)}
onTaskClick={(t) => setSelectedTaskId(t.id)}
/>,
<SoloColumn
key="DONE"
status="DONE"
tasks={columnTasks.DONE}
isDemo={isDemo}
onTaskClick={(t) => setSelectedTask(t)}
onTaskClick={(t) => setSelectedTaskId(t.id)}
/>,
]}
/>
@ -272,7 +276,7 @@ export function SoloBoard({
productId={productId}
isDemo={isDemo}
repoUrl={repoUrl}
onClose={() => setSelectedTask(null)}
onClose={() => setSelectedTaskId(null)}
/>
<UnassignedStoriesSheet
@ -281,7 +285,7 @@ export function SoloBoard({
isDemo={isDemo}
open={sheetOpen}
onOpenChange={setSheetOpen}
onClaim={(id) => setUnassignedStories(prev => prev.filter(s => s.id !== id))}
onClaim={removeUnassignedStory}
/>
{blockerDialog && (

View file

@ -0,0 +1,55 @@
'use client'
import { useEffect, useRef } from 'react'
import { useSoloStore } from '@/stores/solo-store'
import type { SoloWorkspaceSnapshot } from '@/stores/solo-workspace/types'
interface SoloHydrationWrapperProps {
initialData: SoloWorkspaceSnapshot
children: React.ReactNode
}
function fingerprint(data: SoloWorkspaceSnapshot): string {
const taskPart = data.tasks
.map((task) => [
task.id,
task.status,
task.sort_order,
task.title,
task.implementation_plan ?? '',
task.verify_only ? '1' : '0',
task.verify_required,
task.story_id,
task.story_title,
task.story_code ?? '',
].join(':'))
.join(',')
const unassignedPart = data.unassignedStories
.map((story) => [
story.id,
story.title,
story.code ?? '',
story.tasks.map((task) => `${task.id}:${task.status}:${task.title}`).join('|'),
].join(':'))
.join(',')
return [
data.product.id,
data.sprint.id,
data.activeUserId,
taskPart,
unassignedPart,
].join('||')
}
export function SoloHydrationWrapper({ initialData, children }: SoloHydrationWrapperProps) {
const lastFingerprint = useRef<string>('')
useEffect(() => {
const fp = fingerprint(initialData)
if (fp === lastFingerprint.current) return
lastFingerprint.current = fp
useSoloStore.getState().hydrateSnapshot(initialData)
}, [initialData])
return <>{children}</>
}

View file

@ -7,11 +7,9 @@
// productId niet null is; sluit de stream als productId null wordt.
// - Reconnect met exponential backoff (1s → 30s, reset bij ready).
// - PBI-74: stream blijft open op tab hidden (geen close meer). Bij
// hidden→visible en bij window 'online' triggeren we router.refresh()
// zodat gemiste events alsnog binnenkomen via een verse server-render
// (re-fetcht initialTasks → initTasks reset solo-store). Postgres NOTIFY
// heeft geen replay, dus zonder deze resync zouden hidden-tab events
// permanent verloren zijn — zelfde fix als Story 5 voor backlog-realtime.
// hidden→visible en bij window 'online' triggeren we een directe
// workspace-store resync. Postgres NOTIFY heeft geen replay, dus zonder deze
// resync zouden hidden-tab events permanent verloren zijn.
// - Cleanup op unmount.
// - Connection-status (status, showConnectingIndicator) wordt naar de
// solo-store geschreven; UI-componenten lezen daar uit.
@ -24,7 +22,6 @@
import { useEffect, useRef } from 'react'
import { flushSync } from 'react-dom'
import { useRouter } from 'next/navigation'
import { useSoloStore } from '@/stores/solo-store'
import type { ClaudeJobEvent, JobState, RealtimeEvent, RealtimeStatus } from '@/stores/solo-store'
@ -33,7 +30,6 @@ const BACKOFF_MAX_MS = 30_000
const CONNECTING_INDICATOR_DELAY_MS = 4_000
export function useSoloRealtime(productId: string | null) {
const router = useRouter()
const sourceRef = useRef<EventSource | null>(null)
const backoffRef = useRef<number>(BACKOFF_START_MS)
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@ -97,10 +93,9 @@ export function useSoloRealtime(productId: string | null) {
backoffRef.current = BACKOFF_START_MS
scheduleIndicator('open')
readyCountRef.current += 1
// PBI-74: latere ready = post-reconnect → resync via router.refresh()
// zodat gemiste tasks-state via re-render initial-prop binnenkomt.
// PBI-74: latere ready = post-reconnect → directe workspace-resync.
if (readyCountRef.current > 1) {
router.refresh()
void useSoloStore.getState().resyncActiveScopes('reconnect')
}
})
@ -189,19 +184,19 @@ export function useSoloRealtime(productId: string | null) {
// PBI-74: stream blijft open op hidden. Reconnect alleen als de stream
// door netwerkfout/server-close weg is en de tab visible is. Bij iedere
// visible-overgang triggeren we router.refresh() — gemiste events tijdens
// throttling/freeze worden via een verse server-render alsnog opgepakt.
// visible-overgang triggeren we een store-resync — gemiste events tijdens
// throttling/freeze worden via de solo-workspace route alsnog opgepakt.
const onVisibility = () => {
if (document.visibilityState !== 'visible') return
if (sourceRef.current === null) {
backoffRef.current = BACKOFF_START_MS
connect()
}
router.refresh()
void useSoloStore.getState().resyncActiveScopes('visible')
}
const onOnline = () => {
router.refresh()
void useSoloStore.getState().resyncActiveScopes('reconnect')
}
connect()
@ -215,5 +210,5 @@ export function useSoloRealtime(productId: string | null) {
close()
readyCountRef.current = 0
}
}, [productId, router])
}, [productId])
}

View file

@ -0,0 +1,107 @@
import 'server-only'
import { prisma } from '@/lib/prisma'
import { getAccessibleProduct } from '@/lib/product-access'
import { resolveActiveSprint } from '@/lib/active-sprint'
import type {
SoloTask,
SoloUnassignedStory,
SoloWorkspaceSnapshot,
} from '@/stores/solo-workspace/types'
export async function getSoloWorkspaceSnapshot(
productId: string,
userId: string,
sprintId?: string | null,
): Promise<SoloWorkspaceSnapshot | null> {
const product = await getAccessibleProduct(productId, userId)
if (!product) return null
const active = sprintId ? { id: sprintId } : await resolveActiveSprint(productId)
const sprint = active
? await prisma.sprint.findFirst({ where: { id: active.id, product_id: productId } })
: null
if (!sprint) return null
const [rawTasks, rawUnassigned] = await Promise.all([
prisma.task.findMany({
where: {
story: {
sprint_id: sprint.id,
assignee_id: userId,
},
},
include: {
story: {
select: {
id: true,
code: true,
title: true,
tasks: { select: { id: true }, orderBy: { sort_order: 'asc' } },
pbi: { select: { code: true, title: true, description: true } },
},
},
},
orderBy: [
{ story: { pbi: { priority: 'asc' } } },
{ story: { pbi: { sort_order: 'asc' } } },
{ story: { sort_order: 'asc' } },
{ priority: 'asc' },
{ sort_order: 'asc' },
],
}),
prisma.story.findMany({
where: { sprint_id: sprint.id, assignee_id: null },
select: {
id: true,
code: true,
title: true,
tasks: {
select: { id: true, title: true, description: true, priority: true, status: true },
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
},
},
orderBy: { sort_order: 'asc' },
}),
])
const tasks: SoloTask[] = rawTasks.map((task) => ({
id: task.id,
title: task.title,
description: task.description,
implementation_plan: task.implementation_plan,
priority: task.priority,
sort_order: task.sort_order,
status: task.status as SoloTask['status'],
verify_only: task.verify_only,
verify_required: task.verify_required as SoloTask['verify_required'],
story_id: task.story.id,
story_code: task.story.code,
story_title: task.story.title,
task_code: task.code,
pbi_code: task.story.pbi?.code ?? null,
pbi_title: task.story.pbi?.title ?? null,
pbi_description: task.story.pbi?.description ?? null,
}))
const unassignedStories: SoloUnassignedStory[] = rawUnassigned.map((story) => ({
id: story.id,
code: story.code,
title: story.title,
tasks: story.tasks.map((task) => ({
id: task.id,
title: task.title,
description: task.description,
priority: task.priority,
status: task.status,
})),
}))
return {
product: { id: product.id, name: product.name, repo_url: product.repo_url },
sprint: { id: sprint.id, sprint_goal: sprint.sprint_goal },
activeUserId: userId,
tasks,
unassignedStories,
}
}

View file

@ -1,283 +1,8 @@
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<string, SoloTask>
/** 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<string>
/** 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<string, JobState>
connectedWorkers: number
// M13: laatste quota-rapport van een actieve worker. null = geen
// worker actief of nog geen heartbeat met quota ontvangen.
workerQuotaPct: number | null
workerQuotaCheckAt: string | null
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
setWorkerQuota: (pct: number, checkAt: string) => void
handleRealtimeEvent: (event: RealtimeEvent) => void
}
export const useSoloStore = create<SoloStore>((set, get) => ({
tasks: {},
pendingOps: new Set<string>(),
realtimeStatus: 'connecting',
showConnectingIndicator: false,
claudeJobsByTaskId: {},
connectedWorkers: 0,
workerQuotaPct: null,
workerQuotaCheckAt: null,
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),
// Reset quota-state als alle workers weg zijn — pct van een vertrokken
// worker is niet meer actueel.
workerQuotaPct: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaPct,
workerQuotaCheckAt: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaCheckAt,
})),
setWorkerQuota: (pct, checkAt) => set({ workerQuotaPct: pct, workerQuotaCheckAt: checkAt }),
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<SoloTask> = {}
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<string, SoloTask> = {}
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 }
})
}
},
}))
export { useSoloWorkspaceStore as useSoloStore } from '@/stores/solo-workspace/store'
export type {
ClaudeJobEvent,
JobState,
RealtimeEvent,
RealtimeStatus,
VerifyResultApi,
} from '@/stores/solo-workspace/types'

View file

@ -0,0 +1,39 @@
import type { SoloWorkspaceStore } from './store'
import type { SoloColumnStatus, SoloTask, SoloUnassignedStory } from './types'
const EMPTY_TASKS: SoloTask[] = []
const EMPTY_STORIES: SoloUnassignedStory[] = []
export function selectSoloTasksForColumn(
status: SoloColumnStatus,
): (s: SoloWorkspaceStore) => SoloTask[] {
return (s) => {
const ids = s.relations.taskIdsByColumn[status]
if (!ids || ids.length === 0) return EMPTY_TASKS
const out: SoloTask[] = []
for (const id of ids) {
const task = s.entities.tasksById[id]
if (task) out.push(task)
}
return out.length === 0 ? EMPTY_TASKS : out
}
}
export function selectSoloUnassignedStories(s: SoloWorkspaceStore): SoloUnassignedStory[] {
if (s.relations.unassignedStoryIds.length === 0) return EMPTY_STORIES
const out: SoloUnassignedStory[] = []
for (const id of s.relations.unassignedStoryIds) {
const story = s.entities.unassignedStoriesById[id]
if (story) out.push(story)
}
return out.length === 0 ? EMPTY_STORIES : out
}
export function selectSoloTaskById(
taskId: string | null,
): (s: SoloWorkspaceStore) => SoloTask | null {
return (s) => {
if (!taskId) return null
return s.entities.tasksById[taskId] ?? null
}
}

View file

@ -0,0 +1,619 @@
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)
},
}))

View file

@ -0,0 +1,123 @@
import type { ClaudeJobStatusApi } from '@/lib/job-status'
export type SoloTaskStatus = 'TO_DO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE'
export type SoloColumnStatus = 'TO_DO' | 'IN_PROGRESS' | 'DONE'
export type SoloVerifyRequired = 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY'
export type VerifyResultApi = 'aligned' | 'partial' | 'empty' | 'divergent'
export interface SoloTask {
id: string
title: string
description: string | null
implementation_plan: string | null
priority: number
sort_order: number
status: SoloTaskStatus
verify_only: boolean
verify_required: SoloVerifyRequired
story_id: string
story_code: string | null
story_title: string
task_code: string | null
pbi_code: string | null
pbi_title: string | null
pbi_description: string | null
}
export interface SoloUnassignedStoryTask {
id: string
title: string
description: string | null
priority: number
status: string
}
export interface SoloUnassignedStory {
id: string
code: string | null
title: string
tasks: SoloUnassignedStoryTask[]
}
export interface SoloWorkspaceProduct {
id: string
name: string
repo_url?: string | null
}
export interface SoloWorkspaceSprint {
id: string
sprint_goal: string
}
export interface SoloWorkspaceSnapshot {
product: SoloWorkspaceProduct
sprint: SoloWorkspaceSprint
activeUserId: string
tasks: SoloTask[]
unassignedStories: SoloUnassignedStory[]
}
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
}
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
status?: SoloTaskStatus | 'OPEN' | 'IN_SPRINT' | 'DONE'
sort_order?: number
title?: string
code?: string | null
description?: string | null
priority?: number
task_status?: SoloTaskStatus
task_sort_order?: number
task_title?: string
story_status?: 'OPEN' | 'IN_SPRINT' | 'DONE'
story_sort_order?: number
story_title?: string
story_code?: string | null
changed_fields?: string[]
}
export type RealtimeStatus = 'connecting' | 'open' | 'disconnected'
export type ResyncReason = 'visible' | 'reconnect' | 'manual' | 'unknown-event'

View file

@ -1,4 +1,4 @@
import { defineConfig } from 'vitest/config'
import { configDefaults, defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
@ -6,6 +6,7 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
setupFiles: ['tests/setup.ts'],
exclude: [...configDefaults.exclude, '**/.claude/**'],
},
resolve: {
alias: {