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,643 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vertical 24h timeline column with click-to-arm scheduling.
|
|
3
|
+
*
|
|
4
|
+
* Renders today's time-blocked tasks as bands stacked on a per-15-minute
|
|
5
|
+
* grid. Hour rows show their hour label on the left margin; the current
|
|
6
|
+
* time is overlaid as a colored "now" line. Overlapping blocks are
|
|
7
|
+
* rendered side-by-side via a 2-lane split row; a 3rd overlapping block
|
|
8
|
+
* is dropped and reported as overflow via a banner.
|
|
9
|
+
*
|
|
10
|
+
* Mouse interaction (click-to-arm + click-to-place, like Python timeline.py):
|
|
11
|
+
*
|
|
12
|
+
* Click on a band → ARM that block (warm highlight)
|
|
13
|
+
* Click on empty row → if armed, MOVE the armed block's start there
|
|
14
|
+
* Shift+click empty → if armed, RESIZE the armed block's end there
|
|
15
|
+
* Click again on band → toggle: re-arms (or disarms if same block)
|
|
16
|
+
*
|
|
17
|
+
* Keyboard interaction (handled in handleKey when activeZone === "timeline"):
|
|
18
|
+
*
|
|
19
|
+
* j/k → cursor between blocks (chronological order)
|
|
20
|
+
* Enter → bounce kanban cursor to the underlying task
|
|
21
|
+
* j/k while armed → nudge armed block ±15 min (move)
|
|
22
|
+
* +/- while armed → resize armed block end ±15 min
|
|
23
|
+
* Esc → disarm
|
|
24
|
+
*
|
|
25
|
+
* Each timeline row is exactly 1 terminal line tall, so row index maps
|
|
26
|
+
* 1:1 to MINS_PER_ROW (15) minute offsets from DAY_START_HOUR.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
For,
|
|
31
|
+
Show,
|
|
32
|
+
createEffect,
|
|
33
|
+
createMemo,
|
|
34
|
+
createSignal,
|
|
35
|
+
onCleanup,
|
|
36
|
+
onMount,
|
|
37
|
+
} from "solid-js";
|
|
38
|
+
|
|
39
|
+
import { isoToday, type TaskRef } from "~/store/index";
|
|
40
|
+
import {
|
|
41
|
+
DAY_START_HOUR,
|
|
42
|
+
MINS_PER_ROW,
|
|
43
|
+
TOTAL_ROWS,
|
|
44
|
+
buildRowMap,
|
|
45
|
+
buildTimelineEntries,
|
|
46
|
+
formatHm,
|
|
47
|
+
type RowMapEntry,
|
|
48
|
+
type RowMapPair,
|
|
49
|
+
type TimelineEntry,
|
|
50
|
+
} from "~/store/timeline";
|
|
51
|
+
import {
|
|
52
|
+
ATTR,
|
|
53
|
+
PRIORITY_COLOR,
|
|
54
|
+
T,
|
|
55
|
+
boardColor,
|
|
56
|
+
} from "~/ui/glyphs";
|
|
57
|
+
import type { TuiStore } from "~/store/index";
|
|
58
|
+
import type { Task } from "~/types";
|
|
59
|
+
|
|
60
|
+
interface ScrollBoxLike {
|
|
61
|
+
scrollChildIntoView(id: string): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Minimal OpenTUI MouseEvent shape we touch (x, y, modifiers). */
|
|
65
|
+
interface MouseEventLike {
|
|
66
|
+
x: number;
|
|
67
|
+
y: number;
|
|
68
|
+
modifiers?: { shift?: boolean; alt?: boolean; ctrl?: boolean };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface TimelineViewProps {
|
|
72
|
+
store: TuiStore;
|
|
73
|
+
width?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const ROW_ID_PREFIX = "tuiboard-tl-row-";
|
|
77
|
+
/** Minimum block duration in minutes — prevents zero-length blocks on resize. */
|
|
78
|
+
const MIN_BLOCK_MIN = 15;
|
|
79
|
+
/** Default duration applied when an armed (unscheduled) task is dropped. */
|
|
80
|
+
const DEFAULT_BLOCK_MIN = 30;
|
|
81
|
+
|
|
82
|
+
export function TimelineView(props: TimelineViewProps) {
|
|
83
|
+
const isActive = () => props.store.state.ui.activeZone === "timeline";
|
|
84
|
+
const cursor = () => props.store.state.ui.row;
|
|
85
|
+
const armedRef = () => props.store.state.ui.armedTimelineRef;
|
|
86
|
+
|
|
87
|
+
const entries = createMemo(() =>
|
|
88
|
+
buildTimelineEntries(
|
|
89
|
+
props.store.state.boards.map((b) => b.board),
|
|
90
|
+
isoToday(),
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Recompute the row map every minute so the "now" marker stays current.
|
|
95
|
+
// No more sticky-unscheduled trimming — the unscheduled list lived at the
|
|
96
|
+
// top of the timeline and caused unsolvable flex-overlap with the grid
|
|
97
|
+
// scrollbox below it. Replaced by the global `C` (calendar-arm) shortcut:
|
|
98
|
+
// arm a task from the board / virtual panel, then click a timeline slot
|
|
99
|
+
// to place it. The grid now owns the whole panel, clean and simple.
|
|
100
|
+
const nowMin = useNowMin();
|
|
101
|
+
const rowMap = createMemo(() => buildRowMap(entries(), nowMin()));
|
|
102
|
+
|
|
103
|
+
/** Find the armed entry in the current entries list (if still present). */
|
|
104
|
+
const armedEntry = createMemo<TimelineEntry | undefined>(() => {
|
|
105
|
+
const ref = armedRef();
|
|
106
|
+
if (!ref) return undefined;
|
|
107
|
+
return entries().find(
|
|
108
|
+
(e) =>
|
|
109
|
+
e.ref.boardPath === ref.boardPath &&
|
|
110
|
+
e.ref.columnIndex === ref.columnIndex &&
|
|
111
|
+
e.ref.taskIndex === ref.taskIndex,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/** The armed task itself, whether it's a scheduled block or an unscheduled. */
|
|
116
|
+
const armedTask = createMemo<Task | undefined>(() => {
|
|
117
|
+
const ref = armedRef();
|
|
118
|
+
if (!ref) return undefined;
|
|
119
|
+
return props.store.getTask(ref);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/** True when arming an unscheduled-today task (drop = create new block). */
|
|
123
|
+
const armedIsUnscheduled = createMemo(() => {
|
|
124
|
+
const t = armedTask();
|
|
125
|
+
return !!t && !t.timeBlock;
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
let scrollBoxRef: ScrollBoxLike | undefined;
|
|
129
|
+
|
|
130
|
+
// Scroll-to-now on mount.
|
|
131
|
+
onMount(() => {
|
|
132
|
+
setTimeout(() => {
|
|
133
|
+
try {
|
|
134
|
+
const target = nowRowId(rowMap().rows);
|
|
135
|
+
if (target) scrollBoxRef?.scrollChildIntoView(target);
|
|
136
|
+
} catch {
|
|
137
|
+
// First-paint races — harmless.
|
|
138
|
+
}
|
|
139
|
+
}, 50);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Scroll-to-cursor when navigation moves the cursor entry off-screen.
|
|
143
|
+
createEffect(() => {
|
|
144
|
+
const c = cursor();
|
|
145
|
+
if (!isActive() || !scrollBoxRef) return;
|
|
146
|
+
const entry = entries()[c];
|
|
147
|
+
if (!entry) return;
|
|
148
|
+
setTimeout(() => {
|
|
149
|
+
try {
|
|
150
|
+
scrollBoxRef?.scrollChildIntoView(rowIdFor(entry.startRow));
|
|
151
|
+
} catch {
|
|
152
|
+
// Child not yet mounted on first frame — harmless.
|
|
153
|
+
}
|
|
154
|
+
}, 0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const cursorEntry = createMemo(() => entries()[cursor()]);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Click on a block band. Three behaviors, in priority order:
|
|
161
|
+
* 1. DIFFERENT task already armed → PLACE armed task at this band's
|
|
162
|
+
* startMin (lets the user stack two blocks at the same start time
|
|
163
|
+
* by clicking on an existing band).
|
|
164
|
+
* 2. SAME block already armed → DISARM.
|
|
165
|
+
* 3. Nothing armed → ARM this band.
|
|
166
|
+
*/
|
|
167
|
+
const onBlockClick = (entry: TimelineEntry, event: MouseEventLike) => {
|
|
168
|
+
props.store.setActiveZone("timeline");
|
|
169
|
+
|
|
170
|
+
const arm = armedRef();
|
|
171
|
+
const armedSame =
|
|
172
|
+
!!arm &&
|
|
173
|
+
arm.boardPath === entry.ref.boardPath &&
|
|
174
|
+
arm.columnIndex === entry.ref.columnIndex &&
|
|
175
|
+
arm.taskIndex === entry.ref.taskIndex;
|
|
176
|
+
const armedDifferent = !!arm && !armedSame;
|
|
177
|
+
|
|
178
|
+
if (armedDifferent) {
|
|
179
|
+
// Delegate to onEmptyRowClick using the band's startRow — places
|
|
180
|
+
// (move or create) the armed task at this band's start time. Lets
|
|
181
|
+
// the user pile two blocks at the same minute (e.g. both at 9:00).
|
|
182
|
+
onEmptyRowClick(entry.startRow, event);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const idx = entries().indexOf(entry);
|
|
187
|
+
if (idx >= 0) props.store.setCursor(0, idx);
|
|
188
|
+
|
|
189
|
+
if (armedSame) {
|
|
190
|
+
props.store.armTimeline(undefined);
|
|
191
|
+
props.store.flashBanner("info", "Disarmed");
|
|
192
|
+
} else {
|
|
193
|
+
props.store.armTimeline(entry.ref);
|
|
194
|
+
props.store.flashBanner(
|
|
195
|
+
"info",
|
|
196
|
+
`Armed ⌚${formatHm(entry.startMin)}-${formatHm(entry.endMin)} · click empty row to move, shift+click to resize, Esc to cancel`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Click on an empty / hour row when a task is armed. Behavior depends
|
|
203
|
+
* on whether the armed task already has a time block:
|
|
204
|
+
* - Has block + plain click → MOVE start to clicked row (keep duration)
|
|
205
|
+
* - Has block + shift+click → RESIZE end to clicked row
|
|
206
|
+
* - No block (unscheduled) → CREATE block at clicked row, 30min default
|
|
207
|
+
*/
|
|
208
|
+
const onEmptyRowClick = (rowIndex: number, event: MouseEventLike) => {
|
|
209
|
+
const armed = armedTask();
|
|
210
|
+
const ref = armedRef();
|
|
211
|
+
if (!armed || !ref) return;
|
|
212
|
+
const targetMin = DAY_START_HOUR * 60 + rowIndex * MINS_PER_ROW;
|
|
213
|
+
|
|
214
|
+
// Unscheduled task → create a fresh block at the clicked row.
|
|
215
|
+
if (!armed.timeBlock) {
|
|
216
|
+
const startMin = Math.max(0, targetMin);
|
|
217
|
+
const endMin = Math.min(24 * 60 - 1, startMin + DEFAULT_BLOCK_MIN);
|
|
218
|
+
props.store.setTimeBlock(ref, { startMin, endMin });
|
|
219
|
+
props.store.flashBanner(
|
|
220
|
+
"info",
|
|
221
|
+
`⌚ Scheduled → ${formatHm(startMin)}-${formatHm(endMin)}`,
|
|
222
|
+
);
|
|
223
|
+
// Auto-disarm: the task now has a block and will appear as a band;
|
|
224
|
+
// the user can re-click on that band to keep adjusting.
|
|
225
|
+
props.store.armTimeline(undefined);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Existing block: move (plain click) or resize (shift+click).
|
|
230
|
+
const block = armed.timeBlock;
|
|
231
|
+
const shift = !!event.modifiers?.shift;
|
|
232
|
+
if (shift) {
|
|
233
|
+
const newEnd = Math.max(block.startMin + MIN_BLOCK_MIN, targetMin);
|
|
234
|
+
props.store.setTimeBlock(ref, {
|
|
235
|
+
startMin: block.startMin,
|
|
236
|
+
endMin: Math.min(24 * 60 - 1, newEnd),
|
|
237
|
+
});
|
|
238
|
+
props.store.flashBanner(
|
|
239
|
+
"info",
|
|
240
|
+
`↕ Resized → ${formatHm(block.startMin)}-${formatHm(newEnd)}`,
|
|
241
|
+
);
|
|
242
|
+
} else {
|
|
243
|
+
const duration = block.endMin - block.startMin;
|
|
244
|
+
const newStart = Math.max(0, targetMin);
|
|
245
|
+
const newEnd = Math.min(24 * 60 - 1, newStart + duration);
|
|
246
|
+
props.store.setTimeBlock(ref, { startMin: newStart, endMin: newEnd });
|
|
247
|
+
props.store.flashBanner(
|
|
248
|
+
"info",
|
|
249
|
+
`✋ Moved → ${formatHm(newStart)}-${formatHm(newEnd)}`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
// Keep armed so the user can chain adjustments. Esc to release.
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<box
|
|
257
|
+
style={{
|
|
258
|
+
flexDirection: "column",
|
|
259
|
+
width: props.width,
|
|
260
|
+
minWidth: props.width,
|
|
261
|
+
flexGrow: props.width ? 0 : 1,
|
|
262
|
+
marginLeft: 1,
|
|
263
|
+
border: true,
|
|
264
|
+
borderStyle: "rounded",
|
|
265
|
+
borderColor: isActive() ? T.borderActive : T.border,
|
|
266
|
+
paddingLeft: 1,
|
|
267
|
+
paddingRight: 1,
|
|
268
|
+
}}
|
|
269
|
+
title={`┤ Timeline · ${entries().length} ├`}
|
|
270
|
+
titleAlignment="left"
|
|
271
|
+
>
|
|
272
|
+
<Show when={armedTask()}>
|
|
273
|
+
<text wrapMode="none">
|
|
274
|
+
<span style={{ fg: T.warm, attributes: ATTR.bold }}>
|
|
275
|
+
{armedIsUnscheduled() ? "⤤ Armed (new): " : "⤤ Armed: "}
|
|
276
|
+
{armedIsUnscheduled()
|
|
277
|
+
? tailTruncate(armedTask()!.displayTitle, 32)
|
|
278
|
+
: `${formatHm(armedEntry()!.startMin)}-${formatHm(armedEntry()!.endMin)}`}
|
|
279
|
+
</span>
|
|
280
|
+
<span style={{ fg: T.textDim }}>
|
|
281
|
+
{armedIsUnscheduled()
|
|
282
|
+
? " click row to place · Esc to cancel"
|
|
283
|
+
: " click row to move · shift+click to resize · Esc"}
|
|
284
|
+
</span>
|
|
285
|
+
</text>
|
|
286
|
+
</Show>
|
|
287
|
+
<Show when={!armedTask() && rowMap().overflow > 0}>
|
|
288
|
+
<text wrapMode="none">
|
|
289
|
+
<span style={{ fg: T.bannerWarn }}>
|
|
290
|
+
{`⚠ ${rowMap().overflow} block${rowMap().overflow === 1 ? "" : "s"} hidden by 3-way overlap`}
|
|
291
|
+
</span>
|
|
292
|
+
</text>
|
|
293
|
+
</Show>
|
|
294
|
+
|
|
295
|
+
{/* The 24h grid now owns the whole panel — no sticky section above
|
|
296
|
+
it. Tasks are armed for scheduling from the board / virtual panel
|
|
297
|
+
via the `C` shortcut, then placed by clicking a slot here. */}
|
|
298
|
+
<scrollbox
|
|
299
|
+
ref={(r: ScrollBoxLike) => (scrollBoxRef = r)}
|
|
300
|
+
style={{
|
|
301
|
+
width: "100%",
|
|
302
|
+
flexGrow: 1,
|
|
303
|
+
scrollX: false,
|
|
304
|
+
scrollY: true,
|
|
305
|
+
rootOptions: {},
|
|
306
|
+
contentOptions: {},
|
|
307
|
+
scrollbarOptions: { visible: false },
|
|
308
|
+
}}
|
|
309
|
+
>
|
|
310
|
+
<For each={rowMap().rows}>
|
|
311
|
+
{(pair, i) => (
|
|
312
|
+
<box id={rowIdFor(i())}>
|
|
313
|
+
<TimelineRow
|
|
314
|
+
pair={pair}
|
|
315
|
+
rowIndex={i()}
|
|
316
|
+
cursorEntry={isActive() ? cursorEntry() : undefined}
|
|
317
|
+
armedEntry={armedEntry()}
|
|
318
|
+
onBlockClick={onBlockClick}
|
|
319
|
+
onEmptyRowClick={onEmptyRowClick}
|
|
320
|
+
/>
|
|
321
|
+
</box>
|
|
322
|
+
)}
|
|
323
|
+
</For>
|
|
324
|
+
</scrollbox>
|
|
325
|
+
</box>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Bounce the kanban cursor to a specific task. Called from handleKey when
|
|
331
|
+
* Enter is pressed in the timeline zone — moved out of the click handler
|
|
332
|
+
* so single-click stays inside the timeline (arm only).
|
|
333
|
+
*/
|
|
334
|
+
export function jumpToKanban(store: TuiStore, ref: TaskRef): void {
|
|
335
|
+
const boardIdx = store.state.boards.findIndex(
|
|
336
|
+
(b) => b.board.filepath === ref.boardPath,
|
|
337
|
+
);
|
|
338
|
+
if (boardIdx < 0) return;
|
|
339
|
+
store.setActiveBoard(boardIdx);
|
|
340
|
+
// setActiveBoard resets col/row to 0, then we override.
|
|
341
|
+
store.setActiveZone("board");
|
|
342
|
+
// The kanban cursor uses the visible-tasks index, not the all-tasks
|
|
343
|
+
// index. Compute it: visible open-tasks list, find this task's position.
|
|
344
|
+
const board = store.state.boards[boardIdx]!.board;
|
|
345
|
+
const col = board.columns[ref.columnIndex];
|
|
346
|
+
if (!col) return;
|
|
347
|
+
const allTasks = col.children.filter(
|
|
348
|
+
(c): c is import("~/types").Task => !("kind" in c),
|
|
349
|
+
);
|
|
350
|
+
const targetTask = allTasks[ref.taskIndex];
|
|
351
|
+
if (!targetTask) return;
|
|
352
|
+
const openTasks = allTasks.filter((t) => !t.done);
|
|
353
|
+
const visibleRow = openTasks.indexOf(targetTask);
|
|
354
|
+
store.setCursor(ref.columnIndex, Math.max(0, visibleRow));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
interface TimelineRowProps {
|
|
358
|
+
pair: RowMapPair;
|
|
359
|
+
rowIndex: number;
|
|
360
|
+
/** When set, the cursor task — used to highlight whichever lane owns it. */
|
|
361
|
+
cursorEntry: TimelineEntry | undefined;
|
|
362
|
+
/** When set, the armed entry — used to tint its rows warm. */
|
|
363
|
+
armedEntry: TimelineEntry | undefined;
|
|
364
|
+
onBlockClick: (entry: TimelineEntry, event: MouseEventLike) => void;
|
|
365
|
+
onEmptyRowClick: (rowIndex: number, event: MouseEventLike) => void;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function TimelineRow(props: TimelineRowProps) {
|
|
369
|
+
const left = () => props.pair.left;
|
|
370
|
+
const right = () => props.pair.right;
|
|
371
|
+
|
|
372
|
+
// NOW marker: always full width.
|
|
373
|
+
const isNow = () => left().kind === "now";
|
|
374
|
+
// Right lane occupied → split row horizontally.
|
|
375
|
+
const isSplit = () => right().kind !== "empty";
|
|
376
|
+
|
|
377
|
+
const leftIsCursor = () =>
|
|
378
|
+
!!props.cursorEntry &&
|
|
379
|
+
left().entry !== undefined &&
|
|
380
|
+
left().entry === props.cursorEntry;
|
|
381
|
+
const rightIsCursor = () =>
|
|
382
|
+
!!props.cursorEntry &&
|
|
383
|
+
right().entry !== undefined &&
|
|
384
|
+
right().entry === props.cursorEntry;
|
|
385
|
+
|
|
386
|
+
const leftIsBlock = () => isBlockKind(left().kind);
|
|
387
|
+
const rightIsBlock = () => isBlockKind(right().kind);
|
|
388
|
+
|
|
389
|
+
const leftIsArmed = () =>
|
|
390
|
+
!!props.armedEntry && left().entry === props.armedEntry;
|
|
391
|
+
const rightIsArmed = () =>
|
|
392
|
+
!!props.armedEntry && right().entry === props.armedEntry;
|
|
393
|
+
|
|
394
|
+
/** Mouse handler factory for a lane cell. */
|
|
395
|
+
const cellMouseDown = (cellEntry: TimelineEntry | undefined) => {
|
|
396
|
+
return (event: MouseEventLike) => {
|
|
397
|
+
if (cellEntry) {
|
|
398
|
+
props.onBlockClick(cellEntry, event);
|
|
399
|
+
} else {
|
|
400
|
+
// Empty / hour / now row — placement target when armed.
|
|
401
|
+
props.onEmptyRowClick(props.rowIndex, event);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
return (
|
|
407
|
+
<Show
|
|
408
|
+
when={isSplit() && !isNow()}
|
|
409
|
+
fallback={
|
|
410
|
+
// Full-width single lane (covers empty / hour / now / single-block).
|
|
411
|
+
<box
|
|
412
|
+
style={{
|
|
413
|
+
flexDirection: "row",
|
|
414
|
+
height: 1,
|
|
415
|
+
backgroundColor: laneBg(
|
|
416
|
+
leftIsCursor(),
|
|
417
|
+
leftIsArmed(),
|
|
418
|
+
leftIsBlock(),
|
|
419
|
+
),
|
|
420
|
+
}}
|
|
421
|
+
onMouseDown={cellMouseDown(left().entry)}
|
|
422
|
+
>
|
|
423
|
+
<text wrapMode="none" truncate style={{ flexGrow: 1 }}>
|
|
424
|
+
<RowContent row={left()} rowIndex={props.rowIndex} />
|
|
425
|
+
</text>
|
|
426
|
+
</box>
|
|
427
|
+
}
|
|
428
|
+
>
|
|
429
|
+
{/* Split row: hour prefix + left lane + separator + right lane. */}
|
|
430
|
+
<box
|
|
431
|
+
style={{
|
|
432
|
+
flexDirection: "row",
|
|
433
|
+
height: 1,
|
|
434
|
+
}}
|
|
435
|
+
>
|
|
436
|
+
<box
|
|
437
|
+
style={{
|
|
438
|
+
flexDirection: "row",
|
|
439
|
+
flexGrow: 1,
|
|
440
|
+
flexShrink: 1,
|
|
441
|
+
flexBasis: 0,
|
|
442
|
+
backgroundColor: laneBg(
|
|
443
|
+
leftIsCursor(),
|
|
444
|
+
leftIsArmed(),
|
|
445
|
+
leftIsBlock(),
|
|
446
|
+
),
|
|
447
|
+
}}
|
|
448
|
+
onMouseDown={cellMouseDown(left().entry)}
|
|
449
|
+
>
|
|
450
|
+
<text wrapMode="none" truncate style={{ flexGrow: 1 }}>
|
|
451
|
+
<RowContent row={left()} rowIndex={props.rowIndex} />
|
|
452
|
+
</text>
|
|
453
|
+
</box>
|
|
454
|
+
<text style={{ width: 1, flexShrink: 0 }} wrapMode="none">
|
|
455
|
+
<span style={{ fg: T.border }}>{"╎"}</span>
|
|
456
|
+
</text>
|
|
457
|
+
<box
|
|
458
|
+
style={{
|
|
459
|
+
flexDirection: "row",
|
|
460
|
+
flexGrow: 1,
|
|
461
|
+
flexShrink: 1,
|
|
462
|
+
flexBasis: 0,
|
|
463
|
+
backgroundColor: laneBg(
|
|
464
|
+
rightIsCursor(),
|
|
465
|
+
rightIsArmed(),
|
|
466
|
+
rightIsBlock(),
|
|
467
|
+
),
|
|
468
|
+
}}
|
|
469
|
+
onMouseDown={cellMouseDown(right().entry)}
|
|
470
|
+
>
|
|
471
|
+
<text wrapMode="none" truncate style={{ flexGrow: 1 }}>
|
|
472
|
+
{/* Right lane skips the 3-char hour prefix that's already on the row. */}
|
|
473
|
+
<RowContent row={right()} rowIndex={props.rowIndex} skipPrefix />
|
|
474
|
+
</text>
|
|
475
|
+
</box>
|
|
476
|
+
</box>
|
|
477
|
+
</Show>
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
interface RowContentProps {
|
|
482
|
+
row: RowMapEntry;
|
|
483
|
+
rowIndex: number;
|
|
484
|
+
/** When true, omit the leading 3-char hour-gutter spacer. */
|
|
485
|
+
skipPrefix?: boolean;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function RowContent(props: RowContentProps) {
|
|
489
|
+
const r = props.row;
|
|
490
|
+
const prefix = props.skipPrefix ? "" : " ";
|
|
491
|
+
|
|
492
|
+
if (r.kind === "now") {
|
|
493
|
+
return (
|
|
494
|
+
<>
|
|
495
|
+
<span style={{ fg: T.overdue, attributes: ATTR.bold }}>
|
|
496
|
+
{"━━ "}
|
|
497
|
+
{formatHm(r.nowMin ?? 0)}{" "}
|
|
498
|
+
</span>
|
|
499
|
+
<span style={{ fg: T.overdue }}>{"━".repeat(120)}</span>
|
|
500
|
+
</>
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
if (r.kind === "hour") {
|
|
504
|
+
// Hour anchor row: '07 ──────────' — number + horizontal grid line.
|
|
505
|
+
// Gives the eye a strong tick mark to scan against.
|
|
506
|
+
const label = (r.hour ?? 0).toString().padStart(2, "0");
|
|
507
|
+
return (
|
|
508
|
+
<>
|
|
509
|
+
<span style={{ fg: T.textDim }}>{label} </span>
|
|
510
|
+
<span style={{ fg: T.border }}>{"─".repeat(120)}</span>
|
|
511
|
+
</>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
if (r.kind === "empty") {
|
|
515
|
+
// 15-min sub-row: dotted '···' fill so the grid is visually
|
|
516
|
+
// continuous. Reads as 'tick mark every 15 min' without competing
|
|
517
|
+
// with block content (which paints on top with a solid bg color).
|
|
518
|
+
return (
|
|
519
|
+
<>
|
|
520
|
+
<span style={{ fg: T.textDim }}>{prefix}</span>
|
|
521
|
+
<span style={{ fg: T.border }}>{"·".repeat(120)}</span>
|
|
522
|
+
</>
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
if (r.kind === "head" && r.entry) {
|
|
526
|
+
const e = r.entry;
|
|
527
|
+
const bColor = boardColor(e.boardIndex);
|
|
528
|
+
const priorityGlyph = e.task.priority !== "none" ? "🔺 " : "";
|
|
529
|
+
return (
|
|
530
|
+
<>
|
|
531
|
+
<span style={{ fg: T.textDim }}>{prefix}</span>
|
|
532
|
+
<span style={{ fg: bColor, attributes: ATTR.bold }}>
|
|
533
|
+
{"┤ "}
|
|
534
|
+
{formatHm(e.startMin)}
|
|
535
|
+
{"-"}
|
|
536
|
+
{formatHm(e.endMin)}{" "}
|
|
537
|
+
</span>
|
|
538
|
+
<Show when={e.task.assignee}>
|
|
539
|
+
<span style={{ fg: T.assignee }}>
|
|
540
|
+
{"@"}
|
|
541
|
+
{e.task.assignee}{" "}
|
|
542
|
+
</span>
|
|
543
|
+
</Show>
|
|
544
|
+
<Show when={priorityGlyph}>
|
|
545
|
+
<span style={{ fg: PRIORITY_COLOR[e.task.priority] }}>
|
|
546
|
+
{priorityGlyph}
|
|
547
|
+
</span>
|
|
548
|
+
</Show>
|
|
549
|
+
</>
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
if (r.kind === "body" && r.entry) {
|
|
553
|
+
const e = r.entry;
|
|
554
|
+
const bColor = boardColor(e.boardIndex);
|
|
555
|
+
return (
|
|
556
|
+
<>
|
|
557
|
+
<span style={{ fg: T.textDim }}>{prefix}</span>
|
|
558
|
+
<span style={{ fg: bColor }}>{"│ "}</span>
|
|
559
|
+
<span style={{ fg: e.task.done ? T.textDone : T.text }}>
|
|
560
|
+
{e.task.displayTitle}
|
|
561
|
+
</span>
|
|
562
|
+
</>
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
if (r.kind === "fill" && r.entry) {
|
|
566
|
+
const e = r.entry;
|
|
567
|
+
const bColor = boardColor(e.boardIndex);
|
|
568
|
+
const isLast = props.rowIndex === e.endRow - 1;
|
|
569
|
+
return (
|
|
570
|
+
<>
|
|
571
|
+
<span style={{ fg: T.textDim }}>{prefix}</span>
|
|
572
|
+
<span style={{ fg: bColor }}>{isLast ? "╰" : "│"}</span>
|
|
573
|
+
<Show when={isLast}>
|
|
574
|
+
{/* Bottom edge of the block — clear visual cap. */}
|
|
575
|
+
<span style={{ fg: bColor }}>{"─".repeat(120)}</span>
|
|
576
|
+
</Show>
|
|
577
|
+
</>
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
return <span> </span>;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Local tail-truncate helper (mirrors TaskRow's). Keeps the head + `…`. */
|
|
584
|
+
function tailTruncate(s: string, max: number): string {
|
|
585
|
+
if (max <= 0) return "";
|
|
586
|
+
if (s.length <= max) return s;
|
|
587
|
+
if (max < 2) return s.slice(0, max);
|
|
588
|
+
return s.slice(0, max - 1) + "…";
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
592
|
+
|
|
593
|
+
function rowIdFor(rowIndex: number): string {
|
|
594
|
+
return `${ROW_ID_PREFIX}${rowIndex}`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function nowRowId(rows: RowMapPair[]): string | undefined {
|
|
598
|
+
for (let i = 0; i < rows.length; i++) {
|
|
599
|
+
if (rows[i]!.left.kind === "now") return rowIdFor(i);
|
|
600
|
+
}
|
|
601
|
+
return undefined;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function isBlockKind(k: RowMapEntry["kind"]): boolean {
|
|
605
|
+
return k === "head" || k === "body" || k === "fill";
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Pick the background color for a lane cell based on its state. Cursor wins
|
|
610
|
+
* over armed, armed wins over plain "is a block row", and a non-block (hour
|
|
611
|
+
* / empty / now) gets the terminal default.
|
|
612
|
+
*/
|
|
613
|
+
function laneBg(
|
|
614
|
+
isCursor: boolean,
|
|
615
|
+
isArmed: boolean,
|
|
616
|
+
isBlock: boolean,
|
|
617
|
+
): string | undefined {
|
|
618
|
+
if (isArmed) return T.warmDim;
|
|
619
|
+
if (isCursor) return T.cardBgCursor;
|
|
620
|
+
if (isBlock) return T.cardBlockBg;
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Reactive "minutes since midnight". Ticks once per minute via setInterval
|
|
626
|
+
* so the now-line slides down throughout the day without manual refresh.
|
|
627
|
+
*/
|
|
628
|
+
function useNowMin() {
|
|
629
|
+
const [now, setNow] = createSignal(getNowMin());
|
|
630
|
+
const handle = setInterval(() => setNow(getNowMin()), 60_000);
|
|
631
|
+
onCleanup(() => clearInterval(handle));
|
|
632
|
+
return now;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function getNowMin(): number {
|
|
636
|
+
const d = new Date();
|
|
637
|
+
return d.getHours() * 60 + d.getMinutes();
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Imports used implicitly inside the JSX above (silence dead-import warnings).
|
|
641
|
+
void DAY_START_HOUR;
|
|
642
|
+
void MINS_PER_ROW;
|
|
643
|
+
void TOTAL_ROWS;
|