trekoon 0.3.7 → 0.3.8
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 +198 -73
- package/.agents/skills/trekoon/reference/execution-with-team.md +9 -11
- package/.agents/skills/trekoon/reference/execution.md +26 -9
- package/.agents/skills/trekoon/reference/planning.md +48 -0
- package/README.md +39 -14
- package/docs/quickstart.md +21 -0
- package/package.json +1 -1
- package/src/board/assets/app.js +8 -25
- package/src/board/assets/state/api.js +5 -6
- package/src/board/assets/state/utils.js +50 -17
- package/src/board/routes.ts +22 -19
- package/src/board/server.ts +57 -4
- package/src/board/snapshot.ts +133 -84
- package/src/commands/board.ts +1 -1
- package/src/commands/quickstart.ts +10 -0
- package/src/domain/mutation-service.ts +179 -65
- package/src/domain/tracker-domain.ts +16 -2
- package/src/storage/migrations.ts +27 -2
- package/src/storage/schema.ts +2 -1
- package/src/sync/event-writes.ts +11 -7
- package/src/sync/service.ts +183 -4
package/src/board/assets/app.js
CHANGED
|
@@ -13,7 +13,6 @@ import { createConfirmDialog } from "./components/ConfirmDialog.js";
|
|
|
13
13
|
import { createEpicsOverview } from "./components/EpicsOverview.js";
|
|
14
14
|
import { panelClasses, renderIcon, sectionLabelClasses, escapeHtml } from "./components/helpers.js";
|
|
15
15
|
|
|
16
|
-
const SESSION_TOKEN_STORAGE_KEY = "trekoon-board-session-token";
|
|
17
16
|
const SEARCH_FOCUS_KEYS = new Set(["/", "s"]);
|
|
18
17
|
const FOCUSABLE_SELECTOR = [
|
|
19
18
|
"a[href]",
|
|
@@ -33,30 +32,15 @@ const FOCUSABLE_SELECTOR = [
|
|
|
33
32
|
// Session token management
|
|
34
33
|
// ---------------------------------------------------------------------------
|
|
35
34
|
|
|
36
|
-
function readSessionTokenFromStorage() {
|
|
37
|
-
try {
|
|
38
|
-
return (sessionStorage.getItem(SESSION_TOKEN_STORAGE_KEY) || "").trim();
|
|
39
|
-
} catch {
|
|
40
|
-
return "";
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function persistSessionToken(token) {
|
|
45
|
-
try {
|
|
46
|
-
sessionStorage.setItem(SESSION_TOKEN_STORAGE_KEY, token);
|
|
47
|
-
return true;
|
|
48
|
-
} catch {
|
|
49
|
-
return false;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
35
|
function resolveRuntimeSession() {
|
|
54
36
|
const url = new URL(window.location.href);
|
|
55
37
|
const queryToken = (url.searchParams.get("token") || "").trim();
|
|
56
38
|
if (queryToken.length > 0) {
|
|
57
|
-
return { token: queryToken, shouldScrubAddressBar:
|
|
39
|
+
return { token: queryToken, shouldScrubAddressBar: true };
|
|
58
40
|
}
|
|
59
|
-
|
|
41
|
+
const bootstrap = readJsonScript("trekoon-board-bootstrap") ?? {};
|
|
42
|
+
const bootstrapToken = typeof bootstrap?.token === "string" ? bootstrap.token.trim() : "";
|
|
43
|
+
return { token: bootstrapToken, shouldScrubAddressBar: false };
|
|
60
44
|
}
|
|
61
45
|
|
|
62
46
|
function scrubTokenFromAddressBar() {
|
|
@@ -149,11 +133,10 @@ export async function bootLegacyBoard(options = {}) {
|
|
|
149
133
|
if (runtimeSession.shouldScrubAddressBar) scrubTokenFromAddressBar();
|
|
150
134
|
|
|
151
135
|
// Fetch snapshot
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const response = await fetch("/api/snapshot", { headers });
|
|
136
|
+
const bootstrap = readJsonScript("trekoon-board-bootstrap") ?? {};
|
|
137
|
+
let snapshotPayload = bootstrap?.snapshot ?? readJsonScript("trekoon-board-snapshot") ?? {};
|
|
138
|
+
if ((!snapshotPayload || typeof snapshotPayload !== "object") && runtimeSession.token.length > 0) {
|
|
139
|
+
const response = await fetch("/api/snapshot");
|
|
157
140
|
const payload = await response.json();
|
|
158
141
|
if (!payload?.ok) throw new Error(payload?.error?.message || "Board request failed");
|
|
159
142
|
snapshotPayload = payload?.data?.snapshot ?? {};
|
|
@@ -124,13 +124,12 @@ export function createMutationQueue(model, rerender) {
|
|
|
124
124
|
model.store.notice = null;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
// Apply optimistic update
|
|
128
|
-
if (typeof mutation.optimistic === "function") {
|
|
129
|
-
model.store.snapshot = mutation.optimistic(cloneSnapshot(model.store.snapshot));
|
|
130
|
-
rerender();
|
|
131
|
-
}
|
|
132
|
-
|
|
133
127
|
try {
|
|
128
|
+
if (typeof mutation.optimistic === "function") {
|
|
129
|
+
model.store.snapshot = mutation.optimistic(cloneSnapshot(model.store.snapshot));
|
|
130
|
+
rerender();
|
|
131
|
+
}
|
|
132
|
+
|
|
134
133
|
const data = await mutation.request();
|
|
135
134
|
|
|
136
135
|
if (data?.snapshot) {
|
|
@@ -27,10 +27,10 @@ function normalizeArray(value) {
|
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
29
|
* @param {{ id?: string }} record
|
|
30
|
-
* @returns {string}
|
|
30
|
+
* @returns {string|null}
|
|
31
31
|
*/
|
|
32
32
|
function getId(record) {
|
|
33
|
-
return typeof record?.id === "string" && record.id.length > 0 ? record.id :
|
|
33
|
+
return typeof record?.id === "string" && record.id.length > 0 ? record.id : null;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
function normalizeTimestamp(value, fallback) {
|
|
@@ -42,6 +42,10 @@ function normalizeText(value, fallback = "") {
|
|
|
42
42
|
return String(value ?? fallback).replace(/\\n/g, "\n");
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function normalizeOwner(value) {
|
|
46
|
+
return typeof value === "string" ? value : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
45
49
|
/**
|
|
46
50
|
* @param {any[]} tasks
|
|
47
51
|
* @returns {Record<string, number>}
|
|
@@ -68,15 +72,21 @@ export function normalizeSnapshot(rawSnapshot) {
|
|
|
68
72
|
const taskIndex = new Map();
|
|
69
73
|
const subtaskIndex = new Map();
|
|
70
74
|
|
|
71
|
-
const tasks = rawTasks.
|
|
75
|
+
const tasks = rawTasks.flatMap((task) => {
|
|
76
|
+
const taskId = getId(task);
|
|
77
|
+
if (!taskId) {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
|
|
72
81
|
const createdAt = normalizeTimestamp(task.createdAt, Date.now());
|
|
73
82
|
const normalizedTask = {
|
|
74
|
-
id:
|
|
83
|
+
id: taskId,
|
|
75
84
|
kind: "task",
|
|
76
85
|
epicId: task.epicId ?? task.epic?.id ?? null,
|
|
77
86
|
title: normalizeText(task.title, "Untitled task"),
|
|
78
87
|
description: normalizeText(task.description),
|
|
79
88
|
status: normalizeStatus(task.status),
|
|
89
|
+
owner: normalizeOwner(task.owner),
|
|
80
90
|
createdAt,
|
|
81
91
|
updatedAt: normalizeTimestamp(task.updatedAt, createdAt),
|
|
82
92
|
blockedBy: [],
|
|
@@ -88,18 +98,24 @@ export function normalizeSnapshot(rawSnapshot) {
|
|
|
88
98
|
};
|
|
89
99
|
|
|
90
100
|
taskIndex.set(normalizedTask.id, normalizedTask);
|
|
91
|
-
return normalizedTask;
|
|
101
|
+
return [normalizedTask];
|
|
92
102
|
});
|
|
93
103
|
|
|
94
|
-
const subtasks = rawSubtasks.
|
|
104
|
+
const subtasks = rawSubtasks.flatMap((subtask) => {
|
|
105
|
+
const subtaskId = getId(subtask);
|
|
106
|
+
if (!subtaskId) {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
|
|
95
110
|
const createdAt = normalizeTimestamp(subtask.createdAt, Date.now());
|
|
96
111
|
const normalizedSubtask = {
|
|
97
|
-
id:
|
|
112
|
+
id: subtaskId,
|
|
98
113
|
kind: "subtask",
|
|
99
114
|
taskId: subtask.taskId ?? subtask.task?.id ?? null,
|
|
100
115
|
title: normalizeText(subtask.title, "Untitled subtask"),
|
|
101
116
|
description: normalizeText(subtask.description),
|
|
102
117
|
status: normalizeStatus(subtask.status),
|
|
118
|
+
owner: normalizeOwner(subtask.owner),
|
|
103
119
|
createdAt,
|
|
104
120
|
updatedAt: normalizeTimestamp(subtask.updatedAt, createdAt),
|
|
105
121
|
blockedBy: [],
|
|
@@ -110,7 +126,7 @@ export function normalizeSnapshot(rawSnapshot) {
|
|
|
110
126
|
};
|
|
111
127
|
|
|
112
128
|
subtaskIndex.set(normalizedSubtask.id, normalizedSubtask);
|
|
113
|
-
return normalizedSubtask;
|
|
129
|
+
return [normalizedSubtask];
|
|
114
130
|
});
|
|
115
131
|
|
|
116
132
|
for (const subtask of subtasks) {
|
|
@@ -120,13 +136,22 @@ export function normalizeSnapshot(rawSnapshot) {
|
|
|
120
136
|
}
|
|
121
137
|
}
|
|
122
138
|
|
|
123
|
-
const dependencies = rawDependencies.
|
|
124
|
-
|
|
125
|
-
sourceId
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
139
|
+
const dependencies = rawDependencies.flatMap((dependency) => {
|
|
140
|
+
const dependencyId = getId(dependency);
|
|
141
|
+
const sourceId = typeof dependency?.sourceId === "string" && dependency.sourceId.length > 0 ? dependency.sourceId : null;
|
|
142
|
+
const dependsOnId = typeof dependency?.dependsOnId === "string" && dependency.dependsOnId.length > 0 ? dependency.dependsOnId : null;
|
|
143
|
+
if (!dependencyId || !sourceId || !dependsOnId) {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return [{
|
|
148
|
+
id: dependencyId,
|
|
149
|
+
sourceId,
|
|
150
|
+
sourceKind: dependency.sourceKind === "subtask" ? "subtask" : "task",
|
|
151
|
+
dependsOnId,
|
|
152
|
+
dependsOnKind: dependency.dependsOnKind === "subtask" ? "subtask" : "task",
|
|
153
|
+
}];
|
|
154
|
+
});
|
|
130
155
|
|
|
131
156
|
const lookupNode = (kind, id) => {
|
|
132
157
|
if (kind === "subtask") {
|
|
@@ -148,8 +173,12 @@ export function normalizeSnapshot(rawSnapshot) {
|
|
|
148
173
|
}
|
|
149
174
|
}
|
|
150
175
|
|
|
151
|
-
const epics = rawEpics.
|
|
176
|
+
const epics = rawEpics.flatMap((epic) => {
|
|
152
177
|
const epicId = getId(epic);
|
|
178
|
+
if (!epicId) {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
|
|
153
182
|
const epicTasks = tasks.filter((task) => task.epicId === epicId);
|
|
154
183
|
const createdAt = normalizeTimestamp(epic.createdAt, Date.now());
|
|
155
184
|
const normalizedEpic = {
|
|
@@ -165,7 +194,7 @@ export function normalizeSnapshot(rawSnapshot) {
|
|
|
165
194
|
};
|
|
166
195
|
|
|
167
196
|
normalizedEpic.searchText = [normalizedEpic.title, normalizedEpic.description, ...epicTasks.map((task) => task.title)].join(" ").toLowerCase();
|
|
168
|
-
return normalizedEpic;
|
|
197
|
+
return [normalizedEpic];
|
|
169
198
|
});
|
|
170
199
|
|
|
171
200
|
for (const subtask of subtasks) {
|
|
@@ -214,6 +243,10 @@ function mergeRecordsById(existingRecords, incomingRecords, deletedIds = []) {
|
|
|
214
243
|
const indexById = new Map(nextRecords.map((record, index) => [record.id, index]));
|
|
215
244
|
|
|
216
245
|
for (const record of incomingRecords) {
|
|
246
|
+
if (typeof record?.id !== "string" || record.id.length === 0) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
217
250
|
const existingIndex = indexById.get(record.id);
|
|
218
251
|
if (existingIndex === undefined) {
|
|
219
252
|
indexById.set(record.id, nextRecords.length);
|
package/src/board/routes.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { safeErrorMessage } from "../commands/error-utils";
|
|
|
4
4
|
import { MutationService } from "../domain/mutation-service";
|
|
5
5
|
import { TrackerDomain } from "../domain/tracker-domain";
|
|
6
6
|
import { DomainError } from "../domain/types";
|
|
7
|
-
import { buildBoardSnapshot } from "./snapshot";
|
|
7
|
+
import { buildBoardSnapshot, buildBoardSnapshotDelta } from "./snapshot";
|
|
8
8
|
|
|
9
9
|
interface SnapshotDeltaSelection {
|
|
10
10
|
readonly epicIds?: readonly string[];
|
|
@@ -63,6 +63,25 @@ function jsonResponse(status: number, data: unknown): Response {
|
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
function readCookieToken(request: Request): string | null {
|
|
67
|
+
const rawCookie = request.headers.get("cookie");
|
|
68
|
+
if (!rawCookie) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
for (const part of rawCookie.split(";")) {
|
|
73
|
+
const [name, ...valueParts] = part.split("=");
|
|
74
|
+
if (name?.trim() !== "trekoon_board_session") {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const value = valueParts.join("=").trim();
|
|
79
|
+
return value.length > 0 ? decodeURIComponent(value) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
66
85
|
function extractToken(request: Request, url: URL): string | null {
|
|
67
86
|
const authorization: string | null = request.headers.get("authorization");
|
|
68
87
|
if (authorization?.startsWith("Bearer ")) {
|
|
@@ -79,7 +98,7 @@ function extractToken(request: Request, url: URL): string | null {
|
|
|
79
98
|
return queryToken.trim();
|
|
80
99
|
}
|
|
81
100
|
|
|
82
|
-
return
|
|
101
|
+
return readCookieToken(request);
|
|
83
102
|
}
|
|
84
103
|
|
|
85
104
|
function isSqliteBusyMessage(message: string): boolean {
|
|
@@ -147,23 +166,7 @@ function buildMutationResponse(_domain: TrackerDomain, data: Record<string, unkn
|
|
|
147
166
|
});
|
|
148
167
|
}
|
|
149
168
|
|
|
150
|
-
|
|
151
|
-
const snapshot = buildBoardSnapshot(domain);
|
|
152
|
-
const epicIdSet = new Set(selection.epicIds ?? []);
|
|
153
|
-
const taskIdSet = new Set(selection.taskIds ?? []);
|
|
154
|
-
const subtaskIdSet = new Set(selection.subtaskIds ?? []);
|
|
155
|
-
const dependencyIdSet = new Set(selection.dependencyIds ?? []);
|
|
156
|
-
|
|
157
|
-
return {
|
|
158
|
-
generatedAt: snapshot.generatedAt,
|
|
159
|
-
epics: snapshot.epics.filter((epic) => epicIdSet.has(epic.id)),
|
|
160
|
-
tasks: snapshot.tasks.filter((task) => taskIdSet.has(task.id)),
|
|
161
|
-
subtasks: snapshot.subtasks.filter((subtask) => subtaskIdSet.has(subtask.id)),
|
|
162
|
-
dependencies: snapshot.dependencies.filter((dependency) => dependencyIdSet.has(dependency.id)),
|
|
163
|
-
deletedSubtaskIds: [...(selection.deletedSubtaskIds ?? [])],
|
|
164
|
-
deletedDependencyIds: [...(selection.deletedDependencyIds ?? [])],
|
|
165
|
-
};
|
|
166
|
-
}
|
|
169
|
+
const buildSnapshotDelta = buildBoardSnapshotDelta;
|
|
167
170
|
|
|
168
171
|
function buildMutationDeltaResponse(
|
|
169
172
|
domain: TrackerDomain,
|
package/src/board/server.ts
CHANGED
|
@@ -3,9 +3,11 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { dirname, extname, resolve } from "node:path";
|
|
4
4
|
|
|
5
5
|
import { createBoardApiHandler } from "./routes";
|
|
6
|
+
import { buildBoardSnapshot } from "./snapshot";
|
|
6
7
|
|
|
7
8
|
import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
|
|
8
9
|
import { resolveStoragePaths } from "../storage/path";
|
|
10
|
+
import { TrackerDomain } from "../domain/tracker-domain";
|
|
9
11
|
|
|
10
12
|
const CONTENT_TYPES: Record<string, string> = {
|
|
11
13
|
".css": "text/css; charset=utf-8",
|
|
@@ -87,6 +89,37 @@ function isUnavailablePortError(error: unknown): boolean {
|
|
|
87
89
|
return /^(EADDRINUSE|EACCES)$/i.test(errorCode) || /(EADDRINUSE|EACCES|address already in use|permission denied)/i.test(error.message);
|
|
88
90
|
}
|
|
89
91
|
|
|
92
|
+
function buildBoardSessionCookie(token: string): string {
|
|
93
|
+
return `trekoon_board_session=${encodeURIComponent(token)}; Path=/; SameSite=Strict; HttpOnly`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function serializeInlineJson(value: unknown): string {
|
|
97
|
+
return JSON.stringify(value)
|
|
98
|
+
.replace(/</g, "\\u003c")
|
|
99
|
+
.replace(/>/g, "\\u003e")
|
|
100
|
+
.replace(/&/g, "\\u0026")
|
|
101
|
+
.replace(/\u2028/g, "\\u2028")
|
|
102
|
+
.replace(/\u2029/g, "\\u2029");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function buildBoardBootstrapPayload(database: TrekoonDatabase, token: string): string {
|
|
106
|
+
const domain = new TrackerDomain(database.db);
|
|
107
|
+
return serializeInlineJson({
|
|
108
|
+
token,
|
|
109
|
+
snapshot: buildBoardSnapshot(domain),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function injectBoardBootstrap(html: string, bootstrapJson: string): string {
|
|
114
|
+
const bootstrapTag = `<script id="trekoon-board-bootstrap" type="application/json">${bootstrapJson}</script>`;
|
|
115
|
+
const closingBodyIndex = html.lastIndexOf("</body>");
|
|
116
|
+
if (closingBodyIndex === -1) {
|
|
117
|
+
return `${html}${bootstrapTag}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return `${html.slice(0, closingBodyIndex)}${bootstrapTag}\n${html.slice(closingBodyIndex)}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
90
123
|
export function startBoardServer(options: StartBoardServerOptions = {}): BoardServerInfo {
|
|
91
124
|
const cwd: string = options.cwd ?? process.cwd();
|
|
92
125
|
const database: TrekoonDatabase = openTrekoonDatabase(cwd);
|
|
@@ -110,6 +143,13 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
|
|
|
110
143
|
return apiHandler(request);
|
|
111
144
|
}
|
|
112
145
|
|
|
146
|
+
const responseHeaders: Record<string, string> = {
|
|
147
|
+
"cache-control": "no-store",
|
|
148
|
+
};
|
|
149
|
+
if ((url.searchParams.get("token") ?? "") === token) {
|
|
150
|
+
responseHeaders["set-cookie"] = buildBoardSessionCookie(token);
|
|
151
|
+
}
|
|
152
|
+
|
|
113
153
|
const assetPath = readAssetPath(boardRoot, url.pathname === "/" ? "/index.html" : url.pathname);
|
|
114
154
|
if (assetPath === null) {
|
|
115
155
|
const fallbackPath = readAssetPath(boardRoot, "/index.html");
|
|
@@ -117,9 +157,21 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
|
|
|
117
157
|
return new Response("Board assets are not installed", { status: 500 });
|
|
118
158
|
}
|
|
119
159
|
|
|
120
|
-
|
|
160
|
+
const html = injectBoardBootstrap(readFileSync(fallbackPath, "utf8"), buildBoardBootstrapPayload(database, token));
|
|
161
|
+
|
|
162
|
+
return new Response(html, {
|
|
163
|
+
headers: {
|
|
164
|
+
...responseHeaders,
|
|
165
|
+
"content-type": "text/html; charset=utf-8",
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (assetPath.endsWith("/index.html")) {
|
|
171
|
+
const html = injectBoardBootstrap(readFileSync(assetPath, "utf8"), buildBoardBootstrapPayload(database, token));
|
|
172
|
+
return new Response(html, {
|
|
121
173
|
headers: {
|
|
122
|
-
|
|
174
|
+
...responseHeaders,
|
|
123
175
|
"content-type": "text/html; charset=utf-8",
|
|
124
176
|
},
|
|
125
177
|
});
|
|
@@ -127,7 +179,7 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
|
|
|
127
179
|
|
|
128
180
|
return new Response(readFileSync(assetPath), {
|
|
129
181
|
headers: {
|
|
130
|
-
|
|
182
|
+
...responseHeaders,
|
|
131
183
|
"content-type": guessContentType(assetPath),
|
|
132
184
|
},
|
|
133
185
|
});
|
|
@@ -169,11 +221,12 @@ export function startBoardServer(options: StartBoardServerOptions = {}): BoardSe
|
|
|
169
221
|
|
|
170
222
|
const origin: string = `http://127.0.0.1:${port}`;
|
|
171
223
|
const url: string = `${origin}/?token=${encodeURIComponent(token)}`;
|
|
224
|
+
const fallbackUrl: string = origin;
|
|
172
225
|
|
|
173
226
|
return {
|
|
174
227
|
origin,
|
|
175
228
|
url,
|
|
176
|
-
fallbackUrl
|
|
229
|
+
fallbackUrl,
|
|
177
230
|
token,
|
|
178
231
|
hostname: "127.0.0.1",
|
|
179
232
|
port,
|
package/src/board/snapshot.ts
CHANGED
|
@@ -85,6 +85,15 @@ export interface BoardSnapshot {
|
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
interface SnapshotDeltaSelection {
|
|
89
|
+
readonly epicIds?: readonly string[];
|
|
90
|
+
readonly taskIds?: readonly string[];
|
|
91
|
+
readonly subtaskIds?: readonly string[];
|
|
92
|
+
readonly dependencyIds?: readonly string[];
|
|
93
|
+
readonly deletedSubtaskIds?: readonly string[];
|
|
94
|
+
readonly deletedDependencyIds?: readonly string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
88
97
|
function normalizeStatusBucket(status: string): keyof Omit<StatusCounts, "total"> {
|
|
89
98
|
if (status === "todo") return "todo";
|
|
90
99
|
if (status === "blocked") return "blocked";
|
|
@@ -125,56 +134,32 @@ function mapDependency(record: DependencyRecord): BoardSnapshotDependency {
|
|
|
125
134
|
};
|
|
126
135
|
}
|
|
127
136
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const sourceIds = [...tasks.map((task) => task.id), ...subtasks.map((subtask) => subtask.id)];
|
|
134
|
-
const dependenciesBySourceId = domain.listDependenciesBySourceIds(sourceIds);
|
|
135
|
-
const subtasksByTaskId = new Map<string, SubtaskRecord[]>();
|
|
136
|
-
const tasksByEpicId = new Map<string, TaskRecord[]>();
|
|
137
|
+
function uniqueIds(ids: readonly string[]): string[] {
|
|
138
|
+
return [...new Set(ids.filter((id) => id.length > 0))];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildDependencyIndexes(dependenciesBySourceId: Map<string, readonly DependencyRecord[]>, sourceIds: readonly string[]) {
|
|
137
142
|
const dependencyIdsBySource = new Map<string, string[]>();
|
|
138
143
|
const blockedByIdsBySource = new Map<string, string[]>();
|
|
139
144
|
const dependentIdsByTarget = new Map<string, string[]>();
|
|
140
145
|
const blocksByTarget = new Map<string, string[]>();
|
|
141
146
|
const dependencies: BoardSnapshotDependency[] = [];
|
|
142
147
|
|
|
143
|
-
for (const task of tasks) {
|
|
144
|
-
const existing = tasksByEpicId.get(task.epicId) ?? [];
|
|
145
|
-
existing.push(task);
|
|
146
|
-
tasksByEpicId.set(task.epicId, existing);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
for (const subtask of subtasks) {
|
|
150
|
-
const existing = subtasksByTaskId.get(subtask.taskId) ?? [];
|
|
151
|
-
existing.push(subtask);
|
|
152
|
-
subtasksByTaskId.set(subtask.taskId, existing);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
148
|
for (const sourceId of sourceIds) {
|
|
156
149
|
for (const dependency of dependenciesBySourceId.get(sourceId) ?? []) {
|
|
157
150
|
dependencies.push(mapDependency(dependency));
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const sourceBlockedBy = blockedByIdsBySource.get(dependency.sourceId) ?? [];
|
|
164
|
-
sourceBlockedBy.push(dependency.dependsOnId);
|
|
165
|
-
blockedByIdsBySource.set(dependency.sourceId, sourceBlockedBy);
|
|
166
|
-
|
|
167
|
-
const targetDependentIds = dependentIdsByTarget.get(dependency.dependsOnId) ?? [];
|
|
168
|
-
targetDependentIds.push(dependency.id);
|
|
169
|
-
dependentIdsByTarget.set(dependency.dependsOnId, targetDependentIds);
|
|
170
|
-
|
|
171
|
-
const targetBlocks = blocksByTarget.get(dependency.dependsOnId) ?? [];
|
|
172
|
-
targetBlocks.push(dependency.sourceId);
|
|
173
|
-
blocksByTarget.set(dependency.dependsOnId, targetBlocks);
|
|
151
|
+
(dependencyIdsBySource.get(dependency.sourceId) ?? dependencyIdsBySource.set(dependency.sourceId, []).get(dependency.sourceId) ?? []).push(dependency.id);
|
|
152
|
+
(blockedByIdsBySource.get(dependency.sourceId) ?? blockedByIdsBySource.set(dependency.sourceId, []).get(dependency.sourceId) ?? []).push(dependency.dependsOnId);
|
|
153
|
+
(dependentIdsByTarget.get(dependency.dependsOnId) ?? dependentIdsByTarget.set(dependency.dependsOnId, []).get(dependency.dependsOnId) ?? []).push(dependency.id);
|
|
154
|
+
(blocksByTarget.get(dependency.dependsOnId) ?? blocksByTarget.set(dependency.dependsOnId, []).get(dependency.dependsOnId) ?? []).push(dependency.sourceId);
|
|
174
155
|
}
|
|
175
156
|
}
|
|
176
157
|
|
|
177
|
-
|
|
158
|
+
return { dependencies, dependencyIdsBySource, blockedByIdsBySource, dependentIdsByTarget, blocksByTarget };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function mapSnapshotSubtask(subtask: SubtaskRecord, indexes: ReturnType<typeof buildDependencyIndexes>): BoardSnapshotSubtask {
|
|
162
|
+
return {
|
|
178
163
|
id: subtask.id,
|
|
179
164
|
kind: "subtask",
|
|
180
165
|
taskId: subtask.taskId,
|
|
@@ -184,12 +169,113 @@ export function buildBoardSnapshot(domain: TrackerDomain): BoardSnapshot {
|
|
|
184
169
|
owner: subtask.owner ?? null,
|
|
185
170
|
createdAt: subtask.createdAt,
|
|
186
171
|
updatedAt: subtask.updatedAt,
|
|
187
|
-
blockedBy: blockedByIdsBySource.get(subtask.id) ?? [],
|
|
188
|
-
blocks: blocksByTarget.get(subtask.id) ?? [],
|
|
189
|
-
dependencyIds: dependencyIdsBySource.get(subtask.id) ?? [],
|
|
190
|
-
dependentIds: dependentIdsByTarget.get(subtask.id) ?? [],
|
|
172
|
+
blockedBy: indexes.blockedByIdsBySource.get(subtask.id) ?? [],
|
|
173
|
+
blocks: indexes.blocksByTarget.get(subtask.id) ?? [],
|
|
174
|
+
dependencyIds: indexes.dependencyIdsBySource.get(subtask.id) ?? [],
|
|
175
|
+
dependentIds: indexes.dependentIdsByTarget.get(subtask.id) ?? [],
|
|
191
176
|
searchText: [subtask.title, subtask.description, subtask.status].join(" ").toLowerCase(),
|
|
192
|
-
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function mapSnapshotTask(task: TaskRecord, taskSubtasks: readonly BoardSnapshotSubtask[], indexes: ReturnType<typeof buildDependencyIndexes>): BoardSnapshotTask {
|
|
181
|
+
return {
|
|
182
|
+
id: task.id,
|
|
183
|
+
kind: "task",
|
|
184
|
+
epicId: task.epicId,
|
|
185
|
+
title: task.title,
|
|
186
|
+
description: task.description,
|
|
187
|
+
status: task.status,
|
|
188
|
+
owner: task.owner ?? null,
|
|
189
|
+
createdAt: task.createdAt,
|
|
190
|
+
updatedAt: task.updatedAt,
|
|
191
|
+
blockedBy: indexes.blockedByIdsBySource.get(task.id) ?? [],
|
|
192
|
+
blocks: indexes.blocksByTarget.get(task.id) ?? [],
|
|
193
|
+
dependencyIds: indexes.dependencyIdsBySource.get(task.id) ?? [],
|
|
194
|
+
dependentIds: indexes.dependentIdsByTarget.get(task.id) ?? [],
|
|
195
|
+
subtasks: taskSubtasks,
|
|
196
|
+
searchText: [task.title, task.description, task.status, ...taskSubtasks.map((subtask) => `${subtask.title} ${subtask.description} ${subtask.status}`)].join(" ").toLowerCase(),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function mapSnapshotEpic(epic: EpicRecord, epicTasks: readonly BoardSnapshotTask[]): BoardSnapshotEpic {
|
|
201
|
+
return {
|
|
202
|
+
id: epic.id,
|
|
203
|
+
title: epic.title,
|
|
204
|
+
description: epic.description,
|
|
205
|
+
status: epic.status,
|
|
206
|
+
createdAt: epic.createdAt,
|
|
207
|
+
updatedAt: epic.updatedAt,
|
|
208
|
+
taskIds: epicTasks.map((task) => task.id),
|
|
209
|
+
counts: deriveFlatCounts(epicTasks),
|
|
210
|
+
searchText: [epic.title, epic.description, ...epicTasks.map((task) => task.searchText)].join(" ").toLowerCase(),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function buildBoardSnapshotDelta(domain: TrackerDomain, selection: SnapshotDeltaSelection): Record<string, unknown> {
|
|
215
|
+
const epicIds = uniqueIds(selection.epicIds ?? []);
|
|
216
|
+
const requestedTaskIds = uniqueIds(selection.taskIds ?? []);
|
|
217
|
+
const requestedSubtaskIds = uniqueIds(selection.subtaskIds ?? []);
|
|
218
|
+
const requestedDependencyIds = new Set(selection.dependencyIds ?? []);
|
|
219
|
+
const relatedTaskIds = uniqueIds([
|
|
220
|
+
...requestedTaskIds,
|
|
221
|
+
...requestedSubtaskIds.map((subtaskId) => domain.getSubtask(subtaskId)?.taskId ?? ""),
|
|
222
|
+
]);
|
|
223
|
+
const tasks = relatedTaskIds.map((taskId) => domain.getTask(taskId)).filter((task): task is TaskRecord => task !== null);
|
|
224
|
+
const subtasksByTaskId = new Map<string, readonly SubtaskRecord[]>();
|
|
225
|
+
for (const task of tasks) {
|
|
226
|
+
subtasksByTaskId.set(task.id, domain.listSubtasks(task.id));
|
|
227
|
+
}
|
|
228
|
+
const allSubtasks = uniqueIds([
|
|
229
|
+
...requestedSubtaskIds,
|
|
230
|
+
...[...subtasksByTaskId.values()].flatMap((taskSubtasks) => taskSubtasks.map((subtask) => subtask.id)),
|
|
231
|
+
]).map((subtaskId) => domain.getSubtask(subtaskId)).filter((subtask): subtask is SubtaskRecord => subtask !== null);
|
|
232
|
+
const sourceIds = uniqueIds([...tasks.map((task) => task.id), ...allSubtasks.map((subtask) => subtask.id)]);
|
|
233
|
+
const indexes = buildDependencyIndexes(domain.listDependenciesBySourceIds(sourceIds), sourceIds);
|
|
234
|
+
const snapshotSubtasksByTaskId = new Map<string, BoardSnapshotSubtask[]>();
|
|
235
|
+
for (const subtask of allSubtasks) {
|
|
236
|
+
const mappedSubtask = mapSnapshotSubtask(subtask, indexes);
|
|
237
|
+
const taskSubtasks = snapshotSubtasksByTaskId.get(subtask.taskId) ?? [];
|
|
238
|
+
taskSubtasks.push(mappedSubtask);
|
|
239
|
+
snapshotSubtasksByTaskId.set(subtask.taskId, taskSubtasks);
|
|
240
|
+
}
|
|
241
|
+
const snapshotTasks = tasks.map((task) => mapSnapshotTask(task, snapshotSubtasksByTaskId.get(task.id) ?? [], indexes));
|
|
242
|
+
const snapshotEpics = epicIds.map((epicId) => domain.getEpic(epicId)).filter((epic): epic is EpicRecord => epic !== null).map((epic) => mapSnapshotEpic(epic, snapshotTasks.filter((task) => task.epicId === epic.id)));
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
generatedAt: Date.now(),
|
|
246
|
+
epics: snapshotEpics,
|
|
247
|
+
tasks: snapshotTasks.filter((task) => requestedTaskIds.includes(task.id)),
|
|
248
|
+
subtasks: allSubtasks.map((subtask) => mapSnapshotSubtask(subtask, indexes)).filter((subtask) => requestedSubtaskIds.includes(subtask.id)),
|
|
249
|
+
dependencies: indexes.dependencies.filter((dependency) => requestedDependencyIds.has(dependency.id)),
|
|
250
|
+
deletedSubtaskIds: [...(selection.deletedSubtaskIds ?? [])],
|
|
251
|
+
deletedDependencyIds: [...(selection.deletedDependencyIds ?? [])],
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function buildBoardSnapshot(domain: TrackerDomain): BoardSnapshot {
|
|
256
|
+
const generatedAt = Date.now();
|
|
257
|
+
const epics = domain.listEpics();
|
|
258
|
+
const tasks = domain.listTasks();
|
|
259
|
+
const subtasks = domain.listSubtasks();
|
|
260
|
+
const sourceIds = [...tasks.map((task) => task.id), ...subtasks.map((subtask) => subtask.id)];
|
|
261
|
+
const dependenciesBySourceId = domain.listDependenciesBySourceIds(sourceIds);
|
|
262
|
+
const subtasksByTaskId = new Map<string, SubtaskRecord[]>();
|
|
263
|
+
const tasksByEpicId = new Map<string, TaskRecord[]>();
|
|
264
|
+
const indexes = buildDependencyIndexes(dependenciesBySourceId, sourceIds);
|
|
265
|
+
|
|
266
|
+
for (const task of tasks) {
|
|
267
|
+
const existing = tasksByEpicId.get(task.epicId) ?? [];
|
|
268
|
+
existing.push(task);
|
|
269
|
+
tasksByEpicId.set(task.epicId, existing);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
for (const subtask of subtasks) {
|
|
273
|
+
const existing = subtasksByTaskId.get(subtask.taskId) ?? [];
|
|
274
|
+
existing.push(subtask);
|
|
275
|
+
subtasksByTaskId.set(subtask.taskId, existing);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const snapshotSubtasks: BoardSnapshotSubtask[] = subtasks.map((subtask) => mapSnapshotSubtask(subtask, indexes));
|
|
193
279
|
const snapshotSubtasksByTaskId = new Map<string, BoardSnapshotSubtask[]>();
|
|
194
280
|
for (const subtask of snapshotSubtasks) {
|
|
195
281
|
const existing = snapshotSubtasksByTaskId.get(subtask.taskId) ?? [];
|
|
@@ -197,31 +283,7 @@ export function buildBoardSnapshot(domain: TrackerDomain): BoardSnapshot {
|
|
|
197
283
|
snapshotSubtasksByTaskId.set(subtask.taskId, existing);
|
|
198
284
|
}
|
|
199
285
|
|
|
200
|
-
const snapshotTasks: BoardSnapshotTask[] = tasks.map((task) =>
|
|
201
|
-
const taskSubtasks = snapshotSubtasksByTaskId.get(task.id) ?? [];
|
|
202
|
-
return {
|
|
203
|
-
id: task.id,
|
|
204
|
-
kind: "task",
|
|
205
|
-
epicId: task.epicId,
|
|
206
|
-
title: task.title,
|
|
207
|
-
description: task.description,
|
|
208
|
-
status: task.status,
|
|
209
|
-
owner: task.owner ?? null,
|
|
210
|
-
createdAt: task.createdAt,
|
|
211
|
-
updatedAt: task.updatedAt,
|
|
212
|
-
blockedBy: blockedByIdsBySource.get(task.id) ?? [],
|
|
213
|
-
blocks: blocksByTarget.get(task.id) ?? [],
|
|
214
|
-
dependencyIds: dependencyIdsBySource.get(task.id) ?? [],
|
|
215
|
-
dependentIds: dependentIdsByTarget.get(task.id) ?? [],
|
|
216
|
-
subtasks: taskSubtasks,
|
|
217
|
-
searchText: [
|
|
218
|
-
task.title,
|
|
219
|
-
task.description,
|
|
220
|
-
task.status,
|
|
221
|
-
...taskSubtasks.map((subtask) => `${subtask.title} ${subtask.description} ${subtask.status}`),
|
|
222
|
-
].join(" ").toLowerCase(),
|
|
223
|
-
};
|
|
224
|
-
});
|
|
286
|
+
const snapshotTasks: BoardSnapshotTask[] = tasks.map((task) => mapSnapshotTask(task, snapshotSubtasksByTaskId.get(task.id) ?? [], indexes));
|
|
225
287
|
const taskSearchTextByEpicId = new Map<string, string[]>();
|
|
226
288
|
for (const task of snapshotTasks) {
|
|
227
289
|
const existing = taskSearchTextByEpicId.get(task.epicId) ?? [];
|
|
@@ -229,32 +291,19 @@ export function buildBoardSnapshot(domain: TrackerDomain): BoardSnapshot {
|
|
|
229
291
|
taskSearchTextByEpicId.set(task.epicId, existing);
|
|
230
292
|
}
|
|
231
293
|
|
|
232
|
-
const snapshotEpics: BoardSnapshotEpic[] = epics.map((epic) =>
|
|
233
|
-
const epicTasks = tasksByEpicId.get(epic.id) ?? [];
|
|
234
|
-
return {
|
|
235
|
-
id: epic.id,
|
|
236
|
-
title: epic.title,
|
|
237
|
-
description: epic.description,
|
|
238
|
-
status: epic.status,
|
|
239
|
-
createdAt: epic.createdAt,
|
|
240
|
-
updatedAt: epic.updatedAt,
|
|
241
|
-
taskIds: epicTasks.map((task) => task.id),
|
|
242
|
-
counts: deriveFlatCounts(epicTasks),
|
|
243
|
-
searchText: [epic.title, epic.description, ...(taskSearchTextByEpicId.get(epic.id) ?? [])].join(" ").toLowerCase(),
|
|
244
|
-
};
|
|
245
|
-
});
|
|
294
|
+
const snapshotEpics: BoardSnapshotEpic[] = epics.map((epic) => mapSnapshotEpic(epic, snapshotTasks.filter((task) => task.epicId === epic.id)));
|
|
246
295
|
|
|
247
296
|
return {
|
|
248
297
|
generatedAt,
|
|
249
298
|
epics: snapshotEpics,
|
|
250
299
|
tasks: snapshotTasks,
|
|
251
300
|
subtasks: snapshotSubtasks,
|
|
252
|
-
dependencies,
|
|
301
|
+
dependencies: indexes.dependencies,
|
|
253
302
|
counts: {
|
|
254
303
|
epics: countStatuses(epics),
|
|
255
304
|
tasks: countStatuses(tasks),
|
|
256
305
|
subtasks: countStatuses(subtasks),
|
|
257
|
-
dependencies: dependencies.length,
|
|
306
|
+
dependencies: indexes.dependencies.length,
|
|
258
307
|
},
|
|
259
308
|
};
|
|
260
309
|
}
|