From 3b5cee823c034164cad47ab1041d452741a3c715 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Sun, 10 May 2026 07:34:58 +0200 Subject: [PATCH] Load/render workspace alignment (#182) * docs: plan load render workspace alignment * fix: normalize workspace status hydration * fix: avoid duplicate backlog hydration load * refactor: use sprint store active story * refactor: migrate solo to workspace store * chore: stabilize verification ignores --- .../stores/product-workspace/store.test.ts | 55 +- __tests__/stores/solo-workspace/store.test.ts | 131 ++++ .../stores/sprint-workspace/store.test.ts | 19 +- app/(app)/products/[id]/page.tsx | 1 + app/(app)/products/[id]/solo/page.tsx | 111 +--- app/(mobile)/m/products/[id]/page.tsx | 1 + app/(mobile)/m/products/[id]/solo/page.tsx | 106 +-- app/api/products/[id]/solo-workspace/route.ts | 25 + components/shared/set-current-product.tsx | 6 +- components/solo/solo-board.tsx | 82 +-- components/solo/solo-hydration-wrapper.tsx | 55 ++ components/sprint/sprint-board-client.tsx | 9 +- components/sprint/task-list.tsx | 10 +- docs/INDEX.md | 2 + ...load-render-improvement-plan-2026-05-10.md | 201 ++++++ ...render-implementation-review-2026-05-10.md | 112 ++++ eslint.config.mjs | 1 + lib/insights/agent-throughput.ts | 2 - lib/realtime/use-solo-realtime.ts | 25 +- lib/solo-workspace-server.ts | 107 +++ stores/product-workspace/store.ts | 114 +++- stores/solo-store.ts | 291 +------- stores/solo-workspace/selectors.ts | 39 ++ stores/solo-workspace/store.ts | 619 ++++++++++++++++++ stores/solo-workspace/types.ts | 123 ++++ stores/sprint-workspace/store.ts | 84 ++- stores/workspace-status-adapter.ts | 88 +++ vitest.config.ts | 3 +- 28 files changed, 1845 insertions(+), 577 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 docs/plans/load-render-improvement-plan-2026-05-10.md create mode 100644 docs/recommendations/load-render-implementation-review-2026-05-10.md 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 create mode 100644 stores/workspace-status-adapter.ts diff --git a/__tests__/stores/product-workspace/store.test.ts b/__tests__/stores/product-workspace/store.test.ts index 9f3cea7..bfa6cfd 100644 --- a/__tests__/stores/product-workspace/store.test.ts +++ b/__tests__/stores/product-workspace/store.test.ts @@ -90,7 +90,7 @@ function makeStory(overrides: Partial & { id: string; pbi_id: stri acceptance_criteria: overrides.acceptance_criteria ?? null, priority: overrides.priority ?? 2, sort_order: overrides.sort_order ?? 1, - status: overrides.status ?? 'open', + status: overrides.status ?? 'OPEN', pbi_id: overrides.pbi_id, sprint_id: overrides.sprint_id ?? null, created_at: overrides.created_at ?? new Date('2026-01-01'), @@ -104,7 +104,7 @@ function makeTask(overrides: Partial & { id: string; story_id: stri description: overrides.description ?? null, priority: overrides.priority ?? 2, sort_order: overrides.sort_order ?? 1, - status: overrides.status ?? 'todo', + status: overrides.status ?? 'TO_DO', story_id: overrides.story_id, created_at: overrides.created_at ?? new Date('2026-01-01'), } @@ -168,6 +168,27 @@ describe('hydrateSnapshot', () => { expect(s.loading.loadedProductId).toBe('prod-1') }) + it('normaliseert API-statussen naar het interne store-contract', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + [makePbi({ id: 'pbi-1', status: 'READY' as BacklogPbi['status'] })], + { + 'pbi-1': [ + makeStory({ id: 'st-1', pbi_id: 'pbi-1', status: 'in_sprint' }), + ], + }, + { + 'st-1': [makeTask({ id: 'tk-1', story_id: 'st-1', status: 'todo' })], + }, + ), + ) + + const s = useProductWorkspaceStore.getState() + expect(s.entities.pbisById['pbi-1'].status).toBe('ready') + expect(s.entities.storiesById['st-1'].status).toBe('IN_SPRINT') + expect(s.entities.tasksById['tk-1'].status).toBe('TO_DO') + }) + it('reset bestaande entities en relations bij her-hydratie', () => { useProductWorkspaceStore.getState().hydrateSnapshot( snapshotWith([makePbi({ id: 'old-pbi' })]), @@ -236,6 +257,35 @@ describe('selection cascade', () => { expect(s.relations.taskIdsByStory).toEqual({}) expect(s.loading.loadedProductId).toBeNull() }) + + it('setActiveProduct kan alleen context zetten zonder full backlog load', () => { + useProductWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + [makePbi({ id: 'p-1' })], + { 'p-1': [makeStory({ id: 's-1', pbi_id: 'p-1' })] }, + { 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] }, + { id: 'prod-1', name: 'Product 1' }, + ), + ) + useProductWorkspaceStore.setState((s) => { + s.context.activePbiId = 'p-1' + s.context.activeStoryId = 's-1' + }) + const fetchSpy = vi.spyOn(globalThis, 'fetch') + + useProductWorkspaceStore + .getState() + .setActiveProduct( + { id: 'prod-1', name: 'Product 1' }, + { load: false, preserveSelection: true }, + ) + + const s = useProductWorkspaceStore.getState() + expect(fetchSpy).not.toHaveBeenCalled() + expect(s.context.activePbiId).toBe('p-1') + expect(s.context.activeStoryId).toBe('s-1') + expect(s.entities.pbisById['p-1']).toBeDefined() + }) }) // ───────────────────────────────────────────────────────────────────────── @@ -624,6 +674,7 @@ describe('ensureTaskLoaded — zet detail-flag', () => { await useProductWorkspaceStore.getState().ensureTaskLoaded('t-1') const task = useProductWorkspaceStore.getState().entities.tasksById['t-1'] as TaskDetail expect(task._detail).toBe(true) + expect(task.status).toBe('TO_DO') expect(task.implementation_plan).toBe('detailed plan here') expect(useProductWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true) }) 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/__tests__/stores/sprint-workspace/store.test.ts b/__tests__/stores/sprint-workspace/store.test.ts index baf16f2..6e7757a 100644 --- a/__tests__/stores/sprint-workspace/store.test.ts +++ b/__tests__/stores/sprint-workspace/store.test.ts @@ -96,7 +96,7 @@ function makeStory( acceptance_criteria: overrides.acceptance_criteria ?? null, priority: overrides.priority ?? 2, sort_order: overrides.sort_order ?? 1, - status: overrides.status ?? 'open', + status: overrides.status ?? 'OPEN', pbi_id: overrides.pbi_id, sprint_id: overrides.sprint_id ?? null, created_at: overrides.created_at ?? new Date('2026-01-01'), @@ -113,7 +113,7 @@ function makeTask( description: overrides.description ?? null, priority: overrides.priority ?? 2, sort_order: overrides.sort_order ?? 1, - status: overrides.status ?? 'todo', + status: overrides.status ?? 'TO_DO', story_id: overrides.story_id, sprint_id: overrides.sprint_id ?? null, created_at: overrides.created_at ?? new Date('2026-01-01'), @@ -174,6 +174,20 @@ describe('hydrateSnapshot', () => { expect(s.context.activeProduct).toEqual({ id: 'prod-1', name: 'Product 1' }) expect(s.loading.loadedSprintIds['sp-1']).toBe(true) }) + + it('normaliseert API-statussen naar het interne store-contract', () => { + useSprintWorkspaceStore.getState().hydrateSnapshot( + snapshotWith( + makeSprint({ id: 'sp-1', product_id: 'prod-1' }), + [makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1', status: 'in_sprint' })], + { 's-1': [makeTask({ id: 't-1', story_id: 's-1', status: 'todo' })] }, + ), + ) + + const s = useSprintWorkspaceStore.getState() + expect(s.entities.storiesById['s-1'].status).toBe('IN_SPRINT') + expect(s.entities.tasksById['t-1'].status).toBe('TO_DO') + }) }) describe('hydrateProductSprints', () => { @@ -692,6 +706,7 @@ describe('ensureTaskLoaded — zet detail-flag', () => { 't-1' ] as SprintWorkspaceTaskDetail expect(task._detail).toBe(true) + expect(task.status).toBe('TO_DO') expect(task.implementation_plan).toBe('detailed plan here') expect(useSprintWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true) }) diff --git a/app/(app)/products/[id]/page.tsx b/app/(app)/products/[id]/page.tsx index 6acf005..bf38aa4 100644 --- a/app/(app)/products/[id]/page.tsx +++ b/app/(app)/products/[id]/page.tsx @@ -151,6 +151,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
({ id: p.id, code: p.code, title: p.title, priority: p.priority, sort_order: p.sort_order, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), storiesByPbi, 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]/page.tsx b/app/(mobile)/m/products/[id]/page.tsx index 7d33f06..479db27 100644 --- a/app/(mobile)/m/products/[id]/page.tsx +++ b/app/(mobile)/m/products/[id]/page.tsx @@ -91,6 +91,7 @@ export default async function MobileProductBacklogPage({ params, searchParams }:
({ id: p.id, code: p.code, title: p.title, priority: p.priority, sort_order: p.sort_order, description: p.description, created_at: p.created_at, status: pbiStatusToApi(p.status) })), storiesByPbi, 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/shared/set-current-product.tsx b/components/shared/set-current-product.tsx index 63aa151..94d71aa 100644 --- a/components/shared/set-current-product.tsx +++ b/components/shared/set-current-product.tsx @@ -8,9 +8,11 @@ import { debugProps } from '@/lib/debug' // De voorganger (stores/product-store.ts) wordt in Story 8 (T-876) verwijderd. export function SetCurrentProduct({ id, name }: { id: string; name: string }) { useEffect(() => { - useProductWorkspaceStore.getState().setActiveProduct({ id, name }) + useProductWorkspaceStore + .getState() + .setActiveProduct({ id, name }, { load: false, preserveSelection: true }) return () => { - useProductWorkspaceStore.getState().setActiveProduct(null) + useProductWorkspaceStore.getState().setActiveProduct(null, { load: false }) } }, [id, name]) 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/components/sprint/sprint-board-client.tsx b/components/sprint/sprint-board-client.tsx index bc40509..2d0e47a 100644 --- a/components/sprint/sprint-board-client.tsx +++ b/components/sprint/sprint-board-client.tsx @@ -62,8 +62,8 @@ export function SprintBoardClient({ const sprintStories = useSprintWorkspaceStore( useShallow((s) => selectStoriesForActiveSprint(s) as SprintStory[]), ) + const selectedStoryId = useSprintWorkspaceStore((s) => s.context.activeStoryId) const sprintStoryIds = new Set(sprintStories.map(s => s.id)) - const [selectedStoryId, setSelectedStoryId] = useState(null) const [activeDragStory, setActiveDragStory] = useState(null) const [, startTransition] = useTransition() @@ -157,7 +157,9 @@ export function SprintBoardClient({ if (story) story.sprint_id = null }) - if (selectedStoryId === storyId) setSelectedStoryId(null) + if (selectedStoryId === storyId) { + useSprintWorkspaceStore.getState().setActiveStory(null) + } startTransition(async () => { const result = await removeStoryFromSprintAction(storyId) @@ -240,7 +242,7 @@ export function SprintBoardClient({ sprintId={sprintId} isDemo={isDemo} onRemove={handleRemove} - onSelect={setSelectedStoryId} + onSelect={(storyId) => useSprintWorkspaceStore.getState().setActiveStory(storyId)} selectedStoryId={selectedStoryId} currentUserId={currentUserId} productId={productId} @@ -250,7 +252,6 @@ export function SprintBoardClient({ selectedStoryId ? ( s.context.activeStoryId) const orderedTasks = useSprintWorkspaceStore( - useShallow((s) => selectTasksForStory(s, storyId)), + useShallow(selectTasksForActiveStory), ) const [activeDragId, setActiveDragId] = useState(null) const [, startTransition] = useTransition() @@ -179,6 +179,7 @@ export function TaskList({ storyId, sprintId: _sprintId, productId: _productId, function handleDragEnd(event: DragEndEvent) { const { active, over } = event + if (!storyId) return if (!over || active.id === over.id) return const store = useSprintWorkspaceStore.getState() const prevOrder = [...(store.relations.taskIdsByStory[storyId] ?? [])] @@ -217,6 +218,7 @@ export function TaskList({ storyId, sprintId: _sprintId, productId: _productId, } function openCreateDialog() { + if (!storyId) return router.push(`${pathname}?newTask=1&storyId=${storyId}`) } diff --git a/docs/INDEX.md b/docs/INDEX.md index 17e34ae..3848845 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -46,6 +46,7 @@ Auto-generated on 2026-05-10 from front-matter and headings. | [Landing v2 — lokaal & veilig + architectuurdiagram](./plans/landing-local-first.md) | active | 2026-05-03 | | [Landing v3 — van idee tot pull request](./plans/landing-v3-idea-flow.md) | active | 2026-05-04 | | [Scrum4Me-Research — Zustand rearchitecture (reset + execute)](./plans/lees-de-readme-md-validated-book.md) | — | — | +| [Verbeterplan load/render Product Backlog, Sprint en Solo](./plans/load-render-improvement-plan-2026-05-10.md) | draft | 2026-05-10 | | [Advies — Zelf een Git-platform hosten naast of in plaats van GitHub](./plans/Local github setup.md) | — | — | | [M10 — Password-loze inlog via QR-pairing](./plans/M10-qr-pairing-login.md) | active | 2026-05-03 | | [M11 — Claude vraagt, gebruiker antwoordt](./plans/M11-claude-questions.md) | active | 2026-05-03 | @@ -132,6 +133,7 @@ Auto-generated on 2026-05-10 from front-matter and headings. | [Realtime smoke-checklist — PBI / Story / Task](./realtime-smoke.md) | `realtime-smoke.md` | active | 2026-05-03 | | [Caveman plan — Beelink naar Ubuntu Scrum4Me server](./recommendations/beelink-ubuntu-scrum4me-server-caveman-plan.md) | `recommendations/beelink-ubuntu-scrum4me-server-caveman-plan.md` | draft | 2026-05-09 | | [Aanbeveling — Claude VM jobflow en gitstrategie](./recommendations/claude-vm-job-flow-git-strategy.md) | `recommendations/claude-vm-job-flow-git-strategy.md` | draft | 2026-05-09 | +| [Load/render implementatie review](./recommendations/load-render-implementation-review-2026-05-10.md) | `recommendations/load-render-implementation-review-2026-05-10.md` | review | 2026-05-10 | | [Agent-flow: open issues & decision log](./runbooks/agent-flow-pitfalls.md) | `runbooks/agent-flow-pitfalls.md` | active | 2026-05-03 | | [Auto-PR flow: van story-DONE naar gemergde PR](./runbooks/auto-pr-flow.md) | `runbooks/auto-pr-flow.md` | active | 2026-05-06 | | [Branch, PR & Commit Strategy](./runbooks/branch-and-commit.md) | `runbooks/branch-and-commit.md` | active | 2026-05-03 | diff --git a/docs/plans/load-render-improvement-plan-2026-05-10.md b/docs/plans/load-render-improvement-plan-2026-05-10.md new file mode 100644 index 0000000..a47c665 --- /dev/null +++ b/docs/plans/load-render-improvement-plan-2026-05-10.md @@ -0,0 +1,201 @@ +--- +title: "Verbeterplan load/render Product Backlog, Sprint en Solo" +date: 2026-05-10 +status: draft +scope: ["Product Backlog", "Sprint", "Solo", "workspace stores", "realtime resync"] +source_review: "../recommendations/load-render-implementation-review-2026-05-10.md" +chosen_solo_option: "5B - Solo workspace-store migratie" +--- + +# Verbeterplan load/render Product Backlog, Sprint en Solo + +## Doel + +Maak de load/render-flow van Product Backlog, Sprint en Solo voorspelbaar, gelijkvormig en goedkoper: + +- geen dubbele initial loads; +- een expliciet status-contract tussen server, API, stores en UI; +- consistente hydration/resync na reconnect, tab-visible en refresh; +- minder stale render state in Solo; +- duidelijke store-eigenaarschap per scherm. + +## Uitgangspunten + +- De route/API-boundary mag lowercase API-statussen blijven gebruiken. +- De interne Product/Sprint workspace UI verwacht nu story/task statussen als DB `UPPER_SNAKE`. +- Product Backlog en Sprint zijn de referentie voor het gewenste patroon: server snapshot, client hydration wrapper, workspace-store, SSE, directe store-resync. +- Solo hoeft niet in dezelfde grote store te worden gemigreerd in de eerste stap, maar zijn refresh-hydration moet wel correct worden. + +## Fase 1 - Status-contract vastleggen en afdwingen + +### Stap 1.1 - Leg het interne contract vast + +Besluit en documenteer: + +- PBI-status in Product Backlog blijft API-lowercase zolang `PBI_STATUS_LABELS` en `PBI_STATUS_COLORS` daarop gebouwd zijn. +- Story-status en task-status in Product/Sprint workspace-stores zijn intern `UPPER_SNAKE`. +- API routes blijven lowercase teruggeven aan externe/REST clients. + +### Stap 1.2 - Voeg adapters toe aan de workspace-store boundary + +Maak kleine adapterfuncties voor API-responses voordat data in stores wordt gehydrateerd: + +- Product workspace: + - full backlog snapshot; + - PBI stories; + - story tasks; + - task detail. +- Sprint workspace: + - sprint workspace snapshot; + - story tasks; + - task detail. + +Gebruik bestaande mappers uit `lib/task-status.ts`, bijvoorbeeld `storyStatusFromApi` en `taskStatusFromApi`. + +### Stap 1.3 - Voeg regressietests toe + +Test minimaal: + +- API lowercase `todo` wordt in task UI-store `TO_DO`; +- API lowercase `in_sprint` wordt in story UI-store `IN_SPRINT`; +- bestaande PBI lowercase status blijft lowercase; +- Sprint `STATUS_CYCLE` krijgt nooit lowercase input vanuit de store. + +## Fase 2 - Dubbele Product Backlog load verwijderen + +### Stap 2.1 - Maak hydration eigenaar van de initial backlog snapshot + +Pas Product Backlog aan naar hetzelfde eigenaarschap als Sprint: + +- `BacklogHydrationWrapper` hydrateert snapshot; +- wrapper zet ook `context.activeProduct`; +- wrapper markeert `loadedProductId`; +- `SetCurrentProduct` start op routes met eigen hydration geen full `ensureProductLoaded`. + +### Stap 2.2 - Guard `setActiveProduct` + +Voeg een guard toe zodat `setActiveProduct(product)` geen `ensureProductLoaded` start als: + +- hetzelfde product al actief is; +- `loading.loadedProductId === product.id`; +- er al een volledige snapshot gehydrateerd is. + +### Stap 2.3 - Meet en verifieer + +Controleer in devtools/server logs: + +- openen van Product Backlog doet geen extra `/api/products/:id/backlog` na de server-render; +- navigeren tussen product routes laadt nog steeds correct; +- restore hints voor laatste PBI/story/task blijven werken. + +## Fase 3 - Sprint selectie gelijkvormig maken + +### Stap 3.1 - Verplaats geselecteerde story naar de sprint workspace-store + +Vervang lokale `selectedStoryId` in `SprintBoardClient` door: + +- `useSprintWorkspaceStore((s) => s.context.activeStoryId)`; +- `useSprintWorkspaceStore.getState().setActiveStory(storyId)`; +- reset via `setActiveStory(null)` bij verwijderen uit sprint. + +### Stap 3.2 - Laat `TaskList` active-context gebruiken + +Maak `TaskList` gelijkvormig met Product Backlog: + +- lees taken via `selectTasksForActiveStory`; +- behoud `storyId` alleen als fallback of verwijder de prop; +- zorg dat `resyncActiveScopes` nu de actieve story/task werkelijk kan meenemen. + +### Stap 3.3 - Restore-hints testen + +Verifieer: + +- story-selectie blijft behouden na refresh/reconnect; +- task-paneel toont dezelfde story na tab-visible resync; +- verwijderen van de actieve story reset taakpaneel netjes. + +## Fase 4 - Solo refresh-hydration correct maken + +### Stap 4.1 - Vervang task-id-only dependency + +Vervang `taskKey = initialTasks.map(t => t.id).join(',')` door een render-relevante fingerprint, bijvoorbeeld: + +- `id`; +- `status`; +- `sort_order`; +- `title`; +- `implementation_plan`; +- `story_id`; +- `story_title`; +- `story_code`; +- `task_code`; +- relevante verify/queue velden. + +Of hydrateer op iedere nieuwe `initialTasks` prop als performance acceptabel is. + +### Stap 4.2 - Sync unassigned stories uit props + +Voeg een effect toe die `unassignedStories` bijwerkt wanneer `initialUnassigned` inhoudelijk wijzigt. + +### Stap 4.3 - Sorteer solo kolommen expliciet + +Render `columnTasks` gesorteerd op `sort_order` en daarna stabiel op code/titel/id. Vertrouw niet op object insertion order. + +### Stap 4.4 - Test gemiste event scenario's + +Test: + +- tab hidden, task status wijzigt extern, tab visible: kaart staat in juiste kolom; +- reconnect met dezelfde task ids maar gewijzigde titel/status: UI update; +- nieuwe unassigned story verschijnt na refresh; +- gewijzigde `sort_order` past de render-volgorde aan. + +## Fase 5 - Solo naar een gelijkvormig workspace-store patroon + +Gekozen route: **Optie B**. Solo wordt naar een workspace-store patroon gemigreerd dat aansluit op Product Backlog en Sprint. + +### Optie B - Grote stap + +Migreer Solo naar een workspace-store patroon vergelijkbaar met Product/Sprint: + +- normalized entities; +- active sprint/product context; +- loaded scopes; +- resync methods; +- realtime event adapters. + +Concrete taken: + +- Introduceer `stores/solo-workspace/{types,selectors,store}.ts`. +- Introduceer een `SoloHydrationWrapper` die server snapshot en actieve context hydrateert. +- Laat `SoloBoard` renderen vanuit selectors in de solo workspace-store. +- Verplaats realtime event handling en job/worker status naar de solo workspace-store. +- Vervang `router.refresh()` als primaire resync door `resyncActiveScopes`. +- Houd route refresh alleen over als expliciete fallback voor onbekende events of navigatiecases. + +## Fase 6 - Observability en performance check + +Voeg tijdelijk of permanent meetpunten toe: + +- log of dev-only counter voor hydration calls per scherm; +- log of dev-only counter voor API `ensure*Loaded` calls; +- React Profiler rond Product Backlog/Sprint/Solo pane containers; +- netwerkcheck op dubbele fetches. + +Acceptatiecriteria: + +- Product Backlog doet bij eerste openen maximaal een server snapshot plus SSE connect, geen extra full-backlog client fetch. +- Product en Sprint stores bevatten geen lowercase story/task statussen. +- Solo refresh verwerkt bestaande tasks met gewijzigde velden. +- Product Backlog, Sprint en Solo hebben per scherm precies een duidelijke eigenaar voor initial hydration. + +## Voorgestelde implementatievolgorde + +1. Status adapters en tests toevoegen. +2. Product Backlog dubbele load verwijderen. +3. Sprint active story selectie naar store verplaatsen. +4. Solo workspace-store introduceren en hydrateren. +5. Solo realtime/resync naar workspace-store verplaatsen. +6. Performance/netwerk verifiëren. + +Deze volgorde beperkt risico: eerst het data-contract, daarna de extra load, daarna gelijkvormigheid en Solo-resync. diff --git a/docs/recommendations/load-render-implementation-review-2026-05-10.md b/docs/recommendations/load-render-implementation-review-2026-05-10.md new file mode 100644 index 0000000..9d32f14 --- /dev/null +++ b/docs/recommendations/load-render-implementation-review-2026-05-10.md @@ -0,0 +1,112 @@ +--- +title: "Load/render implementatie review" +date: 2026-05-10 +status: review +scope: ["Product Backlog", "Sprint", "Solo"] +--- + +# Load/render implementatie review + +## Samenvatting + +De drie schermen zijn niet gelijkvormig opgebouwd. + +- Product Backlog en Sprint gebruiken allebei een server-fetched snapshot, een hydration wrapper, een genormaliseerde workspace-store, SSE en directe scope-resync. +- Solo gebruikt server props, een eigen `useSoloStore`, een globale SSE-bridge en `router.refresh()` als resync-mechanisme. +- Product Backlog wijkt af doordat het naast server hydration ook nog via de product layout een client-side full backlog fetch start. Dat kan de lange rendering verklaren. +- Product Backlog en Sprint hebben daarnaast een status-contract mismatch: server pages hydrateren story/task statussen als DB `UPPER_SNAKE`, maar API-resync routes geven lowercase API-statussen terug terwijl de UI maps uppercase verwachten. + +## Bevindingen + +### P1 - Statussen wisselen tussen uppercase en lowercase na client load/resync + +`lib/task-status.ts` zegt expliciet dat de DB `UPPER_SNAKE` houdt en de API lowercase exposeert (`lib/task-status.ts:1-2`). De API mapt bijvoorbeeld `TO_DO -> todo` en `OPEN -> open` (`lib/task-status.ts:12-35`). + +De server-render paden hydrateren story/task statussen echter direct uit Prisma: + +- Product Backlog stories/tasks blijven uppercase in `app/(app)/products/[id]/page.tsx:86-98`. +- Sprint stories/tasks blijven uppercase in `app/(app)/products/[id]/sprint/[sprintId]/page.tsx:94-125`. + +De client load/resync paden mappen dezelfde data naar lowercase: + +- Product Backlog full snapshot: `app/api/products/[id]/backlog/route.ts:80-99`. +- PBI stories: `app/api/pbis/[id]/stories/route.ts:49-50`. +- Story tasks: `app/api/stories/[id]/tasks/route.ts:46-48`. +- Sprint workspace snapshot: `app/api/sprints/[id]/workspace/route.ts:71-108`. + +De UI verwacht voor stories/tasks juist uppercase: + +- Backlog stories: `components/backlog/story-panel.tsx:41-50`. +- Backlog tasks: `components/backlog/task-panel.tsx:42-53`. +- Sprint stories: `components/sprint/sprint-backlog.tsx:33-38`. +- Sprint tasks: `components/sprint/task-list.tsx:33-54`. + +Impact: na een client fetch of resync kunnen labels, kleuren, filters en status-cycles anders of leeg renderen. In Sprint is dit extra riskant omdat `STATUS_CYCLE[task.status]` bij lowercase statussen terugvalt naar `TO_DO`. + +Aanpak: kies een intern store-contract. Het meest consistent met de bestaande UI is: DB-uppercase in de workspace-stores houden, en API lowercase alleen aan de route/API-boundary gebruiken. Converteer API-responses dus terug naar DB-statussen voordat ze in Product/Sprint workspace stores landen, of pas alle UI maps en acties consequent aan op API-statussen. + +### P1 - Product Backlog doet een dubbele full backlog load + +Product Backlog haalt op de server al alle PBI's, stories en tasks op (`app/(app)/products/[id]/page.tsx:47-84`) en hydrateert die in de client via `BacklogHydrationWrapper` (`components/backlog/backlog-hydration-wrapper.tsx:60-67`). + +Tegelijkertijd mount de product layout altijd `SetCurrentProduct` (`app/(app)/products/[id]/layout.tsx:19-22`). Die roept `setActiveProduct` aan (`components/shared/set-current-product.tsx:10-14`). `setActiveProduct` start altijd `ensureProductLoaded`, en die fetcht opnieuw de volledige backlog via `/api/products/:id/backlog` (`stores/product-workspace/store.ts:217-257`, `stores/product-workspace/store.ts:329-345`). + +Impact: op Product Backlog komt na de server render nog een client full-backlog API-call en store hydration. Dat veroorzaakt extra werk, extra renders, en door de status mismatch hierboven kan de tweede load de net gehydrateerde uppercase data overschrijven met lowercase data. + +Aanpak: maak server hydration en client ensure geen dubbele eigenaren van dezelfde initial load. Bijvoorbeeld: + +- `SetCurrentProduct` alleen context laten zetten zonder `ensureProductLoaded` wanneer de route zelf een snapshot hydrateert. +- Of `BacklogHydrationWrapper` ook `activeProduct` zetten en `loadedProductId` markeren, waarna `setActiveProduct`/`ensureProductLoaded` guarded wordt. +- Of Product Backlog hetzelfde patroon geven als Sprint: wrapper hydrateert snapshot en zet de actieve context direct. + +### P1 - Solo resync werkt niet voor bestaande taken met dezelfde ids + +`useSoloRealtime` gebruikt `router.refresh()` om gemiste events na reconnect/visible/online op te halen (`lib/realtime/use-solo-realtime.ts:96-104`, `lib/realtime/use-solo-realtime.ts:190-205`). De comment zegt dat server props opnieuw binnenkomen en `initTasks` de store reset. + +Maar `SoloBoard` roept `initTasks(initialTasks)` alleen opnieuw aan als de lijst task-ids verandert: + +- `const taskKey = initialTasks.map(t => t.id).join(',')` (`components/solo/solo-board.tsx:79`) +- effect dependency is alleen `[taskKey]` (`components/solo/solo-board.tsx:80-83`) +- `initTasks` vervangt de store (`stores/solo-store.ts:105-106`) + +Impact: als een gemist event alleen status, titel, sort_order, plan of andere velden wijzigt, en de task-id set gelijk blijft, dan doet de refresh niets in de solo-store. Het scherm blijft stale ondanks de resync. + +Aanpak: gebruik een volledige fingerprint van de render-relevante velden, of hydrateer de store op iedere nieuwe `initialTasks` prop. Als renderperformance een zorg is, maak de fingerprint expliciet met `id`, `status`, `sort_order`, `title`, story metadata en planvelden. + +### P2 - Solo sync't openstaande stories niet na refresh + +`SoloBoard` initialiseert `unassignedStories` eenmalig uit props (`components/solo/solo-board.tsx:66`). De knop en sheet renderen daarna vanuit lokale state (`components/solo/solo-board.tsx:220-225`, `components/solo/solo-board.tsx:278-284`). + +Impact: als `router.refresh()` nieuwe unassigned stories ophaalt, wordt de lokale state niet bijgewerkt. Het aantal en de sheet kunnen stale blijven. + +Aanpak: sync `initialUnassigned` via een effect/fingerprint, of maak unassigned stories onderdeel van dezelfde hydrateerbare solo-store. + +### P2 - Sprint gebruikt niet hetzelfde active-context patroon als Product Backlog + +Product Backlog selecteert PBI/story/task via de workspace-store context. `TaskPanel` leest bijvoorbeeld `context.activeStoryId` en `selectTasksForActiveStory` (`components/backlog/task-panel.tsx:108-115`). + +Sprint hydrateert wel een sprint workspace-store, maar de geselecteerde story staat lokaal in `SprintBoardClient`: + +- `useState(null)` voor `selectedStoryId` (`components/sprint/sprint-board-client.tsx:66`) +- selectie wordt als prop doorgegeven (`components/sprint/sprint-board-client.tsx:238-257`) +- `TaskList` leest tasks via `selectTasksForStory(s, storyId)`, niet via de actieve store-context (`components/sprint/task-list.tsx:161-164`) + +De sprint-store heeft wel `setActiveStory`, `selectTasksForActiveStory` en resync van `activeStoryId`, maar het scherm gebruikt dat pad niet (`stores/sprint-workspace/store.ts:305-327`, `stores/sprint-workspace/store.ts:458-466`). + +Impact: Sprint werkt deels, maar is niet gelijkvormig met Product Backlog. De restore-hints en active-scope resync voor story/task zijn in dit scherm praktisch omzeild. + +Aanpak: zet story-selectie in de sprint workspace-store en laat `TaskList` dezelfde active-context selector gebruiken als Product Backlog, of verwijder de ongebruikte active story/task mechanismen uit de sprint-store. + +## Vergelijking per scherm + +| Scherm | Initial load | Client hydration | Realtime/resync | Selectie/render patroon | +| --- | --- | --- | --- | --- | +| Product Backlog | Server haalt full backlog op | `BacklogHydrationWrapper` hydrateert product workspace-store | SSE + `resyncActiveScopes` | Store-context voor actieve PBI/story/task | +| Sprint | Server haalt sprint snapshot op | `SprintHydrationWrapper` hydrateert sprint workspace-store en zet context | SSE + `resyncActiveScopes` | Sprint/story lijst uit store, maar story selectie lokaal | +| Solo | Server haalt solo props op | `SoloBoard` init `useSoloStore` via effect op task-ids | Globale SSE + `router.refresh()` | Eigen store voor tasks, lokale state voor unassigned stories | + +## Conclusie + +De lange rendering is waarschijnlijk niet door `debug_id` op zichzelf veroorzaakt. De meest concrete render/load oorzaak zit in Product Backlog: server snapshot plus een tweede client-side full backlog load via `SetCurrentProduct`. Daarnaast zorgt de status-contract mismatch ervoor dat die tweede load en latere resyncs een andere datastructuur in dezelfde UI stoppen. + +De schermen zijn functioneel verwant, maar niet gelijkvormig geimplementeerd. Product Backlog en Sprint moeten eerst hetzelfde status- en hydration-contract krijgen. Daarna kan Solo naar hetzelfde patroon groeien, of minimaal zijn `router.refresh()`-hydratie correct laten doorwerken op bestaande tasks en unassigned stories. diff --git a/eslint.config.mjs b/eslint.config.mjs index c2c6d35..faac648 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,6 +18,7 @@ const eslintConfig = defineConfig([ globalIgnores([ // Default ignores of eslint-config-next: ".next/**", + ".claude/**", "out/**", "build/**", "next-env.d.ts", diff --git a/lib/insights/agent-throughput.ts b/lib/insights/agent-throughput.ts index 3e172aa..07ec1f1 100644 --- a/lib/insights/agent-throughput.ts +++ b/lib/insights/agent-throughput.ts @@ -22,8 +22,6 @@ export interface JobsPerDayResult { kpi: ThroughputKpi } -const STATUSES = ['queued', 'claimed', 'running', 'done', 'failed', 'cancelled', 'skipped'] as const - type RawDayRow = { day: Date; status: string; count: bigint } type RawKpiRow = { today_count: bigint; done_7d: bigint; terminal_7d: bigint; avg_seconds: number | null } 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/product-workspace/store.ts b/stores/product-workspace/store.ts index 4571310..103eb28 100644 --- a/stores/product-workspace/store.ts +++ b/stores/product-workspace/store.ts @@ -22,6 +22,14 @@ import { writeStoryHint, writeTaskHint, } from './restore' +import { + normalizeBacklogStory, + normalizeBacklogTask, + normalizeProductBacklogSnapshot, + normalizePbiStatusForStore, + normalizeStoryStatusForStore, + normalizeTaskStatusForStore, +} from '@/stores/workspace-status-adapter' interface ContextSlice { activeProduct: ActiveProduct | null @@ -70,7 +78,10 @@ interface State { interface Actions { hydrateSnapshot(snapshot: ProductBacklogSnapshot): void - setActiveProduct(product: ActiveProduct | null): void + setActiveProduct( + product: ActiveProduct | null, + options?: { load?: boolean; preserveSelection?: boolean }, + ): void setActivePbi(pbiId: string | null): void setActiveStory(storyId: string | null): void setActiveTask(taskId: string | null): void @@ -174,7 +185,8 @@ export const useProductWorkspaceStore = create()( immer((set, get) => ({ ...initialState, - hydrateSnapshot(snapshot) { + hydrateSnapshot(inputSnapshot) { + const snapshot = normalizeProductBacklogSnapshot(inputSnapshot) set((s) => { if (snapshot.product) s.context.activeProduct = snapshot.product @@ -214,15 +226,18 @@ export const useProductWorkspaceStore = create()( }) }, - setActiveProduct(product) { + setActiveProduct(product, options) { const requestId = newRequestId() const productChanged = get().context.activeProduct?.id !== product?.id + const shouldResetSelection = productChanged || !options?.preserveSelection set((s) => { s.context.activeProduct = product - s.context.activePbiId = null - s.context.activeStoryId = null - s.context.activeTaskId = null + if (shouldResetSelection) { + s.context.activePbiId = null + s.context.activeStoryId = null + s.context.activeTaskId = null + } s.loading.activeRequestId = requestId if (productChanged) { @@ -243,7 +258,7 @@ export const useProductWorkspaceStore = create()( // selectie kan herstellen. T-857: restore-flow start na ensureProductLoaded. writeProductHint(product?.id ?? null) - if (product) { + if (product && options?.load !== false) { const productId = product.id void (async () => { await get().ensureProductLoaded(productId, requestId) @@ -358,11 +373,12 @@ export const useProductWorkspaceStore = create()( ) if (requestId && get().loading.activeRequestId !== requestId) return if (!Array.isArray(stories)) return + const normalizedStories = stories.map(normalizeBacklogStory) set((s) => { - for (const story of stories) { + for (const story of normalizedStories) { s.entities.storiesById[story.id] = story } - s.relations.storyIdsByPbi[pbiId] = [...stories] + s.relations.storyIdsByPbi[pbiId] = [...normalizedStories] .sort(compareStory) .map((st) => st.id) s.loading.loadedPbiIds[pbiId] = true @@ -375,8 +391,9 @@ export const useProductWorkspaceStore = create()( ) if (requestId && get().loading.activeRequestId !== requestId) return if (!Array.isArray(tasks)) return + const normalizedTasks = tasks.map(normalizeBacklogTask) set((s) => { - for (const task of tasks) { + for (const task of normalizedTasks) { const existing = s.entities.tasksById[task.id] if (existing && isDetail(existing)) { s.entities.tasksById[task.id] = { ...existing, ...task } @@ -384,7 +401,7 @@ export const useProductWorkspaceStore = create()( s.entities.tasksById[task.id] = task } } - s.relations.taskIdsByStory[storyId] = [...tasks] + s.relations.taskIdsByStory[storyId] = [...normalizedTasks] .sort(compareTask) .map((t) => t.id) s.loading.loadedStoryIds[storyId] = true @@ -397,8 +414,9 @@ export const useProductWorkspaceStore = create()( ) if (requestId && get().loading.activeRequestId !== requestId) return if (!detail || typeof detail !== 'object') return + const normalizedDetail = normalizeBacklogTask(detail) set((s) => { - s.entities.tasksById[taskId] = { ...detail, _detail: true } + s.entities.tasksById[taskId] = { ...normalizedDetail, _detail: true } s.loading.loadedTaskIds[taskId] = true }) }, @@ -772,20 +790,65 @@ function sanitizePbiPayload(p: Record): Partial { const { entity: _e, op: _o, ...rest } = p void _e void _o + if (typeof rest.status === 'string') { + rest.status = normalizePbiStatusForStore(rest.status) + } return rest as Partial } function sanitizeStoryPayload(p: Record): Partial { - const { entity: _e, op: _o, ...rest } = p + const { + entity: _e, + op: _o, + story_status, + story_sort_order, + story_title, + story_code, + ...rest + } = p void _e void _o + if (rest.status === undefined && typeof story_status === 'string') { + rest.status = story_status + } + if (rest.sort_order === undefined && typeof story_sort_order === 'number') { + rest.sort_order = story_sort_order + } + if (rest.title === undefined && typeof story_title === 'string') { + rest.title = story_title + } + if (rest.code === undefined && (typeof story_code === 'string' || story_code === null)) { + rest.code = story_code + } + if (typeof rest.status === 'string') { + rest.status = normalizeStoryStatusForStore(rest.status) + } return rest as Partial } function sanitizeTaskPayload(p: Record): Partial { - const { entity: _e, op: _o, ...rest } = p + const { + entity: _e, + op: _o, + task_status, + task_sort_order, + task_title, + ...rest + } = p void _e void _o + if (rest.status === undefined && typeof task_status === 'string') { + rest.status = task_status + } + if (rest.sort_order === undefined && typeof task_sort_order === 'number') { + rest.sort_order = task_sort_order + } + if (rest.title === undefined && typeof task_title === 'string') { + rest.title = task_title + } + if (typeof rest.status === 'string') { + rest.status = normalizeTaskStatusForStore(rest.status) + } return rest as Partial } @@ -801,20 +864,24 @@ function coercePbiPayload(id: string, p: Record): BacklogPbi { p.created_at instanceof Date ? p.created_at : new Date(String(p.created_at ?? Date.now())), - status: (p.status as BacklogPbi['status']) ?? 'ready', + status: normalizePbiStatusForStore(String(p.status ?? 'ready')), } } function coerceStoryPayload(id: string, p: Record): BacklogStory { + const status = p.status ?? p.story_status ?? 'OPEN' + const sortOrder = p.sort_order ?? p.story_sort_order ?? 0 + const title = p.title ?? p.story_title ?? '' + const code = p.code ?? p.story_code ?? null return { id, - code: (p.code as string | null) ?? null, - title: String(p.title ?? ''), + code: (code as string | null) ?? null, + title: String(title), description: (p.description as string | null | undefined) ?? null, acceptance_criteria: (p.acceptance_criteria as string | null | undefined) ?? null, priority: Number(p.priority ?? 4), - sort_order: Number(p.sort_order ?? 0), - status: String(p.status ?? 'open'), + sort_order: Number(sortOrder), + status: normalizeStoryStatusForStore(String(status)), pbi_id: String(p.pbi_id ?? ''), sprint_id: (p.sprint_id as string | null | undefined) ?? null, created_at: @@ -825,13 +892,16 @@ function coerceStoryPayload(id: string, p: Record): BacklogStor } function coerceTaskPayload(id: string, p: Record): BacklogTask { + const status = p.status ?? p.task_status ?? 'TO_DO' + const sortOrder = p.sort_order ?? p.task_sort_order ?? 0 + const title = p.title ?? p.task_title ?? '' return { id, - title: String(p.title ?? ''), + title: String(title), description: (p.description as string | null | undefined) ?? null, priority: Number(p.priority ?? 4), - sort_order: Number(p.sort_order ?? 0), - status: String(p.status ?? 'todo'), + sort_order: Number(sortOrder), + status: normalizeTaskStatusForStore(String(status)), story_id: String(p.story_id ?? ''), created_at: p.created_at instanceof Date 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/stores/sprint-workspace/store.ts b/stores/sprint-workspace/store.ts index e49afce..81e878f 100644 --- a/stores/sprint-workspace/store.ts +++ b/stores/sprint-workspace/store.ts @@ -21,6 +21,12 @@ import { writeStoryHint, writeTaskHint, } from './restore' +import { + normalizeSprintTask, + normalizeSprintWorkspaceSnapshot, + normalizeStoryStatusForStore, + normalizeTaskStatusForStore, +} from '@/stores/workspace-status-adapter' interface ContextSlice { activeProduct: ActiveProductRef | null @@ -180,7 +186,8 @@ export const useSprintWorkspaceStore = create()( immer((set, get) => ({ ...initialState, - hydrateSnapshot(snapshot) { + hydrateSnapshot(inputSnapshot) { + const snapshot = normalizeSprintWorkspaceSnapshot(inputSnapshot) set((s) => { if (snapshot.product) s.context.activeProduct = snapshot.product @@ -387,8 +394,9 @@ export const useSprintWorkspaceStore = create()( ) if (requestId && get().loading.activeRequestId !== requestId) return if (!Array.isArray(tasks)) return + const normalizedTasks = tasks.map(normalizeSprintTask) set((s) => { - for (const task of tasks) { + for (const task of normalizedTasks) { const existing = s.entities.tasksById[task.id] if (existing && isDetail(existing)) { s.entities.tasksById[task.id] = { ...existing, ...task } @@ -396,7 +404,7 @@ export const useSprintWorkspaceStore = create()( s.entities.tasksById[task.id] = task } } - s.relations.taskIdsByStory[storyId] = [...tasks] + s.relations.taskIdsByStory[storyId] = [...normalizedTasks] .sort(compareTask) .map((t) => t.id) s.loading.loadedStoryIds[storyId] = true @@ -409,8 +417,9 @@ export const useSprintWorkspaceStore = create()( ) if (requestId && get().loading.activeRequestId !== requestId) return if (!detail || typeof detail !== 'object') return + const normalizedDetail = normalizeSprintTask(detail) set((s) => { - s.entities.tasksById[taskId] = { ...detail, _detail: true } + s.entities.tasksById[taskId] = { ...normalizedDetail, _detail: true } s.loading.loadedTaskIds[taskId] = true }) }, @@ -839,16 +848,58 @@ function sanitizeSprintPayload(p: Record): Partial): Partial { - const { entity: _e, op: _o, ...rest } = p + const { + entity: _e, + op: _o, + story_status, + story_sort_order, + story_title, + story_code, + ...rest + } = p void _e void _o + if (rest.status === undefined && typeof story_status === 'string') { + rest.status = story_status + } + if (rest.sort_order === undefined && typeof story_sort_order === 'number') { + rest.sort_order = story_sort_order + } + if (rest.title === undefined && typeof story_title === 'string') { + rest.title = story_title + } + if (rest.code === undefined && (typeof story_code === 'string' || story_code === null)) { + rest.code = story_code + } + if (typeof rest.status === 'string') { + rest.status = normalizeStoryStatusForStore(rest.status) + } return rest as Partial } function sanitizeTaskPayload(p: Record): Partial { - const { entity: _e, op: _o, ...rest } = p + const { + entity: _e, + op: _o, + task_status, + task_sort_order, + task_title, + ...rest + } = p void _e void _o + if (rest.status === undefined && typeof task_status === 'string') { + rest.status = task_status + } + if (rest.sort_order === undefined && typeof task_sort_order === 'number') { + rest.sort_order = task_sort_order + } + if (rest.title === undefined && typeof task_title === 'string') { + rest.title = task_title + } + if (typeof rest.status === 'string') { + rest.status = normalizeTaskStatusForStore(rest.status) + } return rest as Partial } @@ -881,15 +932,19 @@ function coerceStoryPayload( id: string, p: Record, ): SprintWorkspaceStory { + const status = p.status ?? p.story_status ?? 'OPEN' + const sortOrder = p.sort_order ?? p.story_sort_order ?? 0 + const title = p.title ?? p.story_title ?? '' + const code = p.code ?? p.story_code ?? null return { id, - code: (p.code as string | null) ?? null, - title: String(p.title ?? ''), + code: (code as string | null) ?? null, + title: String(title), description: (p.description as string | null | undefined) ?? null, acceptance_criteria: (p.acceptance_criteria as string | null | undefined) ?? null, priority: Number(p.priority ?? 4), - sort_order: Number(p.sort_order ?? 0), - status: String(p.status ?? 'open'), + sort_order: Number(sortOrder), + status: normalizeStoryStatusForStore(String(status)), pbi_id: String(p.pbi_id ?? ''), sprint_id: (p.sprint_id as string | null | undefined) ?? null, created_at: @@ -900,14 +955,17 @@ function coerceStoryPayload( } function coerceTaskPayload(id: string, p: Record): SprintWorkspaceTask { + const status = p.status ?? p.task_status ?? 'TO_DO' + const sortOrder = p.sort_order ?? p.task_sort_order ?? 0 + const title = p.title ?? p.task_title ?? '' return { id, code: (p.code as string | null) ?? null, - title: String(p.title ?? ''), + title: String(title), description: (p.description as string | null | undefined) ?? null, priority: Number(p.priority ?? 4), - sort_order: Number(p.sort_order ?? 0), - status: String(p.status ?? 'todo'), + sort_order: Number(sortOrder), + status: normalizeTaskStatusForStore(String(status)), story_id: String(p.story_id ?? ''), sprint_id: (p.sprint_id as string | null | undefined) ?? null, created_at: diff --git a/stores/workspace-status-adapter.ts b/stores/workspace-status-adapter.ts new file mode 100644 index 0000000..8900058 --- /dev/null +++ b/stores/workspace-status-adapter.ts @@ -0,0 +1,88 @@ +import { + pbiStatusFromApi, + pbiStatusToApi, + storyStatusFromApi, + taskStatusFromApi, +} from '@/lib/task-status' +import type { + BacklogPbi, + BacklogStory, + BacklogTask, + ProductBacklogSnapshot, + TaskDetail, +} from '@/stores/product-workspace/types' +import type { + SprintWorkspaceSnapshot, + SprintWorkspaceStory, + SprintWorkspaceTask, + SprintWorkspaceTaskDetail, +} from '@/stores/sprint-workspace/types' + +export function normalizePbiStatusForStore(status: string): BacklogPbi['status'] { + const dbStatus = pbiStatusFromApi(status) + return dbStatus ? pbiStatusToApi(dbStatus) : (status as BacklogPbi['status']) +} + +export function normalizeStoryStatusForStore(status: string): string { + return storyStatusFromApi(status) ?? status +} + +export function normalizeTaskStatusForStore(status: string): string { + return taskStatusFromApi(status) ?? status +} + +export function normalizeBacklogPbi(pbi: T): T { + const status = normalizePbiStatusForStore(pbi.status) + return status === pbi.status ? pbi : { ...pbi, status } +} + +export function normalizeBacklogStory(story: T): T { + const status = normalizeStoryStatusForStore(story.status) + return status === story.status ? story : { ...story, status } +} + +export function normalizeBacklogTask(task: T): T { + const status = normalizeTaskStatusForStore(task.status) + return status === task.status ? task : { ...task, status } +} + +export function normalizeSprintStory(story: T): T { + const status = normalizeStoryStatusForStore(story.status) + return status === story.status ? story : { ...story, status } +} + +export function normalizeSprintTask( + task: T, +): T { + const status = normalizeTaskStatusForStore(task.status) + return status === task.status ? task : { ...task, status } +} + +export function normalizeProductBacklogSnapshot( + snapshot: ProductBacklogSnapshot, +): ProductBacklogSnapshot { + return { + ...snapshot, + pbis: snapshot.pbis.map(normalizeBacklogPbi), + storiesByPbi: mapRecordLists(snapshot.storiesByPbi, normalizeBacklogStory), + tasksByStory: mapRecordLists(snapshot.tasksByStory, normalizeBacklogTask), + } +} + +export function normalizeSprintWorkspaceSnapshot( + snapshot: SprintWorkspaceSnapshot, +): SprintWorkspaceSnapshot { + return { + ...snapshot, + stories: snapshot.stories.map(normalizeSprintStory), + tasksByStory: mapRecordLists(snapshot.tasksByStory, normalizeSprintTask), + } +} + +function mapRecordLists(record: Record, normalize: (item: T) => T): Record { + const next: Record = {} + for (const [id, list] of Object.entries(record)) { + next[id] = list.map(normalize) + } + return next +} 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: {