tuiboard 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.tuiboard/config.example.yaml +32 -0
  2. package/LICENSE +21 -0
  3. package/README.md +208 -0
  4. package/bin/tuiboard.ts +28 -0
  5. package/package.json +62 -0
  6. package/src/app.tsx +129 -0
  7. package/src/cli/args.test.ts +40 -0
  8. package/src/cli/args.ts +41 -0
  9. package/src/config/loader.ts +169 -0
  10. package/src/input/handleKey.ts +733 -0
  11. package/src/io/watcher.ts +85 -0
  12. package/src/io/writer.ts +92 -0
  13. package/src/parser/markdown.ts +351 -0
  14. package/src/parser/serialize.ts +97 -0
  15. package/src/scripts/agents-check.ts +24 -0
  16. package/src/scripts/parse-check.ts +124 -0
  17. package/src/scripts/roundtrip-check.ts +79 -0
  18. package/src/store/agents.test.ts +181 -0
  19. package/src/store/agents.ts +435 -0
  20. package/src/store/index.test.ts +110 -0
  21. package/src/store/index.ts +972 -0
  22. package/src/store/parsers.ts +243 -0
  23. package/src/store/timeline.test.ts +279 -0
  24. package/src/store/timeline.ts +279 -0
  25. package/src/store/virtual-panel.ts +0 -0
  26. package/src/types.ts +116 -0
  27. package/src/ui/AgentRow.tsx +79 -0
  28. package/src/ui/AgentsBar.tsx +102 -0
  29. package/src/ui/BoardView.tsx +333 -0
  30. package/src/ui/Chrome.tsx +122 -0
  31. package/src/ui/Modal.tsx +613 -0
  32. package/src/ui/TaskRow.tsx +240 -0
  33. package/src/ui/TimelineView.tsx +643 -0
  34. package/src/ui/VirtualPanel.tsx +237 -0
  35. package/src/ui/board-scroll.test.ts +63 -0
  36. package/src/ui/board-scroll.ts +49 -0
  37. package/src/ui/glyphs.ts +129 -0
  38. package/src/views/AgentsOnly.tsx +103 -0
  39. package/src/views/BoardOnly.tsx +35 -0
  40. package/src/views/Dashboard.tsx +106 -0
  41. package/src/views/TimelineOnly.tsx +12 -0
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Natural-language parsers for modal inputs.
3
+ *
4
+ * Date input shortcuts:
5
+ * t → today
6
+ * tm | tom → tomorrow
7
+ * -N → N days ago
8
+ * +N → N days ahead
9
+ * lun/mar/.../dom → next weekday (Italian short)
10
+ * mon/tue/.../sun → next weekday (English short)
11
+ * YYYY-MM-DD → literal
12
+ * DD → next DD of current or next month
13
+ * DD-MM → DD of MM of current or next year
14
+ * "" → clear (returns undefined)
15
+ *
16
+ * Time-block input shortcuts:
17
+ * n → now → now+30min
18
+ * HH:MM → HH:MM → HH:MM+30min
19
+ * HH:MM-HH:MM → literal range
20
+ * HH:MM HH:MM → range (space-separated)
21
+ * - → clear (returns undefined)
22
+ *
23
+ * Quick-add task input: free text with inline metadata
24
+ * "Fix auth @nazza t 9-11 #pr #urgent 🔺"
25
+ * Parses the metadata tokens, leaves the rest as title.
26
+ */
27
+
28
+ import { isoDate, isoToday, isoTomorrow } from "~/store/index";
29
+ import type { PriorityLevel, TimeBlock } from "~/types";
30
+
31
+ // ─── Date ────────────────────────────────────────────────────────────────────
32
+
33
+ const WEEKDAYS_IT: Record<string, number> = {
34
+ dom: 0, lun: 1, mar: 2, mer: 3, gio: 4, ven: 5, sab: 6,
35
+ };
36
+ const WEEKDAYS_EN: Record<string, number> = {
37
+ sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6,
38
+ };
39
+
40
+ export function parseDateShortcut(input: string): string | undefined | null {
41
+ // Returns:
42
+ // string → ISO date
43
+ // undefined → clear date
44
+ // null → parse failed (caller shows error)
45
+ const s = input.trim().toLowerCase();
46
+ if (s === "") return undefined;
47
+ if (s === "-") return undefined;
48
+
49
+ if (s === "t" || s === "today" || s === "oggi") return isoToday();
50
+ if (s === "tm" || s === "tom" || s === "tomorrow" || s === "domani") return isoTomorrow();
51
+
52
+ // Relative ±N
53
+ const rel = s.match(/^([+-])(\d+)$/);
54
+ if (rel) {
55
+ const sign = rel[1] === "+" ? 1 : -1;
56
+ const n = parseInt(rel[2]!, 10);
57
+ const d = new Date();
58
+ d.setDate(d.getDate() + sign * n);
59
+ return isoDate(d);
60
+ }
61
+
62
+ // ISO YYYY-MM-DD
63
+ const iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
64
+ if (iso) {
65
+ const d = new Date(
66
+ parseInt(iso[1]!, 10),
67
+ parseInt(iso[2]!, 10) - 1,
68
+ parseInt(iso[3]!, 10),
69
+ );
70
+ if (Number.isNaN(d.getTime())) return null;
71
+ return isoDate(d);
72
+ }
73
+
74
+ // DD-MM (current or next year if past)
75
+ const ddmm = s.match(/^(\d{1,2})-(\d{1,2})$/);
76
+ if (ddmm) {
77
+ const day = parseInt(ddmm[1]!, 10);
78
+ const mon = parseInt(ddmm[2]!, 10);
79
+ const now = new Date();
80
+ let candidate = new Date(now.getFullYear(), mon - 1, day);
81
+ if (Number.isNaN(candidate.getTime())) return null;
82
+ if (candidate < now) {
83
+ candidate = new Date(now.getFullYear() + 1, mon - 1, day);
84
+ }
85
+ return isoDate(candidate);
86
+ }
87
+
88
+ // DD only
89
+ const dd = s.match(/^(\d{1,2})$/);
90
+ if (dd) {
91
+ const day = parseInt(dd[1]!, 10);
92
+ if (day < 1 || day > 31) return null;
93
+ const now = new Date();
94
+ let candidate = new Date(now.getFullYear(), now.getMonth(), day);
95
+ if (candidate < now) {
96
+ candidate = new Date(now.getFullYear(), now.getMonth() + 1, day);
97
+ }
98
+ return isoDate(candidate);
99
+ }
100
+
101
+ // Weekday
102
+ const wd = WEEKDAYS_IT[s] ?? WEEKDAYS_EN[s];
103
+ if (wd !== undefined) {
104
+ const now = new Date();
105
+ const cur = now.getDay();
106
+ let delta = wd - cur;
107
+ if (delta <= 0) delta += 7;
108
+ const d = new Date();
109
+ d.setDate(d.getDate() + delta);
110
+ return isoDate(d);
111
+ }
112
+
113
+ return null;
114
+ }
115
+
116
+ // ─── Time block ──────────────────────────────────────────────────────────────
117
+
118
+ export function parseTimeBlockShortcut(input: string): TimeBlock | undefined | null {
119
+ const s = input.trim();
120
+ if (s === "" || s === "-") return undefined;
121
+
122
+ if (s.toLowerCase() === "n" || s.toLowerCase() === "now") {
123
+ const now = new Date();
124
+ const start = now.getHours() * 60 + now.getMinutes();
125
+ return { startMin: start, endMin: Math.min(start + 30, 24 * 60) };
126
+ }
127
+
128
+ // HH:MM-HH:MM or HH:MM HH:MM or HHMM-HHMM (with - or space)
129
+ const range = s.match(/^(\d{1,2}):?(\d{2})\s*[- ]\s*(\d{1,2}):?(\d{2})$/);
130
+ if (range) {
131
+ return makeBlock(range[1]!, range[2]!, range[3]!, range[4]!);
132
+ }
133
+ // Loose `H-H` (e.g. "9-11") → 9:00 to 11:00
134
+ const hh = s.match(/^(\d{1,2})\s*[- ]\s*(\d{1,2})$/);
135
+ if (hh) {
136
+ return makeBlock(hh[1]!, "00", hh[2]!, "00");
137
+ }
138
+ // Single HH:MM → HH:MM to HH:MM+30
139
+ const single = s.match(/^(\d{1,2}):(\d{2})$/);
140
+ if (single) {
141
+ const start = parseInt(single[1]!, 10) * 60 + parseInt(single[2]!, 10);
142
+ return { startMin: start, endMin: Math.min(start + 30, 24 * 60) };
143
+ }
144
+ return null;
145
+ }
146
+
147
+ function makeBlock(h1: string, m1: string, h2: string, m2: string): TimeBlock | null {
148
+ const s = parseInt(h1, 10) * 60 + parseInt(m1, 10);
149
+ const e = parseInt(h2, 10) * 60 + parseInt(m2, 10);
150
+ if (!Number.isFinite(s) || !Number.isFinite(e)) return null;
151
+ if (s < 0 || s >= 24 * 60 || e < 0 || e > 24 * 60 || e <= s) return null;
152
+ return { startMin: s, endMin: e };
153
+ }
154
+
155
+ // ─── Quick add ───────────────────────────────────────────────────────────────
156
+
157
+ export interface QuickAddResult {
158
+ title: string;
159
+ assignee?: string;
160
+ tags: string[];
161
+ scheduled?: string;
162
+ timeBlock?: TimeBlock;
163
+ priority: PriorityLevel;
164
+ }
165
+
166
+ /**
167
+ * Parse a free-form quick-add string. Recognized tokens:
168
+ * @name → assignee
169
+ * #tag → tag
170
+ * t, tm, +N → scheduled date shortcut
171
+ * YYYY-MM-DD → scheduled date literal
172
+ * HH:MM-HH:MM → time block (also sets scheduled to today if missing)
173
+ * 9-11 → 09:00-11:00 time block
174
+ * 🔺⏫🔼🔽⏬ → priority
175
+ *
176
+ * Tokens are stripped from the title. Order doesn't matter except that the
177
+ * first scheduling token wins.
178
+ */
179
+ export function parseQuickAdd(input: string): QuickAddResult {
180
+ const result: QuickAddResult = {
181
+ title: "",
182
+ tags: [],
183
+ priority: "none",
184
+ };
185
+
186
+ // Match priority emoji first and remove
187
+ const priorityMap: Record<string, PriorityLevel> = {
188
+ "🔺": "highest",
189
+ "⏫": "high",
190
+ "🔼": "medium",
191
+ "🔽": "low",
192
+ "⏬": "lowest",
193
+ };
194
+ let s = input;
195
+ for (const [emoji, lvl] of Object.entries(priorityMap)) {
196
+ if (s.includes(emoji)) {
197
+ result.priority = lvl;
198
+ s = s.replaceAll(emoji, " ");
199
+ }
200
+ }
201
+
202
+ const tokens = s.split(/\s+/).filter(Boolean);
203
+ const remaining: string[] = [];
204
+
205
+ for (const tok of tokens) {
206
+ if (tok.startsWith("@") && tok.length > 1) {
207
+ result.assignee = tok.slice(1);
208
+ continue;
209
+ }
210
+ if (tok.startsWith("#") && tok.length > 1) {
211
+ result.tags.push(tok.slice(1));
212
+ continue;
213
+ }
214
+ // Time block: HH:MM-HH:MM or H-H
215
+ const tb = parseTimeBlockShortcut(tok);
216
+ if (tb && tb !== null) {
217
+ if (!result.timeBlock) {
218
+ result.timeBlock = tb;
219
+ if (!result.scheduled) result.scheduled = isoToday();
220
+ }
221
+ continue;
222
+ }
223
+ // Date: t, tm, +N, YYYY-MM-DD
224
+ const lower = tok.toLowerCase();
225
+ if (
226
+ lower === "t" || lower === "tm" || lower === "tom" ||
227
+ lower === "today" || lower === "tomorrow" ||
228
+ lower === "oggi" || lower === "domani" ||
229
+ /^[+-]\d+$/.test(lower) ||
230
+ /^\d{4}-\d{2}-\d{2}$/.test(lower)
231
+ ) {
232
+ const d = parseDateShortcut(lower);
233
+ if (typeof d === "string" && !result.scheduled) {
234
+ result.scheduled = d;
235
+ continue;
236
+ }
237
+ }
238
+ remaining.push(tok);
239
+ }
240
+
241
+ result.title = remaining.join(" ").trim();
242
+ return result;
243
+ }
@@ -0,0 +1,279 @@
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
+ buildTimelineEntries,
10
+ countOverlaps,
11
+ formatHm,
12
+ type TimelineEntry,
13
+ } from "./timeline";
14
+
15
+ /** Build a minimal Task object for testing. Only fields touched by timeline. */
16
+ function makeTask(opts: Partial<Task> & Pick<Task, "displayTitle">): Task {
17
+ const base: Partial<Task> = {
18
+ id: "0:0",
19
+ rawBody: opts.displayTitle,
20
+ rawLine: `- [ ] ${opts.displayTitle}`,
21
+ done: false,
22
+ priority: "none",
23
+ tags: [],
24
+ wikilinks: [],
25
+ dirty: false,
26
+ };
27
+ return { ...base, ...opts } as Task;
28
+ }
29
+
30
+ function makeBoard(name: string, filepath: string, tasks: Task[]): Board {
31
+ return {
32
+ name,
33
+ filepath,
34
+ frontmatter: "",
35
+ preamble: "",
36
+ trailer: "",
37
+ lineEnding: "\n",
38
+ originalContent: "",
39
+ columns: [
40
+ {
41
+ name: "Inbox",
42
+ headerLevel: 2,
43
+ rawHeading: "## Inbox",
44
+ children: tasks,
45
+ },
46
+ ],
47
+ };
48
+ }
49
+
50
+ describe("buildTimelineEntries", () => {
51
+ const today = "2026-05-27";
52
+
53
+ it("returns empty when no tasks are time-blocked today", () => {
54
+ const t = makeTask({ displayTitle: "no time block", scheduled: today });
55
+ const board = makeBoard("R3PLICA", "r3.md", [t]);
56
+ expect(buildTimelineEntries([board], today)).toEqual([]);
57
+ });
58
+
59
+ it("includes a scheduled, time-blocked, non-done task for today", () => {
60
+ const t = makeTask({
61
+ displayTitle: "outreach",
62
+ scheduled: today,
63
+ timeBlock: { startMin: 9 * 60, endMin: 10 * 60 + 30 },
64
+ });
65
+ const board = makeBoard("R3PLICA", "r3.md", [t]);
66
+ const entries = buildTimelineEntries([board], today);
67
+ expect(entries.length).toBe(1);
68
+ expect(entries[0]!.startMin).toBe(540);
69
+ expect(entries[0]!.endMin).toBe(630);
70
+ // 9:00 = row 8 (since DAY_START=7, 60min/15 = 4 rows per hour)
71
+ expect(entries[0]!.startRow).toBe(8);
72
+ // 90min duration = 6 rows
73
+ expect(entries[0]!.endRow).toBe(14);
74
+ });
75
+
76
+ it("excludes done tasks", () => {
77
+ const t = makeTask({
78
+ displayTitle: "done deal",
79
+ done: true,
80
+ scheduled: today,
81
+ timeBlock: { startMin: 9 * 60, endMin: 10 * 60 },
82
+ });
83
+ const board = makeBoard("R3PLICA", "r3.md", [t]);
84
+ expect(buildTimelineEntries([board], today)).toEqual([]);
85
+ });
86
+
87
+ it("excludes tasks scheduled for a different date", () => {
88
+ const t = makeTask({
89
+ displayTitle: "tomorrow",
90
+ scheduled: "2026-05-28",
91
+ timeBlock: { startMin: 9 * 60, endMin: 10 * 60 },
92
+ });
93
+ const board = makeBoard("R3PLICA", "r3.md", [t]);
94
+ expect(buildTimelineEntries([board], today)).toEqual([]);
95
+ });
96
+
97
+ it("clips blocks that span outside the rendered window", () => {
98
+ const t = makeTask({
99
+ displayTitle: "early",
100
+ scheduled: today,
101
+ // 6:30-7:30 → only 7:00-7:30 visible (rows 0-1)
102
+ timeBlock: { startMin: 6 * 60 + 30, endMin: 7 * 60 + 30 },
103
+ });
104
+ const board = makeBoard("R3PLICA", "r3.md", [t]);
105
+ const entries = buildTimelineEntries([board], today);
106
+ expect(entries.length).toBe(1);
107
+ expect(entries[0]!.startRow).toBe(0); // clipped at top
108
+ });
109
+
110
+ it("skips blocks entirely outside the window", () => {
111
+ const t = makeTask({
112
+ displayTitle: "midnight",
113
+ scheduled: today,
114
+ timeBlock: { startMin: 0, endMin: 30 },
115
+ });
116
+ const board = makeBoard("R3PLICA", "r3.md", [t]);
117
+ expect(buildTimelineEntries([board], today)).toEqual([]);
118
+ });
119
+
120
+ it("sorts entries by startMin", () => {
121
+ const t1 = makeTask({
122
+ displayTitle: "afternoon",
123
+ scheduled: today,
124
+ timeBlock: { startMin: 14 * 60, endMin: 15 * 60 },
125
+ });
126
+ const t2 = makeTask({
127
+ displayTitle: "morning",
128
+ scheduled: today,
129
+ timeBlock: { startMin: 9 * 60, endMin: 10 * 60 },
130
+ });
131
+ const board = makeBoard("R3PLICA", "r3.md", [t1, t2]);
132
+ const entries = buildTimelineEntries([board], today);
133
+ expect(entries.map((e) => e.task.displayTitle)).toEqual([
134
+ "morning",
135
+ "afternoon",
136
+ ]);
137
+ });
138
+ });
139
+
140
+ describe("buildRowMap", () => {
141
+ function entryAt(startRow: number, endRow: number, title = "block"): TimelineEntry {
142
+ return {
143
+ ref: { boardPath: "x", columnIndex: 0, taskIndex: 0 },
144
+ task: makeTask({ displayTitle: title }),
145
+ boardName: "R3PLICA",
146
+ boardIndex: 0,
147
+ columnName: "Inbox",
148
+ startMin: 0,
149
+ endMin: 0,
150
+ startRow,
151
+ endRow,
152
+ };
153
+ }
154
+
155
+ it("returns TOTAL_ROWS row pairs", () => {
156
+ const result = buildRowMap([], 0);
157
+ expect(result.rows.length).toBe(TOTAL_ROWS);
158
+ expect(result.overflow).toBe(0);
159
+ });
160
+
161
+ it("marks hour-anchor rows on the left lane with kind=hour", () => {
162
+ const { rows } = buildRowMap([], 0);
163
+ expect(rows[0]!.left).toEqual({ kind: "hour", hour: DAY_START_HOUR });
164
+ expect(rows[4]!.left).toEqual({ kind: "hour", hour: DAY_START_HOUR + 1 });
165
+ expect(rows[1]!.left.kind).toBe("empty");
166
+ // Right lane is always empty when no overlap.
167
+ expect(rows[0]!.right.kind).toBe("empty");
168
+ });
169
+
170
+ it("places a single entry on the left lane as head/body/fill", () => {
171
+ const { rows } = buildRowMap([entryAt(8, 14)], 0);
172
+ expect(rows[8]!.left.kind).toBe("head");
173
+ expect(rows[9]!.left.kind).toBe("body");
174
+ expect(rows[10]!.left.kind).toBe("fill");
175
+ expect(rows[13]!.left.kind).toBe("fill");
176
+ expect(rows[14]!.left.kind).not.toBe("fill"); // end exclusive
177
+ // No overlap → right lane all empty.
178
+ for (let r = 8; r < 14; r++) {
179
+ expect(rows[r]!.right.kind).toBe("empty");
180
+ }
181
+ });
182
+
183
+ it("places overlapping entries on left and right lanes side-by-side", () => {
184
+ // A: rows 5-10, B: rows 7-12 → overlap on rows 7-9.
185
+ const a = entryAt(5, 10, "A");
186
+ const b = entryAt(7, 12, "B");
187
+ const { rows, overflow } = buildRowMap([a, b], 0);
188
+ expect(overflow).toBe(0);
189
+ // Lane 0 (left) gets A.
190
+ expect(rows[5]!.left.kind).toBe("head");
191
+ expect(rows[5]!.left.entry?.task.displayTitle).toBe("A");
192
+ // Lane 1 (right) gets B starting at row 7.
193
+ expect(rows[7]!.right.kind).toBe("head");
194
+ expect(rows[7]!.right.entry?.task.displayTitle).toBe("B");
195
+ // Row 10 is past A's end but inside B → left empty, right fill.
196
+ expect(rows[10]!.left.kind).toBe("empty");
197
+ expect(rows[10]!.right.entry?.task.displayTitle).toBe("B");
198
+ });
199
+
200
+ it("counts third+ overlapping entry as overflow", () => {
201
+ // Three blocks overlapping at row 8.
202
+ const a = entryAt(5, 12, "A");
203
+ const b = entryAt(6, 11, "B");
204
+ const c = entryAt(7, 10, "C");
205
+ const { overflow } = buildRowMap([a, b, c], 0);
206
+ expect(overflow).toBe(1); // C didn't fit either lane
207
+ });
208
+
209
+ it("reuses a lane after its block ends", () => {
210
+ // A: rows 5-8, C: rows 10-15 → both can use lane 0.
211
+ const a = entryAt(5, 8, "A");
212
+ const c = entryAt(10, 15, "C");
213
+ const { rows, overflow } = buildRowMap([a, c], 0);
214
+ expect(overflow).toBe(0);
215
+ expect(rows[5]!.left.entry?.task.displayTitle).toBe("A");
216
+ expect(rows[10]!.left.entry?.task.displayTitle).toBe("C");
217
+ // Right lane stayed empty throughout.
218
+ for (let r = 0; r < TOTAL_ROWS; r++) {
219
+ expect(rows[r]!.right.kind).toBe("empty");
220
+ }
221
+ });
222
+
223
+ it("overlays a now marker on the left lane (and clears the right)", () => {
224
+ const nowMin = 10 * 60 + 30; // 10:30 → row 14
225
+ const { rows } = buildRowMap([], nowMin);
226
+ expect(rows[14]!.left.kind).toBe("now");
227
+ expect(rows[14]!.left.nowMin).toBe(nowMin);
228
+ expect(rows[14]!.right.kind).toBe("empty");
229
+ });
230
+
231
+ it("does not place a now marker when out of window", () => {
232
+ const { rows } = buildRowMap([], 3 * 60); // 03:00, before DAY_START
233
+ expect(rows.some((r) => r.left.kind === "now")).toBe(false);
234
+ });
235
+ });
236
+
237
+ describe("countOverlaps", () => {
238
+ function entry(s: number, e: number): TimelineEntry {
239
+ return {
240
+ ref: { boardPath: "x", columnIndex: 0, taskIndex: 0 },
241
+ task: makeTask({ displayTitle: "x" }),
242
+ boardName: "X",
243
+ boardIndex: 0,
244
+ columnName: "X",
245
+ startMin: s,
246
+ endMin: e,
247
+ startRow: 0,
248
+ endRow: 0,
249
+ };
250
+ }
251
+
252
+ it("returns 0 for non-overlapping blocks", () => {
253
+ expect(
254
+ countOverlaps([entry(540, 600), entry(600, 660), entry(720, 780)]),
255
+ ).toBe(0);
256
+ });
257
+
258
+ it("counts overlapping pairs", () => {
259
+ expect(countOverlaps([entry(540, 660), entry(600, 720)])).toBe(1);
260
+ });
261
+
262
+ it("counts every overlapping pair in a 3-way overlap", () => {
263
+ expect(
264
+ countOverlaps([entry(540, 660), entry(600, 720), entry(630, 700)]),
265
+ ).toBe(3);
266
+ });
267
+ });
268
+
269
+ describe("formatHm", () => {
270
+ it("zero-pads hours and minutes", () => {
271
+ expect(formatHm(0)).toBe("00:00");
272
+ expect(formatHm(540)).toBe("09:00");
273
+ expect(formatHm(615)).toBe("10:15");
274
+ expect(formatHm(23 * 60 + 59)).toBe("23:59");
275
+ });
276
+ });
277
+
278
+ // silence unused-var lint about MINS_PER_ROW import
279
+ void MINS_PER_ROW;