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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue