Symptoom op feat/ST-801-realtime-triggers initial implementation: elke task-update sloot de open SSE-stream af en triggerde een herverbinding met backoff. In de tussentijd gemiste events. Oorzaak: Server Actions in App Router doen een impliciete route-tree refresh die client components remount; daarmee killt React de useEffect die de EventSource beheert. Fix in twee delen: 1. Hef de realtime-hook op naar de (app)-layout via een nieuwe `SoloRealtimeBridge`-component. Layouts overleven Server- Action-refreshes beter dan pages, en de bridge leest het product-id uit de URL via usePathname. Connection-status (status, showConnectingIndicator) gaat naar de solo-store zodat SoloBoard 'm uit een gedeelde plek kan lezen. 2. Vervang updateTaskStatusAction en updateTaskPlanAction in de Solo-componenten door fetch naar de bestaande Route Handler `PATCH /api/tasks/[id]`. Route Handlers triggeren geen page-refresh, dus de SSE-stream blijft staan. lib/api-auth.ts accepteert nu naast Bearer-tokens ook iron-session cookies zodat browser-fetches zonder token werken. Bijkomend: actions/tasks.ts laat /solo bewust niet meer revalideren (wordt nu via realtime gedekt). Sprint/planning blijft wel revalidaten — geen realtime daar. Toegevoegd: - components/solo/realtime-bridge.tsx — mount in (app) layout - scripts/realtime-mutate.ts — handige test-helper voor externe mutaties (alsof MCP/REST schrijft) tijdens acceptance Debug-logs in app/api/realtime/solo/route.ts staan nog aan voor ST-806 acceptance; worden later gestript. Bekend issue: Chrome op localhost (HTTP/1.1) cycle't EventSource om de paar seconden vanwege de 6-connectie-limiet en retry- heuristiek. Safari werkt stabiel. Productie op Vercel (HTTP/2 multiplexing) zou beide browsers stabiel moeten houden — Vercel preview test is volgende stap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
191 lines
6.1 KiB
TypeScript
191 lines
6.1 KiB
TypeScript
import { create } from 'zustand'
|
|
import type { SoloTask } from '@/components/solo/solo-board'
|
|
|
|
type TaskStatus = SoloTask['status']
|
|
|
|
// 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
|
|
|
|
initTasks: (tasks: SoloTask[]) => void
|
|
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
|
|
rollback: (taskId: string, prevStatus: TaskStatus) => void
|
|
updatePlan: (taskId: string, plan: string | null) => void
|
|
|
|
markPending: (taskId: string) => void
|
|
clearPending: (taskId: string) => void
|
|
|
|
setRealtimeStatus: (status: RealtimeStatus, showConnectingIndicator: boolean) => void
|
|
|
|
handleRealtimeEvent: (event: RealtimeEvent) => void
|
|
}
|
|
|
|
export const useSoloStore = create<SoloStore>((set, get) => ({
|
|
tasks: {},
|
|
pendingOps: new Set<string>(),
|
|
realtimeStatus: 'connecting',
|
|
showConnectingIndicator: false,
|
|
|
|
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 } } })),
|
|
|
|
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 }
|
|
}),
|
|
|
|
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 }
|
|
})
|
|
}
|
|
},
|
|
}))
|