Compare commits

...
6 Commits
Author SHA1 Message Date
Alexander Heldt 9e47aa53ff Stop the food trend's figures contradicting each other
The caption read like "down about 329 g a week — roughly 460 g a day then,
320 g a day now". Subtract the two amounts and you get 140 g, not 329. The
arithmetic behind it was self-consistent, but the sentence was not, and a
caption a reader can disprove by subtracting its own numbers is wrong whatever
the code was doing.

The rate was slope x 7, while the line only covers the complete days in the
window. Today is never fitted, being unfinished, so a 7-day window leaves at
most five days and any marked day takes another — in this case about three.
The rate was therefore stretched well past the days it was measured from, and
the two endpoints, which were not, could never agree with it.

It quotes the change between the two ends now, which is the one figure a reader
can check: "down about 140 g — from roughly 460 g a day to 320 g." The change
is derived from the rounded ends rather than from the slope, so the subtraction
works exactly rather than to within the rounding.

The weekly rate goes rather than being repaired. It cannot reconcile on a short
window, and it only ever meant anything where the fit spanned a week or more —
which is not something to leave as a trap for whichever window the reader
happens to have picked.

The check that let this through asserted the sentence contained certain
phrases, not that its numbers agreed with each other. There is now one that
parses all three figures back out and asserts the move is exactly the
difference of the ends, across each window length; it fails against the old
wording, which is the only evidence worth having that it would have caught this.
2026-09-21 21:01:27 +00:00
Alexander Heldt 556e4d75a8 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.
2026-09-21 20:53:56 +00:00
Alexander Heldt e4a5c3fe29 Leave a day that doesn't count off the trends entirely
The Sleep and Walk trends already kept a day marked "not counted" out of the
window average and out of the "yesterday" comparison. What they still drew was
that day's own curve, when it was the day you had selected — as the boldest
line on the panel. So the single day you had said not to trust was the one the
chart led with, against references that had carefully excluded it.

It is left off now, along with its legend chip and, for the sleep trend, the
projected tail that continued it. What remains is the average and yesterday,
which is what you would want to look at on a day like that.

This reverses part of an earlier fix. That one stopped the curve being drawn as
a flat zero, on the reasoning that marking a day means "don't let it drag the
average" rather than "pretend nothing happened". The flat zero was certainly
wrong, but so was the conclusion: a real curve for an untrusted day is still
the wrong thing to lead with. Absent is the honest third option.

The walk trend gets the same treatment. It is the same panel in different
units, and the two disagreeing about what a marked day means would be worse
than either answer.
2026-09-21 20:44:23 +00:00
Alexander Heldt 7451650b6f Give the food trend's figures, not only its rate
"Up about 40 g a week" is a rate with nothing to anchor it: it says the line
slopes without saying where it sits. The sentence now names both ends of the
fit — "roughly 280 g a day then, 400 g a day now" — which is the reading anyone
actually wants from a growth chart.

When the fit is not trustworthy it quotes the average instead, and says the
day-to-day variation is larger than any trend. That difference is the point.
The average is a measurement and survives the noise; the ends of the line are
the line's own output, and quoting them on a fit nobody should read would dress
a guess up as a reading. Everything rounds to 10 g for the same reason — "287 g
a day" would be false precision from four noisy points.

The wording moves into a pure foodTrendSentence() so those rules can be
checked, which is worth doing precisely because they are judgement rather than
arithmetic: the checks now pin that a clear climb gives both figures, a flat run
gives the average and no endpoints, a see-saw gives neither, and nothing is ever
quoted to the gram.
2026-09-21 20:43:50 +00:00
Alexander Heldt 668f1f039e Draw a trend line through the food bars
The daily grams bars bounce around enough to hide a steady climb, so they
cannot answer the question you actually have about a growing puppy: is he
eating more than he was? A least-squares fit through them can.

Two kinds of day stay out of the fit. Today is half-eaten, and including it
would pull the line down every morning and let it drift back up as meals go in
— a line that tracks the clock rather than the dog. A day marked "not counted"
has a hatch rather than a figure, and fitting a zero there would invent a dip.
The line is drawn only across the days it was fitted on, so it never implies it
knows about the ones it skipped.

The caption is the part that needed the care. A straight line through seven
noisy points will always have a slope, and announcing it as a fact is the same
mistake the walking goal made. So a direction is named only when the fitted
climb is larger than the scatter of the days around it, and only when it clears
5 g a week and a twentieth of a typical day; otherwise it says the variation is
larger than any trend, which over a short window is usually the truth.

