A phone had started allowing zoom-out, which is how a document wider than the
viewport announces itself. The suspect was the "Ate" modal, but the dialogs are
not it: all seven open with showModal(), so their containing block is the
viewport and width: calc(100% - 32px) cannot exceed it.
It was the month grid, added three commits ago:
left: 50%; transform: translateX(-50%); width: 268px;
max-width bounded the panel's width and nothing bounded its position. Centred
on the date button — which sits near the right edge of the bar — a 268px panel
hangs off the side of a phone, and being absolutely positioned it drags the
document's scrollable width out with it.
Anchoring to the button cannot be made safe: pin it right and it overflows the
left on a narrow screen, pin it left and it overflows the right. So it is a
child of the day bar now, pinned to that bar's inner edge and capped at the
bar's own width. The bar spans the content width exactly, so the panel is on
screen at every size by construction.
A long unbroken word was a second way in, and a pre-existing one. A history
row's note is a flex item with neither min-width: 0 nor a break rule, so a URL
or something copied off a food bag sets its content-based minimum and widens
the row. The Notes log directly below already guarded against precisely this,
so the history row had simply been missed; exercise names and their
instructions had the same gap.
The checks gained the general form of both, since this class of bug is
invisible until a phone starts zooming out: every element that renders text the
user typed must be able to break a long word, the grid must stay edge-anchored
inside the bar, and no fixed width may exceed the content box of a 320px phone.
Each was confirmed to fail with its fix reverted.
The server has go test; the frontend had nothing, and the things most likely to
break there are the ones hardest to see: the arithmetic behind the charts, a
panel lost while shuffling tabs, a label that truncates on a phone none of us
owns. There is no browser in this loop, so these are what can be checked
without one.
They read the real code rather than copying it. The app is one long IIFE with
nothing exported, and adding a module system or a build step to make it
testable would be a large change in service of a small one — so
checks/extract.mjs reads src/app.js, brace-matches the declarations a check
asks for, and evaluates them. Rename a function and it throws by name. A check
quietly exercising a stale copy of the code would be worse than no check, and
that is the failure mode this avoids.
calendarGridStart is pulled out of renderCalendar as part of this. It is the
one line of the month grid that is easy to get wrong and impossible to notice
— a month starting on the week's first day needs no backing up, one starting
the day before needs six — so it earns a name and a test.
The width figures are estimates, not measurements: layout numbers come out of
style.css so they cannot drift, text is sized from per-character advances, and
the pass mark demands a few pixels of headroom because the estimate is only
good to a few percent. They will catch a sixth tab or a longer label. They will
not settle a two-pixel question, and nothing here replaces looking at a phone.
No new dependencies: nodejs is already in the devShell for `node --check`, and
checks/ sits outside src/ so it is not served with the app.
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.
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.
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.
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.
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.
The tab row was painted in the page ground while the day bar above it is a
card, and the two sat as separate bars with the day bar's rounded corners
cutting between them — so the frozen top of the screen read as two mismatched
strips rather than one thing.
It takes the same card now: surface colour, radius and shadow. Once the log
buttons have scrolled away and the two bars meet, they lose the seam and share
one frame — the day bar squares its bottom corners and gives up its shadow, the
tab row squares its top, and the pair casts a single shadow below. Scrolled back
to the top they are two cards again, which is right: the log buttons genuinely
sit between them there.
CSS has no way to ask whether a sticky element is currently stuck, so a class is
toggled from JS on the simplest available test — whether the two are touching.
It rides the rAF-throttled scroll handler that already existed for the timer
pill, so it adds no listener, and it is re-checked on a tab switch (a shorter
tab can leave the page too short to stay scrolled) and when the day bar changes
height.
The tab row's sticky offset is now a pixel less than the day bar's height. The
safe-area inset is free to be fractional while the measured height is a whole
number, and that shortfall would show as a hairline of page ground between the
two; the overlap it costs is invisible, since the day bar paints on top in the
same colour.
Switching tabs scrolled back to the top, on the theory that arriving halfway
down a different tab is disorienting. In practice it is the wrong way round:
the tab row is sticky, so you switch tabs *from* wherever you have scrolled to,
and being thrown back past the log buttons you had deliberately scrolled off is
more disruptive than landing part-way down the new tab.
So it simply does not scroll now. If the new tab is shorter than the old scroll
position the browser clamps on its own and needs no help.
The `scroll` option goes with it rather than being defaulted off — nothing
passes it any more, and a parameter no caller uses is a worse thing to leave
behind than the behaviour it guarded.
Five labels across a 320px viewport is tighter than it looks. The body's own
16px gutters, the bar's padding and four gaps leave about 45px of text per tab,
and "Growth" — the widest label at roughly 3.2em — wants 46px at the default
size. So on an iPhone SE and the smaller Androids it truncated to "Growt…", and
on a 280px foldable cover screen it was 7px short.
The same 370px breakpoint the day bar already uses for the same reason now
trims the type to 0.75rem and the horizontal padding to 2px, which takes the
widest label to about 39px against 43px of room even at 280px. Above the
breakpoint nothing changes; there was never a problem there.
Also fixes the sticky offset going stale. The tab bar sits directly under the
day bar, so its `top` is that bar's measured height, published as --day-bar-h.
That was measured on load and on resize — but the bar also grows the first time
a sleep is logged, when the timer pill appears inside it, and that fires no
resize, leaving the tab bar overlapping the day bar until something else
happened to trigger one. A ResizeObserver on the bar covers that and every
other cause, with the resize listener kept as the fallback.
The label arithmetic is now checked rather than eyeballed, since no browser is
available here: the layout numbers are read back out of the stylesheet so they
cannot drift, text width is estimated from per-character advances, and the pass
mark demands real headroom rather than a bare fit because the estimate is only
good to a few percent. Removing the new rules makes it fail on exactly the two
devices that were broken, which is the only way to know a check like that is
doing anything.
Fourteen panels sat in one column, so reaching the weight curve meant scrolling
past sleep, timing, walks and counts. The page had only grown — walks, walk
patterns, training and the excluded-day marker all landed on the same scroll —
and folding panels away, while it helps, is a per-panel fiddle you then have to
undo to look at anything.
They are grouped by subject now: Today (overview, sleep & wake, history), Sleep,
Walks, Habits (pee/poo/meal timing and counts) and Growth (weight, training,
notes). No tab holds more than three. The day bar and the log buttons stay above
them on every tab, because logging has to be one tap from wherever you are, and
the tab bar sticks under the day bar — two stacked stickies need the second's
offset to be the first's height, so that height is measured and published as
--day-bar-h rather than guessed.
What gets hidden is the wrapper, never the sections inside it. walk-timeline and
walk-trend carry their own hidden, set by renderWalkPatterns once a walk exists,
and hiding them directly would clobber it.
render() still draws every panel on every pass, hidden tabs included. Nothing
measures layout — the charts scale through their viewBox, and the one
getBoundingClientRect belongs to the timer pill — so drawing into a hidden
wrapper is safe, and a tab is never briefly stale when you arrive on it. That
pill's own check gains an explicit "is the card's tab showing": a hidden element
measures as zeroes, which gave the right answer here by coincidence rather than
by rule.
Back returns to Today from any tab in one press, and a second press leaves.
Exactly one history entry is ever live, armed on leaving Today and spent on
returning — including when the return is a tap on the Today tab, which would
otherwise strand the entry and make the next press appear to do nothing. An
entry per switch is what a browser does unaided, and is why tabbed apps get a
reputation for trapping you.
Folding is untouched and composes: tabs group, folding tunes what shows within a
group. Both selectors that reach for panels are descendant selectors, so the
extra nesting cost them nothing.
The reordering was scripted rather than done by hand — fourteen sections moving
between five wrappers is how you silently lose one — and a check now asserts
every panel sits in exactly one tab, that buttons and wrappers correspond, and
that the aria pairs are wired. The first run of that script dropped three
explanatory comments along the way, which is exactly the sort of thing it exists
to catch.
The URL was shown once, in a box under the create button, and then gone: only
a hash of the token was stored, so the app genuinely could not produce it a
second time. Lose the message you sent the sitter and the only way back was to
mint a new link — which strands whoever is already holding the old one.
Settings now lists every live link with its URL and a Copy button, so re-sending
one is just copying it again.
That means keeping the token rather than only its hash, and it is worth being
plain about the trade. It is not the trade you would make for a password, which
the user has probably reused, or a session token, which grants everything
indefinitely. A guest link grants a strict subset of what the same database
already holds in plaintext, expires on a date the owner picked, and can be
revoked in one tap — so an attacker who can read puppy.db gains very little by
also being able to open it as a guest. The lookup column stays a hash and
remains the key redeem matches against; the secret sits in a new column beside
it, which also keeps the migration additive.
Links created before this have an empty secret. They keep working and stay
revocable — the migration touches nothing but the new column — and the list
says why their URL is missing rather than rendering a broken one.
The two tests that asserted the old contract now assert the new one: a listing
hands back a secret that really opens the link, and the lookup column is still
a hash. Added one for the legacy row, since "still works, just cannot be shown"
is the part a future change is most likely to break quietly.
A guest opening an entry the owner had logged still saw Delete and Save on it.
The read-only branch had been setting .hidden on both since guest links landed;
the attribute was there, and the buttons rendered anyway.
The user agent hides [hidden] elements with display: none, but that is a UA
rule and loses to any author rule setting a display — and `button` here sets
display: inline-flex, so no button the app hides has ever actually gone away.
The stylesheet already knew this in eight places: .auth-screen[hidden],
.snackbar[hidden], dialog label[hidden] and five more, each added when someone
noticed that particular element misbehaving. What that pattern cannot do is fix
the cases nobody has hit yet, and there were five sitting there: the 🌳 pedigree
button showed before a pedigree ID was set, the reminder rule rows showed while
unsubscribed, "send a test notification" showed when push was unavailable, the
pedigree disambiguation list stayed up after being dismissed, and the exercise
dialog offered Delete while adding a new exercise rather than editing one.
So this states it once, globally, and drops all ten per-selector rules. It
needs !important: the offenders are not all the same weight, and
`#reminders-rules { display: flex }` is an id selector that outranks any
attribute selector available. That is the right trade here — nothing should
ever want an element painted after being marked hidden, so there is no case the
blunt version gets wrong.
Only the two dialog buttons are new breakage; the other five predate the recent
work and are the reason this is a global fix rather than a sixth patch.
Every logged day was treated as equally trustworthy, and they aren't. A day
someone else had the puppy leaves a thin record that reads exactly like a real
one — five hours of sleep, two pees, no walk — and then drags down the average,
widens the longest gap in the Timing panel and puts a trough in every chart
that never happened. "Not counted", in the overview panel's heading, takes the
day you are looking at out of everything that aggregates across days.
Nothing is deleted or hidden. The day's own overview, history and sleep & wake
list are exactly as they were, dimmed and labelled; navigate to it and it is
all still there. Only the cross-day views stop seeing it, and weight and notes
keep counting wherever they fall — a weigh-in and a vet note are facts you
recorded, not behaviour a sparse logger distorts.
The mark is an ordinary event, the way a training session is. That was the
whole reason to do it this way: a set of marks that sync per-item with
last-write-wins and tombstones is exactly what the event contract already
provides, so un-marking is a delete, offline works, and two devices marking the
same day resolve themselves. An excluded_days table would have meant a table,
an endpoint, a request/response pair and a client cache to re-derive semantics
already in hand. Every renderer selects events by type, so a new type is inert
everywhere it isn't wanted; only the History log has to filter it out, being
the one view that shows whatever it is handed.
render() already computed the event list once and fanned it out, which made the
seam a single place: day-scoped panels keep the full list, weight and notes
keep it too, and the seven cross-day renderers take a counted one.
Filtering alone gets two things wrong, and those are most of the diff.
An empty slot lies. A marked day with no events draws a zero bar, which reads
as "the puppy barely slept" — precisely the misreading the mark exists to
prevent. So weeklyData zeroes the day's figures and flags it, and the four bar
charts, both actograms and the training grid paint a hatch in the slot instead.
Zeroing centrally rather than in each chart means every axis maximum, total and
tooltip downstream is already right. The slot stays: dropping it would make
consecutive bars stop being consecutive days.
Gaps balloon. gapsBetween subtracts consecutive events, so with a day's events
gone Tuesday's last pee sits next to Thursday's first and the subtraction
invents thirty hours — worse for the panel than the sparse day ever was. Any
gap whose interval touches a marked day is therefore discarded rather than
measured. Sleep and walk durations need no such care: sleepMsInRange and
walkMsInRange already clip to the day being measured, so a nap running in from
a marked day contributes only its counted part.
Both trend charts skip marked days explicitly rather than leaning on their
existing "any sleep at all" guard, which would have let a nap crossing midnight
give a marked day a non-zero total and sneak it back into the average.
Owner-only, alongside the rest of what a guest may not decide: a sitter should
not be able to rule their own thin day out, nor quietly take a good one out of
the averages. The server drops day-excluded events arriving on a guest session;
the client hides the control to match.
Handing a dog sitter the ability to log a pee meant handing them the account
password: permanent, total control, revocable only by changing it. Settings →
Guest access now mints a URL that does the one thing instead.
A link is a session, not an account. Opening /guest/<token> inserts an ordinary
session row against the owner's user_id, tagged with the link it came from, so
every data path downstream — sync, photos, the profile — stays scoped by
user_id exactly as before and needed no changes at all. Only the capability
checks differ by role, which is what kept this from touching the sync contract.
Redemption is a plain GET so tapping the link in a message works, and the 303
to / leaves the token out of the address bar, bookmarks and the PWA start URL.
What a guest cannot change is enforced in the upsert, not in the UI. The WHERE
clause gains a logged_by_share test: an owner (empty share id) may change
anything, a guest only rows carrying their own link's id. A sitter can fix up
their own entries and cannot rewrite or delete one of the owner's, including
everything logged before this existed, since those rows carry the empty id too.
Deletes come along free, being tombstones. The test is on the link id rather
than its label because two links can easily both be "Sitter", and the id is
also why /api/me hands the guest its share id: the client needs it to know what
to grey out. The exercise library is the owner's on the same reasoning — a
guest trains against it but the server drops any exercise a guest sends.
Attribution is stamped from the session on insert and left out of DO UPDATE
SET, so it is decided once by whoever logged the event and survives every later
edit. It never comes off the wire, so it cannot be forged — a guest re-POSTs
the owner's whole event list on every sync, but those rows already exist and
keep their stored values.
Expiry is a date the owner picks; the link dies at the end of that day in their
own timezone, which the client computes because the server has no way to know
it. Sessions are capped at the link's own end, and every request re-checks the
link is live rather than trusting the session row, so revoking kicks a guest
out on their next request instead of whenever their session happens to lapse.
Only the token hash is stored, as with session tokens, so the URL is shown once
at creation and cannot be read back.
The client side follows from that. A guest opening someone else's entry gets
the edit dialog read-only rather than a form that would silently discard what
they typed, and mergeSynced takes the server's copy for anything they may not
change — otherwise a refused write would sit in their cache forever showing an
edit that never happened. An ended link wipes their cached copy of someone
else's history and says so, rather than offering a sign-in form they have no
password for.
The hour heatmap and the two actograms all run midnight to midnight, and none
of them said where in that span you currently are. On the heatmap that made the
darkest block ambiguous: a column at 18h is either a habit or simply the hour
you are standing in with the rest of the day not yet lived. On the actograms it
hid the reason today's row stops early — it stops at now, not at bedtime.
One rule serves all three. drawActogram already draws both timelines, so the
line lands there once, and the heatmap appends the same parts after its hit
rects. Ink over a surface-coloured underlay, the same treatment the timing
chart's "since the last one" marker uses, so it stays readable crossing a
shaded sleep window, a dark cell or bare track. A caret hangs above the track
to keep it from reading as one more hour gridline, and the whole thing is
pointer-events: none so it cannot swallow a tap meant for the row underneath.
Deliberately not the accent colour: on these charts accent already means "the
selected day", and now is a different kind of fact.
The captions gain a clause naming the marker, which runs against the last few
commits taking captions away. Those removed lines restated what the picture
already said. This one does not: a thin vertical line has no inherent meaning,
and unlike darker-means-more there is nothing to infer it from.
It was mine, not the user's: added unprompted alongside the Walks panel because
sleepTargetFor existed and it seemed to fit. The number came from the
"five-minute rule" — five minutes per month of age, twice a day — written from
general knowledge with no source consulted and none cited in the code.
That rule is widely repeated but is folk guidance, not a veterinary standard.
Its origin is unclear, it is contested by what evidence exists on early
exercise and joint development, and it ignores breed, terrain, lead or off-lead
and the individual dog. Drawing it as a line labelled "Goal", with a ✓ for
clearing it, stated it far more confidently than a rule of thumb earns —
particularly in an app whose entire job is otherwise to record what happened
rather than to advise.
So the target line goes, along with its legend chip, the ✓, and walkTargetFor
itself, which had no other caller. The trend keeps today against yesterday and
the average: a record, with the judgement left to whoever knows the dog.
It governs nearly every panel on the page — the training grid, the timing
charts, both sleep patterns, the counts panel, all three walk views — but it
sat inside Sleep, so it read as a sleep setting. That was already the reason
its old home carried a comment explaining it was not one; a control needing a
comment to deny what its position says is in the wrong position.
It now has its own row under the quick actions, above the first panel that
reads it, with a "Charts cover" label to say what it does. The row skips the
card chrome: it is a control, not content, and giving it a section's surface
and shadow would have made it look like a panel with nothing in it.
No JS change — both the click wiring and renderChartWindow address the buttons
by class, so they never cared where the picker lived.
Also removes that old comment, which had stopped being wrong about the picker
and started being wrong about the panel: the walks move carried it along by
accident and left it sitting above Weight, explaining a control three panels
away.
Third of the explanatory lines to go, and the same case as the other two: it
told you darker meant more, which a heatmap says by being one.
The tap-to-open-that-day half went with it, but the behaviour did not — the
cells are still wired through setChartSVG. Losing the sentence costs the only
hint that they are tappable at all, which is the trade the other captions made
too.
Both restated their own chart in prose. The timing hint gave the typical pee gap
as a sentence directly under a row whose whole job is to show that gap on a
track, and the walking one gave a target the walk trend already draws as a line
with a chip naming the number.
Removed with them: the pee-gap cache in renderTiming, which existed only so the
hint and the pee row could share one computation, and the .timing-hint rule.
median and walkTargetFor stay — the timing chart and the walk trend's goal line
are still their callers.
The changelog entry announcing walks keeps its mention of the rule of thumb.
That one shipped and was true at the time, so a new entry retires it rather than
an edit pretending it was never there.
Pure white belonged to no colour in the app; the timers it painted read as
uncoloured rather than as the sunshine end of the pair. #fdf4da is white with a
sixth of the gold mixed in — warm enough to be recognisably the same family as
the pill it sits on, pale enough that it is nothing like --wake-ink, which is
where "yellow text on a yellow pill" came from.
11.83:1 on the pill and 12.36 on the card, so the legibility that white bought
is intact. The light theme is untouched: anything this pale on a cream pill
would be gone.
The changelog entry from the previous commit is amended rather than followed by
one of its own — it has not shipped, and "white" was only ever a description of
this same line of the stylesheet.
The last two attempts at this were invisible because they only moved the light
theme's ink. --wake-ink flips per theme and the dark value was left at #f5c451
throughout — a bright gold, on a pill tinted the same hue. Yellow on yellow was
exactly the report, and it was untouched by both changes made in answer to it.
--wake-timer-ink carries white for the two timers on the dark theme, where the
pill is a deep olive and white lands at 12.99:1. The light theme keeps the gold
ink: the same pill there is near-white cream, and white on it would be 1.05:1 —
gone. So this is one value that cannot be shared across the themes even in
principle, only chosen per theme.
Kept apart from --wake-ink so the AWAKE labels in the sleep & wake list stay
gold and go on matching the stripe beside them, which is the one place the
colour is carrying meaning rather than just filling in text.
Reported as too yellow and hard to read, and both were fair. The pill measured
4.71:1, which clears the bar on paper but only just, and the pair it clears it
with — dark yellow on light yellow — differ almost only in lightness, so there
is no hue separation doing any of the work. The purple it replaced measured
worse at 3.46 and still read more easily, which is the tell that the number was
not the whole story. Treating 4.71 as sufficient rather than as barely-passing
was the mistake.
Two changes, one per complaint. The pill's tint drops from 18% to 12%, where
asleep keeps 18%: equal percentages of these two hues are not equally strong,
and the yellow shouted at a share the blue carries quietly. And --wake-ink goes
from #8f6400 to #6f4d00, which the labels in the list and on the big card take
along with the pill — they were light by the same reasoning, just against
paler grounds that hid it.
The pill now measures 7.10 in the light theme and 7.98 in the dark, against the
asleep pill's 5.01, and all sixteen text sites the two states cover still clear
4.5:1, worst 5.01.
--sleep was doing the same two jobs --wake had just been split out of: the
pale tint behind a timer and the text on top of it. As text it never worked —
2.57:1 on the day-bar pill, 2.79 on the big card, 2.85 on the list label — and
the dark theme was no better off than the light one, at 4.07 on the pill, which
I had assumed was fine until I measured it.
So --sleep-ink joins --wake-ink, deep blue in the light theme and a lighter one
in the dark, and the four places that painted text with --sleep now use it. The
tints, stripes, chart bars and borders keep --sleep: nothing about the colour of
sleep changes, only the colour of writing about it.
All sixteen text sites the two states cover — pill, big clock, list label and
open-row label, each state, each theme — now clear 4.5:1, the worst being 4.71.
The pedigree's ♂ keeps the raw --sleep. It is the same blue serving an unrelated
meaning, and recolouring it here would be a change to the pedigree made for
reasons that have nothing to do with it.
The list said awake in gold while the big card and the day-bar pill still said
it in purple, which left the app describing one state in two colours. Both move
across, so asleep against awake is night-blue against day-gold everywhere.
Text takes --wake-ink, not --wake. A gold light enough to read as sunshine is
not legible as text on a pale ground: the bright value measures 1.71:1 against
the card it would sit on, where the ink makes 4.96, and 4.71 on the pill's
tint — the same split the pee button has always used, dark ink on the yellow
rather than yellow ink.
Worth noting for later: this now makes awake considerably easier to read than
asleep, whose blue-on-blue pill has been sitting at 2.57:1 since long before
any of this. Untouched, being a separate question about the sleep colour.
Awake rows in the sleep & wake list borrowed --accent, which meant purple did
double duty: it was the colour of "awake" and also the colour of "this row is
the one still running", so the two readings overlapped on exactly the row where
both applied. A warm gold takes the first job. Asleep against awake now reads
as night against day, which is the distinction the list exists to draw, and it
agrees with the ☀️ the timer pill already shows.
Two variables rather than one. --wake is the bright gold, for the stripe and
the open row's tint. --wake-ink is that warmth pulled dark, for the small
uppercase label, and it is the value that flips with the theme: a yellow legible
on white is not legible on near-black, and vice versa. Measured on the four
backgrounds it lands on, it clears 4.5:1 in both themes, ordinary rows and the
tinted open one alike — better than the purple it replaces, which only ever made
the large-text bar.
The gold sits near --pee, but the two never share a panel: pee is a dot in the
history log and a bar in the counts chart, awake is a stripe in a list of
windows.
Scoped to the list, as asked. The big timer card and the day-bar pill still
paint awake with the accent.
Needed, on the evidence: the history log, the notes log, the weigh-in list and
now sleep & wake all run newest first, so the walks list was the only one in the
app reading the other way. A short list makes the inconsistency cheap rather
than absent — two panels of the same rows in the same shape should not have to
be checked for direction before they can be read.
Reversed at the point of display rather than in walkWindowsForDay. Its other
two callers — the day's total and the history rails — read the windows as a
set, and giving a shared helper a display order would only make them harder to
follow.
The merged list ran oldest first, which put last night's sleep at the top and
pushed whatever is happening now further down with every window the day adds —
so the row you look at most often was the one that moved.
Reversed, it matches the history and notes logs: the open row leads, and the
day reads backwards from where you are. The interleaving is untouched, since
alternation survives either direction.
The changelog entry for the merge is corrected rather than answered with a
second one. It walked through an example day in the old direction, and the
build that shipped it is the same one this lands in — nobody has a version
where the list read the other way.
Sleep windows and Wake windows were two panels showing two halves of one
sequence: a wake window is precisely the gap between two sleeps, so the panels
alternated with each other and neither could be read as the day's rhythm on its
own. Answering "how long was he up before that nap?" meant looking at one panel,
holding a time in your head, and finding it in the other.
They are one list now, the two window sets sorted together by start time, which
is all the interleaving that alternating-by-construction needs. Each row states
whether it is asleep or awake and carries a stripe in its colour, so the
alternation reads before the words do, and the row still running keeps the
highlight it had. renderWindowList grew the ability for a window to bring its
own class and label; the single-kind lists still pass one of each for the whole
list and tag only the open row, where "Walking" is news rather than a repeat of
the heading it sits under.
One rule had to be spelled out: the new per-kind stripe ties on specificity with
the generic .ww.ongoing, and being later would have won, leaving an open awake
row with a faded edge instead of its full-strength one.
The walks group — the panel and both its patterns — moves below "Pees, poos &
meals". The three stay together: they were grouped under Walks deliberately, and
leaving the patterns behind would strand two walk charts among the sleep ones.
Both old panel keys go stale, so anyone who had folded Sleep windows or Wake
windows gets the merged panel open, as with any panel that is new to a build.
The Weight panel holds three things, and only one of them grows: the latest
figure and the curve stay one screen forever, while the row list gains a line
every weigh-in until it pushes everything else off. Folding the panel to get rid
of it also takes away the two parts worth keeping.
So the fold mechanism now works on any keyed element inside main, not only on a
section, and the list moves into a block that folds by its own h3 while the
panel keeps folding by its h2. The two nest without special handling — separate
keys, and a folded section hides the block along with the rest, so the block's
own state is simply there again when the section reopens.
The CSS generalises from section.collapsible to a class, but scoped to main:
the pedigree tree uses .collapsed for its own branches, and an unscoped
selector would have folded every card in it.
Walks had the overview tile, the window list and minutes per day, but not the
two views that answer the questions sleep already answers: when in the day does
it actually happen, and is today keeping up.
"When … walks" is the sleep actogram drawn from walk windows, so the drawing
moves into drawActogram(svgId, windows, barCls) and both timelines become one
line each. Nothing about that picture was ever sleep-specific — it clips
windows to day rows and shades them — so the third caller costs nothing.
"Walk trend" is the sleep trend's shape with three deliberate differences.
Today and yesterday are built from their own walk boundaries rather than hourly
samples, so the steps stay square: a 30-minute walk is a step, not an hour-wide
ramp. The average keeps hourly sampling, since a mean over many days is a
smooth reference with no steps of its own to preserve. And there is no
projected tail — sleep is a state the puppy drifts back into, so continuing
today's line by what the average day adds is a fair guess, while walks are
decisions, and projecting them would be predicting the handler, not the dog.
The goal is a line, not a band: the five-minute rule gives one number to reach
with no upper bound a band would imply. With no projection to carry it, the ✓
that marks being on track goes on today's own chip.
The axis stays linear where the sleep trend stretches above 10h. That stretch
exists because sleep's interesting hours crowd a 16h goal; a walk total reads
the same at 10 minutes as at 60.
Both panels sit with the other walk views rather than beside their sleep twins,
following the grouping-by-subject the charts just moved to, and both hide
themselves until a walk exists so they cost nothing to anyone not logging them.
The 7d/14d/30d picker already drove every chart on the page, but the timing
panel was not a chart and got missed: gapsBetween defaulted to a literal 7 and
the heading said "(last 7 days)" as fixed text, so picking 30d widened the
counts and heatmap while the pee, poo and meal gaps under them stayed on a
week. Two panels describing the same events over different windows is the kind
of disagreement nobody thinks to check for.
The default is now chartDays(), read at call time so it tracks the picker
rather than freezing at load, and the heading joins the other panels in
deriving its "(last N days)" from the same place. The two sentences that
quoted seven days — the empty-row fallback and the range tooltip — say the
picked number, and the note about the faded stretch says "the window" rather
than "the week", which is no longer always true.
An explicit days argument still wins, so a future caller wanting a fixed
window is not forced through the picker.
"Last 7 days" collected four charts for no better reason than sharing an axis,
which put sleep hours a full panel away from the two sleep patterns and the
counts chart nowhere near the by-hour view of the same events.
Each chart now sits with its subject. Sleep hours per day becomes its own Sleep
panel directly above the sleep timeline, so the three sleep views read in
sequence. Daily counts and Food join the heatmap in one "Pees, poos & meals"
panel: how many a day, how much food went with them, and what hours they fall
in are three views of one set of events and belong on one card. Minutes walked
per day goes to the Walks panel, which is the same move applied to the chart
the split didn't mention.
The day-window picker stays a single control, in the Sleep panel, because it
was never scoped to the panel holding it — it drives the training grid, both
sleep patterns and the counts panel too. That is unchanged, and the panel it
lives in occupies the slot the old one did, so it has not moved on screen. A
comment says so, since a global control sitting inside one card does not
announce itself. Duplicating it into each panel would work as-is (both the
labels and the buttons are addressed by querySelectorAll) if reaching it ever
becomes a scroll.
Both new panels take new data-panel keys, so anyone who had the old panels
folded gets the new ones open rather than inheriting a collapse they chose for
something else. The dead #daily-charts-title lookup goes with the heading.
"Weigh-in" was the longest label in the app and, since it moved into a
third-width button, close enough to the edge to wrap on a mid-size phone.
"Weight" clears it with room to spare and loses nothing: the section, the
chart and the dialog field are all called Weight already.
EVENT_LABELS follows the button rather than only the button changing. Every
type's button label and its log label are the same string today, and weight
would have been the one exception — "⚖️ Weight" to log it, "Weigh-in" in the
history row and "Log Weigh-in" on the dialog. The prose that says "weigh-ins"
as a plural noun is left alone; it reads as English, not as a label.
The changelog entry for the rows is amended again rather than gaining a
neighbour, for the same reason as last time: it is one description of one
panel, and the rename is part of what that panel now looks like.
Pee and Poo take a row of their own and Ate joins Weigh-in and Note across the
bottom, which regroups the panel by what a tap does rather than by what the
event means: the bottom row is now exactly the three buttons that open a
dialog to type a value, and the row above it exactly the two that log on one
tap. Ate was the odd one out under the old split — it sat with the pees and
poos but behaved like a weigh-in.
That hands the longest label in the app, "⚖️ Weigh-in", a third-width button.
The narrow-phone padding trim already in place was written for "🍽️ Ate" in
that slot and now serves Weigh-in instead; on a mid-size phone the label is
close enough to the edge that it may wrap to two lines, which costs a little
row height and nothing else. Shortening it to "Weight" is the fix if it does.
The changelog entry describing the rows is corrected in place rather than
answered with a second entry. The update banner diffs by entry text, so an
edited entry is already surfaced as new to anyone coming from the previous
build — and one accurate description of the layout beats two entries whose
combined reading is only correct if you apply them in order.
A pair reads as two unrelated rows in the day's log: "Walk start 08:00" and
"Walk end 08:30" sit apart from each other and, with a pee logged in between,
apart from anything that happened while the puppy was out.
A dotted rail down the margin ties the pair together, in that pair's colour.
It runs the full span rather than only touching the two boundary rows, so
whatever was logged in between falls inside the bracket — a pee taken on a
walk now reads as having happened during it, which is the more useful claim
and comes for free once the rail spans rows at all.
Which rows a span covers comes from the same sleepWindowsForDay /
walkWindowsForDay the Sleep and Walks panels draw, so a pair crossing midnight
is treated identically in both places. Where a span's boundary isn't a row in
this day — an ongoing walk, or one that ran over from yesterday — that end of
the rail is left open and runs off the edge of the list, rather than anchoring
on a row that didn't start or end anything. A boundary with no partner in the
day draws nothing: a rail needs two rows to tie.
The roles are named for the rendered list, which runs newest-first, so a span's
last event is its top row and the rail is built downward from there.
It costs no layout. The rail is drawn in the padding the section already has,
left of the cards, so no row indents or moves; the anchored end stops level
with the row's dot and the open ends overshoot the list gap so consecutive
segments join into one line.
A walk is a start and an end, so it reuses the shape sleep already has rather
than inventing one: walk-start / walk-end events, paired into windows, with a
trailing unmatched start meaning "out right now". The server stores type as an
opaque string, so nothing there changes and the events ride the existing sync.
Pairing boundaries into windows was written out twice already, once for sleep
and once for its inverse, so this pulls the scan into pairWindows(open, close)
and makes all three callers of it. Same for the latest-boundary lookup behind
currentSleepState, which currentWalkState now shares — including the updatedAt
tie-break, which matters as soon as a start and an end land in the same minute.
They are called walks, not exercise. "Exercise" is already taken by the
training definitions (their own synced collection, and exerciseId on training
events), and two meanings of the word in one app would be worse than the
slightly narrower name.
The day's total leads the overview tile with the count underneath, since the
question is how much exercise the puppy got rather than how many outings it
took. The Walks panel lists the day's windows and carries the total in its
heading so a collapsed panel still answers it, and the daily charts gain a
minutes-per-day bar chart that stays hidden until there is a walk to draw —
the grams chart's rule. The panel also states the five-minute rule for the
puppy's current age, the same way the sleep trend states a goal band.
Walk boundaries answer to the walk state, not the sleep one, so "Walk end" is
disabled with no walk running and stays undimmed mid-walk even while the puppy
is logged asleep.
The buttons flowed into one auto-fit grid, so how they grouped was whatever
the viewport's column count happened to produce. At two columns — every
phone — seven buttons left the last one stranded alone on a fifth row, and
between roughly 504px and 584px the grid goes to three columns and splits
"Sleep start" from "Sleep end" across a wrap, which is the one pairing that
has to stay together: the two halves are read as a unit, and one of them is
always the disabled twin of the other.
Each row is now stated outright instead. The timed pair takes a row of its
own, the one-tap moments go three across, and the two that open a dialog to
type a value take the last row. Nothing is orphaned at any width and the
pairing cannot come apart.
The rows group by what the buttons mean rather than by what tapping them
does, which puts "Ate" with the pees and poos even though it opens the grams
dialog like a weigh-in. Meaning is what you are scanning for at 3am; the
dialog is a detail you meet after the tap.
Three columns leave about 55px of text room at 320px wide, so the narrow
breakpoint that already trims the day bar trims that row's side padding too,
rather than letting a button wrap to two lines.
The bar gains a faded stretch from the typical gap out to the longest one of
the window, behind the solid shortest-to-typical part. Where the solid ends is
the median, so the boundary marks "typical" without another mark on the track.
Extending the linear axis to reach it does not work. Gaps are heavily skewed —
one long overnight gap a night against daytime gaps a tenth its length — and
on a linear scale the everyday range collapsed into the first tenth of the
track, to the point that four different "since" values rendered as the same
picture. So the axis is stretched the way the sleep trend's is above 10h: the
typical gap is pinned to the middle, shortest-to-typical takes the left half
and typical-to-longest the right. Every row then reads the same way, left of
centre sooner than usual and right of centre longer, however extreme that
row's tail is. A note in the panel says as much, since a stretched axis is not
something to leave to inference.
The right edge now being the longest gap retires the old cap at twice the
typical: the marker's chevron means "past the longest gap yet" rather than
past an arbitrary cutoff.
A tap fires the emulated mouseenter and then the click on the same block, and
the click handler was a toggle: the first of the pair selected the block, the
second cleared it, so one tap left the caption exactly where it started. It
only appeared on a later tap that arrived without a fresh mouseenter, which is
what made it look like taps were being missed.
The click now selects rather than toggles, so running both handlers for one
tap is a no-op — the same idempotent shape the weight chart's hit targets
already use. Tap-again-to-clear goes with it: telling a hover-set selection
apart from a click-set one is more machinery than the affordance is worth, and
the caption keeping its last block matches the weight chart's caption. The
changelog entry that promised clearing is corrected in place, since the
behaviour it describes never worked.
A pair of numbers per type says how long the gaps are but not where the puppy
currently sits between them, which is the thing you actually want when
deciding whether to go out now. Each row becomes a small chart instead: a band
from the shortest to the typical gap, a marker for time since the last one,
and a scale of [min(shortest, since), max(typical, since)] so the marker is
free to land inside the band, off its left end (just went) or off its right
end (due).
Three cases the layout has to survive. A gap in tracking can leave "since" at
days against a typical gap of hours, so past twice the typical the marker
parks at the right edge behind a chevron and its label keeps the real number —
squashing the band to a sliver would be worse, and clamping silently would
read as being in range. A band too narrow to hang a label off each end carries
both values centred over its middle rather than pinned to the track ends,
where they would imply a spread the band does not have. Under two events there
is no gap at all, so the row falls back to a sentence instead of an axis with
nothing on it.
The marker is ink over a surface-coloured ring so it stays legible on any band
colour in either theme, and the gap between a label's word and its value is an
explicit dx: a trailing space inside a tspan does not survive XML whitespace
normalisation.
Meals have the same shape as the pee and poo intervals already shown here —
gapsBetween() is type-agnostic, so it is two more rows and one more call. The
window, the median-based "typical" and the em-dash empty state all come along
unchanged.
The heading drops "Bathroom", which stops being accurate once feeding is in
the panel. data-panel stays "timing", so a collapsed panel stays collapsed
across the rename.
The hint keeps keying off pees: it is advice about when to take the puppy out,
which meal spacing has nothing to say about. Only its empty-state text widens.
The count behind a block was only ever reachable through its <title> tooltip,
which needs a pointer — on a phone there was no way to find out whether a dark
block meant two meals or five. Each cell now carries a transparent hit rect
that names it in a caption under the chart ("3 meals between 07:00 and
08:00"), the same string the tooltip shows, so hover and tap agree. The
focused cell takes an accent ring and tapping it again clears it.
The hit rect claims the 1px spacing between cells and half the gap to the
neighbouring rows, which takes the target from ~13x31 to ~13x38 CSS px on a
360px-wide screen; the rows still tile without overlapping and stay clear of
the hour labels. Width is capped by fitting 24 hours across the chart, so a
mis-tap lands on a neighbouring hour — the caption names the range it hit,
which makes that self-correcting.
The focused cell is held outside the render so a background sync can't wipe
what is being read; its count is recomputed each pass, so the caption stays
current. The caption is aria-live, which also gives screen readers a route to
the numbers that role="img" on the svg otherwise closes off.
Once the big timer scrolls away, the pill in the day bar is the only thing
still showing the sleep state — but acting on it meant scrolling back up to
the quick actions. Tapping the pill now logs the boundary that ends the state
it displays: sleep-end while asleep, sleep-start while awake. It routes
through quickLog(), so the snackbar and its undo behave exactly as they do
from the buttons, and the state is read from currentSleepState() rather than
the cached bigClockState so a tap can't act on a stale render.
The pill becomes a real <button>, which brings keyboard operation along for
free. In standby it is visibility:hidden, so while the big card is on screen
it is neither clickable nor tab-reachable, and the toggle is live exactly when
the pill is visible.
The .day-bar button padding and :disabled rules are scoped to :not(.bar-clock)
so they don't start outranking the pill's own sizing, and the default accent
button background is reset since the asleep/awake classes paint it.
A closed PWA has no timers, so reminders are evaluated on the server: the
event log is already there (clients sync on every mutation), and a ticker
re-checks each enabled rule once a minute and pushes the ones that are due.
Two rule shapes. "sleep" measures from the last sleep-end and fires only
while the puppy is awake. "pee"/"poo"/"eat" measure from the newest event of
that type and stay quiet while the puppy is asleep — otherwise they nag all
night, and suppressing them means an overdue rule instead fires promptly on
waking, which is when it actually matters. Sleep state is derived exactly the
way currentSleepState() does in app.js, tie-break included, so both sides
always agree. Rules read the event's own timestamp rather than when it synced,
so a pee logged offline at 03:10 cancels the reminder retroactively.
Every push carries a tag, so a repeat replaces the previous notification
instead of stacking another one on the lock screen. last_fired is server-owned
and not writable by a client, so a stale device can't force a re-fire.
Web Push is implemented directly rather than pulled in as a dependency: RFC
8291 encryption in the RFC 8188 aes128gcm coding with an RFC 8292 VAPID token,
stdlib only, checked against the RFC 8291 test vector. The key is generated
into vapid.json beside the DB or supplied via -vapid-key; without one the
server logs a warning, skips registering the routes, and the client hides the
UI. Subscriptions a push service reports as 404/410 are dropped.
PNG icons are added because iOS gates push on a Home Screen install and
rejects SVG for apple-touch-icon, and Android has no notification icon
without them.
While asleep, tapping "Sleep start" again can only produce a zero-length
sleep window, and likewise "Sleep end" while awake — so renderActionHints
now disables that button outright instead of merely dimming it, with a
title explaining why ("Already asleep" / "Already awake"). Every other
action stays clickable, and a genuinely missed boundary is still fixable
from the event log, which accepts any time. With no sleep history yet,
either boundary remains a valid first event.
Also adds nodejs to the dev shell for `node --check` on src/*.js.
A new 📝 Note quick-action logs a free-text note (with a date, optional
photo, and any text), defaulting to the day you're viewing. Notes are a
distinct "note" event type, so they edit, sync and delete like any other
event, and a new Notes section lists them newest-first across every day —
independent of the day picker — so records like vaccinations stay visible
whatever day you're looking at.
The Daily counts legend is now a Pees/Poos/Meals checkbox group, so you
can hide metrics and focus on just the ones you care about. The choice is
persisted per device, at least one metric is always kept visible, and the
y-axis and bar widths adapt to the selected metrics.
Drop the descent phase: each emoji now rises to its peak, slows (ease-out) and
fades out at the top rather than arcing back down. Removes the now-unused --endY.
Rework the effect: instead of a small burst from the tapped button, 30 emoji
launch from across the bottom of the screen, rise to random peaks while
drifting sideways, then arc back down and fade — staggered launches and varied
speed/size/rotation for a fuller, firework feel. Still pointer-events:none,
self-removing, and gated by the Settings toggle and reduced-motion.