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:
parent
98ee05d458
commit
3b5cee823c
28 changed files with 1845 additions and 577 deletions
|
|
@ -90,7 +90,7 @@ function makeStory(overrides: Partial<BacklogStory> & { id: string; pbi_id: stri
|
|||
acceptance_criteria: overrides.acceptance_criteria ?? null,
|
||||
priority: overrides.priority ?? 2,
|
||||
sort_order: overrides.sort_order ?? 1,
|
||||
status: overrides.status ?? 'open',
|
||||
status: overrides.status ?? 'OPEN',
|
||||
pbi_id: overrides.pbi_id,
|
||||
sprint_id: overrides.sprint_id ?? null,
|
||||
created_at: overrides.created_at ?? new Date('2026-01-01'),
|
||||
|
|
@ -104,7 +104,7 @@ function makeTask(overrides: Partial<BacklogTask> & { id: string; story_id: stri
|
|||
description: overrides.description ?? null,
|
||||
priority: overrides.priority ?? 2,
|
||||
sort_order: overrides.sort_order ?? 1,
|
||||
status: overrides.status ?? 'todo',
|
||||
status: overrides.status ?? 'TO_DO',
|
||||
story_id: overrides.story_id,
|
||||
created_at: overrides.created_at ?? new Date('2026-01-01'),
|
||||
}
|
||||
|
|
@ -168,6 +168,27 @@ describe('hydrateSnapshot', () => {
|
|||
expect(s.loading.loadedProductId).toBe('prod-1')
|
||||
})
|
||||
|
||||
it('normaliseert API-statussen naar het interne store-contract', () => {
|
||||
useProductWorkspaceStore.getState().hydrateSnapshot(
|
||||
snapshotWith(
|
||||
[makePbi({ id: 'pbi-1', status: 'READY' as BacklogPbi['status'] })],
|
||||
{
|
||||
'pbi-1': [
|
||||
makeStory({ id: 'st-1', pbi_id: 'pbi-1', status: 'in_sprint' }),
|
||||
],
|
||||
},
|
||||
{
|
||||
'st-1': [makeTask({ id: 'tk-1', story_id: 'st-1', status: 'todo' })],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const s = useProductWorkspaceStore.getState()
|
||||
expect(s.entities.pbisById['pbi-1'].status).toBe('ready')
|
||||
expect(s.entities.storiesById['st-1'].status).toBe('IN_SPRINT')
|
||||
expect(s.entities.tasksById['tk-1'].status).toBe('TO_DO')
|
||||
})
|
||||
|
||||
it('reset bestaande entities en relations bij her-hydratie', () => {
|
||||
useProductWorkspaceStore.getState().hydrateSnapshot(
|
||||
snapshotWith([makePbi({ id: 'old-pbi' })]),
|
||||
|
|
@ -236,6 +257,35 @@ describe('selection cascade', () => {
|
|||
expect(s.relations.taskIdsByStory).toEqual({})
|
||||
expect(s.loading.loadedProductId).toBeNull()
|
||||
})
|
||||
|
||||
it('setActiveProduct kan alleen context zetten zonder full backlog load', () => {
|
||||
useProductWorkspaceStore.getState().hydrateSnapshot(
|
||||
snapshotWith(
|
||||
[makePbi({ id: 'p-1' })],
|
||||
{ 'p-1': [makeStory({ id: 's-1', pbi_id: 'p-1' })] },
|
||||
{ 's-1': [makeTask({ id: 't-1', story_id: 's-1' })] },
|
||||
{ id: 'prod-1', name: 'Product 1' },
|
||||
),
|
||||
)
|
||||
useProductWorkspaceStore.setState((s) => {
|
||||
s.context.activePbiId = 'p-1'
|
||||
s.context.activeStoryId = 's-1'
|
||||
})
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch')
|
||||
|
||||
useProductWorkspaceStore
|
||||
.getState()
|
||||
.setActiveProduct(
|
||||
{ id: 'prod-1', name: 'Product 1' },
|
||||
{ load: false, preserveSelection: true },
|
||||
)
|
||||
|
||||
const s = useProductWorkspaceStore.getState()
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
expect(s.context.activePbiId).toBe('p-1')
|
||||
expect(s.context.activeStoryId).toBe('s-1')
|
||||
expect(s.entities.pbisById['p-1']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -624,6 +674,7 @@ describe('ensureTaskLoaded — zet detail-flag', () => {
|
|||
await useProductWorkspaceStore.getState().ensureTaskLoaded('t-1')
|
||||
const task = useProductWorkspaceStore.getState().entities.tasksById['t-1'] as TaskDetail
|
||||
expect(task._detail).toBe(true)
|
||||
expect(task.status).toBe('TO_DO')
|
||||
expect(task.implementation_plan).toBe('detailed plan here')
|
||||
expect(useProductWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true)
|
||||
})
|
||||
|
|
|
|||
131
__tests__/stores/solo-workspace/store.test.ts
Normal file
131
__tests__/stores/solo-workspace/store.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -96,7 +96,7 @@ function makeStory(
|
|||
acceptance_criteria: overrides.acceptance_criteria ?? null,
|
||||
priority: overrides.priority ?? 2,
|
||||
sort_order: overrides.sort_order ?? 1,
|
||||
status: overrides.status ?? 'open',
|
||||
status: overrides.status ?? 'OPEN',
|
||||
pbi_id: overrides.pbi_id,
|
||||
sprint_id: overrides.sprint_id ?? null,
|
||||
created_at: overrides.created_at ?? new Date('2026-01-01'),
|
||||
|
|
@ -113,7 +113,7 @@ function makeTask(
|
|||
description: overrides.description ?? null,
|
||||
priority: overrides.priority ?? 2,
|
||||
sort_order: overrides.sort_order ?? 1,
|
||||
status: overrides.status ?? 'todo',
|
||||
status: overrides.status ?? 'TO_DO',
|
||||
story_id: overrides.story_id,
|
||||
sprint_id: overrides.sprint_id ?? null,
|
||||
created_at: overrides.created_at ?? new Date('2026-01-01'),
|
||||
|
|
@ -174,6 +174,20 @@ describe('hydrateSnapshot', () => {
|
|||
expect(s.context.activeProduct).toEqual({ id: 'prod-1', name: 'Product 1' })
|
||||
expect(s.loading.loadedSprintIds['sp-1']).toBe(true)
|
||||
})
|
||||
|
||||
it('normaliseert API-statussen naar het interne store-contract', () => {
|
||||
useSprintWorkspaceStore.getState().hydrateSnapshot(
|
||||
snapshotWith(
|
||||
makeSprint({ id: 'sp-1', product_id: 'prod-1' }),
|
||||
[makeStory({ id: 's-1', pbi_id: 'pbi-1', sprint_id: 'sp-1', status: 'in_sprint' })],
|
||||
{ 's-1': [makeTask({ id: 't-1', story_id: 's-1', status: 'todo' })] },
|
||||
),
|
||||
)
|
||||
|
||||
const s = useSprintWorkspaceStore.getState()
|
||||
expect(s.entities.storiesById['s-1'].status).toBe('IN_SPRINT')
|
||||
expect(s.entities.tasksById['t-1'].status).toBe('TO_DO')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hydrateProductSprints', () => {
|
||||
|
|
@ -692,6 +706,7 @@ describe('ensureTaskLoaded — zet detail-flag', () => {
|
|||
't-1'
|
||||
] as SprintWorkspaceTaskDetail
|
||||
expect(task._detail).toBe(true)
|
||||
expect(task.status).toBe('TO_DO')
|
||||
expect(task.implementation_plan).toBe('detailed plan here')
|
||||
expect(useSprintWorkspaceStore.getState().loading.loadedTaskIds['t-1']).toBe(true)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ export default async function ProductBacklogPage({ params, searchParams }: Props
|
|||
<div className="flex-1 overflow-hidden">
|
||||
<BacklogHydrationWrapper
|
||||
productId={id}
|
||||
productName={product.name}
|
||||
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) })),
|
||||
storiesByPbi,
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import { notFound, redirect } from 'next/navigation'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getAccessibleProduct } from '@/lib/product-access'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { resolveActiveSprint } from '@/lib/active-sprint'
|
||||
import { getSprintSwitcherData } from '@/lib/sprint-switcher-data'
|
||||
import { getSoloWorkspaceSnapshot } from '@/lib/solo-workspace-server'
|
||||
import { SoloBoard } from '@/components/solo/solo-board'
|
||||
import { SoloHydrationWrapper } from '@/components/solo/solo-hydration-wrapper'
|
||||
import { NoActiveSprint } from '@/components/solo/no-active-sprint'
|
||||
import { SprintSwitcher } from '@/components/shared/sprint-switcher'
|
||||
import type { SoloTask } from '@/components/solo/solo-board'
|
||||
import type { UnassignedStory } from '@/components/solo/unassigned-stories-sheet'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>
|
||||
|
|
@ -22,12 +20,10 @@ export default async function SoloProductPage({ params }: Props) {
|
|||
const product = await getAccessibleProduct(id, session.userId)
|
||||
if (!product) notFound()
|
||||
|
||||
const active = await resolveActiveSprint(id)
|
||||
const sprint = active
|
||||
? await prisma.sprint.findFirst({ where: { id: active.id, product_id: id } })
|
||||
: null
|
||||
|
||||
const switcherData = await getSprintSwitcherData(id, { activeSprintId: sprint?.id ?? null })
|
||||
const initialData = await getSoloWorkspaceSnapshot(id, session.userId)
|
||||
const switcherData = await getSprintSwitcherData(id, {
|
||||
activeSprintId: initialData?.sprint.id ?? null,
|
||||
})
|
||||
|
||||
const switcherBar = (
|
||||
<div className="px-4 py-3 border-b border-border bg-surface-container-low shrink-0 flex items-center justify-center">
|
||||
|
|
@ -40,7 +36,7 @@ export default async function SoloProductPage({ params }: Props) {
|
|||
</div>
|
||||
)
|
||||
|
||||
if (!sprint) {
|
||||
if (!initialData) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{switcherBar}
|
||||
|
|
@ -49,94 +45,19 @@ export default async function SoloProductPage({ params }: Props) {
|
|||
)
|
||||
}
|
||||
|
||||
const [rawTasks, rawUnassigned] = await Promise.all([
|
||||
prisma.task.findMany({
|
||||
where: {
|
||||
story: {
|
||||
sprint_id: sprint.id,
|
||||
assignee_id: session.userId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
story: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
tasks: { select: { id: true }, orderBy: { sort_order: 'asc' } },
|
||||
pbi: { select: { code: true, title: true, description: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ story: { pbi: { priority: 'asc' } } },
|
||||
{ story: { pbi: { sort_order: 'asc' } } },
|
||||
{ story: { sort_order: 'asc' } },
|
||||
{ priority: 'asc' },
|
||||
{ sort_order: 'asc' },
|
||||
],
|
||||
}),
|
||||
prisma.story.findMany({
|
||||
where: { sprint_id: sprint.id, assignee_id: null },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
tasks: {
|
||||
select: { id: true, title: true, description: true, priority: true, status: true },
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
},
|
||||
},
|
||||
orderBy: { sort_order: 'asc' },
|
||||
}),
|
||||
])
|
||||
|
||||
const tasks: SoloTask[] = rawTasks.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
implementation_plan: t.implementation_plan,
|
||||
priority: t.priority,
|
||||
sort_order: t.sort_order,
|
||||
status: t.status as SoloTask['status'],
|
||||
verify_only: t.verify_only,
|
||||
verify_required: t.verify_required as SoloTask['verify_required'],
|
||||
story_id: t.story.id,
|
||||
story_code: t.story.code,
|
||||
story_title: t.story.title,
|
||||
task_code: t.code,
|
||||
pbi_code: t.story.pbi?.code ?? null,
|
||||
pbi_title: t.story.pbi?.title ?? null,
|
||||
pbi_description: t.story.pbi?.description ?? null,
|
||||
}))
|
||||
|
||||
const unassignedStories: UnassignedStory[] = rawUnassigned.map(s => ({
|
||||
id: s.id,
|
||||
code: s.code,
|
||||
title: s.title,
|
||||
tasks: s.tasks.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
priority: t.priority,
|
||||
status: t.status,
|
||||
})),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{switcherBar}
|
||||
<div className="flex-1 min-h-0">
|
||||
<SoloHydrationWrapper initialData={initialData}>
|
||||
<SoloBoard
|
||||
key={sprint.id}
|
||||
key={initialData.sprint.id}
|
||||
productId={id}
|
||||
sprintGoal={sprint.sprint_goal}
|
||||
tasks={tasks}
|
||||
unassignedStories={unassignedStories}
|
||||
sprintGoal={initialData.sprint.sprint_goal}
|
||||
isDemo={session.isDemo ?? false}
|
||||
currentUserId={session.userId}
|
||||
repoUrl={product.repo_url}
|
||||
/>
|
||||
</SoloHydrationWrapper>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ export default async function MobileProductBacklogPage({ params, searchParams }:
|
|||
<div className="flex flex-col h-full">
|
||||
<BacklogHydrationWrapper
|
||||
productId={id}
|
||||
productName={product.name}
|
||||
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) })),
|
||||
storiesByPbi,
|
||||
|
|
|
|||
|
|
@ -6,13 +6,11 @@
|
|||
|
||||
import { notFound } from 'next/navigation'
|
||||
import { getAccessibleProduct } from '@/lib/product-access'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { requireSession } from '@/lib/auth-guard'
|
||||
import { resolveActiveSprint } from '@/lib/active-sprint'
|
||||
import { getSoloWorkspaceSnapshot } from '@/lib/solo-workspace-server'
|
||||
import { SoloBoard } from '@/components/solo/solo-board'
|
||||
import { SoloHydrationWrapper } from '@/components/solo/solo-hydration-wrapper'
|
||||
import { NoActiveSprint } from '@/components/solo/no-active-sprint'
|
||||
import type { SoloTask } from '@/components/solo/solo-board'
|
||||
import type { UnassignedStory } from '@/components/solo/unassigned-stories-sheet'
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>
|
||||
|
|
@ -25,12 +23,9 @@ export default async function MobileSoloProductPage({ params }: Props) {
|
|||
const product = await getAccessibleProduct(id, session.userId)
|
||||
if (!product) notFound()
|
||||
|
||||
const active = await resolveActiveSprint(id)
|
||||
const sprint = active
|
||||
? await prisma.sprint.findFirst({ where: { id: active.id, product_id: id } })
|
||||
: null
|
||||
const initialData = await getSoloWorkspaceSnapshot(id, session.userId)
|
||||
|
||||
if (!sprint) {
|
||||
if (!initialData) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<NoActiveSprint productId={id} productName={product.name} />
|
||||
|
|
@ -38,90 +33,15 @@ export default async function MobileSoloProductPage({ params }: Props) {
|
|||
)
|
||||
}
|
||||
|
||||
const [rawTasks, rawUnassigned] = await Promise.all([
|
||||
prisma.task.findMany({
|
||||
where: {
|
||||
story: {
|
||||
sprint_id: sprint.id,
|
||||
assignee_id: session.userId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
story: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
tasks: { select: { id: true }, orderBy: { sort_order: 'asc' } },
|
||||
pbi: { select: { code: true, title: true, description: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ story: { pbi: { priority: 'asc' } } },
|
||||
{ story: { pbi: { sort_order: 'asc' } } },
|
||||
{ story: { sort_order: 'asc' } },
|
||||
{ priority: 'asc' },
|
||||
{ sort_order: 'asc' },
|
||||
],
|
||||
}),
|
||||
prisma.story.findMany({
|
||||
where: { sprint_id: sprint.id, assignee_id: null },
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
title: true,
|
||||
tasks: {
|
||||
select: { id: true, title: true, description: true, priority: true, status: true },
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
},
|
||||
},
|
||||
orderBy: { sort_order: 'asc' },
|
||||
}),
|
||||
])
|
||||
|
||||
const tasks: SoloTask[] = rawTasks.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
implementation_plan: t.implementation_plan,
|
||||
priority: t.priority,
|
||||
sort_order: t.sort_order,
|
||||
status: t.status as SoloTask['status'],
|
||||
verify_only: t.verify_only,
|
||||
verify_required: t.verify_required as SoloTask['verify_required'],
|
||||
story_id: t.story.id,
|
||||
story_code: t.story.code,
|
||||
story_title: t.story.title,
|
||||
task_code: t.code,
|
||||
pbi_code: t.story.pbi?.code ?? null,
|
||||
pbi_title: t.story.pbi?.title ?? null,
|
||||
pbi_description: t.story.pbi?.description ?? null,
|
||||
}))
|
||||
|
||||
const unassignedStories: UnassignedStory[] = rawUnassigned.map(s => ({
|
||||
id: s.id,
|
||||
code: s.code,
|
||||
title: s.title,
|
||||
tasks: s.tasks.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
priority: t.priority,
|
||||
status: t.status,
|
||||
})),
|
||||
}))
|
||||
|
||||
return (
|
||||
<SoloHydrationWrapper initialData={initialData}>
|
||||
<SoloBoard
|
||||
key={sprint.id}
|
||||
key={initialData.sprint.id}
|
||||
productId={id}
|
||||
sprintGoal={sprint.sprint_goal}
|
||||
tasks={tasks}
|
||||
unassignedStories={unassignedStories}
|
||||
sprintGoal={initialData.sprint.sprint_goal}
|
||||
isDemo={session.isDemo ?? false}
|
||||
currentUserId={session.userId}
|
||||
repoUrl={product.repo_url}
|
||||
/>
|
||||
</SoloHydrationWrapper>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
25
app/api/products/[id]/solo-workspace/route.ts
Normal file
25
app/api/products/[id]/solo-workspace/route.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -8,9 +8,11 @@ import { debugProps } from '@/lib/debug'
|
|||
// De voorganger (stores/product-store.ts) wordt in Story 8 (T-876) verwijderd.
|
||||
export function SetCurrentProduct({ id, name }: { id: string; name: string }) {
|
||||
useEffect(() => {
|
||||
useProductWorkspaceStore.getState().setActiveProduct({ id, name })
|
||||
useProductWorkspaceStore
|
||||
.getState()
|
||||
.setActiveProduct({ id, name }, { load: false, preserveSelection: true })
|
||||
return () => {
|
||||
useProductWorkspaceStore.getState().setActiveProduct(null)
|
||||
useProductWorkspaceStore.getState().setActiveProduct(null, { load: false })
|
||||
}
|
||||
}, [id, name])
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState, useTransition } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import {
|
||||
DndContext, DragEndEvent, DragOverlay, DragStartEvent,
|
||||
PointerSensor, useSensor, useSensors, closestCorners,
|
||||
} from '@dnd-kit/core'
|
||||
import { toast } from 'sonner'
|
||||
import { useSoloStore } from '@/stores/solo-store'
|
||||
import {
|
||||
selectSoloTaskById,
|
||||
selectSoloTasksForColumn,
|
||||
selectSoloUnassignedStories,
|
||||
} from '@/stores/solo-workspace/selectors'
|
||||
import type { SoloTask, SoloUnassignedStory, SoloWorkspaceSnapshot } from '@/stores/solo-workspace/types'
|
||||
import { taskStatusToApi } from '@/lib/task-status'
|
||||
import { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs'
|
||||
import { BatchEnqueueBlockerDialog } from './batch-enqueue-blocker-dialog'
|
||||
|
|
@ -17,34 +24,17 @@ import { SplitPane } from '@/components/split-pane/split-pane'
|
|||
import { SoloColumn, type ColumnStatus } from './solo-column'
|
||||
import { SoloTaskCardOverlay } from './solo-task-card'
|
||||
import { TaskDetailDialog } from './task-detail-dialog'
|
||||
import { UnassignedStoriesSheet, type UnassignedStory } from './unassigned-stories-sheet'
|
||||
import { UnassignedStoriesSheet } from './unassigned-stories-sheet'
|
||||
|
||||
export interface SoloTask {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
implementation_plan: string | null
|
||||
priority: number
|
||||
sort_order: number
|
||||
status: 'TO_DO' | 'IN_PROGRESS' | 'REVIEW' | 'DONE'
|
||||
verify_only: boolean
|
||||
verify_required: 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY'
|
||||
story_id: string
|
||||
story_code: string | null
|
||||
story_title: string
|
||||
task_code: string | null
|
||||
pbi_code: string | null
|
||||
pbi_title: string | null
|
||||
pbi_description: string | null
|
||||
}
|
||||
export type { SoloTask } from '@/stores/solo-workspace/types'
|
||||
|
||||
export interface SoloBoardProps {
|
||||
productId: string
|
||||
sprintGoal: string
|
||||
tasks: SoloTask[]
|
||||
unassignedStories: UnassignedStory[]
|
||||
tasks?: SoloTask[]
|
||||
unassignedStories?: SoloUnassignedStory[]
|
||||
isDemo: boolean
|
||||
currentUserId: string
|
||||
currentUserId?: string
|
||||
repoUrl?: string | null
|
||||
}
|
||||
|
||||
|
|
@ -56,14 +46,22 @@ function getColumnStatus(status: SoloTask['status']): ColumnStatus {
|
|||
}
|
||||
|
||||
export function SoloBoard({
|
||||
productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, repoUrl,
|
||||
productId, sprintGoal, tasks: initialTasks, unassignedStories: initialUnassigned, isDemo, repoUrl, currentUserId,
|
||||
}: SoloBoardProps) {
|
||||
const { tasks, initTasks, optimisticMove, rollback, markPending, clearPending } = useSoloStore()
|
||||
const {
|
||||
tasks,
|
||||
hydrateSnapshot,
|
||||
optimisticMove,
|
||||
rollback,
|
||||
markPending,
|
||||
clearPending,
|
||||
removeUnassignedStory,
|
||||
} = useSoloStore()
|
||||
const claudeJobsByTaskId = useSoloStore((s) => s.claudeJobsByTaskId)
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||
const [selectedTask, setSelectedTask] = useState<SoloTask | null>(null)
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null)
|
||||
const selectedTask = useSoloStore(selectSoloTaskById(selectedTaskId))
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [unassignedStories, setUnassignedStories] = useState(initialUnassigned)
|
||||
const [, startTransition] = useTransition()
|
||||
const [batchPending, startBatchTransition] = useTransition()
|
||||
const [confirmPending, startConfirmTransition] = useTransition()
|
||||
|
|
@ -76,21 +74,27 @@ export function SoloBoard({
|
|||
}
|
||||
const [blockerDialog, setBlockerDialog] = useState<BlockerDialogState | null>(null)
|
||||
|
||||
const taskKey = initialTasks.map(t => t.id).join(',')
|
||||
useEffect(() => {
|
||||
initTasks(initialTasks)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [taskKey])
|
||||
if (!initialTasks || !initialUnassigned || !currentUserId) return
|
||||
const snapshot: SoloWorkspaceSnapshot = {
|
||||
product: { id: productId, name: '' },
|
||||
sprint: { id: `compat:${productId}`, sprint_goal: sprintGoal },
|
||||
activeUserId: currentUserId,
|
||||
tasks: initialTasks,
|
||||
unassignedStories: initialUnassigned,
|
||||
}
|
||||
hydrateSnapshot(snapshot)
|
||||
}, [currentUserId, hydrateSnapshot, initialTasks, initialUnassigned, productId, sprintGoal])
|
||||
|
||||
const pointerSensor = useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
|
||||
const sensors = useSensors(...(isDemo ? [] : [pointerSensor]))
|
||||
|
||||
const taskList = Object.values(tasks)
|
||||
const columnTasks: Record<ColumnStatus, SoloTask[]> = {
|
||||
TO_DO: taskList.filter(t => getColumnStatus(t.status) === 'TO_DO'),
|
||||
IN_PROGRESS: taskList.filter(t => getColumnStatus(t.status) === 'IN_PROGRESS'),
|
||||
DONE: taskList.filter(t => getColumnStatus(t.status) === 'DONE'),
|
||||
TO_DO: useSoloStore(useShallow(selectSoloTasksForColumn('TO_DO'))),
|
||||
IN_PROGRESS: useSoloStore(useShallow(selectSoloTasksForColumn('IN_PROGRESS'))),
|
||||
DONE: useSoloStore(useShallow(selectSoloTasksForColumn('DONE'))),
|
||||
}
|
||||
const unassignedStories = useSoloStore(useShallow(selectSoloUnassignedStories))
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
setActiveDragId(event.active.id as string)
|
||||
|
|
@ -243,21 +247,21 @@ export function SoloBoard({
|
|||
status="TO_DO"
|
||||
tasks={columnTasks.TO_DO}
|
||||
isDemo={isDemo}
|
||||
onTaskClick={(t) => setSelectedTask(t)}
|
||||
onTaskClick={(t) => setSelectedTaskId(t.id)}
|
||||
/>,
|
||||
<SoloColumn
|
||||
key="IN_PROGRESS"
|
||||
status="IN_PROGRESS"
|
||||
tasks={columnTasks.IN_PROGRESS}
|
||||
isDemo={isDemo}
|
||||
onTaskClick={(t) => setSelectedTask(t)}
|
||||
onTaskClick={(t) => setSelectedTaskId(t.id)}
|
||||
/>,
|
||||
<SoloColumn
|
||||
key="DONE"
|
||||
status="DONE"
|
||||
tasks={columnTasks.DONE}
|
||||
isDemo={isDemo}
|
||||
onTaskClick={(t) => setSelectedTask(t)}
|
||||
onTaskClick={(t) => setSelectedTaskId(t.id)}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
|
|
@ -272,7 +276,7 @@ export function SoloBoard({
|
|||
productId={productId}
|
||||
isDemo={isDemo}
|
||||
repoUrl={repoUrl}
|
||||
onClose={() => setSelectedTask(null)}
|
||||
onClose={() => setSelectedTaskId(null)}
|
||||
/>
|
||||
|
||||
<UnassignedStoriesSheet
|
||||
|
|
@ -281,7 +285,7 @@ export function SoloBoard({
|
|||
isDemo={isDemo}
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
onClaim={(id) => setUnassignedStories(prev => prev.filter(s => s.id !== id))}
|
||||
onClaim={removeUnassignedStory}
|
||||
/>
|
||||
|
||||
{blockerDialog && (
|
||||
|
|
|
|||
55
components/solo/solo-hydration-wrapper.tsx
Normal file
55
components/solo/solo-hydration-wrapper.tsx
Normal 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}</>
|
||||
}
|
||||
|
|
@ -62,8 +62,8 @@ export function SprintBoardClient({
|
|||
const sprintStories = useSprintWorkspaceStore(
|
||||
useShallow((s) => selectStoriesForActiveSprint(s) as SprintStory[]),
|
||||
)
|
||||
const selectedStoryId = useSprintWorkspaceStore((s) => s.context.activeStoryId)
|
||||
const sprintStoryIds = new Set(sprintStories.map(s => s.id))
|
||||
const [selectedStoryId, setSelectedStoryId] = useState<string | null>(null)
|
||||
const [activeDragStory, setActiveDragStory] = useState<SprintStory | null>(null)
|
||||
const [, startTransition] = useTransition()
|
||||
|
||||
|
|
@ -157,7 +157,9 @@ export function SprintBoardClient({
|
|||
if (story) story.sprint_id = null
|
||||
})
|
||||
|
||||
if (selectedStoryId === storyId) setSelectedStoryId(null)
|
||||
if (selectedStoryId === storyId) {
|
||||
useSprintWorkspaceStore.getState().setActiveStory(null)
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await removeStoryFromSprintAction(storyId)
|
||||
|
|
@ -240,7 +242,7 @@ export function SprintBoardClient({
|
|||
sprintId={sprintId}
|
||||
isDemo={isDemo}
|
||||
onRemove={handleRemove}
|
||||
onSelect={setSelectedStoryId}
|
||||
onSelect={(storyId) => useSprintWorkspaceStore.getState().setActiveStory(storyId)}
|
||||
selectedStoryId={selectedStoryId}
|
||||
currentUserId={currentUserId}
|
||||
productId={productId}
|
||||
|
|
@ -250,7 +252,6 @@ export function SprintBoardClient({
|
|||
selectedStoryId ? (
|
||||
<TaskList
|
||||
key="tasks"
|
||||
storyId={selectedStoryId}
|
||||
sprintId={sprintId}
|
||||
productId={productId}
|
||||
isDemo={isDemo}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { CodeBadge } from '@/components/shared/code-badge'
|
|||
import { PanelNavBar } from '@/components/shared/panel-nav-bar'
|
||||
import { PRIORITY_BORDER } from '@/components/backlog/backlog-card'
|
||||
import { useSprintWorkspaceStore } from '@/stores/sprint-workspace/store'
|
||||
import { selectTasksForStory } from '@/stores/sprint-workspace/selectors'
|
||||
import { selectTasksForActiveStory } from '@/stores/sprint-workspace/selectors'
|
||||
import type {
|
||||
SprintWorkspaceTask,
|
||||
SprintWorkspaceTaskDetail,
|
||||
|
|
@ -70,7 +70,6 @@ export interface Task {
|
|||
type WorkspaceTask = SprintWorkspaceTask | SprintWorkspaceTaskDetail
|
||||
|
||||
interface TaskListProps {
|
||||
storyId: string
|
||||
sprintId: string
|
||||
productId: string
|
||||
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(
|
||||
useShallow((s) => selectTasksForStory(s, storyId)),
|
||||
useShallow(selectTasksForActiveStory),
|
||||
)
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null)
|
||||
const [, startTransition] = useTransition()
|
||||
|
|
@ -179,6 +179,7 @@ export function TaskList({ storyId, sprintId: _sprintId, productId: _productId,
|
|||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
if (!storyId) return
|
||||
if (!over || active.id === over.id) return
|
||||
const store = useSprintWorkspaceStore.getState()
|
||||
const prevOrder = [...(store.relations.taskIdsByStory[storyId] ?? [])]
|
||||
|
|
@ -217,6 +218,7 @@ export function TaskList({ storyId, sprintId: _sprintId, productId: _productId,
|
|||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!storyId) return
|
||||
router.push(`${pathname}?newTask=1&storyId=${storyId}`)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ Auto-generated on 2026-05-10 from front-matter and headings.
|
|||
| [Landing v2 — lokaal & veilig + architectuurdiagram](./plans/landing-local-first.md) | active | 2026-05-03 |
|
||||
| [Landing v3 — van idee tot pull request](./plans/landing-v3-idea-flow.md) | active | 2026-05-04 |
|
||||
| [Scrum4Me-Research — Zustand rearchitecture (reset + execute)](./plans/lees-de-readme-md-validated-book.md) | — | — |
|
||||
| [Verbeterplan load/render Product Backlog, Sprint en Solo](./plans/load-render-improvement-plan-2026-05-10.md) | draft | 2026-05-10 |
|
||||
| [Advies — Zelf een Git-platform hosten naast of in plaats van GitHub](./plans/Local github setup.md) | — | — |
|
||||
| [M10 — Password-loze inlog via QR-pairing](./plans/M10-qr-pairing-login.md) | active | 2026-05-03 |
|
||||
| [M11 — Claude vraagt, gebruiker antwoordt](./plans/M11-claude-questions.md) | active | 2026-05-03 |
|
||||
|
|
@ -132,6 +133,7 @@ Auto-generated on 2026-05-10 from front-matter and headings.
|
|||
| [Realtime smoke-checklist — PBI / Story / Task](./realtime-smoke.md) | `realtime-smoke.md` | active | 2026-05-03 |
|
||||
| [Caveman plan — Beelink naar Ubuntu Scrum4Me server](./recommendations/beelink-ubuntu-scrum4me-server-caveman-plan.md) | `recommendations/beelink-ubuntu-scrum4me-server-caveman-plan.md` | draft | 2026-05-09 |
|
||||
| [Aanbeveling — Claude VM jobflow en gitstrategie](./recommendations/claude-vm-job-flow-git-strategy.md) | `recommendations/claude-vm-job-flow-git-strategy.md` | draft | 2026-05-09 |
|
||||
| [Load/render implementatie review](./recommendations/load-render-implementation-review-2026-05-10.md) | `recommendations/load-render-implementation-review-2026-05-10.md` | review | 2026-05-10 |
|
||||
| [Agent-flow: open issues & decision log](./runbooks/agent-flow-pitfalls.md) | `runbooks/agent-flow-pitfalls.md` | active | 2026-05-03 |
|
||||
| [Auto-PR flow: van story-DONE naar gemergde PR](./runbooks/auto-pr-flow.md) | `runbooks/auto-pr-flow.md` | active | 2026-05-06 |
|
||||
| [Branch, PR & Commit Strategy](./runbooks/branch-and-commit.md) | `runbooks/branch-and-commit.md` | active | 2026-05-03 |
|
||||
|
|
|
|||
201
docs/plans/load-render-improvement-plan-2026-05-10.md
Normal file
201
docs/plans/load-render-improvement-plan-2026-05-10.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -18,6 +18,7 @@ const eslintConfig = defineConfig([
|
|||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
".claude/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ export interface JobsPerDayResult {
|
|||
kpi: ThroughputKpi
|
||||
}
|
||||
|
||||
const STATUSES = ['queued', 'claimed', 'running', 'done', 'failed', 'cancelled', 'skipped'] as const
|
||||
|
||||
type RawDayRow = { day: Date; status: string; count: bigint }
|
||||
type RawKpiRow = { today_count: bigint; done_7d: bigint; terminal_7d: bigint; avg_seconds: number | null }
|
||||
|
||||
|
|
|
|||
|
|
@ -7,11 +7,9 @@
|
|||
// productId niet null is; sluit de stream als productId null wordt.
|
||||
// - Reconnect met exponential backoff (1s → 30s, reset bij ready).
|
||||
// - PBI-74: stream blijft open op tab hidden (geen close meer). Bij
|
||||
// hidden→visible en bij window 'online' triggeren we router.refresh()
|
||||
// zodat gemiste events alsnog binnenkomen via een verse server-render
|
||||
// (re-fetcht initialTasks → initTasks reset solo-store). Postgres NOTIFY
|
||||
// heeft geen replay, dus zonder deze resync zouden hidden-tab events
|
||||
// permanent verloren zijn — zelfde fix als Story 5 voor backlog-realtime.
|
||||
// hidden→visible en bij window 'online' triggeren we een directe
|
||||
// workspace-store resync. Postgres NOTIFY heeft geen replay, dus zonder deze
|
||||
// resync zouden hidden-tab events permanent verloren zijn.
|
||||
// - Cleanup op unmount.
|
||||
// - Connection-status (status, showConnectingIndicator) wordt naar de
|
||||
// solo-store geschreven; UI-componenten lezen daar uit.
|
||||
|
|
@ -24,7 +22,6 @@
|
|||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useSoloStore } from '@/stores/solo-store'
|
||||
import type { ClaudeJobEvent, JobState, RealtimeEvent, RealtimeStatus } from '@/stores/solo-store'
|
||||
|
||||
|
|
@ -33,7 +30,6 @@ const BACKOFF_MAX_MS = 30_000
|
|||
const CONNECTING_INDICATOR_DELAY_MS = 4_000
|
||||
|
||||
export function useSoloRealtime(productId: string | null) {
|
||||
const router = useRouter()
|
||||
const sourceRef = useRef<EventSource | null>(null)
|
||||
const backoffRef = useRef<number>(BACKOFF_START_MS)
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
|
@ -97,10 +93,9 @@ export function useSoloRealtime(productId: string | null) {
|
|||
backoffRef.current = BACKOFF_START_MS
|
||||
scheduleIndicator('open')
|
||||
readyCountRef.current += 1
|
||||
// PBI-74: latere ready = post-reconnect → resync via router.refresh()
|
||||
// zodat gemiste tasks-state via re-render initial-prop binnenkomt.
|
||||
// PBI-74: latere ready = post-reconnect → directe workspace-resync.
|
||||
if (readyCountRef.current > 1) {
|
||||
router.refresh()
|
||||
void useSoloStore.getState().resyncActiveScopes('reconnect')
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -189,19 +184,19 @@ export function useSoloRealtime(productId: string | null) {
|
|||
|
||||
// PBI-74: stream blijft open op hidden. Reconnect alleen als de stream
|
||||
// door netwerkfout/server-close weg is en de tab visible is. Bij iedere
|
||||
// visible-overgang triggeren we router.refresh() — gemiste events tijdens
|
||||
// throttling/freeze worden via een verse server-render alsnog opgepakt.
|
||||
// visible-overgang triggeren we een store-resync — gemiste events tijdens
|
||||
// throttling/freeze worden via de solo-workspace route alsnog opgepakt.
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
if (sourceRef.current === null) {
|
||||
backoffRef.current = BACKOFF_START_MS
|
||||
connect()
|
||||
}
|
||||
router.refresh()
|
||||
void useSoloStore.getState().resyncActiveScopes('visible')
|
||||
}
|
||||
|
||||
const onOnline = () => {
|
||||
router.refresh()
|
||||
void useSoloStore.getState().resyncActiveScopes('reconnect')
|
||||
}
|
||||
|
||||
connect()
|
||||
|
|
@ -215,5 +210,5 @@ export function useSoloRealtime(productId: string | null) {
|
|||
close()
|
||||
readyCountRef.current = 0
|
||||
}
|
||||
}, [productId, router])
|
||||
}, [productId])
|
||||
}
|
||||
|
|
|
|||
107
lib/solo-workspace-server.ts
Normal file
107
lib/solo-workspace-server.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,14 @@ import {
|
|||
writeStoryHint,
|
||||
writeTaskHint,
|
||||
} from './restore'
|
||||
import {
|
||||
normalizeBacklogStory,
|
||||
normalizeBacklogTask,
|
||||
normalizeProductBacklogSnapshot,
|
||||
normalizePbiStatusForStore,
|
||||
normalizeStoryStatusForStore,
|
||||
normalizeTaskStatusForStore,
|
||||
} from '@/stores/workspace-status-adapter'
|
||||
|
||||
interface ContextSlice {
|
||||
activeProduct: ActiveProduct | null
|
||||
|
|
@ -70,7 +78,10 @@ interface State {
|
|||
interface Actions {
|
||||
hydrateSnapshot(snapshot: ProductBacklogSnapshot): void
|
||||
|
||||
setActiveProduct(product: ActiveProduct | null): void
|
||||
setActiveProduct(
|
||||
product: ActiveProduct | null,
|
||||
options?: { load?: boolean; preserveSelection?: boolean },
|
||||
): void
|
||||
setActivePbi(pbiId: string | null): void
|
||||
setActiveStory(storyId: string | null): void
|
||||
setActiveTask(taskId: string | null): void
|
||||
|
|
@ -174,7 +185,8 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
|
|||
immer((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
hydrateSnapshot(snapshot) {
|
||||
hydrateSnapshot(inputSnapshot) {
|
||||
const snapshot = normalizeProductBacklogSnapshot(inputSnapshot)
|
||||
set((s) => {
|
||||
if (snapshot.product) s.context.activeProduct = snapshot.product
|
||||
|
||||
|
|
@ -214,15 +226,18 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
|
|||
})
|
||||
},
|
||||
|
||||
setActiveProduct(product) {
|
||||
setActiveProduct(product, options) {
|
||||
const requestId = newRequestId()
|
||||
const productChanged = get().context.activeProduct?.id !== product?.id
|
||||
const shouldResetSelection = productChanged || !options?.preserveSelection
|
||||
|
||||
set((s) => {
|
||||
s.context.activeProduct = product
|
||||
if (shouldResetSelection) {
|
||||
s.context.activePbiId = null
|
||||
s.context.activeStoryId = null
|
||||
s.context.activeTaskId = null
|
||||
}
|
||||
s.loading.activeRequestId = requestId
|
||||
|
||||
if (productChanged) {
|
||||
|
|
@ -243,7 +258,7 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
|
|||
// selectie kan herstellen. T-857: restore-flow start na ensureProductLoaded.
|
||||
writeProductHint(product?.id ?? null)
|
||||
|
||||
if (product) {
|
||||
if (product && options?.load !== false) {
|
||||
const productId = product.id
|
||||
void (async () => {
|
||||
await get().ensureProductLoaded(productId, requestId)
|
||||
|
|
@ -358,11 +373,12 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
|
|||
)
|
||||
if (requestId && get().loading.activeRequestId !== requestId) return
|
||||
if (!Array.isArray(stories)) return
|
||||
const normalizedStories = stories.map(normalizeBacklogStory)
|
||||
set((s) => {
|
||||
for (const story of stories) {
|
||||
for (const story of normalizedStories) {
|
||||
s.entities.storiesById[story.id] = story
|
||||
}
|
||||
s.relations.storyIdsByPbi[pbiId] = [...stories]
|
||||
s.relations.storyIdsByPbi[pbiId] = [...normalizedStories]
|
||||
.sort(compareStory)
|
||||
.map((st) => st.id)
|
||||
s.loading.loadedPbiIds[pbiId] = true
|
||||
|
|
@ -375,8 +391,9 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
|
|||
)
|
||||
if (requestId && get().loading.activeRequestId !== requestId) return
|
||||
if (!Array.isArray(tasks)) return
|
||||
const normalizedTasks = tasks.map(normalizeBacklogTask)
|
||||
set((s) => {
|
||||
for (const task of tasks) {
|
||||
for (const task of normalizedTasks) {
|
||||
const existing = s.entities.tasksById[task.id]
|
||||
if (existing && isDetail(existing)) {
|
||||
s.entities.tasksById[task.id] = { ...existing, ...task }
|
||||
|
|
@ -384,7 +401,7 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
|
|||
s.entities.tasksById[task.id] = task
|
||||
}
|
||||
}
|
||||
s.relations.taskIdsByStory[storyId] = [...tasks]
|
||||
s.relations.taskIdsByStory[storyId] = [...normalizedTasks]
|
||||
.sort(compareTask)
|
||||
.map((t) => t.id)
|
||||
s.loading.loadedStoryIds[storyId] = true
|
||||
|
|
@ -397,8 +414,9 @@ export const useProductWorkspaceStore = create<ProductWorkspaceStore>()(
|
|||
)
|
||||
if (requestId && get().loading.activeRequestId !== requestId) return
|
||||
if (!detail || typeof detail !== 'object') return
|
||||
const normalizedDetail = normalizeBacklogTask(detail)
|
||||
set((s) => {
|
||||
s.entities.tasksById[taskId] = { ...detail, _detail: true }
|
||||
s.entities.tasksById[taskId] = { ...normalizedDetail, _detail: true }
|
||||
s.loading.loadedTaskIds[taskId] = true
|
||||
})
|
||||
},
|
||||
|
|
@ -772,20 +790,65 @@ function sanitizePbiPayload(p: Record<string, unknown>): Partial<BacklogPbi> {
|
|||
const { entity: _e, op: _o, ...rest } = p
|
||||
void _e
|
||||
void _o
|
||||
if (typeof rest.status === 'string') {
|
||||
rest.status = normalizePbiStatusForStore(rest.status)
|
||||
}
|
||||
return rest as Partial<BacklogPbi>
|
||||
}
|
||||
|
||||
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 _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>
|
||||
}
|
||||
|
||||
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 _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>
|
||||
}
|
||||
|
||||
|
|
@ -801,20 +864,24 @@ function coercePbiPayload(id: string, p: Record<string, unknown>): BacklogPbi {
|
|||
p.created_at instanceof Date
|
||||
? p.created_at
|
||||
: new Date(String(p.created_at ?? Date.now())),
|
||||
status: (p.status as BacklogPbi['status']) ?? 'ready',
|
||||
status: normalizePbiStatusForStore(String(p.status ?? 'ready')),
|
||||
}
|
||||
}
|
||||
|
||||
function coerceStoryPayload(id: string, p: Record<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 {
|
||||
id,
|
||||
code: (p.code as string | null) ?? null,
|
||||
title: String(p.title ?? ''),
|
||||
code: (code as string | null) ?? null,
|
||||
title: String(title),
|
||||
description: (p.description as string | null | undefined) ?? null,
|
||||
acceptance_criteria: (p.acceptance_criteria as string | null | undefined) ?? null,
|
||||
priority: Number(p.priority ?? 4),
|
||||
sort_order: Number(p.sort_order ?? 0),
|
||||
status: String(p.status ?? 'open'),
|
||||
sort_order: Number(sortOrder),
|
||||
status: normalizeStoryStatusForStore(String(status)),
|
||||
pbi_id: String(p.pbi_id ?? ''),
|
||||
sprint_id: (p.sprint_id as string | null | undefined) ?? null,
|
||||
created_at:
|
||||
|
|
@ -825,13 +892,16 @@ function coerceStoryPayload(id: string, p: Record<string, unknown>): BacklogStor
|
|||
}
|
||||
|
||||
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 {
|
||||
id,
|
||||
title: String(p.title ?? ''),
|
||||
title: String(title),
|
||||
description: (p.description as string | null | undefined) ?? null,
|
||||
priority: Number(p.priority ?? 4),
|
||||
sort_order: Number(p.sort_order ?? 0),
|
||||
status: String(p.status ?? 'todo'),
|
||||
sort_order: Number(sortOrder),
|
||||
status: normalizeTaskStatusForStore(String(status)),
|
||||
story_id: String(p.story_id ?? ''),
|
||||
created_at:
|
||||
p.created_at instanceof Date
|
||||
|
|
|
|||
|
|
@ -1,283 +1,8 @@
|
|||
import { create } from 'zustand'
|
||||
import type { SoloTask } from '@/components/solo/solo-board'
|
||||
import type { ClaudeJobStatusApi } from '@/lib/job-status'
|
||||
|
||||
type TaskStatus = SoloTask['status']
|
||||
|
||||
export type VerifyResultApi = 'aligned' | 'partial' | 'empty' | 'divergent'
|
||||
|
||||
export interface JobState {
|
||||
job_id: string
|
||||
task_id: string
|
||||
status: ClaudeJobStatusApi
|
||||
branch?: string
|
||||
pushed_at?: string | null
|
||||
pr_url?: string | null
|
||||
verify_result?: VerifyResultApi | null
|
||||
summary?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type ClaudeJobEvent =
|
||||
| { type: 'claude_job_enqueued'; job_id: string; task_id: string; user_id: string; product_id: string; status: 'queued' }
|
||||
| { type: 'claude_job_status'; job_id: string; task_id: string; user_id: string; product_id: string; status: ClaudeJobStatusApi; branch?: string; pushed_at?: string; pr_url?: string; verify_result?: VerifyResultApi; summary?: string; error?: string }
|
||||
|
||||
// Payload-shape gepubliceerd door de Postgres-trigger via pg_notify (ST-801
|
||||
// + ST-804 prereq). Komt het Solo Paneel binnen via de SSE-stream uit
|
||||
// /api/realtime/solo (ST-802).
|
||||
export interface RealtimeEvent {
|
||||
op: 'I' | 'U' | 'D'
|
||||
entity: 'task' | 'story'
|
||||
id: string
|
||||
story_id?: string
|
||||
product_id: string
|
||||
sprint_id: string | null
|
||||
assignee_id: string | null
|
||||
// Task-specifieke velden (alleen aanwezig als entity === 'task')
|
||||
task_status?: TaskStatus
|
||||
task_sort_order?: number
|
||||
task_title?: string
|
||||
// Story-specifieke velden (alleen aanwezig als entity === 'story')
|
||||
story_status?: 'OPEN' | 'IN_SPRINT' | 'DONE'
|
||||
story_sort_order?: number
|
||||
story_title?: string
|
||||
story_code?: string | null
|
||||
// Op UPDATE: lijst van kolommen die zijn veranderd
|
||||
changed_fields?: string[]
|
||||
}
|
||||
|
||||
export type RealtimeStatus = 'connecting' | 'open' | 'disconnected'
|
||||
|
||||
interface SoloStore {
|
||||
tasks: Record<string, SoloTask>
|
||||
/** Task-ids die op dit moment een eigen optimistic write in de lucht hebben.
|
||||
* Realtime echos voor deze ids worden onderdrukt zodat de eigen update niet
|
||||
* twee keer toegepast wordt of door een latere echo overschreven. */
|
||||
pendingOps: Set<string>
|
||||
|
||||
/** Realtime-connection state, beheerd door useSoloRealtime in de
|
||||
* (app)-layout. Hier in de store omdat de UI-indicator in SoloBoard zit en
|
||||
* de hook niet direct in dezelfde subtree draait. */
|
||||
realtimeStatus: RealtimeStatus
|
||||
showConnectingIndicator: boolean
|
||||
|
||||
claudeJobsByTaskId: Record<string, JobState>
|
||||
connectedWorkers: number
|
||||
|
||||
// M13: laatste quota-rapport van een actieve worker. null = geen
|
||||
// worker actief of nog geen heartbeat met quota ontvangen.
|
||||
workerQuotaPct: number | null
|
||||
workerQuotaCheckAt: string | null
|
||||
|
||||
initTasks: (tasks: SoloTask[]) => void
|
||||
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
|
||||
rollback: (taskId: string, prevStatus: TaskStatus) => void
|
||||
updatePlan: (taskId: string, plan: string | null) => void
|
||||
updateVerifyOnly: (taskId: string, value: boolean) => void
|
||||
updateVerifyRequired: (taskId: string, value: 'ALIGNED' | 'ALIGNED_OR_PARTIAL' | 'ANY') => void
|
||||
|
||||
markPending: (taskId: string) => void
|
||||
clearPending: (taskId: string) => void
|
||||
|
||||
setRealtimeStatus: (status: RealtimeStatus, showConnectingIndicator: boolean) => void
|
||||
|
||||
initJobs: (jobs: JobState[]) => void
|
||||
handleJobEvent: (event: ClaudeJobEvent) => void
|
||||
|
||||
setWorkers: (count: number) => void
|
||||
incrementWorkers: () => void
|
||||
decrementWorkers: () => void
|
||||
setWorkerQuota: (pct: number, checkAt: string) => void
|
||||
|
||||
handleRealtimeEvent: (event: RealtimeEvent) => void
|
||||
}
|
||||
|
||||
export const useSoloStore = create<SoloStore>((set, get) => ({
|
||||
tasks: {},
|
||||
pendingOps: new Set<string>(),
|
||||
realtimeStatus: 'connecting',
|
||||
showConnectingIndicator: false,
|
||||
claudeJobsByTaskId: {},
|
||||
connectedWorkers: 0,
|
||||
workerQuotaPct: null,
|
||||
workerQuotaCheckAt: null,
|
||||
|
||||
initTasks: (tasks) =>
|
||||
set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }),
|
||||
|
||||
optimisticMove: (taskId, toStatus) => {
|
||||
const prev = get().tasks[taskId]?.status ?? null
|
||||
if (!prev) return null
|
||||
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], status: toStatus } } }))
|
||||
return prev
|
||||
},
|
||||
|
||||
rollback: (taskId, prevStatus) =>
|
||||
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], status: prevStatus } } })),
|
||||
|
||||
updatePlan: (taskId, plan) =>
|
||||
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], implementation_plan: plan } } })),
|
||||
|
||||
updateVerifyOnly: (taskId, value) =>
|
||||
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_only: value } } })),
|
||||
|
||||
updateVerifyRequired: (taskId, value) =>
|
||||
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], verify_required: value } } })),
|
||||
|
||||
markPending: (taskId) =>
|
||||
set((s) => {
|
||||
if (s.pendingOps.has(taskId)) return s
|
||||
const next = new Set(s.pendingOps)
|
||||
next.add(taskId)
|
||||
return { pendingOps: next }
|
||||
}),
|
||||
|
||||
clearPending: (taskId) =>
|
||||
set((s) => {
|
||||
if (!s.pendingOps.has(taskId)) return s
|
||||
const next = new Set(s.pendingOps)
|
||||
next.delete(taskId)
|
||||
return { pendingOps: next }
|
||||
}),
|
||||
|
||||
setRealtimeStatus: (status, showConnectingIndicator) =>
|
||||
set((s) => {
|
||||
if (s.realtimeStatus === status && s.showConnectingIndicator === showConnectingIndicator) {
|
||||
return s
|
||||
}
|
||||
return { realtimeStatus: status, showConnectingIndicator }
|
||||
}),
|
||||
|
||||
initJobs: (jobs) =>
|
||||
set({ claudeJobsByTaskId: Object.fromEntries(jobs.map(j => [j.task_id, j])) }),
|
||||
|
||||
setWorkers: (count) => set({ connectedWorkers: Math.max(0, count) }),
|
||||
incrementWorkers: () => set(s => ({ connectedWorkers: s.connectedWorkers + 1 })),
|
||||
decrementWorkers: () =>
|
||||
set((s) => ({
|
||||
connectedWorkers: Math.max(0, s.connectedWorkers - 1),
|
||||
// Reset quota-state als alle workers weg zijn — pct van een vertrokken
|
||||
// worker is niet meer actueel.
|
||||
workerQuotaPct: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaPct,
|
||||
workerQuotaCheckAt: s.connectedWorkers - 1 <= 0 ? null : s.workerQuotaCheckAt,
|
||||
})),
|
||||
setWorkerQuota: (pct, checkAt) => set({ workerQuotaPct: pct, workerQuotaCheckAt: checkAt }),
|
||||
|
||||
handleJobEvent: (event) => {
|
||||
const { job_id, task_id } = event
|
||||
if (event.type === 'claude_job_enqueued') {
|
||||
set((s) => ({
|
||||
claudeJobsByTaskId: {
|
||||
...s.claudeJobsByTaskId,
|
||||
[task_id]: { job_id, task_id, status: 'queued' },
|
||||
},
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (event.type === 'claude_job_status') {
|
||||
const { status, branch, pushed_at, pr_url, verify_result, summary, error } = event
|
||||
if (status === 'cancelled') {
|
||||
set((s) => {
|
||||
const next = { ...s.claudeJobsByTaskId }
|
||||
delete next[task_id]
|
||||
return { claudeJobsByTaskId: next }
|
||||
})
|
||||
return
|
||||
}
|
||||
set((s) => ({
|
||||
claudeJobsByTaskId: {
|
||||
...s.claudeJobsByTaskId,
|
||||
[task_id]: { job_id, task_id, status, branch, pushed_at, pr_url, verify_result, summary, error },
|
||||
},
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
handleRealtimeEvent: (event) => {
|
||||
if (event.entity === 'task') {
|
||||
const { id, op } = event
|
||||
|
||||
if (op === 'D') {
|
||||
set((s) => {
|
||||
if (!(id in s.tasks)) return s
|
||||
const next = { ...s.tasks }
|
||||
delete next[id]
|
||||
return { tasks: next }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// INSERT en UPDATE: alleen bestaande taken bijwerken. Nieuwe taken
|
||||
// zonder story-context (story_title, story_code) renderen we niet
|
||||
// — gebruiker ziet ze pas na een refresh. Acceptabel voor v1.
|
||||
const existing = get().tasks[id]
|
||||
if (!existing) return
|
||||
|
||||
if (get().pendingOps.has(id)) {
|
||||
// Echo van een eigen optimistic move — laat de optimistic-state staan
|
||||
return
|
||||
}
|
||||
|
||||
const updates: Partial<SoloTask> = {}
|
||||
if (event.task_status !== undefined && event.task_status !== existing.status) {
|
||||
updates.status = event.task_status
|
||||
}
|
||||
if (
|
||||
event.task_sort_order !== undefined &&
|
||||
event.task_sort_order !== existing.sort_order
|
||||
) {
|
||||
updates.sort_order = event.task_sort_order
|
||||
}
|
||||
if (event.task_title !== undefined && event.task_title !== existing.title) {
|
||||
updates.title = event.task_title
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) return
|
||||
set((s) => ({ tasks: { ...s.tasks, [id]: { ...s.tasks[id], ...updates } } }))
|
||||
return
|
||||
}
|
||||
|
||||
if (event.entity === 'story') {
|
||||
const { id, op } = event
|
||||
|
||||
if (op === 'D') {
|
||||
// Story-cascade pakt tasks ook in de DB; verwijder de bijbehorende
|
||||
// SoloTask-records uit de store.
|
||||
set((s) => {
|
||||
const next: Record<string, SoloTask> = {}
|
||||
for (const [taskId, task] of Object.entries(s.tasks)) {
|
||||
if (task.story_id !== id) next[taskId] = task
|
||||
}
|
||||
return { tasks: next }
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const tasks = get().tasks
|
||||
const affectedIds = Object.entries(tasks)
|
||||
.filter(([, t]) => t.story_id === id)
|
||||
.map(([taskId]) => taskId)
|
||||
|
||||
if (affectedIds.length === 0) return
|
||||
|
||||
const newTitle = event.story_title
|
||||
const newCode = event.story_code ?? null
|
||||
|
||||
set((s) => {
|
||||
const next = { ...s.tasks }
|
||||
for (const taskId of affectedIds) {
|
||||
const t = next[taskId]
|
||||
const titleChanged = newTitle !== undefined && t.story_title !== newTitle
|
||||
const codeChanged = newCode !== t.story_code
|
||||
if (!titleChanged && !codeChanged) continue
|
||||
next[taskId] = {
|
||||
...t,
|
||||
...(titleChanged && newTitle !== undefined && { story_title: newTitle }),
|
||||
...(codeChanged && { story_code: newCode }),
|
||||
}
|
||||
}
|
||||
return { tasks: next }
|
||||
})
|
||||
}
|
||||
},
|
||||
}))
|
||||
export { useSoloWorkspaceStore as useSoloStore } from '@/stores/solo-workspace/store'
|
||||
export type {
|
||||
ClaudeJobEvent,
|
||||
JobState,
|
||||
RealtimeEvent,
|
||||
RealtimeStatus,
|
||||
VerifyResultApi,
|
||||
} from '@/stores/solo-workspace/types'
|
||||
|
|
|
|||
39
stores/solo-workspace/selectors.ts
Normal file
39
stores/solo-workspace/selectors.ts
Normal 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
|
||||
}
|
||||
}
|
||||
619
stores/solo-workspace/store.ts
Normal file
619
stores/solo-workspace/store.ts
Normal 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)
|
||||
},
|
||||
}))
|
||||
123
stores/solo-workspace/types.ts
Normal file
123
stores/solo-workspace/types.ts
Normal 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'
|
||||
|
|
@ -21,6 +21,12 @@ import {
|
|||
writeStoryHint,
|
||||
writeTaskHint,
|
||||
} from './restore'
|
||||
import {
|
||||
normalizeSprintTask,
|
||||
normalizeSprintWorkspaceSnapshot,
|
||||
normalizeStoryStatusForStore,
|
||||
normalizeTaskStatusForStore,
|
||||
} from '@/stores/workspace-status-adapter'
|
||||
|
||||
interface ContextSlice {
|
||||
activeProduct: ActiveProductRef | null
|
||||
|
|
@ -180,7 +186,8 @@ export const useSprintWorkspaceStore = create<SprintWorkspaceStore>()(
|
|||
immer((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
hydrateSnapshot(snapshot) {
|
||||
hydrateSnapshot(inputSnapshot) {
|
||||
const snapshot = normalizeSprintWorkspaceSnapshot(inputSnapshot)
|
||||
set((s) => {
|
||||
if (snapshot.product) s.context.activeProduct = snapshot.product
|
||||
|
||||
|
|
@ -387,8 +394,9 @@ export const useSprintWorkspaceStore = create<SprintWorkspaceStore>()(
|
|||
)
|
||||
if (requestId && get().loading.activeRequestId !== requestId) return
|
||||
if (!Array.isArray(tasks)) return
|
||||
const normalizedTasks = tasks.map(normalizeSprintTask)
|
||||
set((s) => {
|
||||
for (const task of tasks) {
|
||||
for (const task of normalizedTasks) {
|
||||
const existing = s.entities.tasksById[task.id]
|
||||
if (existing && isDetail(existing)) {
|
||||
s.entities.tasksById[task.id] = { ...existing, ...task }
|
||||
|
|
@ -396,7 +404,7 @@ export const useSprintWorkspaceStore = create<SprintWorkspaceStore>()(
|
|||
s.entities.tasksById[task.id] = task
|
||||
}
|
||||
}
|
||||
s.relations.taskIdsByStory[storyId] = [...tasks]
|
||||
s.relations.taskIdsByStory[storyId] = [...normalizedTasks]
|
||||
.sort(compareTask)
|
||||
.map((t) => t.id)
|
||||
s.loading.loadedStoryIds[storyId] = true
|
||||
|
|
@ -409,8 +417,9 @@ export const useSprintWorkspaceStore = create<SprintWorkspaceStore>()(
|
|||
)
|
||||
if (requestId && get().loading.activeRequestId !== requestId) return
|
||||
if (!detail || typeof detail !== 'object') return
|
||||
const normalizedDetail = normalizeSprintTask(detail)
|
||||
set((s) => {
|
||||
s.entities.tasksById[taskId] = { ...detail, _detail: true }
|
||||
s.entities.tasksById[taskId] = { ...normalizedDetail, _detail: true }
|
||||
s.loading.loadedTaskIds[taskId] = true
|
||||
})
|
||||
},
|
||||
|
|
@ -839,16 +848,58 @@ function sanitizeSprintPayload(p: Record<string, unknown>): Partial<SprintWorksp
|
|||
}
|
||||
|
||||
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 _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>
|
||||
}
|
||||
|
||||
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 _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>
|
||||
}
|
||||
|
||||
|
|
@ -881,15 +932,19 @@ function coerceStoryPayload(
|
|||
id: string,
|
||||
p: Record<string, unknown>,
|
||||
): SprintWorkspaceStory {
|
||||
const status = p.status ?? p.story_status ?? 'OPEN'
|
||||
const sortOrder = p.sort_order ?? p.story_sort_order ?? 0
|
||||
const title = p.title ?? p.story_title ?? ''
|
||||
const code = p.code ?? p.story_code ?? null
|
||||
return {
|
||||
id,
|
||||
code: (p.code as string | null) ?? null,
|
||||
title: String(p.title ?? ''),
|
||||
code: (code as string | null) ?? null,
|
||||
title: String(title),
|
||||
description: (p.description as string | null | undefined) ?? null,
|
||||
acceptance_criteria: (p.acceptance_criteria as string | null | undefined) ?? null,
|
||||
priority: Number(p.priority ?? 4),
|
||||
sort_order: Number(p.sort_order ?? 0),
|
||||
status: String(p.status ?? 'open'),
|
||||
sort_order: Number(sortOrder),
|
||||
status: normalizeStoryStatusForStore(String(status)),
|
||||
pbi_id: String(p.pbi_id ?? ''),
|
||||
sprint_id: (p.sprint_id as string | null | undefined) ?? null,
|
||||
created_at:
|
||||
|
|
@ -900,14 +955,17 @@ function coerceStoryPayload(
|
|||
}
|
||||
|
||||
function coerceTaskPayload(id: string, p: Record<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 {
|
||||
id,
|
||||
code: (p.code as string | null) ?? null,
|
||||
title: String(p.title ?? ''),
|
||||
title: String(title),
|
||||
description: (p.description as string | null | undefined) ?? null,
|
||||
priority: Number(p.priority ?? 4),
|
||||
sort_order: Number(p.sort_order ?? 0),
|
||||
status: String(p.status ?? 'todo'),
|
||||
sort_order: Number(sortOrder),
|
||||
status: normalizeTaskStatusForStore(String(status)),
|
||||
story_id: String(p.story_id ?? ''),
|
||||
sprint_id: (p.sprint_id as string | null | undefined) ?? null,
|
||||
created_at:
|
||||
|
|
|
|||
88
stores/workspace-status-adapter.ts
Normal file
88
stores/workspace-status-adapter.ts
Normal 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
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
import { configDefaults, defineConfig } from 'vitest/config'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
|
|
@ -6,6 +6,7 @@ export default defineConfig({
|
|||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['tests/setup.ts'],
|
||||
exclude: [...configDefaults.exclude, '**/.claude/**'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue