trekoon 0.4.0 → 0.4.2
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/.agents/skills/trekoon/SKILL.md +20 -577
- package/.agents/skills/trekoon/reference/execution-with-team.md +21 -9
- package/.agents/skills/trekoon/reference/execution.md +246 -7
- package/.agents/skills/trekoon/reference/planning.md +138 -1
- package/.agents/skills/trekoon/reference/status-machine.md +21 -0
- package/.agents/skills/trekoon/reference/sync.md +129 -0
- package/README.md +8 -7
- package/docs/ai-agents.md +17 -2
- package/docs/commands.md +147 -3
- package/docs/machine-contracts.md +123 -0
- package/docs/quickstart.md +52 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +49 -16
- package/src/board/assets/components/Component.js +22 -8
- package/src/board/assets/components/Workspace.js +9 -3
- package/src/board/assets/components/helpers.js +5 -1
- package/src/board/assets/runtime/delegation.js +8 -0
- package/src/board/assets/runtime/focus-trap.js +48 -0
- package/src/board/assets/state/actions.js +47 -4
- package/src/board/assets/state/api.js +284 -11
- package/src/board/assets/state/store.js +87 -11
- package/src/board/assets/state/url.js +10 -0
- package/src/board/assets/state/utils.js +2 -1
- package/src/board/event-bus.ts +72 -0
- package/src/board/routes.ts +412 -33
- package/src/board/server.ts +77 -8
- package/src/board/wal-watcher.ts +302 -0
- package/src/commands/board.ts +52 -17
- package/src/commands/epic.ts +7 -9
- package/src/commands/error-utils.ts +54 -1
- package/src/commands/help.ts +69 -4
- package/src/commands/migrate.ts +153 -24
- package/src/commands/quickstart.ts +7 -0
- package/src/commands/subtask.ts +71 -10
- package/src/commands/suggest.ts +6 -13
- package/src/commands/task.ts +137 -88
- package/src/domain/batch-validation.ts +329 -0
- package/src/domain/cascade-planner.ts +412 -0
- package/src/domain/dependency-rules.ts +15 -0
- package/src/domain/mutation-service.ts +828 -192
- package/src/domain/search.ts +113 -0
- package/src/domain/tracker-domain.ts +150 -680
- package/src/domain/types.ts +53 -2
- package/src/index.ts +37 -0
- package/src/runtime/cli-shell.ts +44 -0
- package/src/runtime/daemon.ts +639 -0
- package/src/storage/backup.ts +166 -0
- package/src/storage/database.ts +261 -4
- package/src/storage/migrations.ts +422 -20
- package/src/storage/path.ts +8 -0
- package/src/storage/schema.ts +5 -1
- package/src/sync/event-writes.ts +38 -11
- package/src/sync/git-context.ts +226 -8
- package/src/sync/service.ts +650 -147
|
@@ -13,6 +13,133 @@ function cloneSnapshot(snapshot) {
|
|
|
13
13
|
return JSON.parse(JSON.stringify(snapshot));
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
const SNAPSHOT_COLLECTIONS = ["epics", "tasks", "subtasks", "dependencies"];
|
|
17
|
+
|
|
18
|
+
const COLLECTION_TO_DELETED_KEY = {
|
|
19
|
+
epics: "deletedEpicIds",
|
|
20
|
+
tasks: "deletedTaskIds",
|
|
21
|
+
subtasks: "deletedSubtaskIds",
|
|
22
|
+
dependencies: "deletedDependencyIds",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function arraysShallowEqual(left, right) {
|
|
26
|
+
if (left === right) return true;
|
|
27
|
+
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
|
28
|
+
if (left.length !== right.length) return false;
|
|
29
|
+
for (let i = 0; i < left.length; i += 1) {
|
|
30
|
+
if (left[i] !== right[i]) return false;
|
|
31
|
+
}
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Shallow equality on plain board records.
|
|
37
|
+
*
|
|
38
|
+
* Board records are flat objects whose values are primitives or arrays of
|
|
39
|
+
* primitives (e.g. dependency-id arrays). A field-by-field shallow comparison
|
|
40
|
+
* is therefore equivalent to a structural deep-equal but avoids the
|
|
41
|
+
* O(snapshot) JSON.stringify cost the previous implementation paid on every
|
|
42
|
+
* rollback. Cross-realm values and odd nested objects fall back to
|
|
43
|
+
* reference equality, which is a safe over-restore (worst case: an unchanged
|
|
44
|
+
* record is included in the inverse delta).
|
|
45
|
+
*/
|
|
46
|
+
function recordsShallowEqual(left, right) {
|
|
47
|
+
if (left === right) return true;
|
|
48
|
+
if (!left || !right || typeof left !== "object" || typeof right !== "object") return false;
|
|
49
|
+
const leftKeys = Object.keys(left);
|
|
50
|
+
const rightKeys = Object.keys(right);
|
|
51
|
+
if (leftKeys.length !== rightKeys.length) return false;
|
|
52
|
+
for (const key of leftKeys) {
|
|
53
|
+
const leftValue = left[key];
|
|
54
|
+
const rightValue = right[key];
|
|
55
|
+
if (leftValue === rightValue) continue;
|
|
56
|
+
if (Array.isArray(leftValue) || Array.isArray(rightValue)) {
|
|
57
|
+
if (!arraysShallowEqual(leftValue, rightValue)) return false;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function indexById(records) {
|
|
66
|
+
const map = new Map();
|
|
67
|
+
if (!Array.isArray(records)) {
|
|
68
|
+
return map;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const record of records) {
|
|
72
|
+
if (record && typeof record === "object" && typeof record.id === "string" && record.id.length > 0) {
|
|
73
|
+
map.set(record.id, record);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return map;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Compute the inverse delta needed to revert an optimistic mutation.
|
|
82
|
+
*
|
|
83
|
+
* The inverse is built by diffing the snapshot _before_ the optimistic patch
|
|
84
|
+
* against the snapshot _after_ the patch. We only describe entities the
|
|
85
|
+
* optimistic patch actually touched so that concurrent deltas pushed by the
|
|
86
|
+
* server (for unrelated entities) are preserved when we apply the inverse.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} previousSnapshot - Snapshot prior to optimistic apply.
|
|
89
|
+
* @param {object} optimisticSnapshot - Snapshot after optimistic apply.
|
|
90
|
+
* @returns {{
|
|
91
|
+
* epics?: object[], tasks?: object[], subtasks?: object[], dependencies?: object[],
|
|
92
|
+
* deletedEpicIds?: string[], deletedTaskIds?: string[], deletedSubtaskIds?: string[], deletedDependencyIds?: string[],
|
|
93
|
+
* }}
|
|
94
|
+
*/
|
|
95
|
+
export function computeInverseDelta(previousSnapshot, optimisticSnapshot) {
|
|
96
|
+
const inverse = {};
|
|
97
|
+
for (const collection of SNAPSHOT_COLLECTIONS) {
|
|
98
|
+
const before = indexById(previousSnapshot?.[collection]);
|
|
99
|
+
const after = indexById(optimisticSnapshot?.[collection]);
|
|
100
|
+
|
|
101
|
+
const restored = [];
|
|
102
|
+
const deletedIds = [];
|
|
103
|
+
|
|
104
|
+
// Entities that the optimistic patch deleted -> restore them.
|
|
105
|
+
for (const [id, beforeRecord] of before) {
|
|
106
|
+
if (!after.has(id)) {
|
|
107
|
+
restored.push(beforeRecord);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Entities present in both but mutated -> restore the previous version.
|
|
112
|
+
// cloneSnapshot/normalizeSnapshot always produce fresh references for
|
|
113
|
+
// every record in the optimistic snapshot, so plain reference inequality
|
|
114
|
+
// would flag every entity. Use a shallow field-by-field equality check
|
|
115
|
+
// instead — equivalent to a structural compare for these flat records but
|
|
116
|
+
// O(field) per record rather than O(snapshot) JSON.stringify on each
|
|
117
|
+
// rollback (P2 perf finding).
|
|
118
|
+
for (const [id, afterRecord] of after) {
|
|
119
|
+
const beforeRecord = before.get(id);
|
|
120
|
+
if (beforeRecord && beforeRecord !== afterRecord && !recordsShallowEqual(beforeRecord, afterRecord)) {
|
|
121
|
+
restored.push(beforeRecord);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Entities the optimistic patch added -> mark for deletion.
|
|
126
|
+
for (const id of after.keys()) {
|
|
127
|
+
if (!before.has(id)) {
|
|
128
|
+
deletedIds.push(id);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (restored.length > 0) {
|
|
133
|
+
inverse[collection] = restored;
|
|
134
|
+
}
|
|
135
|
+
if (deletedIds.length > 0) {
|
|
136
|
+
inverse[COLLECTION_TO_DELETED_KEY[collection]] = deletedIds;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return inverse;
|
|
141
|
+
}
|
|
142
|
+
|
|
16
143
|
async function readJsonPayload(response) {
|
|
17
144
|
const text = await response.text();
|
|
18
145
|
if (text.length === 0) {
|
|
@@ -119,14 +246,27 @@ export function createMutationQueue(model, rerender) {
|
|
|
119
246
|
|
|
120
247
|
while (queue.length > 0) {
|
|
121
248
|
const mutation = queue.shift();
|
|
122
|
-
const previousSnapshot = cloneSnapshot(model.store.snapshot);
|
|
123
249
|
if (model.store.notice?.retryMutationId !== mutation.id) {
|
|
124
250
|
model.store.notice = null;
|
|
125
251
|
}
|
|
126
252
|
|
|
253
|
+
// Capture per-mutation inverse delta if the optimistic patch ran.
|
|
254
|
+
// Using an inverse delta (rather than wholesale replaceSnapshot) means
|
|
255
|
+
// concurrent server-pushed deltas applied to unrelated entities while
|
|
256
|
+
// the request was in flight survive a rollback.
|
|
257
|
+
let inverseDelta = null;
|
|
258
|
+
|
|
127
259
|
try {
|
|
128
260
|
if (typeof mutation.optimistic === "function") {
|
|
129
|
-
|
|
261
|
+
const previousSnapshot = cloneSnapshot(model.store.snapshot);
|
|
262
|
+
const optimisticSnapshot = mutation.optimistic(cloneSnapshot(model.store.snapshot));
|
|
263
|
+
inverseDelta = computeInverseDelta(previousSnapshot, optimisticSnapshot);
|
|
264
|
+
model.store.snapshot = optimisticSnapshot;
|
|
265
|
+
// Direct snapshot mutation bypasses setState/syncState; invalidate
|
|
266
|
+
// the memo so the next getBoardState() reflects the optimistic write.
|
|
267
|
+
if (typeof model.invalidateBoardStateMemo === "function") {
|
|
268
|
+
model.invalidateBoardStateMemo();
|
|
269
|
+
}
|
|
130
270
|
rerender();
|
|
131
271
|
}
|
|
132
272
|
|
|
@@ -146,8 +286,11 @@ export function createMutationQueue(model, rerender) {
|
|
|
146
286
|
? { type: "success", message: mutation.successMessage }
|
|
147
287
|
: null;
|
|
148
288
|
} catch (error) {
|
|
149
|
-
// Revert
|
|
150
|
-
|
|
289
|
+
// Revert only the entities this mutation touched. Any unrelated
|
|
290
|
+
// entities updated by concurrent server deltas remain intact.
|
|
291
|
+
if (inverseDelta) {
|
|
292
|
+
model.applySnapshotDelta(inverseDelta);
|
|
293
|
+
}
|
|
151
294
|
|
|
152
295
|
const message = error instanceof Error ? error.message : String(error);
|
|
153
296
|
model.store.notice = {
|
|
@@ -166,6 +309,9 @@ export function createMutationQueue(model, rerender) {
|
|
|
166
309
|
|
|
167
310
|
processing = false;
|
|
168
311
|
model.store.isMutating = false;
|
|
312
|
+
if (typeof model.invalidateBoardStateMemo === "function") {
|
|
313
|
+
model.invalidateBoardStateMemo();
|
|
314
|
+
}
|
|
169
315
|
rerender();
|
|
170
316
|
resolveFlushes();
|
|
171
317
|
}
|
|
@@ -204,20 +350,26 @@ export function createApi(model, options) {
|
|
|
204
350
|
let lastFailedMutation = null;
|
|
205
351
|
|
|
206
352
|
function enqueueMutation(definition) {
|
|
353
|
+
// Assign a stable identity token so success callbacks can clear
|
|
354
|
+
// lastFailedMutation by id rather than by function-reference equality
|
|
355
|
+
// (inline arrow functions are never the same reference across retries).
|
|
356
|
+
const mutationId = crypto.randomUUID();
|
|
357
|
+
const tagged = { ...definition, mutationId };
|
|
358
|
+
|
|
207
359
|
queue.enqueue({
|
|
208
|
-
...
|
|
360
|
+
...tagged,
|
|
209
361
|
onSuccess(data) {
|
|
210
|
-
if (lastFailedMutation?.
|
|
362
|
+
if (lastFailedMutation?.mutationId === mutationId) {
|
|
211
363
|
lastFailedMutation = null;
|
|
212
364
|
}
|
|
213
|
-
if (typeof
|
|
214
|
-
|
|
365
|
+
if (typeof tagged.onSuccess === "function") {
|
|
366
|
+
tagged.onSuccess(data);
|
|
215
367
|
}
|
|
216
368
|
},
|
|
217
369
|
onError(error) {
|
|
218
|
-
lastFailedMutation =
|
|
219
|
-
if (typeof
|
|
220
|
-
|
|
370
|
+
lastFailedMutation = tagged;
|
|
371
|
+
if (typeof tagged.onError === "function") {
|
|
372
|
+
tagged.onError(error);
|
|
221
373
|
}
|
|
222
374
|
},
|
|
223
375
|
});
|
|
@@ -403,3 +555,124 @@ export function createApi(model, options) {
|
|
|
403
555
|
},
|
|
404
556
|
};
|
|
405
557
|
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* Subscribe the board client to /api/snapshot/stream.
|
|
561
|
+
*
|
|
562
|
+
* Receives `snapshotDelta` events emitted by the per-server-instance event bus
|
|
563
|
+
* (own server mutations + WAL-watcher-derived deltas from external CLI writes)
|
|
564
|
+
* and applies them via `model.applySnapshotDelta`. Idempotent merge means
|
|
565
|
+
* re-applying a delta we already saw via the mutation response is harmless.
|
|
566
|
+
*
|
|
567
|
+
* Returns a `dispose()` function that closes the EventSource and stops
|
|
568
|
+
* processing further events.
|
|
569
|
+
*
|
|
570
|
+
* @param {object} model - Store with `applySnapshotDelta` method
|
|
571
|
+
* @param {object} options
|
|
572
|
+
* @param {string} options.sessionToken - Auth token (forwarded as ?token=)
|
|
573
|
+
* @param {function} options.rerender - Trigger UI rerender after applying deltas
|
|
574
|
+
* @param {typeof EventSource} [options.EventSourceCtor] - Constructor override for tests
|
|
575
|
+
* @param {string} [options.path] - Override stream path; default /api/snapshot/stream
|
|
576
|
+
* @returns {{ dispose: () => void, eventSource: EventSource | null }}
|
|
577
|
+
*/
|
|
578
|
+
export function subscribeSnapshotStream(model, options) {
|
|
579
|
+
const {
|
|
580
|
+
sessionToken,
|
|
581
|
+
rerender,
|
|
582
|
+
EventSourceCtor = typeof EventSource !== "undefined" ? EventSource : null,
|
|
583
|
+
path = "/api/snapshot/stream",
|
|
584
|
+
} = options ?? {};
|
|
585
|
+
|
|
586
|
+
if (!EventSourceCtor) {
|
|
587
|
+
return { dispose: () => {}, eventSource: null };
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// EventSource cannot set custom headers, so the auth token rides as a query
|
|
591
|
+
// parameter. Server `extractToken` already accepts ?token=.
|
|
592
|
+
const url = sessionToken && sessionToken.length > 0
|
|
593
|
+
? `${path}?token=${encodeURIComponent(sessionToken)}`
|
|
594
|
+
: path;
|
|
595
|
+
|
|
596
|
+
let disposed = false;
|
|
597
|
+
const eventSource = new EventSourceCtor(url);
|
|
598
|
+
|
|
599
|
+
const handleSnapshotDelta = (event) => {
|
|
600
|
+
if (disposed) return;
|
|
601
|
+
const raw = typeof event?.data === "string" ? event.data : "";
|
|
602
|
+
if (raw.length === 0) return;
|
|
603
|
+
let payload;
|
|
604
|
+
try {
|
|
605
|
+
payload = JSON.parse(raw);
|
|
606
|
+
} catch (error) {
|
|
607
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
608
|
+
// Surface malformed payloads so operators can spot serialization bugs.
|
|
609
|
+
// Avoid logging the raw text (may include sensitive content) — just length + error.
|
|
610
|
+
console.warn(`subscribeSnapshotStream: malformed snapshotDelta JSON (${raw.length} bytes): ${message}`);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const delta = payload?.snapshotDelta;
|
|
614
|
+
if (!delta || typeof delta !== "object") return;
|
|
615
|
+
model.applySnapshotDelta(delta);
|
|
616
|
+
if (typeof rerender === "function") rerender();
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const handleSnapshot = (event) => {
|
|
620
|
+
if (disposed) return;
|
|
621
|
+
const raw = typeof event?.data === "string" ? event.data : "";
|
|
622
|
+
if (raw.length === 0) return;
|
|
623
|
+
let payload;
|
|
624
|
+
try {
|
|
625
|
+
payload = JSON.parse(raw);
|
|
626
|
+
} catch (error) {
|
|
627
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
628
|
+
console.warn(`subscribeSnapshotStream: malformed snapshot JSON (${raw.length} bytes): ${message}`);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
const snapshot = payload?.snapshot;
|
|
632
|
+
if (!snapshot || typeof snapshot !== "object") return;
|
|
633
|
+
if (typeof model.replaceSnapshot === "function") {
|
|
634
|
+
model.replaceSnapshot(snapshot);
|
|
635
|
+
if (typeof rerender === "function") rerender();
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
const handleError = () => {
|
|
640
|
+
if (disposed) return;
|
|
641
|
+
// Surface the disconnect to the user so they don't silently miss live
|
|
642
|
+
// updates. EventSource will keep auto-reconnecting; once a snapshot/
|
|
643
|
+
// snapshotDelta event lands again, the regular notice clearing flow
|
|
644
|
+
// (e.g. on the next mutation) replaces this notice.
|
|
645
|
+
if (model.store && typeof model.store === "object") {
|
|
646
|
+
const existing = model.store.notice;
|
|
647
|
+
if (!existing || existing.code !== "live_updates_disconnected") {
|
|
648
|
+
model.store.notice = {
|
|
649
|
+
type: "warning",
|
|
650
|
+
code: "live_updates_disconnected",
|
|
651
|
+
title: "Live updates disconnected",
|
|
652
|
+
message: "Reconnecting to the server. Changes from other sessions may be delayed.",
|
|
653
|
+
};
|
|
654
|
+
if (typeof rerender === "function") rerender();
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
eventSource.addEventListener("snapshotDelta", handleSnapshotDelta);
|
|
660
|
+
eventSource.addEventListener("snapshot", handleSnapshot);
|
|
661
|
+
// EventSource calls .onerror on disconnect (and will continue auto-reconnecting).
|
|
662
|
+
// Use the onerror property rather than addEventListener("error") so tests can
|
|
663
|
+
// trigger it via `instance.onerror?.()` without a full event object.
|
|
664
|
+
eventSource.onerror = handleError;
|
|
665
|
+
|
|
666
|
+
return {
|
|
667
|
+
eventSource,
|
|
668
|
+
dispose() {
|
|
669
|
+
if (disposed) return;
|
|
670
|
+
disposed = true;
|
|
671
|
+
try {
|
|
672
|
+
eventSource.close();
|
|
673
|
+
} catch {
|
|
674
|
+
// best-effort
|
|
675
|
+
}
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
}
|
|
@@ -116,9 +116,19 @@ export function orderEpicsNewestFirst(epics) {
|
|
|
116
116
|
|
|
117
117
|
/** Recently-done epics stay visible for 24h even when the "done" filter is off. */
|
|
118
118
|
const DONE_GRACE_PERIOD_MS = 86400000;
|
|
119
|
+
/**
|
|
120
|
+
* Bucket Date.now() into hour-aligned chunks so the memoized epic selector
|
|
121
|
+
* re-evaluates the 24h grace cutoff at most once per hour on a long-open page.
|
|
122
|
+
*/
|
|
123
|
+
const GRACE_BUCKET_MS = 3600000;
|
|
119
124
|
|
|
120
125
|
const selectVisibleEpics = createSelector(
|
|
121
|
-
(s) => [
|
|
126
|
+
(s) => [
|
|
127
|
+
s.snapshot?.epics,
|
|
128
|
+
s.searchQuery,
|
|
129
|
+
s.epicStatusFilter,
|
|
130
|
+
Math.floor(Date.now() / GRACE_BUCKET_MS),
|
|
131
|
+
],
|
|
122
132
|
(epics, searchQuery, epicStatusFilter) => {
|
|
123
133
|
if (!epics) return [];
|
|
124
134
|
const now = Date.now();
|
|
@@ -224,6 +234,8 @@ function selectSearchScope(state) {
|
|
|
224
234
|
* @property {object|null} selectedTask
|
|
225
235
|
* @property {string|null} selectedSubtaskId
|
|
226
236
|
* @property {object|null} selectedSubtask
|
|
237
|
+
* @property {boolean} taskModalOpen
|
|
238
|
+
* @property {boolean} subtaskModalOpen
|
|
227
239
|
* @property {string} search
|
|
228
240
|
* @property {string} searchQuery
|
|
229
241
|
* @property {object} searchScope
|
|
@@ -231,6 +243,38 @@ function selectSearchScope(state) {
|
|
|
231
243
|
* @property {object[]} visibleTasks
|
|
232
244
|
*/
|
|
233
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Create a memoizer for deriveBoardState that returns the same reference until
|
|
248
|
+
* invalidate() is called or the hour bucket changes (so time-sensitive
|
|
249
|
+
* selectors like selectVisibleEpics can re-evaluate the 24h grace cutoff).
|
|
250
|
+
* Designed to be invalidated by the store's notify().
|
|
251
|
+
* @param {(state: object) => BoardState} compute
|
|
252
|
+
*/
|
|
253
|
+
function createBoardStateMemo(compute) {
|
|
254
|
+
let cachedState = null;
|
|
255
|
+
let cachedBucket = null;
|
|
256
|
+
let cachedResult = null;
|
|
257
|
+
let dirty = true;
|
|
258
|
+
|
|
259
|
+
function get(state) {
|
|
260
|
+
const bucket = Math.floor(Date.now() / GRACE_BUCKET_MS);
|
|
261
|
+
if (!dirty && cachedState === state && cachedBucket === bucket) {
|
|
262
|
+
return cachedResult;
|
|
263
|
+
}
|
|
264
|
+
cachedState = state;
|
|
265
|
+
cachedBucket = bucket;
|
|
266
|
+
cachedResult = compute(state);
|
|
267
|
+
dirty = false;
|
|
268
|
+
return cachedResult;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function invalidate() {
|
|
272
|
+
dirty = true;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { get, invalidate };
|
|
276
|
+
}
|
|
277
|
+
|
|
234
278
|
/**
|
|
235
279
|
* Compute full derived board state from the internal state, using memoized selectors.
|
|
236
280
|
* @param {object} state
|
|
@@ -264,6 +308,9 @@ function deriveBoardState(state) {
|
|
|
264
308
|
: stateWithScreen;
|
|
265
309
|
const selectedSubtask = selectedTaskId ? selectSelectedSubtask(stateWithTaskSelection) : null;
|
|
266
310
|
|
|
311
|
+
const taskModalOpen = state.taskModalOpen === true;
|
|
312
|
+
const subtaskModalOpen = state.subtaskModalOpen === true && selectedSubtask !== null;
|
|
313
|
+
|
|
267
314
|
return {
|
|
268
315
|
screen,
|
|
269
316
|
selectedEpicId: selectedEpic?.id ?? null,
|
|
@@ -272,6 +319,8 @@ function deriveBoardState(state) {
|
|
|
272
319
|
selectedTask: selectedTaskId ? selectedTask : null,
|
|
273
320
|
selectedSubtaskId: selectedSubtask?.id ?? null,
|
|
274
321
|
selectedSubtask,
|
|
322
|
+
taskModalOpen,
|
|
323
|
+
subtaskModalOpen,
|
|
275
324
|
search: stateWithScreen.search,
|
|
276
325
|
searchQuery: stateWithScreen.searchQuery,
|
|
277
326
|
searchScope: selectSearchScope(stateWithScreen),
|
|
@@ -289,6 +338,8 @@ function reconcileBoardState(state) {
|
|
|
289
338
|
view: VIEW_MODES.includes(state.view) ? state.view : "kanban",
|
|
290
339
|
selectedTaskId: derived.selectedTaskId,
|
|
291
340
|
selectedSubtaskId: derived.selectedSubtaskId,
|
|
341
|
+
taskModalOpen: derived.taskModalOpen,
|
|
342
|
+
subtaskModalOpen: derived.subtaskModalOpen,
|
|
292
343
|
};
|
|
293
344
|
}
|
|
294
345
|
|
|
@@ -331,11 +382,14 @@ export function createStore(initialSnapshot, options = {}) {
|
|
|
331
382
|
view: VIEW_MODES.includes(storedState.view) ? storedState.view : "kanban",
|
|
332
383
|
selectedTaskId: typeof storedState.selectedTaskId === "string" ? storedState.selectedTaskId : null,
|
|
333
384
|
selectedSubtaskId: null,
|
|
385
|
+
taskModalOpen: false,
|
|
386
|
+
subtaskModalOpen: false,
|
|
334
387
|
theme: readThemePreference(),
|
|
335
388
|
focusedEpicIndex: 0,
|
|
336
389
|
copyFeedback: null,
|
|
337
390
|
notice: null,
|
|
338
391
|
isMutating: false,
|
|
392
|
+
dragFeedback: null,
|
|
339
393
|
notesPanelOpen: storedState.notesPanelOpen === true,
|
|
340
394
|
epicStatusFilter: readStatusFilter(storedState.epicStatusFilter),
|
|
341
395
|
taskStatusFilter: readStatusFilter(storedState.taskStatusFilter),
|
|
@@ -344,7 +398,10 @@ export function createStore(initialSnapshot, options = {}) {
|
|
|
344
398
|
/** @type {Set<(state: object) => void>} */
|
|
345
399
|
const listeners = new Set();
|
|
346
400
|
|
|
401
|
+
const boardStateMemo = createBoardStateMemo(deriveBoardState);
|
|
402
|
+
|
|
347
403
|
function notify() {
|
|
404
|
+
boardStateMemo.invalidate();
|
|
348
405
|
for (const listener of listeners) {
|
|
349
406
|
listener(state);
|
|
350
407
|
}
|
|
@@ -355,12 +412,12 @@ export function createStore(initialSnapshot, options = {}) {
|
|
|
355
412
|
screen: state.screen,
|
|
356
413
|
selectedEpicId: state.selectedEpicId,
|
|
357
414
|
search: state.search,
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
415
|
+
view: state.view,
|
|
416
|
+
selectedTaskId: state.selectedTaskId,
|
|
417
|
+
notesPanelOpen: state.notesPanelOpen,
|
|
418
|
+
epicStatusFilter: state.epicStatusFilter,
|
|
419
|
+
taskStatusFilter: state.taskStatusFilter,
|
|
420
|
+
});
|
|
364
421
|
}
|
|
365
422
|
|
|
366
423
|
function syncState(patch = {}) {
|
|
@@ -375,7 +432,9 @@ export function createStore(initialSnapshot, options = {}) {
|
|
|
375
432
|
|| state.search !== reconciled.search
|
|
376
433
|
|| state.view !== reconciled.view
|
|
377
434
|
|| state.selectedTaskId !== reconciled.selectedTaskId
|
|
378
|
-
|| state.selectedSubtaskId !== reconciled.selectedSubtaskId
|
|
435
|
+
|| state.selectedSubtaskId !== reconciled.selectedSubtaskId
|
|
436
|
+
|| state.taskModalOpen !== reconciled.taskModalOpen
|
|
437
|
+
|| state.subtaskModalOpen !== reconciled.subtaskModalOpen;
|
|
379
438
|
|
|
380
439
|
state.screen = reconciled.screen;
|
|
381
440
|
state.selectedEpicId = reconciled.selectedEpicId;
|
|
@@ -384,10 +443,12 @@ export function createStore(initialSnapshot, options = {}) {
|
|
|
384
443
|
state.view = reconciled.view;
|
|
385
444
|
state.selectedTaskId = reconciled.selectedTaskId;
|
|
386
445
|
state.selectedSubtaskId = reconciled.selectedSubtaskId;
|
|
446
|
+
state.taskModalOpen = reconciled.taskModalOpen;
|
|
447
|
+
state.subtaskModalOpen = reconciled.subtaskModalOpen;
|
|
387
448
|
if (changed) {
|
|
388
449
|
notify();
|
|
389
450
|
}
|
|
390
|
-
return
|
|
451
|
+
return boardStateMemo.get(state);
|
|
391
452
|
}
|
|
392
453
|
|
|
393
454
|
// Reconcile initial state
|
|
@@ -450,7 +511,7 @@ export function createStore(initialSnapshot, options = {}) {
|
|
|
450
511
|
* @returns {BoardState}
|
|
451
512
|
*/
|
|
452
513
|
getBoardState() {
|
|
453
|
-
return
|
|
514
|
+
return boardStateMemo.get(state);
|
|
454
515
|
},
|
|
455
516
|
|
|
456
517
|
/**
|
|
@@ -528,7 +589,22 @@ export function createStore(initialSnapshot, options = {}) {
|
|
|
528
589
|
* @returns {object|null}
|
|
529
590
|
*/
|
|
530
591
|
getSelectedTask() {
|
|
531
|
-
return
|
|
592
|
+
return boardStateMemo.get(state).selectedTask;
|
|
532
593
|
},
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Invalidate the memoized board state. Call after a direct mutation of
|
|
597
|
+
* `model.store` that didn't go through `setState`/`syncState`/`notify`,
|
|
598
|
+
* before triggering a rerender, so `getBoardState()` recomputes.
|
|
599
|
+
*/
|
|
600
|
+
invalidateBoardStateMemo() {
|
|
601
|
+
boardStateMemo.invalidate();
|
|
602
|
+
},
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Notify subscribers (and invalidate the memo). Use after a direct
|
|
606
|
+
* mutation of `model.store` that needs to be reflected in derived state.
|
|
607
|
+
*/
|
|
608
|
+
notify,
|
|
533
609
|
};
|
|
534
610
|
}
|
|
@@ -47,6 +47,9 @@ export function stateToHash(state) {
|
|
|
47
47
|
if (state.selectedTaskId) {
|
|
48
48
|
params.set("task", state.selectedTaskId);
|
|
49
49
|
}
|
|
50
|
+
if (state.selectedSubtaskId) {
|
|
51
|
+
params.set("subtask", state.selectedSubtaskId);
|
|
52
|
+
}
|
|
50
53
|
if (state.screen === "epics" && state.selectedEpicId) {
|
|
51
54
|
params.set("screen", "epics");
|
|
52
55
|
}
|
|
@@ -73,6 +76,7 @@ export function hashToState(hash) {
|
|
|
73
76
|
|
|
74
77
|
const epicId = params.get("epic") || null;
|
|
75
78
|
const taskId = params.get("task") || null;
|
|
79
|
+
const subtaskId = params.get("subtask") || null;
|
|
76
80
|
const screenParam = params.get("screen");
|
|
77
81
|
const search = params.get("search") || "";
|
|
78
82
|
const view = params.get("view") || DEFAULT_VIEW;
|
|
@@ -82,9 +86,15 @@ export function hashToState(hash) {
|
|
|
82
86
|
? "tasks"
|
|
83
87
|
: "epics";
|
|
84
88
|
|
|
89
|
+
// Deep-linking with task=/subtask= must open the corresponding modals so the
|
|
90
|
+
// booted page actually renders the deep-linked dialog instead of just
|
|
91
|
+
// selecting a row in the workspace.
|
|
85
92
|
return {
|
|
86
93
|
selectedEpicId: epicId,
|
|
87
94
|
selectedTaskId: taskId,
|
|
95
|
+
selectedSubtaskId: subtaskId,
|
|
96
|
+
taskModalOpen: Boolean(taskId),
|
|
97
|
+
subtaskModalOpen: Boolean(subtaskId),
|
|
88
98
|
search,
|
|
89
99
|
view,
|
|
90
100
|
screen,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Board event bus.
|
|
3
|
+
*
|
|
4
|
+
* Per-server-instance pub/sub used to broadcast snapshot deltas to SSE
|
|
5
|
+
* subscribers (browser tabs). Publishers (board route handlers, future WAL
|
|
6
|
+
* watcher) call publish; subscribers receive the JSON-serializable payload.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface BoardDeltaEvent {
|
|
10
|
+
readonly type: "snapshotDelta";
|
|
11
|
+
readonly id: number;
|
|
12
|
+
readonly snapshotDelta: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type BoardEvent = BoardDeltaEvent;
|
|
16
|
+
|
|
17
|
+
export type BoardEventListener = (event: BoardEvent) => void;
|
|
18
|
+
|
|
19
|
+
export interface BoardEventBus {
|
|
20
|
+
publishSnapshotDelta(snapshotDelta: Record<string, unknown>): BoardDeltaEvent;
|
|
21
|
+
subscribe(listener: BoardEventListener): () => void;
|
|
22
|
+
readonly subscriberCount: number;
|
|
23
|
+
close(): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createBoardEventBus(): BoardEventBus {
|
|
27
|
+
const listeners = new Set<BoardEventListener>();
|
|
28
|
+
let nextId = 1;
|
|
29
|
+
let closed = false;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
publishSnapshotDelta(snapshotDelta: Record<string, unknown>): BoardDeltaEvent {
|
|
33
|
+
const event: BoardDeltaEvent = {
|
|
34
|
+
type: "snapshotDelta",
|
|
35
|
+
id: nextId++,
|
|
36
|
+
snapshotDelta,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
if (closed) {
|
|
40
|
+
return event;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Snapshot to allow listeners to unsubscribe during dispatch.
|
|
44
|
+
for (const listener of [...listeners]) {
|
|
45
|
+
try {
|
|
46
|
+
listener(event);
|
|
47
|
+
} catch {
|
|
48
|
+
// Listener errors must not block other subscribers.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return event;
|
|
53
|
+
},
|
|
54
|
+
subscribe(listener: BoardEventListener): () => void {
|
|
55
|
+
if (closed) {
|
|
56
|
+
return () => {};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
listeners.add(listener);
|
|
60
|
+
return () => {
|
|
61
|
+
listeners.delete(listener);
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
get subscriberCount(): number {
|
|
65
|
+
return listeners.size;
|
|
66
|
+
},
|
|
67
|
+
close(): void {
|
|
68
|
+
closed = true;
|
|
69
|
+
listeners.clear();
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|