// 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 } }), }))