Add frontend checks, run from the repo

The server has go test; the frontend had nothing, and the things most likely to
break there are the ones hardest to see: the arithmetic behind the charts, a
panel lost while shuffling tabs, a label that truncates on a phone none of us
owns. There is no browser in this loop, so these are what can be checked
without one.

They read the real code rather than copying it. The app is one long IIFE with
nothing exported, and adding a module system or a build step to make it
testable would be a large change in service of a small one — so
checks/extract.mjs reads src/app.js, brace-matches the declarations a check
asks for, and evaluates them. Rename a function and it throws by name. A check
quietly exercising a stale copy of the code would be worse than no check, and
that is the failure mode this avoids.

calendarGridStart is pulled out of renderCalendar as part of this. It is the
one line of the month grid that is easy to get wrong and impossible to notice
— a month starting on the week's first day needs no backing up, one starting
the day before needs six — so it earns a name and a test.

The width figures are estimates, not measurements: layout numbers come out of
style.css so they cannot drift, text is sized from per-character advances, and
the pass mark demands a few pixels of headroom because the estimate is only
good to a few percent. They will catch a sixth tab or a longer label. They will
not settle a two-pixel question, and nothing here replaces looking at a phone.

No new dependencies: nodejs is already in the devShell for `node --check`, and
checks/ sits outside src/ so it is not served with the app.
This commit is contained in:
Alexander Heldt
2026-09-20 10:43:13 +00:00
parent 8d7139b031
commit 59cf567946
8 changed files with 551 additions and 2 deletions
+28
View File
@@ -0,0 +1,28 @@
// The smallest thing that will do. Checks print a line per assertion so a
// failure says what was expected of the code, not just which line threw.
let fails = 0;
let current = "";
export function suite(name) {
current = name;
console.log(`\n== ${name} ==`);
}
export function eq(got, want, what) {
const g = JSON.stringify(got), w = JSON.stringify(want);
if (g === w) { console.log(` ok ${what}`); return; }
console.log(` FAIL ${what}\n got ${g}\n want ${w}`);
fails++;
}
export function ok(cond, what) {
eq(Boolean(cond), true, what);
}
export function failed() { return fails; }
export function report(file) {
if (fails === 0) console.log(`\n${file}: all passed`);
else console.log(`\n${file}: ${fails} FAILED`);
return fails;
}
+62
View File
@@ -0,0 +1,62 @@
// The month grid behind the date button. Off-by-one week starts and month
// boundaries are how a hand-rolled calendar goes wrong, and none of it shows
// until somebody opens the month that breaks.
import { load } from "./extract.mjs";
import { suite, eq, report } from "./assert.mjs";
const app = load({ names: ["ymd", "calendarGridStart"] });
const MONDAY = 1, SUNDAY = 0;
const cells = (year, monthIdx, start) => {
const gs = app.calendarGridStart(new Date(year, monthIdx, 1), start);
return Array.from({ length: 42 }, (_, i) => {
const d = new Date(gs);
d.setDate(gs.getDate() + i);
return d;
});
};
suite("the grid starts on the locale's first weekday");
for (const [name, start] of [["Monday", MONDAY], ["Sunday", SUNDAY]]) {
const everyMonth = Array.from({ length: 12 }, (_, m) => cells(2026, m, start)[0].getDay());
eq(everyMonth.every(day => day === start), true,
`${name} start: all twelve months of 2026 open on a ${name}`);
}
suite("six rows, covering the month exactly once");
{
let alwaysSix = true, coversAll = true;
for (let m = 0; m < 12; m++) {
const cs = cells(2026, m, MONDAY);
if (cs.length !== 42) alwaysSix = false;
const inMonth = cs.filter(d => d.getMonth() === m).length;
if (inMonth !== new Date(2026, m + 1, 0).getDate()) coversAll = false;
}
eq(alwaysSix, true, "42 cells every month, so the panel never changes height");
eq(coversAll, true, "every day of every month appears exactly once");
}
suite("the months that catch people out");
{
// 1 Feb 2026 is a Sunday: under Monday weeks that needs six leading days from
// January, which is precisely what a naive `1 - getDay()` gets wrong.
const feb = cells(2026, 1, MONDAY);
eq(app.ymd(feb[0]), "2026-01-26", "Feb 2026 starts Sunday: the grid opens on 26 Jan");
eq(app.ymd(feb[6]), "2026-02-01", "…putting the 1st in the last column of row one");
eq(app.ymd(cells(2026, 1, SUNDAY)[0]), "2026-02-01",
"the same month under Sunday weeks needs no leading days at all");
eq(cells(2026, 7, MONDAY).filter(d => d.getMonth() === 7).length, 31,
"a 31-day month starting late in the week still fits");
eq(cells(2024, 1, MONDAY).filter(d => d.getMonth() === 1).length, 29, "Feb 2024 shows 29 days");
eq(cells(2026, 1, MONDAY).filter(d => d.getMonth() === 1).length, 28, "Feb 2026 shows 28");
}
suite("year boundaries");
{
eq(app.ymd(cells(2026, 0, MONDAY)[0]).startsWith("2025"), true,
"January's leading cells come from the previous December");
eq(app.ymd(cells(2026, 11, MONDAY)[41]).startsWith("2027"), true,
"December's trailing cells run into January");
}
export default report("calendar");
+106
View File
@@ -0,0 +1,106 @@
// A day marked "not counted" leaves the averages but stays on the record. The
// subtlety is that marking a day is not the same as deleting its events, and
// three panels break if you treat it that way — see the notes in render().
import { load } from "./extract.mjs";
import { suite, eq, report } from "./assert.mjs";
const app = load({
names: [
"ymd", "startOfDay", "EXCLUDED_TYPE", "ALWAYS_COUNTS", "excludedSet",
"excludedDays", "isExcluded", "countedEvents", "spansExcluded",
"pairWindows", "sleepWindows", "sleepMsInRange", "lastEventOfType",
"gapsBetween",
],
lets: ["excludedSet"],
// gapsBetween defaults its window to the chart picker; the checks pass it in.
stubs: { chartDays: () => 7 },
});
const H = 3600_000;
const at = (day, hour, min = 0) => new Date(2026, 8, day, hour, min).getTime();
const NOW = at(20, 12);
const mark = (day) => ({ id: `x${day}`, type: app.EXCLUDED_TYPE, at: at(day, 12) });
const marking = (events) => { app.set.excludedSet(app.excludedDays(events)); return events; };
suite("what a marked day takes out of the numbers");
{
const evs = marking([
{ id: "a", type: "pee", at: at(18, 9) },
{ id: "b", type: "pee", at: at(19, 9) },
{ id: "c", type: "pee", at: at(20, 9) },
mark(19),
]);
eq(app.gapsBetween(evs, "pee", 14), [],
"a gap reaching across a marked day is discarded rather than measured");
}
{
const evs = marking([
{ id: "d", type: "pee", at: at(18, 8) },
{ id: "e", type: "pee", at: at(18, 12) },
{ id: "f", type: "pee", at: at(20, 8) },
{ id: "g", type: "pee", at: at(20, 12) },
mark(19),
]);
eq(app.gapsBetween(evs, "pee", 14), [4 * H, 4 * H],
"…while the gaps either side of it survive");
}
{
const evs = marking([
{ id: "a", type: "pee", at: at(19, 9) },
{ id: "b", type: "pee", at: at(20, 9) },
{ id: "w", type: "weight", at: at(19, 10) },
{ id: "n", type: "note", at: at(19, 11) },
mark(19),
]);
// The marker sits on the day it marks, so it is filtered out with the rest;
// harmless, since excludedSet was read off the full list beforehand.
eq(app.countedEvents(evs).map(e => e.id).sort(), ["b", "n", "w"],
"behaviour on a marked day is dropped; a weigh-in and a note are not");
app.set.excludedSet(new Set());
eq(app.countedEvents(evs).length, evs.length, "nothing marked means nothing filtered");
}
suite("spansExcluded");
{
marking([mark(19)]);
eq(app.spansExcluded(at(19, 1), at(19, 5)), true, "wholly inside a marked day");
eq(app.spansExcluded(at(18, 23), at(20, 1)), true, "straddling one");
eq(app.spansExcluded(at(20, 1), at(20, 9)), false, "clear of one");
app.set.excludedSet(new Set());
eq(app.spansExcluded(at(18, 1), at(24, 1)), false, "nothing marked, so nothing spans");
}
// These three were live bugs: the panels were handed a list with the marked
// day's events removed, which answers a different question from the one each
// of them is asking.
suite("what a marked day must NOT take out");
{
const evs = marking([
{ id: "p1", type: "pee", at: at(19, 9) },
{ id: "p2", type: "pee", at: at(20, 10) }, // on the marked day
mark(20),
]);
const last = app.lastEventOfType(evs, "pee");
eq((NOW - last.at) / H, 2,
"Timing: 'since the last pee' is about now, so it finds the one on the marked day");
}
{
const evs = marking([
{ id: "s1", type: "sleep-start", at: at(20, 1) },
{ id: "s2", type: "sleep-end", at: at(20, 3) },
mark(20),
]);
eq(app.sleepMsInRange(evs, at(20, 0), NOW) / H, 2,
"Sleep trend: the curve for the day you are looking at shows its real hours");
}
{
const evs = marking([
{ id: "s1", type: "sleep-start", at: at(19, 23) }, // marked day
{ id: "s2", type: "sleep-end", at: at(20, 7) }, // the next, unmarked
mark(19),
]);
eq(app.sleepMsInRange(evs, at(20, 0), at(20, 24)) / H, 7,
"a nap crossing midnight still counts for the unmarked day it ends on");
}
export default report("excluded-days");
+100
View File
@@ -0,0 +1,100 @@
// Pulls named declarations out of src/app.js and evaluates them, so a check
// exercises the code that ships rather than a copy of it.
//
// The app is one long IIFE with nothing exported — it has no build step and no
// module system, and adding either to make it testable would be a large change
// in service of a small one. Reading the source back is the cheaper trade: the
// checks stay honest, and the app stays a file you can open in a browser.
//
// If a declaration is renamed or removed, load() throws by name. That is the
// point: a check that quietly tested a stale copy would be worse than no check.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "app.js");
// Blanks out comments and string bodies so brace counting can't be fooled by a
// `}` inside one. Positions are preserved, so offsets into the result are valid
// offsets into the original.
function mask(src) {
const out = src.split("");
let i = 0;
const blank = (from, to) => { for (let k = from; k < to; k++) if (out[k] !== "\n") out[k] = " "; };
while (i < src.length) {
const c = src[i], next = src[i + 1];
if (c === "/" && next === "/") {
const end = src.indexOf("\n", i); const stop = end === -1 ? src.length : end;
blank(i, stop); i = stop; continue;
}
if (c === "/" && next === "*") {
const end = src.indexOf("*/", i + 2); const stop = end === -1 ? src.length : end + 2;
blank(i, stop); i = stop; continue;
}
if (c === '"' || c === "'" || c === "`") {
let k = i + 1;
while (k < src.length) {
if (src[k] === "\\") { k += 2; continue; }
if (src[k] === c) break;
k++;
}
blank(i + 1, Math.min(k, src.length)); i = Math.min(k + 1, src.length); continue;
}
i++;
}
return out.join("");
}
// The source of one top-level declaration, brace-matched from its opening line.
function declaration(src, masked, name) {
const patterns = [
new RegExp(`^ {2}(?:async )?function ${name}\\b`, "m"),
new RegExp(`^ {2}(?:const|let) ${name}\\b`, "m"),
];
for (const re of patterns) {
const m = re.exec(masked);
if (!m) continue;
const start = m.index;
// A function runs to its matching close brace; a const/let to the newline
// after the statement that balances its own brackets.
let depth = 0, seen = false, i = start;
for (; i < masked.length; i++) {
const ch = masked[i];
if (ch === "{" || ch === "(" || ch === "[") { depth++; seen = true; }
else if (ch === "}" || ch === ")" || ch === "]") {
depth--;
if (depth === 0 && seen && ch === "}" && /function/.test(m[0])) return src.slice(start, i + 1);
} else if (ch === ";" && depth === 0 && !/function/.test(m[0])) {
return src.slice(start, i + 1);
}
}
}
throw new Error(
`checks/extract: could not find "${name}" in src/app.js.\n` +
`It was probably renamed or removed — update the check that asks for it.`);
}
/**
* load({ names, lets, stubs }) → { ...declarations, set: { <let>: fn } }
*
* names declarations to pull across, in dependency order
* lets of those, the mutable ones a check needs to assign (a setter is
* generated for each, since a check can't reach the binding otherwise)
* stubs names the extracted code calls but which are not worth extracting —
* DOM lookups, chartDays(), and so on
*/
export function load({ names, lets = [], stubs = {} }) {
const src = readFileSync(SRC, "utf8");
const masked = mask(src);
const body = names.map(n => declaration(src, masked, n)).join("\n\n");
const stubNames = Object.keys(stubs);
const exported = names.map(n => n.replace(/^.*\s/, ""));
const setters = lets.map(n => `${n}: (v) => { ${n} = v; }`).join(", ");
const factory = new Function(...stubNames, `
${body}
return { ${exported.join(", ")}, set: { ${setters} } };
`);
return factory(...stubNames.map(n => stubs[n]));
}
export const appSource = () => readFileSync(SRC, "utf8");
+152
View File
@@ -0,0 +1,152 @@
// Structural facts about the markup and stylesheet that no amount of reading
// the diff reliably catches: a panel lost while shuffling tabs, a comment half
// removed, a label that truncates on a phone nobody here owns.
//
// The width arithmetic is an estimate, not a measurement — there is no browser
// in this loop. Layout numbers are read out of style.css so they cannot drift
// from the real ones, text is sized from per-character advances, and the pass
// mark insists on a few pixels of headroom because the estimate is only good
// to a few percent. It will catch a sixth tab or a longer label; it will not
// settle a two-pixel question.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { suite, eq, ok, report } from "./assert.mjs";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const html = readFileSync(join(root, "src", "index.html"), "utf8");
const css = readFileSync(join(root, "src", "style.css"), "utf8");
// ---------------------------------------------------------------- stylesheet
suite("the stylesheet is structurally intact");
{
let depth = 0, pairs = 0, stray = 0;
for (let i = 0; i < css.length - 1; i++) {
if (css[i] === "/" && css[i + 1] === "*") { depth++; pairs++; i++; }
else if (css[i] === "*" && css[i + 1] === "/") { depth--; if (depth < 0) stray++; i++; }
}
eq(depth, 0, `comments open and close in pairs (${pairs})`);
eq(stray, 0, "no stray comment terminators");
const bare = css.replace(/\/\*[\s\S]*?\*\//g, "");
eq((bare.match(/\{/g) || []).length, (bare.match(/\}/g) || []).length, "braces balance");
eq(/\*\//.test(bare), false, "no orphaned comment tails left by an edit");
// Author rules that set a display beat the user agent's [hidden] rule, so
// hidden elements keep rendering without this. It has caught five so far.
ok(/^\[hidden\] \{ display: none !important; \}$/m.test(css),
"the global [hidden] rule is present");
}
// ------------------------------------------------------------------ the tabs
suite("every panel lives in exactly one tab");
{
const EXPECTED = [
"overview", "sleep-wake", "history",
"sleep-daily", "sleep-timeline", "sleep-trend",
"walks", "walk-timeline", "walk-trend",
"timing", "counts",
"weight", "training", "notes",
];
const main = html.slice(html.indexOf(" <main>"), html.indexOf(" </main>"));
let current = null;
const placement = new Map();
const wrappers = [];
for (const line of main.split("\n")) {
const open = line.match(/^ {6}<div class="tab-panel" data-tab="([^"]+)"/);
if (open) { current = open[1]; wrappers.push(current); continue; }
if (/^ {6}<\/div>/.test(line) && current) { current = null; continue; }
const panel = line.match(/data-panel="([^"]+)"/);
if (panel && /^ {8}<section/.test(line)) {
if (!placement.has(panel[1])) placement.set(panel[1], []);
placement.get(panel[1]).push(current);
}
}
const missing = EXPECTED.filter(k => !placement.has(k));
const duplicated = EXPECTED.filter(k => (placement.get(k) || []).length > 1);
const loose = EXPECTED.filter(k => (placement.get(k) || [null])[0] === null);
const unexpected = [...placement.keys()].filter(k => !EXPECTED.includes(k));
eq(missing, [], "no panel has gone missing");
eq(duplicated, [], "no panel appears twice");
eq(loose, [], "no panel sits outside a tab");
eq(unexpected, [], "no unaccounted-for panel has appeared");
const buttons = [...html.matchAll(/class="tab" role="tab" data-tab="([^"]+)"/g)].map(m => m[1]);
eq(buttons, wrappers, "a button for each tab, in the same order");
const ariaOk = buttons.every(t =>
html.includes(`id="tab-${t}" aria-controls="tabpanel-${t}"`) &&
html.includes(`id="tabpanel-${t}" role="tabpanel" aria-labelledby="tab-${t}"`));
ok(ariaOk, "aria-controls and aria-labelledby paired on every tab");
// renderWalkPatterns hides these two by hand, and reaches for them by tag.
for (const k of ["walk-timeline", "walk-trend"]) {
ok(new RegExp(`<section[^>]*data-panel="${k}"`).test(html),
`"${k}" is still a <section>, which renderWalkPatterns queries for`);
}
}
// ----------------------------------------------------------------- the widths
const ADV = { cap: 0.72, lower: 0.52, digit: 0.6, thin: 0.28 };
const NARROW = { i: 0.26, l: 0.26, t: 0.35, j: 0.26, f: 0.32, r: 0.37 };
const em = (s) => [...s].reduce((n, ch) => {
if (ch >= "0" && ch <= "9") return n + ADV.digit;
if (ch === ":" || ch === " ") return n + ADV.thin;
if (ch >= "A" && ch <= "Z") return n + ADV.cap;
if (ch === "←" || ch === "→") return n + 0.6;
return n + (NARROW[ch] ?? ADV.lower);
}, 0);
const EMOJI = 18;
const BODY_GUTTER = 16;
const rule = (re) => (css.match(re) || [, ""])[1] || "";
const px = (block, prop, dflt) => {
const m = block.match(new RegExp(`(?:^|[;{\\s])${prop}:\\s*([\\d.]+)(rem|px)`));
return m ? (m[2] === "rem" ? parseFloat(m[1]) * 16 : parseFloat(m[1])) : dflt;
};
const DEVICES = [
["Galaxy Fold cover", 280], ["iPhone SE 1 / small Android", 320],
["Galaxy S / common Android", 360], ["iPhone SE 2-3", 375],
["iPhone 14 / Pixel", 393], ["iPhone 14 Plus", 428],
];
const narrowBlock = rule(/max-width: 370px\)\s*\{([\s\S]*?)\n\}/);
const inNarrow = (sel) => narrowBlock.match(new RegExp(`${sel} \\{[^}]*\\}`))?.[0] ?? "";
suite("the tab labels fit without truncating");
{
const labels = [...html.matchAll(/class="tab" role="tab"[^>]*>([^<]+)</g)].map(m => m[1]);
const wide = { gap: px(rule(/\n\.tabs \{([^}]*)\}/), "gap", 4), barPad: 4, tabPad: 4,
font: px(rule(/\n\.tabs \.tab \{([^}]*)\}/), "font-size", 13.6) };
const narrow = { gap: px(inNarrow("\\.tabs"), "gap", wide.gap),
barPad: px(inNarrow("\\.tabs"), "padding-left", wide.barPad),
tabPad: px(inNarrow("\\.tabs \\.tab"), "padding-left", wide.tabPad),
font: px(inNarrow("\\.tabs \\.tab"), "font-size", wide.font) };
const widest = labels.reduce((a, b) => (em(a) > em(b) ? a : b));
for (const [name, w] of DEVICES) {
const v = w <= 370 ? narrow : wide;
const room = (Math.min(w, 720) - 2 * BODY_GUTTER - 2 * v.barPad
- (labels.length - 1) * v.gap) / labels.length - 2 * v.tabPad;
const need = em(widest) * v.font;
ok(room - need >= 2, `${String(w).padStart(4)}px ${name}: "${widest}" fits ` +
`(${room.toFixed(0)}px of room, ${need.toFixed(0)}px needed)`);
}
}
suite("both day-bar timers fit on a row of their own");
{
// The bar may wrap when it must; what it must not do is squash the timers.
const pillCss = rule(/\n\.bar-clock \{([^}]*)\}/);
const wide = { font: px(pillCss, "font-size", 16), padX: 9, gap: px(pillCss, "gap", 4) };
const narrowPill = inNarrow("\\.bar-clock");
const narrow = { font: px(narrowPill, "font-size", wide.font),
padX: px(narrowPill, "padding", wide.padX), gap: px(narrowPill, "gap", wide.gap) };
const barPadX = 10, groupGap = 6;
const worst = "3:12:45"; // both timers past an hour
for (const [name, w] of DEVICES) {
const v = w <= 370 ? narrow : wide;
const pill = EMOJI + v.gap + em(worst) * v.font + 2 * v.padX;
const room = Math.min(w, 720) - 2 * BODY_GUTTER - 2 * barPadX;
ok(room - (2 * pill + groupGap) >= 2,
`${String(w).padStart(4)}px ${name}: two timers fit ` +
`(${room.toFixed(0)}px of room, ${(2 * pill + groupGap).toFixed(0)}px needed)`);
}
}
export default report("layout");
+53
View File
@@ -0,0 +1,53 @@
// Runs every check. Exits non-zero if any assertion failed.
//
// nix develop -c node checks/run.mjs
//
// These sit beside the Go tests rather than replacing them: the server has
// `go test`, and this is the frontend's half — the arithmetic behind the
// charts, the structure of the markup, and the width budgets that no one here
// can see. It needs no build step and no dependencies; node is already in the
// devShell for `node --check`.
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { readdirSync } from "node:fs";
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, "..");
let failed = 0;
// The frontend has no build step, so a syntax error would otherwise only show
// up in a browser nobody here is running.
for (const file of ["app.js", "sw.js"]) {
try {
execFileSync(process.execPath, ["--check", join(root, "src", file)], { stdio: "pipe" });
console.log(` ok src/${file} parses`);
} catch (e) {
console.log(` FAIL src/${file}\n${e.stderr?.toString() ?? e.message}`);
failed++;
}
}
try {
JSON.parse(readdirSync(join(root, "src")).includes("changelog.json")
? (await import("node:fs")).readFileSync(join(root, "src", "changelog.json"), "utf8")
: "[]");
console.log(" ok src/changelog.json parses");
} catch (e) {
console.log(` FAIL src/changelog.json: ${e.message}`);
failed++;
}
const suites = readdirSync(here)
.filter(f => f.endsWith(".mjs") && !["run.mjs", "extract.mjs", "assert.mjs"].includes(f))
.sort();
for (const file of suites) {
const mod = await import(join(here, file));
failed += mod.default ?? 0;
}
console.log(failed === 0
? `\nall checks passed (${suites.length} suites)`
: `\n${failed} assertion(s) FAILED`);
process.exit(failed === 0 ? 0 : 1);