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.
@@ -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: persistSessionToken(queryToken) };
39
+ return { token: queryToken, shouldScrubAddressBar: true };
58
40
  }
59
- return { token: readSessionTokenFromStorage(), shouldScrubAddressBar: false };
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
- let snapshotPayload = readJsonScript("trekoon-board-snapshot") ?? {};
153
- if (runtimeSession.token.length > 0) {
154
- const headers = new Headers();
155
- headers.set("authorization", `Bearer ${runtimeSession.token}`);
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 : crypto.randomUUID();
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.map((task) => {
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: getId(task),
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.map((subtask) => {
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: getId(subtask),
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.map((dependency) => ({
124
- id: getId(dependency),
125
- sourceId: String(dependency.sourceId ?? ""),
126
- sourceKind: dependency.sourceKind === "subtask" ? "subtask" : "task",
127
- dependsOnId: String(dependency.dependsOnId ?? ""),
128
- dependsOnKind: dependency.dependsOnKind === "subtask" ? "subtask" : "task",
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.map((epic) => {
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);
@@ -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 null;
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
- function buildSnapshotDelta(domain: TrackerDomain, selection: SnapshotDeltaSelection): Record<string, unknown> {
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,
@@ -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
- return new Response(readFileSync(fallbackPath), {
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
- "cache-control": "no-store",
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
- "cache-control": "no-store",
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: url,
229
+ fallbackUrl,
177
230
  token,
178
231
  hostname: "127.0.0.1",
179
232
  port,
@@ -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
- export function buildBoardSnapshot(domain: TrackerDomain): BoardSnapshot {
129
- const generatedAt = Date.now();
130
- const epics = domain.listEpics();
131
- const tasks = domain.listTasks();
132
- const subtasks = domain.listSubtasks();
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
- const sourceDependencyIds = dependencyIdsBySource.get(dependency.sourceId) ?? [];
160
- sourceDependencyIds.push(dependency.id);
161
- dependencyIdsBySource.set(dependency.sourceId, sourceDependencyIds);
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
- const snapshotSubtasks: BoardSnapshotSubtask[] = subtasks.map((subtask) => ({
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
  }