@hiroleague/taskmanager 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +1 -1
  2. package/dist/assets/index-BpzHnKdP.css +1 -0
  3. package/dist/assets/index-DmNErTAP.js +273 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/skills/hiro-task-manager-cli/SKILL.md +6 -4
  7. package/skills/hiro-task-manager-cli/reference/cli-access-policy.md +1 -0
  8. package/skills/hiro-task-manager-cli/reference/releases.md +14 -0
  9. package/src/cli/commands/query.ts +56 -56
  10. package/src/cli/commands/releases.ts +22 -0
  11. package/src/cli/handlers/boards.test.ts +669 -669
  12. package/src/cli/handlers/cli-wiring.test.ts +38 -1
  13. package/src/cli/handlers/releases.ts +15 -0
  14. package/src/cli/handlers/search.test.ts +374 -374
  15. package/src/cli/handlers/search.ts +17 -17
  16. package/src/cli/lib/cli-http-errors.test.ts +85 -85
  17. package/src/cli/lib/write/releases.ts +64 -1
  18. package/src/cli/lib/write-result.test.ts +3 -0
  19. package/src/cli/lib/write-result.ts +3 -0
  20. package/src/cli/lib/writeCommands.breadth.test.ts +143 -0
  21. package/src/cli/lib/writeCommands.ts +1 -0
  22. package/src/cli/subprocess.real-stack.test.ts +625 -611
  23. package/src/cli/subprocess.smoke.test.ts +954 -954
  24. package/src/client/api/useBoardChangeStream.ts +421 -168
  25. package/src/client/api/useBoardIndexStream.ts +35 -0
  26. package/src/client/components/board/BoardStatsChips.tsx +233 -233
  27. package/src/client/components/board/BoardStatsContext.tsx +41 -41
  28. package/src/client/components/board/boardHeaderButtonStyles.ts +38 -38
  29. package/src/client/components/board/shortcuts/useBoardShortcutKeydown.ts +49 -49
  30. package/src/client/components/board/useBoardCanvasPanScroll.ts +108 -108
  31. package/src/client/components/board/useBoardTaskContainerDroppableReact.ts +33 -33
  32. package/src/client/components/board/useBoardTaskSortableReact.ts +26 -26
  33. package/src/client/components/layout/AppShell.tsx +5 -2
  34. package/src/client/components/layout/NotificationToasts.tsx +38 -1
  35. package/src/client/components/multi-select.tsx +1206 -1206
  36. package/src/client/components/routing/BoardPage.tsx +20 -20
  37. package/src/client/components/routing/NavigationRegistrar.tsx +13 -13
  38. package/src/client/components/task/TaskCard.tsx +643 -643
  39. package/src/client/components/ui/badge.tsx +49 -49
  40. package/src/client/components/ui/button.tsx +65 -65
  41. package/src/client/components/ui/command.tsx +193 -193
  42. package/src/client/components/ui/dialog.tsx +163 -163
  43. package/src/client/components/ui/input-group.tsx +155 -155
  44. package/src/client/components/ui/input.tsx +19 -19
  45. package/src/client/components/ui/popover.tsx +87 -87
  46. package/src/client/components/ui/separator.tsx +28 -28
  47. package/src/client/components/ui/textarea.tsx +18 -18
  48. package/src/client/index.css +248 -248
  49. package/src/client/lib/appNavigate.ts +16 -16
  50. package/src/client/lib/taskCardDate.ts +111 -111
  51. package/src/client/lib/utils.ts +6 -6
  52. package/src/client/store/notificationUi.ts +14 -0
  53. package/src/server/auth.ts +351 -351
  54. package/src/server/events.ts +31 -4
  55. package/src/server/migrations/registry.ts +43 -43
  56. package/src/server/notificationEvents.ts +8 -1
  57. package/src/server/routes/boards.ts +15 -1
  58. package/src/server/routes/trash.ts +6 -1
  59. package/src/shared/boardEvents.ts +6 -0
  60. package/src/shared/runtimeConfig.ts +256 -256
  61. package/dist/assets/index-hMFTu7sr.css +0 -1
  62. package/dist/assets/index-oKG1C41_.js +0 -273
