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
This commit is contained in:
Janpeter Visser 2026-05-10 07:34:58 +02:00 committed by GitHub
parent 98ee05d458
commit 3b5cee823c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1845 additions and 577 deletions

View file

@ -90,7 +90,7 @@ function makeStory(overrides: Partial<BacklogStory> & { id: string; pbi_id: stri
acceptance_criteria: overrides.acceptance_criteria ?? null, acceptance_criteria: overrides.acceptance_criteria ?? null,
priority: overrides.priority ?? 2, priority: overrides.priority ?? 2,
sort_order: overrides.sort_order ?? 1, sort_order: overrides.sort_order ?? 1,
status: overrides.status ?? 'open', status: overrides.status ?? 'OPEN',
pbi_id: overrides.pbi_id, pbi_id: overrides.pbi_id,
sprint_id: overrides.sprint_id ?? null, sprint_id: overrides.sprint_id ?? null,
created_at: overrides.created_at ?? new Date('2026-01-01'), created_at: overrides.created_at ?? new Date('2026-01-01'),
@ -104,7 +104,7 @@ function makeTask(overrides: Partial<BacklogTask> & { id: string; story_id: stri
description: overrides.description ?? null, description: overrides.description ?? null,
priority: overrides.priority ?? 2, priority: overrides.priority ?? 2,
sort_order: overrides.sort_order ?? 1, sort_order: overrides.sort_order ?? 1,
status: overrides.status ?? 'todo', status: overrides.status ?? 'TO_DO',
story_id: overrides.story_id, story_id: overrides.story_id,
created_at: overrides.created_at ?? new Date('2026-01-01'), created_at: overrides.created_at ?? new Date('2026-01-01'),
} }
@ -168,6 +168,27 @@ describe('hydrateSnapshot', () => {
expect(s.loading.loadedProductId).toBe('prod-1') 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', () => { it('reset bestaande entities en relations bij her-hydratie', () => {
useProductWorkspaceStore.getState().hydrateSnapshot( useProductWorkspaceStore.getState().hydrateSnapshot(
snapshotWith([makePbi({ id: 'old-pbi' })]), snapshotWith([makePbi({ id: 'old-pbi' })]),
@ -236,6 +257,35 @@ describe('selection cascade', () => {
expect(s.relations.taskIdsByStory).toEqual({}) expect(s.relations.taskIdsByStory).toEqual({})
expect(s.loading.loadedProductId).toBeNull() 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') await useProductWorkspaceStore.getState().ensureTaskLoaded('t-1')
const task = useProductWorkspaceStore.getState().entities.tasksById['t-1'] as TaskDetail const task = useProductWorkspaceStore.getState().entities.tasksById['t-1'] as TaskDetail
expect(task._detail).toBe(true) expect(task._detail).toBe(true)
expect(task.status).toBe('TO_DO')
expect(task.implementation_plan).toBe('detailed plan here') expect(task.implementation_plan).toBe('detailed plan here')
expect(useProductWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true) expect(useProductWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true)
}) })

View file

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

View file

@ -96,7 +96,7 @@ function makeStory(
acceptance_criteria: overrides.acceptance_criteria ?? null, acceptance_criteria: overrides.acceptance_criteria ?? null,
priority: overrides.priority ?? 2, priority: overrides.priority ?? 2,
sort_order: overrides.sort_order ?? 1, sort_order: overrides.sort_order ?? 1,
status: overrides.status ?? 'open', status: overrides.status ?? 'OPEN',
pbi_id: overrides.pbi_id, pbi_id: overrides.pbi_id,
sprint_id: overrides.sprint_id ?? null, sprint_id: overrides.sprint_id ?? null,
created_at: overrides.created_at ?? new Date('2026-01-01'), created_at: overrides.created_at ?? new Date('2026-01-01'),
@ -113,7 +113,7 @@ function makeTask(
description: overrides.description ?? null, description: overrides.description ?? null,
priority: overrides.priority ?? 2, priority: overrides.priority ?? 2,
sort_order: overrides.sort_order ?? 1, sort_order: overrides.sort_order ?? 1,
status: overrides.status ?? 'todo', status: overrides.status ?? 'TO_DO',
story_id: overrides.story_id, story_id: overrides.story_id,
sprint_id: overrides.sprint_id ?? null, sprint_id: overrides.sprint_id ?? null,
created_at: overrides.created_at ?? new Date('2026-01-01'), 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.context.activeProduct).toEqual({ id: 'prod-1', name: 'Product 1' })
expect(s.loading.loadedSprintIds['sp-1']).toBe(true) 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', () => { describe('hydrateProductSprints', () => {
@ -692,6 +706,7 @@ describe('ensureTaskLoaded — zet detail-flag', () => {
't-1' 't-1'
] as SprintWorkspaceTaskDetail ] as SprintWorkspaceTaskDetail
expect(task._detail).toBe(true) expect(task._detail).toBe(true)
expect(task.status).toBe('TO_DO')
expect(task.implementation_plan).toBe('detailed plan here') expect(task.implementation_plan).toBe('detailed plan here')
expect(useSprintWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true) expect(useSprintWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true)
}) })

View file

@ -151,6 +151,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<BacklogHydrationWrapper <BacklogHydrationWrapper
productId={id} productId={id}
productName={product.name}
initialData={{ initialData={{
pbis: pbis.map((p) => ({ 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) })), pbis: pbis.map((p) => ({ 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, storiesByPbi,

View file

@ -1,14 +1,12 @@
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { getAccessibleProduct } from '@/lib/product-access' 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 { getSprintSwitcherData } from '@/lib/sprint-switcher-data'
import { getSoloWorkspaceSnapshot } from '@/lib/solo-workspace-server'
import { SoloBoard } from '@/components/solo/solo-board' import { SoloBoard } from '@/components/solo/solo-board'
import { SoloHydrationWrapper } from '@/components/solo/solo-hydration-wrapper'
import { NoActiveSprint } from '@/components/solo/no-active-sprint' import { NoActiveSprint } from '@/components/solo/no-active-sprint'
import { SprintSwitcher } from '@/components/shared/sprint-switcher' 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 { interface Props {
params: Promise<{ id: string }> params: Promise<{ id: string }>
@ -22,12 +20,10 @@ export default async function SoloProductPage({ params }: Props) {
const product = await getAccessibleProduct(id, session.userId) const product = await getAccessibleProduct(id, session.userId)
if (!product) notFound() if (!product) notFound()
const active = await resolveActiveSprint(id) const initialData = await getSoloWorkspaceSnapshot(id, session.userId)
const sprint = active const switcherData = await getSprintSwitcherData(id, {
? await prisma.sprint.findFirst({ where: { id: active.id, product_id: id } }) activeSprintId: initialData?.sprint.id ?? null,
: null })
const switcherData = await getSprintSwitcherData(id, { activeSprintId: sprint?.id ?? null })
const switcherBar = ( const switcherBar = (
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-center"> <div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-center">
@ -40,7 +36,7 @@ export default async function SoloProductPage({ params }: Props) {
</div> </div>
) )
if (!sprint) { if (!initialData) {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{switcherBar} {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 ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{switcherBar} {switcherBar}
<div className="flex-1 min-h-0"> <div className="flex-1 min-h-0">
<SoloHydrationWrapper initialData={initialData}>
<SoloBoard <SoloBoard
key={sprint.id} key={initialData.sprint.id}
productId={id} productId={id}
sprintGoal={sprint.sprint_goal} sprintGoal={initialData.sprint.sprint_goal}
tasks={tasks}
unassignedStories={unassignedStories}
isDemo={session.isDemo ?? false} isDemo={session.isDemo ?? false}
currentUserId={session.userId}
repoUrl={product.repo_url} repoUrl={product.repo_url}
/> />
</SoloHydrationWrapper>
</div> </div>
</div> </div>
) )

View file

@ -91,6 +91,7 @@ export default async function MobileProductBacklogPage({ params, searchParams }:
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<BacklogHydrationWrapper <BacklogHydrationWrapper
productId={id} productId={id}
productName={product.name}
initialData={{ initialData={{
pbis: pbis.map((p) => ({ 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) })), pbis: pbis.map((p) => ({ 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, storiesByPbi,

View file

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

View file

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

View file

@ -8,9 +8,11 @@ import { debugProps } from '@/lib/debug'
// De voorganger (stores/product-store.ts) wordt in Story 8 (T-876) verwijderd. // De voorganger (stores/product-store.ts) wordt in Story 8 (T-876) verwijderd.
export function SetCurrentProduct({ id, name }: { id: string; name: string }) { export function SetCurrentProduct({ id, name }: { id: string; name: string }) {
useEffect(() => { useEffect(() => {
useProductWorkspaceStore.getState().setActiveProduct({ id, name }) useProductWorkspaceStore
.getState()
.setActiveProduct({ id, name }, { load: false, preserveSelection: true })
return () => { return () => {
useProductWorkspaceStore.getState().setActiveProduct(null) useProductWorkspaceStore.getState().setActiveProduct(null, { load: false })
} }
}, [id, name]) }, [id, name])

View file

@ -1,12 +1,19 @@
'use client' 'use client'
import { useEffect, useState, useTransition } from 'react' import { useEffect, useState, useTransition } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { import {
DndContext, DragEndEvent, DragOverlay, DragStartEvent, DndContext, DragEndEvent, DragOverlay, DragStartEvent,
PointerSensor, useSensor, useSensors, closestCorners, PointerSensor, useSensor, useSensors, closestCorners,
} from '@dnd-kit/core' } from '@dnd-kit/core'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useSoloStore } from '@/stores/solo-store' 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 { taskStatusToApi } from '@/lib/task-status'
import { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs' import { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs'
import { BatchEnqueueBlockerDialog } from './batch-enqueue-blocker-dialog' 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 { SoloColumn, type ColumnStatus } from './solo-column'
import { SoloTaskCardOverlay } from './solo-task-card' import { SoloTaskCardOverlay } from './solo-task-card'
import { TaskDetailDialog } from './task-detail-dialog' import { TaskDetailDialog } from './task-detail-dialog'
import { UnassignedStoriesSheet, type UnassignedStory } from './unassigned-stories-sheet' import { UnassignedStoriesSheet } from './unassigned-stories-sheet'
export interface SoloTask { export type { SoloTask } from '@/stores/solo-workspace/types'
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 interface SoloBoardProps { export interface SoloBoardProps {
productId: string productId: string
sprintGoal: string sprintGoal: string
tasks: SoloTask[] tasks?: SoloTask[]
unassignedStories: UnassignedStory[] unassignedStories?: SoloUnassignedStory[]
isDemo: boolean isDemo: boolean
currentUserId: string currentUserId?: string
repoUrl?: string | null repoUrl?: string | null
} }
@ -56,14 +46,22 @@ function getColumnStatus(status: SoloTask['status']): ColumnStatus {
} }
export function SoloBoard({ export function SoloBoard({
productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, repoUrl, productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, repoUrl, currentUserId,
}: SoloBoardProps) { }: 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 claudeJobsByTaskId = useSoloStore((s) => s.claudeJobsByTaskId)
const [activeDragId, setActiveDragId] = useState<string | null>(null) const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [selectedTask, setSelectedTask] = useState<SoloTask | null>(null) const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null)
const selectedTask = useSoloStore(selectSoloTaskById(selectedTaskId))
const [sheetOpen, setSheetOpen] = useState(false) const [sheetOpen, setSheetOpen] = useState(false)
const [unassignedStories, setUnassignedStories] = useState(initialUnassigned)
const [, startTransition] = useTransition() const [, startTransition] = useTransition()
const [batchPending, startBatchTransition] = useTransition() const [batchPending, startBatchTransition] = useTransition()
const [confirmPending, startConfirmTransition] = useTransition() const [confirmPending, startConfirmTransition] = useTransition()
@ -76,21 +74,27 @@ export function SoloBoard({
} }
const [blockerDialog, setBlockerDialog] = useState<BlockerDialogState | null>(null) const [blockerDialog, setBlockerDialog] = useState<BlockerDialogState | null>(null)
const taskKey = initialTasks.map(t => t.id).join(',')
useEffect(() => { useEffect(() => {
initTasks(initialTasks) if (!initialTasks || !initialUnassigned || !currentUserId) return
// eslint-disable-next-line react-hooks/exhaustive-deps const snapshot: SoloWorkspaceSnapshot = {
}, [taskKey]) 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 pointerSensor = useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
const sensors = useSensors(...(isDemo ? [] : [pointerSensor])) const sensors = useSensors(...(isDemo ? [] : [pointerSensor]))
const taskList = Object.values(tasks)
const columnTasks: Record<ColumnStatus, SoloTask[]> = { const columnTasks: Record<ColumnStatus, SoloTask[]> = {
TO_DO: taskList.filter(t => getColumnStatus(t.status) === 'TO_DO'), TO_DO: useSoloStore(useShallow(selectSoloTasksForColumn('TO_DO'))),
IN_PROGRESS: taskList.filter(t => getColumnStatus(t.status) === 'IN_PROGRESS'), IN_PROGRESS: useSoloStore(useShallow(selectSoloTasksForColumn('IN_PROGRESS'))),
DONE: taskList.filter(t => getColumnStatus(t.status) === 'DONE'), DONE: useSoloStore(useShallow(selectSoloTasksForColumn('DONE'))),
} }
const unassignedStories = useSoloStore(useShallow(selectSoloUnassignedStories))
function handleDragStart(event: DragStartEvent) { function handleDragStart(event: DragStartEvent) {
setActiveDragId(event.active.id as string) setActiveDragId(event.active.id as string)
@ -243,21 +247,21 @@ export function SoloBoard({
status="TO_DO" status="TO_DO"
tasks={columnTasks.TO_DO} tasks={columnTasks.TO_DO}
isDemo={isDemo} isDemo={isDemo}
onTaskClick={(t) => setSelectedTask(t)} onTaskClick={(t) => setSelectedTaskId(t.id)}
/>, />,
<SoloColumn <SoloColumn
key="IN_PROGRESS" key="IN_PROGRESS"
status="IN_PROGRESS" status="IN_PROGRESS"
tasks={columnTasks.IN_PROGRESS} tasks={columnTasks.IN_PROGRESS}
isDemo={isDemo} isDemo={isDemo}
onTaskClick={(t) => setSelectedTask(t)} onTaskClick={(t) => setSelectedTaskId(t.id)}
/>, />,
<SoloColumn <SoloColumn
key="DONE" key="DONE"
status="DONE" status="DONE"
tasks={columnTasks.DONE} tasks={columnTasks.DONE}
isDemo={isDemo} isDemo={isDemo}
onTaskClick={(t) => setSelectedTask(t)} onTaskClick={(t) => setSelectedTaskId(t.id)}
/>, />,
]} ]}
/> />
@ -272,7 +276,7 @@ export function SoloBoard({
productId={productId} productId={productId}
isDemo={isDemo} isDemo={isDemo}
repoUrl={repoUrl} repoUrl={repoUrl}
onClose={() => setSelectedTask(null)} onClose={() => setSelectedTaskId(null)}
/> />
<UnassignedStoriesSheet <UnassignedStoriesSheet
@ -281,7 +285,7 @@ export function SoloBoard({
isDemo={isDemo} isDemo={isDemo}
open={sheetOpen} open={sheetOpen}
onOpenChange={setSheetOpen} onOpenChange={setSheetOpen}
onClaim={(id) => setUnassignedStories(prev => prev.filter(s => s.id !== id))} onClaim={removeUnassignedStory}
/> />
{blockerDialog && ( {blockerDialog && (

View file

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

View file

@ -62,8 +62,8 @@ export function SprintBoardClient({
const sprintStories = useSprintWorkspaceStore( const sprintStories = useSprintWorkspaceStore(
useShallow((s) => selectStoriesForActiveSprint(s) as SprintStory[]), useShallow((s) => selectStoriesForActiveSprint(s) as SprintStory[]),
) )
const selectedStoryId = useSprintWorkspaceStore((s) => s.context.activeStoryId)
const sprintStoryIds = new Set(sprintStories.map(s => s.id)) const sprintStoryIds = new Set(sprintStories.map(s => s.id))
const [selectedStoryId, setSelectedStoryId] = useState<string | null>(null)
const [activeDragStory, setActiveDragStory] = useState<SprintStory | null>(null) const [activeDragStory, setActiveDragStory] = useState<SprintStory | null>(null)
const [, startTransition] = useTransition() const [, startTransition] = useTransition()
@ -157,7 +157,9 @@ export function SprintBoardClient({
if (story) story.sprint_id = null if (story) story.sprint_id = null
}) })
if (selectedStoryId === storyId) setSelectedStoryId(null) if (selectedStoryId === storyId) {
useSprintWorkspaceStore.getState().setActiveStory(null)
}
startTransition(async () => { startTransition(async () => {
const result = await removeStoryFromSprintAction(storyId) const result = await removeStoryFromSprintAction(storyId)
@ -240,7 +242,7 @@ export function SprintBoardClient({
sprintId={sprintId} sprintId={sprintId}
isDemo={isDemo} isDemo={isDemo}
onRemove={handleRemove} onRemove={handleRemove}
onSelect={setSelectedStoryId} onSelect={(storyId) => useSprintWorkspaceStore.getState().setActiveStory(storyId)}
selectedStoryId={selectedStoryId} selectedStoryId={selectedStoryId}
currentUserId={currentUserId} currentUserId={currentUserId}
productId={productId} productId={productId}
@ -250,7 +252,6 @@ export function SprintBoardClient({
selectedStoryId ? ( selectedStoryId ? (
<TaskList <TaskList
key="tasks" key="tasks"
storyId={selectedStoryId}
sprintId={sprintId} sprintId={sprintId}
productId={productId} productId={productId}
isDemo={isDemo} isDemo={isDemo}

View file

@ -20,7 +20,7 @@ import { CodeBadge } from '@/components/shared/code-badge'
import { PanelNavBar } from '@/components/shared/panel-nav-bar' import { PanelNavBar } from '@/components/shared/panel-nav-bar'
import { PRIORITY_BORDER } from '@/components/backlog/backlog-card' import { PRIORITY_BORDER } from '@/components/backlog/backlog-card'
import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store' import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store'
import { selectTasksForStory } from '@/stores/sprint-workspace/selectors' import { selectTasksForActiveStory } from '@/stores/sprint-workspace/selectors'
import type { import type {
SprintWorkspaceTask, SprintWorkspaceTask,
SprintWorkspaceTaskDetail, SprintWorkspaceTaskDetail,
@ -70,7 +70,6 @@ export interface Task {
type WorkspaceTask = SprintWorkspaceTask | SprintWorkspaceTaskDetail type WorkspaceTask = SprintWorkspaceTask | SprintWorkspaceTaskDetail
interface TaskListProps { interface TaskListProps {
storyId: string
sprintId: string sprintId: string
productId: string productId: string
isDemo: boolean isDemo: boolean
@ -158,9 +157,10 @@ function SortableTaskRow({
) )
} }
export function TaskList({ storyId, sprintId: _sprintId, productId: _productId, isDemo }: TaskListProps) { export function TaskList({ sprintId: _sprintId, productId: _productId, isDemo }: TaskListProps) {
const storyId = useSprintWorkspaceStore((s) => s.context.activeStoryId)
const orderedTasks = useSprintWorkspaceStore( const orderedTasks = useSprintWorkspaceStore(
useShallow((s) => selectTasksForStory(s, storyId)), useShallow(selectTasksForActiveStory),
) )
const [activeDragId, setActiveDragId] = useState<string | null>(null) const [activeDragId, setActiveDragId] = useState<string | null>(null)
const [, startTransition] = useTransition() const [, startTransition] = useTransition()
@ -179,6 +179,7 @@ export function TaskList({ storyId, sprintId: _sprintId, productId: _productId,
function handleDragEnd(event: DragEndEvent) { function handleDragEnd(event: DragEndEvent) {
const { active, over } = event const { active, over } = event
if (!storyId) return
if (!over || active.id === over.id) return if (!over || active.id === over.id) return
const store = useSprintWorkspaceStore.getState() const store = useSprintWorkspaceStore.getState()
const prevOrder = [...(store.relations.taskIdsByStory[storyId] ?? [])] const prevOrder = [...(store.relations.taskIdsByStory[storyId] ?? [])]
@ -217,6 +218,7 @@ export function TaskList({ storyId, sprintId: _sprintId, productId: _productId,
} }
function openCreateDialog() { function openCreateDialog() {
if (!storyId) return
router.push(`${pathname}?newTask=1&storyId=${storyId}`) router.push(`${pathname}?newTask=1&storyId=${storyId}`)
} }

View file

@ -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 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 | | [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) | — | — | | [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) | — | — | | [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 | | [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 | | [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 | | [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 | | [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 | | [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 | | [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 | | [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 | | [Branch, PR & Commit Strategy](./runbooks/branch-and-commit.md) | `runbooks/branch-and-commit.md` | active | 2026-05-03 |

View file

@ -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.

View file

@ -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<string | null>(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.

View file

@ -18,6 +18,7 @@ const eslintConfig = defineConfig([
globalIgnores([ globalIgnores([
// Default ignores of eslint-config-next: // Default ignores of eslint-config-next:
".next/**", ".next/**",
".claude/**",
"out/**", "out/**",
"build/**", "build/**",
"next-env.d.ts", "next-env.d.ts",

View file

@ -22,8 +22,6 @@ export interface JobsPerDayResult {
kpi: ThroughputKpi kpi: ThroughputKpi
} }
const STATUSES = ['queued', 'claimed', 'running', 'done', 'failed', 'cancelled', 'skipped'] as const
type RawDayRow = { day: Date; status: string; count: bigint } type RawDayRow = { day: Date; status: string; count: bigint }
type RawKpiRow = { today_count: bigint; done_7d: bigint; terminal_7d: bigint; avg_seconds: number | null } type RawKpiRow = { today_count: bigint; done_7d: bigint; terminal_7d: bigint; avg_seconds: number | null }

View file

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

View file

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

View file

@ -22,6 +22,14 @@ import {
writeStoryHint, writeStoryHint,
writeTaskHint, writeTaskHint,
} from './restore' } from './restore'
import {
normalizeBacklogStory,
normalizeBacklogTask,
normalizeProductBacklogSnapshot,
normalizePbiStatusForStore,
normalizeStoryStatusForStore,
normalizeTaskStatusForStore,
} from '@/stores/workspace-status-adapter'
interface ContextSlice { interface ContextSlice {
activeProduct: ActiveProduct | null activeProduct: ActiveProduct | null
@ -70,7 +78,10 @@ interface State {
interface Actions { interface Actions {
hydrateSnapshot(snapshot: ProductBacklogSnapshot): void hydrateSnapshot(snapshot: ProductBacklogSnapshot): void
setActiveProduct(product: ActiveProduct | null): void setActiveProduct(
product: ActiveProduct | null,
options?: { load?: boolean; preserveSelection?: boolean },
): void
setActivePbi(pbiId: string | null): void setActivePbi(pbiId: string | null): void
setActiveStory(storyId: string | null): void setActiveStory(storyId: string | null): void
setActiveTask(taskId: string | null): void setActiveTask(taskId: string | null): void
@ -174,7 +185,8 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
immer((set, get) => ({ immer((set, get) => ({
...initialState, ...initialState,
hydrateSnapshot(snapshot) { hydrateSnapshot(inputSnapshot) {
const snapshot = normalizeProductBacklogSnapshot(inputSnapshot)
set((s) => { set((s) => {
if (snapshot.product) s.context.activeProduct = snapshot.product if (snapshot.product) s.context.activeProduct = snapshot.product
@ -214,15 +226,18 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
}) })
}, },
setActiveProduct(product) { setActiveProduct(product, options) {
const requestId = newRequestId() const requestId = newRequestId()
const productChanged = get().context.activeProduct?.id !== product?.id const productChanged = get().context.activeProduct?.id !== product?.id
const shouldResetSelection = productChanged || !options?.preserveSelection
set((s) => { set((s) => {
s.context.activeProduct = product s.context.activeProduct = product
if (shouldResetSelection) {
s.context.activePbiId = null s.context.activePbiId = null
s.context.activeStoryId = null s.context.activeStoryId = null
s.context.activeTaskId = null s.context.activeTaskId = null
}
s.loading.activeRequestId = requestId s.loading.activeRequestId = requestId
if (productChanged) { if (productChanged) {
@ -243,7 +258,7 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
// selectie kan herstellen. T-857: restore-flow start na ensureProductLoaded. // selectie kan herstellen. T-857: restore-flow start na ensureProductLoaded.
writeProductHint(product?.id ?? null) writeProductHint(product?.id ?? null)
if (product) { if (product && options?.load !== false) {
const productId = product.id const productId = product.id
void (async () => { void (async () => {
await get().ensureProductLoaded(productId, requestId) await get().ensureProductLoaded(productId, requestId)
@ -358,11 +373,12 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
) )
if (requestId && get().loading.activeRequestId !== requestId) return if (requestId && get().loading.activeRequestId !== requestId) return
if (!Array.isArray(stories)) return if (!Array.isArray(stories)) return
const normalizedStories = stories.map(normalizeBacklogStory)
set((s) => { set((s) => {
for (const story of stories) { for (const story of normalizedStories) {
s.entities.storiesById[story.id] = story s.entities.storiesById[story.id] = story
} }
s.relations.storyIdsByPbi[pbiId] = [...stories] s.relations.storyIdsByPbi[pbiId] = [...normalizedStories]
.sort(compareStory) .sort(compareStory)
.map((st) => st.id) .map((st) => st.id)
s.loading.loadedPbiIds[pbiId] = true s.loading.loadedPbiIds[pbiId] = true
@ -375,8 +391,9 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
) )
if (requestId && get().loading.activeRequestId !== requestId) return if (requestId && get().loading.activeRequestId !== requestId) return
if (!Array.isArray(tasks)) return if (!Array.isArray(tasks)) return
const normalizedTasks = tasks.map(normalizeBacklogTask)
set((s) => { set((s) => {
for (const task of tasks) { for (const task of normalizedTasks) {
const existing = s.entities.tasksById[task.id] const existing = s.entities.tasksById[task.id]
if (existing && isDetail(existing)) { if (existing && isDetail(existing)) {
s.entities.tasksById[task.id] = { ...existing, ...task } s.entities.tasksById[task.id] = { ...existing, ...task }
@ -384,7 +401,7 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
s.entities.tasksById[task.id] = task s.entities.tasksById[task.id] = task
} }
} }
s.relations.taskIdsByStory[storyId] = [...tasks] s.relations.taskIdsByStory[storyId] = [...normalizedTasks]
.sort(compareTask) .sort(compareTask)
.map((t) => t.id) .map((t) => t.id)
s.loading.loadedStoryIds[storyId] = true s.loading.loadedStoryIds[storyId] = true
@ -397,8 +414,9 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
) )
if (requestId && get().loading.activeRequestId !== requestId) return if (requestId && get().loading.activeRequestId !== requestId) return
if (!detail || typeof detail !== 'object') return if (!detail || typeof detail !== 'object') return
const normalizedDetail = normalizeBacklogTask(detail)
set((s) => { set((s) => {
s.entities.tasksById[taskId] = { ...detail, _detail: true } s.entities.tasksById[taskId] = { ...normalizedDetail, _detail: true }
s.loading.loadedTaskIds[taskId] = true s.loading.loadedTaskIds[taskId] = true
}) })
}, },
@ -772,20 +790,65 @@ function sanitizePbiPayload(p: Record<string, unknown>): Partial<BacklogPbi> {
const { entity: _e, op: _o, ...rest } = p const { entity: _e, op: _o, ...rest } = p
void _e void _e
void _o void _o
if (typeof rest.status === 'string') {
rest.status = normalizePbiStatusForStore(rest.status)
}
return rest as Partial<BacklogPbi> return rest as Partial<BacklogPbi>
} }
function sanitizeStoryPayload(p: Record<string, unknown>): Partial<BacklogStory> { function sanitizeStoryPayload(p: Record<string, unknown>): Partial<BacklogStory> {
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 _e
void _o 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<BacklogStory> return rest as Partial<BacklogStory>
} }
function sanitizeTaskPayload(p: Record<string, unknown>): Partial<BacklogTask> { function sanitizeTaskPayload(p: Record<string, unknown>): Partial<BacklogTask> {
const { entity: _e, op: _o, ...rest } = p const {
entity: _e,
op: _o,
task_status,
task_sort_order,
task_title,
...rest
} = p
void _e void _e
void _o 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<BacklogTask> return rest as Partial<BacklogTask>
} }
@ -801,20 +864,24 @@ function coercePbiPayload(id: string, p: Record<string, unknown>): BacklogPbi {
p.created_at instanceof Date p.created_at instanceof Date
? p.created_at ? p.created_at
: new Date(String(p.created_at ?? Date.now())), : 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<string, unknown>): BacklogStory { function coerceStoryPayload(id: string, p: Record<string, unknown>): 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 { return {
id, id,
code: (p.code as string | null) ?? null, code: (code as string | null) ?? null,
title: String(p.title ?? ''), title: String(title),
description: (p.description as string | null | undefined) ?? null, description: (p.description as string | null | undefined) ?? null,
acceptance_criteria: (p.acceptance_criteria as string | null | undefined) ?? null, acceptance_criteria: (p.acceptance_criteria as string | null | undefined) ?? null,
priority: Number(p.priority ?? 4), priority: Number(p.priority ?? 4),
sort_order: Number(p.sort_order ?? 0), sort_order: Number(sortOrder),
status: String(p.status ?? 'open'), status: normalizeStoryStatusForStore(String(status)),
pbi_id: String(p.pbi_id ?? ''), pbi_id: String(p.pbi_id ?? ''),
sprint_id: (p.sprint_id as string | null | undefined) ?? null, sprint_id: (p.sprint_id as string | null | undefined) ?? null,
created_at: created_at:
@ -825,13 +892,16 @@ function coerceStoryPayload(id: string, p: Record<string, unknown>): BacklogStor
} }
function coerceTaskPayload(id: string, p: Record<string, unknown>): BacklogTask { function coerceTaskPayload(id: string, p: Record<string, unknown>): 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 { return {
id, id,
title: String(p.title ?? ''), title: String(title),
description: (p.description as string | null | undefined) ?? null, description: (p.description as string | null | undefined) ?? null,
priority: Number(p.priority ?? 4), priority: Number(p.priority ?? 4),
sort_order: Number(p.sort_order ?? 0), sort_order: Number(sortOrder),
status: String(p.status ?? 'todo'), status: normalizeTaskStatusForStore(String(status)),
story_id: String(p.story_id ?? ''), story_id: String(p.story_id ?? ''),
created_at: created_at:
p.created_at instanceof Date p.created_at instanceof Date

View file

@ -1,283 +1,8 @@
import { create } from 'zustand' export { useSoloWorkspaceStore as useSoloStore } from '@/stores/solo-workspace/store'
import type { SoloTask } from '@/components/solo/solo-board' export type {
import type { ClaudeJobStatusApi } from '@/lib/job-status' ClaudeJobEvent,
JobState,
type TaskStatus = SoloTask['status'] RealtimeEvent,
RealtimeStatus,
export type VerifyResultApi = 'aligned' | 'partial' | 'empty' | 'divergent' VerifyResultApi,
} from '@/stores/solo-workspace/types'
export interface JobState {
job_id: string
task_id: string
status: ClaudeJobStatusApi
branch?: string
pushed_at?: string | null
pr_url?: string | null
verify_result?: VerifyResultApi | null
summary?: string
error?: string
}
export type ClaudeJobEvent =
| { type: 'claude_job_enqueued'; job_id: string; task_id: string; user_id: string; product_id: string; status: 'queued' }
| { type: 'claude_job_status'; job_id: string; task_id: string; user_id: string; product_id: string; status: ClaudeJobStatusApi; branch?: string; pushed_at?: string; pr_url?: string; verify_result?: VerifyResultApi; summary?: string; error?: string }
// Payload-shape gepubliceerd door de Postgres-trigger via pg_notify (ST-801
// + ST-804 prereq). Komt het Solo Paneel binnen via de SSE-stream uit
// /api/realtime/solo (ST-802).
export interface RealtimeEvent {
op: 'I' | 'U' | 'D'
entity: 'task' | 'story'
id: string
story_id?: string
product_id: string
sprint_id: string | null
assignee_id: string | null
// Task-specifieke velden (alleen aanwezig als entity === 'task')
task_status?: TaskStatus
task_sort_order?: number
task_title?: string
// Story-specifieke velden (alleen aanwezig als entity === 'story')
story_status?: 'OPEN' | 'IN_SPRINT' | 'DONE'
story_sort_order?: number
story_title?: string
story_code?: string | null
// Op UPDATE: lijst van kolommen die zijn veranderd
changed_fields?: string[]
}
export type RealtimeStatus = 'connecting' | 'open' | 'disconnected'
interface SoloStore {
tasks: Record<string, SoloTask>
/** Task-ids die op dit moment een eigen optimistic write in de lucht hebben.
* Realtime echos voor deze ids worden onderdrukt zodat de eigen update niet
* twee keer toegepast wordt of door een latere echo overschreven. */
pendingOps: Set<string>
/** Realtime-connection state, beheerd door useSoloRealtime in de
* (app)-layout. Hier in de store omdat de UI-indicator in SoloBoard zit en
* de hook niet direct in dezelfde subtree draait. */
realtimeStatus: RealtimeStatus
showConnectingIndicator: boolean
claudeJobsByTaskId: Record<string, JobState>
connectedWorkers: number
// M13: laatste quota-rapport van een actieve worker. null = geen
// worker actief of nog geen heartbeat met quota ontvangen.
workerQuotaPct: number | null
workerQuotaCheckAt: string | null
initTasks: (tasks: SoloTask[]) => void
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
rollback: (taskId: string, prevStatus: TaskStatus) => void
updatePlan: (taskId: string, plan: string | null) => void
updateVerifyOnly: (taskId: string, value: boolean) => void
updateVerifyRequired: (taskId: string, value: 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY') => void
markPending: (taskId: string) => void
clearPending: (taskId: string) => void
setRealtimeStatus: (status: RealtimeStatus, showConnectingIndicator: boolean) => void
initJobs: (jobs: JobState[]) => void
handleJobEvent: (event: ClaudeJobEvent) => void
setWorkers: (count: number) => void
incrementWorkers: () => void
decrementWorkers: () => void
setWorkerQuota: (pct: number, checkAt: string) => void
handleRealtimeEvent: (event: RealtimeEvent) => void
}
export const useSoloStore = create<SoloStore>((set, get) => ({
tasks: {},
pendingOps: new Set<string>(),
realtimeStatus: 'connecting',
showConnectingIndicator: false,
claudeJobsByTaskId: {},
connectedWorkers: 0,
workerQuotaPct: null,
workerQuotaCheckAt: null,
initTasks: (tasks) =>
set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }),
optimisticMove: (taskId, toStatus) => {
const prev = get().tasks[taskId]?.status ?? null
if (!prev) return null
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], status: toStatus } } }))
return prev
},
rollback: (taskId, prevStatus) =>
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], status: prevStatus } } })),
updatePlan: (taskId, plan) =>
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], implementation_plan: plan } } })),
updateVerifyOnly: (taskId, value) =>
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_only: value } } })),
updateVerifyRequired: (taskId, value) =>
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_required: value } } })),
markPending: (taskId) =>
set((s) => {
if (s.pendingOps.has(taskId)) return s
const next = new Set(s.pendingOps)
next.add(taskId)
return { pendingOps: next }
}),
clearPending: (taskId) =>
set((s) => {
if (!s.pendingOps.has(taskId)) return s
const next = new Set(s.pendingOps)
next.delete(taskId)
return { pendingOps: next }
}),
setRealtimeStatus: (status, showConnectingIndicator) =>
set((s) => {
if (s.realtimeStatus === status && s.showConnectingIndicator === showConnectingIndicator) {
return s
}
return { realtimeStatus: status, showConnectingIndicator }
}),
initJobs: (jobs) =>
set({ claudeJobsByTaskId: Object.fromEntries(jobs.map(j => [j.task_id, j])) }),
setWorkers: (count) => set({ connectedWorkers: Math.max(0, count) }),
incrementWorkers: () => set(s => ({ connectedWorkers: s.connectedWorkers + 1 })),
decrementWorkers: () =>
set((s) => ({
connectedWorkers: Math.max(0, s.connectedWorkers - 1),
// Reset quota-state als alle workers weg zijn — pct van een vertrokken
// worker is niet meer actueel.
workerQuotaPct: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaPct,
workerQuotaCheckAt: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaCheckAt,
})),
setWorkerQuota: (pct, checkAt) => set({ workerQuotaPct: pct, workerQuotaCheckAt: checkAt }),
handleJobEvent: (event) => {
const { job_id, task_id } = event
if (event.type === 'claude_job_enqueued') {
set((s) => ({
claudeJobsByTaskId: {
...s.claudeJobsByTaskId,
[task_id]: { job_id, task_id, status: 'queued' },
},
}))
return
}
if (event.type === 'claude_job_status') {
const { status, branch, pushed_at, pr_url, verify_result, summary, error } = event
if (status === 'cancelled') {
set((s) => {
const next = { ...s.claudeJobsByTaskId }
delete next[task_id]
return { claudeJobsByTaskId: next }
})
return
}
set((s) => ({
claudeJobsByTaskId: {
...s.claudeJobsByTaskId,
[task_id]: { job_id, task_id, status, branch, pushed_at, pr_url, verify_result, summary, error },
},
}))
}
},
handleRealtimeEvent: (event) => {
if (event.entity === 'task') {
const { id, op } = event
if (op === 'D') {
set((s) => {
if (!(id in s.tasks)) return s
const next = { ...s.tasks }
delete next[id]
return { tasks: next }
})
return
}
// INSERT en UPDATE: alleen bestaande taken bijwerken. Nieuwe taken
// zonder story-context (story_title, story_code) renderen we niet
// — gebruiker ziet ze pas na een refresh. Acceptabel voor v1.
const existing = get().tasks[id]
if (!existing) return
if (get().pendingOps.has(id)) {
// Echo van een eigen optimistic move — laat de optimistic-state staan
return
}
const updates: Partial<SoloTask> = {}
if (event.task_status !== undefined && event.task_status !== existing.status) {
updates.status = event.task_status
}
if (
event.task_sort_order !== undefined &&
event.task_sort_order !== existing.sort_order
) {
updates.sort_order = event.task_sort_order
}
if (event.task_title !== undefined && event.task_title !== existing.title) {
updates.title = event.task_title
}
if (Object.keys(updates).length === 0) return
set((s) => ({ tasks: { ...s.tasks, [id]: { ...s.tasks[id], ...updates } } }))
return
}
if (event.entity === 'story') {
const { id, op } = event
if (op === 'D') {
// Story-cascade pakt tasks ook in de DB; verwijder de bijbehorende
// SoloTask-records uit de store.
set((s) => {
const next: Record<string, SoloTask> = {}
for (const [taskId, task] of Object.entries(s.tasks)) {
if (task.story_id !== id) next[taskId] = task
}
return { tasks: next }
})
return
}
const tasks = get().tasks
const affectedIds = Object.entries(tasks)
.filter(([, t]) => t.story_id === id)
.map(([taskId]) => taskId)
if (affectedIds.length === 0) return
const newTitle = event.story_title
const newCode = event.story_code ?? null
set((s) => {
const next = { ...s.tasks }
for (const taskId of affectedIds) {
const t = next[taskId]
const titleChanged = newTitle !== undefined && t.story_title !== newTitle
const codeChanged = newCode !== t.story_code
if (!titleChanged && !codeChanged) continue
next[taskId] = {
...t,
...(titleChanged && newTitle !== undefined && { story_title: newTitle }),
...(codeChanged && { story_code: newCode }),
}
}
return { tasks: next }
})
}
},
}))

View file

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

View file

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

View file

@ -0,0 +1,123 @@
import type { ClaudeJobStatusApi } from '@/lib/job-status'
export type SoloTaskStatus = 'TO_DO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE'
export type SoloColumnStatus = 'TO_DO' | 'IN_PROGRESS' | 'DONE'
export type SoloVerifyRequired = 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY'
export type VerifyResultApi = 'aligned' | 'partial' | 'empty' | 'divergent'
export interface SoloTask {
id: string
title: string
description: string | null
implementation_plan: string | null
priority: number
sort_order: number
status: SoloTaskStatus
verify_only: boolean
verify_required: SoloVerifyRequired
story_id: string
story_code: string | null
story_title: string
task_code: string | null
pbi_code: string | null
pbi_title: string | null
pbi_description: string | null
}
export interface SoloUnassignedStoryTask {
id: string
title: string
description: string | null
priority: number
status: string
}
export interface SoloUnassignedStory {
id: string
code: string | null
title: string
tasks: SoloUnassignedStoryTask[]
}
export interface SoloWorkspaceProduct {
id: string
name: string
repo_url?: string | null
}
export interface SoloWorkspaceSprint {
id: string
sprint_goal: string
}
export interface SoloWorkspaceSnapshot {
product: SoloWorkspaceProduct
sprint: SoloWorkspaceSprint
activeUserId: string
tasks: SoloTask[]
unassignedStories: SoloUnassignedStory[]
}
export interface JobState {
job_id: string
task_id: string
status: ClaudeJobStatusApi
branch?: string
pushed_at?: string | null
pr_url?: string | null
verify_result?: VerifyResultApi | null
summary?: string
error?: string
}
export type ClaudeJobEvent =
| {
type: 'claude_job_enqueued'
job_id: string
task_id: string
user_id: string
product_id: string
status: 'queued'
}
| {
type: 'claude_job_status'
job_id: string
task_id: string
user_id: string
product_id: string
status: ClaudeJobStatusApi
branch?: string
pushed_at?: string
pr_url?: string
verify_result?: VerifyResultApi
summary?: string
error?: string
}
export interface RealtimeEvent {
op: 'I' | 'U' | 'D'
entity: 'task' | 'story'
id: string
story_id?: string
product_id: string
sprint_id: string | null
assignee_id: string | null
status?: SoloTaskStatus | 'OPEN' | 'IN_SPRINT' | 'DONE'
sort_order?: number
title?: string
code?: string | null
description?: string | null
priority?: number
task_status?: SoloTaskStatus
task_sort_order?: number
task_title?: string
story_status?: 'OPEN' | 'IN_SPRINT' | 'DONE'
story_sort_order?: number
story_title?: string
story_code?: string | null
changed_fields?: string[]
}
export type RealtimeStatus = 'connecting' | 'open' | 'disconnected'
export type ResyncReason = 'visible' | 'reconnect' | 'manual' | 'unknown-event'

View file

@ -21,6 +21,12 @@ import {
writeStoryHint, writeStoryHint,
writeTaskHint, writeTaskHint,
} from './restore' } from './restore'
import {
normalizeSprintTask,
normalizeSprintWorkspaceSnapshot,
normalizeStoryStatusForStore,
normalizeTaskStatusForStore,
} from '@/stores/workspace-status-adapter'
interface ContextSlice { interface ContextSlice {
activeProduct: ActiveProductRef | null activeProduct: ActiveProductRef | null
@ -180,7 +186,8 @@ export const useSprintWorkspaceStore = create<SprintWorkspaceStore>()(
immer((set, get) => ({ immer((set, get) => ({
...initialState, ...initialState,
hydrateSnapshot(snapshot) { hydrateSnapshot(inputSnapshot) {
const snapshot = normalizeSprintWorkspaceSnapshot(inputSnapshot)
set((s) => { set((s) => {
if (snapshot.product) s.context.activeProduct = snapshot.product if (snapshot.product) s.context.activeProduct = snapshot.product
@ -387,8 +394,9 @@ export const useSprintWorkspaceStore = create<SprintWorkspaceStore>()(
) )
if (requestId && get().loading.activeRequestId !== requestId) return if (requestId && get().loading.activeRequestId !== requestId) return
if (!Array.isArray(tasks)) return if (!Array.isArray(tasks)) return
const normalizedTasks = tasks.map(normalizeSprintTask)
set((s) => { set((s) => {
for (const task of tasks) { for (const task of normalizedTasks) {
const existing = s.entities.tasksById[task.id] const existing = s.entities.tasksById[task.id]
if (existing && isDetail(existing)) { if (existing && isDetail(existing)) {
s.entities.tasksById[task.id] = { ...existing, ...task } s.entities.tasksById[task.id] = { ...existing, ...task }
@ -396,7 +404,7 @@ export const useSprintWorkspaceStore = create<SprintWorkspaceStore>()(
s.entities.tasksById[task.id] = task s.entities.tasksById[task.id] = task
} }
} }
s.relations.taskIdsByStory[storyId] = [...tasks] s.relations.taskIdsByStory[storyId] = [...normalizedTasks]
.sort(compareTask) .sort(compareTask)
.map((t) => t.id) .map((t) => t.id)
s.loading.loadedStoryIds[storyId] = true s.loading.loadedStoryIds[storyId] = true
@ -409,8 +417,9 @@ export const useSprintWorkspaceStore = create<SprintWorkspaceStore>()(
) )
if (requestId && get().loading.activeRequestId !== requestId) return if (requestId && get().loading.activeRequestId !== requestId) return
if (!detail || typeof detail !== 'object') return if (!detail || typeof detail !== 'object') return
const normalizedDetail = normalizeSprintTask(detail)
set((s) => { set((s) => {
s.entities.tasksById[taskId] = { ...detail, _detail: true } s.entities.tasksById[taskId] = { ...normalizedDetail, _detail: true }
s.loading.loadedTaskIds[taskId] = true s.loading.loadedTaskIds[taskId] = true
}) })
}, },
@ -839,16 +848,58 @@ function sanitizeSprintPayload(p: Record<string, unknown>): Partial<SprintWorksp
} }
function sanitizeStoryPayload(p: Record<string, unknown>): Partial<SprintWorkspaceStory> { function sanitizeStoryPayload(p: Record<string, unknown>): Partial<SprintWorkspaceStory> {
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 _e
void _o 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<SprintWorkspaceStory> return rest as Partial<SprintWorkspaceStory>
} }
function sanitizeTaskPayload(p: Record<string, unknown>): Partial<SprintWorkspaceTask> { function sanitizeTaskPayload(p: Record<string, unknown>): Partial<SprintWorkspaceTask> {
const { entity: _e, op: _o, ...rest } = p const {
entity: _e,
op: _o,
task_status,
task_sort_order,
task_title,
...rest
} = p
void _e void _e
void _o 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<SprintWorkspaceTask> return rest as Partial<SprintWorkspaceTask>
} }
@ -881,15 +932,19 @@ function coerceStoryPayload(
id: string, id: string,
p: Record<string, unknown>, p: Record<string, unknown>,
): SprintWorkspaceStory { ): 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 { return {
id, id,
code: (p.code as string | null) ?? null, code: (code as string | null) ?? null,
title: String(p.title ?? ''), title: String(title),
description: (p.description as string | null | undefined) ?? null, description: (p.description as string | null | undefined) ?? null,
acceptance_criteria: (p.acceptance_criteria as string | null | undefined) ?? null, acceptance_criteria: (p.acceptance_criteria as string | null | undefined) ?? null,
priority: Number(p.priority ?? 4), priority: Number(p.priority ?? 4),
sort_order: Number(p.sort_order ?? 0), sort_order: Number(sortOrder),
status: String(p.status ?? 'open'), status: normalizeStoryStatusForStore(String(status)),
pbi_id: String(p.pbi_id ?? ''), pbi_id: String(p.pbi_id ?? ''),
sprint_id: (p.sprint_id as string | null | undefined) ?? null, sprint_id: (p.sprint_id as string | null | undefined) ?? null,
created_at: created_at:
@ -900,14 +955,17 @@ function coerceStoryPayload(
} }
function coerceTaskPayload(id: string, p: Record<string, unknown>): SprintWorkspaceTask { function coerceTaskPayload(id: string, p: Record<string, unknown>): 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 { return {
id, id,
code: (p.code as string | null) ?? null, code: (p.code as string | null) ?? null,
title: String(p.title ?? ''), title: String(title),
description: (p.description as string | null | undefined) ?? null, description: (p.description as string | null | undefined) ?? null,
priority: Number(p.priority ?? 4), priority: Number(p.priority ?? 4),
sort_order: Number(p.sort_order ?? 0), sort_order: Number(sortOrder),
status: String(p.status ?? 'todo'), status: normalizeTaskStatusForStore(String(status)),
story_id: String(p.story_id ?? ''), story_id: String(p.story_id ?? ''),
sprint_id: (p.sprint_id as string | null | undefined) ?? null, sprint_id: (p.sprint_id as string | null | undefined) ?? null,
created_at: created_at:

View file

@ -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<T extends BacklogPbi>(pbi: T): T {
const status = normalizePbiStatusForStore(pbi.status)
return status === pbi.status ? pbi : { ...pbi, status }
}
export function normalizeBacklogStory<T extends BacklogStory>(story: T): T {
const status = normalizeStoryStatusForStore(story.status)
return status === story.status ? story : { ...story, status }
}
export function normalizeBacklogTask<T extends BacklogTask | TaskDetail>(task: T): T {
const status = normalizeTaskStatusForStore(task.status)
return status === task.status ? task : { ...task, status }
}
export function normalizeSprintStory<T extends SprintWorkspaceStory>(story: T): T {
const status = normalizeStoryStatusForStore(story.status)
return status === story.status ? story : { ...story, status }
}
export function normalizeSprintTask<T extends SprintWorkspaceTask | SprintWorkspaceTaskDetail>(
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<T>(record: Record<string, T[]>, normalize: (item: T) => T): Record<string, T[]> {
const next: Record<string, T[]> = {}
for (const [id, list] of Object.entries(record)) {
next[id] = list.map(normalize)
}
return next
}

View file

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