tuiboard 0.5.4 → 0.6.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 +17 -0
- package/README.md +62 -2
- package/bin/tuiboard.ts +7 -0
- package/package.json +3 -2
- package/src/calendar/setup.ts +337 -0
- package/src/config/loader.ts +81 -0
- package/src/input/handleKey.ts +22 -2
- package/src/parser/markdown.ts +1 -1
- package/src/store/calendar.ts +439 -0
- package/src/store/index.test.ts +49 -1
- package/src/store/index.ts +48 -0
- package/src/store/timeline.test.ts +64 -13
- package/src/store/timeline.ts +94 -21
- package/src/ui/Modal.tsx +4 -1
- package/src/ui/TimelineView.tsx +77 -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/Modal.tsx
CHANGED
|
@@ -606,7 +606,10 @@ function HelpModal(props: { store: TuiStore }) {
|
|
|
606
606
|
<span style={{ fg: T.text }}>{" d Delete task (with confirm)\n"}</span>
|
|
607
607
|
<span style={{ fg: T.text }}>{" X Archive task → moves to Archive column\n"}</span>
|
|
608
608
|
<span style={{ fg: T.text }}>{" C Copy task to clipboard (markdown line)\n"}</span>
|
|
609
|
-
<span style={{ fg: T.textDim }}>{"\
|
|
609
|
+
<span style={{ fg: T.textDim }}>{"\nAgenda (timeline) day navigation\n"}</span>
|
|
610
|
+
<span style={{ fg: T.text }}>{" [ / ] Previous / next day (tasks + calendar events)\n"}</span>
|
|
611
|
+
<span style={{ fg: T.text }}>{" \\ Jump back to today\n"}</span>
|
|
612
|
+
<span style={{ fg: T.textDim }}>{"\nAgenda (timeline) scheduling\n"}</span>
|
|
610
613
|
<span style={{ fg: T.text }}>{" c (any zone) Toggle ARM MODE — then click a task, click a slot, repeat\n"}</span>
|
|
611
614
|
<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
615
|
<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,12 @@ export function TimelineView(props: TimelineViewProps) {
|
|
|
306
335
|
</span>
|
|
307
336
|
</text>
|
|
308
337
|
</Show>
|
|
338
|
+
<Show when={!isToday()}>
|
|
339
|
+
<text wrapMode="none">
|
|
340
|
+
<span style={{ fg: T.warm }}>{"◷ "}</span>
|
|
341
|
+
<span style={{ fg: T.textDim }}>{"[ prev · ] next · \\ today"}</span>
|
|
342
|
+
</text>
|
|
343
|
+
</Show>
|
|
309
344
|
<Show when={!armedTask() && rowMap().overflow > 0}>
|
|
310
345
|
<text wrapMode="none">
|
|
311
346
|
<span style={{ fg: T.bannerWarn }}>
|
|
@@ -416,8 +451,10 @@ function TimelineRow(props: TimelineRowProps) {
|
|
|
416
451
|
const rightIsArmed = () =>
|
|
417
452
|
!!props.armedEntry && right().entry === props.armedEntry;
|
|
418
453
|
|
|
419
|
-
const
|
|
420
|
-
|
|
454
|
+
const entryDone = (e: TimelineEntry | undefined) =>
|
|
455
|
+
e?.kind === "task" && e.task.done;
|
|
456
|
+
const leftIsDone = () => entryDone(left().entry);
|
|
457
|
+
const rightIsDone = () => entryDone(right().entry);
|
|
421
458
|
|
|
422
459
|
// Cell budget per lane, so RowContent can tail-truncate the title (keeping
|
|
423
460
|
// the head readable) instead of leaning on OpenTUI's middle-ellipsis.
|
|
@@ -563,6 +600,18 @@ function RowContent(props: RowContentProps) {
|
|
|
563
600
|
}
|
|
564
601
|
if (r.kind === "head" && r.entry) {
|
|
565
602
|
const e = r.entry;
|
|
603
|
+
if (e.kind === "calendar") {
|
|
604
|
+
// Read-only calendar event: time + 📅, in the calendar's own color.
|
|
605
|
+
return (
|
|
606
|
+
<>
|
|
607
|
+
<span style={{ fg: T.textDim }}>{prefix}</span>
|
|
608
|
+
<span style={{ fg: e.color, attributes: ATTR.bold }}>
|
|
609
|
+
{"┤ "}{formatHm(e.startMin)}{"-"}{formatHm(e.endMin)}{" "}
|
|
610
|
+
</span>
|
|
611
|
+
<span style={{ fg: e.color }}>{"📅 "}</span>
|
|
612
|
+
</>
|
|
613
|
+
);
|
|
614
|
+
}
|
|
566
615
|
const bColor = boardColor(e.boardIndex);
|
|
567
616
|
const priorityGlyph = e.task.priority !== "none" ? "🔺 " : "";
|
|
568
617
|
return (
|
|
@@ -590,11 +639,21 @@ function RowContent(props: RowContentProps) {
|
|
|
590
639
|
}
|
|
591
640
|
if (r.kind === "body" && r.entry) {
|
|
592
641
|
const e = r.entry;
|
|
642
|
+
const avail = props.laneWidth ?? 200;
|
|
643
|
+
if (e.kind === "calendar") {
|
|
644
|
+
const budget = Math.max(6, avail - (props.skipPrefix ? 0 : 3) - 2);
|
|
645
|
+
return (
|
|
646
|
+
<>
|
|
647
|
+
<span style={{ fg: T.textDim }}>{prefix}</span>
|
|
648
|
+
<span style={{ fg: e.color }}>{"│ "}</span>
|
|
649
|
+
<span style={{ fg: e.color }}>{tailTruncate(e.title, budget)}</span>
|
|
650
|
+
</>
|
|
651
|
+
);
|
|
652
|
+
}
|
|
593
653
|
const bColor = boardColor(e.boardIndex);
|
|
594
654
|
// Tail-truncate so the START of the title stays readable (the head/tail
|
|
595
655
|
// ellipsis OpenTUI does otherwise chops the middle). Budget = lane width
|
|
596
656
|
// minus the prefix, the "│ " gutter, and the done check.
|
|
597
|
-
const avail = props.laneWidth ?? 200;
|
|
598
657
|
const budget = Math.max(
|
|
599
658
|
6,
|
|
600
659
|
avail - (props.skipPrefix ? 0 : 3) - 2 - (e.task.done ? 2 : 0),
|
|
@@ -614,15 +673,15 @@ function RowContent(props: RowContentProps) {
|
|
|
614
673
|
}
|
|
615
674
|
if (r.kind === "fill" && r.entry) {
|
|
616
675
|
const e = r.entry;
|
|
617
|
-
const
|
|
676
|
+
const color = e.kind === "calendar" ? e.color : boardColor(e.boardIndex);
|
|
618
677
|
const isLast = props.rowIndex === e.endRow - 1;
|
|
619
678
|
return (
|
|
620
679
|
<>
|
|
621
680
|
<span style={{ fg: T.textDim }}>{prefix}</span>
|
|
622
|
-
<span style={{ fg:
|
|
681
|
+
<span style={{ fg: color }}>{isLast ? "╰" : "│"}</span>
|
|
623
682
|
<Show when={isLast}>
|
|
624
683
|
{/* Bottom edge of the block — clear visual cap. */}
|
|
625
|
-
<span style={{ fg:
|
|
684
|
+
<span style={{ fg: color }}>{"─".repeat(120)}</span>
|
|
626
685
|
</Show>
|
|
627
686
|
</>
|
|
628
687
|
);
|