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.
- package/README.md +14 -14
- package/docs/commands.md +9 -11
- package/docs/quickstart.md +10 -12
- package/package.json +23 -1
- package/src/board/assets/app.js +469 -1377
- package/src/board/assets/components/ClampedText.js +1 -1
- package/src/board/assets/components/Component.js +271 -0
- package/src/board/assets/components/ConfirmDialog.js +81 -0
- package/src/board/assets/components/EpicRow.js +43 -26
- package/src/board/assets/components/EpicsOverview.js +52 -11
- package/src/board/assets/components/Inspector.js +335 -0
- package/src/board/assets/components/Notice.js +87 -0
- package/src/board/assets/components/SubtaskModal.js +100 -0
- package/src/board/assets/components/TaskCard.js +82 -0
- package/src/board/assets/components/TaskModal.js +99 -0
- package/src/board/assets/components/TopBar.js +167 -0
- package/src/board/assets/components/Workspace.js +319 -0
- package/src/board/assets/components/assetMap.js +29 -14
- package/src/board/assets/components/helpers.js +261 -0
- package/src/board/assets/fonts/inter-latin.woff2 +0 -0
- package/src/board/assets/fonts/material-symbols-rounded.woff2 +0 -0
- package/src/board/assets/index.html +20 -57
- package/src/board/assets/main.js +2 -18
- package/src/board/assets/runtime/clipboard.js +34 -0
- package/src/board/assets/runtime/delegation.js +342 -0
- package/src/board/assets/state/actions.js +204 -16
- package/src/board/assets/state/api.js +201 -46
- package/src/board/assets/state/store.js +418 -117
- package/src/board/assets/state/url.js +184 -0
- package/src/board/assets/state/utils.js +222 -0
- package/src/board/assets/styles/board.css +933 -129
- package/src/board/assets/styles/fonts.css +22 -0
- package/src/board/routes.ts +15 -6
- package/src/board/server.ts +1 -0
- package/src/board/assets/components/AppShell.js +0 -17
- package/src/board/assets/components/BoardTopbar.js +0 -78
- package/src/board/assets/components/WorkspaceHeader.js +0 -70
- 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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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(
|
|
72
|
-
const
|
|
233
|
+
function reconcileBoardState(state) {
|
|
234
|
+
const derived = deriveBoardState(state);
|
|
73
235
|
return {
|
|
74
|
-
screen:
|
|
75
|
-
selectedEpicId:
|
|
76
|
-
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:
|
|
79
|
-
selectedSubtaskId:
|
|
240
|
+
selectedTaskId: derived.selectedTaskId,
|
|
241
|
+
selectedSubtaskId: derived.selectedSubtaskId,
|
|
80
242
|
};
|
|
81
243
|
}
|
|
82
244
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
125
|
-
selectedEpicId:
|
|
126
|
-
search:
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
}
|