Compare commits

..
12 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
Alexander Heldt 59cf567946 Add frontend checks, run from the repo
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.
2026-09-20 10:43:13 +00:00
Alexander Heldt 8d7139b031 Stop a marked day distorting Timing, the trends, and its neighbour
Marking a day "not counted" was implemented by filtering its events out of the
list the cross-day panels are given. That is too blunt a tool, because three of
those panels are not asking "which days count":

  - Timing's marker is how long since the last pee, which is a question about
    now. With the marked day's events gone it answered from the day before —
    27 hours instead of 2 in the case I reproduced, so the marker sat off the
    end of its band.

  - The sleep and walk trends draw the day you are looking at against yesterday
    and the average. Marking that day collapsed its own curve to a flat zero.
    Marking a day means don't let it drag the average, not pretend nothing
    happened on it.

  - A nap from 23:00 on a marked day to 07:00 the next morning lost its
    sleep-start, leaving a dangling sleep-end that pairWindows discards. The
    next day — not marked — lost seven hours it really slept. Nobody reported
    this one; it turned up while reproducing the other two.

None of them needed the filtering, because each already excludes marked days
itself and more precisely than deleting events can: gapsBetween throws away a
gap that *touches* one, the trend loops skip them when averaging, weeklyData
and the actograms zero and hatch them. The filter was a second mechanism
fighting the first. Only the by-hour and training panels still get the filtered
list — they bucket individual events and care about neither day boundaries nor
spans, which is exactly what removing events does.
2026-09-20 10:42:51 +00:00
Alexander Heldt e4b056b5b9 Drop the big clock; keep both timers in the bar, with seconds
The asleep/awake counter rendered twice: a big card at the top of Today, and a
pill in the frozen bar that stayed invisible until the card scrolled out of
sight. That arrangement made the timer hardest to see exactly when you wanted
it — it lived on one tab in five, and hid itself whenever it was on screen.

So the card goes. Both timers sit in the frozen bar, on every tab, always
visible. That takes the standby mechanism with it, along with the rect
comparison that decided when to engage it, renderBigClockCard, and one of the
two jobs the scroll handler was doing.

Having a single place to render them is also what pays for the seconds. They
were cut to minutes last change to buy width; the month grid has since given
back about 93px by taking Today off the bar, which more than covers the 32px
the seconds cost. One row from 360px up now, wrapping only on a 320px SE and
the 280px foldable.

The pills stay at 0.9rem rather than going back to 1rem. It is tempting now
they are the only timer, but there are 6px of slack at 375px and the larger
type adds about 10, which would wrap an iPhone SE 2 and an iPhone 8.
2026-09-09 04:13:03 +00:00
Alexander Heldt fbacd97da7 Replace the date picker with a month grid of our own
The day bar had run out of room: two timers and four day controls came to more
than the bar's width on every phone, so it wrapped onto two rows whenever a walk
was running. Shaving pixels off both groups was not enough — even the most
compact form of it only fitted a 428px iPhone Plus.

The way out was structural. The browser's date picker is a sheet that covers
the screen, which is backwards here: the reason to change day is to see what
the figures did on it, and a modal hides exactly the thing you opened it for.
So the date button now opens a small panel under the bar instead — month name
with arrows, locale-ordered weekday initials, six rows of days — with the
overview still on screen and updating as you move through it.

That also solved the width, because Today belongs inside the grid rather than
beside it. The day controls drop from about 204px to 111px, which is enough for
both timers on one row from 320px up; only a 280px foldable cover still wraps.
The bar keeps its wrapping for that case and for large system font sizes.

The hidden input stays as the value everything reads — selectedDay() and every
caller are untouched, and only the input's own picker is no longer opened.

Six rows always, so the panel does not change height from month to month.
Future days are disabled, matching the bar's → being disabled on today. The
week starts where Intl says it does for the reader's locale, falling back to
Monday. Arrow keys walk the grid and pull the neighbouring month into view at
the edges.

The grid arithmetic is checked rather than eyeballed, both week starts across
every month of a year, leap years and year boundaries — including February
2026, which begins on a Sunday and so needs six leading days from January under
Monday weeks. That is the case a naive `1 - getDay()` gets wrong, and it would
have been invisible until somebody happened to open that month.
2026-09-09 04:03:09 +00:00
Alexander Heldt fba0f73717 Show a timer in the day bar while a walk is on
The bar already carries the asleep/awake counter; a walk in progress had
nothing, so "how long have we been out" meant going to the Walks panel to look.
It gets a second pill now, counting from the walk's start, and tapping it ends
the walk — the same bargain the sleep pill offers for the sleep boundary.

Two timers no longer fit beside the day controls on a narrow phone, so the bar
wraps. That needed the children grouping first: with seven loose ones the break
could land anywhere, and stranding "Today" alone on a second line is worse than
not wrapping at all. The timers are one group and the day controls another, so
the wrap falls between them.

Two things follow from the bar changing height. The tab bar sticks to that
height, and the ResizeObserver added when the pill first appearing had the same
effect already covers it — nothing new needed. And both pills go to standby
together while the big card is on screen: standby is visibility, not display,
so the bar keeps its wrapped height while you scroll and the tab bar beneath it
does not shuffle.

The card shows the walk while there is one. It has room for a single timer, and
you are necessarily awake on a walk, so "awake for 3h" is the less useful of the
two readings; the bar keeps both. That also meant moving the card out of the
early return for "no sleep logged yet" — a walk can be the first thing ever
recorded, and the card was staying blank through it.
2026-09-08 20:46:04 +00:00
Alexander Heldt 7c0ccca1b2 Stop the Today tab jumping the viewport too
The last change stopped showTab scrolling, which fixed four of the five tabs.
Today kept jumping, because getting there is not an ordinary tab switch: it is
a history step. Tapping the tab spends the armed entry with history.back(), the
back button pops the same one, and either way the browser restores the scroll
position it saved against the entry it lands on — wherever you happened to be
when you left Today. showTab scrolling nothing made no difference; the scroll
was the browser's, not ours.

So scroll restoration is turned off for the document. Tabs are not pages and
carry no scroll of their own to restore, so the automatic behaviour has nothing
useful to offer here. It also governs reloads, which now open at the top, which
is the right place for this app to start anyway.

