Compare commits
3
Commits
668f1f039e
...
556e4d75a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
556e4d75a8 | ||
|
|
e4a5c3fe29 | ||
|
|
7451650b6f |
@@ -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
@@ -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]));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { load } from "./extract.mjs";
|
||||
import { suite, eq, ok, report } from "./assert.mjs";
|
||||
|
||||
const app = load({ names: ["foodTrend"] });
|
||||
const words = load({ names: ["foodTrendSentence"] });
|
||||
|
||||
// The 7/14/30 picker reaches the trend by deciding how many days weeklyData
|
||||
// builds — there is no second mechanism, so this is the thing to hold still.
|
||||
@@ -86,4 +87,35 @@ suite("marked days are skipped without shifting the line");
|
||||
ok(t.clear, "…and it is still clear, not drowned by a false zero");
|
||||
}
|
||||
|
||||
suite("what the sentence is allowed to say");
|
||||
{
|
||||
const say = (grams, opts) => words.foodTrendSentence(app.foodTrend(days(grams, opts)), 14);
|
||||
|
||||
const rising = say([200, 250, 300, 350, 400, 450, 0]);
|
||||
ok(/up about 350 g a week/.test(rising), "a clear climb gives the rate");
|
||||
ok(/200 g a day then/.test(rising) && /450 g a day now/.test(rising),
|
||||
"…and the figures at each end of the line, so it is not only a rate");
|
||||
ok(/the last 14 days/.test(rising), "…named against the window it was fitted over");
|
||||
|
||||
const steady = say([300, 302, 298, 301, 299, 300, 0]);
|
||||
ok(/roughly steady/.test(steady), "a flat run is called steady");
|
||||
ok(/averaging about 300 g a day/.test(steady),
|
||||
"…and quotes the average, which is a measurement rather than model output");
|
||||
ok(!/then/.test(steady) && !/ now\b/.test(steady),
|
||||
"…but not fitted endpoints, which would dress up a line nobody should read");
|
||||
|
||||
const noisy = say([200, 500, 210, 480, 190, 520, 0]);
|
||||
ok(/roughly steady/.test(noisy) && /variation is larger/.test(noisy),
|
||||
"a see-saw says the variation beat the trend, rather than quoting a slope");
|
||||
|
||||
const none = say([300, 320, 310]);
|
||||
ok(/Not enough complete days/.test(none) && /needs four/.test(none),
|
||||
"too few days explains itself instead of leaving the chart bare");
|
||||
|
||||
// False precision would make a fit look like a reading.
|
||||
ok(/\b\d*[05] g a day/.test(rising), "figures are rounded to 10 g, not quoted to the gram");
|
||||
const falling = say([450, 400, 350, 300, 250, 200, 0]);
|
||||
ok(/down about/.test(falling), "a clear fall says down");
|
||||
}
|
||||
|
||||
export default report("food-trend");
|
||||
|
||||
@@ -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");
|
||||
@@ -0,0 +1,77 @@
|
||||
// The sleep and walk trends draw the selected day against yesterday and the
|
||||
// window average. A day marked "not counted" has to be absent from all three,
|
||||
// and the one that kept slipping through was the selected day itself — it is
|
||||
// the boldest line on the panel, so it reads as the answer.
|
||||
import { load } from "./extract.mjs";
|
||||
import { suite, eq, ok, report } from "./assert.mjs";
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const SEL = new Date(2026, 8, 20); // the day under the cursor
|
||||
const at = (day, hour) => new Date(2026, 8, day, hour).getTime();
|
||||
|
||||
let excluded = new Set();
|
||||
let windowDays = 7;
|
||||
|
||||
const curves = load({
|
||||
names: ["startOfDay", "pairWindows", "sleepWindows", "sleepTrendCurves"],
|
||||
stubs: {
|
||||
selectedDay: () => SEL,
|
||||
ymd: (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`,
|
||||
isExcluded: (d) => excluded.has(d.getDate()),
|
||||
chartDays: () => windowDays,
|
||||
},
|
||||
});
|
||||
|
||||
// A night's sleep on each of several days, so every curve has something to draw.
|
||||
const slept = (day, fromHour, toHour) => ([
|
||||
{ id: `s${day}a`, type: "sleep-start", at: at(day, fromHour) },
|
||||
{ id: `s${day}b`, type: "sleep-end", at: at(day, toHour) },
|
||||
]);
|
||||
const week = [16, 17, 18, 19, 20].flatMap(d => slept(d, 1, 5));
|
||||
|
||||
suite("the selected day's own curve");
|
||||
{
|
||||
excluded = new Set();
|
||||
const c = curves.sleepTrendCurves(week);
|
||||
ok(c.today && c.today.length > 1, "a normal day is drawn");
|
||||
eq(c.dayExcluded, false, "…and not flagged as excluded");
|
||||
|
||||
excluded = new Set([20]); // the selected day
|
||||
const m = curves.sleepTrendCurves(week);
|
||||
eq(m.today, null, "a day marked 'not counted' is not drawn at all");
|
||||
eq(m.dayExcluded, true, "…and says so, so the legend can drop its chip");
|
||||
eq(m.projected, null, "…and nothing is projected from a curve that isn't there");
|
||||
ok(m.avg && m.avg.length > 1, "the average it would have been read against survives");
|
||||
ok(m.yesterday && m.yesterday.length > 1, "so does yesterday");
|
||||
}
|
||||
|
||||
suite("the comparison day and the average");
|
||||
{
|
||||
excluded = new Set([19]); // yesterday, relative to the 20th
|
||||
const c = curves.sleepTrendCurves(week);
|
||||
eq(c.yesterday, null, "a marked yesterday is dropped rather than drawn flat");
|
||||
ok(c.today && c.today.length > 1, "the selected day is unaffected by it");
|
||||
|
||||
// Every day but the selected one marked: nothing left to average over.
|
||||
excluded = new Set([16, 17, 18, 19]);
|
||||
const none = curves.sleepTrendCurves(week);
|
||||
eq(none.avg, null, "an average with no days left to average is null, not zero");
|
||||
ok(none.today && none.today.length > 1, "…and the selected day still draws");
|
||||
}
|
||||
|
||||
suite("a marked day never contributes to the average");
|
||||
{
|
||||
// The 19th sleeps far longer than the rest. With it counted the average is
|
||||
// dragged up; marked, it should leave no trace.
|
||||
const lopsided = [...[16, 17, 18].flatMap(d => slept(d, 1, 3)), ...slept(19, 1, 23), ...slept(20, 1, 3)];
|
||||
windowDays = 7;
|
||||
|
||||
excluded = new Set();
|
||||
const withIt = curves.sleepTrendCurves(lopsided).avg[24].y;
|
||||
excluded = new Set([19]);
|
||||
const without = curves.sleepTrendCurves(lopsided).avg[24].y;
|
||||
ok(withIt > without, "marking the outlier lowers the average it was inflating");
|
||||
eq(Math.round(without), 2, "…back to the two hours the remaining days actually slept");
|
||||
}
|
||||
|
||||
export default report("trend-curves");
|
||||
+213
-33
@@ -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");
|
||||
@@ -1713,30 +1843,43 @@
|
||||
// Says what the line means, and what it cannot mean. Kept in words under the
|
||||
// chart rather than as a figure on it: "up 40 g a week" is a claim, and it
|
||||
// needs the room to be qualified.
|
||||
function renderFoodTrendNote(days, trend) {
|
||||
const note = document.getElementById("grams-note");
|
||||
if (!note) return;
|
||||
const lines = [];
|
||||
// The window comes from the 7/14/30 picker, and naming it is the only way
|
||||
// the reader can tell that switching it changed the answer — the line
|
||||
// itself often moves too little to notice.
|
||||
const window = `the last ${chartDays()} days`;
|
||||
|
||||
// What the line is allowed to claim, in words. Separated from the drawing
|
||||
// because this is where the judgement lives: a straight line through noisy
|
||||
// points always has a slope, and stating it as a fact is how a chart starts
|
||||
// lying. Kept pure so the rules can be checked.
|
||||
//
|
||||
// Figures are rounded to 10 g. The fitted endpoints are model output, not
|
||||
// measurements — quoting "287 g" would dress a guess up as a reading.
|
||||
function foodTrendSentence(trend, windowDays) {
|
||||
const window = `the last ${windowDays} days`;
|
||||
if (!trend) {
|
||||
// Says why there is no line. Without this the chart looks broken on a
|
||||
// short window, or on one where most days are marked.
|
||||
lines.push(`Not enough complete days in ${window} to draw a trend — it needs four, and today doesn't count until it's over.`);
|
||||
} else {
|
||||
const perWeek = Math.round(Math.abs(trend.perWeek));
|
||||
// Under a twentieth of a typical day, a week apart, is not a trend
|
||||
// anyone could act on, whatever the arithmetic says.
|
||||
const slight = perWeek < 5 || perWeek < trend.mean * 0.05;
|
||||
if (!trend.clear || slight) {
|
||||
lines.push(`Over ${window}, daily intake is roughly steady — day-to-day variation is larger than any trend.`);
|
||||
} else {
|
||||
lines.push(`Over ${window}, daily intake is ${trend.perWeek > 0 ? "up" : "down"} about ${perWeek} g a week.`);
|
||||
}
|
||||
return `Not enough complete days in ${window} to draw a trend — it needs four, and today doesn't count until it's over.`;
|
||||
}
|
||||
const round10 = (v) => Math.round(Math.max(0, v) / 10) * 10;
|
||||
const perWeek = Math.round(Math.abs(trend.perWeek));
|
||||
// Under a twentieth of a typical day, a week apart, is not a trend anyone
|
||||
// could act on, whatever the arithmetic says.
|
||||
const slight = perWeek < 5 || perWeek < trend.mean * 0.05;
|
||||
if (!trend.clear || slight) {
|
||||
// The average is a real measurement and survives the noise; the fitted
|
||||
// endpoints would not, so they are not quoted here.
|
||||
return `Over ${window}, daily intake is roughly steady, averaging about ` +
|
||||
`${round10(trend.mean)} g a day — day-to-day variation is larger than any trend.`;
|
||||
}
|
||||
return `Over ${window}, daily intake is ${trend.perWeek > 0 ? "up" : "down"} about ` +
|
||||
`${perWeek} g a week — roughly ${round10(trend.at(trend.first))} g a day then, ` +
|
||||
`${round10(trend.at(trend.last))} g a day now.`;
|
||||
}
|
||||
|
||||
function renderFoodTrendNote(days, trend) {
|
||||
const note = document.getElementById("grams-note");
|
||||
if (!note) return;
|
||||
// The window comes from the 7/14/30 picker, and naming it is the only way
|
||||
// the reader can tell that switching it changed the answer — the line
|
||||
// itself often moves too little to notice.
|
||||
const lines = [foodTrendSentence(trend, chartDays())];
|
||||
|
||||
const missing = days.reduce((s, d) => s + (d.mealsMissingGrams || 0), 0);
|
||||
if (missing > 0) {
|
||||
@@ -1745,7 +1888,7 @@
|
||||
}
|
||||
|
||||
note.textContent = lines.join(" ");
|
||||
note.hidden = lines.length === 0;
|
||||
note.hidden = false; // there is always a sentence now, even if it is "no trend"
|
||||
}
|
||||
|
||||
// Minutes walked per day. Hidden until there's a walk to show, like the
|
||||
@@ -1984,9 +2127,12 @@
|
||||
};
|
||||
const totalOf = (pts) => pts[pts.length - 1].y;
|
||||
|
||||
const today = curveFor(dayStartTs(0), isToday ? Date.now() : null);
|
||||
// Same as the sleep trend: a day that doesn't count is dropped as a
|
||||
// comparison rather than drawn flat at zero.
|
||||
// Left off entirely when the day is marked "not counted" — same reasoning
|
||||
// as the sleep trend: it is already out of the average and out of
|
||||
// "yesterday", so drawing it as the boldest line would contradict that.
|
||||
const dayExcluded = isExcluded(day);
|
||||
const today = dayExcluded ? null : curveFor(dayStartTs(0), isToday ? Date.now() : null);
|
||||
// Same rule for the comparison day: dropped rather than drawn flat at zero.
|
||||
const prev = curveFor(dayStartTs(1));
|
||||
const yesterday = (!isExcluded(dayAgo(1)) && totalOf(prev) > 0) ? prev : null;
|
||||
|
||||
@@ -2010,7 +2156,7 @@
|
||||
const fmtDay = (daysAgo) =>
|
||||
new Date(dayStartTs(daysAgo)).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
return {
|
||||
today, yesterday, avg, avgDays,
|
||||
today, yesterday, avg, avgDays, dayExcluded,
|
||||
dayLabel: isToday ? "Today" : fmtDay(0),
|
||||
prevDayLabel: isToday ? "Yesterday" : fmtDay(1),
|
||||
};
|
||||
@@ -2073,7 +2219,11 @@
|
||||
|
||||
const chip = (id) => document.getElementById(id);
|
||||
const mins = (pts) => `${Math.round(pts[pts.length - 1].y)} min`;
|
||||
chip("legend-wtrend-today-text").textContent = `${curves.dayLabel} ${mins(curves.today)}`;
|
||||
// No line for a day that doesn't count, so no chip for it either.
|
||||
chip("legend-wtrend-today").hidden = !curves.today;
|
||||
if (curves.today) {
|
||||
chip("legend-wtrend-today-text").textContent = `${curves.dayLabel} ${mins(curves.today)}`;
|
||||
}
|
||||
|
||||
const yLegend = chip("legend-wtrend-yesterday");
|
||||
yLegend.hidden = !curves.yesterday;
|
||||
@@ -2226,7 +2376,14 @@
|
||||
};
|
||||
|
||||
// A past day is complete, so its curve runs the full 24h uncapped.
|
||||
const today = curveFor(dayStartTs(0), isToday ? Date.now() : null);
|
||||
//
|
||||
// Unless the day is marked "not counted", in which case it is left off the
|
||||
// chart entirely. It is already out of the average and out of "yesterday",
|
||||
// and drawing it as the headline curve would put the one day you have said
|
||||
// not to trust in the boldest line on the panel. What remains is the
|
||||
// references — which is what you would want to see on a day like that.
|
||||
const dayExcluded = isExcluded(day);
|
||||
const today = dayExcluded ? null : curveFor(dayStartTs(0), isToday ? Date.now() : null);
|
||||
|
||||
// A day that doesn't count is no comparison at all, so it is dropped
|
||||
// outright rather than drawn as a flat line at zero. Checked explicitly
|
||||
@@ -2262,7 +2419,7 @@
|
||||
// No history → no average → no projection. Past days are already complete,
|
||||
// so there is nothing to project.
|
||||
let projected = null;
|
||||
if (avg && isToday) {
|
||||
if (avg && isToday && today) {
|
||||
const nowPt = today[today.length - 1];
|
||||
const avgAt = (x) => {
|
||||
const lo = Math.floor(x);
|
||||
@@ -2282,7 +2439,7 @@
|
||||
const dayLabel = isToday ? "Today" : fmtDay(0);
|
||||
const prevDayLabel = isToday ? "Yesterday" : fmtDay(1);
|
||||
|
||||
return { today, yesterday, avg, avgDays, projected, dayLabel, prevDayLabel };
|
||||
return { today, yesterday, avg, avgDays, projected, dayLabel, prevDayLabel, dayExcluded };
|
||||
}
|
||||
|
||||
function drawSleepTrendChart(curves, target) {
|
||||
@@ -2379,7 +2536,12 @@
|
||||
// write each curve's slept-hours total into its chip.
|
||||
const chip = (id) => document.getElementById(id);
|
||||
const hrs = (pts) => `${pts[pts.length - 1].y.toFixed(1)}h`;
|
||||
chip("legend-trend-today-text").textContent = `${curves.dayLabel} ${hrs(curves.today)}`;
|
||||
// No curve for a day that doesn't count, so no chip for it either — a
|
||||
// legend entry pointing at a line that isn't drawn is worse than none.
|
||||
chip("legend-trend-today").hidden = !curves.today;
|
||||
if (curves.today) {
|
||||
chip("legend-trend-today-text").textContent = `${curves.dayLabel} ${hrs(curves.today)}`;
|
||||
}
|
||||
const yLegend = chip("legend-trend-yesterday");
|
||||
yLegend.hidden = !curves.yesterday;
|
||||
if (curves.yesterday) {
|
||||
@@ -2559,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);
|
||||
}
|
||||
|
||||
@@ -2895,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:
|
||||
@@ -5156,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";
|
||||
|
||||
+3
-1
@@ -1,5 +1,7 @@
|
||||
[
|
||||
{ "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: “daily intake is up about 40 g a week”, or that it is roughly steady when the day-to-day variation is bigger than any trend, which is most of the time over a short window. 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-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" },
|
||||
{ "date": "2026-09-20", "text": "Fixed three things that went wrong around a day marked “not counted”. The Timing panel measured “how long since the last pee” from before the marked day rather than from the actual last one, so the marker sat far out to the right. The Sleep and Walk trends drew the marked day's own curve as a flat zero when you were looking at that day — marking a day means don't let it drag the average, not pretend nothing happened on it. And a nap that started on a marked day and ended the next morning vanished from that next day's figures, even though the next day wasn't marked and the puppy really did sleep those hours" },
|
||||
{ "date": "2026-09-09", "text": "The big asleep/awake card at the top of Today is gone, and both timers now live permanently in the frozen bar at the top — visible on every tab, wherever you have scrolled to. The card only existed on one tab and the timers hid themselves whenever it was on screen, which meant the thing you most often want at a glance was the thing you had to go and find. With only one place left to show them they have their seconds back too" },
|
||||
|
||||
+12
-2
@@ -311,7 +311,7 @@
|
||||
<h2>Sleep trend</h2>
|
||||
<svg id="chart-sleep-trend" class="chart-svg" viewBox="0 0 320 220" role="img" aria-label="Cumulative sleep hours through the selected day, the day before it, the recent average and (for today) the projected end-of-day total, with the age-based sleep goal band"></svg>
|
||||
<div class="legend">
|
||||
<span class="lg trend-today"><span class="sw"></span><span id="legend-trend-today-text">Today</span></span>
|
||||
<span class="lg trend-today" id="legend-trend-today"><span class="sw"></span><span id="legend-trend-today-text">Today</span></span>
|
||||
<span class="lg trend-projected" id="legend-trend-projected" hidden><span class="sw"></span><span id="legend-trend-projected-text">Projected</span></span>
|
||||
<span class="lg trend-yesterday" id="legend-trend-yesterday"><span class="sw"></span><span id="legend-trend-yesterday-text">Yesterday</span></span>
|
||||
<span class="lg trend-avg" id="legend-trend-avg"><span class="sw"></span><span id="legend-trend-avg-text">7-day avg</span></span>
|
||||
@@ -346,7 +346,7 @@
|
||||
<h2>Walk trend</h2>
|
||||
<svg id="chart-walk-trend" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Cumulative minutes walked through the selected day, the day before it, and the recent average"></svg>
|
||||
<div class="legend">
|
||||
<span class="lg wtrend-today"><span class="sw"></span><span id="legend-wtrend-today-text">Today</span></span>
|
||||
<span class="lg wtrend-today" id="legend-wtrend-today"><span class="sw"></span><span id="legend-wtrend-today-text">Today</span></span>
|
||||
<span class="lg wtrend-yesterday" id="legend-wtrend-yesterday"><span class="sw"></span><span id="legend-wtrend-yesterday-text">Yesterday</span></span>
|
||||
<span class="lg wtrend-avg" id="legend-wtrend-avg"><span class="sw"></span><span id="legend-wtrend-avg-text">7-day avg</span></span>
|
||||
</div>
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user