diff --git a/__tests__/stores/solo-store-realtime.test.ts b/__tests__/stores/solo-store-realtime.test.ts new file mode 100644 index 0000000..589b961 --- /dev/null +++ b/__tests__/stores/solo-store-realtime.test.ts @@ -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 => ({ + 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 => ({ + 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 => ({ + 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() + }) +}) diff --git a/prisma/migrations/20260427000216_extend_realtime_payload/migration.sql b/prisma/migrations/20260427000216_extend_realtime_payload/migration.sql new file mode 100644 index 0000000..eb5bb6f --- /dev/null +++ b/prisma/migrations/20260427000216_extend_realtime_payload/migration.sql @@ -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; diff --git a/stores/solo-store.ts b/stores/solo-store.ts index d4ff93c..961716c 100644 --- a/stores/solo-store.ts +++ b/stores/solo-store.ts @@ -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 + /** 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 + 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((set, get) => ({ tasks: {}, + pendingOps: new Set(), initTasks: (tasks) => set({ tasks: Object.fromEntries(tasks.map(t => [t.id, t])) }), @@ -46,7 +65,107 @@ export const useSoloStore = create((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 = {} + 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 = {} + 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 } + }) + } }, }))