diff --git a/apps/web/src/hooks/timelineSsePolicy.test.ts b/apps/web/src/hooks/timelineSsePolicy.test.ts new file mode 100644 index 0000000..8018de7 --- /dev/null +++ b/apps/web/src/hooks/timelineSsePolicy.test.ts @@ -0,0 +1,36 @@ +import { SSE_EVENT_TYPES } from "@capakraken/shared"; +import { describe, expect, it } from "vitest"; +import { getTimelineSseInvalidationKeys, parseTimelineSseEvent } from "./timelineSsePolicy.js"; + +describe("timelineSsePolicy", () => { + it("returns null for malformed event payloads", () => { + expect(parseTimelineSseEvent("{not-json")).toBeNull(); + expect(parseTimelineSseEvent(JSON.stringify({ nope: "missing-type" }))).toBeNull(); + expect(parseTimelineSseEvent(JSON.stringify({ type: 42 }))).toBeNull(); + }); + + it("does not invalidate queries for unknown event types", () => { + expect(getTimelineSseInvalidationKeys("timeline.unknown")).toEqual([]); + }); + + it("maps allocation and vacation updates to planning query invalidations", () => { + expect(getTimelineSseInvalidationKeys(SSE_EVENT_TYPES.ALLOCATION_UPDATED)).toEqual([ + [["timeline", "getEntries"]], + [["timeline", "getEntriesView"]], + [["timeline", "getMyEntriesView"]], + [["timeline", "getHolidayOverlays"]], + [["timeline", "getMyHolidayOverlays"]], + [["vacation", "list"]], + [["allocation", "list"]], + ]); + }); + + it("maps task updates to task and notification invalidations", () => { + expect(getTimelineSseInvalidationKeys(SSE_EVENT_TYPES.TASK_STATUS_CHANGED)).toEqual([ + [["notification", "listTasks"]], + [["notification", "taskCounts"]], + [["notification", "list"]], + [["notification", "unreadCount"]], + ]); + }); +}); diff --git a/apps/web/src/hooks/timelineSsePolicy.ts b/apps/web/src/hooks/timelineSsePolicy.ts new file mode 100644 index 0000000..90515cc --- /dev/null +++ b/apps/web/src/hooks/timelineSsePolicy.ts @@ -0,0 +1,71 @@ +import { SSE_EVENT_TYPES } from "@capakraken/shared"; + +export type TimelineQueryKey = readonly [readonly [string, string]]; + +const TIMELINE_ENTRY_KEYS: TimelineQueryKey[] = [ + [["timeline", "getEntries"]], + [["timeline", "getEntriesView"]], + [["timeline", "getMyEntriesView"]], + [["timeline", "getHolidayOverlays"]], + [["timeline", "getMyHolidayOverlays"]], +]; + +const NOTIFICATION_KEYS: TimelineQueryKey[] = [ + [["notification", "list"]], + [["notification", "unreadCount"]], +]; + +export function parseTimelineSseEvent(rawData: string): string | null { + try { + const data = JSON.parse(rawData) as { type?: unknown }; + return typeof data.type === "string" ? data.type : null; + } catch { + return null; + } +} + +export function getTimelineSseInvalidationKeys(eventType: string): TimelineQueryKey[] { + switch (eventType) { + case SSE_EVENT_TYPES.ALLOCATION_CREATED: + case SSE_EVENT_TYPES.ALLOCATION_UPDATED: + case SSE_EVENT_TYPES.ALLOCATION_DELETED: + case SSE_EVENT_TYPES.VACATION_CREATED: + case SSE_EVENT_TYPES.VACATION_UPDATED: + case SSE_EVENT_TYPES.VACATION_DELETED: + return [ + ...TIMELINE_ENTRY_KEYS, + [["vacation", "list"]], + [["allocation", "list"]], + ]; + + case SSE_EVENT_TYPES.PROJECT_SHIFTED: + return [ + ...TIMELINE_ENTRY_KEYS, + [["project", "list"]], + ]; + + case SSE_EVENT_TYPES.BUDGET_WARNING: + return [[["timeline", "getBudgetStatus"]]]; + + case SSE_EVENT_TYPES.NOTIFICATION_CREATED: + return NOTIFICATION_KEYS; + + case SSE_EVENT_TYPES.TASK_ASSIGNED: + case SSE_EVENT_TYPES.TASK_COMPLETED: + case SSE_EVENT_TYPES.TASK_STATUS_CHANGED: + return [ + [["notification", "listTasks"]], + [["notification", "taskCounts"]], + ...NOTIFICATION_KEYS, + ]; + + case SSE_EVENT_TYPES.REMINDER_DUE: + return [ + ...NOTIFICATION_KEYS, + [["notification", "listReminders"]], + ]; + + default: + return []; + } +} diff --git a/apps/web/src/hooks/useTimelineSSE.ts b/apps/web/src/hooks/useTimelineSSE.ts index ad0a785..a875ea5 100644 --- a/apps/web/src/hooks/useTimelineSSE.ts +++ b/apps/web/src/hooks/useTimelineSSE.ts @@ -3,6 +3,7 @@ import { SSE_EVENT_TYPES } from "@capakraken/shared"; import { useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef } from "react"; +import { getTimelineSseInvalidationKeys, parseTimelineSseEvent } from "./timelineSsePolicy.js"; /** * Connects to the SSE timeline endpoint and invalidates React Query caches @@ -37,64 +38,18 @@ export function useTimelineSSE() { }; es.onmessage = (event) => { - try { - const data = JSON.parse(event.data as string) as { type: string }; + const eventType = parseTimelineSseEvent(String(event.data)); + if (!eventType) { + return; + } - switch (data.type) { - case SSE_EVENT_TYPES.ALLOCATION_CREATED: - case SSE_EVENT_TYPES.ALLOCATION_UPDATED: - case SSE_EVENT_TYPES.ALLOCATION_DELETED: - case SSE_EVENT_TYPES.VACATION_CREATED: - case SSE_EVENT_TYPES.VACATION_UPDATED: - case SSE_EVENT_TYPES.VACATION_DELETED: - void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntries"]] }); - void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntriesView"]] }); - void queryClient.invalidateQueries({ queryKey: [["timeline", "getMyEntriesView"]] }); - void queryClient.invalidateQueries({ queryKey: [["timeline", "getHolidayOverlays"]] }); - void queryClient.invalidateQueries({ queryKey: [["timeline", "getMyHolidayOverlays"]] }); - void queryClient.invalidateQueries({ queryKey: [["vacation", "list"]] }); - void queryClient.invalidateQueries({ queryKey: [["allocation", "list"]] }); - break; + if (eventType === SSE_EVENT_TYPES.PING) { + reconnectAttempts = 0; + return; + } - case SSE_EVENT_TYPES.PROJECT_SHIFTED: - void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntries"]] }); - void queryClient.invalidateQueries({ queryKey: [["timeline", "getEntriesView"]] }); - void queryClient.invalidateQueries({ queryKey: [["timeline", "getMyEntriesView"]] }); - void queryClient.invalidateQueries({ queryKey: [["timeline", "getHolidayOverlays"]] }); - void queryClient.invalidateQueries({ queryKey: [["timeline", "getMyHolidayOverlays"]] }); - void queryClient.invalidateQueries({ queryKey: [["project", "list"]] }); - break; - - case SSE_EVENT_TYPES.BUDGET_WARNING: - void queryClient.invalidateQueries({ queryKey: [["timeline", "getBudgetStatus"]] }); - break; - - case SSE_EVENT_TYPES.NOTIFICATION_CREATED: - void queryClient.invalidateQueries({ queryKey: [["notification", "list"]] }); - void queryClient.invalidateQueries({ queryKey: [["notification", "unreadCount"]] }); - break; - - case SSE_EVENT_TYPES.TASK_ASSIGNED: - case SSE_EVENT_TYPES.TASK_COMPLETED: - case SSE_EVENT_TYPES.TASK_STATUS_CHANGED: - void queryClient.invalidateQueries({ queryKey: [["notification", "listTasks"]] }); - void queryClient.invalidateQueries({ queryKey: [["notification", "taskCounts"]] }); - void queryClient.invalidateQueries({ queryKey: [["notification", "unreadCount"]] }); - void queryClient.invalidateQueries({ queryKey: [["notification", "list"]] }); - break; - - case SSE_EVENT_TYPES.REMINDER_DUE: - void queryClient.invalidateQueries({ queryKey: [["notification", "list"]] }); - void queryClient.invalidateQueries({ queryKey: [["notification", "unreadCount"]] }); - void queryClient.invalidateQueries({ queryKey: [["notification", "listReminders"]] }); - break; - - case SSE_EVENT_TYPES.PING: - reconnectAttempts = 0; // Reset on successful ping - break; - } - } catch { - // Ignore parse errors + for (const queryKey of getTimelineSseInvalidationKeys(eventType)) { + void queryClient.invalidateQueries({ queryKey }); } };