feat(ST-804): solo-store realtime dispatch + pendingOps

Wire de SSE-events uit /api/realtime/solo door naar de Zustand-store
zodat het Solo Paneel zonder refresh meebeweegt met DB-mutaties uit
welke bron dan ook (web, REST, MCP).

Migratie 20260427000216_extend_realtime_payload: voegt new-state
velden aan de pg_notify-payload toe (task_status, task_sort_order,
task_title, story_status, story_sort_order, story_title, story_code)
zodat de client geen extra fetch nodig heeft per event.

Store-uitbreiding (stores/solo-store.ts):
- pendingOps: Set<task-id> die optimistic-writes markeert; realtime
  echos voor die ids worden onderdrukt zodat eigen UI-mutaties niet
  twee keer toegepast worden of door een latere echo overschreven
- handleRealtimeEvent: dispatch op entity + op
  - task UPDATE/INSERT: bestaande tasks krijgen status/title/sort_order
    bijgewerkt; onbekende tasks worden genegeerd (story-context
    ontbreekt — gebruiker ziet ze pas na refresh)
  - task DELETE: verwijdert uit store
  - story UPDATE: werkt story_title/story_code bij op alle child-tasks
    in de store
  - story DELETE: verwijdert alle child-tasks (cascade reflectie)

Unit-test: 7 scenario's (status update, pendingOps echo-suppression,
DELETE, story-rename cascade, story-delete cascade, unknown task
no-op).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-27 02:11:08 +02:00
parent 1e548da9bf
commit 562735b98b
3 changed files with 345 additions and 6 deletions

View file

@ -0,0 +1,109 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useSoloStore } from '@/stores/solo-store'
import type { RealtimeEvent } from '@/stores/solo-store'
import type { SoloTask } from '@/components/solo/solo-board'
const baseTask = (id: string, overrides: Partial<SoloTask> = {}): SoloTask => ({
id,
title: `Task ${id}`,
description: null,
implementation_plan: null,
priority: 1,
sort_order: 1,
status: 'TO_DO',
story_id: 'story-1',
story_code: 'ST-100',
story_title: 'Original Story',
task_code: 'ST-100.1',
...overrides,
})
const taskEvent = (overrides: Partial<RealtimeEvent>): RealtimeEvent => ({
op: 'U',
entity: 'task',
id: 'task-1',
story_id: 'story-1',
product_id: 'prod-1',
sprint_id: 'sprint-1',
assignee_id: 'user-1',
...overrides,
})
const storyEvent = (overrides: Partial<RealtimeEvent>): RealtimeEvent => ({
op: 'U',
entity: 'story',
id: 'story-1',
product_id: 'prod-1',
sprint_id: 'sprint-1',
assignee_id: 'user-1',
...overrides,
})
describe('solo-store realtime', () => {
beforeEach(() => {
useSoloStore.setState({ tasks: {}, pendingOps: new Set() })
})
it('applies a task status update', () => {
useSoloStore.getState().initTasks([baseTask('task-1', { status: 'TO_DO' })])
useSoloStore.getState().handleRealtimeEvent(taskEvent({ task_status: 'IN_PROGRESS' }))
expect(useSoloStore.getState().tasks['task-1'].status).toBe('IN_PROGRESS')
})
it('skips a task update when pendingOps has the id', () => {
useSoloStore.getState().initTasks([baseTask('task-1', { status: 'TO_DO' })])
useSoloStore.getState().markPending('task-1')
useSoloStore.getState().handleRealtimeEvent(taskEvent({ task_status: 'DONE' }))
expect(useSoloStore.getState().tasks['task-1'].status).toBe('TO_DO')
})
it('applies the update again once pendingOps is cleared', () => {
useSoloStore.getState().initTasks([baseTask('task-1', { status: 'TO_DO' })])
useSoloStore.getState().markPending('task-1')
useSoloStore.getState().handleRealtimeEvent(taskEvent({ task_status: 'DONE' }))
expect(useSoloStore.getState().tasks['task-1'].status).toBe('TO_DO')
useSoloStore.getState().clearPending('task-1')
useSoloStore.getState().handleRealtimeEvent(taskEvent({ task_status: 'DONE' }))
expect(useSoloStore.getState().tasks['task-1'].status).toBe('DONE')
})
it('removes a task on D op', () => {
useSoloStore.getState().initTasks([baseTask('task-1'), baseTask('task-2')])
useSoloStore.getState().handleRealtimeEvent(taskEvent({ id: 'task-1', op: 'D' }))
expect(useSoloStore.getState().tasks['task-1']).toBeUndefined()
expect(useSoloStore.getState().tasks['task-2']).toBeDefined()
})
it('ignores task INSERT/UPDATE for tasks not in the store', () => {
useSoloStore.getState().initTasks([baseTask('task-1')])
useSoloStore.getState().handleRealtimeEvent(taskEvent({ id: 'task-other', task_status: 'DONE' }))
expect(Object.keys(useSoloStore.getState().tasks)).toEqual(['task-1'])
})
it('updates story_title/code on all child tasks via story UPDATE', () => {
useSoloStore.getState().initTasks([
baseTask('task-1', { story_id: 'story-1' }),
baseTask('task-2', { story_id: 'story-1' }),
baseTask('task-3', { story_id: 'story-other', story_title: 'Other' }),
])
useSoloStore.getState().handleRealtimeEvent(
storyEvent({ story_title: 'Renamed Story', story_code: 'ST-100B' }),
)
expect(useSoloStore.getState().tasks['task-1'].story_title).toBe('Renamed Story')
expect(useSoloStore.getState().tasks['task-1'].story_code).toBe('ST-100B')
expect(useSoloStore.getState().tasks['task-2'].story_title).toBe('Renamed Story')
expect(useSoloStore.getState().tasks['task-3'].story_title).toBe('Other')
})
it('removes all tasks of a story on story DELETE', () => {
useSoloStore.getState().initTasks([
baseTask('task-1', { story_id: 'story-1' }),
baseTask('task-2', { story_id: 'story-1' }),
baseTask('task-3', { story_id: 'story-other' }),
])
useSoloStore.getState().handleRealtimeEvent(storyEvent({ op: 'D' }))
expect(useSoloStore.getState().tasks['task-1']).toBeUndefined()
expect(useSoloStore.getState().tasks['task-2']).toBeUndefined()
expect(useSoloStore.getState().tasks['task-3']).toBeDefined()
})
})

View file

@ -0,0 +1,111 @@
-- ST-804 prereq: extend the realtime trigger payload with the new-state
-- fields the Solo Paneel needs for in-place rendering, so the client doesn't
-- have to refetch on every update.
--
-- Added fields:
-- task → task_status, task_sort_order, task_title
-- story → story_status, story_sort_order, story_title, story_code
--
-- Description and implementation_plan stay out of the payload — they can
-- be large and aren't needed for kanban-board rendering. UI fetches them
-- on demand when the detail dialog opens.
CREATE OR REPLACE FUNCTION notify_task_change() RETURNS trigger AS $$
DECLARE
rec record;
story_row record;
payload jsonb;
BEGIN
IF TG_OP = 'DELETE' THEN
rec := OLD;
ELSE
rec := NEW;
END IF;
SELECT product_id, sprint_id, assignee_id
INTO story_row
FROM stories
WHERE id = rec.story_id;
IF NOT FOUND THEN
RETURN rec;
END IF;
payload := jsonb_build_object(
'op', CASE TG_OP
WHEN 'INSERT' THEN 'I'
WHEN 'UPDATE' THEN 'U'
WHEN 'DELETE' THEN 'D'
END,
'entity', 'task',
'id', rec.id,
'story_id', rec.story_id,
'product_id', story_row.product_id,
'sprint_id', story_row.sprint_id,
'assignee_id', story_row.assignee_id,
'task_status', rec.status,
'task_sort_order', rec.sort_order,
'task_title', rec.title
);
IF TG_OP = 'UPDATE' THEN
payload := payload || jsonb_build_object(
'changed_fields',
COALESCE((
SELECT jsonb_agg(n.key)
FROM jsonb_each(to_jsonb(NEW)) n
JOIN jsonb_each(to_jsonb(OLD)) o USING (key)
WHERE n.value IS DISTINCT FROM o.value
), '[]'::jsonb)
);
END IF;
PERFORM pg_notify('scrum4me_changes', payload::text);
RETURN rec;
END;
$$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION notify_story_change() RETURNS trigger AS $$
DECLARE
rec record;
payload jsonb;
BEGIN
IF TG_OP = 'DELETE' THEN
rec := OLD;
ELSE
rec := NEW;
END IF;
payload := jsonb_build_object(
'op', CASE TG_OP
WHEN 'INSERT' THEN 'I'
WHEN 'UPDATE' THEN 'U'
WHEN 'DELETE' THEN 'D'
END,
'entity', 'story',
'id', rec.id,
'product_id', rec.product_id,
'sprint_id', rec.sprint_id,
'assignee_id', rec.assignee_id,
'story_status', rec.status,
'story_sort_order', rec.sort_order,
'story_title', rec.title,
'story_code', rec.code
);
IF TG_OP = 'UPDATE' THEN
payload := payload || jsonb_build_object(
'changed_fields',
COALESCE((
SELECT jsonb_agg(n.key)
FROM jsonb_each(to_jsonb(NEW)) n
JOIN jsonb_each(to_jsonb(OLD)) o USING (key)
WHERE n.value IS DISTINCT FROM o.value
), '[]'::jsonb)
);
END IF;
PERFORM pg_notify('scrum4me_changes', payload::text);
RETURN rec;
END;
$$ LANGUAGE plpgsql;

View file

@ -3,8 +3,9 @@ import type { SoloTask } from '@/components/solo/solo-board'
type TaskStatus = SoloTask['status']
// Payload-shape gepubliceerd door de Postgres-trigger via pg_notify (ST-801).
// Komt het Solo Paneel binnen via de SSE-stream uit /api/realtime/solo (ST-802).
// 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'
@ -13,22 +14,40 @@ export interface RealtimeEvent {
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[]
}
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>
initTasks: (tasks: SoloTask[]) => void
optimisticMove: (taskId: string, toStatus: TaskStatus) => TaskStatus | null
rollback: (taskId: string, prevStatus: TaskStatus) => void
updatePlan: (taskId: string, plan: string | null) => void
// ST-803 stub. Echte implementatie in ST-804 met pendingOps en
// gedifferentieerde apply{Task,Story}{Update,Create,Delete}.
markPending: (taskId: string) => void
clearPending: (taskId: string) => void
handleRealtimeEvent: (event: RealtimeEvent) => void
}
export const useSoloStore = create<SoloStore>((set, get) => ({
tasks: {},
pendingOps: new Set<string>(),
initTasks: (tasks) =>
set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }),
@ -46,7 +65,107 @@ export const useSoloStore = create<SoloStore>((set, get) => ({
updatePlan: (taskId, plan) =>
set((s) => ({ tasks: { ...s.tasks, [taskId]: { ...s.tasks[taskId], implementation_plan: plan } } })),
handleRealtimeEvent: (_event) => {
// ST-803 stub — vol invullen in ST-804.
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 }
}),
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 }
})
}
},
}))