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
|
|
@ -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}</>
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue