trekoon 0.2.7 → 0.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -0
- package/docs/commands.md +100 -0
- package/docs/quickstart.md +74 -1
- package/package.json +2 -1
- package/src/board/assets/app.js +1498 -0
- package/src/board/assets/components/AppShell.js +17 -0
- package/src/board/assets/components/BoardTopbar.js +78 -0
- package/src/board/assets/components/ClampedText.js +31 -0
- package/src/board/assets/components/EpicRow.js +62 -0
- package/src/board/assets/components/EpicsOverview.js +43 -0
- package/src/board/assets/components/WorkspaceHeader.js +70 -0
- package/src/board/assets/components/assetMap.js +65 -0
- package/src/board/assets/index.html +76 -0
- package/src/board/assets/main.js +27 -0
- package/src/board/assets/manifest.json +12 -0
- package/src/board/assets/state/actions.js +334 -0
- package/src/board/assets/state/api.js +126 -0
- package/src/board/assets/state/store.js +172 -0
- package/src/board/assets/styles/board.css +1127 -0
- package/src/board/assets/utils/dom.js +308 -0
- package/src/board/install.ts +196 -0
- package/src/board/open-browser.ts +131 -0
- package/src/board/routes.ts +299 -0
- package/src/board/server.ts +184 -0
- package/src/board/snapshot.ts +277 -0
- package/src/board/types.ts +43 -0
- package/src/commands/board.ts +158 -0
- package/src/commands/help.ts +21 -0
- package/src/commands/init.ts +29 -0
- package/src/domain/mutation-service.ts +40 -0
- package/src/domain/tracker-domain.ts +11 -3
- package/src/runtime/cli-shell.ts +5 -0
- package/src/storage/path.ts +36 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
function cloneSnapshot(snapshot) {
|
|
2
|
+
if (typeof structuredClone === "function") {
|
|
3
|
+
return structuredClone(snapshot);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
return JSON.parse(JSON.stringify(snapshot));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function normalizeArray(value) {
|
|
10
|
+
return Array.isArray(value) ? value : [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function updateTaskInSnapshot(snapshot, taskId, updates, normalizeSnapshot) {
|
|
14
|
+
const nextSnapshot = cloneSnapshot(snapshot);
|
|
15
|
+
const task = nextSnapshot.tasks.find((candidate) => candidate.id === taskId);
|
|
16
|
+
if (!task) {
|
|
17
|
+
return snapshot;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (updates.title !== undefined) task.title = updates.title;
|
|
21
|
+
if (updates.description !== undefined) task.description = updates.description;
|
|
22
|
+
if (updates.status !== undefined) task.status = updates.status;
|
|
23
|
+
task.updatedAt = Date.now();
|
|
24
|
+
return normalizeSnapshot(nextSnapshot);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function updateSubtaskInSnapshot(snapshot, subtaskId, updates, normalizeSnapshot) {
|
|
28
|
+
const nextSnapshot = cloneSnapshot(snapshot);
|
|
29
|
+
const subtask = nextSnapshot.subtasks.find((candidate) => candidate.id === subtaskId);
|
|
30
|
+
if (!subtask) {
|
|
31
|
+
return snapshot;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (updates.title !== undefined) subtask.title = updates.title;
|
|
35
|
+
if (updates.description !== undefined) subtask.description = updates.description;
|
|
36
|
+
if (updates.status !== undefined) subtask.status = updates.status;
|
|
37
|
+
subtask.updatedAt = Date.now();
|
|
38
|
+
return normalizeSnapshot(nextSnapshot);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function addDependencyInSnapshot(snapshot, sourceId, dependsOnId, normalizeSnapshot) {
|
|
42
|
+
const nextSnapshot = cloneSnapshot(snapshot);
|
|
43
|
+
const duplicate = normalizeArray(nextSnapshot.dependencies).some(
|
|
44
|
+
(dependency) => dependency.sourceId === sourceId && dependency.dependsOnId === dependsOnId,
|
|
45
|
+
);
|
|
46
|
+
if (!duplicate) {
|
|
47
|
+
normalizeArray(nextSnapshot.dependencies).push({
|
|
48
|
+
id: crypto.randomUUID(),
|
|
49
|
+
sourceId,
|
|
50
|
+
sourceKind: nextSnapshot.subtasks.some((subtask) => subtask.id === sourceId) ? "subtask" : "task",
|
|
51
|
+
dependsOnId,
|
|
52
|
+
dependsOnKind: nextSnapshot.subtasks.some((subtask) => subtask.id === dependsOnId) ? "subtask" : "task",
|
|
53
|
+
createdAt: Date.now(),
|
|
54
|
+
updatedAt: Date.now(),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
return normalizeSnapshot(nextSnapshot);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function removeDependencyInSnapshot(snapshot, sourceId, dependsOnId, normalizeSnapshot) {
|
|
61
|
+
const nextSnapshot = cloneSnapshot(snapshot);
|
|
62
|
+
nextSnapshot.dependencies = normalizeArray(nextSnapshot.dependencies).filter(
|
|
63
|
+
(dependency) => !(dependency.sourceId === sourceId && dependency.dependsOnId === dependsOnId),
|
|
64
|
+
);
|
|
65
|
+
return normalizeSnapshot(nextSnapshot);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createSubtaskInSnapshot(snapshot, input, normalizeSnapshot) {
|
|
69
|
+
const nextSnapshot = cloneSnapshot(snapshot);
|
|
70
|
+
normalizeArray(nextSnapshot.subtasks).push({
|
|
71
|
+
id: crypto.randomUUID(),
|
|
72
|
+
taskId: input.taskId,
|
|
73
|
+
title: input.title,
|
|
74
|
+
description: input.description ?? "",
|
|
75
|
+
status: input.status ?? "todo",
|
|
76
|
+
createdAt: Date.now(),
|
|
77
|
+
updatedAt: Date.now(),
|
|
78
|
+
});
|
|
79
|
+
return normalizeSnapshot(nextSnapshot);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function deleteSubtaskInSnapshot(snapshot, subtaskId, normalizeSnapshot) {
|
|
83
|
+
const nextSnapshot = cloneSnapshot(snapshot);
|
|
84
|
+
nextSnapshot.subtasks = normalizeArray(nextSnapshot.subtasks).filter((candidate) => candidate.id !== subtaskId);
|
|
85
|
+
nextSnapshot.dependencies = normalizeArray(nextSnapshot.dependencies).filter(
|
|
86
|
+
(dependency) => dependency.sourceId !== subtaskId && dependency.dependsOnId !== subtaskId,
|
|
87
|
+
);
|
|
88
|
+
return normalizeSnapshot(nextSnapshot);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createBoardActions(options) {
|
|
92
|
+
const {
|
|
93
|
+
model,
|
|
94
|
+
api,
|
|
95
|
+
rerender,
|
|
96
|
+
normalizeSnapshot,
|
|
97
|
+
normalizeStatus,
|
|
98
|
+
applyTheme,
|
|
99
|
+
closeTopmostDisclosure,
|
|
100
|
+
dismissSearch,
|
|
101
|
+
focusSearch,
|
|
102
|
+
focusTaskDetail,
|
|
103
|
+
searchFocusKeys,
|
|
104
|
+
} = options;
|
|
105
|
+
const { store, persist, getBoardState, getTaskById, syncState } = model;
|
|
106
|
+
|
|
107
|
+
const transition = (patch = {}, options = {}) => {
|
|
108
|
+
const { persistState = true, rerenderBoard = true } = options;
|
|
109
|
+
const boardState = syncState(patch);
|
|
110
|
+
if (persistState) {
|
|
111
|
+
persist();
|
|
112
|
+
}
|
|
113
|
+
if (rerenderBoard) {
|
|
114
|
+
rerender();
|
|
115
|
+
}
|
|
116
|
+
return boardState;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
let searchTimer = null;
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
toggleTheme() {
|
|
123
|
+
store.theme = store.theme === "dark" ? "light" : "dark";
|
|
124
|
+
applyTheme(store.theme);
|
|
125
|
+
rerender();
|
|
126
|
+
},
|
|
127
|
+
updateSearch(value) {
|
|
128
|
+
store.search = typeof value === "string" ? value : "";
|
|
129
|
+
if (searchTimer !== null) {
|
|
130
|
+
clearTimeout(searchTimer);
|
|
131
|
+
}
|
|
132
|
+
searchTimer = setTimeout(() => {
|
|
133
|
+
searchTimer = null;
|
|
134
|
+
syncState({ search: store.search });
|
|
135
|
+
persist();
|
|
136
|
+
rerender({ preserveFocus: false });
|
|
137
|
+
const input = document.querySelector("#board-search-input");
|
|
138
|
+
if (input instanceof HTMLInputElement) {
|
|
139
|
+
input.focus({ preventScroll: true });
|
|
140
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
141
|
+
}
|
|
142
|
+
}, 180);
|
|
143
|
+
},
|
|
144
|
+
openEpic(epicId) {
|
|
145
|
+
transition({
|
|
146
|
+
screen: "tasks",
|
|
147
|
+
selectedEpicId: epicId || null,
|
|
148
|
+
selectedTaskId: null,
|
|
149
|
+
selectedSubtaskId: null,
|
|
150
|
+
});
|
|
151
|
+
},
|
|
152
|
+
selectEpic(epicId) {
|
|
153
|
+
transition({
|
|
154
|
+
screen: "tasks",
|
|
155
|
+
selectedEpicId: epicId || null,
|
|
156
|
+
selectedTaskId: null,
|
|
157
|
+
selectedSubtaskId: null,
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
showEpics() {
|
|
161
|
+
transition({
|
|
162
|
+
screen: "epics",
|
|
163
|
+
selectedTaskId: null,
|
|
164
|
+
selectedSubtaskId: null,
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
showBoard() {
|
|
168
|
+
const fallbackEpicId = getBoardState().selectedEpicId || store.snapshot.epics[0]?.id || null;
|
|
169
|
+
if (!fallbackEpicId) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
transition({
|
|
174
|
+
screen: "tasks",
|
|
175
|
+
selectedEpicId: fallbackEpicId,
|
|
176
|
+
selectedTaskId: null,
|
|
177
|
+
selectedSubtaskId: null,
|
|
178
|
+
});
|
|
179
|
+
},
|
|
180
|
+
setView(view) {
|
|
181
|
+
transition({ view });
|
|
182
|
+
},
|
|
183
|
+
selectTask(taskId) {
|
|
184
|
+
const task = getTaskById(taskId);
|
|
185
|
+
if (!task) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
transition({
|
|
189
|
+
screen: "tasks",
|
|
190
|
+
selectedEpicId: task.epicId,
|
|
191
|
+
selectedTaskId: taskId,
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
closeTask() {
|
|
195
|
+
transition({ selectedTaskId: null, selectedSubtaskId: null });
|
|
196
|
+
},
|
|
197
|
+
openSubtask(subtaskId) {
|
|
198
|
+
transition({ selectedSubtaskId: subtaskId || null }, { persistState: false });
|
|
199
|
+
},
|
|
200
|
+
closeSubtask() {
|
|
201
|
+
transition({ selectedSubtaskId: null }, { persistState: false });
|
|
202
|
+
},
|
|
203
|
+
submitTaskForm(taskId, formData) {
|
|
204
|
+
const updates = {
|
|
205
|
+
title: String(formData.get("title") || "").trim(),
|
|
206
|
+
description: String(formData.get("description") || "").trim(),
|
|
207
|
+
status: normalizeStatus(String(formData.get("status") || "todo")),
|
|
208
|
+
};
|
|
209
|
+
api.patchTask(taskId, updates, (snapshot) => updateTaskInSnapshot(snapshot, taskId, updates, normalizeSnapshot));
|
|
210
|
+
},
|
|
211
|
+
submitSubtaskForm(subtaskId, formData) {
|
|
212
|
+
const updates = {
|
|
213
|
+
title: String(formData.get("title") || "").trim(),
|
|
214
|
+
description: String(formData.get("description") || "").trim(),
|
|
215
|
+
status: normalizeStatus(String(formData.get("status") || "todo")),
|
|
216
|
+
};
|
|
217
|
+
syncState({ selectedSubtaskId: subtaskId });
|
|
218
|
+
api.patchSubtask(subtaskId, updates, (snapshot) => updateSubtaskInSnapshot(snapshot, subtaskId, updates, normalizeSnapshot));
|
|
219
|
+
},
|
|
220
|
+
submitCreateSubtask(taskId, formData) {
|
|
221
|
+
const input = {
|
|
222
|
+
taskId,
|
|
223
|
+
title: String(formData.get("title") || "").trim(),
|
|
224
|
+
description: String(formData.get("description") || "").trim(),
|
|
225
|
+
status: normalizeStatus(String(formData.get("status") || "todo")),
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
if (!taskId || input.title.length === 0) {
|
|
229
|
+
store.notice = { type: "error", message: "Subtasks need a title before they can be added." };
|
|
230
|
+
rerender();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
api.createSubtask(input, (snapshot) => createSubtaskInSnapshot(snapshot, input, normalizeSnapshot));
|
|
235
|
+
},
|
|
236
|
+
deleteSubtask(subtaskId) {
|
|
237
|
+
if (!subtaskId) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
api.deleteSubtask(subtaskId, (snapshot) => deleteSubtaskInSnapshot(snapshot, subtaskId, normalizeSnapshot));
|
|
242
|
+
},
|
|
243
|
+
addDependency(sourceId, formData) {
|
|
244
|
+
const dependsOnId = String(formData.get("dependsOnId") || "").trim();
|
|
245
|
+
if (!dependsOnId) {
|
|
246
|
+
store.notice = { type: "error", message: "Choose a dependency target first." };
|
|
247
|
+
rerender();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
api.addDependency(sourceId, dependsOnId, (snapshot) => addDependencyInSnapshot(snapshot, sourceId, dependsOnId, normalizeSnapshot));
|
|
252
|
+
},
|
|
253
|
+
removeDependency(sourceId, dependsOnId) {
|
|
254
|
+
api.removeDependency(sourceId, dependsOnId, (snapshot) => removeDependencyInSnapshot(snapshot, sourceId, dependsOnId, normalizeSnapshot));
|
|
255
|
+
},
|
|
256
|
+
dropTaskStatus(taskId, nextStatus) {
|
|
257
|
+
const task = getTaskById(taskId);
|
|
258
|
+
if (!task || !nextStatus || task.status === nextStatus) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
transition({ selectedTaskId: taskId }, { rerenderBoard: false });
|
|
262
|
+
api.patchTask(taskId, { status: nextStatus }, (snapshot) => updateTaskInSnapshot(snapshot, taskId, { status: nextStatus }, normalizeSnapshot));
|
|
263
|
+
},
|
|
264
|
+
handleKeydown(event) {
|
|
265
|
+
const boardState = getBoardState();
|
|
266
|
+
const activeElement = document.activeElement;
|
|
267
|
+
const tagName = activeElement?.tagName?.toLowerCase();
|
|
268
|
+
const isTypingTarget = tagName === "input" || tagName === "textarea" || tagName === "select";
|
|
269
|
+
const visibleTasks = boardState.visibleTasks;
|
|
270
|
+
const currentIndex = visibleTasks.findIndex((task) => task.id === boardState.selectedTaskId);
|
|
271
|
+
|
|
272
|
+
if (searchFocusKeys.has(event.key.toLowerCase()) && activeElement?.id !== "board-search-input" && !isTypingTarget) {
|
|
273
|
+
event.preventDefault();
|
|
274
|
+
focusSearch?.(activeElement);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (event.key === "Escape") {
|
|
279
|
+
if (closeTopmostDisclosure?.(boardState, activeElement)) {
|
|
280
|
+
event.preventDefault();
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (boardState.selectedSubtaskId) {
|
|
285
|
+
event.preventDefault();
|
|
286
|
+
this.closeSubtask();
|
|
287
|
+
} else if (boardState.selectedTaskId) {
|
|
288
|
+
event.preventDefault();
|
|
289
|
+
this.closeTask();
|
|
290
|
+
} else if (dismissSearch?.(boardState, activeElement)) {
|
|
291
|
+
event.preventDefault();
|
|
292
|
+
return;
|
|
293
|
+
} else if (boardState.screen === "tasks") {
|
|
294
|
+
event.preventDefault();
|
|
295
|
+
this.showEpics();
|
|
296
|
+
} else if (store.notice) {
|
|
297
|
+
event.preventDefault();
|
|
298
|
+
store.notice = null;
|
|
299
|
+
rerender();
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (boardState.screen !== "tasks" || isTypingTarget || visibleTasks.length === 0) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (event.key.toLowerCase() === "j" || event.key === "ArrowDown") {
|
|
309
|
+
event.preventDefault();
|
|
310
|
+
const nextTask = visibleTasks[Math.min(currentIndex + 1, visibleTasks.length - 1)] ?? visibleTasks[0];
|
|
311
|
+
this.selectTask(nextTask.id);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (event.key.toLowerCase() === "k" || event.key === "ArrowUp") {
|
|
316
|
+
event.preventDefault();
|
|
317
|
+
const previousTask = visibleTasks[Math.max(currentIndex - 1, 0)] ?? visibleTasks[0];
|
|
318
|
+
this.selectTask(previousTask.id);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (event.key === "Enter" && currentIndex >= 0) {
|
|
323
|
+
event.preventDefault();
|
|
324
|
+
focusTaskDetail?.();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (event.key === "Enter" && currentIndex === -1 && visibleTasks[0]) {
|
|
329
|
+
event.preventDefault();
|
|
330
|
+
this.selectTask(visibleTasks[0].id);
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
function cloneSnapshot(snapshot) {
|
|
2
|
+
if (typeof structuredClone === "function") {
|
|
3
|
+
return structuredClone(snapshot);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
return JSON.parse(JSON.stringify(snapshot));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createApi(model, options) {
|
|
10
|
+
const { sessionToken, rerender } = options;
|
|
11
|
+
|
|
12
|
+
async function request(path, requestOptions = {}) {
|
|
13
|
+
const headers = new Headers(requestOptions.headers || {});
|
|
14
|
+
if (sessionToken.length > 0) {
|
|
15
|
+
headers.set("authorization", `Bearer ${sessionToken}`);
|
|
16
|
+
}
|
|
17
|
+
if (requestOptions.body && !headers.has("content-type")) {
|
|
18
|
+
headers.set("content-type", "application/json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const response = await fetch(path, { ...requestOptions, headers });
|
|
22
|
+
const payload = await response.json();
|
|
23
|
+
if (!payload?.ok) {
|
|
24
|
+
const message = payload?.error?.message || "Board request failed";
|
|
25
|
+
const error = new Error(message);
|
|
26
|
+
error.code = payload?.error?.code;
|
|
27
|
+
error.details = payload?.error?.details;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return payload.data;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function runMutation({ optimistic, request: mutationRequest, successMessage }) {
|
|
35
|
+
if (model.store.isMutating) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const previousSnapshot = cloneSnapshot(model.store.snapshot);
|
|
40
|
+
model.store.notice = null;
|
|
41
|
+
model.store.isMutating = true;
|
|
42
|
+
|
|
43
|
+
if (typeof optimistic === "function") {
|
|
44
|
+
model.store.snapshot = optimistic(cloneSnapshot(model.store.snapshot));
|
|
45
|
+
rerender();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const data = await mutationRequest();
|
|
50
|
+
if (data?.snapshot) {
|
|
51
|
+
model.replaceSnapshot(data.snapshot);
|
|
52
|
+
}
|
|
53
|
+
model.store.notice = successMessage ? { type: "success", message: successMessage } : null;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
model.replaceSnapshot(previousSnapshot);
|
|
56
|
+
model.store.notice = {
|
|
57
|
+
type: "error",
|
|
58
|
+
message: error instanceof Error ? error.message : String(error),
|
|
59
|
+
};
|
|
60
|
+
} finally {
|
|
61
|
+
model.store.isMutating = false;
|
|
62
|
+
rerender();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
patchTask(taskId, updates, optimistic) {
|
|
68
|
+
return runMutation({
|
|
69
|
+
optimistic,
|
|
70
|
+
successMessage: "Task saved.",
|
|
71
|
+
request: () => request(`/api/tasks/${encodeURIComponent(taskId)}`, {
|
|
72
|
+
method: "PATCH",
|
|
73
|
+
body: JSON.stringify(updates),
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
patchSubtask(subtaskId, updates, optimistic) {
|
|
78
|
+
return runMutation({
|
|
79
|
+
optimistic,
|
|
80
|
+
successMessage: "Subtask saved.",
|
|
81
|
+
request: () => request(`/api/subtasks/${encodeURIComponent(subtaskId)}`, {
|
|
82
|
+
method: "PATCH",
|
|
83
|
+
body: JSON.stringify(updates),
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
createSubtask(input, optimistic) {
|
|
88
|
+
return runMutation({
|
|
89
|
+
optimistic,
|
|
90
|
+
successMessage: "Subtask added.",
|
|
91
|
+
request: () => request("/api/subtasks", {
|
|
92
|
+
method: "POST",
|
|
93
|
+
body: JSON.stringify(input),
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
deleteSubtask(subtaskId, optimistic) {
|
|
98
|
+
return runMutation({
|
|
99
|
+
optimistic,
|
|
100
|
+
successMessage: "Subtask removed.",
|
|
101
|
+
request: () => request(`/api/subtasks/${encodeURIComponent(subtaskId)}`, {
|
|
102
|
+
method: "DELETE",
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
addDependency(sourceId, dependsOnId, optimistic) {
|
|
107
|
+
return runMutation({
|
|
108
|
+
optimistic,
|
|
109
|
+
successMessage: "Dependency added.",
|
|
110
|
+
request: () => request("/api/dependencies", {
|
|
111
|
+
method: "POST",
|
|
112
|
+
body: JSON.stringify({ sourceId, dependsOnId }),
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
removeDependency(sourceId, dependsOnId, optimistic) {
|
|
117
|
+
return runMutation({
|
|
118
|
+
optimistic,
|
|
119
|
+
successMessage: "Dependency removed.",
|
|
120
|
+
request: () => request(`/api/dependencies?sourceId=${encodeURIComponent(sourceId)}&dependsOnId=${encodeURIComponent(dependsOnId)}`, {
|
|
121
|
+
method: "DELETE",
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
export const THEME_STORAGE_KEY = "trekoon-board-theme";
|
|
2
|
+
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
|
+
|
|
6
|
+
function normalizeSearch(value) {
|
|
7
|
+
return typeof value === "string" ? value : "";
|
|
8
|
+
}
|
|
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
|
+
? {
|
|
39
|
+
kind: searchQuery.length > 0 ? "epic_search" : "epic",
|
|
40
|
+
label: selectedEpic.title,
|
|
41
|
+
summary: searchQuery.length > 0 ? `Searching ${selectedEpic.title}` : `Epic ${selectedEpic.title}`,
|
|
42
|
+
detail: searchQuery.length > 0
|
|
43
|
+
? `${visibleTasks.length} matching task${visibleTasks.length === 1 ? "" : "s"} in this epic`
|
|
44
|
+
: `${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
|
+
};
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
screen,
|
|
57
|
+
selectedEpicId,
|
|
58
|
+
selectedEpic,
|
|
59
|
+
selectedTaskId,
|
|
60
|
+
selectedTask: selectedTaskId ? selectedTask : null,
|
|
61
|
+
selectedSubtaskId,
|
|
62
|
+
selectedSubtask,
|
|
63
|
+
search,
|
|
64
|
+
searchQuery,
|
|
65
|
+
searchScope,
|
|
66
|
+
visibleEpics,
|
|
67
|
+
visibleTasks,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function reconcileBoardState(snapshot, state) {
|
|
72
|
+
const derivedState = deriveBoardState(snapshot, state);
|
|
73
|
+
return {
|
|
74
|
+
screen: derivedState.screen,
|
|
75
|
+
selectedEpicId: derivedState.selectedEpicId,
|
|
76
|
+
search: derivedState.search,
|
|
77
|
+
view: VIEW_MODES.includes(state.view) ? state.view : "kanban",
|
|
78
|
+
selectedTaskId: derivedState.selectedTaskId,
|
|
79
|
+
selectedSubtaskId: derivedState.selectedSubtaskId,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
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
|
+
}
|
|
94
|
+
|
|
95
|
+
export function readThemePreference() {
|
|
96
|
+
const storedTheme = localStorage.getItem(THEME_STORAGE_KEY);
|
|
97
|
+
return storedTheme === "light" || storedTheme === "dark" ? storedTheme : "dark";
|
|
98
|
+
}
|
|
99
|
+
|
|
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 = {
|
|
109
|
+
snapshot,
|
|
110
|
+
screen: storedState.screen === "tasks" ? "tasks" : "epics",
|
|
111
|
+
selectedEpicId: typeof storedState.selectedEpicId === "string" ? storedState.selectedEpicId : null,
|
|
112
|
+
search: normalizeSearch(storedState.search),
|
|
113
|
+
view: VIEW_MODES.includes(storedState.view) ? storedState.view : "kanban",
|
|
114
|
+
selectedTaskId: typeof storedState.selectedTaskId === "string" ? storedState.selectedTaskId : null,
|
|
115
|
+
selectedSubtaskId: null,
|
|
116
|
+
theme: readThemePreference(),
|
|
117
|
+
focusedEpicIndex: 0,
|
|
118
|
+
notice: null,
|
|
119
|
+
isMutating: false,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const persist = () => {
|
|
123
|
+
writeStoredState({
|
|
124
|
+
screen: store.screen,
|
|
125
|
+
selectedEpicId: store.selectedEpicId,
|
|
126
|
+
search: store.search,
|
|
127
|
+
view: store.view,
|
|
128
|
+
selectedTaskId: store.selectedTaskId,
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
|
|
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
|
+
};
|
|
150
|
+
|
|
151
|
+
const replaceSnapshot = (nextSnapshot) => {
|
|
152
|
+
store.snapshot = normalizeSnapshot(nextSnapshot);
|
|
153
|
+
syncState();
|
|
154
|
+
persist();
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
syncState();
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
store,
|
|
161
|
+
persist,
|
|
162
|
+
getTaskById,
|
|
163
|
+
getSubtaskById,
|
|
164
|
+
getBoardState,
|
|
165
|
+
getSelectedEpic,
|
|
166
|
+
getSelectedTask,
|
|
167
|
+
getVisibleTasks,
|
|
168
|
+
getVisibleEpics,
|
|
169
|
+
syncState,
|
|
170
|
+
replaceSnapshot,
|
|
171
|
+
};
|
|
172
|
+
}
|