Skip to main content

max / goingson

Make a row's cells answer to its column description The description reaches CSS as tracks and hiding rules, and neither of them says which cells a row should emit. So a cell naming a column that does not exist is invisible to the stylesheet -- its hiding rule never matches, it survives a narrow screen, and nothing anywhere says so. Both halves of the events bug had that shape, and both were caught by reading the file. build.rs writes the column names out a second way, as tables.columns.json, and six tests assert that each table's row builder and its header in index.html emit exactly those columns in exactly that order. The tables are one list in build.rs now, read twice, so the CSS and the JSON cannot describe different tables. Checked against the bugs it is for: renaming a column in the description fails the two tests for that table, and inserting one that no cell fills fails them too, while the table next to it stays green. The events row builder had to become loadable in the harness, which cost a touch-flag stub. It renders from data at index 0 and takes neither the date-grouping path nor the scroller, so it is a pure string call like the task one already was. The JSON is generated, so it is gitignored beside tables.css, and a missing one is a fatal error rather than a skipped suite: a drift gate that stands down on an unbuilt tree is the gate not existing.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-30 16:05 UTC
Signed with PGP, not checked
Commit: 86f88e7d785a63bd7b71304902618d170bb1233b
Parent: df44f84
3 files changed, +138 insertions, -25 deletions
M .gitignore +3
@@ -53,3 +53,6 @@
53 53 /src-tauri/frontend/css/geometry.css
54 54 /src-tauri/frontend/css/layout.css
55 55 /src-tauri/frontend/css/tables.css
56 +
57 + # Generated by src-tauri/build.rs from the column descriptions
58 + /src-tauri/frontend/tables.columns.json
M src-tauri/build.rs +69 -25
@@ -1,3 +1,4 @@
1 + use std::fmt::Write as _;
1 2 use std::fs;
2 3 use std::path::Path;
3 4
@@ -131,6 +132,63 @@
131 132 },
132 133 ];
133 134
135 + /// The tables: `(name, header class, selector, columns)`.
136 + ///
137 + /// One list, read twice. [`table_css`] emits the rules and
138 + /// [`table_columns_json`] hands the same order to the frontend's tests, which
139 + /// is what lets them assert that a row's cells are the columns the description
140 + /// names.
141 + ///
142 + /// The two event selectors carry a modifier rather than a class of their own,
143 + /// so everything the two tables genuinely share -- padding, hover, the row
144 + /// border -- stays on `.event-row-virtual` and only the grid splits.
145 + const TABLES: &[(&str, &str, &str, &[Column<'static>])] = &[
146 + (
147 + "task",
148 + "task-header-row",
149 + ".task-header-row, .task-row",
150 + TASK_COLUMNS,
151 + ),
152 + (
153 + "upcoming",
154 + "event-header-upcoming",
155 + ".event-header-row.event-header-upcoming, .event-row-virtual.event-upcoming",
156 + UPCOMING_COLUMNS,
157 + ),
158 + (
159 + "recurring",
160 + "event-header-recurring",
161 + ".event-header-row.event-header-recurring, .event-row-virtual.event-recurring",
162 + RECURRING_COLUMNS,
163 + ),
164 + ];
165 +
166 + /// The column names, for a reader that is not a stylesheet.
167 + ///
168 + /// The CSS carries the names of the columns it *hides* and nothing else, so a
169 + /// cell whose `col-` class matches no column is invisible to it: the narrowing
170 + /// rules simply never match, and the cell stays on a narrow screen with nothing
171 + /// said. That is how the events table came to render six cells into five
172 + /// tracks. This file is the description in a form the frontend can check itself
173 + /// against.
174 + fn table_columns_json() -> String {
175 + let mut json = String::from("{\n");
176 + for (i, (table, header, _, columns)) in TABLES.iter().enumerate() {
177 + let names = columns
178 + .iter()
179 + .map(|c| format!("\"{}\"", c.name))
180 + .collect::<Vec<_>>()
181 + .join(", ");
182 + let _ = writeln!(
183 + json,
184 + " \"{table}\": {{ \"header\": \"{header}\", \"columns\": [{names}] }}{}",
185 + if i + 1 == TABLES.len() { "" } else { "," }
186 + );
187 + }
188 + json.push_str("}\n");
189 + json
190 + }
191 +
134 192 /// Generate the tables' column CSS.
135 193 ///
136 194 /// Both halves of narrowing come out of one call per breakpoint: the track list
@@ -218,29 +276,13 @@
218 276 silently disappears. */\n",
219 277 );
220 278
221 - // The two event selectors carry a modifier rather than a class of their
222 - // own, so everything the two tables genuinely share -- padding, hover, the
223 - // row border -- stays on .event-row-virtual and only the grid splits.
224 - for (selector, columns, wide, narrow) in [
225 - (
226 - ".task-header-row, .task-row",
227 - TASK_COLUMNS,
228 - &task_wide,
229 - &task_narrow,
230 - ),
231 - (
232 - ".event-header-row.event-header-upcoming, .event-row-virtual.event-upcoming",
233 - UPCOMING_COLUMNS,
234 - &upcoming_wide,
235 - &upcoming_narrow,
236 - ),
237 - (
238 - ".event-header-row.event-header-recurring, .event-row-virtual.event-recurring",
239 - RECURRING_COLUMNS,
240 - &recurring_wide,
241 - &recurring_narrow,
242 - ),
243 - ] {
279 + // Zipped rather than carried in TABLES: a length is a CSS answer and the
280 + // description deliberately holds none, which is the split Sizing exists for.
281 + for ((_, _, selector, columns), (wide, narrow)) in TABLES.iter().zip([
282 + (&task_wide, &task_narrow),
283 + (&upcoming_wide, &upcoming_narrow),
284 + (&recurring_wide, &recurring_narrow),
285 + ]) {
244 286 css.push('\n');
245 287 css.push_str(&narrowing_css(
246 288 columns,
@@ -299,8 +341,10 @@
299 341
300 342 // The columns are this app's, so the shared helper cannot know them, but
301 343 // what is done with them comes from the description all the same.
302 - let tables = Path::new(env!("CARGO_MANIFEST_DIR")).join("frontend/css/tables.css");
303 - fs::write(&tables, table_css()).expect("write tables.css");
344 + let frontend = Path::new(env!("CARGO_MANIFEST_DIR")).join("frontend");
345 + fs::write(frontend.join("css/tables.css"), table_css()).expect("write tables.css");
346 + fs::write(frontend.join("tables.columns.json"), table_columns_json())
347 + .expect("write tables.columns.json");
304 348 println!("cargo:rerun-if-changed=build.rs");
305 349
306 350 tauri_build::build();
@@ -106,6 +106,12 @@
106 106 require('../time-tracking'); // GoingsOn.timeTracking (fmtElapsed, used by the row renderer)
107 107 require('../tasks-render'); // GoingsOn.tasksRender (task row markup)
108 108
109 + // events builds row markup too. It reads the touch flag and the event lists off
110 + // the namespace when it groups rows by date, and index 0 with no touch takes
111 + // neither path, so a row render is a pure string call here as well.
112 + GoingsOn.touch = GoingsOn.touch || { isTouchDevice: false };
113 + require('../events'); // GoingsOn.events.renderEventRow (event row markup)
114 +
109 115 // Test: AppStateManager / GoingsOn.state
110 116
111 117 describe('GoingsOn.state', () => {
@@ -911,6 +917,66 @@
911 917 });
912 918 });
913 919
920 + // Test: the tables' cells are the columns their descriptions name
921 +
922 + // A column description in src-tauri/build.rs reaches the stylesheet as tracks
923 + // and hiding rules, and neither of them says which cells a row should emit. So
924 + // a cell naming a column that does not exist is invisible to the CSS: its
925 + // hiding rule never matches, it survives a narrow screen, and nothing says so.
926 + // That is the shape the events table failed in -- six cells rendered into five
927 + // tracks, every one of them a column left of its own label -- and it was caught
928 + // by reading, twice. tables.columns.json is the same description in a form
929 + // these tests can read.
930 + describe('table cells match the column description', () => {
931 + const generated = path.join(__dirname, '../../tables.columns.json');
932 + if (!fs.existsSync(generated)) {
933 + // Deliberately fatal rather than skipped. A drift gate that quietly
934 + // stands down on a tree nobody has built yet is the gate not existing.
935 + throw new Error('src-tauri/frontend/tables.columns.json is missing. build.rs writes it: build src-tauri first.');
936 + }
937 + const spec = JSON.parse(fs.readFileSync(generated, 'utf8'));
938 + const indexHtml = fs.readFileSync(path.join(__dirname, '../../index.html'), 'utf8');
939 +
940 + // The col- classes in some markup, in the order they appear.
941 + function columnsOf(html) {
942 + return [...html.matchAll(/class="[^"]*\bcol-([a-z]+)\b/g)].map(m => m[1]);
943 + }
944 +
945 + // A header row's markup: from its own class to the list container below it.
946 + function headerMarkup(headerClass) {
947 + const start = indexHtml.indexOf(headerClass);
948 + assert(start !== -1, `no .${headerClass} in index.html`);
949 + const end = indexHtml.indexOf('list-container', start);
950 + assert(end !== -1, `no list container after .${headerClass}`);
951 + return indexHtml.slice(start, end);
952 + }
953 +
954 + const task = {
955 + id: 't1', title: 'A task', status: 'Pending', priority: 'High',
956 + dueFormatted: 'Today', projectName: 'A project', subtaskCount: 0,
957 + };
958 + const event = {
959 + id: 'e1', title: 'An event', startTime: new Date(0).toISOString(),
960 + timeFormatted: '09:00', location: 'Here',
961 + };
962 +
963 + const rows = {
964 + task: () => GoingsOn.tasksRender.renderTaskRow(task, 0),
965 + upcoming: () => GoingsOn.events.renderEventRow(event, 0, false, false),
966 + recurring: () => GoingsOn.events.renderEventRow(event, 0, false, true),
967 + };
968 +
969 + for (const [table, { header, columns }] of Object.entries(spec)) {
970 + test(`${table} row emits every column, in order`, () => {
971 + assertDeepEqual(columnsOf(rows[table]()), columns);
972 + });
973 +
974 + test(`${table} header emits every column, in order`, () => {
975 + assertDeepEqual(columnsOf(headerMarkup(header)), columns);
976 + });
977 + }
978 + });
979 +
914 980 // Report
915 981
916 982 const success = report();