trekoon 0.2.8 → 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.
- package/package.json +1 -1
- package/src/board/assets/app.js +468 -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 +23 -21
- package/src/board/assets/components/EpicsOverview.js +48 -11
- package/src/board/assets/components/Inspector.js +335 -0
- package/src/board/assets/components/Notice.js +80 -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 +308 -0
- package/src/board/assets/components/assetMap.js +29 -14
- package/src/board/assets/components/helpers.js +244 -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/delegation.js +309 -0
- package/src/board/assets/state/actions.js +136 -16
- package/src/board/assets/state/api.js +201 -46
- package/src/board/assets/state/store.js +417 -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 +811 -127
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/** @type {readonly string[]} */
|
|
2
|
+
export const STATUS_ORDER = ["todo", "blocked", "in_progress", "done"];
|
|
3
|
+
|
|
4
|
+
/** @type {readonly string[]} */
|
|
5
|
+
export const VIEW_MODES = ["kanban", "list"];
|
|
6
|
+
|
|
7
|
+
const VALID_STATUSES = new Set(STATUS_ORDER);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Normalize a raw status string into one of the canonical status values.
|
|
11
|
+
* @param {string} rawStatus
|
|
12
|
+
* @returns {"todo"|"blocked"|"in_progress"|"done"}
|
|
13
|
+
*/
|
|
14
|
+
export function normalizeStatus(rawStatus) {
|
|
15
|
+
if (rawStatus === "in-progress") return "in_progress";
|
|
16
|
+
if (VALID_STATUSES.has(rawStatus)) return rawStatus;
|
|
17
|
+
return "todo";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {unknown} value
|
|
22
|
+
* @returns {any[]}
|
|
23
|
+
*/
|
|
24
|
+
function normalizeArray(value) {
|
|
25
|
+
return Array.isArray(value) ? value : [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{ id?: string }} record
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
function getId(record) {
|
|
33
|
+
return typeof record?.id === "string" && record.id.length > 0 ? record.id : crypto.randomUUID();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {any[]} tasks
|
|
38
|
+
* @returns {Record<string, number>}
|
|
39
|
+
*/
|
|
40
|
+
function deriveCounts(tasks) {
|
|
41
|
+
return STATUS_ORDER.reduce((counts, status) => {
|
|
42
|
+
counts[status] = tasks.filter((task) => task.status === status).length;
|
|
43
|
+
return counts;
|
|
44
|
+
}, {});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Normalize a raw board snapshot into the canonical shape with search text,
|
|
49
|
+
* dependency cross-references, and epic counts.
|
|
50
|
+
* @param {object} rawSnapshot
|
|
51
|
+
* @returns {object}
|
|
52
|
+
*/
|
|
53
|
+
export function normalizeSnapshot(rawSnapshot) {
|
|
54
|
+
const rawEpics = normalizeArray(rawSnapshot?.epics);
|
|
55
|
+
const rawTasks = normalizeArray(rawSnapshot?.tasks);
|
|
56
|
+
const rawSubtasks = normalizeArray(rawSnapshot?.subtasks);
|
|
57
|
+
const rawDependencies = normalizeArray(rawSnapshot?.dependencies);
|
|
58
|
+
const taskIndex = new Map();
|
|
59
|
+
const subtaskIndex = new Map();
|
|
60
|
+
|
|
61
|
+
const tasks = rawTasks.map((task) => {
|
|
62
|
+
const normalizedTask = {
|
|
63
|
+
id: getId(task),
|
|
64
|
+
kind: "task",
|
|
65
|
+
epicId: task.epicId ?? task.epic?.id ?? null,
|
|
66
|
+
title: String(task.title ?? "Untitled task"),
|
|
67
|
+
description: String(task.description ?? "").replace(/\\n/g, "\n"),
|
|
68
|
+
status: normalizeStatus(task.status),
|
|
69
|
+
createdAt: Number(task.createdAt ?? Date.now()),
|
|
70
|
+
updatedAt: Number(task.updatedAt ?? task.createdAt ?? Date.now()),
|
|
71
|
+
blockedBy: [],
|
|
72
|
+
blocks: [],
|
|
73
|
+
dependencyIds: [],
|
|
74
|
+
dependentIds: [],
|
|
75
|
+
subtasks: [],
|
|
76
|
+
searchText: "",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
taskIndex.set(normalizedTask.id, normalizedTask);
|
|
80
|
+
return normalizedTask;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const subtasks = rawSubtasks.map((subtask) => {
|
|
84
|
+
const normalizedSubtask = {
|
|
85
|
+
id: getId(subtask),
|
|
86
|
+
kind: "subtask",
|
|
87
|
+
taskId: subtask.taskId ?? subtask.task?.id ?? null,
|
|
88
|
+
title: String(subtask.title ?? "Untitled subtask"),
|
|
89
|
+
description: String(subtask.description ?? "").replace(/\\n/g, "\n"),
|
|
90
|
+
status: normalizeStatus(subtask.status),
|
|
91
|
+
createdAt: Number(subtask.createdAt ?? Date.now()),
|
|
92
|
+
updatedAt: Number(subtask.updatedAt ?? subtask.createdAt ?? Date.now()),
|
|
93
|
+
blockedBy: [],
|
|
94
|
+
blocks: [],
|
|
95
|
+
dependencyIds: [],
|
|
96
|
+
dependentIds: [],
|
|
97
|
+
searchText: "",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
subtaskIndex.set(normalizedSubtask.id, normalizedSubtask);
|
|
101
|
+
return normalizedSubtask;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
for (const subtask of subtasks) {
|
|
105
|
+
const parentTask = taskIndex.get(subtask.taskId);
|
|
106
|
+
if (parentTask) {
|
|
107
|
+
parentTask.subtasks.push(subtask);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const dependencies = rawDependencies.map((dependency) => ({
|
|
112
|
+
id: getId(dependency),
|
|
113
|
+
sourceId: String(dependency.sourceId ?? ""),
|
|
114
|
+
sourceKind: dependency.sourceKind === "subtask" ? "subtask" : "task",
|
|
115
|
+
dependsOnId: String(dependency.dependsOnId ?? ""),
|
|
116
|
+
dependsOnKind: dependency.dependsOnKind === "subtask" ? "subtask" : "task",
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
const lookupNode = (kind, id) => {
|
|
120
|
+
if (kind === "subtask") {
|
|
121
|
+
return subtaskIndex.get(id) ?? null;
|
|
122
|
+
}
|
|
123
|
+
return taskIndex.get(id) ?? null;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
for (const dependency of dependencies) {
|
|
127
|
+
const source = lookupNode(dependency.sourceKind, dependency.sourceId);
|
|
128
|
+
const target = lookupNode(dependency.dependsOnKind, dependency.dependsOnId);
|
|
129
|
+
if (source) {
|
|
130
|
+
source.blockedBy.push(dependency.dependsOnId);
|
|
131
|
+
source.dependencyIds.push(dependency.id);
|
|
132
|
+
}
|
|
133
|
+
if (target) {
|
|
134
|
+
target.blocks.push(dependency.sourceId);
|
|
135
|
+
target.dependentIds.push(dependency.id);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const epics = rawEpics.map((epic) => {
|
|
140
|
+
const epicId = getId(epic);
|
|
141
|
+
const epicTasks = tasks.filter((task) => task.epicId === epicId);
|
|
142
|
+
const normalizedEpic = {
|
|
143
|
+
id: epicId,
|
|
144
|
+
title: String(epic.title ?? "Untitled epic"),
|
|
145
|
+
description: String(epic.description ?? "").replace(/\\n/g, "\n"),
|
|
146
|
+
status: String(epic.status ?? "todo"),
|
|
147
|
+
createdAt: Number(epic.createdAt ?? Date.now()),
|
|
148
|
+
updatedAt: Number(epic.updatedAt ?? epic.createdAt ?? Date.now()),
|
|
149
|
+
taskIds: epicTasks.map((task) => task.id),
|
|
150
|
+
counts: deriveCounts(epicTasks),
|
|
151
|
+
searchText: "",
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
normalizedEpic.searchText = [normalizedEpic.title, normalizedEpic.description, ...epicTasks.map((task) => task.title)].join(" ").toLowerCase();
|
|
155
|
+
return normalizedEpic;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
for (const subtask of subtasks) {
|
|
159
|
+
subtask.searchText = [subtask.title, subtask.description, subtask.status].join(" ").toLowerCase();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const task of tasks) {
|
|
163
|
+
task.searchText = [
|
|
164
|
+
task.title,
|
|
165
|
+
task.description,
|
|
166
|
+
task.status,
|
|
167
|
+
...task.subtasks.map((subtask) => `${subtask.title} ${subtask.description} ${subtask.status}`),
|
|
168
|
+
].join(" ").toLowerCase();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const taskSearchTextByEpicId = new Map();
|
|
172
|
+
for (const task of tasks) {
|
|
173
|
+
if (!task.epicId) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const entries = taskSearchTextByEpicId.get(task.epicId) ?? [];
|
|
178
|
+
entries.push(task.searchText);
|
|
179
|
+
taskSearchTextByEpicId.set(task.epicId, entries);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
generatedAt: rawSnapshot?.generatedAt ?? null,
|
|
184
|
+
epics: epics.map((epic) => ({
|
|
185
|
+
...epic,
|
|
186
|
+
searchText: [
|
|
187
|
+
epic.title,
|
|
188
|
+
epic.description,
|
|
189
|
+
...(taskSearchTextByEpicId.get(epic.id) ?? []),
|
|
190
|
+
].join(" ").toLowerCase(),
|
|
191
|
+
})),
|
|
192
|
+
tasks,
|
|
193
|
+
subtasks,
|
|
194
|
+
dependencies,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const dateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" });
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Format a timestamp into a human-readable date string.
|
|
202
|
+
* Uses a cached Intl.DateTimeFormat instance for performance.
|
|
203
|
+
* @param {number|null|undefined} timestamp
|
|
204
|
+
* @returns {string}
|
|
205
|
+
*/
|
|
206
|
+
export function formatDate(timestamp) {
|
|
207
|
+
if (!timestamp) return "Unknown";
|
|
208
|
+
return dateFormatter.format(timestamp);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Escape HTML special characters to prevent XSS.
|
|
213
|
+
* @param {string} value
|
|
214
|
+
* @returns {string}
|
|
215
|
+
*/
|
|
216
|
+
export function escapeHtml(value) {
|
|
217
|
+
return String(value)
|
|
218
|
+
.replaceAll("&", "&")
|
|
219
|
+
.replaceAll("<", "<")
|
|
220
|
+
.replaceAll(">", ">")
|
|
221
|
+
.replaceAll('"', """);
|
|
222
|
+
}
|