trekoon 0.2.7 → 0.2.9

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.
Files changed (45) hide show
  1. package/README.md +60 -0
  2. package/docs/commands.md +100 -0
  3. package/docs/quickstart.md +74 -1
  4. package/package.json +2 -1
  5. package/src/board/assets/app.js +589 -0
  6. package/src/board/assets/components/ClampedText.js +31 -0
  7. package/src/board/assets/components/Component.js +271 -0
  8. package/src/board/assets/components/ConfirmDialog.js +81 -0
  9. package/src/board/assets/components/EpicRow.js +64 -0
  10. package/src/board/assets/components/EpicsOverview.js +80 -0
  11. package/src/board/assets/components/Inspector.js +335 -0
  12. package/src/board/assets/components/Notice.js +80 -0
  13. package/src/board/assets/components/SubtaskModal.js +100 -0
  14. package/src/board/assets/components/TaskCard.js +82 -0
  15. package/src/board/assets/components/TaskModal.js +99 -0
  16. package/src/board/assets/components/TopBar.js +167 -0
  17. package/src/board/assets/components/Workspace.js +308 -0
  18. package/src/board/assets/components/assetMap.js +80 -0
  19. package/src/board/assets/components/helpers.js +244 -0
  20. package/src/board/assets/fonts/inter-latin.woff2 +0 -0
  21. package/src/board/assets/fonts/material-symbols-rounded.woff2 +0 -0
  22. package/src/board/assets/index.html +39 -0
  23. package/src/board/assets/main.js +11 -0
  24. package/src/board/assets/manifest.json +12 -0
  25. package/src/board/assets/runtime/delegation.js +309 -0
  26. package/src/board/assets/state/actions.js +454 -0
  27. package/src/board/assets/state/api.js +281 -0
  28. package/src/board/assets/state/store.js +472 -0
  29. package/src/board/assets/state/url.js +184 -0
  30. package/src/board/assets/state/utils.js +222 -0
  31. package/src/board/assets/styles/board.css +1811 -0
  32. package/src/board/assets/styles/fonts.css +22 -0
  33. package/src/board/install.ts +196 -0
  34. package/src/board/open-browser.ts +131 -0
  35. package/src/board/routes.ts +308 -0
  36. package/src/board/server.ts +185 -0
  37. package/src/board/snapshot.ts +277 -0
  38. package/src/board/types.ts +43 -0
  39. package/src/commands/board.ts +158 -0
  40. package/src/commands/help.ts +21 -0
  41. package/src/commands/init.ts +29 -0
  42. package/src/domain/mutation-service.ts +40 -0
  43. package/src/domain/tracker-domain.ts +11 -3
  44. package/src/runtime/cli-shell.ts +5 -0
  45. package/src/storage/path.ts +36 -0