It names its window too — "over the last 14 days" — because the 7/14/30 picker
already drove this (weeklyData builds the array the fit runs on) but nothing on
screen said so, and the line moves too little between windows to show it. When
there are fewer than four complete days it now says why there is no line rather
than leaving bars with nothing through them.

Meals can be logged without an amount, so the note counts them: a day can read
low because he ate little or because nobody typed the number, and the chart
should not let those look the same.

The checks cover the refusals rather than the arithmetic — a see-saw is not
reported as a trend, a slope under the scatter is not either, three days will
not fit, a marked day does not shift the line, and each window length reaches
the fit intact.
2026-09-21 20:16:10 +00:00
Alexander Heldt 14cad44d9b Stop the page growing wider than the screen
A phone had started allowing zoom-out, which is how a document wider than the
viewport announces itself. The suspect was the "Ate" modal, but the dialogs are
not it: all seven open with showModal(), so their containing block is the
viewport and width: calc(100% - 32px) cannot exceed it.

It was the month grid, added three commits ago:

  left: 50%; transform: translateX(-50%); width: 268px;

max-width bounded the panel's width and nothing bounded its position. Centred
on the date button — which sits near the right edge of the bar — a 268px panel
hangs off the side of a phone, and being absolutely positioned it drags the
document's scrollable width out with it.

Anchoring to the button cannot be made safe: pin it right and it overflows the
left on a narrow screen, pin it left and it overflows the right. So it is a
child of the day bar now, pinned to that bar's inner edge and capped at the
bar's own width. The bar spans the content width exactly, so the panel is on
screen at every size by construction.

A long unbroken word was a second way in, and a pre-existing one. A history
row's note is a flex item with neither min-width: 0 nor a break rule, so a URL
or something copied off a food bag sets its content-based minimum and widens
the row. The Notes log directly below already guarded against precisely this,
so the history row had simply been missed; exercise names and their
instructions had the same gap.

