From 2d42e2b95418181249ee80d5ed88ba0080315946 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Sun, 10 May 2026 07:27:43 +0200 Subject: [PATCH] refactor: migrate solo to workspace store --- __tests__/stores/solo-workspace/store.test.ts | 131 ++++ app/(app)/products/[id]/solo/page.tsx | 111 +--- app/(mobile)/m/products/[id]/solo/page.tsx | 106 +-- app/api/products/[id]/solo-workspace/route.ts | 25 + components/solo/solo-board.tsx | 82 +-- components/solo/solo-hydration-wrapper.tsx | 55 ++ lib/realtime/use-solo-realtime.ts | 25 +- lib/solo-workspace-server.ts | 107 +++ stores/solo-store.ts | 291 +------- stores/solo-workspace/selectors.ts | 39 ++ stores/solo-workspace/store.ts | 619 ++++++++++++++++++ stores/solo-workspace/types.ts | 123 ++++ vitest.config.ts | 3 +- 13 files changed, 1191 insertions(+), 526 deletions(-) create mode 100644 __tests__/stores/solo-workspace/store.test.ts create mode 100644 app/api/products/[id]/solo-workspace/route.ts create mode 100644 components/solo/solo-hydration-wrapper.tsx create mode 100644 lib/solo-workspace-server.ts create mode 100644 stores/solo-workspace/selectors.ts create mode 100644 stores/solo-workspace/store.ts create mode 100644 stores/solo-workspace/types.ts diff --git a/__tests__/stores/solo-workspace/store.test.ts b/__tests__/stores/solo-workspace/store.test.ts new file mode 100644 index 0000000..b31000d --- /dev/null +++ b/__tests__/stores/solo-workspace/store.test.ts @@ -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 { + 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 { + 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') + }) +}) diff --git a/app/(app)/products/[id]/solo/page.tsx b/app/(app)/products/[id]/solo/page.tsx index 83a1720..b19ec4a 100644 --- a/app/(app)/products/[id]/solo/page.tsx +++ b/app/(app)/products/[id]/solo/page.tsx @@ -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 = (
@@ -40,7 +36,7 @@ export default async function SoloProductPage({ params }: Props) {
) - if (!sprint) { + if (!initialData) { return (
{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 (
{switcherBar}
- + + +
) diff --git a/app/(mobile)/m/products/[id]/solo/page.tsx b/app/(mobile)/m/products/[id]/solo/page.tsx index d571870..c86c2cf 100644 --- a/app/(mobile)/m/products/[id]/solo/page.tsx +++ b/app/(mobile)/m/products/[id]/solo/page.tsx @@ -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 (
@@ -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 ( - + + + ) } diff --git a/app/api/products/[id]/solo-workspace/route.ts b/app/api/products/[id]/solo-workspace/route.ts new file mode 100644 index 0000000..36d438e --- /dev/null +++ b/app/api/products/[id]/solo-workspace/route.ts @@ -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) +} diff --git a/components/solo/solo-board.tsx b/components/solo/solo-board.tsx index 64b1a3b..c7a554e 100644 --- a/components/solo/solo-board.tsx +++ b/components/solo/solo-board.tsx @@ -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(null) - const [selectedTask, setSelectedTask] = useState(null) + const [selectedTaskId, setSelectedTaskId] = useState(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(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 = { - 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)} />, setSelectedTask(t)} + onTaskClick={(t) => setSelectedTaskId(t.id)} />, 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)} /> setUnassignedStories(prev => prev.filter(s => s.id !== id))} + onClaim={removeUnassignedStory} /> {blockerDialog && ( diff --git a/components/solo/solo-hydration-wrapper.tsx b/components/solo/solo-hydration-wrapper.tsx new file mode 100644 index 0000000..a260060 --- /dev/null +++ b/components/solo/solo-hydration-wrapper.tsx @@ -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('') + + useEffect(() => { + const fp = fingerprint(initialData) + if (fp === lastFingerprint.current) return + lastFingerprint.current = fp + useSoloStore.getState().hydrateSnapshot(initialData) + }, [initialData]) + + return <>{children} +} diff --git a/lib/realtime/use-solo-realtime.ts b/lib/realtime/use-solo-realtime.ts index 50a837b..209c972 100644 --- a/lib/realtime/use-solo-realtime.ts +++ b/lib/realtime/use-solo-realtime.ts @@ -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(null) const backoffRef = useRef(BACKOFF_START_MS) const reconnectTimerRef = useRef | 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]) } diff --git a/lib/solo-workspace-server.ts b/lib/solo-workspace-server.ts new file mode 100644 index 0000000..972c546 --- /dev/null +++ b/lib/solo-workspace-server.ts @@ -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 { + 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, + } +} diff --git a/stores/solo-store.ts b/stores/solo-store.ts index 5fa6e7c..d682504 100644 --- a/stores/solo-store.ts +++ b/stores/solo-store.ts @@ -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 - /** 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 - - // 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((set, get) => ({ - tasks: {}, - pendingOps: new Set(), - 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 = {} - 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 } - }) - } - }, -})) +export { useSoloWorkspaceStore as useSoloStore } from '@/stores/solo-workspace/store' +export type { + ClaudeJobEvent, + JobState, + RealtimeEvent, + RealtimeStatus, + VerifyResultApi, +} from '@/stores/solo-workspace/types' diff --git a/stores/solo-workspace/selectors.ts b/stores/solo-workspace/selectors.ts new file mode 100644 index 0000000..5645a08 --- /dev/null +++ b/stores/solo-workspace/selectors.ts @@ -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 + } +} diff --git a/stores/solo-workspace/store.ts b/stores/solo-workspace/store.ts new file mode 100644 index 0000000..1889b8a --- /dev/null +++ b/stores/solo-workspace/store.ts @@ -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 + 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) + }, +})) diff --git a/stores/solo-workspace/types.ts b/stores/solo-workspace/types.ts new file mode 100644 index 0000000..e8a14f4 --- /dev/null +++ b/stores/solo-workspace/types.ts @@ -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' diff --git a/vitest.config.ts b/vitest.config.ts index 8ea8b90..5e1c8d8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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: {