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:
Janpeter Visser 2026-05-04 20:02:22 +02:00
parent 0e2808ac88
commit 8cc4e0aeb7
3 changed files with 398 additions and 7 deletions

View 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')
})
})