tuiboard 0.5.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/.tuiboard/config.example.yaml +32 -0
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/bin/tuiboard.ts +28 -0
- package/package.json +62 -0
- package/src/app.tsx +129 -0
- package/src/cli/args.test.ts +40 -0
- package/src/cli/args.ts +41 -0
- package/src/config/loader.ts +169 -0
- package/src/input/handleKey.ts +733 -0
- package/src/io/watcher.ts +85 -0
- package/src/io/writer.ts +92 -0
- package/src/parser/markdown.ts +351 -0
- package/src/parser/serialize.ts +97 -0
- package/src/scripts/agents-check.ts +24 -0
- package/src/scripts/parse-check.ts +124 -0
- package/src/scripts/roundtrip-check.ts +79 -0
- package/src/store/agents.test.ts +181 -0
- package/src/store/agents.ts +435 -0
- package/src/store/index.test.ts +110 -0
- package/src/store/index.ts +972 -0
- package/src/store/parsers.ts +243 -0
- package/src/store/timeline.test.ts +279 -0
- package/src/store/timeline.ts +279 -0
- package/src/store/virtual-panel.ts +0 -0
- package/src/types.ts +116 -0
- package/src/ui/AgentRow.tsx +79 -0
- package/src/ui/AgentsBar.tsx +102 -0
- package/src/ui/BoardView.tsx +333 -0
- package/src/ui/Chrome.tsx +122 -0
- package/src/ui/Modal.tsx +613 -0
- package/src/ui/TaskRow.tsx +240 -0
- package/src/ui/TimelineView.tsx +643 -0
- package/src/ui/VirtualPanel.tsx +237 -0
- package/src/ui/board-scroll.test.ts +63 -0
- package/src/ui/board-scroll.ts +49 -0
- package/src/ui/glyphs.ts +129 -0
- package/src/views/AgentsOnly.tsx +103 -0
- package/src/views/BoardOnly.tsx +35 -0
- package/src/views/Dashboard.tsx +106 -0
- package/src/views/TimelineOnly.tsx +12 -0
|
@@ -0,0 +1,972 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reactive store — single source of truth for tuiboard.
|
|
3
|
+
*
|
|
4
|
+
* Built on Solid's `createStore` (proxied mutable store) for cheap fine-
|
|
5
|
+
* grained reactivity: a change to a single task's `done` flag re-renders
|
|
6
|
+
* only the views observing that task, not the whole board.
|
|
7
|
+
*
|
|
8
|
+
* The store also owns:
|
|
9
|
+
* - The list of loaded boards (with their original mtime watermark)
|
|
10
|
+
* - UI state: active board index, cursor (col/row), collapse flags,
|
|
11
|
+
* filter, mode (kanban / virtual / list)
|
|
12
|
+
* - The undo log
|
|
13
|
+
*
|
|
14
|
+
* Mutations always:
|
|
15
|
+
* 1. Update the in-memory model.
|
|
16
|
+
* 2. Mark the touched task as `dirty: true`.
|
|
17
|
+
* 3. Push an inverse action onto the undo log.
|
|
18
|
+
* 4. Schedule a debounced write to disk (writer + watcher self-mark).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import { createMemo } from "solid-js";
|
|
23
|
+
import { createStore, produce } from "solid-js/store";
|
|
24
|
+
|
|
25
|
+
import type { Config } from "~/config/loader";
|
|
26
|
+
import {
|
|
27
|
+
createBoardWatcher,
|
|
28
|
+
type BoardWatcher,
|
|
29
|
+
} from "~/io/watcher";
|
|
30
|
+
import { createAgentsStore, type AgentsStore } from "./agents";
|
|
31
|
+
import { ConflictError, statMtime, writeBoardFile } from "~/io/writer";
|
|
32
|
+
import { isTask, parseBoard } from "~/parser/markdown";
|
|
33
|
+
import { serializeBoard } from "~/parser/serialize";
|
|
34
|
+
import type {
|
|
35
|
+
Board,
|
|
36
|
+
Column,
|
|
37
|
+
PriorityLevel,
|
|
38
|
+
Task,
|
|
39
|
+
TimeBlock,
|
|
40
|
+
} from "~/types";
|
|
41
|
+
|
|
42
|
+
// ─── Store shape ────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/** Identity of a task within the store: board path + column index + task index in column. */
|
|
45
|
+
export interface TaskRef {
|
|
46
|
+
boardPath: string;
|
|
47
|
+
columnIndex: number;
|
|
48
|
+
/** Index of the task among `Task` children of its column (ignores blanks/breaks). */
|
|
49
|
+
taskIndex: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface LoadedBoard {
|
|
53
|
+
board: Board;
|
|
54
|
+
/** mtime in ms at the moment of the last successful read or write. */
|
|
55
|
+
mtimeMs: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type ViewMode = "kanban" | "list";
|
|
59
|
+
|
|
60
|
+
export type ModalKind =
|
|
61
|
+
| { kind: "add"; targetColumnIndex: number }
|
|
62
|
+
| { kind: "edit"; ref: TaskRef }
|
|
63
|
+
| { kind: "schedule"; ref: TaskRef }
|
|
64
|
+
| { kind: "timeblock"; ref: TaskRef }
|
|
65
|
+
| { kind: "assign"; ref: TaskRef }
|
|
66
|
+
| { kind: "confirm-delete"; ref: TaskRef }
|
|
67
|
+
| { kind: "detail"; ref: TaskRef }
|
|
68
|
+
| { kind: "agent-detail"; sessionId: string }
|
|
69
|
+
| { kind: "search" }
|
|
70
|
+
| { kind: "help" };
|
|
71
|
+
|
|
72
|
+
/** Which dashboard zone owns the keyboard cursor. */
|
|
73
|
+
export type ActiveZone = "virtual" | "board" | "timeline" | "agents";
|
|
74
|
+
|
|
75
|
+
/** Fixed cycling order for Shift+Tab navigation. */
|
|
76
|
+
const ZONE_ORDER: readonly ActiveZone[] = ["virtual", "board", "timeline", "agents"];
|
|
77
|
+
|
|
78
|
+
export interface UIState {
|
|
79
|
+
activeBoardIndex: number;
|
|
80
|
+
/** Which dashboard zone owns the keyboard cursor right now. */
|
|
81
|
+
activeZone: ActiveZone;
|
|
82
|
+
/** Which zones are currently rendered. `board` cannot be hidden (load-bearing). */
|
|
83
|
+
visibleZones: Record<ActiveZone, boolean>;
|
|
84
|
+
/** Column index (within active board) — meaningful only when `activeZone === "board"`. */
|
|
85
|
+
col: number;
|
|
86
|
+
/** Row index inside the active zone. */
|
|
87
|
+
row: number;
|
|
88
|
+
/**
|
|
89
|
+
* Zoom mode: when true, only the active column is rendered, taking the
|
|
90
|
+
* full width of the board area. Done tasks within the zoomed column
|
|
91
|
+
* become visible inline (since the user has explicitly focused there).
|
|
92
|
+
* Toggled with `z`.
|
|
93
|
+
*/
|
|
94
|
+
zoomed: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Grab mode: when true, h/l moves the cursor task between adjacent
|
|
97
|
+
* columns instead of just moving the cursor. Toggled with `g`. Exit
|
|
98
|
+
* with `g` again or `Esc`. Mirrors Python kanban `toggle_move`.
|
|
99
|
+
*/
|
|
100
|
+
grabbing: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Currently-armed timeline block. Click-to-arm + click-to-place pattern
|
|
103
|
+
* (mirrors Python timeline.py): first click arms; second click on an
|
|
104
|
+
* empty row moves the armed block's start there; shift+click resizes
|
|
105
|
+
* end. `j`/`k`/`+`/`-` while armed nudge the block by 15 min. `Esc`
|
|
106
|
+
* cancels.
|
|
107
|
+
*/
|
|
108
|
+
armedTimelineRef?: TaskRef;
|
|
109
|
+
view: ViewMode;
|
|
110
|
+
/**
|
|
111
|
+
* Tasks marked for bulk ops (`Space`). Key format:
|
|
112
|
+
* `${boardPath}::${columnIndex}::${taskIndex}`
|
|
113
|
+
*
|
|
114
|
+
* Plain Record for Solid reactivity (Set isn't tracked).
|
|
115
|
+
*/
|
|
116
|
+
marked: Record<string, true>;
|
|
117
|
+
/** Active filter. */
|
|
118
|
+
filter: "all" | "today" | "overdue" | "tomorrow" | "followup";
|
|
119
|
+
/** Banner messages (errors, conflicts, undo notifications). */
|
|
120
|
+
banner?: { kind: "info" | "warn" | "error"; text: string; ts: number };
|
|
121
|
+
/** Open modal, if any. Keyboard handler routes input to the modal when set. */
|
|
122
|
+
modal?: ModalKind;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface UndoEntry {
|
|
126
|
+
description: string;
|
|
127
|
+
/** Inverse function — closes over enough state to restore the prior value. */
|
|
128
|
+
inverse: () => void;
|
|
129
|
+
ts: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface StoreState {
|
|
133
|
+
boards: LoadedBoard[];
|
|
134
|
+
ui: UIState;
|
|
135
|
+
undo: UndoEntry[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── Construction ───────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
export interface CreateStoreOptions {
|
|
141
|
+
config: Config;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function createTuiStore({ config }: CreateStoreOptions) {
|
|
145
|
+
const initialBoards = loadAll(config);
|
|
146
|
+
|
|
147
|
+
const [state, setState] = createStore<StoreState>({
|
|
148
|
+
boards: initialBoards,
|
|
149
|
+
ui: {
|
|
150
|
+
activeBoardIndex: 0,
|
|
151
|
+
activeZone: "board",
|
|
152
|
+
visibleZones: { virtual: true, board: true, timeline: true, agents: true },
|
|
153
|
+
col: 0,
|
|
154
|
+
row: 0,
|
|
155
|
+
zoomed: false,
|
|
156
|
+
grabbing: false,
|
|
157
|
+
view: "kanban",
|
|
158
|
+
marked: {},
|
|
159
|
+
filter: "all",
|
|
160
|
+
},
|
|
161
|
+
undo: [],
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ─── Watcher ─────────────────────────────────────────────────────────────
|
|
165
|
+
const watcher: BoardWatcher = createBoardWatcher(
|
|
166
|
+
initialBoards.map((b) => b.board.filepath),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// Agents store has its own lifecycle (chokidar watcher on ~/.claude).
|
|
170
|
+
// Shared dispose() boundary below so SIGINT cleans both.
|
|
171
|
+
const agentsStore: AgentsStore = createAgentsStore();
|
|
172
|
+
watcher.onChange((filepath) => {
|
|
173
|
+
// External edit. Re-read this board from disk.
|
|
174
|
+
try {
|
|
175
|
+
const content = readFileSync(filepath, "utf-8");
|
|
176
|
+
const { board } = parseBoard(content, { filepath });
|
|
177
|
+
const mtimeMs = statMtime(filepath);
|
|
178
|
+
setState(
|
|
179
|
+
"boards",
|
|
180
|
+
(b) => b.board.filepath === filepath,
|
|
181
|
+
produce((lb) => {
|
|
182
|
+
lb.board = board;
|
|
183
|
+
lb.mtimeMs = mtimeMs;
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
flashBanner("info", `Reloaded ${board.name} after external edit`);
|
|
187
|
+
} catch (e) {
|
|
188
|
+
flashBanner("error", `Reload failed: ${(e as Error).message}`);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
watcher.start();
|
|
192
|
+
|
|
193
|
+
// ─── Derived selectors ───────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
const activeBoard = createMemo(() => state.boards[state.ui.activeBoardIndex]?.board);
|
|
196
|
+
|
|
197
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
function getBoardByPath(path: string): LoadedBoard | undefined {
|
|
200
|
+
return state.boards.find((b) => b.board.filepath === path);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function getTask(ref: TaskRef): Task | undefined {
|
|
204
|
+
const lb = getBoardByPath(ref.boardPath);
|
|
205
|
+
const col = lb?.board.columns[ref.columnIndex];
|
|
206
|
+
if (!col) return undefined;
|
|
207
|
+
let i = 0;
|
|
208
|
+
for (const child of col.children) {
|
|
209
|
+
if (isTask(child)) {
|
|
210
|
+
if (i === ref.taskIndex) return child;
|
|
211
|
+
i++;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function listTasks(col: Column): Task[] {
|
|
218
|
+
return col.children.filter(isTask);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let bannerTimer: ReturnType<typeof setTimeout> | undefined;
|
|
222
|
+
function flashBanner(
|
|
223
|
+
kind: "info" | "warn" | "error",
|
|
224
|
+
text: string,
|
|
225
|
+
): void {
|
|
226
|
+
const ts = Date.now();
|
|
227
|
+
setState("ui", "banner", { kind, text, ts });
|
|
228
|
+
if (bannerTimer) clearTimeout(bannerTimer);
|
|
229
|
+
// Auto-dismiss after a few seconds so the keybar isn't permanently
|
|
230
|
+
// crowded by stale messages. Errors linger a bit longer.
|
|
231
|
+
const ttl = kind === "error" ? 6000 : 3000;
|
|
232
|
+
bannerTimer = setTimeout(() => {
|
|
233
|
+
if (state.ui.banner?.ts === ts) {
|
|
234
|
+
setState("ui", "banner", undefined);
|
|
235
|
+
}
|
|
236
|
+
}, ttl);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function clearBanner(): void {
|
|
240
|
+
if (bannerTimer) clearTimeout(bannerTimer);
|
|
241
|
+
setState("ui", "banner", undefined);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ─── Persistence ─────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
/** Save the board containing the referenced task. */
|
|
247
|
+
function saveBoard(boardPath: string): void {
|
|
248
|
+
const lb = getBoardByPath(boardPath);
|
|
249
|
+
if (!lb) return;
|
|
250
|
+
try {
|
|
251
|
+
const content = serializeBoard(lb.board);
|
|
252
|
+
watcher.markSelfWrite(boardPath);
|
|
253
|
+
const { mtimeMs } = writeBoardFile(boardPath, content, {
|
|
254
|
+
expectedMtimeMs: lb.mtimeMs,
|
|
255
|
+
});
|
|
256
|
+
setState(
|
|
257
|
+
"boards",
|
|
258
|
+
(b) => b.board.filepath === boardPath,
|
|
259
|
+
"mtimeMs",
|
|
260
|
+
mtimeMs,
|
|
261
|
+
);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
if (e instanceof ConflictError) {
|
|
264
|
+
flashBanner(
|
|
265
|
+
"warn",
|
|
266
|
+
`${boardPath.split(/[/\\]/).pop()} changed externally — reload before saving`,
|
|
267
|
+
);
|
|
268
|
+
// Re-read to recover.
|
|
269
|
+
try {
|
|
270
|
+
const content = readFileSync(boardPath, "utf-8");
|
|
271
|
+
const { board } = parseBoard(content, { filepath: boardPath });
|
|
272
|
+
setState(
|
|
273
|
+
"boards",
|
|
274
|
+
(b) => b.board.filepath === boardPath,
|
|
275
|
+
produce((lb2) => {
|
|
276
|
+
lb2.board = board;
|
|
277
|
+
lb2.mtimeMs = statMtime(boardPath);
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
280
|
+
} catch {
|
|
281
|
+
// ignore — banner already shown
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
flashBanner("error", `Save failed: ${(e as Error).message}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ─── Mutations ───────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
function pushUndo(entry: Omit<UndoEntry, "ts">): void {
|
|
292
|
+
setState(
|
|
293
|
+
"undo",
|
|
294
|
+
produce((u) => {
|
|
295
|
+
u.push({ ...entry, ts: Date.now() });
|
|
296
|
+
// Cap at 50 entries.
|
|
297
|
+
while (u.length > 50) u.shift();
|
|
298
|
+
}),
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function undo(): void {
|
|
303
|
+
const last = state.undo[state.undo.length - 1];
|
|
304
|
+
if (!last) {
|
|
305
|
+
flashBanner("info", "Nothing to undo");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
last.inverse();
|
|
309
|
+
setState("undo", (u) => u.slice(0, -1));
|
|
310
|
+
flashBanner("info", `Undone: ${last.description}`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function toggleDone(ref: TaskRef): void {
|
|
314
|
+
const task = getTask(ref);
|
|
315
|
+
if (!task) return;
|
|
316
|
+
const wasD = task.done;
|
|
317
|
+
const prevDoneDate = task.doneDate;
|
|
318
|
+
const today = isoToday();
|
|
319
|
+
|
|
320
|
+
setState(
|
|
321
|
+
"boards",
|
|
322
|
+
(b) => b.board.filepath === ref.boardPath,
|
|
323
|
+
"board",
|
|
324
|
+
"columns",
|
|
325
|
+
ref.columnIndex,
|
|
326
|
+
produce((col: Column) => {
|
|
327
|
+
const t = listTasks(col)[ref.taskIndex];
|
|
328
|
+
if (!t) return;
|
|
329
|
+
t.done = !wasD;
|
|
330
|
+
t.dirty = true;
|
|
331
|
+
t.doneDate = t.done ? (prevDoneDate ?? today) : undefined;
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
pushUndo({
|
|
336
|
+
description: `toggle done: ${task.displayTitle.slice(0, 40)}`,
|
|
337
|
+
inverse: () => {
|
|
338
|
+
setState(
|
|
339
|
+
"boards",
|
|
340
|
+
(b) => b.board.filepath === ref.boardPath,
|
|
341
|
+
"board",
|
|
342
|
+
"columns",
|
|
343
|
+
ref.columnIndex,
|
|
344
|
+
produce((col: Column) => {
|
|
345
|
+
const t = listTasks(col)[ref.taskIndex];
|
|
346
|
+
if (!t) return;
|
|
347
|
+
t.done = wasD;
|
|
348
|
+
t.doneDate = prevDoneDate;
|
|
349
|
+
t.dirty = true;
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
352
|
+
saveBoard(ref.boardPath);
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
saveBoard(ref.boardPath);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function setScheduled(ref: TaskRef, date: string | undefined): void {
|
|
360
|
+
const task = getTask(ref);
|
|
361
|
+
if (!task) return;
|
|
362
|
+
const prev = task.scheduled;
|
|
363
|
+
mutateTask(ref, (t) => {
|
|
364
|
+
t.scheduled = date;
|
|
365
|
+
t.dirty = true;
|
|
366
|
+
});
|
|
367
|
+
pushUndo({
|
|
368
|
+
description: `schedule date`,
|
|
369
|
+
inverse: () => {
|
|
370
|
+
mutateTask(ref, (t) => {
|
|
371
|
+
t.scheduled = prev;
|
|
372
|
+
t.dirty = true;
|
|
373
|
+
});
|
|
374
|
+
saveBoard(ref.boardPath);
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
saveBoard(ref.boardPath);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function setTimeBlock(ref: TaskRef, tb: TimeBlock | undefined): void {
|
|
381
|
+
const task = getTask(ref);
|
|
382
|
+
if (!task) return;
|
|
383
|
+
const prev = task.timeBlock;
|
|
384
|
+
const prevSrc = task.timeBlockSource;
|
|
385
|
+
mutateTask(ref, (t) => {
|
|
386
|
+
t.timeBlock = tb;
|
|
387
|
+
t.timeBlockSource = tb ? "watch-emoji" : undefined;
|
|
388
|
+
t.dirty = true;
|
|
389
|
+
});
|
|
390
|
+
pushUndo({
|
|
391
|
+
description: `time block`,
|
|
392
|
+
inverse: () => {
|
|
393
|
+
mutateTask(ref, (t) => {
|
|
394
|
+
t.timeBlock = prev;
|
|
395
|
+
t.timeBlockSource = prevSrc;
|
|
396
|
+
t.dirty = true;
|
|
397
|
+
});
|
|
398
|
+
saveBoard(ref.boardPath);
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
saveBoard(ref.boardPath);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function setAssignee(ref: TaskRef, assignee: string | undefined): void {
|
|
405
|
+
const task = getTask(ref);
|
|
406
|
+
if (!task) return;
|
|
407
|
+
const prev = task.assignee;
|
|
408
|
+
mutateTask(ref, (t) => {
|
|
409
|
+
t.assignee = assignee;
|
|
410
|
+
t.dirty = true;
|
|
411
|
+
});
|
|
412
|
+
pushUndo({
|
|
413
|
+
description: `assignee`,
|
|
414
|
+
inverse: () => {
|
|
415
|
+
mutateTask(ref, (t) => {
|
|
416
|
+
t.assignee = prev;
|
|
417
|
+
t.dirty = true;
|
|
418
|
+
});
|
|
419
|
+
saveBoard(ref.boardPath);
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
saveBoard(ref.boardPath);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function setPriority(ref: TaskRef, p: PriorityLevel): void {
|
|
426
|
+
const task = getTask(ref);
|
|
427
|
+
if (!task) return;
|
|
428
|
+
const prev = task.priority;
|
|
429
|
+
mutateTask(ref, (t) => {
|
|
430
|
+
t.priority = p;
|
|
431
|
+
t.dirty = true;
|
|
432
|
+
});
|
|
433
|
+
pushUndo({
|
|
434
|
+
description: `priority`,
|
|
435
|
+
inverse: () => {
|
|
436
|
+
mutateTask(ref, (t) => {
|
|
437
|
+
t.priority = prev;
|
|
438
|
+
t.dirty = true;
|
|
439
|
+
});
|
|
440
|
+
saveBoard(ref.boardPath);
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
saveBoard(ref.boardPath);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function editDisplayTitle(ref: TaskRef, title: string): void {
|
|
447
|
+
const task = getTask(ref);
|
|
448
|
+
if (!task) return;
|
|
449
|
+
const prev = task.displayTitle;
|
|
450
|
+
mutateTask(ref, (t) => {
|
|
451
|
+
t.displayTitle = title;
|
|
452
|
+
t.dirty = true;
|
|
453
|
+
});
|
|
454
|
+
pushUndo({
|
|
455
|
+
description: `edit text`,
|
|
456
|
+
inverse: () => {
|
|
457
|
+
mutateTask(ref, (t) => {
|
|
458
|
+
t.displayTitle = prev;
|
|
459
|
+
t.dirty = true;
|
|
460
|
+
});
|
|
461
|
+
saveBoard(ref.boardPath);
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
saveBoard(ref.boardPath);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Insert a brand-new task into a column. Returns its ref. */
|
|
468
|
+
function addTask(
|
|
469
|
+
boardPath: string,
|
|
470
|
+
columnIndex: number,
|
|
471
|
+
init: Partial<Task> & { displayTitle: string },
|
|
472
|
+
insertPos: "top" | "bottom" = "top",
|
|
473
|
+
): TaskRef | undefined {
|
|
474
|
+
const lb = getBoardByPath(boardPath);
|
|
475
|
+
if (!lb) return undefined;
|
|
476
|
+
const col = lb.board.columns[columnIndex];
|
|
477
|
+
if (!col) return undefined;
|
|
478
|
+
|
|
479
|
+
const newTask: Task = {
|
|
480
|
+
id: `${columnIndex}:new-${Date.now()}`,
|
|
481
|
+
done: false,
|
|
482
|
+
rawBody: "",
|
|
483
|
+
rawLine: "",
|
|
484
|
+
dirty: true,
|
|
485
|
+
displayTitle: init.displayTitle,
|
|
486
|
+
tags: init.tags ?? [],
|
|
487
|
+
wikilinks: init.wikilinks ?? [],
|
|
488
|
+
priority: init.priority ?? "none",
|
|
489
|
+
assignee: init.assignee,
|
|
490
|
+
scheduled: init.scheduled,
|
|
491
|
+
due: init.due,
|
|
492
|
+
start: init.start,
|
|
493
|
+
doneDate: init.doneDate,
|
|
494
|
+
timeBlock: init.timeBlock,
|
|
495
|
+
timeBlockSource: init.timeBlock ? "watch-emoji" : undefined,
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
let taskIndex = 0;
|
|
499
|
+
setState(
|
|
500
|
+
"boards",
|
|
501
|
+
(b) => b.board.filepath === boardPath,
|
|
502
|
+
"board",
|
|
503
|
+
"columns",
|
|
504
|
+
columnIndex,
|
|
505
|
+
produce((c: Column) => {
|
|
506
|
+
if (insertPos === "top") {
|
|
507
|
+
c.children.unshift(newTask);
|
|
508
|
+
taskIndex = 0;
|
|
509
|
+
} else {
|
|
510
|
+
c.children.push(newTask);
|
|
511
|
+
taskIndex = listTasks(c).length - 1;
|
|
512
|
+
}
|
|
513
|
+
}),
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
pushUndo({
|
|
517
|
+
description: `add task: ${init.displayTitle.slice(0, 40)}`,
|
|
518
|
+
inverse: () => {
|
|
519
|
+
setState(
|
|
520
|
+
"boards",
|
|
521
|
+
(b) => b.board.filepath === boardPath,
|
|
522
|
+
"board",
|
|
523
|
+
"columns",
|
|
524
|
+
columnIndex,
|
|
525
|
+
produce((c: Column) => {
|
|
526
|
+
const idx = c.children.indexOf(newTask);
|
|
527
|
+
if (idx >= 0) c.children.splice(idx, 1);
|
|
528
|
+
}),
|
|
529
|
+
);
|
|
530
|
+
saveBoard(boardPath);
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
saveBoard(boardPath);
|
|
535
|
+
return { boardPath, columnIndex, taskIndex };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function deleteTask(ref: TaskRef): void {
|
|
539
|
+
const lb = getBoardByPath(ref.boardPath);
|
|
540
|
+
if (!lb) return;
|
|
541
|
+
const col = lb.board.columns[ref.columnIndex];
|
|
542
|
+
if (!col) return;
|
|
543
|
+
// Find the children-index of the Nth task.
|
|
544
|
+
let found = -1;
|
|
545
|
+
let i = 0;
|
|
546
|
+
for (let k = 0; k < col.children.length; k++) {
|
|
547
|
+
if (isTask(col.children[k]!)) {
|
|
548
|
+
if (i === ref.taskIndex) { found = k; break; }
|
|
549
|
+
i++;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
if (found < 0) return;
|
|
553
|
+
const removed = col.children[found]!;
|
|
554
|
+
setState(
|
|
555
|
+
"boards",
|
|
556
|
+
(b) => b.board.filepath === ref.boardPath,
|
|
557
|
+
"board",
|
|
558
|
+
"columns",
|
|
559
|
+
ref.columnIndex,
|
|
560
|
+
produce((c: Column) => {
|
|
561
|
+
c.children.splice(found, 1);
|
|
562
|
+
}),
|
|
563
|
+
);
|
|
564
|
+
pushUndo({
|
|
565
|
+
description: `delete task`,
|
|
566
|
+
inverse: () => {
|
|
567
|
+
setState(
|
|
568
|
+
"boards",
|
|
569
|
+
(b) => b.board.filepath === ref.boardPath,
|
|
570
|
+
"board",
|
|
571
|
+
"columns",
|
|
572
|
+
ref.columnIndex,
|
|
573
|
+
produce((c: Column) => {
|
|
574
|
+
c.children.splice(found, 0, removed);
|
|
575
|
+
}),
|
|
576
|
+
);
|
|
577
|
+
saveBoard(ref.boardPath);
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
saveBoard(ref.boardPath);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Move a task between columns of the same board. */
|
|
584
|
+
function moveTaskWithinBoard(
|
|
585
|
+
ref: TaskRef,
|
|
586
|
+
destColumnIndex: number,
|
|
587
|
+
destInsertAt: "top" | "bottom" = "top",
|
|
588
|
+
): TaskRef | undefined {
|
|
589
|
+
if (destColumnIndex === ref.columnIndex) return ref;
|
|
590
|
+
const lb = getBoardByPath(ref.boardPath);
|
|
591
|
+
if (!lb) return undefined;
|
|
592
|
+
const srcCol = lb.board.columns[ref.columnIndex];
|
|
593
|
+
const dstCol = lb.board.columns[destColumnIndex];
|
|
594
|
+
if (!srcCol || !dstCol) return undefined;
|
|
595
|
+
|
|
596
|
+
// Find children-index of the source task.
|
|
597
|
+
let srcCh = -1;
|
|
598
|
+
let i = 0;
|
|
599
|
+
for (let k = 0; k < srcCol.children.length; k++) {
|
|
600
|
+
if (isTask(srcCol.children[k]!)) {
|
|
601
|
+
if (i === ref.taskIndex) { srcCh = k; break; }
|
|
602
|
+
i++;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (srcCh < 0) return undefined;
|
|
606
|
+
const task = srcCol.children[srcCh] as Task;
|
|
607
|
+
|
|
608
|
+
let newTaskIndex = 0;
|
|
609
|
+
setState(
|
|
610
|
+
"boards",
|
|
611
|
+
(b) => b.board.filepath === ref.boardPath,
|
|
612
|
+
"board",
|
|
613
|
+
"columns",
|
|
614
|
+
produce((cols: Column[]) => {
|
|
615
|
+
cols[ref.columnIndex]!.children.splice(srcCh, 1);
|
|
616
|
+
const target = cols[destColumnIndex]!;
|
|
617
|
+
if (destInsertAt === "top") {
|
|
618
|
+
target.children.unshift(task);
|
|
619
|
+
newTaskIndex = 0;
|
|
620
|
+
} else {
|
|
621
|
+
target.children.push(task);
|
|
622
|
+
newTaskIndex = target.children.filter(isTask).length - 1;
|
|
623
|
+
}
|
|
624
|
+
task.dirty = true;
|
|
625
|
+
}),
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
pushUndo({
|
|
629
|
+
description: `move task`,
|
|
630
|
+
inverse: () => {
|
|
631
|
+
setState(
|
|
632
|
+
"boards",
|
|
633
|
+
(b) => b.board.filepath === ref.boardPath,
|
|
634
|
+
"board",
|
|
635
|
+
"columns",
|
|
636
|
+
produce((cols: Column[]) => {
|
|
637
|
+
const idx = cols[destColumnIndex]!.children.indexOf(task);
|
|
638
|
+
if (idx >= 0) cols[destColumnIndex]!.children.splice(idx, 1);
|
|
639
|
+
cols[ref.columnIndex]!.children.splice(srcCh, 0, task);
|
|
640
|
+
}),
|
|
641
|
+
);
|
|
642
|
+
saveBoard(ref.boardPath);
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
saveBoard(ref.boardPath);
|
|
647
|
+
return { ...ref, columnIndex: destColumnIndex, taskIndex: newTaskIndex };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ─── Cursor / UI ─────────────────────────────────────────────────────────
|
|
651
|
+
|
|
652
|
+
function setActiveBoard(idx: number): void {
|
|
653
|
+
const len = state.boards.length;
|
|
654
|
+
if (len === 0) return;
|
|
655
|
+
setState("ui", "activeBoardIndex", ((idx % len) + len) % len);
|
|
656
|
+
setState("ui", "col", 0);
|
|
657
|
+
setState("ui", "row", 0);
|
|
658
|
+
setState("ui", "activeZone", "board");
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function setCursor(col: number, row: number): void {
|
|
662
|
+
setState("ui", "col", Math.max(0, col));
|
|
663
|
+
setState("ui", "row", Math.max(0, row));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function setActiveZone(zone: ActiveZone): void {
|
|
667
|
+
setState("ui", "activeZone", zone);
|
|
668
|
+
// Moving to a vertical-list zone (virtual / agents / timeline) resets
|
|
669
|
+
// the row cursor to the top so the user always lands somewhere sensible.
|
|
670
|
+
if (zone !== "board") setState("ui", "row", 0);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function setZoneVisible(zone: ActiveZone, visible: boolean): void {
|
|
674
|
+
// Board is the load-bearing zone — never allow it to be hidden.
|
|
675
|
+
if (zone === "board" && !visible) return;
|
|
676
|
+
setState("ui", "visibleZones", zone, visible);
|
|
677
|
+
// If we just hid the active zone, bounce the cursor to "board".
|
|
678
|
+
if (!visible && state.ui.activeZone === zone) {
|
|
679
|
+
setActiveZone("board");
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function cycleActiveZone(): void {
|
|
684
|
+
const visible = ZONE_ORDER.filter((z) => state.ui.visibleZones[z]);
|
|
685
|
+
if (visible.length <= 1) return;
|
|
686
|
+
const currentIdx = visible.indexOf(state.ui.activeZone);
|
|
687
|
+
const nextIdx = (currentIdx + 1) % visible.length;
|
|
688
|
+
setActiveZone(visible[nextIdx]!);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function toggleZoom(): void {
|
|
692
|
+
setState("ui", "zoomed", (z: boolean) => !z);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function setFilter(f: UIState["filter"]): void {
|
|
696
|
+
setState("ui", "filter", f);
|
|
697
|
+
// The cursor's row was an index into the unfiltered list — reset to top
|
|
698
|
+
// of the new view to avoid pointing past the filtered tail.
|
|
699
|
+
setState("ui", "row", 0);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Apply the current board filter to an open-task list. Used by both the
|
|
704
|
+
* BoardView render (to decide what to draw) and handleKey (to keep the
|
|
705
|
+
* cursor reference aligned with the rendered list).
|
|
706
|
+
*/
|
|
707
|
+
function applyBoardFilter(tasks: Task[]): Task[] {
|
|
708
|
+
const f = state.ui.filter;
|
|
709
|
+
if (f === "all") return tasks;
|
|
710
|
+
const today = isoToday();
|
|
711
|
+
const tomorrow = isoTomorrow();
|
|
712
|
+
switch (f) {
|
|
713
|
+
case "today":
|
|
714
|
+
return tasks.filter((t) => t.scheduled === today);
|
|
715
|
+
case "overdue":
|
|
716
|
+
return tasks.filter((t) => t.scheduled !== undefined && t.scheduled < today);
|
|
717
|
+
case "tomorrow":
|
|
718
|
+
return tasks.filter((t) => t.scheduled === tomorrow);
|
|
719
|
+
case "followup":
|
|
720
|
+
return tasks.filter((t) => t.tags.includes("pr-followup"));
|
|
721
|
+
}
|
|
722
|
+
return tasks;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function setZoomed(v: boolean): void {
|
|
726
|
+
setState("ui", "zoomed", v);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function toggleGrab(): void {
|
|
730
|
+
setState("ui", "grabbing", (g: boolean) => !g);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function exitGrab(): void {
|
|
734
|
+
setState("ui", "grabbing", false);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function armTimeline(ref: TaskRef | undefined): void {
|
|
738
|
+
setState("ui", "armedTimelineRef", ref);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ─── Multi-select ────────────────────────────────────────────────────────
|
|
742
|
+
|
|
743
|
+
function markKey(ref: TaskRef): string {
|
|
744
|
+
return `${ref.boardPath}::${ref.columnIndex}::${ref.taskIndex}`;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function toggleMark(ref: TaskRef): void {
|
|
748
|
+
const key = markKey(ref);
|
|
749
|
+
setState("ui", "marked", produce((m: Record<string, true>) => {
|
|
750
|
+
if (m[key]) delete m[key];
|
|
751
|
+
else m[key] = true;
|
|
752
|
+
}));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function isMarked(ref: TaskRef): boolean {
|
|
756
|
+
return state.ui.marked[markKey(ref)] === true;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function clearMarks(): void {
|
|
760
|
+
setState("ui", "marked", {});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Decoded list of currently marked refs. */
|
|
764
|
+
function getMarkedRefs(): TaskRef[] {
|
|
765
|
+
return Object.keys(state.ui.marked).map((k) => {
|
|
766
|
+
const [boardPath, ci, ti] = k.split("::");
|
|
767
|
+
return {
|
|
768
|
+
boardPath: boardPath!,
|
|
769
|
+
columnIndex: Number(ci),
|
|
770
|
+
taskIndex: Number(ti),
|
|
771
|
+
};
|
|
772
|
+
});
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Apply a single-task action to the marked set if non-empty, otherwise
|
|
777
|
+
* to the provided fallback ref. The caller passes the bound action.
|
|
778
|
+
*/
|
|
779
|
+
function applyToMarkedOr(
|
|
780
|
+
fallback: TaskRef | undefined,
|
|
781
|
+
action: (ref: TaskRef) => void,
|
|
782
|
+
): number {
|
|
783
|
+
const marked = getMarkedRefs();
|
|
784
|
+
if (marked.length > 0) {
|
|
785
|
+
for (const ref of marked) action(ref);
|
|
786
|
+
clearMarks();
|
|
787
|
+
return marked.length;
|
|
788
|
+
}
|
|
789
|
+
if (fallback) {
|
|
790
|
+
action(fallback);
|
|
791
|
+
return 1;
|
|
792
|
+
}
|
|
793
|
+
return 0;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ─── Archive ─────────────────────────────────────────────────────────────
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Move the task into the configured Archive column. If the Archive
|
|
800
|
+
* column doesn't exist on the board, create one at the end and use it.
|
|
801
|
+
* Returns the new TaskRef inside Archive, or undefined on failure.
|
|
802
|
+
*/
|
|
803
|
+
function archiveTask(ref: TaskRef): TaskRef | undefined {
|
|
804
|
+
const lb = getBoardByPath(ref.boardPath);
|
|
805
|
+
if (!lb) return undefined;
|
|
806
|
+
const archiveName = config.archiveColumn;
|
|
807
|
+
let archiveIdx = lb.board.columns.findIndex((c) => c.name === archiveName);
|
|
808
|
+
if (archiveIdx < 0) {
|
|
809
|
+
// Create Archive column at the end.
|
|
810
|
+
const lineEnding = lb.board.lineEnding;
|
|
811
|
+
setState(
|
|
812
|
+
"boards",
|
|
813
|
+
(b) => b.board.filepath === ref.boardPath,
|
|
814
|
+
"board",
|
|
815
|
+
"columns",
|
|
816
|
+
produce((cols: Column[]) => {
|
|
817
|
+
cols.push({
|
|
818
|
+
name: archiveName,
|
|
819
|
+
headerLevel: 2,
|
|
820
|
+
rawHeading: `## ${archiveName}`,
|
|
821
|
+
children: [],
|
|
822
|
+
});
|
|
823
|
+
}),
|
|
824
|
+
);
|
|
825
|
+
void lineEnding;
|
|
826
|
+
archiveIdx = lb.board.columns.length - 1;
|
|
827
|
+
}
|
|
828
|
+
return moveTaskWithinBoard(ref, archiveIdx, "top");
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// ─── Bulk: reset all overdue across all boards to today ──────────────────
|
|
832
|
+
|
|
833
|
+
function resetAllOverdueToToday(): number {
|
|
834
|
+
const today = isoToday();
|
|
835
|
+
let count = 0;
|
|
836
|
+
for (const lb of state.boards) {
|
|
837
|
+
const board = lb.board;
|
|
838
|
+
for (let ci = 0; ci < board.columns.length; ci++) {
|
|
839
|
+
const col = board.columns[ci]!;
|
|
840
|
+
let ti = 0;
|
|
841
|
+
for (const child of col.children) {
|
|
842
|
+
if (!isTask(child)) continue;
|
|
843
|
+
if (!child.done && child.scheduled && child.scheduled < today) {
|
|
844
|
+
setScheduled(
|
|
845
|
+
{ boardPath: board.filepath, columnIndex: ci, taskIndex: ti },
|
|
846
|
+
today,
|
|
847
|
+
);
|
|
848
|
+
count++;
|
|
849
|
+
}
|
|
850
|
+
ti++;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return count;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function openModal(m: ModalKind): void {
|
|
858
|
+
setState("ui", "modal", m);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function closeModal(): void {
|
|
862
|
+
setState("ui", "modal", undefined);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// ─── Private mutation helper ─────────────────────────────────────────────
|
|
866
|
+
|
|
867
|
+
function mutateTask(ref: TaskRef, f: (t: Task) => void): void {
|
|
868
|
+
setState(
|
|
869
|
+
"boards",
|
|
870
|
+
(b) => b.board.filepath === ref.boardPath,
|
|
871
|
+
"board",
|
|
872
|
+
"columns",
|
|
873
|
+
ref.columnIndex,
|
|
874
|
+
produce((col: Column) => {
|
|
875
|
+
const t = listTasks(col)[ref.taskIndex];
|
|
876
|
+
if (t) f(t);
|
|
877
|
+
}),
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// ─── Cleanup ─────────────────────────────────────────────────────────────
|
|
882
|
+
|
|
883
|
+
async function dispose(): Promise<void> {
|
|
884
|
+
await watcher.stop();
|
|
885
|
+
await agentsStore.dispose();
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
return {
|
|
889
|
+
state,
|
|
890
|
+
config,
|
|
891
|
+
activeBoard,
|
|
892
|
+
agents: agentsStore,
|
|
893
|
+
// queries
|
|
894
|
+
getBoardByPath,
|
|
895
|
+
getTask,
|
|
896
|
+
listTasks,
|
|
897
|
+
// mutations
|
|
898
|
+
toggleDone,
|
|
899
|
+
setScheduled,
|
|
900
|
+
setTimeBlock,
|
|
901
|
+
setAssignee,
|
|
902
|
+
setPriority,
|
|
903
|
+
editDisplayTitle,
|
|
904
|
+
addTask,
|
|
905
|
+
deleteTask,
|
|
906
|
+
moveTaskWithinBoard,
|
|
907
|
+
// ui
|
|
908
|
+
setActiveBoard,
|
|
909
|
+
setCursor,
|
|
910
|
+
setActiveZone,
|
|
911
|
+
setZoneVisible,
|
|
912
|
+
cycleActiveZone,
|
|
913
|
+
toggleZoom,
|
|
914
|
+
toggleGrab,
|
|
915
|
+
exitGrab,
|
|
916
|
+
armTimeline,
|
|
917
|
+
setFilter,
|
|
918
|
+
applyBoardFilter,
|
|
919
|
+
setZoomed,
|
|
920
|
+
toggleMark,
|
|
921
|
+
isMarked,
|
|
922
|
+
clearMarks,
|
|
923
|
+
getMarkedRefs,
|
|
924
|
+
applyToMarkedOr,
|
|
925
|
+
archiveTask,
|
|
926
|
+
resetAllOverdueToToday,
|
|
927
|
+
openModal,
|
|
928
|
+
closeModal,
|
|
929
|
+
flashBanner,
|
|
930
|
+
clearBanner,
|
|
931
|
+
// undo
|
|
932
|
+
undo,
|
|
933
|
+
// lifecycle
|
|
934
|
+
dispose,
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
export type TuiStore = ReturnType<typeof createTuiStore>;
|
|
939
|
+
|
|
940
|
+
// ─── Load helpers ────────────────────────────────────────────────────────────
|
|
941
|
+
|
|
942
|
+
function loadAll(config: Config): LoadedBoard[] {
|
|
943
|
+
const out: LoadedBoard[] = [];
|
|
944
|
+
for (const b of config.boards) {
|
|
945
|
+
try {
|
|
946
|
+
const content = readFileSync(b.path, "utf-8");
|
|
947
|
+
const { board } = parseBoard(content, { filepath: b.path });
|
|
948
|
+
if (b.name) board.name = b.name;
|
|
949
|
+
out.push({ board, mtimeMs: statMtime(b.path) });
|
|
950
|
+
} catch (e) {
|
|
951
|
+
console.error(`Skipping ${b.path}: ${(e as Error).message}`);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
return out;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
export function isoToday(): string {
|
|
958
|
+
return isoDate(new Date());
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
export function isoTomorrow(): string {
|
|
962
|
+
const d = new Date();
|
|
963
|
+
d.setDate(d.getDate() + 1);
|
|
964
|
+
return isoDate(d);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
export function isoDate(d: Date): string {
|
|
968
|
+
const yyyy = d.getFullYear();
|
|
969
|
+
const mm = (d.getMonth() + 1).toString().padStart(2, "0");
|
|
970
|
+
const dd = d.getDate().toString().padStart(2, "0");
|
|
971
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
972
|
+
}
|