The harness gained an assertion for it, and it was checked by removing the line
and watching it fail — a browser silently undoing what the code just did is
exactly the sort of thing that slips past a test that was never seen to break.
2026-09-07 20:18:31 +00:00
14 changed files with 1745 additions and 160 deletions
+78
View File
@@ -51,6 +51,37 @@ notes) — so each screen holds one subject instead of all fourteen panels in on
column. The day bar and the log buttons sit above the tabs and stay put on all
of them, because logging has to be one tap from wherever you are.
- The day bar carries up to two timers, each of which logs the boundary that
ends what it is counting when tapped: the asleep/awake one, and — only while
a walk is running — the walk. They are frozen at the top on every tab, and
they are the only place a timer appears. There used to be a big card at the
top of Today as well, with the pills standing by until it scrolled out of
sight; a timer you have to scroll to, on one tab in five, is not doing the
job a timer is for. Having one place to render them is also what lets them
carry seconds — they are the display now, not a summary of one.
- **The day picker is a month grid of the app's own**, not the browser's. The
native one is a sheet covering the screen, and the reason to change day is to
see what the figures did on it — so this is a small panel under the bar, with
the overview still visible and updating as you move. `←` and `→` stay in the
bar for the common ±1 day; the grid handles jumps and carries *Today*, which
is what freed the width to fit two timers on one row. The hidden
`<input type="date">` remains the value everything reads; only its own picker
is no longer opened.
- 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
@@ -85,6 +116,13 @@ puppy-tracker/
│ ├── webpush.go # VAPID + RFC 8291/8188 message encryption
│ ├── pedigree.go # SKK lookup, background crawl, per-dog cache
│ └── htmlutil.go # scraping helpers for the pedigree crawl
├── checks/ # frontend checks (see Checks); `node checks/run.mjs`
│ ├── run.mjs # runs every suite, exits non-zero on a failure
│ ├── extract.mjs # pulls declarations out of app.js so checks run real code
│ ├── assert.mjs
│ ├── excluded-days.mjs
│ ├── calendar.mjs
│ └── layout.mjs
└── src/ # the web app
├── index.html
├── app.js
@@ -96,6 +134,37 @@ puppy-tracker/
└── icon-180.png, icon-192.png, icon-512.png
```
## Checks
```sh
nix develop -c sh -c 'cd server && go test ./...' # the server
nix develop -c node checks/run.mjs # the frontend
```
The server has `go test`; `checks/` is the other half. The frontend has no
build step and no test framework, so these are plain scripts with no
dependencies beyond the `nodejs` already in the devShell. They cover the things
that are invisible until they bite:
- **Arithmetic behind the charts** — what a day marked *not counted* does and
does not take out of the numbers, and the month grid's week starts, leap
years and month boundaries.
- **Structure** — that every panel still sits in exactly one tab (losing one
while shuffling tabs is silent), that the stylesheet's braces and comments
balance, that the global `[hidden]` rule is still there.
- **Width budgets** — whether the tab labels and the two day-bar timers still
fit the phones people use.
Two things worth knowing about them. They **extract the real functions out of
`src/app.js`** rather than copying them, so a check cannot quietly go on
testing a stale copy — rename a function and `checks/extract.mjs` throws by
name. And the width figures are **estimates, not measurements**: there is no
browser in the loop, so the layout numbers are read out of `style.css` and the
text is sized from per-character advances, with a couple of pixels of headroom
demanded 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. A real
phone is still the arbiter of anything visual.
## Run locally
```sh
@@ -145,6 +214,15 @@ heading, takes the day you're looking at out of the aggregates.
- **What stops counting** is the behaviour: sleep hours, timeline and trend,
walk minutes and patterns, pee/poo/meal counts, food, by-hour, the training
grid, and the Timing panel's typical gaps.
- **Marking a day is not the same as deleting its events**, and only two panels
are handed a filtered list (by-hour and training, which bucket individual
events and care about neither day boundaries nor spans). The rest take the
whole log and exclude days themselves, because three things break if the
events simply go: "how long since the last pee" is a question about *now* and
answered from the wrong event; the trend curve for the day you are *looking
at* collapses to zero; and a nap from 23:00 on a marked day to 07:00 on the
next loses its `sleep-start`, leaving a dangling `sleep-end` and costing the
next day — which isn't marked — seven hours it really slept.
- **What keeps counting** is weight and notes. A weigh-in and a vet note are
records of fact, not behaviour a sparse logger distorts, so they stay on the
weight curve and in the Notes log.
+28
View File
@@ -0,0 +1,28 @@
// The smallest thing that will do. Checks print a line per assertion so a
// failure says what was expected of the code, not just which line threw.
let fails = 0;
let current = "";
export function suite(name) {
current = name;
console.log(`\n== ${name} ==`);
}
export function eq(got, want, what) {
const g = JSON.stringify(got), w = JSON.stringify(want);
if (g === w) { console.log(` ok ${what}`); return; }
console.log(` FAIL ${what}\n got ${g}\n want ${w}`);
fails++;
}
export function ok(cond, what) {
eq(Boolean(cond), true, what);
}
export function failed() { return fails; }
export function report(file) {
if (fails === 0) console.log(`\n${file}: all passed`);
else console.log(`\n${file}: ${fails} FAILED`);
return fails;
}
+62
View File
@@ -0,0 +1,62 @@
// The month grid behind the date button. Off-by-one week starts and month
// boundaries are how a hand-rolled calendar goes wrong, and none of it shows
// until somebody opens the month that breaks.
import { load } from "./extract.mjs";
import { suite, eq, report } from "./assert.mjs";
const app = load({ names: ["ymd", "calendarGridStart"] });
const MONDAY = 1, SUNDAY = 0;
const cells = (year, monthIdx, start) => {
const gs = app.calendarGridStart(new Date(year, monthIdx, 1), start);
return Array.from({ length: 42 }, (_, i) => {
const d = new Date(gs);
d.setDate(gs.getDate() + i);
return d;
});
};
suite("the grid starts on the locale's first weekday");
for (const [name, start] of [["Monday", MONDAY], ["Sunday", SUNDAY]]) {
const everyMonth = Array.from({ length: 12 }, (_, m) => cells(2026, m, start)[0].getDay());
eq(everyMonth.every(day => day === start), true,
`${name} start: all twelve months of 2026 open on a ${name}`);
}
suite("six rows, covering the month exactly once");
{
let alwaysSix = true, coversAll = true;
for (let m = 0; m < 12; m++) {
const cs = cells(2026, m, MONDAY);
if (cs.length !== 42) alwaysSix = false;
const inMonth = cs.filter(d => d.getMonth() === m).length;
if (inMonth !== new Date(2026, m + 1, 0).getDate()) coversAll = false;
}
eq(alwaysSix, true, "42 cells every month, so the panel never changes height");
eq(coversAll, true, "every day of every month appears exactly once");
}
suite("the months that catch people out");
{
// 1 Feb 2026 is a Sunday: under Monday weeks that needs six leading days from
// January, which is precisely what a naive `1 - getDay()` gets wrong.
const feb = cells(2026, 1, MONDAY);
eq(app.ymd(feb[0]), "2026-01-26", "Feb 2026 starts Sunday: the grid opens on 26 Jan");
eq(app.ymd(feb[6]), "2026-02-01", "…putting the 1st in the last column of row one");
eq(app.ymd(cells(2026, 1, SUNDAY)[0]), "2026-02-01",
"the same month under Sunday weeks needs no leading days at all");
eq(cells(2026, 7, MONDAY).filter(d => d.getMonth() === 7).length, 31,
"a 31-day month starting late in the week still fits");
eq(cells(2024, 1, MONDAY).filter(d => d.getMonth() === 1).length, 29, "Feb 2024 shows 29 days");
eq(cells(2026, 1, MONDAY).filter(d => d.getMonth() === 1).length, 28, "Feb 2026 shows 28");
}
suite("year boundaries");
{
eq(app.ymd(cells(2026, 0, MONDAY)[0]).startsWith("2025"), true,
"January's leading cells come from the previous December");
eq(app.ymd(cells(2026, 11, MONDAY)[41]).startsWith("2027"), true,
"December's trailing cells run into January");
}
export default report("calendar");
+106
View File
@@ -0,0 +1,106 @@
// A day marked "not counted" leaves the averages but stays on the record. The
// subtlety is that marking a day is not the same as deleting its events, and
// three panels break if you treat it that way — see the notes in render().
import { load } from "./extract.mjs";
import { suite, eq, report } from "./assert.mjs";
const app = load({
names: [
"ymd", "startOfDay", "EXCLUDED_TYPE", "ALWAYS_COUNTS", "excludedSet",
"excludedDays", "isExcluded", "countedEvents", "spansExcluded",
"pairWindows", "sleepWindows", "sleepMsInRange", "lastEventOfType",
"gapsBetween",
],
lets: ["excludedSet"],
// gapsBetween defaults its window to the chart picker; the checks pass it in.
stubs: { chartDays: () => 7 },
});
const H = 3600_000;
const at = (day, hour, min = 0) => new Date(2026, 8, day, hour, min).getTime();
const NOW = at(20, 12);
const mark = (day) => ({ id: `x${day}`, type: app.EXCLUDED_TYPE, at: at(day, 12) });
const marking = (events) => { app.set.excludedSet(app.excludedDays(events)); return events; };
suite("what a marked day takes out of the numbers");
{
const evs = marking([
{ id: "a", type: "pee", at: at(18, 9) },
{ id: "b", type: "pee", at: at(19, 9) },
{ id: "c", type: "pee", at: at(20, 9) },
mark(19),
]);
eq(app.gapsBetween(evs, "pee", 14), [],
"a gap reaching across a marked day is discarded rather than measured");
}
{
const evs = marking([
{ id: "d", type: "pee", at: at(18, 8) },
{ id: "e", type: "pee", at: at(18, 12) },
{ id: "f", type: "pee", at: at(20, 8) },
{ id: "g", type: "pee", at: at(20, 12) },
mark(19),
]);
eq(app.gapsBetween(evs, "pee", 14), [4 * H, 4 * H],
"…while the gaps either side of it survive");
}
{
const evs = marking([
{ id: "a", type: "pee", at: at(19, 9) },
{ id: "b", type: "pee", at: at(20, 9) },
{ id: "w", type: "weight", at: at(19, 10) },
{ id: "n", type: "note", at: at(19, 11) },
mark(19),
]);
// The marker sits on the day it marks, so it is filtered out with the rest;
// harmless, since excludedSet was read off the full list beforehand.
eq(app.countedEvents(evs).map(e => e.id).sort(), ["b", "n", "w"],
"behaviour on a marked day is dropped; a weigh-in and a note are not");
app.set.excludedSet(new Set());
eq(app.countedEvents(evs).length, evs.length, "nothing marked means nothing filtered");
}
suite("spansExcluded");
{
marking([mark(19)]);
eq(app.spansExcluded(at(19, 1), at(19, 5)), true, "wholly inside a marked day");
eq(app.spansExcluded(at(18, 23), at(20, 1)), true, "straddling one");
eq(app.spansExcluded(at(20, 1), at(20, 9)), false, "clear of one");
app.set.excludedSet(new Set());
eq(app.spansExcluded(at(18, 1), at(24, 1)), false, "nothing marked, so nothing spans");
}
// These three were live bugs: the panels were handed a list with the marked
// day's events removed, which answers a different question from the one each
// of them is asking.
suite("what a marked day must NOT take out");
{
const evs = marking([
{ id: "p1", type: "pee", at: at(19, 9) },
{ id: "p2", type: "pee", at: at(20, 10) }, // on the marked day
mark(20),
]);
const last = app.lastEventOfType(evs, "pee");
eq((NOW - last.at) / H, 2,
"Timing: 'since the last pee' is about now, so it finds the one on the marked day");
}
{
const evs = marking([
{ id: "s1", type: "sleep-start", at: at(20, 1) },
{ id: "s2", type: "sleep-end", at: at(20, 3) },
mark(20),
]);
eq(app.sleepMsInRange(evs, at(20, 0), NOW) / H, 2,
"Sleep trend: the curve for the day you are looking at shows its real hours");
}
{
const evs = marking([
{ id: "s1", type: "sleep-start", at: at(19, 23) }, // marked day
{ id: "s2", type: "sleep-end", at: at(20, 7) }, // the next, unmarked
mark(19),
]);
eq(app.sleepMsInRange(evs, at(20, 0), at(20, 24)) / H, 7,
"a nap crossing midnight still counts for the unmarked day it ends on");
}
export default report("excluded-days");
+103
View File
@@ -0,0 +1,103 @@
// Pulls named declarations out of src/app.js and evaluates them, so a check
// exercises the code that ships rather than a copy of it.
//
// The app is one long IIFE with nothing exported — it has no build step and no
// module system, and adding either to make it testable would be a large change
// in service of a small one. Reading the source back is the cheaper trade: the
// checks stay honest, and the app stays a file you can open in a browser.
//
// If a declaration is renamed or removed, load() throws by name. That is the
// point: a check that quietly tested a stale copy would be worse than no check.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "src", "app.js");
// Blanks out comments and string bodies so brace counting can't be fooled by a
// `}` inside one. Positions are preserved, so offsets into the result are valid
// offsets into the original.
function mask(src) {
const out = src.split("");
let i = 0;
const blank = (from, to) => { for (let k = from; k < to; k++) if (out[k] !== "\n") out[k] = " "; };
while (i < src.length) {
const c = src[i], next = src[i + 1];
if (c === "/" && next === "/") {
const end = src.indexOf("\n", i); const stop = end === -1 ? src.length : end;
blank(i, stop); i = stop; continue;
}
if (c === "/" && next === "*") {
const end = src.indexOf("*/", i + 2); const stop = end === -1 ? src.length : end + 2;
blank(i, stop); i = stop; continue;
}
if (c === '"' || c === "'" || c === "`") {
let k = i + 1;
while (k < src.length) {
if (src[k] === "\\") { k += 2; continue; }
if (src[k] === c) break;
k++;
}
blank(i + 1, Math.min(k, src.length)); i = Math.min(k + 1, src.length); continue;
}
i++;
}
return out.join("");
}
// The source of one top-level declaration, brace-matched from its opening line.
function declaration(src, masked, name) {
const patterns = [
new RegExp(`^ {2}(?:async )?function ${name}\\b`, "m"),
new RegExp(`^ {2}(?:const|let) ${name}\\b`, "m"),
];
for (const re of patterns) {
const m = re.exec(masked);
if (!m) continue;
const start = m.index;
// A function runs to its matching close brace; a const/let to the newline
// after the statement that balances its own brackets.
let depth = 0, seen = false, i = start;
for (; i < masked.length; i++) {
const ch = masked[i];
if (ch === "{" || ch === "(" || ch === "[") { depth++; seen = true; }
else if (ch === "}" || ch === ")" || ch === "]") {
depth--;
if (depth === 0 && seen && ch === "}" && /function/.test(m[0])) return src.slice(start, i + 1);
} else if (ch === ";" && depth === 0 && !/function/.test(m[0])) {
return src.slice(start, i + 1);
}
}
}
throw new Error(
`checks/extract: could not find "${name}" in src/app.js.\n` +
`It was probably renamed or removed — update the check that asks for it.`);
}
/**
* 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 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
*/
export function load({ names, lets = [], stubs = {} }) {
const src = readFileSync(SRC, "utf8");
const masked = mask(src);
const body = names.map(n => declaration(src, masked, n)).join("\n\n");
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} }, get: { ${getters} } };
`);
return factory(...stubNames.map(n => stubs[n]));
}
export const appSource = () => readFileSync(SRC, "utf8");
+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");
+196
View File
@@ -0,0 +1,196 @@
// Structural facts about the markup and stylesheet that no amount of reading
// the diff reliably catches: a panel lost while shuffling tabs, a comment half
// removed, a label that truncates on a phone nobody here owns.
//
// The width arithmetic is an estimate, not a measurement — there is no browser
// in this loop. Layout numbers are read out of style.css so they cannot drift
// from the real ones, text is sized from per-character advances, and the pass
// mark insists on a few pixels of headroom because the estimate is only good
// to a few percent. It will catch a sixth tab or a longer label; it will not
// settle a two-pixel question.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { suite, eq, ok, report } from "./assert.mjs";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
const html = readFileSync(join(root, "src", "index.html"), "utf8");
const css = readFileSync(join(root, "src", "style.css"), "utf8");
// ---------------------------------------------------------------- stylesheet
suite("the stylesheet is structurally intact");
{
let depth = 0, pairs = 0, stray = 0;
for (let i = 0; i < css.length - 1; i++) {
if (css[i] === "/" && css[i + 1] === "*") { depth++; pairs++; i++; }
else if (css[i] === "*" && css[i + 1] === "/") { depth--; if (depth < 0) stray++; i++; }
}
eq(depth, 0, `comments open and close in pairs (${pairs})`);
eq(stray, 0, "no stray comment terminators");
const bare = css.replace(/\/\*[\s\S]*?\*\//g, "");
eq((bare.match(/\{/g) || []).length, (bare.match(/\}/g) || []).length, "braces balance");
eq(/\*\//.test(bare), false, "no orphaned comment tails left by an edit");
// Author rules that set a display beat the user agent's [hidden] rule, so
// hidden elements keep rendering without this. It has caught five so far.
ok(/^\[hidden\] \{ display: none !important; \}$/m.test(css),
"the global [hidden] rule is present");
}
// ------------------------------------------------------------------ the tabs
suite("every panel lives in exactly one tab");
{
const EXPECTED = [
"overview", "sleep-wake", "history",
"sleep-daily", "sleep-timeline", "sleep-trend",
"walks", "walk-timeline", "walk-trend",
"timing", "counts",
"weight", "training", "notes",
];
const main = html.slice(html.indexOf(" <main>"), html.indexOf(" </main>"));
let current = null;
const placement = new Map();
const wrappers = [];
for (const line of main.split("\n")) {
const open = line.match(/^ {6}<div class="tab-panel" data-tab="([^"]+)"/);
if (open) { current = open[1]; wrappers.push(current); continue; }
if (/^ {6}<\/div>/.test(line) && current) { current = null; continue; }
const panel = line.match(/data-panel="([^"]+)"/);
if (panel && /^ {8}<section/.test(line)) {
if (!placement.has(panel[1])) placement.set(panel[1], []);
placement.get(panel[1]).push(current);
}
}
const missing = EXPECTED.filter(k => !placement.has(k));
const duplicated = EXPECTED.filter(k => (placement.get(k) || []).length > 1);
const loose = EXPECTED.filter(k => (placement.get(k) || [null])[0] === null);
const unexpected = [...placement.keys()].filter(k => !EXPECTED.includes(k));
eq(missing, [], "no panel has gone missing");
eq(duplicated, [], "no panel appears twice");
eq(loose, [], "no panel sits outside a tab");
eq(unexpected, [], "no unaccounted-for panel has appeared");
const buttons = [...html.matchAll(/class="tab" role="tab" data-tab="([^"]+)"/g)].map(m => m[1]);
eq(buttons, wrappers, "a button for each tab, in the same order");
const ariaOk = buttons.every(t =>
html.includes(`id="tab-${t}" aria-controls="tabpanel-${t}"`) &&
html.includes(`id="tabpanel-${t}" role="tabpanel" aria-labelledby="tab-${t}"`));
ok(ariaOk, "aria-controls and aria-labelledby paired on every tab");
// renderWalkPatterns hides these two by hand, and reaches for them by tag.
for (const k of ["walk-timeline", "walk-trend"]) {
ok(new RegExp(`<section[^>]*data-panel="${k}"`).test(html),
`"${k}" is still a <section>, which renderWalkPatterns queries for`);
}
}
// ----------------------------------------------------------------- the widths
const ADV = { cap: 0.72, lower: 0.52, digit: 0.6, thin: 0.28 };
const NARROW = { i: 0.26, l: 0.26, t: 0.35, j: 0.26, f: 0.32, r: 0.37 };
const em = (s) => [...s].reduce((n, ch) => {
if (ch >= "0" && ch <= "9") return n + ADV.digit;
if (ch === ":" || ch === " ") return n + ADV.thin;
if (ch >= "A" && ch <= "Z") return n + ADV.cap;
if (ch === "←" || ch === "→") return n + 0.6;
return n + (NARROW[ch] ?? ADV.lower);
}, 0);
const EMOJI = 18;
const BODY_GUTTER = 16;
const rule = (re) => (css.match(re) || [, ""])[1] || "";
const px = (block, prop, dflt) => {
const m = block.match(new RegExp(`(?:^|[;{\\s])${prop}:\\s*([\\d.]+)(rem|px)`));
return m ? (m[2] === "rem" ? parseFloat(m[1]) * 16 : parseFloat(m[1])) : dflt;
};
const DEVICES = [
["Galaxy Fold cover", 280], ["iPhone SE 1 / small Android", 320],
["Galaxy S / common Android", 360], ["iPhone SE 2-3", 375],
["iPhone 14 / Pixel", 393], ["iPhone 14 Plus", 428],
];
const narrowBlock = rule(/max-width: 370px\)\s*\{([\s\S]*?)\n\}/);
const inNarrow = (sel) => narrowBlock.match(new RegExp(`${sel} \\{[^}]*\\}`))?.[0] ?? "";
suite("the tab labels fit without truncating");
{
const labels = [...html.matchAll(/class="tab" role="tab"[^>]*>([^<]+)</g)].map(m => m[1]);
const wide = { gap: px(rule(/\n\.tabs \{([^}]*)\}/), "gap", 4), barPad: 4, tabPad: 4,
font: px(rule(/\n\.tabs \.tab \{([^}]*)\}/), "font-size", 13.6) };
const narrow = { gap: px(inNarrow("\\.tabs"), "gap", wide.gap),
barPad: px(inNarrow("\\.tabs"), "padding-left", wide.barPad),
tabPad: px(inNarrow("\\.tabs \\.tab"), "padding-left", wide.tabPad),
font: px(inNarrow("\\.tabs \\.tab"), "font-size", wide.font) };
const widest = labels.reduce((a, b) => (em(a) > em(b) ? a : b));
for (const [name, w] of DEVICES) {
const v = w <= 370 ? narrow : wide;
const room = (Math.min(w, 720) - 2 * BODY_GUTTER - 2 * v.barPad
- (labels.length - 1) * v.gap) / labels.length - 2 * v.tabPad;
const need = em(widest) * v.font;
ok(room - need >= 2, `${String(w).padStart(4)}px ${name}: "${widest}" fits ` +
`(${room.toFixed(0)}px of room, ${need.toFixed(0)}px needed)`);
}
}
suite("both day-bar timers fit on a row of their own");
{
// The bar may wrap when it must; what it must not do is squash the timers.
const pillCss = rule(/\n\.bar-clock \{([^}]*)\}/);
const wide = { font: px(pillCss, "font-size", 16), padX: 9, gap: px(pillCss, "gap", 4) };
const narrowPill = inNarrow("\\.bar-clock");
const narrow = { font: px(narrowPill, "font-size", wide.font),
padX: px(narrowPill, "padding", wide.padX), gap: px(narrowPill, "gap", wide.gap) };
const barPadX = 10, groupGap = 6;
const worst = "3:12:45"; // both timers past an hour
for (const [name, w] of DEVICES) {
const v = w <= 370 ? narrow : wide;
const pill = EMOJI + v.gap + em(worst) * v.font + 2 * v.padX;
const room = Math.min(w, 720) - 2 * BODY_GUTTER - 2 * barPadX;
ok(room - (2 * pill + groupGap) >= 2,
`${String(w).padStart(4)}px ${name}: two timers fit ` +
`(${room.toFixed(0)}px of room, ${(2 * pill + groupGap).toFixed(0)}px needed)`);
}
}
// 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");
+53
View File
@@ -0,0 +1,53 @@
// Runs every check. Exits non-zero if any assertion failed.
//
// nix develop -c node checks/run.mjs
//
// These sit beside the Go tests rather than replacing them: the server has
// `go test`, and this is the frontend's half — the arithmetic behind the
// charts, the structure of the markup, and the width budgets that no one here
// can see. It needs no build step and no dependencies; node is already in the
// devShell for `node --check`.
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { readdirSync } from "node:fs";
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, "..");
let failed = 0;
// The frontend has no build step, so a syntax error would otherwise only show
// up in a browser nobody here is running.
for (const file of ["app.js", "sw.js"]) {
try {
execFileSync(process.execPath, ["--check", join(root, "src", file)], { stdio: "pipe" });
console.log(` ok src/${file} parses`);
} catch (e) {
console.log(` FAIL src/${file}\n${e.stderr?.toString() ?? e.message}`);
failed++;
}
}
try {
JSON.parse(readdirSync(join(root, "src")).includes("changelog.json")
? (await import("node:fs")).readFileSync(join(root, "src", "changelog.json"), "utf8")
: "[]");
console.log(" ok src/changelog.json parses");
} catch (e) {
console.log(` FAIL src/changelog.json: ${e.message}`);
failed++;
}
const suites = readdirSync(here)
.filter(f => f.endsWith(".mjs") && !["run.mjs", "extract.mjs", "assert.mjs"].includes(f))
.sort();
for (const file of suites) {
const mod = await import(join(here, file));
failed += mod.default ?? 0;
}
console.log(failed === 0
? `\nall checks passed (${suites.length} suites)`
: `\n${failed} assertion(s) FAILED`);
process.exit(failed === 0 ? 0 : 1);
+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");
+535 -93
View File
@@ -983,69 +983,67 @@
// Tracks the latest sleep transition so the 1-second tick can update the
// counter without re-deriving from the event log.
let bigClockState = null; // "asleep" | "awake" | null
let bigClockSince = 0;
let sleepState = null; // "asleep" | "awake" | null
let sleepSince = 0;
let walkSince = 0; // start of the walk in progress, 0 when none is
// The counter renders twice: the big card at the top of the page and the
// compact pill in the frozen day bar. The pill only *shows* once the big
// card is scrolled out of sight (see updateBarClockMode); while the card is
// visible the pill is invisible but keeps its slot so the bar never shifts.
function updateBarClockMode() {
const pill = document.getElementById("bar-clock");
if (!bigClockState || pill.hidden) return;
const card = document.getElementById("big-clock");
const bar = document.querySelector(".day-bar");
// The card lives on the Today tab, so on any other tab it isn't on screen
// at all and the pill is the only timer there is. Asked explicitly rather
// than left to the rect: a hidden element measures as zeroes, which would
// give the right answer here by coincidence rather than by rule.
const cardOnThisTab = card.offsetParent !== null;
const cardVisible = cardOnThisTab &&
card.getBoundingClientRect().bottom > bar.getBoundingClientRect().bottom;
pill.classList.toggle("standby", cardVisible);
// The walk currently in progress, if any. pairWindows already marks an
// unmatched walk-start as ongoing, so this is just the last such window —
// there can only be one, since a start closes the previous pair.
function currentWalkStart(events) {
const open = walkWindows(events).find(w => w.ongoing);
return open ? open.start : 0;
}
function renderBigClock(events) {
const card = document.getElementById("big-clock");
const label = document.getElementById("bc-label");
const time = document.getElementById("bc-time");
const since = document.getElementById("bc-since");
// Both timers live in the frozen bar and nowhere else. There used to be a big
// card at the top of Today as well, with the pills standing by until it
// scrolled out of sight — but a timer you have to scroll to, on one tab out
// of five, is not doing the job a timer is for. The bar shows them always,
// and having only one place to render them is what lets them carry seconds
// again: they are the display now, not a summary of one.
function renderTimers(events) {
const pill = document.getElementById("bar-clock");
const icon = document.getElementById("bar-clock-icon");
const ptime = document.getElementById("bar-clock-time");
const { state, since: ts } = currentSleepState(events);
bigClockState = state;
bigClockSince = ts;
if (!state) {
card.hidden = true;
pill.hidden = true;
return;
}
card.hidden = false;
sleepState = state;
sleepSince = ts;
// The two are independent: the walk pill comes and goes with the walk, and
// the sleep pill is unaffected by it.
walkSince = currentWalkStart(events);
renderWalkPill();
if (!state) { pill.hidden = true; return; }
pill.hidden = false;
card.classList.toggle("asleep", state === "asleep");
card.classList.toggle("awake", state === "awake");
pill.classList.toggle("asleep", state === "asleep");
pill.classList.toggle("awake", state === "awake");
label.textContent = state === "asleep" ? "Asleep for" : "Awake for";
const counter = formatCounter(Date.now() - ts);
time.textContent = counter;
ptime.textContent = counter;
since.textContent = `since ${formatTime(ts)}`;
ptime.textContent = formatCounter(Date.now() - ts);
icon.textContent = state === "asleep" ? "😴" : "☀️";
const flip = state === "asleep" ? "Sleep end" : "Sleep start";
pill.title = `${state === "asleep" ? "Asleep" : "Awake"} since ${formatTime(ts)} — tap to log ${flip.toLowerCase()}`;
pill.setAttribute("aria-label", `${state === "asleep" ? "Asleep" : "Awake"} since ${formatTime(ts)}. Log ${flip.toLowerCase()}.`);
updateBarClockMode();
}
function tickBigClock() {
if (!bigClockState) return;
const counter = formatCounter(Date.now() - bigClockSince);
const time = document.getElementById("bc-time");
const ptime = document.getElementById("bar-clock-time");
if (time) time.textContent = counter;
if (ptime) ptime.textContent = counter;
function renderWalkPill() {
const pill = document.getElementById("bar-walk");
const wtime = document.getElementById("bar-walk-time");
if (!walkSince) { pill.hidden = true; return; }
pill.hidden = false;
wtime.textContent = formatCounter(Date.now() - walkSince);
pill.title = `Walking since ${formatTime(walkSince)} — tap to log walk end`;
pill.setAttribute("aria-label", `Walking since ${formatTime(walkSince)}. Log walk end.`);
}
function tickTimers() {
if (sleepState) {
const ptime = document.getElementById("bar-clock-time");
if (ptime) ptime.textContent = formatCounter(Date.now() - sleepSince);
}
if (walkSince) {
const wtime = document.getElementById("bar-walk-time");
if (wtime) wtime.textContent = formatCounter(Date.now() - walkSince);
}
}
function renderWindowList(listId, emptyId, windows, ongoingLabel, extraClass) {
@@ -1168,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
@@ -1217,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");
@@ -1264,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");
@@ -1382,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,
});
}
@@ -1591,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");
@@ -1644,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) {
@@ -1883,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;
@@ -1909,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),
};
@@ -1972,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;
@@ -2125,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
@@ -2161,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);
@@ -2181,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) {
@@ -2278,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) {
@@ -2458,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);
}
@@ -2670,8 +2940,8 @@
function renderDayBar() {
const day = selectedDay();
const isToday = ymd(day) === ymd(new Date());
document.getElementById("day-next").disabled = isToday;
document.getElementById("day-today").disabled = isToday;
document.getElementById("day-next").disabled = isToday;
document.getElementById("cal-today").disabled = isToday;
// Year-less date on the picker's face — the year is implicit and the
// saved width keeps the bar on one row on small phones.
document.getElementById("day-date-face").textContent =
@@ -2783,7 +3053,7 @@
renderChartWindow();
// Day-scoped panels get the full list: you navigated to this day, so you
// should see what is actually on it, marked or not.
renderBigClock(events);
renderTimers(events);
renderActionHints(events);
renderStats(events);
renderLasts(events);
@@ -2794,12 +3064,31 @@
// logger distorts, so they count everywhere regardless.
renderWeight(events);
renderNotes(events);
// Everything that aggregates across days works from the counted list.
renderTiming(counted);
renderWeekly(counted);
renderSleepTimeline(counted);
renderWalkPatterns(counted);
renderSleepTrend(counted);
// 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:
//
// - gapsBetween throws away a gap that *touches* a marked day, and
// "how long since the last one" is a question about now, not about
// the window — with the events gone it answered from the wrong one.
// - the trend curves skip marked days when averaging, but the curve for
// the day you are looking at is about that day; marking it means
// "don't let it drag the average", not "pretend nothing happened".
// - weeklyData and the actograms zero and hatch a marked day themselves,
// and need the events either side of it: a nap from 23:00 on a marked
// day to 07:00 on the next belongs, for those seven hours, to the next
// day — which is not marked and should show them. Dropping the
// sleep-start left a dangling sleep-end and lost the window entirely.
renderTiming(events);
renderWeekly(events);
renderSleepTimeline(events);
renderWalkPatterns(events);
renderSleepTrend(events);
// These two bucket individual events with no notion of a day boundary or a
// span, so removing the marked days' events is exactly the right tool.
renderHourHeatmap(counted);
renderTraining(counted);
}
@@ -4721,6 +5010,16 @@
// from tapping the tab.
let backArmed = false;
// Returning to Today is a history navigation — by the back button, or by
// tapping the tab, which spends the same entry — and a browser restores the
// scroll position it saved against the entry it lands on. That position is
// wherever you happened to be when you left Today, so the viewport jumped on
// arrival even though showTab itself scrolls nothing. Tabs are not pages and
// carry no scroll of their own to restore, so the automatic behaviour has
// nothing useful to offer here and is turned off. (It also governs reloads,
// which now open at the top — the right place to start anyway.)
if ("scrollRestoration" in history) history.scrollRestoration = "manual";
function armBack() {
if (backArmed) return;
history.pushState({ puppyTab: true }, "");
@@ -4765,10 +5064,8 @@
history.back();
}
}
// The pill's visibility depends on whether the big card is on screen, and
// the card only exists on one tab. A shorter tab can also leave the page
// too short to stay scrolled, unsticking the bars, so re-check that too.
updateBarClockMode();
// A shorter tab can leave the page too short to stay scrolled, which
// unsticks the two frozen bars and parts them again.
updateTabsMerged();
}
@@ -4833,14 +5130,20 @@
// The timer pill doubles as a one-tap sleep toggle: tapping it logs the
// boundary that ends the state it shows, exactly like the matching quick
// action. Only reachable once the big card has scrolled away — in standby
// the pill is visibility:hidden, so clicks can never land on it.
// action.
document.getElementById("bar-clock").addEventListener("click", () => {
const { state } = currentSleepState(live());
if (!state) return; // no sleep history yet; the pill is hidden anyway
quickLog(state === "asleep" ? "sleep-end" : "sleep-start");
});
document.getElementById("bar-walk").addEventListener("click", () => {
// Re-read rather than trusting walkSince: the pill is only shown while a
// walk is open, but another device may have ended it since the last render.
if (!currentWalkStart(live())) return;
quickLog("walk-end");
});
document.querySelectorAll(".chart-days-picker button").forEach(b => {
b.addEventListener("click", () => setChartDays(Number(b.dataset.days)));
});
@@ -4853,19 +5156,14 @@
});
});
// Swap the timer pill in/out of the frozen bar as the big card scrolls past,
// and join the two frozen bars up once they meet. rAF-throttled: scroll
// events fire far more often than we can paint.
// Join the two frozen bars up once they meet. rAF-throttled: scroll events
// fire far more often than we can paint.
{
let queued = false;
const onScroll = () => {
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
updateBarClockMode();
updateTabsMerged();
});
requestAnimationFrame(() => { queued = false; updateTabsMerged(); });
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll, { passive: true });
@@ -4876,13 +5174,146 @@
// The face opens the hidden input's native picker (iOS 16+ has showPicker;
// focus() is the fallback and is what pops the picker on older iOS anyway).
document.getElementById("day-date-face").addEventListener("click", () => {
// ---------- the month grid ----------
// The browser's own date picker is a sheet that covers the screen, which is
// exactly wrong here: the reason to change day is to see what the figures
// did on it, and a modal hides them. This one is a small panel under the
// bar, so the overview stays visible and updates as you move.
const dayCal = document.getElementById("day-cal");
const dayFace = document.getElementById("day-date-face");
const calMonthEl = document.getElementById("cal-month");
const calWeekdays = document.getElementById("cal-weekdays");
const calGrid = document.getElementById("cal-grid");
let calMonth = null; // first of the month on show
// Which weekday a week starts on, per the reader's locale — Monday in most of
// Europe, Sunday in the US. Intl knows; older engines don't, and the app's own
// day boundaries are local-midnight either way, so Monday is the fallback.
function firstDayOfWeek() {
try {
if (typeof dayPicker.showPicker === "function") dayPicker.showPicker();
else dayPicker.focus();
} catch {
dayPicker.focus();
const loc = new Intl.Locale(navigator.language);
const info = loc.weekInfo || (typeof loc.getWeekInfo === "function" ? loc.getWeekInfo() : null);
if (info && info.firstDay) return info.firstDay % 7; // Intl 1..7 (Mon..Sun) → JS 1..0
} catch { /* not supported; fall through */ }
return 1;
}
// The top-left cell of the grid: back up from the 1st to the most recent
// `start` weekday, which is nought to six days. Named because it is the one
// line of the calendar that is easy to get wrong and impossible to notice —
// a month beginning on the week's first day needs no backing up at all, and
// one beginning the day before needs six.
function calendarGridStart(calMonth, start) {
const d = new Date(calMonth);
d.setDate(1 - ((calMonth.getDay() - start + 7) % 7));
return d;
}
function renderCalendar() {
const start = firstDayOfWeek();
const selected = ymd(selectedDay());
const todayYmd = ymd(new Date());
calMonthEl.textContent = calMonth.toLocaleDateString(undefined, { month: "long", year: "numeric" });
// Weekday initials, taken from a real week so they follow the locale.
calWeekdays.innerHTML = "";
for (let i = 0; i < 7; i++) {
const d = new Date(2026, 1, 1 + ((start + i - new Date(2026, 1, 1).getDay()) + 7) % 7);
const cell = document.createElement("span");
cell.textContent = d.toLocaleDateString(undefined, { weekday: "narrow" });
calWeekdays.appendChild(cell);
}
// Always six rows, so the panel doesn't change height from month to month.
const gridStart = calendarGridStart(calMonth, start);
calGrid.innerHTML = "";
for (let i = 0; i < 42; i++) {
const d = new Date(gridStart);
d.setDate(gridStart.getDate() + i);
const key = ymd(d);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "cal-day";
btn.textContent = d.getDate();
btn.dataset.day = key;
if (d.getMonth() !== calMonth.getMonth()) btn.classList.add("other-month");
if (key === todayYmd) btn.classList.add("is-today");
if (key === selected) btn.classList.add("is-selected");
btn.setAttribute("aria-selected", String(key === selected));
// There is nothing to show for a day that hasn't happened, and the bar's
// → is disabled on today for the same reason.
if (key > todayYmd) btn.disabled = true;
btn.setAttribute("aria-label",
d.toLocaleDateString(undefined, { weekday: "long", day: "numeric", month: "long", year: "numeric" }));
calGrid.appendChild(btn);
}
}
function openCalendar() {
calMonth = startOfDay(selectedDay());
calMonth.setDate(1);
renderCalendar();
dayCal.hidden = false;
dayFace.setAttribute("aria-expanded", "true");
const sel = calGrid.querySelector(".is-selected") || calGrid.querySelector(".cal-day:not([disabled])");
if (sel) sel.focus();
}
function closeCalendar({ refocus = true } = {}) {
if (dayCal.hidden) return;
dayCal.hidden = true;
dayFace.setAttribute("aria-expanded", "false");
if (refocus) dayFace.focus();
}
function pickDay(key) {
dayPicker.value = key;
closeCalendar();
render();
}
dayFace.addEventListener("click", () => {
if (dayCal.hidden) openCalendar(); else closeCalendar();
});
function shiftCalMonth(months) {
calMonth = new Date(calMonth.getFullYear(), calMonth.getMonth() + months, 1);
renderCalendar();
}
document.getElementById("cal-prev").addEventListener("click", () => shiftCalMonth(-1));
document.getElementById("cal-next").addEventListener("click", () => shiftCalMonth(1));
document.getElementById("cal-today").addEventListener("click", () => pickDay(ymd(new Date())));
calGrid.addEventListener("click", (e) => {
const btn = e.target.closest(".cal-day");
if (btn && !btn.disabled) pickDay(btn.dataset.day);
});
// Arrow keys walk the grid, which is what makes it usable without a mouse;
// moving off the edge of the month brings the neighbouring one into view.
calGrid.addEventListener("keydown", (e) => {
const step = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7 }[e.key];
if (!step) return;
e.preventDefault();
const from = e.target.closest(".cal-day");
if (!from) return;
const [y, m, d] = from.dataset.day.split("-").map(Number);
const to = new Date(y, m - 1, d + step);
if (ymd(to) > ymd(new Date())) return; // nothing beyond today
if (to.getMonth() !== calMonth.getMonth() || to.getFullYear() !== calMonth.getFullYear()) {
calMonth = new Date(to.getFullYear(), to.getMonth(), 1);
}
renderCalendar();
const next = calGrid.querySelector(`[data-day="${ymd(to)}"]`);
if (next) next.focus();
});
dayCal.addEventListener("keydown", (e) => {
if (e.key === "Escape") { e.stopPropagation(); closeCalendar(); }
});
document.addEventListener("click", (e) => {
if (dayCal.hidden) return;
if (!dayCal.contains(e.target) && e.target !== dayFace) closeCalendar({ refocus: false });
});
function shiftSelectedDay(days) {
@@ -4893,14 +5324,25 @@
}
document.getElementById("day-prev").addEventListener("click", () => shiftSelectedDay(-1));
document.getElementById("day-next").addEventListener("click", () => shiftSelectedDay(+1));
document.getElementById("day-today").addEventListener("click", () => {
dayPicker.value = ymd(new Date());
render();
});
document.getElementById("exclude-day").addEventListener("click", () => {
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";
@@ -5061,7 +5503,7 @@
setInterval(() => {
const evs = live();
renderHeader();
renderBigClock(evs);
renderTimers(evs);
renderActionHints(evs);
renderStats(evs);
renderLasts(evs);
@@ -5077,7 +5519,7 @@
if (navigator.onLine && !syncing) setStatus();
}, 60_000);
setInterval(tickBigClock, 1000);
setInterval(tickTimers, 1000);
setInterval(sync, SYNC_POLL_MS);
setInterval(syncConfig, SYNC_POLL_MS);
+10
View File
@@ -1,4 +1,14 @@
[
{ "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" },
{ "date": "2026-09-08", "text": "A walk in progress now has a timer in the frozen bar at the top, next to the asleep/awake one, counting from when the walk started — so you can see how long you have been out from any tab without going to look. Tapping it ends the walk, the same way tapping the sleep timer logs the sleep boundary. On a narrow phone two timers no longer fit beside the date controls, so the bar splits onto two rows while a walk is on and goes back to one when it ends. The big timer card on Today shows the walk too while there is one — you are awake on a walk either way, so the walk is the more useful of the two" },
{ "date": "2026-09-07", "text": "Going back to the Today tab no longer jumps the viewport either — by tapping it or with the back button. The other tabs stopped jumping in the last change, but returning to Today is a history step, and the browser was restoring the scroll position from when you last left it" },
{ "date": "2026-09-07", "text": "The tab row now matches the frozen row above it that holds the date. It was a different shade, and the two sat as separate bars with the date row's rounded corners cutting between them; they share the same card colour now, and once you have scrolled the log buttons away they join into a single rounded block instead of two stacked ones" },
{ "date": "2026-09-07", "text": "Switching tabs no longer jumps the page back to the top. The tab row is frozen to the top of the screen, so you change tabs from wherever you have scrolled to, and being thrown back past the log buttons you had just scrolled off was more disruptive than landing part-way down the new tab" },
{ "date": "2026-09-07", "text": "The page is split into five tabs — Today, Sleep, Walks, Habits and Growth — instead of one long column of fourteen panels. Today has the overview, sleep & wake and the day's history; Sleep and Walks each have their day-by-day chart, their when-it-happens grid and their trend; Habits has the pee, poo and meal timing and counts; Growth has weight, training and notes. Getting to the weight curve no longer means scrolling past everything else. The day bar and the log buttons sit above the tabs and stay there whichever one you're on, so logging is still one tap from anywhere. Folding a panel by tapping its heading works exactly as before, inside its tab, and the app reopens on the tab you left it on. The back button (or the back gesture) returns you to Today from wherever you are, and pressing it again leaves the app — always two presses to get out, however much you'd been flicking between tabs beforehand" },
+71 -27
View File
@@ -108,25 +108,62 @@
<main>
<section class="day-bar">
<!-- Compact twin of the big timer below: invisible (but keeping its
slot) while the big card is on screen, shown once it scrolls
away. Hidden entirely until a sleep event exists. Tapping it logs
the boundary that flips the current state (asleep → sleep end,
awake → sleep start). -->
<button type="button" id="bar-clock" class="bar-clock" hidden>
<span id="bar-clock-icon" aria-hidden="true"></span>
<span id="bar-clock-time"></span>
</button>
<button type="button" id="day-prev" class="ghost" aria-label="Previous day"></button>
<!-- A browser won't let us shorten the text a native date input shows,
so the face button carries a compact year-less date and the real
input stays (visually hidden) as the value + native picker. -->
<span class="day-date">
<button type="button" id="day-date-face" class="ghost"></button>
<input type="date" id="day-picker" tabindex="-1" aria-hidden="true" />
<!-- Two groups, not seven loose children: on a narrow phone a running
walk adds a second pill and the row no longer fits, so it wraps —
and it has to wrap between the timers and the day controls rather
than splitting the controls across two lines. -->
<span class="bar-timers">
<!-- The asleep/awake timer, and the only one there is: it used to be
the compact twin of a big card on Today, shown once that scrolled
away, but a timer you have to scroll to is no timer at all. It is
frozen at the top instead, on every tab. Hidden until a sleep
event exists. Tapping it logs the boundary that flips the current
state (asleep → sleep end, awake → sleep start). -->
<button type="button" id="bar-clock" class="bar-clock" hidden>
<span id="bar-clock-icon" aria-hidden="true"></span>
<span id="bar-clock-time"></span>
</button>
<!-- Only while a walk is running. Tapping it ends the walk, the same
bargain the sleep pill offers. -->
<button type="button" id="bar-walk" class="bar-clock walking" hidden>
<span aria-hidden="true">🦮</span>
<span id="bar-walk-time"></span>
</button>
</span>
<button type="button" id="day-next" class="ghost" aria-label="Next day"></button>
<button type="button" id="day-today" class="ghost">Today</button>
<span class="day-nav">
<button type="button" id="day-prev" class="ghost" aria-label="Previous day"></button>
<!-- A browser won't let us shorten the text a native date input shows,
so the face button carries a compact year-less date and the real
input stays (visually hidden) as the value + native picker. -->
<span class="day-date">
<!-- Opens the month grid below rather than the browser's own date
picker: that one is a full-screen sheet on a phone, and the
point of changing day here is watching the figures underneath
change with it. The input stays as the value and is never
shown — every read of the selected day still goes through it. -->
<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" />
</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">
@@ -182,12 +219,6 @@
</div>
<div class="tab-panel" data-tab="today" id="tabpanel-today" role="tabpanel" aria-labelledby="tab-today" hidden>
<section id="big-clock" class="big-clock" hidden>
<div class="bc-label" id="bc-label"></div>
<div class="bc-time" id="bc-time">0:00</div>
<div class="bc-since" id="bc-since"></div>
</section>
<section class="overview" data-panel="overview">
<!-- The toggle lives here rather than in the day bar: that bar is held
to one row on small phones and a sixth control would break it,
@@ -280,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>
@@ -315,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>
@@ -366,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>
@@ -661,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>
+188 -40
View File
@@ -270,7 +270,13 @@ body::before {
gap: 6px;
align-items: center;
justify-content: flex-end;
flex-wrap: nowrap;
/* Wraps only when it has to which on a narrow phone is while a walk is
running and there are two timers to fit. The two groups above are what
make that wrap land in a sensible place. The bar's height changes when it
does, and the tab bar sticks to that height, so a ResizeObserver keeps
--day-bar-h honest (see measureDayBar). */
flex-wrap: wrap;
row-gap: 6px;
padding: 8px 10px;
}
/* The face button shows the short date; the real input sits invisibly behind
@@ -287,6 +293,74 @@ body::before {
opacity: 0;
pointer-events: none;
}
/* ---------- the month grid ---------- */
/* Anchored under the date button rather than filling the screen the way the
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);
right: 10px; /* the bar's own horizontal padding */
z-index: 70; /* over the day bar itself, which is 60 */
width: 268px;
max-width: calc(100% - 20px);
padding: 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.day-cal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
margin-bottom: 6px;
}
.day-cal-month {
font-weight: 700;
font-size: 0.9rem;
}
.day-cal-weekdays,
.day-cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.day-cal-weekdays span {
text-align: center;
font-size: 0.7rem;
color: var(--muted);
padding: 2px 0;
}
button.cal-day {
background: transparent;
color: var(--text);
font-weight: 500;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
padding: 6px 0;
border-radius: 8px;
}
button.cal-day.other-month { color: var(--muted); opacity: 0.5; }
button.cal-day:disabled { opacity: 0.25; cursor: default; }
button.cal-day:disabled:hover { filter: none; }
/* Today is outlined, the selected day is filled so "where I am" and "where
now is" stay tellable apart when they are different days. */
button.cal-day.is-today { box-shadow: inset 0 0 0 1.5px var(--accent); }
button.cal-day.is-selected {
background: var(--accent);
color: #fff;
font-weight: 700;
}
.day-cal-today { width: 100%; margin-top: 8px; padding: 7px 10px; font-size: 0.85rem; }
#day-date-face {
font-variant-numeric: tabular-nums;
white-space: nowrap;
@@ -309,59 +383,51 @@ body::before {
opacity: 0.4;
cursor: not-allowed;
}
/* The timers sit left, the day controls right. Both are groups so that when a
running walk makes the row too long the bar wraps between them, rather than
stranding "Today" on a line by itself. */
.bar-timers {
display: flex;
align-items: center;
gap: 6px;
margin-right: auto;
min-width: 0;
}
.day-nav {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* An empty timer group must not hold a line open once it has wrapped. */
.bar-timers:empty { display: none; }
.bar-clock {
display: flex;
/* It is a button (tapping it flips the sleep state), so undo the default
accent look the asleep/awake classes below paint it. */
accent look the asleep/awake/walking classes below paint it. */
background: var(--surface);
color: var(--text);
align-items: center;
gap: 6px;
margin-right: auto; /* pin left; the flexible gap sits between it and the day controls */
padding: 7px 10px;
/* Sized so two of these fit beside the day controls on one row. These are
the only timers now, so they are what has to be readable. */
gap: 4px;
padding: 7px 9px;
border-radius: 999px;
font-weight: 700;
font-size: 1rem;
font-size: 0.9rem;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
/* Big timer still on screen: keep the pill's slot but show nothing. */
.bar-clock.standby { visibility: hidden; }
.bar-clock.asleep { background: color-mix(in srgb, var(--sleep) 18%, var(--surface)); color: var(--sleep-ink); }
/* Dark text on the yellow, the way the pee button already does it: a gold
light enough to read as sunshine is never legible as text on a pale ground.
12% where asleep takes 18% equal percentages of these two hues are not
equally strong, and yellow at 18% shouted while the blue did not. */
.bar-clock.awake { background: color-mix(in srgb, var(--wake) 12%, var(--surface)); color: var(--wake-timer-ink); }
.big-clock {
text-align: center;
padding: 24px 16px;
}
.big-clock .bc-label {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin-bottom: 6px;
}
.big-clock .bc-time {
font-size: 3.25rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.05;
letter-spacing: -0.01em;
}
.big-clock .bc-since {
margin-top: 6px;
font-size: 0.8rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.big-clock.asleep { background: linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--sleep) 10%, var(--surface))); }
.big-clock.asleep .bc-time { color: var(--sleep-ink); }
.big-clock.awake { background: linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--wake) 10%, var(--surface))); }
.big-clock.awake .bc-time { color: var(--wake-timer-ink); }
/* The walk timer takes the walk colour the rest of the app already uses for
walks, so the pill says which of the two it is without needing its label. */
.bar-clock.walking { background: color-mix(in srgb, var(--walk) 16%, var(--surface)); color: var(--walk); }
/* Quick actions: a stack of explicit rows (see index.html) instead of one
auto-fit grid, so the grouping is the same at every width. Equal columns
@@ -664,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; }
@@ -1261,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;
@@ -1329,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);
@@ -1443,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 {
@@ -1464,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; }