trekoon 0.4.1 → 0.4.3
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 +97 -765
- package/.agents/skills/trekoon/reference/execution-with-team.md +91 -141
- package/.agents/skills/trekoon/reference/execution.md +188 -159
- package/.agents/skills/trekoon/reference/harness-primitives.md +77 -0
- package/.agents/skills/trekoon/reference/planning.md +213 -213
- package/.agents/skills/trekoon/reference/status-machine.md +21 -0
- package/.agents/skills/trekoon/reference/sync.md +82 -0
- package/README.md +29 -8
- package/docs/ai-agents.md +65 -6
- package/docs/commands.md +149 -5
- package/docs/machine-contracts.md +123 -0
- package/docs/quickstart.md +55 -3
- package/package.json +1 -1
- package/src/board/assets/app.js +47 -13
- package/src/board/assets/components/Component.js +20 -8
- package/src/board/assets/components/Workspace.js +9 -3
- package/src/board/assets/components/helpers.js +4 -0
- 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 +45 -4
- package/src/board/assets/state/api.js +304 -17
- package/src/board/assets/state/store.js +82 -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 +81 -0
- package/src/board/routes.ts +430 -40
- package/src/board/server.ts +86 -10
- package/src/board/snapshot.ts +6 -0
- package/src/board/wal-watcher.ts +313 -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 +75 -10
- package/src/commands/migrate.ts +153 -24
- package/src/commands/quickstart.ts +7 -0
- package/src/commands/skills.ts +17 -5
- 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 +842 -187
- package/src/domain/search.ts +113 -0
- package/src/domain/tracker-domain.ts +167 -693
- package/src/domain/types.ts +56 -2
- package/src/export/render-markdown.ts +1 -2
- package/src/index.ts +37 -0
- package/src/runtime/cli-shell.ts +44 -0
- package/src/runtime/daemon.ts +700 -0
- package/src/storage/backup.ts +166 -0
- package/src/storage/database.ts +268 -4
- package/src/storage/migrations.ts +441 -22
- 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 +679 -156
|
@@ -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) {
|
|
@@ -95,10 +222,9 @@ function createTimeoutError(method, path, timeoutMs) {
|
|
|
95
222
|
* }}
|
|
96
223
|
*/
|
|
97
224
|
export function createMutationQueue(model, rerender) {
|
|
98
|
-
/** @type {Array<{ optimistic?: function, request: function, onSuccess?: function, onError?: function, successMessage?: string }>} */
|
|
225
|
+
/** @type {Array<{ mutationId: string, optimistic?: function, request: function, onSuccess?: function, onError?: function, successMessage?: string }>} */
|
|
99
226
|
const queue = [];
|
|
100
227
|
let processing = false;
|
|
101
|
-
let nextMutationId = 1;
|
|
102
228
|
/** @type {Array<() => void>} */
|
|
103
229
|
let flushResolvers = [];
|
|
104
230
|
|
|
@@ -119,14 +245,27 @@ export function createMutationQueue(model, rerender) {
|
|
|
119
245
|
|
|
120
246
|
while (queue.length > 0) {
|
|
121
247
|
const mutation = queue.shift();
|
|
122
|
-
|
|
123
|
-
if (model.store.notice?.retryMutationId !== mutation.id) {
|
|
248
|
+
if (model.store.notice?.retryMutationId !== mutation.mutationId) {
|
|
124
249
|
model.store.notice = null;
|
|
125
250
|
}
|
|
126
251
|
|
|
252
|
+
// Capture per-mutation inverse delta if the optimistic patch ran.
|
|
253
|
+
// Using an inverse delta (rather than wholesale replaceSnapshot) means
|
|
254
|
+
// concurrent server-pushed deltas applied to unrelated entities while
|
|
255
|
+
// the request was in flight survive a rollback.
|
|
256
|
+
let inverseDelta = null;
|
|
257
|
+
|
|
127
258
|
try {
|
|
128
259
|
if (typeof mutation.optimistic === "function") {
|
|
129
|
-
|
|
260
|
+
const previousSnapshot = model.store.snapshot;
|
|
261
|
+
const optimisticSnapshot = mutation.optimistic(cloneSnapshot(previousSnapshot));
|
|
262
|
+
inverseDelta = computeInverseDelta(previousSnapshot, optimisticSnapshot);
|
|
263
|
+
model.store.snapshot = optimisticSnapshot;
|
|
264
|
+
// Direct snapshot mutation bypasses setState/syncState; invalidate
|
|
265
|
+
// the memo so the next getBoardState() reflects the optimistic write.
|
|
266
|
+
if (typeof model.invalidateBoardStateMemo === "function") {
|
|
267
|
+
model.invalidateBoardStateMemo();
|
|
268
|
+
}
|
|
130
269
|
rerender();
|
|
131
270
|
}
|
|
132
271
|
|
|
@@ -146,8 +285,11 @@ export function createMutationQueue(model, rerender) {
|
|
|
146
285
|
? { type: "success", message: mutation.successMessage }
|
|
147
286
|
: null;
|
|
148
287
|
} catch (error) {
|
|
149
|
-
// Revert
|
|
150
|
-
|
|
288
|
+
// Revert only the entities this mutation touched. Any unrelated
|
|
289
|
+
// entities updated by concurrent server deltas remain intact.
|
|
290
|
+
if (inverseDelta) {
|
|
291
|
+
model.applySnapshotDelta(inverseDelta);
|
|
292
|
+
}
|
|
151
293
|
|
|
152
294
|
const message = error instanceof Error ? error.message : String(error);
|
|
153
295
|
model.store.notice = {
|
|
@@ -155,7 +297,7 @@ export function createMutationQueue(model, rerender) {
|
|
|
155
297
|
title: "Action failed",
|
|
156
298
|
message,
|
|
157
299
|
retryLabel: "Retry",
|
|
158
|
-
retryMutationId: mutation.
|
|
300
|
+
retryMutationId: mutation.mutationId,
|
|
159
301
|
};
|
|
160
302
|
|
|
161
303
|
if (typeof mutation.onError === "function") {
|
|
@@ -166,14 +308,19 @@ export function createMutationQueue(model, rerender) {
|
|
|
166
308
|
|
|
167
309
|
processing = false;
|
|
168
310
|
model.store.isMutating = false;
|
|
311
|
+
if (typeof model.invalidateBoardStateMemo === "function") {
|
|
312
|
+
model.invalidateBoardStateMemo();
|
|
313
|
+
}
|
|
169
314
|
rerender();
|
|
170
315
|
resolveFlushes();
|
|
171
316
|
}
|
|
172
317
|
|
|
173
318
|
return {
|
|
174
319
|
enqueue(mutation) {
|
|
175
|
-
queue.push({
|
|
176
|
-
|
|
320
|
+
queue.push({
|
|
321
|
+
...mutation,
|
|
322
|
+
mutationId: mutation.mutationId ?? crypto.randomUUID(),
|
|
323
|
+
});
|
|
177
324
|
processNext();
|
|
178
325
|
},
|
|
179
326
|
|
|
@@ -204,20 +351,26 @@ export function createApi(model, options) {
|
|
|
204
351
|
let lastFailedMutation = null;
|
|
205
352
|
|
|
206
353
|
function enqueueMutation(definition) {
|
|
354
|
+
// Assign a stable identity token so success callbacks can clear
|
|
355
|
+
// lastFailedMutation by id rather than by function-reference equality
|
|
356
|
+
// (inline arrow functions are never the same reference across retries).
|
|
357
|
+
const mutationId = crypto.randomUUID();
|
|
358
|
+
const tagged = { ...definition, mutationId };
|
|
359
|
+
|
|
207
360
|
queue.enqueue({
|
|
208
|
-
...
|
|
361
|
+
...tagged,
|
|
209
362
|
onSuccess(data) {
|
|
210
|
-
if (lastFailedMutation?.
|
|
363
|
+
if (lastFailedMutation?.mutationId === mutationId) {
|
|
211
364
|
lastFailedMutation = null;
|
|
212
365
|
}
|
|
213
|
-
if (typeof
|
|
214
|
-
|
|
366
|
+
if (typeof tagged.onSuccess === "function") {
|
|
367
|
+
tagged.onSuccess(data);
|
|
215
368
|
}
|
|
216
369
|
},
|
|
217
370
|
onError(error) {
|
|
218
|
-
lastFailedMutation =
|
|
219
|
-
if (typeof
|
|
220
|
-
|
|
371
|
+
lastFailedMutation = tagged;
|
|
372
|
+
if (typeof tagged.onError === "function") {
|
|
373
|
+
tagged.onError(error);
|
|
221
374
|
}
|
|
222
375
|
},
|
|
223
376
|
});
|
|
@@ -403,3 +556,137 @@ export function createApi(model, options) {
|
|
|
403
556
|
},
|
|
404
557
|
};
|
|
405
558
|
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Subscribe the board client to /api/snapshot/stream.
|
|
562
|
+
*
|
|
563
|
+
* Receives `snapshotDelta` events emitted by the per-server-instance event bus
|
|
564
|
+
* (own server mutations + WAL-watcher-derived deltas from external CLI writes)
|
|
565
|
+
* and applies them via `model.applySnapshotDelta`. Idempotent merge means
|
|
566
|
+
* re-applying a delta we already saw via the mutation response is harmless.
|
|
567
|
+
*
|
|
568
|
+
* Returns a `dispose()` function that closes the EventSource and stops
|
|
569
|
+
* processing further events.
|
|
570
|
+
*
|
|
571
|
+
* @param {object} model - Store with `applySnapshotDelta` method
|
|
572
|
+
* @param {object} options
|
|
573
|
+
* @param {string} options.sessionToken - Auth token for API parity; EventSource uses the same-origin HttpOnly cookie.
|
|
574
|
+
* @param {function} options.rerender - Trigger UI rerender after applying deltas
|
|
575
|
+
* @param {typeof EventSource} [options.EventSourceCtor] - Constructor override for tests
|
|
576
|
+
* @param {string} [options.path] - Override stream path; default /api/snapshot/stream
|
|
577
|
+
* @returns {{ dispose: () => void, eventSource: EventSource | null }}
|
|
578
|
+
*/
|
|
579
|
+
export function subscribeSnapshotStream(model, options) {
|
|
580
|
+
const {
|
|
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
|
+
let disposed = false;
|
|
591
|
+
let consecutiveErrors = 0;
|
|
592
|
+
const eventSource = new EventSourceCtor(path);
|
|
593
|
+
|
|
594
|
+
const clearLiveUpdateNotice = () => {
|
|
595
|
+
if (model.store?.notice?.code === "live_updates_disconnected") {
|
|
596
|
+
model.store.notice = null;
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const markLiveUpdateSuccess = () => {
|
|
601
|
+
consecutiveErrors = 0;
|
|
602
|
+
clearLiveUpdateNotice();
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const handleSnapshotDelta = (event) => {
|
|
606
|
+
if (disposed) return;
|
|
607
|
+
const raw = typeof event?.data === "string" ? event.data : "";
|
|
608
|
+
if (raw.length === 0) return;
|
|
609
|
+
let payload;
|
|
610
|
+
try {
|
|
611
|
+
payload = JSON.parse(raw);
|
|
612
|
+
} catch (error) {
|
|
613
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
614
|
+
// Surface malformed payloads so operators can spot serialization bugs.
|
|
615
|
+
// Avoid logging the raw text (may include sensitive content) — just length + error.
|
|
616
|
+
console.warn(`subscribeSnapshotStream: malformed snapshotDelta JSON (${raw.length} bytes): ${message}`);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const delta = payload?.snapshotDelta;
|
|
620
|
+
if (!delta || typeof delta !== "object") return;
|
|
621
|
+
model.applySnapshotDelta(delta);
|
|
622
|
+
markLiveUpdateSuccess();
|
|
623
|
+
if (typeof rerender === "function") rerender();
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const handleSnapshot = (event) => {
|
|
627
|
+
if (disposed) return;
|
|
628
|
+
const raw = typeof event?.data === "string" ? event.data : "";
|
|
629
|
+
if (raw.length === 0) return;
|
|
630
|
+
let payload;
|
|
631
|
+
try {
|
|
632
|
+
payload = JSON.parse(raw);
|
|
633
|
+
} catch (error) {
|
|
634
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
635
|
+
console.warn(`subscribeSnapshotStream: malformed snapshot JSON (${raw.length} bytes): ${message}`);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
const snapshot = payload?.snapshot;
|
|
639
|
+
if (!snapshot || typeof snapshot !== "object") return;
|
|
640
|
+
if (typeof model.replaceSnapshot === "function") {
|
|
641
|
+
model.replaceSnapshot(snapshot);
|
|
642
|
+
markLiveUpdateSuccess();
|
|
643
|
+
if (typeof rerender === "function") rerender();
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
function dispose() {
|
|
648
|
+
if (disposed) return;
|
|
649
|
+
disposed = true;
|
|
650
|
+
try {
|
|
651
|
+
eventSource.close();
|
|
652
|
+
} catch {
|
|
653
|
+
// best-effort
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const handleError = () => {
|
|
658
|
+
if (disposed) return;
|
|
659
|
+
consecutiveErrors += 1;
|
|
660
|
+
if (model.store && typeof model.store === "object") {
|
|
661
|
+
const existing = model.store.notice;
|
|
662
|
+
const disabled = consecutiveErrors >= 5;
|
|
663
|
+
const nextCode = disabled ? "live_updates_disabled" : "live_updates_disconnected";
|
|
664
|
+
if (!existing || existing.code !== nextCode) {
|
|
665
|
+
model.store.notice = {
|
|
666
|
+
type: "warning",
|
|
667
|
+
code: nextCode,
|
|
668
|
+
title: disabled ? "Live updates disabled" : "Live updates disconnected",
|
|
669
|
+
message: disabled
|
|
670
|
+
? "Refresh the board to resume live updates from other sessions."
|
|
671
|
+
: "Reconnecting to the server. Changes from other sessions may be delayed.",
|
|
672
|
+
};
|
|
673
|
+
if (typeof rerender === "function") rerender();
|
|
674
|
+
}
|
|
675
|
+
if (disabled) {
|
|
676
|
+
dispose();
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
eventSource.addEventListener("snapshotDelta", handleSnapshotDelta);
|
|
682
|
+
eventSource.addEventListener("snapshot", handleSnapshot);
|
|
683
|
+
// EventSource calls .onerror on disconnect (and will continue auto-reconnecting).
|
|
684
|
+
// Use the onerror property rather than addEventListener("error") so tests can
|
|
685
|
+
// trigger it via `instance.onerror?.()` without a full event object.
|
|
686
|
+
eventSource.onerror = handleError;
|
|
687
|
+
|
|
688
|
+
return {
|
|
689
|
+
eventSource,
|
|
690
|
+
dispose,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
@@ -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();
|
|
@@ -225,6 +235,7 @@ function selectSearchScope(state) {
|
|
|
225
235
|
* @property {string|null} selectedSubtaskId
|
|
226
236
|
* @property {object|null} selectedSubtask
|
|
227
237
|
* @property {boolean} taskModalOpen
|
|
238
|
+
* @property {boolean} subtaskModalOpen
|
|
228
239
|
* @property {string} search
|
|
229
240
|
* @property {string} searchQuery
|
|
230
241
|
* @property {object} searchScope
|
|
@@ -232,6 +243,38 @@ function selectSearchScope(state) {
|
|
|
232
243
|
* @property {object[]} visibleTasks
|
|
233
244
|
*/
|
|
234
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
|
+
|
|
235
278
|
/**
|
|
236
279
|
* Compute full derived board state from the internal state, using memoized selectors.
|
|
237
280
|
* @param {object} state
|
|
@@ -266,6 +309,7 @@ function deriveBoardState(state) {
|
|
|
266
309
|
const selectedSubtask = selectedTaskId ? selectSelectedSubtask(stateWithTaskSelection) : null;
|
|
267
310
|
|
|
268
311
|
const taskModalOpen = state.taskModalOpen === true;
|
|
312
|
+
const subtaskModalOpen = state.subtaskModalOpen === true && selectedSubtask !== null;
|
|
269
313
|
|
|
270
314
|
return {
|
|
271
315
|
screen,
|
|
@@ -276,6 +320,7 @@ function deriveBoardState(state) {
|
|
|
276
320
|
selectedSubtaskId: selectedSubtask?.id ?? null,
|
|
277
321
|
selectedSubtask,
|
|
278
322
|
taskModalOpen,
|
|
323
|
+
subtaskModalOpen,
|
|
279
324
|
search: stateWithScreen.search,
|
|
280
325
|
searchQuery: stateWithScreen.searchQuery,
|
|
281
326
|
searchScope: selectSearchScope(stateWithScreen),
|
|
@@ -294,6 +339,7 @@ function reconcileBoardState(state) {
|
|
|
294
339
|
selectedTaskId: derived.selectedTaskId,
|
|
295
340
|
selectedSubtaskId: derived.selectedSubtaskId,
|
|
296
341
|
taskModalOpen: derived.taskModalOpen,
|
|
342
|
+
subtaskModalOpen: derived.subtaskModalOpen,
|
|
297
343
|
};
|
|
298
344
|
}
|
|
299
345
|
|
|
@@ -337,11 +383,13 @@ export function createStore(initialSnapshot, options = {}) {
|
|
|
337
383
|
selectedTaskId: typeof storedState.selectedTaskId === "string" ? storedState.selectedTaskId : null,
|
|
338
384
|
selectedSubtaskId: null,
|
|
339
385
|
taskModalOpen: false,
|
|
386
|
+
subtaskModalOpen: false,
|
|
340
387
|
theme: readThemePreference(),
|
|
341
388
|
focusedEpicIndex: 0,
|
|
342
389
|
copyFeedback: null,
|
|
343
390
|
notice: null,
|
|
344
391
|
isMutating: false,
|
|
392
|
+
dragFeedback: null,
|
|
345
393
|
notesPanelOpen: storedState.notesPanelOpen === true,
|
|
346
394
|
epicStatusFilter: readStatusFilter(storedState.epicStatusFilter),
|
|
347
395
|
taskStatusFilter: readStatusFilter(storedState.taskStatusFilter),
|
|
@@ -350,7 +398,13 @@ export function createStore(initialSnapshot, options = {}) {
|
|
|
350
398
|
/** @type {Set<(state: object) => void>} */
|
|
351
399
|
const listeners = new Set();
|
|
352
400
|
|
|
401
|
+
const boardStateMemo = createBoardStateMemo(deriveBoardState);
|
|
402
|
+
|
|
403
|
+
// Direct `model.store` mutations must manually invalidate this memo before
|
|
404
|
+
// rerendering. Current direct-mutation fields: snapshot, notice, isMutating,
|
|
405
|
+
// dragFeedback.
|
|
353
406
|
function notify() {
|
|
407
|
+
boardStateMemo.invalidate();
|
|
354
408
|
for (const listener of listeners) {
|
|
355
409
|
listener(state);
|
|
356
410
|
}
|
|
@@ -361,12 +415,12 @@ export function createStore(initialSnapshot, options = {}) {
|
|
|
361
415
|
screen: state.screen,
|
|
362
416
|
selectedEpicId: state.selectedEpicId,
|
|
363
417
|
search: state.search,
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
418
|
+
view: state.view,
|
|
419
|
+
selectedTaskId: state.selectedTaskId,
|
|
420
|
+
notesPanelOpen: state.notesPanelOpen,
|
|
421
|
+
epicStatusFilter: state.epicStatusFilter,
|
|
422
|
+
taskStatusFilter: state.taskStatusFilter,
|
|
423
|
+
});
|
|
370
424
|
}
|
|
371
425
|
|
|
372
426
|
function syncState(patch = {}) {
|
|
@@ -382,7 +436,8 @@ export function createStore(initialSnapshot, options = {}) {
|
|
|
382
436
|
|| state.view !== reconciled.view
|
|
383
437
|
|| state.selectedTaskId !== reconciled.selectedTaskId
|
|
384
438
|
|| state.selectedSubtaskId !== reconciled.selectedSubtaskId
|
|
385
|
-
|| state.taskModalOpen !== reconciled.taskModalOpen
|
|
439
|
+
|| state.taskModalOpen !== reconciled.taskModalOpen
|
|
440
|
+
|| state.subtaskModalOpen !== reconciled.subtaskModalOpen;
|
|
386
441
|
|
|
387
442
|
state.screen = reconciled.screen;
|
|
388
443
|
state.selectedEpicId = reconciled.selectedEpicId;
|
|
@@ -392,10 +447,11 @@ export function createStore(initialSnapshot, options = {}) {
|
|
|
392
447
|
state.selectedTaskId = reconciled.selectedTaskId;
|
|
393
448
|
state.selectedSubtaskId = reconciled.selectedSubtaskId;
|
|
394
449
|
state.taskModalOpen = reconciled.taskModalOpen;
|
|
450
|
+
state.subtaskModalOpen = reconciled.subtaskModalOpen;
|
|
395
451
|
if (changed) {
|
|
396
452
|
notify();
|
|
397
453
|
}
|
|
398
|
-
return
|
|
454
|
+
return boardStateMemo.get(state);
|
|
399
455
|
}
|
|
400
456
|
|
|
401
457
|
// Reconcile initial state
|
|
@@ -458,7 +514,7 @@ export function createStore(initialSnapshot, options = {}) {
|
|
|
458
514
|
* @returns {BoardState}
|
|
459
515
|
*/
|
|
460
516
|
getBoardState() {
|
|
461
|
-
return
|
|
517
|
+
return boardStateMemo.get(state);
|
|
462
518
|
},
|
|
463
519
|
|
|
464
520
|
/**
|
|
@@ -536,7 +592,22 @@ export function createStore(initialSnapshot, options = {}) {
|
|
|
536
592
|
* @returns {object|null}
|
|
537
593
|
*/
|
|
538
594
|
getSelectedTask() {
|
|
539
|
-
return
|
|
595
|
+
return boardStateMemo.get(state).selectedTask;
|
|
540
596
|
},
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Invalidate the memoized board state. Call after a direct mutation of
|
|
600
|
+
* `model.store` that didn't go through `setState`/`syncState`/`notify`,
|
|
601
|
+
* before triggering a rerender, so `getBoardState()` recomputes.
|
|
602
|
+
*/
|
|
603
|
+
invalidateBoardStateMemo() {
|
|
604
|
+
boardStateMemo.invalidate();
|
|
605
|
+
},
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Notify subscribers (and invalidate the memo). Use after a direct
|
|
609
|
+
* mutation of `model.store` that needs to be reflected in derived state.
|
|
610
|
+
*/
|
|
611
|
+
notify,
|
|
541
612
|
};
|
|
542
613
|
}
|
|
@@ -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,
|