trekoon 0.2.8 → 0.3.0

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 (38) hide show
  1. package/README.md +14 -14
  2. package/docs/commands.md +9 -11
  3. package/docs/quickstart.md +10 -12
  4. package/package.json +23 -1
  5. package/src/board/assets/app.js +469 -1377
  6. package/src/board/assets/components/ClampedText.js +1 -1
  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 +43 -26
  10. package/src/board/assets/components/EpicsOverview.js +52 -11
  11. package/src/board/assets/components/Inspector.js +335 -0
  12. package/src/board/assets/components/Notice.js +87 -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 +319 -0
  18. package/src/board/assets/components/assetMap.js +29 -14
  19. package/src/board/assets/components/helpers.js +261 -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 +20 -57
  23. package/src/board/assets/main.js +2 -18
  24. package/src/board/assets/runtime/clipboard.js +34 -0
  25. package/src/board/assets/runtime/delegation.js +342 -0
  26. package/src/board/assets/state/actions.js +204 -16
  27. package/src/board/assets/state/api.js +201 -46
  28. package/src/board/assets/state/store.js +418 -117
  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 +933 -129
  32. package/src/board/assets/styles/fonts.css +22 -0
  33. package/src/board/routes.ts +15 -6
  34. package/src/board/server.ts +1 -0
  35. package/src/board/assets/components/AppShell.js +0 -17
  36. package/src/board/assets/components/BoardTopbar.js +0 -78
  37. package/src/board/assets/components/WorkspaceHeader.js +0 -70
  38. package/src/board/assets/utils/dom.js +0 -308
@@ -1,172 +1,473 @@
1
+ import { normalizeSnapshot, VIEW_MODES } from "./utils.js";
2
+
1
3
  export const THEME_STORAGE_KEY = "trekoon-board-theme";
2
4
  export const STATE_STORAGE_KEY = "trekoon-board-state";
3
- export const VIEW_MODES = ["kanban", "list"];
4
- export const STATUS_ORDER = ["todo", "blocked", "in_progress", "done"];
5
5
 
6
6
  function normalizeSearch(value) {
7
7
  return typeof value === "string" ? value : "";
8
8
  }
9
9
 
