@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.
- package/README.md +1 -1
- package/dist/assets/index-BpzHnKdP.css +1 -0
- package/dist/assets/index-DmNErTAP.js +273 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/skills/hiro-task-manager-cli/SKILL.md +6 -4
- package/skills/hiro-task-manager-cli/reference/cli-access-policy.md +1 -0
- package/skills/hiro-task-manager-cli/reference/releases.md +14 -0
- package/src/cli/commands/query.ts +56 -56
- package/src/cli/commands/releases.ts +22 -0
- package/src/cli/handlers/boards.test.ts +669 -669
- package/src/cli/handlers/cli-wiring.test.ts +38 -1
- package/src/cli/handlers/releases.ts +15 -0
- package/src/cli/handlers/search.test.ts +374 -374
- package/src/cli/handlers/search.ts +17 -17
- package/src/cli/lib/cli-http-errors.test.ts +85 -85
- package/src/cli/lib/write/releases.ts +64 -1
- package/src/cli/lib/write-result.test.ts +3 -0
- package/src/cli/lib/write-result.ts +3 -0
- package/src/cli/lib/writeCommands.breadth.test.ts +143 -0
- package/src/cli/lib/writeCommands.ts +1 -0
- package/src/cli/subprocess.real-stack.test.ts +625 -611
- package/src/cli/subprocess.smoke.test.ts +954 -954
- package/src/client/api/useBoardChangeStream.ts +421 -168
- package/src/client/api/useBoardIndexStream.ts +35 -0
- package/src/client/components/board/BoardStatsChips.tsx +233 -233
- package/src/client/components/board/BoardStatsContext.tsx +41 -41
- package/src/client/components/board/boardHeaderButtonStyles.ts +38 -38
- package/src/client/components/board/shortcuts/useBoardShortcutKeydown.ts +49 -49
- package/src/client/components/board/useBoardCanvasPanScroll.ts +108 -108
- package/src/client/components/board/useBoardTaskContainerDroppableReact.ts +33 -33
- package/src/client/components/board/useBoardTaskSortableReact.ts +26 -26
- package/src/client/components/layout/AppShell.tsx +5 -2
- package/src/client/components/layout/NotificationToasts.tsx +38 -1
- package/src/client/components/multi-select.tsx +1206 -1206
- package/src/client/components/routing/BoardPage.tsx +20 -20
- package/src/client/components/routing/NavigationRegistrar.tsx +13 -13
- package/src/client/components/task/TaskCard.tsx +643 -643
- package/src/client/components/ui/badge.tsx +49 -49
- package/src/client/components/ui/button.tsx +65 -65
- package/src/client/components/ui/command.tsx +193 -193
- package/src/client/components/ui/dialog.tsx +163 -163
- package/src/client/components/ui/input-group.tsx +155 -155
- package/src/client/components/ui/input.tsx +19 -19
- package/src/client/components/ui/popover.tsx +87 -87
- package/src/client/components/ui/separator.tsx +28 -28
- package/src/client/components/ui/textarea.tsx +18 -18
- package/src/client/index.css +248 -248
- package/src/client/lib/appNavigate.ts +16 -16
- package/src/client/lib/taskCardDate.ts +111 -111
- package/src/client/lib/utils.ts +6 -6
- package/src/client/store/notificationUi.ts +14 -0
- package/src/server/auth.ts +351 -351
- package/src/server/events.ts +31 -4
- package/src/server/migrations/registry.ts +43 -43
- package/src/server/notificationEvents.ts +8 -1
- package/src/server/routes/boards.ts +15 -1
- package/src/server/routes/trash.ts +6 -1
- package/src/shared/boardEvents.ts +6 -0
- package/src/shared/runtimeConfig.ts +256 -256
- package/dist/assets/index-hMFTu7sr.css +0 -1
- 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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
*
|
|
29
|
-
*
|
|
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
|
-
|
|
237
|
+
acquire(eventBoardId);
|
|
238
|
+
|
|
239
|
+
const unsubs: (() => void)[] = [];
|
|
240
|
+
const isShellLevel = eventBoardId == null;
|
|
42
241
|
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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) =>
|
|
291
|
+
qc.setQueryData<Board>(key, (current) =>
|
|
292
|
+
current ? updater(current) : current,
|
|
293
|
+
);
|
|
62
294
|
}
|
|
63
295
|
};
|
|
64
296
|
|
|
65
|
-
const invalidateBoard = () =>
|
|
66
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
if (!current) {
|
|
375
|
+
if (isAlreadyApplied(event)) {
|
|
105
376
|
invalidateBoard();
|
|
106
377
|
return;
|
|
107
378
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
const event = JSON.parse(
|
|
407
|
+
function onTaskRemovedFromLiveBoard(raw: Event): void {
|
|
408
|
+
const event = JSON.parse(
|
|
409
|
+
(raw as MessageEvent<string>).data,
|
|
410
|
+
) as BoardEvent;
|
|
118
411
|
if (
|
|
119
|
-
|
|
120
|
-
|
|
412
|
+
event.kind !== "task-deleted" &&
|
|
413
|
+
event.kind !== "task-trashed" &&
|
|
414
|
+
event.kind !== "task-purged"
|
|
121
415
|
) {
|
|
122
416
|
return;
|
|
123
417
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
166
|
-
const event = JSON.parse(
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
const event = JSON.parse(
|
|
465
|
+
function onListRemovedFromLiveBoard(raw: Event): void {
|
|
466
|
+
const event = JSON.parse(
|
|
467
|
+
(raw as MessageEvent<string>).data,
|
|
468
|
+
) as BoardEvent;
|
|
197
469
|
if (
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
215
|
-
const event = JSON.parse(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
}
|