refactor(web): extract timeline sse invalidation policy
This commit is contained in:
@@ -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"]],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { SSE_EVENT_TYPES } from "@capakraken/shared";
|
import { SSE_EVENT_TYPES } from "@capakraken/shared";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
|
import { getTimelineSseInvalidationKeys, parseTimelineSseEvent } from "./timelineSsePolicy.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connects to the SSE timeline endpoint and invalidates React Query caches
|
* Connects to the SSE timeline endpoint and invalidates React Query caches
|
||||||
@@ -37,64 +38,18 @@ export function useTimelineSSE() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
es.onmessage = (event) => {
|
es.onmessage = (event) => {
|
||||||
try {
|
const eventType = parseTimelineSseEvent(String(event.data));
|
||||||
const data = JSON.parse(event.data as string) as { type: string };
|
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;
|
|
||||||
|
|
||||||
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
|
if (eventType === SSE_EVENT_TYPES.PING) {
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const queryKey of getTimelineSseInvalidationKeys(eventType)) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user