@@ -0,0 +1,472 @@
1
+ import { normalizeSnapshot, VIEW_MODES } from "./utils.js";
2
+
3
+ export const THEME_STORAGE_KEY = "trekoon-board-theme";
4
+ export const STATE_STORAGE_KEY = "trekoon-board-state";
5
+
6
+ function normalizeSearch(value) {
7
+ return typeof value === "string" ? value : "";
8
+ }
9
+
10
+ // --- Persistence helpers ---
11
+
12
+ export function readStoredState() {
13
+ try {
14
+ return JSON.parse(localStorage.getItem(STATE_STORAGE_KEY) || "{}");
15
+ } catch {
16
+ return {};
17
+ }
18
+ }
19
+
20
+ export function writeStoredState(nextState) {
21
+ localStorage.setItem(STATE_STORAGE_KEY, JSON.stringify(nextState));
22
+ }
23
+
24
+ export function readThemePreference() {
25
+ const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
26
+ return storedTheme === "light" || storedTheme === "dark" ? storedTheme : "dark";
27
+ }
28
+
29
+ export function applyTheme(theme) {
30
+ document.documentElement.dataset.theme = theme;
31
+ localStorage.setItem(THEME_STORAGE_KEY, theme);
32
+
33
+ const themeColor = theme === "light" ? "#f4f6fb" : "#0b0d12";
34
+ const themeColorMeta = document.querySelector('meta[name="theme-color"][data-board-theme-color="active"]');
35
+ if (themeColorMeta instanceof HTMLMetaElement) {
36
+ themeColorMeta.setAttribute("content", themeColor);
37
+ }
38
+ }
39
+
40
+ // --- Memoization helper ---
41
+
42
+ /**
43
+ * Create a memoized selector that only recomputes when its dependency keys change.
44
+ * @param {(state: object) => any[]} getDeps - Extract dependency values from state
45
+ * @param {(...deps: any[]) => any} compute - Compute derived value from deps
46
+ */
47
+ function createSelector(getDeps, compute) {
48
+ let cachedDeps = null;
49
+ let cachedResult = undefined;
50
+
51
+ return (state) => {
52
+ const deps = getDeps(state);
53
+ if (cachedDeps !== null && deps.every((dep, i) => dep === cachedDeps[i])) {
54
+ return cachedResult;
55
+ }
56
+ cachedDeps = deps;
57
+ cachedResult = compute(...deps);
58
+ return cachedResult;
59
+ };
60
+ }
61
+
62
+ function compareEpicOverviewOrder(leftEpic, rightEpic) {
63
+ if (leftEpic.createdAt !== rightEpic.createdAt) {
64
+ return rightEpic.createdAt - leftEpic.createdAt;
65
+ }
66
+
67
+ return leftEpic.id.localeCompare(rightEpic.id);
68
+ }
69
+
70
+ export function orderEpicsNewestFirst(epics) {
71
+ if (!Array.isArray(epics)) {
72
+ return [];
73
+ }
74
+
75
+ return [...epics].sort(compareEpicOverviewOrder);
76
+ }
77
+
78
+ // --- Derived state selectors ---
79
+
80
+ const selectVisibleEpics = createSelector(
81
+ (s) => [s.snapshot?.epics, s.searchQuery],
82
+ (epics, searchQuery) => {
83
+ if (!epics) return [];
84
+ const matchingEpics = searchQuery.length === 0
85
+ ? epics
86
+ : epics.filter((epic) => epic.searchText.includes(searchQuery));
87
+
88
+ return orderEpicsNewestFirst(matchingEpics);
89
+ },
90
+ );
91
+
92
+ const selectTasksInScope = createSelector(
93
+ (s) => [s.snapshot?.tasks, s.screen, s.selectedEpicId],
94
+ (tasks, screen, selectedEpicId) => {
95
+ if (!tasks) return [];
96
+ return screen === "tasks" && selectedEpicId
97
+ ? tasks.filter((task) => task.epicId === selectedEpicId)
98
+ : tasks;
99
+ },
100
+ );
101
+
102
+ const selectVisibleTasks = createSelector(
103
+ (s) => [selectTasksInScope(s), s.searchQuery],
104
+ (tasksInScope, searchQuery) => {
105
+ return searchQuery.length === 0
106
+ ? tasksInScope
107
+ : tasksInScope.filter((task) => task.searchText.includes(searchQuery));
108
+ },
109
+ );
110
+
111
+ const selectSelectedEpic = createSelector(
112
+ (s) => [s.snapshot?.epics, s.selectedEpicId],
113
+ (epics, selectedEpicId) => {
114
+ if (!epics || !selectedEpicId) return null;
115
+ return epics.find((epic) => epic.id === selectedEpicId) ?? null;
116
+ },
117
+ );
118
+
119
+ const selectSelectedTask = createSelector(
120
+ (s) => [s.screen, selectVisibleTasks(s), selectTasksInScope(s), s.selectedTaskId],
121
+ (screen, visibleTasks, tasksInScope, selectedTaskId) => {
122
+ if (screen !== "tasks" || !selectedTaskId) return null;
123
+ const fromVisible = visibleTasks.find((task) => task.id === selectedTaskId);
124
+ if (fromVisible) return fromVisible;
125
+ return tasksInScope.find((task) => task.id === selectedTaskId) ?? null;
126
+ },
127
+ );
128
+
129
+ const selectSelectedSubtask = createSelector(
130
+ (s) => [s.snapshot?.subtasks, s.selectedTaskId, s.selectedSubtaskId],
131
+ (subtasks, selectedTaskId, selectedSubtaskId) => {
132
+ if (!subtasks || !selectedTaskId || !selectedSubtaskId) return null;
133
+ return subtasks.find(
134
+ (subtask) => subtask.id === selectedSubtaskId && subtask.taskId === selectedTaskId,
135
+ ) ?? null;
136
+ },
137
+ );
138
+
139
+ function selectSearchScope(state) {
140
+ const selectedEpic = selectSelectedEpic(state);
141
+ const visibleEpics = selectVisibleEpics(state);
142
+ const visibleTasks = selectVisibleTasks(state);
143
+ const tasksInScope = selectTasksInScope(state);
144
+ const searchQuery = state.searchQuery;
145
+ const screen = state.screen;
146
+
147
+ if (screen === "tasks" && selectedEpic) {
148
+ return {
149
+ kind: searchQuery.length > 0 ? "epic_search" : "epic",
150
+ label: selectedEpic.title,
151
+ summary: searchQuery.length > 0 ? `Searching ${selectedEpic.title}` : `Epic ${selectedEpic.title}`,
152
+ detail: searchQuery.length > 0
153
+ ? `${visibleTasks.length} matching task${visibleTasks.length === 1 ? "" : "s"} in this epic`
154
+ : `${tasksInScope.length} task${tasksInScope.length === 1 ? "" : "s"} in this epic`,
155
+ };
156
+ }
157
+
158
+ return {
159
+ kind: searchQuery.length > 0 ? "overview_search" : "overview",
160
+ label: "All epics",
161
+ summary: searchQuery.length > 0 ? "Searching all epics" : "Epic overview",
162
+ detail: searchQuery.length > 0
163
+ ? `${visibleEpics.length} matching epic${visibleEpics.length === 1 ? "" : "s"}`
164
+ : `${state.snapshot?.epics?.length ?? 0} epic${(state.snapshot?.epics?.length ?? 0) === 1 ? "" : "s"} total`,
165
+ };
166
+ }
167
+
168
+ /**
169
+ * @typedef {object} BoardState
170
+ * @property {"epics"|"tasks"} screen
171
+ * @property {string|null} selectedEpicId
172
+ * @property {object|null} selectedEpic
173
+ * @property {string|null} selectedTaskId
174
+ * @property {object|null} selectedTask
175
+ * @property {string|null} selectedSubtaskId
176
+ * @property {object|null} selectedSubtask
177
+ * @property {string} search
178
+ * @property {string} searchQuery
179
+ * @property {object} searchScope
180
+ * @property {object[]} visibleEpics
181
+ * @property {object[]} visibleTasks
182
+ */
183
+
184
+ /**
185
+ * Compute full derived board state from the internal state, using memoized selectors.
186
+ * @param {object} state
187
+ * @returns {BoardState}
188
+ */
189
+ function deriveBoardState(state) {
190
+ const requestedTask = state.selectedTaskId
191
+ ? state.snapshot?.tasks?.find((task) => task.id === state.selectedTaskId) ?? null
192
+ : null;
193
+ const requestedEpicId = requestedTask?.epicId ?? state.selectedEpicId ?? null;
194
+ const selectedEpic = requestedEpicId
195
+ ? state.snapshot?.epics?.find((epic) => epic.id === requestedEpicId) ?? null
196
+ : null;
197
+ const screen = requestedTask && selectedEpic
198
+ ? "tasks"
199
+ : state.screen === "tasks" && selectedEpic
200
+ ? "tasks"
201
+ : "epics";
202
+ const normalizedSelectedEpicId = selectedEpic?.id ?? null;
203
+ const stateWithScreen = state.screen !== screen || state.selectedEpicId !== normalizedSelectedEpicId
204
+ ? { ...state, screen, selectedEpicId: normalizedSelectedEpicId }
205
+ : state;
206
+
207
+ const visibleTasks = selectVisibleTasks(stateWithScreen);
208
+ const selectedTask = selectSelectedTask(stateWithScreen);
209
+ const selectedTaskId = selectedTask && visibleTasks.some((t) => t.id === selectedTask.id)
210
+ ? selectedTask.id
211
+ : null;
212
+ const stateWithTaskSelection = stateWithScreen.selectedTaskId !== selectedTaskId
213
+ ? { ...stateWithScreen, selectedTaskId }
214
+ : stateWithScreen;
215
+ const selectedSubtask = selectedTaskId ? selectSelectedSubtask(stateWithTaskSelection) : null;
216
+
217
+ return {
218
+ screen,
219
+ selectedEpicId: selectedEpic?.id ?? null,
220
+ selectedEpic,
221
+ selectedTaskId,
222
+ selectedTask: selectedTaskId ? selectedTask : null,
223
+ selectedSubtaskId: selectedSubtask?.id ?? null,
224
+ selectedSubtask,
225
+ search: stateWithScreen.search,
226
+ searchQuery: stateWithScreen.searchQuery,
227
+ searchScope: selectSearchScope(stateWithScreen),
228
+ visibleEpics: selectVisibleEpics(stateWithScreen),
229
+ visibleTasks,
230
+ };
231
+ }
232
+
233
+ function reconcileBoardState(state) {
234
+ const derived = deriveBoardState(state);
235
+ return {
236
+ screen: derived.screen,
237
+ selectedEpicId: derived.selectedEpicId,
238
+ search: derived.search,
239
+ view: VIEW_MODES.includes(state.view) ? state.view : "kanban",
240
+ selectedTaskId: derived.selectedTaskId,
241
+ selectedSubtaskId: derived.selectedSubtaskId,
242
+ };
243
+ }
244
+
245
+ /**
246
+ * Create an observable store with memoized derived state.
247
+ *
248
+ * @param {object} initialSnapshot - Raw board snapshot from server
249
+ * @param {object} [options]
250
+ * @param {function} [options.normalizeSnapshot] - Custom normalizer (defaults to utils.normalizeSnapshot)
251
+ * @returns {{
252
+ * getState: () => object,
253
+ * setState: (patch: object) => void,
254
+ * subscribe: (listener: (state: object) => void) => () => void,
255
+ * getSnapshot: () => object,
256
+ * getBoardState: () => BoardState,
257
+ * getTaskById: (id: string) => object|null,
258
+ * getSubtaskById: (id: string) => object|null,
259
+ * replaceSnapshot: (rawSnapshot: object) => void,
260
+ * store: object,
261
+ * persist: () => void,
262
+ * syncState: (patch?: object) => BoardState,
263
+ * }}
264
+ */
265
+ export function createStore(initialSnapshot, options = {}) {
266
+ const normalize = options.normalizeSnapshot ?? normalizeSnapshot;
267
+ const storedState = readStoredState();
268
+ const snapshot = typeof initialSnapshot === "object" && initialSnapshot !== null
269
+ ? normalize(initialSnapshot)
270
+ : normalize({ epics: [], tasks: [], subtasks: [], dependencies: [] });
271
+
272
+ const search = normalizeSearch(storedState.search);
273
+
274
+ /** @type {object} Internal mutable state */
275
+ const state = {
276
+ snapshot,
277
+ screen: storedState.screen === "tasks" ? "tasks" : "epics",
278
+ selectedEpicId: typeof storedState.selectedEpicId === "string" ? storedState.selectedEpicId : null,
279
+ search,
280
+ searchQuery: search.trim().toLowerCase(),
281
+ view: VIEW_MODES.includes(storedState.view) ? storedState.view : "kanban",
282
+ selectedTaskId: typeof storedState.selectedTaskId === "string" ? storedState.selectedTaskId : null,
283
+ selectedSubtaskId: null,
284
+ theme: readThemePreference(),
285
+ focusedEpicIndex: 0,
286
+ notice: null,
287
+ isMutating: false,
288
+ notesPanelOpen: storedState.notesPanelOpen === true,
289
+ };
290
+
291
+ /** @type {Set<(state: object) => void>} */
292
+ const listeners = new Set();
293
+
294
+ function notify() {
295
+ for (const listener of listeners) {
296
+ listener(state);
297
+ }
298
+ }
299
+
300
+ function persist() {
301
+ writeStoredState({
302
+ screen: state.screen,
303
+ selectedEpicId: state.selectedEpicId,
304
+ search: state.search,
305
+ view: state.view,
306
+ selectedTaskId: state.selectedTaskId,
307
+ notesPanelOpen: state.notesPanelOpen,
308
+ });
309
+ }
310
+
311
+ function syncState(patch = {}) {
312
+ const merged = { ...state, ...patch };
313
+ if (merged.search !== state.search || patch.search !== undefined) {
314
+ merged.searchQuery = normalizeSearch(merged.search).trim().toLowerCase();
315
+ }
316
+ const reconciled = reconcileBoardState(merged);
317
+ const changed =
318
+ state.screen !== reconciled.screen
319
+ || state.selectedEpicId !== reconciled.selectedEpicId
320
+ || state.search !== reconciled.search
321
+ || state.view !== reconciled.view
322
+ || state.selectedTaskId !== reconciled.selectedTaskId
323
+ || state.selectedSubtaskId !== reconciled.selectedSubtaskId;
324
+
325
+ state.screen = reconciled.screen;
326
+ state.selectedEpicId = reconciled.selectedEpicId;
327
+ state.search = reconciled.search;
328
+ state.searchQuery = normalizeSearch(reconciled.search).trim().toLowerCase();
329
+ state.view = reconciled.view;
330
+ state.selectedTaskId = reconciled.selectedTaskId;
331
+ state.selectedSubtaskId = reconciled.selectedSubtaskId;
332
+ if (changed) {
333
+ notify();
334
+ }
335
+ return deriveBoardState(state);
336
+ }
337
+
338
+ // Reconcile initial state
339
+ syncState();
340
+
341
+ return {
342
+ /** Direct reference to internal state (legacy compatibility). */
343
+ store: state,
344
+
345
+ /**
346
+ * Get a shallow copy of the current state.
347
+ * @returns {object}
348
+ */
349
+ getState() {
350
+ return { ...state };
351
+ },
352
+
353
+ /**
354
+ * Merge a patch into state, notifying subscribers only if a value actually changed.
355
+ * @param {object} patch
356
+ */
357
+ setState(patch) {
358
+ let changed = false;
359
+ for (const key of Object.keys(patch)) {
360
+ if (state[key] !== patch[key]) {
361
+ state[key] = patch[key];
362
+ changed = true;
363
+ }
364
+ }
365
+ if (patch.search !== undefined) {
366
+ state.searchQuery = normalizeSearch(state.search).trim().toLowerCase();
367
+ }
368
+ if (changed) {
369
+ notify();
370
+ }
371
+ },
372
+
373
+ /**
374
+ * Register a listener that fires on state changes.
375
+ * @param {(state: object) => void} listener
376
+ * @returns {() => void} Unsubscribe function
377
+ */
378
+ subscribe(listener) {
379
+ listeners.add(listener);
380
+ return () => {
381
+ listeners.delete(listener);
382
+ };
383
+ },
384
+
385
+ /**
386
+ * Get the current normalized snapshot.
387
+ * @returns {object}
388
+ */
389
+ getSnapshot() {
390
+ return state.snapshot;
391
+ },
392
+
393
+ /**
394
+ * Get the full derived board state (memoized).
395
+ * @returns {BoardState}
396
+ */
397
+ getBoardState() {
398
+ return deriveBoardState(state);
399
+ },
400
+
401
+ /**
402
+ * Find a task by ID in the current snapshot.
403
+ * @param {string} taskId
404
+ * @returns {object|null}
405
+ */
406
+ getTaskById(taskId) {
407
+ return state.snapshot.tasks.find((task) => task.id === taskId) ?? null;
408
+ },
409
+
410
+ /**
411
+ * Find a subtask by ID in the current snapshot.
412
+ * @param {string} subtaskId
413
+ * @returns {object|null}
414
+ */
415
+ getSubtaskById(subtaskId) {
416
+ return state.snapshot.subtasks.find((subtask) => subtask.id === subtaskId) ?? null;
417
+ },
418
+
419
+ /**
420
+ * Replace the snapshot with a new one (e.g. after server response).
421
+ * @param {object} nextRawSnapshot
422
+ */
423
+ replaceSnapshot(nextRawSnapshot) {
424
+ state.snapshot = normalize(nextRawSnapshot);
425
+ syncState();
426
+ persist();
427
+ notify();
428
+ },
429
+
430
+ /** Persist navigational state to localStorage. */
431
+ persist,
432
+
433
+ /**
434
+ * Reconcile state with a patch and return derived board state.
435
+ * @param {object} [patch]
436
+ * @returns {BoardState}
437
+ */
438
+ syncState,
439
+
440
+ /**
441
+ * Get visible epics from memoized selector.
442
+ * @returns {object[]}
443
+ */
444
+ getVisibleEpics() {
445
+ return selectVisibleEpics(state);
446
+ },
447
+
448
+ /**
449
+ * Get visible tasks from memoized selector.
450
+ * @returns {object[]}
451
+ */
452
+ getVisibleTasks() {
453
+ return selectVisibleTasks(state);
454
+ },
455
+
456
+ /**
457
+ * Get selected epic from memoized selector.
458
+ * @returns {object|null}
459
+ */
460
+ getSelectedEpic() {
461
+ return selectSelectedEpic(state);
462
+ },
463
+
464
+ /**
465
+ * Get selected task from memoized selector.
466
+ * @returns {object|null}
467
+ */
468
+ getSelectedTask() {
469
+ return deriveBoardState(state).selectedTask;
470
+ },
471
+ };
472
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * URL hash synchronization for board state.
3
+ *
4
+ * Serializes epic, task, search, and view into the URL hash so that
5
+ * browser back/forward and refresh preserve board navigation.
6
+ * Theme stays in localStorage; token stays in sessionStorage.
7
+ */
8
+
9
+ const DEFAULT_VIEW = "kanban";
10
+
11
+ function toHistoryState(state) {
12
+ return {
13
+ screen: state.screen || "epics",
14
+ selectedEpicId: state.selectedEpicId || null,
15
+ view: state.view || DEFAULT_VIEW,
16
+ };
17
+ }
18
+
19
+ function shouldPushHistoryEntry(previousState, nextState) {
20
+ if (!previousState) {
21
+ return false;
22
+ }
23
+
24
+ return previousState.selectedEpicId !== nextState.selectedEpicId
25
+ || previousState.screen !== nextState.screen
26
+ || previousState.view !== nextState.view;
27
+ }
28
+
29
+ /**
30
+ * Serialize board-relevant state fields into a URL hash string.
31
+ * Omits default values to keep the URL clean.
32
+ *
33
+ * @param {object} state
34
+ * @param {string|null} state.selectedEpicId
35
+ * @param {string|null} state.selectedTaskId
36
+ * @param {string} state.screen
37
+ * @param {string} state.search
38
+ * @param {string} state.view
39
+ * @returns {string} Hash string without leading '#'
40
+ */
41
+ export function stateToHash(state) {
42
+ const params = new URLSearchParams();
43
+
44
+ if (state.selectedEpicId) {
45
+ params.set("epic", state.selectedEpicId);
46
+ }
47
+ if (state.selectedTaskId) {
48
+ params.set("task", state.selectedTaskId);
49
+ }
50
+ if (state.screen === "epics" && state.selectedEpicId) {
51
+ params.set("screen", "epics");
52
+ }
53
+ if (state.search && state.search.trim().length > 0) {
54
+ params.set("search", state.search);
55
+ }
56
+ if (state.view && state.view !== DEFAULT_VIEW) {
57
+ params.set("view", state.view);
58
+ }
59
+
60
+ const hash = params.toString();
61
+ return hash;
62
+ }
63
+
64
+ /**
65
+ * Parse a URL hash string back into state fields.
66
+ *
67
+ * @param {string} hash - Hash string (with or without leading '#')
68
+ * @returns {{ selectedEpicId: string|null, selectedTaskId: string|null, search: string, view: string, screen: string }}
69
+ */
70
+ export function hashToState(hash) {
71
+ const cleaned = hash.startsWith("#") ? hash.slice(1) : hash;
72
+ const params = new URLSearchParams(cleaned);
73
+
74
+ const epicId = params.get("epic") || null;
75
+ const taskId = params.get("task") || null;
76
+ const screenParam = params.get("screen");
77
+ const search = params.get("search") || "";
78
+ const view = params.get("view") || DEFAULT_VIEW;
79
+ const screen = screenParam === "epics" || screenParam === "tasks"
80
+ ? screenParam
81
+ : epicId || taskId
82
+ ? "tasks"
83
+ : "epics";
84
+
85
+ return {
86
+ selectedEpicId: epicId,
87
+ selectedTaskId: taskId,
88
+ search,
89
+ view,
90
+ screen,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Set up bidirectional URL hash synchronization with a store.
96
+ *
97
+ * - On store changes, pushes history for major navigation changes and replaces
98
+ * the current entry for noisy state like transient selection/search.
99
+ * - On hashchange (browser back/forward), reads the hash and updates store state.
100
+ *
101
+ * @param {object} store - Observable store from createStore
102
+ * @param {object} [options]
103
+ * @param {() => void} [options.onRestore] - Callback after URL-driven state restore
104
+ * @returns {() => void} Cleanup function that removes event listeners and unsubscribes
105
+ */
106
+ export function syncUrlHash(store, options = {}) {
107
+ const { beforeRestore, onRestore } = options;
108
+ let isApplyingLocation = false;
109
+ let lastSerializedState = "";
110
+ let lastHistoryState = toHistoryState(store.store);
111
+
112
+ function serializeCurrentState() {
113
+ return stateToHash(store.store);
114
+ }
115
+
116
+ function buildUrl(hash) {
117
+ const { pathname, search } = window.location;
118
+ return `${pathname}${search}${hash.length > 0 ? `#${hash}` : ""}`;
119
+ }
120
+
121
+ function applyLocation(hash, mode) {
122
+ const nextUrl = buildUrl(hash);
123
+ const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
124
+ lastSerializedState = hash;
125
+ if (nextUrl === currentUrl) {
126
+ return;
127
+ }
128
+
129
+ if (mode === "push") {
130
+ window.history.pushState({ boardHash: hash }, "", nextUrl);
131
+ return;
132
+ }
133
+
134
+ window.history.replaceState({ boardHash: hash }, "", nextUrl);
135
+ }
136
+
137
+ function restoreFromLocation() {
138
+ beforeRestore?.();
139
+ isApplyingLocation = true;
140
+ const urlState = hashToState(window.location.hash);
141
+ const restoredState = store.syncState(urlState);
142
+ store.persist();
143
+ const restoredHash = stateToHash(restoredState);
144
+ applyLocation(restoredHash, "replace");
145
+ lastSerializedState = restoredHash;
146
+ lastHistoryState = toHistoryState(store.store);
147
+ isApplyingLocation = false;
148
+ onRestore?.();
149
+ }
150
+
151
+ // Restore state from current URL hash on init
152
+ if (window.location.hash.length > 1) {
153
+ restoreFromLocation();
154
+ } else {
155
+ applyLocation(serializeCurrentState(), "replace");
156
+ }
157
+
158
+ // Store → URL: update hash when state changes
159
+ const unsubscribe = store.subscribe(() => {
160
+ if (isApplyingLocation) {
161
+ return;
162
+ }
163
+
164
+ const hash = serializeCurrentState();
165
+ if (hash !== lastSerializedState) {
166
+ const nextHistoryState = toHistoryState(store.store);
167
+ const mode = shouldPushHistoryEntry(lastHistoryState, nextHistoryState) ? "push" : "replace";
168
+ applyLocation(hash, mode);
169
+ lastHistoryState = nextHistoryState;
170
+ }
171
+ });
172
+
173
+ // URL → Store: respond to browser back/forward
174
+ function onPopState() {
175
+ restoreFromLocation();
176
+ }
177
+
178
+ window.addEventListener("popstate", onPopState);
179
+
180
+ return () => {
181
+ unsubscribe();
182
+ window.removeEventListener("popstate", onPopState);
183
+ };
184
+ }