Measure the time between two events by long-pressing them

"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.
This commit is contained in:
Alexander Heldt
2026-09-21 20:53:56 +00:00
parent e4a5c3fe29
commit 556e4d75a8
7 changed files with 344 additions and 7 deletions
+12
View File
@@ -70,6 +70,18 @@ of them, because logging has to be one tap from wherever you are.
- The bar can still wrap, and does below about 300px. Its height changes when
it does and the tab bar sticks to that height, which is why `--day-bar-h` is
kept current by a `ResizeObserver` rather than measured once.
- **Long-press two event rows to measure between them.** "How long after eating
did he poo?" is answerable from the log, but only by reading two times off the
screen and subtracting — and the pair is often on different days, so it is
rarely on screen together. A bar along the bottom holds the gap until you
clear it, so changing day mid-measurement is fine. Any row that is one event
at one moment can be picked: history, notes, weigh-ins. Sleep and walk rows
cannot, being spans rather than moments. A third pick is refused while two are
held; pressing a picked row unpicks it. The picks live in a variable rather
than `localStorage` — a measurement is a question you are asking now, not a
setting — but being module-level is what carries them through the re-render a
background sync causes every minute. Long-press has no keyboard equivalent, so
this is touch and mouse only.
- Each tab is a `.tab-panel` wrapper around the existing sections. The
**wrapper** is what gets hidden, never the sections: `walk-timeline` and
`walk-trend` carry their own `hidden`, set by `renderWalkPatterns` once a walk
+6 -3
View File
@@ -78,8 +78,10 @@ function declaration(src, masked, name) {
* 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)
* 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
*/
@@ -90,9 +92,10 @@ export function load({ names, lets = [], stubs = {} }) {
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} } };
return { ${exported.join(", ")}, set: { ${setters} }, get: { ${getters} } };
`);
return factory(...stubNames.map(n => stubs[n]));
}
+98
View File
@@ -0,0 +1,98 @@
// Long-press two rows and the app subtracts their times. The press itself
// needs a finger, but everything it decides — which picks are held, what the
// bar says — is ordinary logic, and that is where this can go quietly wrong.
import { load } from "./extract.mjs";
import { suite, eq, ok, report } from "./assert.mjs";
let rendered = 0;
const app = load({
names: [
"EVENT_LABELS", "ymd", "formatDuration", "formatTime",
"measurePick", "toggleMeasurePick", "clearMeasure",
"measureSummary", "measureLabel",
],
lets: ["measurePick"],
stubs: { render: () => { rendered++; } },
});
const at = (day, hour, min = 0) => new Date(2026, 8, day, hour, min).getTime();
const ate = { id: "a", type: "eat", at: at(20, 12, 10) };
const poo = { id: "b", type: "poo", at: at(20, 15, 52) };
const pee = { id: "c", type: "pee", at: at(20, 18, 30) };
const lateEat = { id: "d", type: "eat", at: at(19, 18, 30) }; // the evening before
const events = [ate, poo, pee, lateEat];
const pick = (...ids) => { app.set.measurePick([]); ids.forEach(app.toggleMeasurePick); };
const held = () => app.get.measurePick();
suite("what a press does to the pick");
{
pick("a");
eq(held(), ["a"], "one press holds one");
pick("a", "b");
eq(held(), ["a", "b"], "a second press holds the pair");
// The user's choice: a third is refused rather than rolling the pair on.
pick("a", "b", "c");
eq(held(), ["a", "b"], "a third press is ignored while two are held");
// Not a third selection but an undo of one — a mis-press costs one press
// rather than starting over.
pick("a", "b");
app.toggleMeasurePick("a");
eq(held(), ["b"], "pressing a picked row unpicks it");
app.toggleMeasurePick("c");
eq(held(), ["b", "c"], "…leaving room for a different second");
pick("a", "b");
app.clearMeasure();
eq(held(), [], "clearing drops both");
}
suite("the reading");
{
const two = app.measureSummary(["a", "b"], events);
ok(two.show && two.complete, "two picks give a complete reading");
eq(two.duration, "3h 42m", "12:10 to 15:52 is 3h 42m");
// Pressed newest-first, which is the natural way to scan a log upward.
const reversed = app.measureSummary(["b", "a"], events);
eq(reversed.duration, "3h 42m", "the order they were pressed in doesn't change the gap");
eq(reversed.text, two.text, "…and it still reads chronologically, earliest first");
ok(/Ate/.test(two.text) && /Poo/.test(two.text), "both events are named");
}
suite("a pair that straddles midnight");
{
const overnight = app.measureSummary(["d", "b"], events); // 19th 18:30 → 20th 15:52
eq(overnight.duration, "21h 22m", "the gap crosses the day boundary correctly");
ok(/Sep/.test(overnight.text),
"the dates are named, since two bare times would be ambiguous across days");
ok(!/Sep/.test(app.measureSummary(["a", "b"], events).text),
"…but a same-day pair stays uncluttered");
}
suite("an incomplete or stale pick");
{
const one = app.measureSummary(["a"], events);
ok(one.show && !one.complete, "one pick shows the bar without a duration");
ok(/long-press another/.test(one.text), "…and asks for the second");
eq(app.measureSummary([], events).show, false, "nothing picked hides the bar");
// Deleted here, or tombstoned by another device mid-measurement.
const stale = app.measureSummary(["a", "gone"], events);
eq(stale.ids, ["a"], "an id that no longer resolves is dropped from the pick");
ok(!stale.complete, "…so what is left is one pick, not a broken pair");
eq(app.measureSummary(["gone", "also-gone"], events).show, false,
"both gone hides the bar rather than showing an empty one");
}
suite("the label");
{
eq(app.measureLabel(ate), `Ate ${app.formatTime(ate.at)}`, "type and time");
ok(/Sep 20/.test(app.measureLabel(ate, true)), "with the date when asked for");
}
export default report("measure");
+151 -3
View File
@@ -1166,6 +1166,136 @@
return rails;
}
// ---------- measuring between two events ----------
// "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 two are
// often on different days, so they are rarely on screen together. Long-press
// one row, long-press another, and a bar along the bottom does the
// subtraction and holds it until cleared.
//
// The picks live in a module-level variable rather than localStorage: a
// measurement is a question you are asking right now, not a setting. Being
// module-level is what carries it across the re-render a background sync
// causes every minute, which would otherwise wipe a half-made measurement —
// the same reason hourCellSel is held this way.
let measurePick = []; // up to two event ids, in the order they were picked
let measureBarResized = null; // set once the bar is wired; republishes its height
// Adds, removes, or refuses. Pressing a row that is already picked unpicks
// it, so a mis-press costs one press rather than a clear; a third *new* event
// is ignored while two are held, which is what was asked for.
function toggleMeasurePick(id) {
const at = measurePick.indexOf(id);
if (at !== -1) measurePick.splice(at, 1);
else if (measurePick.length < 2) measurePick.push(id);
else return; // two already held — clear first
render();
}
function clearMeasure() {
if (measurePick.length === 0) return;
measurePick = [];
render();
}
// What the bar should say. Pure, so the arithmetic and the wording can be
// checked without a DOM — which is most of the risk in this feature.
//
// `events` is the live list; an id that no longer resolves has been deleted
// here or tombstoned by another device, and is dropped rather than left
// showing as half a measurement.
function measureSummary(ids, events) {
const byId = new Map(events.map(e => [e.id, e]));
const picked = ids.map(id => byId.get(id)).filter(Boolean);
if (picked.length === 0) return { show: false, ids: [] };
const kept = picked.map(e => e.id);
if (picked.length === 1) {
return {
show: true, ids: kept, complete: false,
text: `${measureLabel(picked[0])} picked — long-press another event to measure.`,
};
}
// Ordered by time rather than by which was pressed first, so the reading is
// always chronological and never negative.
const [a, b] = [...picked].sort((x, y) => x.at - y.at);
const spansDays = ymd(new Date(a.at)) !== ymd(new Date(b.at));
return {
show: true, ids: kept, complete: true,
duration: formatDuration(b.at - a.at),
text: `${measureLabel(a, spansDays)}${measureLabel(b, spansDays)}`,
};
}
// An event in a few words: the time, plus the date when the pair straddles
// midnight and the time alone would be ambiguous.
function measureLabel(ev, withDate = false) {
const label = EVENT_LABELS[ev.type] || ev.type;
const when = withDate
? `${new Date(ev.at).toLocaleDateString(undefined, { month: "short", day: "numeric" })} ${formatTime(ev.at)}`
: formatTime(ev.at);
return `${label} ${when}`;
}
// Every row that is a single event at a single moment gets the same two
// gestures: tap to edit, long-press to pick it for measuring. Shared by the
// history log, the notes log and the weigh-in list so the three cannot drift
// apart, and so the picked highlight is rebuilt from measurePick on every
// render rather than being toggled in place.
const LONG_PRESS_MS = 450;
const PRESS_SLOP_PX = 10;
function attachRowHandlers(li, ev) {
if (measurePick.includes(ev.id)) li.classList.add("picked");
li.setAttribute("aria-pressed", String(measurePick.includes(ev.id)));
let timer = null, origin = null, fired = false;
const cancel = () => { clearTimeout(timer); timer = null; origin = null; };
li.addEventListener("pointerdown", (e) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
fired = false;
origin = { x: e.clientX, y: e.clientY };
timer = setTimeout(() => {
fired = true;
cancel();
toggleMeasurePick(ev.id);
}, LONG_PRESS_MS);
});
// A finger that travels is a scroll, not a press. Without this, dragging
// the list past a row picks it.
li.addEventListener("pointermove", (e) => {
if (!origin) return;
if (Math.hypot(e.clientX - origin.x, e.clientY - origin.y) > PRESS_SLOP_PX) cancel();
});
li.addEventListener("pointerup", cancel);
li.addEventListener("pointercancel", cancel);
// A press that fired would otherwise also open the edit dialog, and on
// touch would raise the platform's own long-press menu over the row.
li.addEventListener("contextmenu", (e) => { if (fired) e.preventDefault(); });
li.addEventListener("click", (e) => {
if (fired) { e.preventDefault(); e.stopPropagation(); fired = false; return; }
openEditDialog(ev);
});
}
function renderMeasureBar(events) {
const bar = document.getElementById("measure-bar");
if (!bar) return;
const summary = measureSummary(measurePick, events);
// Drop ids that no longer resolve, so the pick and what is on screen agree.
if (summary.ids.length !== measurePick.length) measurePick = summary.ids;
bar.hidden = !summary.show;
if (summary.show) {
document.getElementById("measure-duration").textContent = summary.complete ? summary.duration : "";
document.getElementById("measure-detail").textContent = summary.text;
}
// Hidden→shown doesn't trip a ResizeObserver, so the snackbar's offset is
// republished here as well.
if (measureBarResized) measureBarResized();
}
function renderHistory(events) {
const day = selectedDay();
// The "not counted" mark is bookkeeping about the day, not something that
@@ -1215,7 +1345,7 @@
} else {
noteEl.textContent = ev.note || "";
}
li.addEventListener("click", () => openEditDialog(ev));
attachRowHandlers(li, ev);
for (const pid of photoIdsOf(ev)) {
const img = document.createElement("img");
@@ -1262,7 +1392,7 @@
<span class="note-text"></span>
`;
li.querySelector(".note-text").textContent = ev.note || "";
li.addEventListener("click", () => openEditDialog(ev));
attachRowHandlers(li, ev);
for (const pid of photoIdsOf(ev)) {
const img = document.createElement("img");
@@ -2591,7 +2721,7 @@
val.textContent = formatWeight(w.weight);
li.appendChild(date);
li.appendChild(val);
li.addEventListener("click", () => openEditDialog(w));
attachRowHandlers(li, w);
list.appendChild(li);
}
@@ -2927,6 +3057,9 @@
// logger distorts, so they count everywhere regardless.
renderWeight(events);
renderNotes(events);
// After the lists, so a pick whose event has gone is dropped in the same
// pass that stops drawing it as picked.
renderMeasureBar(events);
// These take the whole list even though they aggregate, because each
// already knows about marked days and does something more precise with
// them than dropping their events would:
@@ -5188,6 +5321,21 @@
toggleExcludedDay(selectedDay()); // addEvent/deleteEvent re-render for us
});
document.getElementById("measure-clear").addEventListener("click", clearMeasure);
// The snackbar sits above the measure bar when both are up, which means it
// needs that bar's height — measured, because the text wraps differently
// depending on the pair. Same arrangement as --day-bar-h and the tab bar.
{
const bar = document.getElementById("measure-bar");
const publish = () => document.documentElement.style.setProperty(
"--measure-bar-h", bar.hidden ? "0px" : `${bar.offsetHeight + 8}px`);
if (typeof ResizeObserver === "function") new ResizeObserver(publish).observe(bar);
// A ResizeObserver doesn't fire on hidden→shown, so publish on render too.
measureBarResized = publish;
publish();
}
// Clicking the status pill forces an immediate sync.
statusEl.style.cursor = "pointer";
statusEl.title = "Click to sync now";
+1
View File
@@ -1,4 +1,5 @@
[
{ "date": "2026-09-21", "text": "You can measure the time between two events. Press and hold one row, press and hold another, and a bar along the bottom shows the gap — “3h 42m · Ate 12:10 → Poo 15:52” — which answers things like how long after a meal he needs to go out. It stays there until you clear it with the ✕, so you can change day in between and pick the second event from another day; when the pair straddles midnight the bar shows the dates too. It works on any row that is a single moment: the history log, the notes log and weigh-ins. Holding a row you already picked unpicks it, and a third pick is ignored until you clear. Tapping a row still opens it for editing as before. One cost: because holding a row now means something, you can no longer select the text of a note to copy it" },
{ "date": "2026-09-21", "text": "A day marked “not counted” no longer appears in the Sleep trend or the Walk trend. It was already left out of the average and out of the “yesterday” comparison, but the day you were actually looking at was still drawn as the boldest line on the chart — so the one day you had said not to trust was the one the panel led with. Now it is left off and its legend chip goes with it, leaving the average and yesterday, which is what you would want to see on a day like that" },
{ "date": "2026-09-21", "text": "The Food (grams) chart has a trend line through it now, so you can see whether he is eating more as he grows — the daily bars bounce around enough to hide a steady climb. A line under the chart says what it amounts to in figures: “daily intake is up about 40 g a week — roughly 280 g a day then, 400 g a day now”. When the day-to-day variation is bigger than any trend, which is most of the time over a short window, it says so and gives the average instead — that is a real measurement, where the ends of the line would only be the line's own guess. Today is left out of the line, since the day isn't finished and including it would drag the line down every morning; days marked “not counted” are skipped too. The line follows the 7 / 14 / 30 day picker like the rest of the charts, and the sentence names the window so you can see it change when you switch. If there aren't four complete days to fit it says so rather than leaving you with an empty chart, and if some meals have no amount recorded it says how many, because those days read lower than they really were" },
{ "date": "2026-09-20", "text": "Fixed the page being wider than the screen on a phone, which is why it had started letting you zoom out. The month grid behind the date was the main culprit: it was centred on the date button, which sits near the right edge, so part of the panel hung off the side of the screen. It is anchored to the edge of the bar now and stays on screen at any width. Also fixed a long unbroken word — a link, or something copied off a food bag — in a history note, an exercise name or its instructions pushing its row wider than the screen instead of wrapping" },
+10
View File
@@ -695,6 +695,16 @@
</dialog>
<!-- Brief confirmation after a one-tap quick log, with Undo / Add note. -->
<!-- Long-press two event rows and this holds the time between them until
you clear it — so you can change day in between and still be measuring.
Fixed at the bottom like the snackbar, and stays put where that one
fades; the snackbar lifts above it when both are on screen. -->
<div id="measure-bar" class="measure-bar" hidden role="status" aria-live="polite">
<span id="measure-duration" class="measure-duration"></span>
<span id="measure-detail" class="measure-detail"></span>
<button type="button" id="measure-clear" class="measure-clear" aria-label="Clear the measurement"></button>
</div>
<div id="snackbar" class="snackbar" hidden role="status" aria-live="polite">
<span id="snackbar-msg" class="snackbar-msg"></span>
<button type="button" id="snackbar-note" class="snackbar-action">Add note</button>
+66 -1
View File
@@ -1331,10 +1331,75 @@ input.switch:checked::after { transform: translateX(18px); }
.update-banner-btn:hover { filter: brightness(0.97); }
/* ---------- quick-log snackbar ---------- */
.snackbar {
/* ---------- measuring between two events ---------- */
/* Fixed at the bottom, near the thumb, and it stays until cleared the
measurement is the answer to a question you asked, not a notification. */
.measure-bar {
position: fixed;
left: 50%;
bottom: calc(16px + env(safe-area-inset-bottom, 0));
transform: translateX(-50%);
z-index: 59; /* just under the snackbar, which lifts above it */
display: flex;
align-items: center;
gap: 10px;
width: max-content;
max-width: calc(100% - 32px);
padding: 8px 8px 8px 14px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--accent);
border-radius: 999px;
box-shadow: var(--shadow);
}
.measure-duration:empty { display: none; }
.measure-duration {
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--accent);
flex: none;
}
/* The pair can be long ("Ate Sep 19 18:30 → Poo Sep 20 07:10"), and the
duration and the clear button are what must never be squeezed out. */
.measure-detail {
font-size: 0.8rem;
color: var(--muted);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
button.measure-clear {
flex: none;
background: transparent;
color: var(--muted);
padding: 4px 8px;
font-size: 1rem;
line-height: 1;
}
/* A picked row. The accent ring rather than a fill, so the row's own type
colour (its dot and any rail) still reads underneath. */
.event.picked {
box-shadow: inset 0 0 0 2px var(--accent);
background: var(--accent-soft);
}
/* Long-press means "pick this" on these rows, so the platform's own
long-press behaviour has to get out of the way: iOS would otherwise raise
the text-selection callout over the row mid-press. The cost is that note
text on a row can no longer be selected to copy. */
.event {
-webkit-touch-callout: none;
user-select: none;
}
.snackbar {
position: fixed;
left: 50%;
/* Above the measure bar when one is up, so the two never overlap. Its height
is published by a ResizeObserver, the same trick --day-bar-h uses. */
bottom: calc(16px + env(safe-area-inset-bottom, 0) + var(--measure-bar-h, 0px));
transform: translate(-50%, 12px);
z-index: 60;
display: flex;