The checks gained the general form of both, since this class of bug is
invisible until a phone starts zooming out: every element that renders text the
user typed must be able to break a long word, the grid must stay edge-anchored
inside the bar, and no fixed width may exceed the content box of a 320px phone.
Each was confirmed to fail with its fix reverted.
2026-09-20 21:05:50 +00:00
10 changed files with 809 additions and 34 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]));
}
+140
View File
@@ -0,0 +1,140 @@
// The fit through the Food (grams) bars. The arithmetic is easy to get subtly
// wrong and the result is a sentence stating a fact about the puppy, so the
// cases that matter are the ones where it should decline to say anything.
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.
let windowDays = 7;
const weekly = load({
names: ["startOfDay", "endOfDay", "ymd", "weeklyData"],
stubs: {
chartDays: () => windowDays,
isExcluded: () => false,
eventsForDay: () => [],
sleepMsInRange: () => 0,
walkMsInRange: () => 0,
},
});
suite("the day picker is what sets the trend's window");
for (const n of [7, 14, 30]) {
windowDays = n;
eq(weekly.weeklyData([]).length, n, `picking ${n}d gives the charts ${n} days to fit over`);
}
windowDays = 7;
// weeklyData's shape, as far as foodTrend reads it. Today is last, as there.
const days = (grams, { excluded = [] } = {}) =>
grams.map((g, i) => ({ grams: g, excluded: excluded.includes(i) }));
suite("it declines to fit when there is nothing to fit");
{
eq(app.foodTrend(days([300, 320, 310])), null,
"three days is too few — today is dropped, leaving two, and two always fit perfectly");
eq(app.foodTrend(days([300, 320, 310, 330, 340], { excluded: [0, 1] })), null,
"marked days don't count toward the four either");
eq(app.foodTrend(days([])), null, "an empty window fits nothing");
}
suite("today is left out, being half-eaten");
{
// Four steady days then a partial today. Including today would tip the line
// down; the fit should not see it at all.
const t = app.foodTrend(days([400, 400, 400, 400, 50]));
ok(t, "four complete days are enough");
eq(Math.round(t.change), 0, "a flat run stays flat despite today being low");
eq(t.last, 3, "the line stops at the last complete day, not at today");
}
suite("it reports a direction only when the climb beats the scatter");
{
const rising = app.foodTrend(days([200, 250, 300, 350, 400, 450, 0]));
ok(rising.clear, "a clean climb is reported");
eq(Math.round(rising.change), 250, "…as the move across the five days it fitted, 200 g to 450 g");
const falling = app.foodTrend(days([450, 400, 350, 300, 250, 200, 0]));
ok(falling.clear, "a clean fall is reported");
ok(falling.change < 0, "…with a negative change");
// Same mean, no direction, plenty of noise: the honest answer is "steady".
const noisy = app.foodTrend(days([200, 500, 210, 480, 190, 520, 0]));
ok(!noisy.clear, "a see-saw is not a trend, however the slope comes out");
// A gentle real climb buried in large day-to-day swings: also not claimable.
const buried = app.foodTrend(days([300, 520, 180, 540, 200, 560, 0]));
ok(!buried.clear, "a slope smaller than the scatter is not reported as a trend");
}
suite("the fitted line passes through the data");
{
const t = app.foodTrend(days([100, 200, 300, 400, 500, 0]));
eq(Math.round(t.at(0)), 100, "it starts where the first day sits");
eq(Math.round(t.at(4)), 500, "and ends where the last complete day sits");
eq(Math.round(t.mean), 300, "the mean is the mean of the days it fitted");
}
suite("marked days are skipped without shifting the line");
{
// The middle day is marked; the rest describe a clean 50 g/day climb. The fit
// must ignore the hatch rather than reading it as a day of zero grams.
const t = app.foodTrend(days([200, 250, 0, 350, 400, 450, 0], { excluded: [2] }));
eq(Math.round(t.change), 250, "the climb is unchanged by the marked day");
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, win = 14) => words.foodTrendSentence(app.foodTrend(days(grams, opts)), win);
const rising = say([200, 250, 300, 350, 400, 450, 0]);
ok(/up about 250 g/.test(rising), "a clear climb gives the size of the move");
ok(/from roughly 200 g a day to 450 g/.test(rising), "…and the figures at each end");
ok(/the last 14 days/.test(rising), "…named against the window it was fitted over");
// The defect this replaced: the move was quoted per week while the fit spans
// at most five days on a 7-day window, so the figure and the two endpoints
// disagreed and a reader who subtracted them found the sentence wrong.
// Whatever the window, the three numbers in the sentence must reconcile.
for (const [label, grams, win] of [
["a steep 7-day fall", [460, 425, 390, 355, 320, 285, 0], 7],
["a long 14-day climb", [200, 220, 240, 260, 280, 300, 320, 340, 360, 380, 400, 420, 440, 0], 14],
["a gentle 30-day climb", [...Array(29).fill(0).map((_, i) => 300 + i * 12), 0], 30],
]) {
const s = say(grams, undefined, win);
const m = s.match(/about (\d+) g — from roughly (\d+) g a day to (\d+) g/);
ok(m, `${label}: the sentence has all three figures`);
if (m) {
const [, moved, from, to] = m.map(Number);
eq(moved, Math.abs(to - from), `${label}: the move is exactly the difference of the two ends`);
ok(new RegExp(`is ${to > from ? "up" : "down"} about`).test(s),
`${label}: and the direction matches which end is larger`);
}
}
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");
+44
View File
@@ -149,4 +149,48 @@ suite("both day-bar timers fit on a row of their own");
}
}
// Anything wider than the screen makes the whole document wider than the
// viewport, and a phone responds by letting you zoom out — which is how this
// class of bug is usually noticed, long after it was introduced.
suite("nothing can push the page wider than the screen");
{
// Free text the user types has no width limit of its own. A long unbroken
// token — a URL in a note, a chemical name off a food bag — sets a flex
// item's content-based minimum, or simply spills out of its box, and either
// way it widens the document. Every element that renders user input needs a
// break rule; this is the list, and it is easier to extend than to remember.
const USER_TEXT = [
"\\.event \\.note", // a note on a history row
"\\.event \\.note-text", // the Notes log
"\\.ex-name", // exercise names
"\\.ex-note", // exercise instructions
"\\.guest-item-label", // the label on a guest link
];
for (const sel of USER_TEXT) {
const block = rule(new RegExp(`\\n${sel}[^{]*\\{([^}]*)\\}`));
ok(/overflow-wrap:\s*(anywhere|break-word)/.test(block),
`${sel.replace(/\\/g, "")} can break a long unbroken word`);
}
// The month grid is the one panel positioned against something narrower than
// the page. Centred on the date button it hung off the right of a phone; it
// is anchored to the day bar instead, which spans the content width.
const dayCal = rule(/\n\.day-cal \{([^}]*)\}/);
ok(/right:/.test(dayCal) && !/left:\s*50%/.test(dayCal),
"the month grid is edge-anchored, not centred on the date button");
ok(!/max-width:[^;]*vw/.test(dayCal),
"…and bounded by its container rather than by the viewport");
const main = html.slice(html.indexOf('<section class="day-bar">'),
html.indexOf("</section>", html.indexOf('<section class="day-bar">')));
ok(/id="day-cal"/.test(main), "…and sits inside the day bar, which is what it is measured against");
// A fixed width wider than the narrowest content box cannot fit by
// definition. 320px phone, less the body's two 16px gutters.
const NARROWEST = 320 - 2 * BODY_GUTTER;
const tooWide = [...css.matchAll(/(?:^|[;{\s])(width|min-width):\s*(\d{3,})px/g)]
.filter(m => Number(m[2]) > NARROWEST)
.map(m => `${m[1]}: ${m[2]}px`);
eq(tooWide, [], `no fixed width exceeds a ${NARROWEST}px content box`);
}
export default report("layout");
+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");
+77
View File
@@ -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");
+302 -12
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");
@@ -1380,6 +1510,11 @@
grams: dayEvents
.filter(e => e.type === "eat" && Number.isFinite(e.grams))
.reduce((s, e) => s + e.grams, 0),
// The amount is optional on a meal, so a low day can mean "ate little"
// or "didn't type the number". The food trend reports this rather than
// leaving the reader to assume the first.
mealsMissingGrams: dayEvents
.filter(e => e.type === "eat" && !(Number.isFinite(e.grams) && e.grams > 0)).length,
walkMinutes: excluded ? 0 : walkMsInRange(events, from, to) / 60_000,
});
}
@@ -1589,6 +1724,57 @@
// Grams of food per day. Hidden entirely until any meal in the window has an
// amount logged, so the weekly card doesn't grow an empty chart.
// A straight least-squares fit through the daily totals, to answer "is he
// eating more as he grows?" — which the bars alone don't, because day-to-day
// variation is large enough to hide a steady climb.
//
// Two days are left out of the fit. A day marked "not counted" has no figure
// to fit (its bar is a hatch, not a zero). And today is still in progress, so
// including it would drag the line down every morning and let it drift back
// up over the day — a moving line that reflects the clock rather than the
// puppy. The line is drawn only across the days it was fitted on, so it never
// implies it knows about the ones it skipped.
function foodTrend(days) {
const pts = [];
days.forEach((d, i) => {
if (d.excluded) return;
if (i === days.length - 1) return; // today, still being eaten
pts.push({ x: i, y: d.grams });
});
// Two points always fit a line perfectly and say nothing; four is the least
// that can show a direction rather than a coincidence.
if (pts.length < 4) return null;
const n = pts.length;
const mx = pts.reduce((s, p) => s + p.x, 0) / n;
const my = pts.reduce((s, p) => s + p.y, 0) / n;
const sxx = pts.reduce((s, p) => s + (p.x - mx) ** 2, 0);
if (sxx === 0) return null;
const slope = pts.reduce((s, p) => s + (p.x - mx) * (p.y - my), 0) / sxx;
const intercept = my - slope * mx;
// How far the fitted line climbs across the days it covers, against how far
// the days themselves scatter around it. Claiming a direction when the
// scatter is the larger of the two would be reading noise as a story.
const first = pts[0].x, last = pts[n - 1].x;
const rise = Math.abs(slope * (last - first));
const residualSD = n > 2
? Math.sqrt(pts.reduce((s, p) => s + (p.y - (slope * p.x + intercept)) ** 2, 0) / (n - 2))
: Infinity;
return {
at: (i) => slope * i + intercept,
first, last,
// How much the daily figure moved across the days actually fitted. Not a
// per-week rate: on a 7-day window today is never fitted, so the span is
// at most five days and a weekly figure would be extrapolated past the
// data — leaving a sentence whose own endpoints contradicted it.
change: slope * (last - first),
mean: my,
clear: rise > residualSD, // the climb outruns the scatter
};
}
function drawGramsChart(days) {
const wrap = document.getElementById("grams-chart-wrap");
const svg = document.getElementById("chart-grams");
@@ -1642,9 +1828,76 @@
}
});
// The trend goes on top of the bars, and only across the days it was fitted
// on. Clamped to the plot area so a steep fit can't draw outside the axes.
const trend = foodTrend(days);
if (trend) {
const cx = (i) => ML + i * (barW + gap) + barW / 2;
const cy = (g) => MT + innerH * (1 - Math.min(Math.max(g, 0), yMax) / yMax);
parts.push(
`<line class="food-trend" x1="${cx(trend.first).toFixed(1)}" y1="${cy(trend.at(trend.first)).toFixed(1)}" ` +
`x2="${cx(trend.last).toFixed(1)}" y2="${cy(trend.at(trend.last)).toFixed(1)}"/>`
);
}
renderFoodTrendNote(days, trend);
setChartSVG(svg, parts);
}
// 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.
// 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.
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;
// Under a twentieth of a typical day is not a move anyone could act on,
// whatever the arithmetic says.
const slight = Math.abs(trend.change) < 5 || Math.abs(trend.change) < 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.`;
}
// The change is derived from the *rounded* ends rather than from the slope,
// so that subtracting the two figures on screen gives exactly the figure
// quoted. A reader who checks the arithmetic has to find it correct.
const from = round10(trend.at(trend.first));
const to = round10(trend.at(trend.last));
return `Over ${window}, daily intake is ${to > from ? "up" : "down"} about ` +
`${Math.abs(to - from)} g — from roughly ${from} g a day to ${to} g.`;
}
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) {
const meals = days.reduce((s, d) => s + d.meals, 0);
lines.push(`${missing} of ${meals} meals here have no amount recorded, so those days read lower than they were.`);
}
note.textContent = lines.join(" ");
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
// grams chart — no point in an empty panel for someone who doesn't log walks.
function drawWalkChart(days) {
@@ -1881,9 +2134,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;
@@ -1907,7 +2163,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),
};
@@ -1970,7 +2226,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;
@@ -2123,7 +2383,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
@@ -2159,7 +2426,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);
@@ -2179,7 +2446,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) {
@@ -2276,7 +2543,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) {
@@ -2456,7 +2728,7 @@
val.textContent = formatWeight(w.weight);
li.appendChild(date);
li.appendChild(val);
li.addEventListener("click", () => openEditDialog(w));
attachRowHandlers(li, w);
list.appendChild(li);
}
@@ -2792,6 +3064,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:
@@ -5053,6 +5328,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";
+5
View File
@@ -1,4 +1,9 @@
[
{ "date": "2026-09-21", "text": "Fixed the figures under the Food (grams) chart contradicting each other. It read like “down about 329 g a week — roughly 460 g a day then, 320 g a day now”, where subtracting the two amounts gives 140 g, not 329 g. The rate was worked out per week while the line itself only covers the complete days in the window — at most five of them on a 7-day window, since today isn't finished — so it was stretched past the days it was measured from. It now gives the change between the two ends, which is a figure you can check by subtracting them: “down about 140 g — from roughly 460 g a day to 320 g”" },
{ "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 120 g — from roughly 280 g a day to 400 g”. 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" },
{ "date": "2026-09-08", "text": "The date now opens a small month grid of the app's own instead of the browser's date picker. The browser's one is a sheet that covers the screen, which is backwards when the reason to change day is to see what the numbers did on it — this one sits under the bar with the overview still visible and updating as you move. ← and → still step a day at a time; the grid is for jumping further, and the Today button now lives inside it. That is what made room for the walk timer and the sleep timer to sit on one row: the top bar no longer splits onto two rows on a phone, except on the very smallest. The two timers count in minutes now rather than seconds — the big card on Today still ticks in seconds, which is where you look if you want them" },
+33 -13
View File
@@ -144,19 +144,26 @@
<button type="button" id="day-date-face" class="ghost"
aria-haspopup="dialog" aria-expanded="false"></button>
<input type="date" id="day-picker" tabindex="-1" aria-hidden="true" />
<div id="day-cal" class="day-cal" role="dialog" aria-label="Pick a day" hidden>
<div class="day-cal-head">
<button type="button" id="cal-prev" class="ghost icon-btn" aria-label="Previous month"></button>
<span id="cal-month" class="day-cal-month" aria-live="polite"></span>
<button type="button" id="cal-next" class="ghost icon-btn" aria-label="Next month"></button>
</div>
<div id="cal-weekdays" class="day-cal-weekdays" aria-hidden="true"></div>
<div id="cal-grid" class="day-cal-grid" role="grid"></div>
<button type="button" id="cal-today" class="ghost day-cal-today">Today</button>
</div>
</span>
<button type="button" id="day-next" class="ghost" aria-label="Next day"></button>
</span>
<!-- A child of the bar rather than of the date button, even though it
belongs to that button: positioned against the button it would be
centred on something near the right edge, and a 268px panel would
hang off the side of the screen — which makes the whole page wider
than the viewport and lets a phone zoom out. The bar spans the
content width, so anchoring to its edge can't leave the screen. -->
<div id="day-cal" class="day-cal" role="dialog" aria-label="Pick a day" hidden>
<div class="day-cal-head">
<button type="button" id="cal-prev" class="ghost icon-btn" aria-label="Previous month"></button>
<span id="cal-month" class="day-cal-month" aria-live="polite"></span>
<button type="button" id="cal-next" class="ghost icon-btn" aria-label="Next month"></button>
</div>
<div id="cal-weekdays" class="day-cal-weekdays" aria-hidden="true"></div>
<div id="cal-grid" class="day-cal-grid" role="grid"></div>
<button type="button" id="cal-today" class="ghost day-cal-today">Today</button>
</div>
</section>
<section class="quick-actions">
@@ -304,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>
@@ -339,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>
@@ -390,7 +397,10 @@
</div>
<div class="chart" id="grams-chart-wrap" hidden>
<div class="chart-title">Food (grams)</div>
<svg id="chart-grams" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Grams of food eaten per day"></svg>
<svg id="chart-grams" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Grams of food eaten per day, with a trend line through them"></svg>
<!-- What the trend line says, and when it is not saying anything —
see renderFoodTrendNote. -->
<p id="grams-note" class="muted-note" hidden></p>
</div>
<div class="chart">
<div class="chart-title">By hour of day</div>
@@ -685,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>
+92 -6
View File
@@ -298,14 +298,18 @@ body::before {
browser's own picker does: changing day is worth doing *because* of the
figures below, so they have to stay in view while you move. Deliberately
kept short six rows of small cells for the same reason. */
/* Positioned against the day bar, not the date button (see index.html). The
bar is exactly the content width, so pinning the panel to its inner right
edge and capping it at the bar's own width keeps it on screen at every size.
Centring it on the button instead let it hang off the right of a phone,
which widens the document and lets the page zoom out. */
.day-cal {
position: absolute;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
right: 10px; /* the bar's own horizontal padding */
z-index: 70; /* over the day bar itself, which is 60 */
width: 268px;
max-width: calc(100vw - 24px);
max-width: calc(100% - 20px);
padding: 10px;
background: var(--surface);
border: 1px solid var(--border);
@@ -726,7 +730,11 @@ textarea { resize: vertical; }
.event .time { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 60px; }
.event .label { font-weight: 600; min-width: 110px; }
.event .note { color: var(--muted); font-size: 0.9rem; flex: 1; }
/* min-width:0 and a break rule, or a long unbroken word a URL, a chemical
name off a food bag sets this flex item's content-based minimum and pushes
the whole row wider than the screen. The Notes log's own text below already
guards against it; the History row was missed. */
.event .note { color: var(--muted); font-size: 0.9rem; flex: 1; min-width: 0; overflow-wrap: anywhere; }
/* Notes log rows: a date instead of a time-of-day, then the note text. */
.event .note-date { font-weight: 600; white-space: nowrap; font-variant-numeric: tabular-nums; }
@@ -1323,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;
@@ -1391,6 +1464,17 @@ input.switch:checked::after { transform: translateX(18px); }
.chart-svg .now-rule { stroke: var(--text); stroke-width: 1; opacity: 0.75; pointer-events: none; }
.chart-svg .now-rule-cap { fill: var(--text); opacity: 0.75; pointer-events: none; }
/* The fit through the food bars. Dashed and in the weight colour rather than
the food one: it is a reading of the bars, not another bar, and the same
teal carries the other charts' "this is a derived line" (see .trend-avg). */
.chart-svg .food-trend {
stroke: var(--weight);
stroke-width: 2;
stroke-dasharray: 5 3;
stroke-linecap: round;
fill: none;
}
/* Sleep trend lines: today strongest, the reference curves lighter/dashed. */
.chart-svg .trend-today {
stroke: var(--sleep);
@@ -1505,7 +1589,7 @@ input.switch:checked::after { transform: translateX(18px); }
gap: 2px;
}
.ex-name { font-weight: 600; }
.ex-name { font-weight: 600; overflow-wrap: anywhere; }
.ex-meta { color: var(--muted); font-size: 0.8rem; }
button.ex-log {
@@ -1526,11 +1610,13 @@ button.ex-log {
.ex-note {
flex: 1;
min-width: 0;
margin: 0;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.4;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
button.ex-edit { padding: 6px 12px; flex-shrink: 0; }