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
+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";