tuiboard 0.5.5 → 0.6.1
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 +17 -0
- package/README.md +62 -1
- package/bin/tuiboard.ts +7 -0
- package/package.json +1 -1
- package/src/calendar/setup.ts +337 -0
- package/src/config/loader.ts +81 -0
- package/src/input/handleKey.ts +42 -2
- package/src/parser/markdown.ts +1 -1
- package/src/store/calendar.ts +456 -0
- package/src/store/index.test.ts +49 -1
- package/src/store/index.ts +65 -0
- package/src/store/timeline.test.ts +64 -13
- package/src/store/timeline.ts +94 -21
- package/src/ui/AgentRow.tsx +5 -3
- package/src/ui/Chrome.tsx +1 -1
- package/src/ui/Modal.tsx +5 -1
- package/src/ui/TimelineView.tsx +81 -18
|
@@ -6,8 +6,10 @@ import {
|
|
|
6
6
|
MINS_PER_ROW,
|
|
7
7
|
TOTAL_ROWS,
|
|
8
8
|
buildRowMap,
|
|
9
|
+
buildCalendarEntries,
|
|
9
10
|
buildTimelineEntries,
|
|
10
11
|
countOverlaps,
|
|
12
|
+
formatAgendaDay,
|
|
11
13
|
formatHm,
|
|
12
14
|
type TimelineEntry,
|
|
13
15
|
} from "./timeline";
|
|
@@ -52,7 +54,7 @@ describe("buildTimelineEntries", () => {
|
|
|
52
54
|
|
|
53
55
|
it("returns empty when no tasks are time-blocked today", () => {
|
|
54
56
|
const t = makeTask({ displayTitle: "no time block", scheduled: today });
|
|
55
|
-
const board = makeBoard("
|
|
57
|
+
const board = makeBoard("Work", "work.md", [t]);
|
|
56
58
|
expect(buildTimelineEntries([board], today)).toEqual([]);
|
|
57
59
|
});
|
|
58
60
|
|
|
@@ -62,7 +64,7 @@ describe("buildTimelineEntries", () => {
|
|
|
62
64
|
scheduled: today,
|
|
63
65
|
timeBlock: { startMin: 9 * 60, endMin: 10 * 60 + 30 },
|
|
64
66
|
});
|
|
65
|
-
const board = makeBoard("
|
|
67
|
+
const board = makeBoard("Work", "work.md", [t]);
|
|
66
68
|
const entries = buildTimelineEntries([board], today);
|
|
67
69
|
expect(entries.length).toBe(1);
|
|
68
70
|
expect(entries[0]!.startMin).toBe(540);
|
|
@@ -80,7 +82,7 @@ describe("buildTimelineEntries", () => {
|
|
|
80
82
|
scheduled: today,
|
|
81
83
|
timeBlock: { startMin: 9 * 60, endMin: 10 * 60 },
|
|
82
84
|
});
|
|
83
|
-
const board = makeBoard("
|
|
85
|
+
const board = makeBoard("Work", "work.md", [t]);
|
|
84
86
|
const entries = buildTimelineEntries([board], today);
|
|
85
87
|
expect(entries).toHaveLength(1);
|
|
86
88
|
expect(entries[0]!.task.done).toBe(true);
|
|
@@ -92,7 +94,7 @@ describe("buildTimelineEntries", () => {
|
|
|
92
94
|
scheduled: "2026-05-28",
|
|
93
95
|
timeBlock: { startMin: 9 * 60, endMin: 10 * 60 },
|
|
94
96
|
});
|
|
95
|
-
const board = makeBoard("
|
|
97
|
+
const board = makeBoard("Work", "work.md", [t]);
|
|
96
98
|
expect(buildTimelineEntries([board], today)).toEqual([]);
|
|
97
99
|
});
|
|
98
100
|
|
|
@@ -103,7 +105,7 @@ describe("buildTimelineEntries", () => {
|
|
|
103
105
|
// 6:30-7:30 → only 7:00-7:30 visible (rows 0-1)
|
|
104
106
|
timeBlock: { startMin: 6 * 60 + 30, endMin: 7 * 60 + 30 },
|
|
105
107
|
});
|
|
106
|
-
const board = makeBoard("
|
|
108
|
+
const board = makeBoard("Work", "work.md", [t]);
|
|
107
109
|
const entries = buildTimelineEntries([board], today);
|
|
108
110
|
expect(entries.length).toBe(1);
|
|
109
111
|
expect(entries[0]!.startRow).toBe(0); // clipped at top
|
|
@@ -115,7 +117,7 @@ describe("buildTimelineEntries", () => {
|
|
|
115
117
|
scheduled: today,
|
|
116
118
|
timeBlock: { startMin: 0, endMin: 30 },
|
|
117
119
|
});
|
|
118
|
-
const board = makeBoard("
|
|
120
|
+
const board = makeBoard("Work", "work.md", [t]);
|
|
119
121
|
expect(buildTimelineEntries([board], today)).toEqual([]);
|
|
120
122
|
});
|
|
121
123
|
|
|
@@ -130,7 +132,7 @@ describe("buildTimelineEntries", () => {
|
|
|
130
132
|
scheduled: today,
|
|
131
133
|
timeBlock: { startMin: 9 * 60, endMin: 10 * 60 },
|
|
132
134
|
});
|
|
133
|
-
const board = makeBoard("
|
|
135
|
+
const board = makeBoard("Work", "work.md", [t1, t2]);
|
|
134
136
|
const entries = buildTimelineEntries([board], today);
|
|
135
137
|
expect(entries.map((e) => e.task.displayTitle)).toEqual([
|
|
136
138
|
"morning",
|
|
@@ -142,9 +144,10 @@ describe("buildTimelineEntries", () => {
|
|
|
142
144
|
describe("buildRowMap", () => {
|
|
143
145
|
function entryAt(startRow: number, endRow: number, title = "block"): TimelineEntry {
|
|
144
146
|
return {
|
|
147
|
+
kind: "task",
|
|
145
148
|
ref: { boardPath: "x", columnIndex: 0, taskIndex: 0 },
|
|
146
149
|
task: makeTask({ displayTitle: title }),
|
|
147
|
-
boardName: "
|
|
150
|
+
boardName: "Work",
|
|
148
151
|
boardIndex: 0,
|
|
149
152
|
columnName: "Inbox",
|
|
150
153
|
startMin: 0,
|
|
@@ -154,6 +157,9 @@ describe("buildRowMap", () => {
|
|
|
154
157
|
};
|
|
155
158
|
}
|
|
156
159
|
|
|
160
|
+
const titleOf = (e: TimelineEntry | undefined) =>
|
|
161
|
+
e?.kind === "task" ? e.task.displayTitle : undefined;
|
|
162
|
+
|
|
157
163
|
it("returns TOTAL_ROWS row pairs", () => {
|
|
158
164
|
const result = buildRowMap([], 0);
|
|
159
165
|
expect(result.rows.length).toBe(TOTAL_ROWS);
|
|
@@ -190,13 +196,13 @@ describe("buildRowMap", () => {
|
|
|
190
196
|
expect(overflow).toBe(0);
|
|
191
197
|
// Lane 0 (left) gets A.
|
|
192
198
|
expect(rows[5]!.left.kind).toBe("head");
|
|
193
|
-
expect(rows[5]!.left.entry
|
|
199
|
+
expect(titleOf(rows[5]!.left.entry)).toBe("A");
|
|
194
200
|
// Lane 1 (right) gets B starting at row 7.
|
|
195
201
|
expect(rows[7]!.right.kind).toBe("head");
|
|
196
|
-
expect(rows[7]!.right.entry
|
|
202
|
+
expect(titleOf(rows[7]!.right.entry)).toBe("B");
|
|
197
203
|
// Row 10 is past A's end but inside B → left empty, right fill.
|
|
198
204
|
expect(rows[10]!.left.kind).toBe("empty");
|
|
199
|
-
expect(rows[10]!.right.entry
|
|
205
|
+
expect(titleOf(rows[10]!.right.entry)).toBe("B");
|
|
200
206
|
});
|
|
201
207
|
|
|
202
208
|
it("counts third+ overlapping entry as overflow", () => {
|
|
@@ -214,8 +220,8 @@ describe("buildRowMap", () => {
|
|
|
214
220
|
const c = entryAt(10, 15, "C");
|
|
215
221
|
const { rows, overflow } = buildRowMap([a, c], 0);
|
|
216
222
|
expect(overflow).toBe(0);
|
|
217
|
-
expect(rows[5]!.left.entry
|
|
218
|
-
expect(rows[10]!.left.entry
|
|
223
|
+
expect(titleOf(rows[5]!.left.entry)).toBe("A");
|
|
224
|
+
expect(titleOf(rows[10]!.left.entry)).toBe("C");
|
|
219
225
|
// Right lane stayed empty throughout.
|
|
220
226
|
for (let r = 0; r < TOTAL_ROWS; r++) {
|
|
221
227
|
expect(rows[r]!.right.kind).toBe("empty");
|
|
@@ -239,6 +245,7 @@ describe("buildRowMap", () => {
|
|
|
239
245
|
describe("countOverlaps", () => {
|
|
240
246
|
function entry(s: number, e: number): TimelineEntry {
|
|
241
247
|
return {
|
|
248
|
+
kind: "task",
|
|
242
249
|
ref: { boardPath: "x", columnIndex: 0, taskIndex: 0 },
|
|
243
250
|
task: makeTask({ displayTitle: "x" }),
|
|
244
251
|
boardName: "X",
|
|
@@ -268,6 +275,50 @@ describe("countOverlaps", () => {
|
|
|
268
275
|
});
|
|
269
276
|
});
|
|
270
277
|
|
|
278
|
+
describe("formatAgendaDay", () => {
|
|
279
|
+
it("uses relative words for the near days", () => {
|
|
280
|
+
expect(formatAgendaDay(0, "2026-05-30")).toBe("Today");
|
|
281
|
+
expect(formatAgendaDay(1, "2026-05-31")).toBe("Tomorrow");
|
|
282
|
+
expect(formatAgendaDay(-1, "2026-05-29")).toBe("Yesterday");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("stamps weekday + day + month for farther days", () => {
|
|
286
|
+
// 2026-06-02 is a Tuesday.
|
|
287
|
+
expect(formatAgendaDay(3, "2026-06-02")).toBe("Tue 02 Jun");
|
|
288
|
+
// 2026-05-25 is a Monday.
|
|
289
|
+
expect(formatAgendaDay(-5, "2026-05-25")).toBe("Mon 25 May");
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe("buildCalendarEntries", () => {
|
|
294
|
+
const ev = (startMin: number, endMin: number, title = "Standup") => ({
|
|
295
|
+
title,
|
|
296
|
+
startMin,
|
|
297
|
+
endMin,
|
|
298
|
+
color: "#FF5F00",
|
|
299
|
+
source: "google" as const,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("maps an in-window event to a calendar entry with rows", () => {
|
|
303
|
+
const out = buildCalendarEntries([ev(9 * 60, 10 * 60, "Standup")]);
|
|
304
|
+
expect(out).toHaveLength(1);
|
|
305
|
+
expect(out[0]!.kind).toBe("calendar");
|
|
306
|
+
expect(out[0]!.title).toBe("Standup");
|
|
307
|
+
expect(out[0]!.color).toBe("#FF5F00");
|
|
308
|
+
// 09:00 → (540 - DAY_START*60) / 15
|
|
309
|
+
expect(out[0]!.startRow).toBe((9 * 60 - DAY_START_HOUR * 60) / MINS_PER_ROW);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("drops events entirely outside the rendered window", () => {
|
|
313
|
+
expect(buildCalendarEntries([ev(2 * 60, 3 * 60)])).toEqual([]);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("sorts by start time", () => {
|
|
317
|
+
const out = buildCalendarEntries([ev(11 * 60, 12 * 60, "late"), ev(8 * 60, 9 * 60, "early")]);
|
|
318
|
+
expect(out.map((e) => e.title)).toEqual(["early", "late"]);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
271
322
|
describe("formatHm", () => {
|
|
272
323
|
it("zero-pads hours and minutes", () => {
|
|
273
324
|
expect(formatHm(0)).toBe("00:00");
|
package/src/store/timeline.ts
CHANGED
|
@@ -25,12 +25,7 @@ export const TOTAL_ROWS =
|
|
|
25
25
|
/** Minimum block height in rows — single-row blocks are unreadable. */
|
|
26
26
|
export const MIN_BLOCK_ROWS = 2;
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
ref: TaskRef;
|
|
30
|
-
task: Task;
|
|
31
|
-
boardName: string;
|
|
32
|
-
boardIndex: number;
|
|
33
|
-
columnName: string;
|
|
28
|
+
interface BaseEntry {
|
|
34
29
|
startMin: number;
|
|
35
30
|
endMin: number;
|
|
36
31
|
/** Vertical position in the row grid (clipped to [0, TOTAL_ROWS)). */
|
|
@@ -39,6 +34,46 @@ export interface TimelineEntry {
|
|
|
39
34
|
endRow: number;
|
|
40
35
|
}
|
|
41
36
|
|
|
37
|
+
/** A time-blocked task placed on the grid. */
|
|
38
|
+
export interface TaskTimelineEntry extends BaseEntry {
|
|
39
|
+
kind: "task";
|
|
40
|
+
ref: TaskRef;
|
|
41
|
+
task: Task;
|
|
42
|
+
boardName: string;
|
|
43
|
+
boardIndex: number;
|
|
44
|
+
columnName: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A read-only calendar event (Google / Microsoft) placed on the grid. */
|
|
48
|
+
export interface CalTimelineEntry extends BaseEntry {
|
|
49
|
+
kind: "calendar";
|
|
50
|
+
title: string;
|
|
51
|
+
color: string;
|
|
52
|
+
source: "google" | "microsoft";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type TimelineEntry = TaskTimelineEntry | CalTimelineEntry;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Map an event's start/end (minutes since midnight) to grid rows, or null if
|
|
59
|
+
* it falls entirely outside the rendered [DAY_START_HOUR, DAY_END_HOUR] window.
|
|
60
|
+
*/
|
|
61
|
+
function rowsFor(
|
|
62
|
+
startMin: number,
|
|
63
|
+
endMin: number,
|
|
64
|
+
): { startRow: number; endRow: number } | null {
|
|
65
|
+
const windowStart = DAY_START_HOUR * 60;
|
|
66
|
+
const windowEnd = DAY_END_HOUR * 60;
|
|
67
|
+
if (endMin <= windowStart || startMin >= windowEnd) return null;
|
|
68
|
+
const startRow = Math.floor((startMin - windowStart) / MINS_PER_ROW);
|
|
69
|
+
const naturalHeight = Math.max(
|
|
70
|
+
MIN_BLOCK_ROWS,
|
|
71
|
+
Math.floor((endMin - startMin) / MINS_PER_ROW),
|
|
72
|
+
);
|
|
73
|
+
const endRow = startRow + naturalHeight;
|
|
74
|
+
return { startRow: Math.max(0, startRow), endRow: Math.min(TOTAL_ROWS, endRow) };
|
|
75
|
+
}
|
|
76
|
+
|
|
42
77
|
export type RowKind = "empty" | "hour" | "head" | "body" | "fill" | "now";
|
|
43
78
|
|
|
44
79
|
export interface RowMapEntry {
|
|
@@ -85,8 +120,8 @@ export interface BuildRowMapResult {
|
|
|
85
120
|
export function buildTimelineEntries(
|
|
86
121
|
boards: Board[],
|
|
87
122
|
date: string,
|
|
88
|
-
):
|
|
89
|
-
const out:
|
|
123
|
+
): TaskTimelineEntry[] {
|
|
124
|
+
const out: TaskTimelineEntry[] = [];
|
|
90
125
|
for (let bi = 0; bi < boards.length; bi++) {
|
|
91
126
|
const board = boards[bi]!;
|
|
92
127
|
for (let ci = 0; ci < board.columns.length; ci++) {
|
|
@@ -102,19 +137,11 @@ export function buildTimelineEntries(
|
|
|
102
137
|
if (t.scheduled !== date) continue;
|
|
103
138
|
|
|
104
139
|
const { startMin, endMin } = t.timeBlock;
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
// Skip blocks that fall entirely outside the rendered window.
|
|
108
|
-
if (endMin <= windowStart || startMin >= windowEnd) continue;
|
|
109
|
-
|
|
110
|
-
const startRow = Math.floor((startMin - windowStart) / MINS_PER_ROW);
|
|
111
|
-
const naturalHeight = Math.max(
|
|
112
|
-
MIN_BLOCK_ROWS,
|
|
113
|
-
Math.floor((endMin - startMin) / MINS_PER_ROW),
|
|
114
|
-
);
|
|
115
|
-
const endRow = startRow + naturalHeight;
|
|
140
|
+
const rows = rowsFor(startMin, endMin);
|
|
141
|
+
if (!rows) continue; // entirely outside the rendered window
|
|
116
142
|
|
|
117
143
|
out.push({
|
|
144
|
+
kind: "task",
|
|
118
145
|
ref: {
|
|
119
146
|
boardPath: board.filepath,
|
|
120
147
|
columnIndex: ci,
|
|
@@ -126,8 +153,8 @@ export function buildTimelineEntries(
|
|
|
126
153
|
columnName: col.name,
|
|
127
154
|
startMin,
|
|
128
155
|
endMin,
|
|
129
|
-
startRow:
|
|
130
|
-
endRow:
|
|
156
|
+
startRow: rows.startRow,
|
|
157
|
+
endRow: rows.endRow,
|
|
131
158
|
});
|
|
132
159
|
}
|
|
133
160
|
}
|
|
@@ -136,6 +163,32 @@ export function buildTimelineEntries(
|
|
|
136
163
|
return out;
|
|
137
164
|
}
|
|
138
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Turn read-only calendar events (already mapped to minutes-since-midnight for
|
|
168
|
+
* the target day) into grid entries, clipped to the rendered window.
|
|
169
|
+
*/
|
|
170
|
+
export function buildCalendarEntries(
|
|
171
|
+
events: Array<{ title: string; startMin: number; endMin: number; color: string; source: "google" | "microsoft" }>,
|
|
172
|
+
): CalTimelineEntry[] {
|
|
173
|
+
const out: CalTimelineEntry[] = [];
|
|
174
|
+
for (const e of events) {
|
|
175
|
+
const rows = rowsFor(e.startMin, e.endMin);
|
|
176
|
+
if (!rows) continue;
|
|
177
|
+
out.push({
|
|
178
|
+
kind: "calendar",
|
|
179
|
+
title: e.title,
|
|
180
|
+
color: e.color,
|
|
181
|
+
source: e.source,
|
|
182
|
+
startMin: e.startMin,
|
|
183
|
+
endMin: e.endMin,
|
|
184
|
+
startRow: rows.startRow,
|
|
185
|
+
endRow: rows.endRow,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
out.sort((a, b) => a.startMin - b.startMin);
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
139
192
|
/**
|
|
140
193
|
* Place entries onto the TOTAL_ROWS grid, using up to 2 lanes to render
|
|
141
194
|
* overlapping blocks side-by-side. A third+ overlapping block on the same
|
|
@@ -275,6 +328,26 @@ export function buildUnscheduledToday(
|
|
|
275
328
|
return out;
|
|
276
329
|
}
|
|
277
330
|
|
|
331
|
+
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
332
|
+
const MONTHS = [
|
|
333
|
+
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
334
|
+
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
335
|
+
];
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Human label for the Agenda's viewed day. Relative words for the near days
|
|
339
|
+
* (Today / Tomorrow / Yesterday), else a "Mon 02 Jun" stamp. `dateIso` is the
|
|
340
|
+
* already-resolved date so this stays pure (no clock access).
|
|
341
|
+
*/
|
|
342
|
+
export function formatAgendaDay(offset: number, dateIso: string): string {
|
|
343
|
+
if (offset === 0) return "Today";
|
|
344
|
+
if (offset === 1) return "Tomorrow";
|
|
345
|
+
if (offset === -1) return "Yesterday";
|
|
346
|
+
const [y, m, d] = dateIso.split("-").map(Number);
|
|
347
|
+
const dt = new Date(y!, (m ?? 1) - 1, d ?? 1);
|
|
348
|
+
return `${WEEKDAYS[dt.getDay()]} ${String(d).padStart(2, "0")} ${MONTHS[(m ?? 1) - 1]}`;
|
|
349
|
+
}
|
|
350
|
+
|
|
278
351
|
/** Format minutes since midnight as "HH:MM". */
|
|
279
352
|
export function formatHm(mins: number): string {
|
|
280
353
|
const h = Math.floor(mins / 60) % 24;
|
package/src/ui/AgentRow.tsx
CHANGED
|
@@ -70,11 +70,13 @@ export function AgentRow(props: AgentRowProps) {
|
|
|
70
70
|
<span style={{ fg: T.textDim }}>{" "}{props.session.gitBranch}</span>
|
|
71
71
|
</Show>
|
|
72
72
|
</text>
|
|
73
|
-
{/* cwd + age pinned together on the right
|
|
74
|
-
|
|
73
|
+
{/* cwd + age pinned together on the right. The age is right-aligned in a
|
|
74
|
+
fixed-width field (pad to 3: "59m" / "23h" / "10d") so the END of each
|
|
75
|
+
cwd lands on the same column across rows — a 1- vs 2-digit age no
|
|
76
|
+
longer shoves the directory names out of vertical alignment. */}
|
|
75
77
|
<text style={{ flexShrink: 0 }} wrapMode="none">
|
|
76
78
|
<span style={{ fg: T.textDim }}>
|
|
77
|
-
{props.session.cwdShort}{"
|
|
79
|
+
{props.session.cwdShort}{" "}{ageStr().padStart(3)}
|
|
78
80
|
</span>
|
|
79
81
|
</text>
|
|
80
82
|
</box>
|
package/src/ui/Chrome.tsx
CHANGED
|
@@ -115,7 +115,7 @@ export function BottomBar(props: { store: TuiStore }) {
|
|
|
115
115
|
*/}
|
|
116
116
|
<text wrapMode="none" truncate>
|
|
117
117
|
<span style={{ fg: T.textDim }}>
|
|
118
|
-
{"hjkl move · Tab board · ⇧Tab zone · ⏎ done · n new · t today · b block · c schedule · z zoom · ? help · q quit"}
|
|
118
|
+
{"hjkl ↑↓←→ move · Tab board · ⇧Tab zone · ⏎ done · n new · t today · b block · c schedule · r refresh · z zoom · ? help · q quit"}
|
|
119
119
|
</span>
|
|
120
120
|
</text>
|
|
121
121
|
</box>
|
package/src/ui/Modal.tsx
CHANGED
|
@@ -592,6 +592,7 @@ function HelpModal(props: { store: TuiStore }) {
|
|
|
592
592
|
<span style={{ fg: T.text }}>{" Shift-Tab Cycle active zone (virtual → board → timeline → agents)\n"}</span>
|
|
593
593
|
<span style={{ fg: T.text }}>{" F1 / F2 / F3 Toggle visibility of Virtual / Timeline / Agents zones\n"}</span>
|
|
594
594
|
<span style={{ fg: T.text }}>{" z Zoom active zone (or column) to full screen\n"}</span>
|
|
595
|
+
<span style={{ fg: T.text }}>{" r Refresh everything (boards from disk, agents, agenda calendar)\n"}</span>
|
|
595
596
|
<span style={{ fg: T.textDim }}>{"\nTask actions (work in board, virtual, AND timeline zones)\n"}</span>
|
|
596
597
|
<span style={{ fg: T.text }}>{" Enter Toggle done\n"}</span>
|
|
597
598
|
<span style={{ fg: T.text }}>{" o Open detail view\n"}</span>
|
|
@@ -606,7 +607,10 @@ function HelpModal(props: { store: TuiStore }) {
|
|
|
606
607
|
<span style={{ fg: T.text }}>{" d Delete task (with confirm)\n"}</span>
|
|
607
608
|
<span style={{ fg: T.text }}>{" X Archive task → moves to Archive column\n"}</span>
|
|
608
609
|
<span style={{ fg: T.text }}>{" C Copy task to clipboard (markdown line)\n"}</span>
|
|
609
|
-
<span style={{ fg: T.textDim }}>{"\
|
|
610
|
+
<span style={{ fg: T.textDim }}>{"\nAgenda (timeline) day navigation — works from any zone\n"}</span>
|
|
611
|
+
<span style={{ fg: T.text }}>{" [ / ] Previous / next day (tasks + calendar events)\n"}</span>
|
|
612
|
+
<span style={{ fg: T.text }}>{" \\ Jump back to today\n"}</span>
|
|
613
|
+
<span style={{ fg: T.textDim }}>{"\nAgenda (timeline) scheduling\n"}</span>
|
|
610
614
|
<span style={{ fg: T.text }}>{" c (any zone) Toggle ARM MODE — then click a task, click a slot, repeat\n"}</span>
|
|
611
615
|
<span style={{ fg: T.text }}>{" click empty row Place the armed task here (30-min block, or move if it has one)\n"}</span>
|
|
612
616
|
<span style={{ fg: T.text }}>{" click band Arm an existing block (or place the armed task at its start)\n"}</span>
|
package/src/ui/TimelineView.tsx
CHANGED
|
@@ -36,13 +36,15 @@ import {
|
|
|
36
36
|
onMount,
|
|
37
37
|
} from "solid-js";
|
|
38
38
|
|
|
39
|
-
import {
|
|
39
|
+
import type { TaskRef } from "~/store/index";
|
|
40
40
|
import {
|
|
41
41
|
DAY_START_HOUR,
|
|
42
42
|
MINS_PER_ROW,
|
|
43
43
|
TOTAL_ROWS,
|
|
44
44
|
buildRowMap,
|
|
45
|
+
buildCalendarEntries,
|
|
45
46
|
buildTimelineEntries,
|
|
47
|
+
formatAgendaDay,
|
|
46
48
|
formatHm,
|
|
47
49
|
type RowMapEntry,
|
|
48
50
|
type RowMapPair,
|
|
@@ -85,22 +87,40 @@ export function TimelineView(props: TimelineViewProps) {
|
|
|
85
87
|
const armedRef = () => props.store.state.ui.armedTimelineRef;
|
|
86
88
|
const armMode = () => props.store.state.ui.armMode;
|
|
87
89
|
|
|
90
|
+
// Which day the Agenda is showing (today + offset). Drives task entries,
|
|
91
|
+
// the calendar overlay, and the "now" line.
|
|
92
|
+
const viewedDate = () => props.store.agendaDate();
|
|
93
|
+
const isToday = () => props.store.state.ui.agendaOffset === 0;
|
|
94
|
+
|
|
95
|
+
// Task entries drive the cursor + arm/keyboard interactions.
|
|
88
96
|
const entries = createMemo(() => {
|
|
89
97
|
props.store.state.rev; // recompute on any board mutation
|
|
90
98
|
return buildTimelineEntries(
|
|
91
99
|
props.store.state.boards.map((b) => b.board),
|
|
92
|
-
|
|
100
|
+
viewedDate(),
|
|
93
101
|
);
|
|
94
102
|
});
|
|
95
103
|
|
|
104
|
+
// Tell the calendar store which day to fetch whenever the viewed day changes.
|
|
105
|
+
createEffect(() => {
|
|
106
|
+
props.store.calendar.setActiveDate(viewedDate());
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Read-only calendar events (Google / Microsoft), merged into the grid for
|
|
110
|
+
// display only — not cursor-navigable, not editable.
|
|
111
|
+
const calEntries = createMemo(() =>
|
|
112
|
+
buildCalendarEntries(props.store.calendar.events()),
|
|
113
|
+
);
|
|
114
|
+
|
|
96
115
|
// Recompute the row map every minute so the "now" marker stays current.
|
|
97
|
-
//
|
|
98
|
-
// top of the timeline and caused unsolvable flex-overlap with the grid
|
|
99
|
-
// scrollbox below it. Replaced by the global `C` (calendar-arm) shortcut:
|
|
100
|
-
// arm a task from the board / virtual panel, then click a timeline slot
|
|
101
|
-
// to place it. The grid now owns the whole panel, clean and simple.
|
|
116
|
+
// The now-line only renders on today's view (a sentinel hides it elsewhere).
|
|
102
117
|
const nowMin = useNowMin();
|
|
103
|
-
const rowMap = createMemo(() =>
|
|
118
|
+
const rowMap = createMemo(() => {
|
|
119
|
+
const merged: TimelineEntry[] = [...entries(), ...calEntries()].sort(
|
|
120
|
+
(a, b) => a.startMin - b.startMin,
|
|
121
|
+
);
|
|
122
|
+
return buildRowMap(merged, isToday() ? nowMin() : -1);
|
|
123
|
+
});
|
|
104
124
|
|
|
105
125
|
/** Find the armed entry in the current entries list (if still present). */
|
|
106
126
|
const armedEntry = createMemo<TimelineEntry | undefined>(() => {
|
|
@@ -169,6 +189,14 @@ export function TimelineView(props: TimelineViewProps) {
|
|
|
169
189
|
const onBlockClick = (entry: TimelineEntry, event: MouseEventLike) => {
|
|
170
190
|
props.store.setActiveZone("timeline");
|
|
171
191
|
|
|
192
|
+
// Calendar events are read-only: they can't be armed or moved. A click on
|
|
193
|
+
// one while a task is armed just places the armed task at that slot;
|
|
194
|
+
// otherwise it's a no-op (beyond focusing the zone).
|
|
195
|
+
if (entry.kind !== "task") {
|
|
196
|
+
if (armedRef()) onEmptyRowClick(entry.startRow, event);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
172
200
|
const arm = armedRef();
|
|
173
201
|
const armedSame =
|
|
174
202
|
!!arm &&
|
|
@@ -218,9 +246,10 @@ export function TimelineView(props: TimelineViewProps) {
|
|
|
218
246
|
const startMin = Math.max(0, targetMin);
|
|
219
247
|
const endMin = Math.min(24 * 60 - 1, startMin + DEFAULT_BLOCK_MIN);
|
|
220
248
|
// A time block only renders on the timeline when the task is also
|
|
221
|
-
// scheduled for
|
|
222
|
-
// here pins it to
|
|
223
|
-
|
|
249
|
+
// scheduled for the viewed day — so arming a task from ANY board and
|
|
250
|
+
// dropping it here pins it to whatever day the Agenda is showing
|
|
251
|
+
// (otherwise it'd vanish: block set, wrong date).
|
|
252
|
+
props.store.setScheduled(ref, viewedDate());
|
|
224
253
|
props.store.setTimeBlock(ref, { startMin, endMin });
|
|
225
254
|
props.store.flashBanner(
|
|
226
255
|
"info",
|
|
@@ -278,7 +307,7 @@ export function TimelineView(props: TimelineViewProps) {
|
|
|
278
307
|
paddingLeft: 1,
|
|
279
308
|
paddingRight: 1,
|
|
280
309
|
}}
|
|
281
|
-
title={`┤
|
|
310
|
+
title={`┤ Agenda · ${formatAgendaDay(props.store.state.ui.agendaOffset, viewedDate())} · ${entries().length}${armMode() ? " ◉ ARM" : ""} ├`}
|
|
282
311
|
titleAlignment="left"
|
|
283
312
|
>
|
|
284
313
|
<Show when={armMode()}>
|
|
@@ -306,6 +335,16 @@ export function TimelineView(props: TimelineViewProps) {
|
|
|
306
335
|
</span>
|
|
307
336
|
</text>
|
|
308
337
|
</Show>
|
|
338
|
+
{/* Day-navigation hint — always visible in the resting state (not while
|
|
339
|
+
arming) so the [ ] day-switch is discoverable. Off-today, the
|
|
340
|
+
"\ today" reset is highlighted to pull the eye back. */}
|
|
341
|
+
<Show when={!armMode() && !armedTask()}>
|
|
342
|
+
<text wrapMode="none">
|
|
343
|
+
<span style={{ fg: T.warm }}>{"◷ "}</span>
|
|
344
|
+
<span style={{ fg: T.textDim }}>{"[ ] change day · "}</span>
|
|
345
|
+
<span style={{ fg: isToday() ? T.textDim : T.warm }}>{"\\ today"}</span>
|
|
346
|
+
</text>
|
|
347
|
+
</Show>
|
|
309
348
|
<Show when={!armedTask() && rowMap().overflow > 0}>
|
|
310
349
|
<text wrapMode="none">
|
|
311
350
|
<span style={{ fg: T.bannerWarn }}>
|
|
@@ -416,8 +455,10 @@ function TimelineRow(props: TimelineRowProps) {
|
|
|
416
455
|
const rightIsArmed = () =>
|
|
417
456
|
!!props.armedEntry && right().entry === props.armedEntry;
|
|
418
457
|
|
|
419
|
-
const
|
|
420
|
-
|
|
458
|
+
const entryDone = (e: TimelineEntry | undefined) =>
|
|
459
|
+
e?.kind === "task" && e.task.done;
|
|
460
|
+
const leftIsDone = () => entryDone(left().entry);
|
|
461
|
+
const rightIsDone = () => entryDone(right().entry);
|
|
421
462
|
|
|
422
463
|
// Cell budget per lane, so RowContent can tail-truncate the title (keeping
|
|
423
464
|
// the head readable) instead of leaning on OpenTUI's middle-ellipsis.
|
|
@@ -563,6 +604,18 @@ function RowContent(props: RowContentProps) {
|
|
|
563
604
|
}
|
|
564
605
|
if (r.kind === "head" && r.entry) {
|
|
565
606
|
const e = r.entry;
|
|
607
|
+
if (e.kind === "calendar") {
|
|
608
|
+
// Read-only calendar event: time + 📅, in the calendar's own color.
|
|
609
|
+
return (
|
|
610
|
+
<>
|
|
611
|
+
<span style={{ fg: T.textDim }}>{prefix}</span>
|
|
612
|
+
<span style={{ fg: e.color, attributes: ATTR.bold }}>
|
|
613
|
+
{"┤ "}{formatHm(e.startMin)}{"-"}{formatHm(e.endMin)}{" "}
|
|
614
|
+
</span>
|
|
615
|
+
<span style={{ fg: e.color }}>{"📅 "}</span>
|
|
616
|
+
</>
|
|
617
|
+
);
|
|
618
|
+
}
|
|
566
619
|
const bColor = boardColor(e.boardIndex);
|
|
567
620
|
const priorityGlyph = e.task.priority !== "none" ? "🔺 " : "";
|
|
568
621
|
return (
|
|
@@ -590,11 +643,21 @@ function RowContent(props: RowContentProps) {
|
|
|
590
643
|
}
|
|
591
644
|
if (r.kind === "body" && r.entry) {
|
|
592
645
|
const e = r.entry;
|
|
646
|
+
const avail = props.laneWidth ?? 200;
|
|
647
|
+
if (e.kind === "calendar") {
|
|
648
|
+
const budget = Math.max(6, avail - (props.skipPrefix ? 0 : 3) - 2);
|
|
649
|
+
return (
|
|
650
|
+
<>
|
|
651
|
+
<span style={{ fg: T.textDim }}>{prefix}</span>
|
|
652
|
+
<span style={{ fg: e.color }}>{"│ "}</span>
|
|
653
|
+
<span style={{ fg: e.color }}>{tailTruncate(e.title, budget)}</span>
|
|
654
|
+
</>
|
|
655
|
+
);
|
|
656
|
+
}
|
|
593
657
|
const bColor = boardColor(e.boardIndex);
|
|
594
658
|
// Tail-truncate so the START of the title stays readable (the head/tail
|
|
595
659
|
// ellipsis OpenTUI does otherwise chops the middle). Budget = lane width
|
|
596
660
|
// minus the prefix, the "│ " gutter, and the done check.
|
|
597
|
-
const avail = props.laneWidth ?? 200;
|
|
598
661
|
const budget = Math.max(
|
|
599
662
|
6,
|
|
600
663
|
avail - (props.skipPrefix ? 0 : 3) - 2 - (e.task.done ? 2 : 0),
|
|
@@ -614,15 +677,15 @@ function RowContent(props: RowContentProps) {
|
|
|
614
677
|
}
|
|
615
678
|
if (r.kind === "fill" && r.entry) {
|
|
616
679
|
const e = r.entry;
|
|
617
|
-
const
|
|
680
|
+
const color = e.kind === "calendar" ? e.color : boardColor(e.boardIndex);
|
|
618
681
|
const isLast = props.rowIndex === e.endRow - 1;
|
|
619
682
|
return (
|
|
620
683
|
<>
|
|
621
684
|
<span style={{ fg: T.textDim }}>{prefix}</span>
|
|
622
|
-
<span style={{ fg:
|
|
685
|
+
<span style={{ fg: color }}>{isLast ? "╰" : "│"}</span>
|
|
623
686
|
<Show when={isLast}>
|
|
624
687
|
{/* Bottom edge of the block — clear visual cap. */}
|
|
625
|
-
<span style={{ fg:
|
|
688
|
+
<span style={{ fg: color }}>{"─".repeat(120)}</span>
|
|
626
689
|
</Show>
|
|
627
690
|
</>
|
|
628
691
|
);
|