"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.
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.
"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.
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.
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.
The server has go test; the frontend had nothing, and the things most likely to
break there are the ones hardest to see: the arithmetic behind the charts, a
panel lost while shuffling tabs, a label that truncates on a phone none of us
owns. There is no browser in this loop, so these are what can be checked
without one.
They read the real code rather than copying it. The app is one long IIFE with
nothing exported, and adding a module system or a build step to make it
testable would be a large change in service of a small one — so
checks/extract.mjs reads src/app.js, brace-matches the declarations a check
asks for, and evaluates them. Rename a function and it throws by name. A check
quietly exercising a stale copy of the code would be worse than no check, and
that is the failure mode this avoids.
calendarGridStart is pulled out of renderCalendar as part of this. It is the
one line of the month grid that is easy to get wrong and impossible to notice
— a month starting on the week's first day needs no backing up, one starting
the day before needs six — so it earns a name and a test.
The width figures are estimates, not measurements: layout numbers come out of
style.css so they cannot drift, text is sized from per-character advances, and
the pass mark demands a few pixels of headroom because the estimate is only
good to a few percent. They will catch a sixth tab or a longer label. They will
not settle a two-pixel question, and nothing here replaces looking at a phone.
No new dependencies: nodejs is already in the devShell for `node --check`, and
checks/ sits outside src/ so it is not served with the app.