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:
+151
-3
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user