From 8cc4e0aeb7bc5d18521132309e36331db66518ee Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Mon, 4 May 2026 20:02:22 +0200 Subject: [PATCH] realtime: idea-store + extend notifications hook for idea events (M12 T-503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stores/idea-store.ts (Zustand): - jobByIdea, ideaStatuses, openQuestionsByIdea - handleIdeaJobEvent: derives optimistic ideaStatus (queued/claimed/running → grilling/planning; failed → grill_failed/plan_failed; done = no-op since the server-side update_idea_*_md is source-of-truth) - handleIdeaQuestionEvent: list-based, removes on non-open - setIdeaStatus / setJobStatus / clearForIdea optimistic helpers - connectedWorkers NOT duplicated — UI reads useSoloStore(s.connectedWorkers) lib/realtime/use-notifications-realtime.ts: - Single SSE serves both bell-questions and idea-state. Adds dispatcher branches: idea-job payloads → idea-store; idea-question payloads (idea_id set) → idea-store; story-questions → existing notifications-store path. Tests: 7/7 idea-store cases (queued→grilling, failed→*_failed, done no-op, question-list management, clearForIdea isolation). Full suite: 546/546 green. Co-Authored-By: Claude Opus 4.7 (1M context) --- __tests__/stores/idea-store.test.ts | 145 ++++++++++++++++ lib/realtime/use-notifications-realtime.ts | 78 ++++++++- stores/idea-store.ts | 182 +++++++++++++++++++++ 3 files changed, 398 insertions(+), 7 deletions(-) create mode 100644 __tests__/stores/idea-store.test.ts create mode 100644 stores/idea-store.ts diff --git a/__tests__/stores/idea-store.test.ts b/__tests__/stores/idea-store.test.ts new file mode 100644 index 0000000..37d7413 --- /dev/null +++ b/__tests__/stores/idea-store.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, beforeEach } from 'vitest' + +import { useIdeaStore } from '@/stores/idea-store' + +beforeEach(() => { + // Reset store between tests — Zustand persists state across tests otherwise. + useIdeaStore.setState({ + jobByIdea: {}, + ideaStatuses: {}, + openQuestionsByIdea: {}, + }) +}) + +describe('useIdeaStore — handleIdeaJobEvent', () => { + it('queued IDEA_GRILL → ideaStatuses[id] = grilling', () => { + useIdeaStore.getState().handleIdeaJobEvent({ + type: 'claude_job_enqueued', + job_id: 'job-1', + idea_id: 'idea-1', + user_id: 'u-1', + kind: 'IDEA_GRILL', + status: 'queued', + }) + const s = useIdeaStore.getState() + expect(s.jobByIdea['idea-1']?.status).toBe('queued') + expect(s.ideaStatuses['idea-1']).toBe('grilling') + }) + + it('failed IDEA_GRILL → ideaStatuses[id] = grill_failed', () => { + useIdeaStore.getState().handleIdeaJobEvent({ + type: 'claude_job_status', + job_id: 'job-1', + idea_id: 'idea-1', + user_id: 'u-1', + kind: 'IDEA_GRILL', + status: 'failed', + error: 'oops', + }) + expect(useIdeaStore.getState().ideaStatuses['idea-1']).toBe('grill_failed') + expect(useIdeaStore.getState().jobByIdea['idea-1']?.error).toBe('oops') + }) + + it('failed IDEA_MAKE_PLAN → plan_failed', () => { + useIdeaStore.getState().handleIdeaJobEvent({ + type: 'claude_job_status', + job_id: 'job-2', + idea_id: 'idea-2', + user_id: 'u-1', + kind: 'IDEA_MAKE_PLAN', + status: 'failed', + }) + expect(useIdeaStore.getState().ideaStatuses['idea-2']).toBe('plan_failed') + }) + + it('done does NOT auto-derive status (server is source-of-truth)', () => { + useIdeaStore.getState().setIdeaStatus('idea-3', 'grilled') + useIdeaStore.getState().handleIdeaJobEvent({ + type: 'claude_job_status', + job_id: 'job-3', + idea_id: 'idea-3', + user_id: 'u-1', + kind: 'IDEA_GRILL', + status: 'done', + }) + expect(useIdeaStore.getState().ideaStatuses['idea-3']).toBe('grilled') + }) +}) + +describe('useIdeaStore — handleIdeaQuestionEvent', () => { + it('non-open status removes question from list', () => { + useIdeaStore.getState().initQuestions('idea-1', [ + { + id: 'q-1', + idea_id: 'idea-1', + question: 'Q', + options: null, + status: 'open', + created_at: '', + expires_at: '', + }, + ]) + useIdeaStore.getState().handleIdeaQuestionEvent({ + op: 'U', + entity: 'question', + id: 'q-1', + product_id: 'p-1', + story_id: null, + idea_id: 'idea-1', + status: 'answered', + }) + expect(useIdeaStore.getState().openQuestionsByIdea['idea-1']).toEqual([]) + }) + + it('open status keeps existing list (no detail in payload)', () => { + const q = { + id: 'q-1', + idea_id: 'idea-1', + question: 'Q', + options: null, + status: 'open' as const, + created_at: '', + expires_at: '', + } + useIdeaStore.getState().initQuestions('idea-1', [q]) + useIdeaStore.getState().handleIdeaQuestionEvent({ + op: 'I', + entity: 'question', + id: 'q-2', + product_id: 'p-1', + story_id: null, + idea_id: 'idea-1', + status: 'open', + }) + // List length blijft 1 (server-fetch leveert de detail) + expect(useIdeaStore.getState().openQuestionsByIdea['idea-1']).toHaveLength(1) + }) +}) + +describe('useIdeaStore — clearForIdea', () => { + it('removes job + status + questions for one idea, leaves others', () => { + const s = useIdeaStore.getState() + s.setJobStatus({ + job_id: 'j-1', + idea_id: 'idea-1', + kind: 'IDEA_GRILL', + status: 'running', + }) + s.setJobStatus({ + job_id: 'j-2', + idea_id: 'idea-2', + kind: 'IDEA_GRILL', + status: 'running', + }) + s.setIdeaStatus('idea-1', 'grilling') + s.setIdeaStatus('idea-2', 'grilling') + + s.clearForIdea('idea-1') + + const after = useIdeaStore.getState() + expect(after.jobByIdea['idea-1']).toBeUndefined() + expect(after.jobByIdea['idea-2']).toBeDefined() + expect(after.ideaStatuses['idea-1']).toBeUndefined() + expect(after.ideaStatuses['idea-2']).toBe('grilling') + }) +}) diff --git a/lib/realtime/use-notifications-realtime.ts b/lib/realtime/use-notifications-realtime.ts index 8f58e12..f9ad0e3 100644 --- a/lib/realtime/use-notifications-realtime.ts +++ b/lib/realtime/use-notifications-realtime.ts @@ -12,21 +12,52 @@ import { useEffect, useRef } from 'react' import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store' +import { useIdeaStore } from '@/stores/idea-store' const BACKOFF_START_MS = 1_000 const BACKOFF_MAX_MS = 30_000 -interface NotifyPayload { +// Question-payloads (M11 + M12). story_id en idea_id zijn mutually exclusive +// (DB-check-constraint). Voor story-questions blijft het pad onveranderd; +// idea-questions worden naar de idea-store doorgezet. +interface QuestionPayload { op: 'I' | 'U' entity: 'question' id: string product_id: string - story_id: string + story_id: string | null task_id: string | null + idea_id?: string | null assignee_id: string | null status: 'open' | 'answered' | 'cancelled' | 'expired' } +// Idea-job-payloads (M12). Komen uit actions/ideas.ts pg_notify. +interface IdeaJobPayload { + type: 'claude_job_enqueued' | 'claude_job_status' + job_id: string + idea_id: string + user_id: string + product_id?: string | null + kind: 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' + status: string + error?: string +} + +type AnyPayload = QuestionPayload | IdeaJobPayload + +function isQuestionPayload(p: AnyPayload): p is QuestionPayload { + return 'entity' in p && p.entity === 'question' +} + +function isIdeaJobPayload(p: AnyPayload): p is IdeaJobPayload { + return ( + 'type' in p && + (p.type === 'claude_job_enqueued' || p.type === 'claude_job_status') && + (p.kind === 'IDEA_GRILL' || p.kind === 'IDEA_MAKE_PLAN') + ) +} + interface StateEvent { questions: NotificationQuestion[] } @@ -73,11 +104,44 @@ export function useNotificationsRealtime() { source.addEventListener('message', (ev) => { try { - const payload = JSON.parse(ev.data) as NotifyPayload - if (payload.entity !== 'question') return - // Bij open of nieuwe insert → upsert (server stuurt geen vraag-tekst - // mee in de payload, dus we doen een mini-fetch via de same SSE's - // initial-state on reconnect; hier voor MVP alleen status-handling). + const payload = JSON.parse(ev.data) as AnyPayload + + // M12 — idea-job events naar idea-store dispatchen. + if (isIdeaJobPayload(payload)) { + useIdeaStore.getState().handleIdeaJobEvent({ + type: payload.type, + job_id: payload.job_id, + idea_id: payload.idea_id, + user_id: payload.user_id, + product_id: payload.product_id ?? null, + kind: payload.kind, + // The store-types narrow this; cast is safe because the server + // emits valid statuses. + status: payload.status as 'queued', + error: payload.error, + }) + return + } + + if (!isQuestionPayload(payload)) return + + // M12 — idea-question events naar idea-store dispatchen. + if (payload.idea_id) { + useIdeaStore.getState().handleIdeaQuestionEvent({ + op: payload.op, + entity: 'question', + id: payload.id, + product_id: payload.product_id, + story_id: null, + idea_id: payload.idea_id, + task_id: payload.task_id, + assignee_id: payload.assignee_id, + status: payload.status, + }) + return + } + + // Story-questions: bestaande bell-pad onveranderd. if (payload.status === 'open') { // Inkomende open vraag: we hebben de details nog niet — beste optie is // herfetchen door opnieuw te verbinden, of via een API. Voor v1 diff --git a/stores/idea-store.ts b/stores/idea-store.ts new file mode 100644 index 0000000..0b95f16 --- /dev/null +++ b/stores/idea-store.ts @@ -0,0 +1,182 @@ +// M12: Zustand-store voor idee-gerelateerde realtime state. +// +// Wordt gevoed door `use-notifications-realtime.ts` (zelfde SSE-stream als de +// notifications-bell — geen tweede EventSource nodig). Houdt: +// - jobByIdea: live status van de actieve grill/make-plan-job per idee +// - ideaStatuses: optimistische idea-status-updates (uit job-events) +// - openQuestionsByIdea: open vragen voor de Timeline-tab (M12 ST-1199) +// +// connectedWorkers wordt NIET gedupliceerd — UI-componenten lezen die direct +// via `useSoloStore(s => s.connectedWorkers)` (zie M12 grill-keuze 16). + +import { create } from 'zustand' + +import type { ClaudeJobStatusApi } from '@/lib/job-status' +import type { IdeaStatusApi } from '@/lib/idea-status' + +export type IdeaJobKind = 'IDEA_GRILL' | 'IDEA_MAKE_PLAN' + +export interface IdeaJobState { + job_id: string + idea_id: string + kind: IdeaJobKind + status: ClaudeJobStatusApi + error?: string + started_at?: string | null + finished_at?: string | null +} + +export interface IdeaQuestion { + id: string + idea_id: string + question: string + options: string[] | null + status: 'open' | 'answered' | 'cancelled' | 'expired' + answer?: string | null + created_at: string + expires_at: string +} + +export type IdeaJobEvent = + | { + type: 'claude_job_enqueued' + job_id: string + idea_id: string + user_id: string + product_id?: string | null + kind: IdeaJobKind + status: 'queued' + } + | { + type: 'claude_job_status' + job_id: string + idea_id: string + user_id: string + product_id?: string | null + kind: IdeaJobKind + status: ClaudeJobStatusApi + error?: string + } + +export type IdeaQuestionEvent = { + op: 'I' | 'U' + entity: 'question' + id: string + product_id: string + story_id: null + idea_id: string + task_id?: string | null + assignee_id?: string | null + status: 'open' | 'answered' | 'cancelled' | 'expired' +} + +interface IdeaStore { + jobByIdea: Record + ideaStatuses: Record + openQuestionsByIdea: Record + + // Bulk-init bij mount van een page (server-component → client hydration). + initJobs: (jobs: IdeaJobState[]) => void + initStatuses: (statuses: Record) => void + initQuestions: (ideaId: string, questions: IdeaQuestion[]) => void + + // Realtime event handlers — aangeroepen door use-notifications-realtime. + handleIdeaJobEvent: (event: IdeaJobEvent) => void + handleIdeaQuestionEvent: (event: IdeaQuestionEvent) => void + + // Optimistic updates vanuit server-actions in client-components. + setIdeaStatus: (ideaId: string, status: IdeaStatusApi) => void + setJobStatus: (job: IdeaJobState) => void + + // Cleanup bij navigeren weg van een detail-pagina. + clearForIdea: (ideaId: string) => void +} + +// Mapping van een job-status (uit pg_notify event) naar een afgeleide +// idea-status. De server is de bron-van-waarheid; dit is alleen optimistic UI. +function deriveIdeaStatusFromJob( + kind: IdeaJobKind, + status: ClaudeJobStatusApi, +): IdeaStatusApi | null { + if (status === 'queued' || status === 'claimed' || status === 'running') { + return kind === 'IDEA_GRILL' ? 'grilling' : 'planning' + } + if (status === 'failed') { + return kind === 'IDEA_GRILL' ? 'grill_failed' : 'plan_failed' + } + // 'done' wordt door update_idea_*_md gezet (GRILLED resp. PLAN_READY) — + // daar is geen kind-onafhankelijke afleiding voor; lees de DB-update via + // re-fetch / page-revalidate. We laten de status hier ongemoeid. + return null +} + +export const useIdeaStore = create((set) => ({ + jobByIdea: {}, + ideaStatuses: {}, + openQuestionsByIdea: {}, + + initJobs: (jobs) => + set(() => { + const jobByIdea: Record = {} + for (const j of jobs) jobByIdea[j.idea_id] = j + return { jobByIdea } + }), + + initStatuses: (statuses) => set({ ideaStatuses: { ...statuses } }), + + initQuestions: (ideaId, questions) => + set((s) => ({ + openQuestionsByIdea: { ...s.openQuestionsByIdea, [ideaId]: questions }, + })), + + handleIdeaJobEvent: (event) => + set((s) => { + const jobState: IdeaJobState = { + job_id: event.job_id, + idea_id: event.idea_id, + kind: event.kind, + status: event.status as ClaudeJobStatusApi, + error: 'error' in event ? event.error : undefined, + } + const derived = deriveIdeaStatusFromJob(event.kind, event.status as ClaudeJobStatusApi) + return { + jobByIdea: { ...s.jobByIdea, [event.idea_id]: jobState }, + ideaStatuses: + derived !== null + ? { ...s.ideaStatuses, [event.idea_id]: derived } + : s.ideaStatuses, + } + }), + + handleIdeaQuestionEvent: (event) => + set((s) => { + const list = s.openQuestionsByIdea[event.idea_id] ?? [] + // Bij open/insert: we hebben alleen status + id; de UI fetcht de + // detail bij re-render. Voor v1 markeren we 'm in de lijst zodat de + // count niet uit sync raakt. + let next = list + if (event.status !== 'open') { + next = list.filter((q) => q.id !== event.id) + } + return { + openQuestionsByIdea: { ...s.openQuestionsByIdea, [event.idea_id]: next }, + } + }), + + setIdeaStatus: (ideaId, status) => + set((s) => ({ ideaStatuses: { ...s.ideaStatuses, [ideaId]: status } })), + + setJobStatus: (job) => + set((s) => ({ jobByIdea: { ...s.jobByIdea, [job.idea_id]: job } })), + + clearForIdea: (ideaId) => + set((s) => { + const { [ideaId]: _j, ...jobByIdea } = s.jobByIdea + const { [ideaId]: _s, ...ideaStatuses } = s.ideaStatuses + const { [ideaId]: _q, ...openQuestionsByIdea } = s.openQuestionsByIdea + void _j + void _s + void _q + return { jobByIdea, ideaStatuses, openQuestionsByIdea } + }), +}))