realtime: idea-store + extend notifications hook for idea events (M12 T-503)
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) <noreply@anthropic.com>
This commit is contained in:
parent
0e2808ac88
commit
8cc4e0aeb7
3 changed files with 398 additions and 7 deletions
145
__tests__/stores/idea-store.test.ts
Normal file
145
__tests__/stores/idea-store.test.ts
Normal file
|
|
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -12,21 +12,52 @@
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store'
|
import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store'
|
||||||
|
import { useIdeaStore } from '@/stores/idea-store'
|
||||||
|
|
||||||
const BACKOFF_START_MS = 1_000
|
const BACKOFF_START_MS = 1_000
|
||||||
const BACKOFF_MAX_MS = 30_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'
|
op: 'I' | 'U'
|
||||||
entity: 'question'
|
entity: 'question'
|
||||||
id: string
|
id: string
|
||||||
product_id: string
|
product_id: string
|
||||||
story_id: string
|
story_id: string | null
|
||||||
task_id: string | null
|
task_id: string | null
|
||||||
|
idea_id?: string | null
|
||||||
assignee_id: string | null
|
assignee_id: string | null
|
||||||
status: 'open' | 'answered' | 'cancelled' | 'expired'
|
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 {
|
interface StateEvent {
|
||||||
questions: NotificationQuestion[]
|
questions: NotificationQuestion[]
|
||||||
}
|
}
|
||||||
|
|
@ -73,11 +104,44 @@ export function useNotificationsRealtime() {
|
||||||
|
|
||||||
source.addEventListener('message', (ev) => {
|
source.addEventListener('message', (ev) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(ev.data) as NotifyPayload
|
const payload = JSON.parse(ev.data) as AnyPayload
|
||||||
if (payload.entity !== 'question') return
|
|
||||||
// Bij open of nieuwe insert → upsert (server stuurt geen vraag-tekst
|
// M12 — idea-job events naar idea-store dispatchen.
|
||||||
// mee in de payload, dus we doen een mini-fetch via de same SSE's
|
if (isIdeaJobPayload(payload)) {
|
||||||
// initial-state on reconnect; hier voor MVP alleen status-handling).
|
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') {
|
if (payload.status === 'open') {
|
||||||
// Inkomende open vraag: we hebben de details nog niet — beste optie is
|
// Inkomende open vraag: we hebben de details nog niet — beste optie is
|
||||||
// herfetchen door opnieuw te verbinden, of via een API. Voor v1
|
// herfetchen door opnieuw te verbinden, of via een API. Voor v1
|
||||||
|
|
|
||||||
182
stores/idea-store.ts
Normal file
182
stores/idea-store.ts
Normal file
|
|
@ -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<string, IdeaJobState | undefined>
|
||||||
|
ideaStatuses: Record<string, IdeaStatusApi | undefined>
|
||||||
|
openQuestionsByIdea: Record<string, IdeaQuestion[]>
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// 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<IdeaStore>((set) => ({
|
||||||
|
jobByIdea: {},
|
||||||
|
ideaStatuses: {},
|
||||||
|
openQuestionsByIdea: {},
|
||||||
|
|
||||||
|
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 },
|
||||||
|
})),
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}),
|
||||||
|
}))
|
||||||
Loading…
Add table
Add a link
Reference in a new issue