@@ -1,6 +1,7 @@
1
- import { useEffect } from "react";
2
- import { useQueryClient } from "@tanstack/react-query";
1
+ import { useEffect, useRef } from "react";
2
+ import { useQueryClient, type QueryClient } from "@tanstack/react-query";
3
3
  import type { BoardEvent } from "../../shared/boardEvents";
4
+ import type { NotificationCreatedEvent } from "../../shared/notifications";
4
5
  import { mergeReleaseUpsertIntoList } from "../../shared/boardReleaseMerge";
5
6
  import type { Board } from "../../shared/models";
6
7
  import {
@@ -11,11 +12,79 @@ import {
11
12
  fetchBoardTask,
12
13
  invalidateBoardStatsQueries,
13
14
  } from "./queries";
15
+ import { invalidateNotificationQueries } from "./notifications";
16
+ import { getBrowserClientInstanceId } from "./clientHeaders";
14
17
  import { devDirectApiOrigin } from "./devDirectApiOrigin";
18
+ import { useNotificationUiStore } from "@/store/notificationUi";
15
19
 
16
- /** In dev, EventSource must hit the API process directly; Vite's HTTP proxy does not keep Bun SSE subscribers alive. */
17
- function boardEventsUrl(eventBoardId: number): string {
18
- const path = `/api/events?boardId=${eventBoardId}`;
20
+ // ---------------------------------------------------------------------------
21
+ // Shared singleton EventSource with event-bus dispatch.
22
+ //
23
+ // One connection per tab. All subscribers register callbacks on a central
24
+ // registry (not directly on the EventSource). When the connection is replaced
25
+ // (e.g. upgrading from shell → board-scoped) the singleton re-attaches its
26
+ // dispatch listeners to the new EventSource — every subscriber's callbacks
27
+ // continue to fire without re-registration.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** SSE event names the singleton dispatches. */
31
+ const SSE_EVENT_NAMES = [
32
+ "board-index-changed",
33
+ "notification-created",
34
+ "board-changed",
35
+ "release-upserted",
36
+ "task-created",
37
+ "task-updated",
38
+ "task-deleted",
39
+ "task-trashed",
40
+ "task-purged",
41
+ "task-restored",
42
+ "list-created",
43
+ "list-updated",
44
+ "list-deleted",
45
+ "list-trashed",
46
+ "list-purged",
47
+ "list-restored",
48
+ ] as const;
49
+
50
+ type SseEventName = (typeof SSE_EVENT_NAMES)[number];
51
+ type SseCallback = (raw: Event) => void;
52
+
53
+ /** Per-event-name set of subscriber callbacks. */
54
+ const callbackRegistry = new Map<SseEventName, Set<SseCallback>>();
55
+
56
+ for (const name of SSE_EVENT_NAMES) {
57
+ callbackRegistry.set(name, new Set());
58
+ }
59
+
60
+ let activeEs: EventSource | null = null;
61
+ let activeBoardId: number | null = null;
62
+ let activeRefCount = 0;
63
+
64
+ const SSE_OPEN_WARNING_MS = 8000;
65
+ const SSE_WARNING_THROTTLE_MS = 60_000;
66
+ let connectWarnTimer: ReturnType<typeof setTimeout> | null = null;
67
+ let lastSseConnectionWarningAt = 0;
68
+
69
+ function clearConnectWarnTimer(): void {
70
+ if (connectWarnTimer != null) {
71
+ clearTimeout(connectWarnTimer);
72
+ connectWarnTimer = null;
73
+ }
74
+ }
75
+
76
+ function maybeWarnSseConnectSlow(): void {
77
+ const now = Date.now();
78
+ if (now - lastSseConnectionWarningAt < SSE_WARNING_THROTTLE_MS) return;
79
+ lastSseConnectionWarningAt = now;
80
+ useNotificationUiStore.getState().pushSystemToast(
81
+ "Live updates are slow to connect. Browsers allow only a few connections per site — many open tabs can block them. Close extra tabs to restore real-time updates.",
82
+ );
83
+ }
84
+
85
+ function eventsUrl(boardId: number | null): string {
86
+ const path =
87
+ boardId != null ? `/api/events?boardId=${boardId}` : "/api/events";
19
88
  if (import.meta.env.PROD) return path;
20
89
  const raw = import.meta.env.VITE_API_ORIGIN as string | undefined;
21
90
  const fallbackOrigin = devDirectApiOrigin();
@@ -24,9 +93,131 @@ function boardEventsUrl(eventBoardId: number): string {
24
93
  return `${origin}${path}`;
25
94
  }
26
95
 
96
+ /** Attach the singleton's dispatch listeners to an EventSource. Each named
97
+ * event fans out to every callback in the registry for that name. */
98
+ function attachDispatchListeners(es: EventSource): void {
99
+ for (const name of SSE_EVENT_NAMES) {
100
+ const callbacks = callbackRegistry.get(name)!;
101
+ es.addEventListener(name, (raw: Event) => {
102
+ for (const cb of callbacks) {
103
+ try {
104
+ cb(raw);
105
+ } catch (err) {
106
+ console.error(`[sse-dispatch] error in ${name} handler`, err);
107
+ }
108
+ }
109
+ });
110
+ }
111
+ }
112
+
113
+ /** Open (or replace) the shared EventSource. Attaches dispatch listeners to
114
+ * the new instance so all registered callbacks immediately receive events. */
115
+ function openConnection(boardId: number | null): void {
116
+ clearConnectWarnTimer();
117
+ if (activeEs) activeEs.close();
118
+
119
+ const es = new EventSource(eventsUrl(boardId), { withCredentials: true });
120
+ activeEs = es;
121
+ activeBoardId = boardId;
122
+ attachDispatchListeners(es);
123
+
124
+ let opened = false;
125
+ const onOpen = () => {
126
+ opened = true;
127
+ clearConnectWarnTimer();
128
+ es.removeEventListener("open", onOpen);
129
+ };
130
+ es.addEventListener("open", onOpen);
131
+ connectWarnTimer = setTimeout(() => {
132
+ connectWarnTimer = null;
133
+ if (!opened && activeEs === es) maybeWarnSseConnectSlow();
134
+ }, SSE_OPEN_WARNING_MS);
135
+ }
136
+
137
+ /** Register a callback for a named SSE event. Returns an unsubscribe fn. */
138
+ function subscribe(name: SseEventName, cb: SseCallback): () => void {
139
+ callbackRegistry.get(name)!.add(cb);
140
+ return () => {
141
+ callbackRegistry.get(name)!.delete(cb);
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Acquire the singleton. Board-scoped requests upgrade the connection;
147
+ * shell requests (null) piggyback on whatever exists.
148
+ */
149
+ function acquire(wantedBoardId: number | null): void {
150
+ activeRefCount++;
151
+ const needNew =
152
+ activeEs == null ||
153
+ (wantedBoardId != null && activeBoardId !== wantedBoardId);
154
+ if (needNew) {
155
+ // Prefer board-scoped when available.
156
+ openConnection(wantedBoardId ?? activeBoardId);
157
+ }
158
+ }
159
+
160
+ function release(): void {
161
+ activeRefCount--;
162
+ if (activeRefCount <= 0) {
163
+ clearConnectWarnTimer();
164
+ activeEs?.close();
165
+ activeEs = null;
166
+ activeBoardId = null;
167
+ activeRefCount = 0;
168
+ }
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // Debounced board invalidation
173
+ // ---------------------------------------------------------------------------
174
+
175
+ let invalidateTimer: ReturnType<typeof setTimeout> | null = null;
176
+
177
+ function debouncedInvalidateBoard(
178
+ qc: QueryClient,
179
+ routeKey: string | number | null,
180
+ eventBoardId: number | null,
181
+ ): void {
182
+ if (eventBoardId == null) return;
183
+ if (invalidateTimer != null) clearTimeout(invalidateTimer);
184
+ invalidateTimer = setTimeout(() => {
185
+ invalidateTimer = null;
186
+ if (routeKey != null) {
187
+ void qc.invalidateQueries({
188
+ queryKey: [...boardKeys.all, routeKey],
189
+ exact: true,
190
+ });
191
+ }
192
+ if (routeKey !== eventBoardId) {
193
+ void qc.invalidateQueries({
194
+ queryKey: boardKeys.detail(eventBoardId),
195
+ exact: true,
196
+ });
197
+ }
198
+ invalidateBoardStatsQueries(qc, eventBoardId);
199
+ }, 300);
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Public hook
204
+ // ---------------------------------------------------------------------------
205
+
27
206
  /**
28
- * Listen for server-side board writes so CLI changes invalidate the active board
29
- * without waiting for focus or a manual refresh.
207
+ * Single SSE connection per tab. Handles board-change, board-index, and
208
+ * notification events over one EventSource to stay within the browser's
209
+ * per-origin HTTP/1.1 connection limit (6 in Chrome).
210
+ *
211
+ * Call sites:
212
+ * - `AppShell` → `useBoardChangeStream(null, null)` — shell-level
213
+ * (board-index + notifications).
214
+ * - `BoardView` → `useBoardChangeStream(routeId, resolvedId)` — adds
215
+ * board-scoped events on top.
216
+ *
217
+ * Both hooks register callbacks on a shared event bus. When BoardView mounts
218
+ * the connection upgrades to board-scoped but AppShell's callbacks (e.g.
219
+ * notification-created) keep working because they live in the registry, not
220
+ * on the old EventSource instance.
30
221
  */
31
222
  export function useBoardChangeStream(
32
223
  routeBoardId: string | number | null,
@@ -37,16 +228,55 @@ export function useBoardChangeStream(
37
228
  const eventBoardId =
38
229
  typeof routeKey === "number" ? routeKey : resolvedBoardId;
39
230
 
231
+ const panelOpenRef = useRef(false);
232
+ panelOpenRef.current = useNotificationUiStore((s) => s.panelOpen);
233
+ const pushToastRef = useRef(useNotificationUiStore((s) => s.pushToast));
234
+ pushToastRef.current = useNotificationUiStore((s) => s.pushToast);
235
+
40
236
  useEffect(() => {
41
- if (eventBoardId == null) return;
237
+ acquire(eventBoardId);
238
+
239
+ const unsubs: (() => void)[] = [];
240
+ const isShellLevel = eventBoardId == null;
42
241
 
43
- const sseUrl = boardEventsUrl(eventBoardId);
44
- const es = new EventSource(sseUrl, { withCredentials: true });
242
+ // Board-index and notification handlers are registered only by the
243
+ // shell-level caller (AppShell, eventBoardId=null) to avoid duplicates
244
+ // when both AppShell and BoardView are mounted simultaneously.
245
+ if (isShellLevel) {
246
+ unsubs.push(
247
+ subscribe("board-index-changed", () => {
248
+ void qc.invalidateQueries({ queryKey: boardKeys.all, exact: true });
249
+ }),
250
+ );
45
251
 
46
- const boardCacheKeys: ReadonlyArray<readonly unknown[]> = [
47
- ...(routeKey != null ? [[...boardKeys.all, routeKey] as const] : []),
48
- ...(routeKey !== eventBoardId ? [boardKeys.detail(eventBoardId)] : []),
49
- ];
252
+ const browserInstanceId = getBrowserClientInstanceId();
253
+ unsubs.push(
254
+ subscribe("notification-created", (raw) => {
255
+ const event = JSON.parse(
256
+ (raw as MessageEvent<string>).data,
257
+ ) as NotificationCreatedEvent;
258
+ invalidateNotificationQueries(qc);
259
+ if (panelOpenRef.current) return;
260
+ if (event.notification.clientInstanceId === browserInstanceId) return;
261
+ const st = event.notification.sourceType;
262
+ if (st !== "cli" && st !== "system") return;
263
+ pushToastRef.current(event.notification);
264
+ }),
265
+ );
266
+ }
267
+
268
+ // --- board-detail handlers (active when viewing a board) ---
269
+ const boardCacheKeys: ReadonlyArray<readonly unknown[]> =
270
+ eventBoardId != null
271
+ ? [
272
+ ...(routeKey != null
273
+ ? [[...boardKeys.all, routeKey] as const]
274
+ : []),
275
+ ...(routeKey !== eventBoardId
276
+ ? [boardKeys.detail(eventBoardId)]
277
+ : []),
278
+ ]
279
+ : [];
50
280
 
51
281
  const getCurrentBoard = (): Board | undefined => {
52
282
  for (const key of boardCacheKeys) {
@@ -58,200 +288,223 @@ export function useBoardChangeStream(
58
288
 
59
289
  const setBoardCaches = (updater: (current: Board) => Board) => {
60
290
  for (const key of boardCacheKeys) {
61
- qc.setQueryData<Board>(key, (current) => (current ? updater(current) : current));
291
+ qc.setQueryData<Board>(key, (current) =>
292
+ current ? updater(current) : current,
293
+ );
62
294
  }
63
295
  };
64
296
 
65
- const invalidateBoard = () => {
66
- // Fall back to the canonical full-board read model when the event is structural
67
- // or when a targeted patch cannot be trusted.
68
- if (routeKey != null) {
69
- void qc.invalidateQueries({
70
- queryKey: [...boardKeys.all, routeKey],
71
- exact: true,
72
- });
73
- }
74
- if (routeKey !== eventBoardId) {
75
- void qc.invalidateQueries({
76
- queryKey: boardKeys.detail(eventBoardId),
77
- exact: true,
78
- });
79
- }
80
- invalidateBoardStatsQueries(qc, eventBoardId);
81
- };
297
+ const invalidateBoard = () =>
298
+ debouncedInvalidateBoard(qc, routeKey, eventBoardId);
82
299
 
83
- /** Only skip when cache is strictly newer than the event. Using `>=` would drop updates
84
- * when another writer's change shares the same board `updatedAt` ms as a prior bump
85
- * (multi-writer race); granular handlers still need to merge that row. */
86
300
  const isAlreadyApplied = (event: BoardEvent): boolean => {
301
+ if (event.kind === "board-index-changed") return false;
87
302
  const current = getCurrentBoard();
88
303
  if (!current) return false;
89
304
  return current.updatedAt > event.boardUpdatedAt;
90
305
  };
91
306
 
92
- const onBoardChanged = (raw: Event) => {
93
- const event = JSON.parse((raw as MessageEvent<string>).data) as BoardEvent;
94
- if (isAlreadyApplied(event)) return;
95
- invalidateBoard();
96
- };
307
+ unsubs.push(
308
+ subscribe("board-changed", (raw) => {
309
+ const event = JSON.parse(
310
+ (raw as MessageEvent<string>).data,
311
+ ) as BoardEvent;
312
+ if (event.kind !== "board-changed") return;
313
+ invalidateBoard();
314
+ }),
315
+ );
316
+
317
+ unsubs.push(
318
+ subscribe("release-upserted", (raw) => {
319
+ const event = JSON.parse(
320
+ (raw as MessageEvent<string>).data,
321
+ ) as BoardEvent;
322
+ if (event.kind !== "release-upserted") return;
323
+ if (!isAlreadyApplied(event)) {
324
+ const current = getCurrentBoard();
325
+ if (!current) {
326
+ invalidateBoard();
327
+ return;
328
+ }
329
+ setBoardCaches((b) => ({
330
+ ...b,
331
+ releases: mergeReleaseUpsertIntoList(b.releases, event.release),
332
+ updatedAt: event.boardUpdatedAt,
333
+ }));
334
+ }
335
+ invalidateBoardStatsQueries(qc, event.boardId);
336
+ }),
337
+ );
338
+
339
+ unsubs.push(
340
+ subscribe("task-created", onTaskCreatedOrUpdated),
341
+ subscribe("task-updated", onTaskCreatedOrUpdated),
342
+ );
343
+
344
+ unsubs.push(
345
+ subscribe("task-deleted", onTaskRemovedFromLiveBoard),
346
+ subscribe("task-trashed", onTaskRemovedFromLiveBoard),
347
+ subscribe("task-purged", onTaskRemovedFromLiveBoard),
348
+ );
349
+
350
+ unsubs.push(
351
+ subscribe("list-created", onListCreatedOrUpdated),
352
+ subscribe("list-updated", onListCreatedOrUpdated),
353
+ );
97
354
 
98
- const onReleaseUpserted = (raw: Event) => {
99
- const event = JSON.parse((raw as MessageEvent<string>).data) as BoardEvent;
100
- if (event.kind !== "release-upserted" || isAlreadyApplied(event)) {
355
+ unsubs.push(
356
+ subscribe("list-deleted", onListRemovedFromLiveBoard),
357
+ subscribe("list-trashed", onListRemovedFromLiveBoard),
358
+ subscribe("list-purged", onListRemovedFromLiveBoard),
359
+ );
360
+
361
+ unsubs.push(
362
+ subscribe("task-restored", onTaskOrListRestored),
363
+ subscribe("list-restored", onTaskOrListRestored),
364
+ );
365
+
366
+ // --- handler definitions (hoisted for subscribe calls above) ---
367
+
368
+ function onTaskCreatedOrUpdated(raw: Event): void {
369
+ const event = JSON.parse(
370
+ (raw as MessageEvent<string>).data,
371
+ ) as BoardEvent;
372
+ if (event.kind !== "task-created" && event.kind !== "task-updated") {
101
373
  return;
102
374
  }
103
- const current = getCurrentBoard();
104
- if (!current) {
375
+ if (isAlreadyApplied(event)) {
105
376
  invalidateBoard();
106
377
  return;
107
378
  }
108
- setBoardCaches((b) => ({
109
- ...b,
110
- releases: mergeReleaseUpsertIntoList(b.releases, event.release),
111
- updatedAt: event.boardUpdatedAt,
112
- }));
113
- invalidateBoardStatsQueries(qc, event.boardId);
114
- };
379
+ void (async () => {
380
+ try {
381
+ const task = await fetchBoardTask(event.boardId, event.taskId);
382
+ qc.setQueryData(
383
+ boardTaskDetailKey(event.boardId, event.taskId),
384
+ task,
385
+ );
386
+ setBoardCaches((current) => {
387
+ const exists = current.tasks.some(
388
+ (item) => item.taskId === task.taskId,
389
+ );
390
+ return {
391
+ ...current,
392
+ tasks: exists
393
+ ? current.tasks.map((item) =>
394
+ item.taskId === task.taskId ? task : item,
395
+ )
396
+ : [...current.tasks, task],
397
+ updatedAt: event.boardUpdatedAt,
398
+ };
399
+ });
400
+ invalidateBoard();
401
+ } catch {
402
+ invalidateBoard();
403
+ }
404
+ })();
405
+ }
115
406
 
116
- const onTaskCreatedOrUpdated = async (raw: Event) => {
117
- const event = JSON.parse((raw as MessageEvent<string>).data) as BoardEvent;
407
+ function onTaskRemovedFromLiveBoard(raw: Event): void {
408
+ const event = JSON.parse(
409
+ (raw as MessageEvent<string>).data,
410
+ ) as BoardEvent;
118
411
  if (
119
- (event.kind !== "task-created" && event.kind !== "task-updated") ||
120
- isAlreadyApplied(event)
412
+ event.kind !== "task-deleted" &&
413
+ event.kind !== "task-trashed" &&
414
+ event.kind !== "task-purged"
121
415
  ) {
122
416
  return;
123
417
  }
124
- try {
125
- const task = await fetchBoardTask(event.boardId, event.taskId);
126
- qc.setQueryData(boardTaskDetailKey(event.boardId, event.taskId), task);
127
- setBoardCaches((current) => {
128
- const exists = current.tasks.some((item) => item.taskId === task.taskId);
129
- return {
130
- ...current,
131
- tasks: exists
132
- ? current.tasks.map((item) =>
133
- item.taskId === task.taskId ? task : item,
134
- )
135
- : [...current.tasks, task],
136
- updatedAt: event.boardUpdatedAt,
137
- };
138
- });
139
- // Keep the partial patch for responsiveness, then refetch the active board so
140
- // every external task write converges even if a future patch path misses detail.
141
- invalidateBoard();
142
- } catch {
143
- invalidateBoard();
418
+ if (!isAlreadyApplied(event)) {
419
+ setBoardCaches((current) => ({
420
+ ...current,
421
+ tasks: current.tasks.filter(
422
+ (task) => task.taskId !== event.taskId,
423
+ ),
424
+ updatedAt: event.boardUpdatedAt,
425
+ }));
144
426
  }
145
- };
146
-
147
- const onTaskRemovedFromLiveBoard = (raw: Event) => {
148
- const event = JSON.parse((raw as MessageEvent<string>).data) as BoardEvent;
149
- if (
150
- (event.kind !== "task-deleted" &&
151
- event.kind !== "task-trashed" &&
152
- event.kind !== "task-purged") ||
153
- isAlreadyApplied(event)
154
- ) {
155
- return;
156
- }
157
- setBoardCaches((current) => ({
158
- ...current,
159
- tasks: current.tasks.filter((task) => task.taskId !== event.taskId),
160
- updatedAt: event.boardUpdatedAt,
161
- }));
162
427
  invalidateBoard();
163
- };
428
+ }
164
429
 
165
- const onListCreatedOrUpdated = async (raw: Event) => {
166
- const event = JSON.parse((raw as MessageEvent<string>).data) as BoardEvent;
167
- if (
168
- (event.kind !== "list-created" && event.kind !== "list-updated") ||
169
- isAlreadyApplied(event)
170
- ) {
430
+ function onListCreatedOrUpdated(raw: Event): void {
431
+ const event = JSON.parse(
432
+ (raw as MessageEvent<string>).data,
433
+ ) as BoardEvent;
434
+ if (event.kind !== "list-created" && event.kind !== "list-updated") {
171
435
  return;
172
436
  }
173
- try {
174
- const list = await fetchBoardList(event.boardId, event.listId);
175
- setBoardCaches((current) => {
176
- const exists = current.lists.some((item) => item.listId === list.listId);
177
- return {
178
- ...current,
179
- lists: exists
180
- ? current.lists.map((item) =>
181
- item.listId === list.listId ? list : item,
182
- )
183
- : [...current.lists, list],
184
- updatedAt: event.boardUpdatedAt,
185
- };
186
- });
187
- // Keep the partial patch for responsiveness, then refetch the active board so
188
- // every external list write converges even if a future patch path misses detail.
189
- invalidateBoard();
190
- } catch {
437
+ if (isAlreadyApplied(event)) {
191
438
  invalidateBoard();
439
+ return;
192
440
  }
193
- };
441
+ void (async () => {
442
+ try {
443
+ const list = await fetchBoardList(event.boardId, event.listId);
444
+ setBoardCaches((current) => {
445
+ const exists = current.lists.some(
446
+ (item) => item.listId === list.listId,
447
+ );
448
+ return {
449
+ ...current,
450
+ lists: exists
451
+ ? current.lists.map((item) =>
452
+ item.listId === list.listId ? list : item,
453
+ )
454
+ : [...current.lists, list],
455
+ updatedAt: event.boardUpdatedAt,
456
+ };
457
+ });
458
+ invalidateBoard();
459
+ } catch {
460
+ invalidateBoard();
461
+ }
462
+ })();
463
+ }
194
464
 
195
- const onListRemovedFromLiveBoard = (raw: Event) => {
196
- const event = JSON.parse((raw as MessageEvent<string>).data) as BoardEvent;
465
+ function onListRemovedFromLiveBoard(raw: Event): void {
466
+ const event = JSON.parse(
467
+ (raw as MessageEvent<string>).data,
468
+ ) as BoardEvent;
197
469
  if (
198
- (event.kind !== "list-deleted" &&
199
- event.kind !== "list-trashed" &&
200
- event.kind !== "list-purged") ||
201
- isAlreadyApplied(event)
470
+ event.kind !== "list-deleted" &&
471
+ event.kind !== "list-trashed" &&
472
+ event.kind !== "list-purged"
202
473
  ) {
203
474
  return;
204
475
  }
205
- setBoardCaches((current) => ({
206
- ...current,
207
- lists: current.lists.filter((list) => list.listId !== event.listId),
208
- tasks: current.tasks.filter((task) => task.listId !== event.listId),
209
- updatedAt: event.boardUpdatedAt,
210
- }));
476
+ if (!isAlreadyApplied(event)) {
477
+ setBoardCaches((current) => ({
478
+ ...current,
479
+ lists: current.lists.filter(
480
+ (list) => list.listId !== event.listId,
481
+ ),
482
+ tasks: current.tasks.filter(
483
+ (task) => task.listId !== event.listId,
484
+ ),
485
+ updatedAt: event.boardUpdatedAt,
486
+ }));
487
+ }
211
488
  invalidateBoard();
212
- };
489
+ }
213
490
 
214
- const onTaskOrListRestored = (raw: Event) => {
215
- const event = JSON.parse((raw as MessageEvent<string>).data) as BoardEvent;
216
- if (
217
- (event.kind !== "task-restored" && event.kind !== "list-restored") ||
218
- isAlreadyApplied(event)
219
- ) {
491
+ function onTaskOrListRestored(raw: Event): void {
492
+ const event = JSON.parse(
493
+ (raw as MessageEvent<string>).data,
494
+ ) as BoardEvent;
495
+ if (event.kind !== "task-restored" && event.kind !== "list-restored") {
220
496
  return;
221
497
  }
222
498
  invalidateBoard();
223
- };
499
+ }
224
500
 
225
- es.addEventListener("board-changed", onBoardChanged);
226
- es.addEventListener("release-upserted", onReleaseUpserted);
227
- es.addEventListener("task-created", onTaskCreatedOrUpdated);
228
- es.addEventListener("task-updated", onTaskCreatedOrUpdated);
229
- es.addEventListener("task-deleted", onTaskRemovedFromLiveBoard);
230
- es.addEventListener("task-trashed", onTaskRemovedFromLiveBoard);
231
- es.addEventListener("task-purged", onTaskRemovedFromLiveBoard);
232
- es.addEventListener("task-restored", onTaskOrListRestored);
233
- es.addEventListener("list-created", onListCreatedOrUpdated);
234
- es.addEventListener("list-updated", onListCreatedOrUpdated);
235
- es.addEventListener("list-deleted", onListRemovedFromLiveBoard);
236
- es.addEventListener("list-trashed", onListRemovedFromLiveBoard);
237
- es.addEventListener("list-purged", onListRemovedFromLiveBoard);
238
- es.addEventListener("list-restored", onTaskOrListRestored);
239
501
  return () => {
240
- es.removeEventListener("board-changed", onBoardChanged);
241
- es.removeEventListener("release-upserted", onReleaseUpserted);
242
- es.removeEventListener("task-created", onTaskCreatedOrUpdated);
243
- es.removeEventListener("task-updated", onTaskCreatedOrUpdated);
244
- es.removeEventListener("task-deleted", onTaskRemovedFromLiveBoard);
245
- es.removeEventListener("task-trashed", onTaskRemovedFromLiveBoard);
246
- es.removeEventListener("task-purged", onTaskRemovedFromLiveBoard);
247
- es.removeEventListener("task-restored", onTaskOrListRestored);
248
- es.removeEventListener("list-created", onListCreatedOrUpdated);
249
- es.removeEventListener("list-updated", onListCreatedOrUpdated);
250
- es.removeEventListener("list-deleted", onListRemovedFromLiveBoard);
251
- es.removeEventListener("list-trashed", onListRemovedFromLiveBoard);
252
- es.removeEventListener("list-purged", onListRemovedFromLiveBoard);
253
- es.removeEventListener("list-restored", onTaskOrListRestored);
254
- es.close();
502
+ if (invalidateTimer != null) {
503
+ clearTimeout(invalidateTimer);
504
+ invalidateTimer = null;
505
+ }
506
+ for (const unsub of unsubs) unsub();
507
+ release();
255
508
  };
256
509
  }, [eventBoardId, qc, routeKey]);
257
510
  }