Scrum4Me/stores/idea-store.ts
Scrum4Me Agent c0e8270e5f feat(ST-qcmqlv2i): SSE + idea-store — user_question events verwerken
- IdeaUserQuestion type + UserQuestionEvent type in idea-store
- userQuestions state + initUserQuestions + handleUserQuestionEvent
- clearForIdea filtert ook userQuestions voor het idea
- IdeaJobKind uitgebreid met PLAN_CHAT
- notifications/route.ts: UserQuestionPayload, isUserQuestionPayload,
  user_question events forwarden naar SSE, userQuestionsInit in state-event
- use-notifications-realtime.ts: UserQuestionPayload handler +
  initUserQuestions aanroepen bij state-event

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 17:37:57 +02:00

222 lines
6.8 KiB
TypeScript

// 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' | 'PLAN_CHAT'
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'
}
export interface IdeaUserQuestion {
id: string
idea_id: string
question: string
answer: string | null
status: 'pending' | 'answered'
created_at: string
}
export type UserQuestionEvent = {
op: 'I' | 'U'
entity: 'user_question'
id: string
idea_id: string
status: 'pending' | 'answered'
}
interface IdeaStore {
jobByIdea: Record<string, IdeaJobState | undefined>
ideaStatuses: Record<string, IdeaStatusApi | undefined>
openQuestionsByIdea: Record<string, IdeaQuestion[]>
userQuestions: IdeaUserQuestion[]
// Bulk-init bij mount van een page (server-component → client hydration).
initJobs: (jobs: IdeaJobState[]) => void
initStatuses: (statuses: Record<string, IdeaStatusApi>) => void
initQuestions: (ideaId: string, questions: IdeaQuestion[]) => void
initUserQuestions: (uqs: IdeaUserQuestion[]) => void
// Realtime event handlers — aangeroepen door use-notifications-realtime.
handleIdeaJobEvent: (event: IdeaJobEvent) => void
handleIdeaQuestionEvent: (event: IdeaQuestionEvent) => void
handleUserQuestionEvent: (event: UserQuestionEvent) => 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<IdeaStore>((set) => ({
jobByIdea: {},
ideaStatuses: {},
openQuestionsByIdea: {},
userQuestions: [],
initJobs: (jobs) =>
set(() => {
const jobByIdea: Record<string, IdeaJobState> = {}
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 },
})),
initUserQuestions: (uqs) => set({ userQuestions: uqs }),
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 },
}
}),
handleUserQuestionEvent: (event) =>
set((s) => {
if (event.op === 'I') {
return { userQuestions: [...s.userQuestions, { id: event.id, idea_id: event.idea_id, question: '', answer: null, status: event.status, created_at: new Date().toISOString() }] }
}
return {
userQuestions: s.userQuestions.map((uq) =>
uq.id === event.id ? { ...uq, status: event.status } : uq,
),
}
}),
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,
userQuestions: s.userQuestions.filter((uq) => uq.idea_id !== ideaId),
}
}),
}))