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.
@@ -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("R3PLICA", "r3.md", [t]);
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("R3PLICA", "r3.md", [t]);
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("R3PLICA", "r3.md", [t]);
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("R3PLICA", "r3.md", [t]);
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("R3PLICA", "r3.md", [t]);
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("R3PLICA", "r3.md", [t]);
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("R3PLICA", "r3.md", [t1, t2]);
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: "R3PLICA",
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?.task.displayTitle).toBe("A");
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?.task.displayTitle).toBe("B");
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?.task.displayTitle).toBe("B");
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?.task.displayTitle).toBe("A");
218
- expect(rows[10]!.left.entry?.task.displayTitle).toBe("C");
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");
@@ -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
- export interface TimelineEntry {
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
- ): TimelineEntry[] {
89
- const out: TimelineEntry[] = [];
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 windowStart = DAY_START_HOUR * 60;
106
- const windowEnd = DAY_END_HOUR * 60;
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: Math.max(0, startRow),
130
- endRow: Math.min(TOTAL_ROWS, 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 }}>{"\nTimeline scheduling\n"}</span>
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>
@@ -36,13 +36,15 @@ import {
36
36
  onMount,
37
37
  } from "solid-js";
38
38
 
39
- import { isoToday, type TaskRef } from "~/store/index";
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
- isoToday(),
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
- // No more sticky-unscheduled trimming the unscheduled list lived at the
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(() => buildRowMap(entries(), nowMin()));
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 today — so arming a task from ANY board and dropping it
222
- // here pins it to today (otherwise it'd vanish: block set, wrong date).
223
- props.store.setScheduled(ref, isoToday());
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={`┤ Timeline · ${entries().length}${armMode() ? " ◉ ARM" : ""} ├`}
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 leftIsDone = () => !!left().entry?.task.done;
420
- const rightIsDone = () => !!right().entry?.task.done;
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 bColor = boardColor(e.boardIndex);
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: bColor }}>{isLast ? "╰" : "│"}</span>
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: bColor }}>{"─".repeat(120)}</span>
684
+ <span style={{ fg: color }}>{"─".repeat(120)}</span>
626
685
  </Show>
627
686
  </>
628
687
  );