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
|
|
@ -12,21 +12,52 @@
|
|||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store'
|
||||
import { useIdeaStore } from '@/stores/idea-store'
|
||||
|
||||
const BACKOFF_START_MS = 1_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'
|
||||
entity: 'question'
|
||||
id: string
|
||||
product_id: string
|
||||
story_id: string
|
||||
story_id: string | null
|
||||
task_id: string | null
|
||||
idea_id?: string | null
|
||||
assignee_id: string | null
|
||||
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 {
|
||||
questions: NotificationQuestion[]
|
||||
}
|
||||
|
|
@ -73,11 +104,44 @@ export function useNotificationsRealtime() {
|
|||
|
||||
source.addEventListener('message', (ev) => {
|
||||
try {
|
||||
const payload = JSON.parse(ev.data) as NotifyPayload
|
||||
if (payload.entity !== 'question') return
|
||||
// Bij open of nieuwe insert → upsert (server stuurt geen vraag-tekst
|
||||
// mee in de payload, dus we doen een mini-fetch via de same SSE's
|
||||
// initial-state on reconnect; hier voor MVP alleen status-handling).
|
||||
const payload = JSON.parse(ev.data) as AnyPayload
|
||||
|
||||
// M12 — idea-job events naar idea-store dispatchen.
|
||||
if (isIdeaJobPayload(payload)) {
|
||||
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') {
|
||||
// Inkomende open vraag: we hebben de details nog niet — beste optie is
|
||||
// herfetchen door opnieuw te verbinden, of via een API. Voor v1
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue