tuiboard 0.8.3 → 0.9.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.
@@ -1,332 +0,0 @@
1
- import { describe, expect, it } from "bun:test";
2
-
3
- import type { Board, Task } from "~/types";
4
- import {
5
- DAY_START_HOUR,
6
- MINS_PER_ROW,
7
- TOTAL_ROWS,
8
- buildRowMap,
9
- buildCalendarEntries,
10
- buildTimelineEntries,
11
- countOverlaps,
12
- formatAgendaDay,
13
- formatHm,
14
- type TimelineEntry,
15
- } from "./timeline";
16
-
17
- /** Build a minimal Task object for testing. Only fields touched by timeline. */
18
- function makeTask(opts: Partial<Task> & Pick<Task, "displayTitle">): Task {
19
- const base: Partial<Task> = {
20
- id: "0:0",
21
- rawBody: opts.displayTitle,
22
- rawLine: `- [ ] ${opts.displayTitle}`,
23
- done: false,
24
- priority: "none",
25
- tags: [],
26
- wikilinks: [],
27
- dirty: false,
28
- };
29
- return { ...base, ...opts } as Task;
30
- }
31
-
32
- function makeBoard(name: string, filepath: string, tasks: Task[]): Board {
33
- return {
34
- name,
35
- filepath,
36
- frontmatter: "",
37
- preamble: "",
38
- trailer: "",
39
- lineEnding: "\n",
40
- originalContent: "",
41
- columns: [
42
- {
43
- name: "Inbox",
44
- headerLevel: 2,
45
- rawHeading: "## Inbox",
46
- children: tasks,
47
- },
48
- ],
49
- };
50
- }
51
-
52
- describe("buildTimelineEntries", () => {
53
- const today = "2026-05-27";
54
-
55
- it("returns empty when no tasks are time-blocked today", () => {
56
- const t = makeTask({ displayTitle: "no time block", scheduled: today });
57
- const board = makeBoard("Work", "work.md", [t]);
58
- expect(buildTimelineEntries([board], today)).toEqual([]);
59
- });
60
-
61
- it("includes a scheduled, time-blocked, non-done task for today", () => {
62
- const t = makeTask({
63
- displayTitle: "outreach",
64
- scheduled: today,
65
- timeBlock: { startMin: 9 * 60, endMin: 10 * 60 + 30 },
66
- });
67
- const board = makeBoard("Work", "work.md", [t]);
68
- const entries = buildTimelineEntries([board], today);
69
- expect(entries.length).toBe(1);
70
- expect(entries[0]!.startMin).toBe(540);
71
- expect(entries[0]!.endMin).toBe(630);
72
- // 9:00 = row 8 (since DAY_START=7, 60min/15 = 4 rows per hour)
73
- expect(entries[0]!.startRow).toBe(8);
74
- // 90min duration = 6 rows
75
- expect(entries[0]!.endRow).toBe(14);
76
- });
77
-
78
- it("includes done tasks (timeline doubles as a done-log)", () => {
79
- const t = makeTask({
80
- displayTitle: "done deal",
81
- done: true,
82
- scheduled: today,
83
- timeBlock: { startMin: 9 * 60, endMin: 10 * 60 },
84
- });
85
- const board = makeBoard("Work", "work.md", [t]);
86
- const entries = buildTimelineEntries([board], today);
87
- expect(entries).toHaveLength(1);
88
- expect(entries[0]!.task.done).toBe(true);
89
- });
90
-
91
- it("excludes tasks scheduled for a different date", () => {
92
- const t = makeTask({
93
- displayTitle: "tomorrow",
94
- scheduled: "2026-05-28",
95
- timeBlock: { startMin: 9 * 60, endMin: 10 * 60 },
96
- });
97
- const board = makeBoard("Work", "work.md", [t]);
98
- expect(buildTimelineEntries([board], today)).toEqual([]);
99
- });
100
-
101
- it("clips blocks that span outside the rendered window", () => {
102
- const t = makeTask({
103
- displayTitle: "early",
104
- scheduled: today,
105
- // 6:30-7:30 → only 7:00-7:30 visible (rows 0-1)
106
- timeBlock: { startMin: 6 * 60 + 30, endMin: 7 * 60 + 30 },
107
- });
108
- const board = makeBoard("Work", "work.md", [t]);
109
- const entries = buildTimelineEntries([board], today);
110
- expect(entries.length).toBe(1);
111
- expect(entries[0]!.startRow).toBe(0); // clipped at top
112
- });
113
-
114
- it("skips blocks entirely outside the window", () => {
115
- const t = makeTask({
116
- displayTitle: "midnight",
117
- scheduled: today,
118
- timeBlock: { startMin: 0, endMin: 30 },
119
- });
120
- const board = makeBoard("Work", "work.md", [t]);
121
- expect(buildTimelineEntries([board], today)).toEqual([]);
122
- });
123
-
124
- it("sorts entries by startMin", () => {
125
- const t1 = makeTask({
126
- displayTitle: "afternoon",
127
- scheduled: today,
128
- timeBlock: { startMin: 14 * 60, endMin: 15 * 60 },
129
- });
130
- const t2 = makeTask({
131
- displayTitle: "morning",
132
- scheduled: today,
133
- timeBlock: { startMin: 9 * 60, endMin: 10 * 60 },
134
- });
135
- const board = makeBoard("Work", "work.md", [t1, t2]);
136
- const entries = buildTimelineEntries([board], today);
137
- expect(entries.map((e) => e.task.displayTitle)).toEqual([
138
- "morning",
139
- "afternoon",
140
- ]);
141
- });
142
- });
143
-
144
- describe("buildRowMap", () => {
145
- function entryAt(startRow: number, endRow: number, title = "block"): TimelineEntry {
146
- return {
147
- kind: "task",
148
- ref: { boardPath: "x", columnIndex: 0, taskIndex: 0 },
149
- task: makeTask({ displayTitle: title }),
150
- boardName: "Work",
151
- boardIndex: 0,
152
- columnName: "Inbox",
153
- startMin: 0,
154
- endMin: 0,
155
- startRow,
156
- endRow,
157
- };
158
- }
159
-
160
- const titleOf = (e: TimelineEntry | undefined) =>
161
- e?.kind === "task" ? e.task.displayTitle : undefined;
162
-
163
- it("returns TOTAL_ROWS row pairs", () => {
164
- const result = buildRowMap([], 0);
165
- expect(result.rows.length).toBe(TOTAL_ROWS);
166
- expect(result.overflow).toBe(0);
167
- });
168
-
169
- it("marks hour-anchor rows on the left lane with kind=hour", () => {
170
- const { rows } = buildRowMap([], 0);
171
- expect(rows[0]!.left).toEqual({ kind: "hour", hour: DAY_START_HOUR });
172
- expect(rows[4]!.left).toEqual({ kind: "hour", hour: DAY_START_HOUR + 1 });
173
- expect(rows[1]!.left.kind).toBe("empty");
174
- // Right lane is always empty when no overlap.
175
- expect(rows[0]!.right.kind).toBe("empty");
176
- });
177
-
178
- it("places a single entry on the left lane as head/body/fill", () => {
179
- const { rows } = buildRowMap([entryAt(8, 14)], 0);
180
- expect(rows[8]!.left.kind).toBe("head");
181
- expect(rows[9]!.left.kind).toBe("body");
182
- expect(rows[10]!.left.kind).toBe("fill");
183
- expect(rows[13]!.left.kind).toBe("fill");
184
- expect(rows[14]!.left.kind).not.toBe("fill"); // end exclusive
185
- // No overlap → right lane all empty.
186
- for (let r = 8; r < 14; r++) {
187
- expect(rows[r]!.right.kind).toBe("empty");
188
- }
189
- });
190
-
191
- it("places overlapping entries on left and right lanes side-by-side", () => {
192
- // A: rows 5-10, B: rows 7-12 → overlap on rows 7-9.
193
- const a = entryAt(5, 10, "A");
194
- const b = entryAt(7, 12, "B");
195
- const { rows, overflow } = buildRowMap([a, b], 0);
196
- expect(overflow).toBe(0);
197
- // Lane 0 (left) gets A.
198
- expect(rows[5]!.left.kind).toBe("head");
199
- expect(titleOf(rows[5]!.left.entry)).toBe("A");
200
- // Lane 1 (right) gets B starting at row 7.
201
- expect(rows[7]!.right.kind).toBe("head");
202
- expect(titleOf(rows[7]!.right.entry)).toBe("B");
203
- // Row 10 is past A's end but inside B → left empty, right fill.
204
- expect(rows[10]!.left.kind).toBe("empty");
205
- expect(titleOf(rows[10]!.right.entry)).toBe("B");
206
- });
207
-
208
- it("counts third+ overlapping entry as overflow", () => {
209
- // Three blocks overlapping at row 8.
210
- const a = entryAt(5, 12, "A");
211
- const b = entryAt(6, 11, "B");
212
- const c = entryAt(7, 10, "C");
213
- const { overflow } = buildRowMap([a, b, c], 0);
214
- expect(overflow).toBe(1); // C didn't fit either lane
215
- });
216
-
217
- it("reuses a lane after its block ends", () => {
218
- // A: rows 5-8, C: rows 10-15 → both can use lane 0.
219
- const a = entryAt(5, 8, "A");
220
- const c = entryAt(10, 15, "C");
221
- const { rows, overflow } = buildRowMap([a, c], 0);
222
- expect(overflow).toBe(0);
223
- expect(titleOf(rows[5]!.left.entry)).toBe("A");
224
- expect(titleOf(rows[10]!.left.entry)).toBe("C");
225
- // Right lane stayed empty throughout.
226
- for (let r = 0; r < TOTAL_ROWS; r++) {
227
- expect(rows[r]!.right.kind).toBe("empty");
228
- }
229
- });
230
-
231
- it("overlays a now marker on the left lane (and clears the right)", () => {
232
- const nowMin = 10 * 60 + 30; // 10:30 → row 14
233
- const { rows } = buildRowMap([], nowMin);
234
- expect(rows[14]!.left.kind).toBe("now");
235
- expect(rows[14]!.left.nowMin).toBe(nowMin);
236
- expect(rows[14]!.right.kind).toBe("empty");
237
- });
238
-
239
- it("does not place a now marker when out of window", () => {
240
- const { rows } = buildRowMap([], 3 * 60); // 03:00, before DAY_START
241
- expect(rows.some((r) => r.left.kind === "now")).toBe(false);
242
- });
243
- });
244
-
245
- describe("countOverlaps", () => {
246
- function entry(s: number, e: number): TimelineEntry {
247
- return {
248
- kind: "task",
249
- ref: { boardPath: "x", columnIndex: 0, taskIndex: 0 },
250
- task: makeTask({ displayTitle: "x" }),
251
- boardName: "X",
252
- boardIndex: 0,
253
- columnName: "X",
254
- startMin: s,
255
- endMin: e,
256
- startRow: 0,
257
- endRow: 0,
258
- };
259
- }
260
-
261
- it("returns 0 for non-overlapping blocks", () => {
262
- expect(
263
- countOverlaps([entry(540, 600), entry(600, 660), entry(720, 780)]),
264
- ).toBe(0);
265
- });
266
-
267
- it("counts overlapping pairs", () => {
268
- expect(countOverlaps([entry(540, 660), entry(600, 720)])).toBe(1);
269
- });
270
-
271
- it("counts every overlapping pair in a 3-way overlap", () => {
272
- expect(
273
- countOverlaps([entry(540, 660), entry(600, 720), entry(630, 700)]),
274
- ).toBe(3);
275
- });
276
- });
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
-
322
- describe("formatHm", () => {
323
- it("zero-pads hours and minutes", () => {
324
- expect(formatHm(0)).toBe("00:00");
325
- expect(formatHm(540)).toBe("09:00");
326
- expect(formatHm(615)).toBe("10:15");
327
- expect(formatHm(23 * 60 + 59)).toBe("23:59");
328
- });
329
- });
330
-
331
- // silence unused-var lint about MINS_PER_ROW import
332
- void MINS_PER_ROW;
@@ -1,63 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
-
3
- import { computeColumnScrollLeft } from "~/ui/board-scroll";
4
-
5
- const COL = 42;
6
- const GAP = 1;
7
- const STRIDE = COL + GAP;
8
-
9
- // Helper for the common uniform-width case: column at a given index.
10
- function scroll(index: number, viewportWidth: number, currentScroll: number) {
11
- return computeColumnScrollLeft({
12
- colStart: index * STRIDE,
13
- colWidth: COL,
14
- viewportWidth,
15
- currentScroll,
16
- });
17
- }
18
-
19
- describe("computeColumnScrollLeft", () => {
20
- test("column already fully visible → scroll unchanged", () => {
21
- expect(scroll(0, 100, 0)).toBe(0);
22
- expect(scroll(1, 100, 0)).toBe(0);
23
- });
24
-
25
- test("column off the right edge → align its right edge to viewport", () => {
26
- // viewport 60, col 1 spans 43..85 → right-align: 85-60 = 25.
27
- expect(scroll(1, 60, 0)).toBe(25);
28
- });
29
-
30
- test("column off the left edge → align its left edge to viewport", () => {
31
- expect(scroll(0, 60, 25)).toBe(0);
32
- });
33
-
34
- test("far-right hidden column scrolls fully into view", () => {
35
- // col 5 spans 215..257 → right-align: 257-60 = 197.
36
- expect(scroll(5, 60, 0)).toBe(197);
37
- });
38
-
39
- test("column wider than viewport → align left edge (show the start)", () => {
40
- // viewport 30 < column 42. col 2 starts at 86 → align left edge at 86.
41
- expect(scroll(2, 30, 0)).toBe(86);
42
- });
43
-
44
- test("negative start or zero viewport → no change", () => {
45
- expect(
46
- computeColumnScrollLeft({ colStart: -1, colWidth: COL, viewportWidth: 60, currentScroll: 17 }),
47
- ).toBe(17);
48
- expect(scroll(3, 0, 17)).toBe(17);
49
- });
50
-
51
- test("never returns a negative scroll offset", () => {
52
- expect(scroll(0, 200, 0)).toBeGreaterThanOrEqual(0);
53
- expect(scroll(0, 30, 5)).toBeGreaterThanOrEqual(0);
54
- });
55
-
56
- test("variable widths: narrow collapsed column to the left shifts offsets", () => {
57
- // Columns: [42], [18 collapsed], [42]. Third column starts at
58
- // 43 + 19 = 62, spans 62..104. viewport 50 → right-align 104-50 = 54.
59
- expect(
60
- computeColumnScrollLeft({ colStart: 62, colWidth: 42, viewportWidth: 50, currentScroll: 0 }),
61
- ).toBe(54);
62
- });
63
- });
@@ -1,31 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
-
3
- import { cellWidth } from "~/ui/glyphs";
4
-
5
- describe("cellWidth", () => {
6
- test("ASCII counts one cell per char", () => {
7
- expect(cellWidth("09:00")).toBe(5);
8
- expect(cellWidth("Costruire routine")).toBe(17);
9
- expect(cellWidth("")).toBe(0);
10
- });
11
-
12
- test("the ⌚ time-block glyph is 2 cells (the bug that broke truncation)", () => {
13
- expect(cellWidth("⌚")).toBe(2);
14
- // The actual suffix on a time-blocked row: ⌚ + "09:00" = 2 + 5 = 7,
15
- // not the 6 that String.length reports.
16
- expect(cellWidth("⌚09:00")).toBe(7);
17
- expect("⌚09:00".length).toBe(6); // proves the undercount we corrected
18
- });
19
-
20
- test("priority + clock emoji are 2 cells", () => {
21
- expect(cellWidth("🔺")).toBe(2); // U+1F53A
22
- expect(cellWidth("⏰")).toBe(2); // U+23F0
23
- expect(cellWidth("⏫")).toBe(2); // U+23EB
24
- });
25
-
26
- test("narrow symbols used in rows stay 1 cell", () => {
27
- expect(cellWidth("✓")).toBe(1); // done check
28
- expect(cellWidth("●")).toBe(1); // marked dot
29
- expect(cellWidth("→")).toBe(1); // tomorrow arrow
30
- });
31
- });