"How long after eating did he poo?" is answerable from the log, but only by reading two times off the screen and subtracting them — and the pair is often on different days, so it is rarely on screen together at all. Hold one row, hold another, and a bar along the bottom does the subtraction and keeps it until you clear it, which is what lets you change day between the two picks. Any row that is a single moment can be picked: history, notes, weigh-ins. Sleep and walk rows cannot, being spans — measuring from one would need a rule about which end, and a rule you have to remember is worse than the feature. The picks are a module-level variable rather than storage. A measurement is a question you are asking now, not a setting; but module-level is also what carries it through the re-render a background sync causes every minute, which would otherwise wipe a half-made measurement. Ids that stop resolving — deleted here, tombstoned by another device — leave the pick on the next render instead of lingering as half a pair. Two additions beyond what was asked. Holding a picked row unpicks it: that is not a third selection but an undo of one, and without it a mis-press costs a clear. And the reading is ordered by time rather than by which was pressed first, so it is always chronological and never negative — pressing upward through a log is the natural way to read it. The press mechanics are all load-bearing: a finger that travels is a scroll and cancels, a fired press swallows the click that would otherwise also open the edit dialog, and the platform's own long-press menu is suppressed. That last part needs user-select: none on the rows, which costs the ability to select a note's text to copy. Worth stating plainly — it is a real loss, taken because holding a row now means something else. checks/extract.mjs gained getters for mutable bindings while writing the checks for this. It only ever returned a let's value at load time, so measurePick went stale the moment the code reassigned it and the checks were quietly asserting against a snapshot. Any future check reading a mutable binding would have hit the same thing.
104 lines
4.3 KiB
JavaScript
104 lines
4.3 KiB
JavaScript
// 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 reach. Each gets a setter
|
|
* and a getter: the plain export is the value at load time, so a binding
|
|
* the code reassigns (rather than mutates) would go stale and a check
|
|
* would quietly assert against a snapshot.
|
|
* 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 getters = lets.map(n => `${n}: () => ${n}`).join(", ");
|
|
const factory = new Function(...stubNames, `
|
|
${body}
|
|
return { ${exported.join(", ")}, set: { ${setters} }, get: { ${getters} } };
|
|
`);
|
|
return factory(...stubNames.map(n => stubs[n]));
|
|
}
|
|
|
|
export const appSource = () => readFileSync(SRC, "utf8");
|