10
- function deriveBoardState(snapshot, state) {
11
- const selectedEpic = snapshot.epics.find((epic) => epic.id === state.selectedEpicId) ?? null;
12
- const screen = state.screen === "tasks" && selectedEpic ? "tasks" : "epics";
13
- const selectedEpicId = selectedEpic?.id ?? null;
14
- const search = normalizeSearch(state.search);
15
- const searchQuery = search.trim().toLowerCase();
16
- const visibleEpics = searchQuery.length === 0
17
- ? snapshot.epics
18
- : snapshot.epics.filter((epic) => epic.searchText.includes(searchQuery));
19
- const tasksInScope = screen === "tasks" && selectedEpicId
20
- ? snapshot.tasks.filter((task) => task.epicId === selectedEpicId)
21
- : snapshot.tasks;
22
- const visibleTasks = searchQuery.length === 0
23
- ? tasksInScope
24
- : tasksInScope.filter((task) => task.searchText.includes(searchQuery));
25
- const selectedTask = visibleTasks.find((task) => task.id === state.selectedTaskId)
26
- ?? tasksInScope.find((task) => task.id === state.selectedTaskId)
27
- ?? null;
28
- const selectedTaskId = selectedTask && visibleTasks.some((task) => task.id === selectedTask.id)
29
- ? selectedTask.id
30
- : null;
31
- const selectedSubtask = selectedTaskId
32
- ? snapshot.subtasks.find(
33
- (subtask) => subtask.id === state.selectedSubtaskId && subtask.taskId === selectedTaskId,
34
- ) ?? null
35
- : null;
36
- const selectedSubtaskId = selectedSubtask?.id ?? null;
37
- const searchScope = screen === "tasks" && selectedEpic
38
- ? {
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 {
39
149
  kind: searchQuery.length > 0 ? "epic_search" : "epic",
40
150
  label: selectedEpic.title,
41
151
  summary: searchQuery.length > 0 ? `Searching ${selectedEpic.title}` : `Epic ${selectedEpic.title}`,
42
152
  detail: searchQuery.length > 0
43
153
  ? `${visibleTasks.length} matching task${visibleTasks.length === 1 ? "" : "s"} in this epic`
44
154
  : `${tasksInScope.length} task${tasksInScope.length === 1 ? "" : "s"} in this epic`,
45
- }
46
- : {
47
- kind: searchQuery.length > 0 ? "overview_search" : "overview",
48
- label: "All epics",
49
- summary: searchQuery.length > 0 ? "Searching all epics" : "Epic overview",
50
- detail: searchQuery.length > 0
51
- ? `${visibleEpics.length} matching epic${visibleEpics.length === 1 ? "" : "s"}`
52
- : `${snapshot.epics.length} epic${snapshot.epics.length === 1 ? "" : "s"} total`,
53
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;
54
216
 
55
217
  return {
56
218
  screen,
57
- selectedEpicId,
219
+ selectedEpicId: selectedEpic?.id ?? null,
58
220
  selectedEpic,
59
221
  selectedTaskId,
60
222
  selectedTask: selectedTaskId ? selectedTask : null,
61
- selectedSubtaskId,
223
+ selectedSubtaskId: selectedSubtask?.id ?? null,
62
224
  selectedSubtask,
63
- search,
64
- searchQuery,
65
- searchScope,
66
- visibleEpics,
225
+ search: stateWithScreen.search,
226
+ searchQuery: stateWithScreen.searchQuery,
227
+ searchScope: selectSearchScope(stateWithScreen),
228
+ visibleEpics: selectVisibleEpics(stateWithScreen),
67
229
  visibleTasks,
68
230
  };
69
231
  }
70
232
 
71
- function reconcileBoardState(snapshot, state) {
72
- const derivedState = deriveBoardState(snapshot, state);
233
+ function reconcileBoardState(state) {
234
+ const derived = deriveBoardState(state);
73
235
  return {
74
- screen: derivedState.screen,
75
- selectedEpicId: derivedState.selectedEpicId,
76
- search: derivedState.search,
236
+ screen: derived.screen,
237
+ selectedEpicId: derived.selectedEpicId,
238
+ search: derived.search,
77
239
  view: VIEW_MODES.includes(state.view) ? state.view : "kanban",
78
- selectedTaskId: derivedState.selectedTaskId,
79
- selectedSubtaskId: derivedState.selectedSubtaskId,
240
+ selectedTaskId: derived.selectedTaskId,
241
+ selectedSubtaskId: derived.selectedSubtaskId,
80
242
  };
81
243
  }
82
244
 
83
- export function readStoredState() {
84
- try {
85
- return JSON.parse(localStorage.getItem(STATE_STORAGE_KEY) || "{}");
86
- } catch {
87
- return {};
88
- }
89
- }
90
-
91
- export function writeStoredState(nextState) {
92
- localStorage.setItem(STATE_STORAGE_KEY, JSON.stringify(nextState));
93
- }
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: [] });
94
271
 
95
- export function readThemePreference() {
96
- const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
97
- return storedTheme === "light" || storedTheme === "dark" ? storedTheme : "dark";
98
- }
272
+ const search = normalizeSearch(storedState.search);
99
273
 
100
- export function applyTheme(theme) {
101
- document.documentElement.dataset.theme = theme;
102
- localStorage.setItem(THEME_STORAGE_KEY, theme);
103
- }
104
-
105
- export function createStore(snapshot, options) {
106
- const { normalizeSnapshot } = options;
107
- const storedState = readStoredState();
108
- const store = {
274
+ /** @type {object} Internal mutable state */
275
+ const state = {
109
276
  snapshot,
110
277
  screen: storedState.screen === "tasks" ? "tasks" : "epics",
111
278
  selectedEpicId: typeof storedState.selectedEpicId === "string" ? storedState.selectedEpicId : null,
112
- search: normalizeSearch(storedState.search),
279
+ search,
280
+ searchQuery: search.trim().toLowerCase(),
113
281
  view: VIEW_MODES.includes(storedState.view) ? storedState.view : "kanban",
114
282
  selectedTaskId: typeof storedState.selectedTaskId === "string" ? storedState.selectedTaskId : null,
115
283
  selectedSubtaskId: null,
116
284
  theme: readThemePreference(),
117
285
  focusedEpicIndex: 0,
286
+ copyFeedback: null,
118
287
  notice: null,
119
288
  isMutating: false,
289
+ notesPanelOpen: storedState.notesPanelOpen === true,
120
290
  };
121
291
 
122
- const persist = () => {
292
+ /** @type {Set<(state: object) => void>} */
293
+ const listeners = new Set();
294
+
295
+ function notify() {
296
+ for (const listener of listeners) {
297
+ listener(state);
298
+ }
299
+ }
300
+
301
+ function persist() {
123
302
  writeStoredState({
124
- screen: store.screen,
125
- selectedEpicId: store.selectedEpicId,
126
- search: store.search,
127
- view: store.view,
128
- selectedTaskId: store.selectedTaskId,
129
- });
130
- };
303
+ screen: state.screen,
304
+ selectedEpicId: state.selectedEpicId,
305
+ search: state.search,
306
+ view: state.view,
307
+ selectedTaskId: state.selectedTaskId,
308
+ notesPanelOpen: state.notesPanelOpen,
309
+ });
310
+ }
131
311
 
132
- const getTaskById = (taskId) => store.snapshot.tasks.find((task) => task.id === taskId) ?? null;
133
- const getSubtaskById = (subtaskId) => store.snapshot.subtasks.find((subtask) => subtask.id === subtaskId) ?? null;
134
- const getBoardState = () => deriveBoardState(store.snapshot, store);
135
- const getSelectedEpic = () => getBoardState().selectedEpic;
136
- const getSelectedTask = () => getBoardState().selectedTask;
137
- const getVisibleTasks = () => getBoardState().visibleTasks;
138
- const getVisibleEpics = () => getBoardState().visibleEpics;
139
-
140
- const syncState = (patch = {}) => {
141
- const nextState = reconcileBoardState(store.snapshot, { ...store, ...patch });
142
- store.screen = nextState.screen;
143
- store.selectedEpicId = nextState.selectedEpicId;
144
- store.search = nextState.search;
145
- store.view = nextState.view;
146
- store.selectedTaskId = nextState.selectedTaskId;
147
- store.selectedSubtaskId = nextState.selectedSubtaskId;
148
- return getBoardState();
149
- };
312
+ function syncState(patch = {}) {
313
+ const merged = { ...state, ...patch };
314
+ if (merged.search !== state.search || patch.search !== undefined) {
315
+ merged.searchQuery = normalizeSearch(merged.search).trim().toLowerCase();
316
+ }
317
+ const reconciled = reconcileBoardState(merged);
318
+ const changed =
319
+ state.screen !== reconciled.screen
320
+ || state.selectedEpicId !== reconciled.selectedEpicId
321
+ || state.search !== reconciled.search
322
+ || state.view !== reconciled.view
323
+ || state.selectedTaskId !== reconciled.selectedTaskId
324
+ || state.selectedSubtaskId !== reconciled.selectedSubtaskId;
150
325
 
151
- const replaceSnapshot = (nextSnapshot) => {
152
- store.snapshot = normalizeSnapshot(nextSnapshot);
153
- syncState();
154
- persist();
155
- };
326
+ state.screen = reconciled.screen;
327
+ state.selectedEpicId = reconciled.selectedEpicId;
328
+ state.search = reconciled.search;
329
+ state.searchQuery = normalizeSearch(reconciled.search).trim().toLowerCase();
330
+ state.view = reconciled.view;
331
+ state.selectedTaskId = reconciled.selectedTaskId;
332
+ state.selectedSubtaskId = reconciled.selectedSubtaskId;
333
+ if (changed) {
334
+ notify();
335
+ }
336
+ return deriveBoardState(state);
337
+ }
156
338
 
339
+ // Reconcile initial state
157
340
  syncState();
158
341
 
159
342
  return {
160
- store,
343
+ /** Direct reference to internal state (legacy compatibility). */
344
+ store: state,
345
+
346
+ /**
347
+ * Get a shallow copy of the current state.
348
+ * @returns {object}
349
+ */
350
+ getState() {
351
+ return { ...state };
352
+ },
353
+
354
+ /**
355
+ * Merge a patch into state, notifying subscribers only if a value actually changed.
356
+ * @param {object} patch
357
+ */
358
+ setState(patch) {
359
+ let changed = false;
360
+ for (const key of Object.keys(patch)) {
361
+ if (state[key] !== patch[key]) {
362
+ state[key] = patch[key];
363
+ changed = true;
364
+ }
365
+ }
366
+ if (patch.search !== undefined) {
367
+ state.searchQuery = normalizeSearch(state.search).trim().toLowerCase();
368
+ }
369
+ if (changed) {
370
+ notify();
371
+ }
372
+ },
373
+
374
+ /**
375
+ * Register a listener that fires on state changes.
376
+ * @param {(state: object) => void} listener
377
+ * @returns {() => void} Unsubscribe function
378
+ */
379
+ subscribe(listener) {
380
+ listeners.add(listener);
381
+ return () => {
382
+ listeners.delete(listener);
383
+ };
384
+ },
385
+
386
+ /**
387
+ * Get the current normalized snapshot.
388
+ * @returns {object}
389
+ */
390
+ getSnapshot() {
391
+ return state.snapshot;
392
+ },
393
+
394
+ /**
395
+ * Get the full derived board state (memoized).
396
+ * @returns {BoardState}
397
+ */
398
+ getBoardState() {
399
+ return deriveBoardState(state);
400
+ },
401
+
402
+ /**
403
+ * Find a task by ID in the current snapshot.
404
+ * @param {string} taskId
405
+ * @returns {object|null}
406
+ */
407
+ getTaskById(taskId) {
408
+ return state.snapshot.tasks.find((task) => task.id === taskId) ?? null;
409
+ },
410
+
411
+ /**
412
+ * Find a subtask by ID in the current snapshot.
413
+ * @param {string} subtaskId
414
+ * @returns {object|null}
415
+ */
416
+ getSubtaskById(subtaskId) {
417
+ return state.snapshot.subtasks.find((subtask) => subtask.id === subtaskId) ?? null;
418
+ },
419
+
420
+ /**
421
+ * Replace the snapshot with a new one (e.g. after server response).
422
+ * @param {object} nextRawSnapshot
423
+ */
424
+ replaceSnapshot(nextRawSnapshot) {
425
+ state.snapshot = normalize(nextRawSnapshot);
426
+ syncState();
427
+ persist();
428
+ notify();
429
+ },
430
+
431
+ /** Persist navigational state to localStorage. */
161
432
  persist,
162
- getTaskById,
163
- getSubtaskById,
164
- getBoardState,
165
- getSelectedEpic,
166
- getSelectedTask,
167
- getVisibleTasks,
168
- getVisibleEpics,
433
+
434
+ /**
435
+ * Reconcile state with a patch and return derived board state.
436
+ * @param {object} [patch]
437
+ * @returns {BoardState}
438
+ */
169
439
  syncState,
170
- replaceSnapshot,
440
+
441
+ /**
442
+ * Get visible epics from memoized selector.
443
+ * @returns {object[]}
444
+ */
445
+ getVisibleEpics() {
446
+ return selectVisibleEpics(state);
447
+ },
448
+
449
+ /**
450
+ * Get visible tasks from memoized selector.
451
+ * @returns {object[]}
452
+ */
453
+ getVisibleTasks() {
454
+ return selectVisibleTasks(state);
455
+ },
456
+
457
+ /**
458
+ * Get selected epic from memoized selector.
459
+ * @returns {object|null}
460
+ */
461
+ getSelectedEpic() {
462
+ return selectSelectedEpic(state);
463
+ },
464
+
465
+ /**
466
+ * Get selected task from memoized selector.
467
+ * @returns {object|null}
468
+ */
469
+ getSelectedTask() {
470
+ return deriveBoardState(state).selectedTask;
471
+ },
171
472
  };
172
473
  }