Compare commits

..
37 Commits
Author SHA1 Message Date
Alexander Heldt 8c9ea45410 Spell out the highlighted day under the food chart
A stacked bar cannot be read on a phone. There is nothing to hover, so the
bar's title is unreachable, and the only way to get a day's figure was to
estimate it against the axis — which a split bar makes harder, not easier.

Tapping a bar now writes that day out beneath the chart: "Sun, Sep 20 — Dry
260 g · Fresh 100 g · 360 g in total", or just the total where no kinds are in
play. The same problem exists without kinds, so it is not gated on them; the
unsplit form says the number once rather than "No kind 340 g · 340 g in total".

It reads off the day already selected rather than keeping a selection of its
own. Tapping a bar selects that day on every chart in the app and this one
already highlights it, so a second piece of "which day" state would only be
something to keep in step and eventually fail to. It falls out of that choice
that the arrows and the date picker move the readout too, which is the
behaviour you would want anyway.

Three cases say something rather than reading as blank: a day with no food, a
day marked not counted, and a day outside the window — which has no bar, so no
readout.

The changelog entry for this sits on its own rather than inside the food-kinds
one. It started life gated on kinds and is not any more, and "once you are
using kinds" would have been the wrong condition to file it under.
2026-09-22 10:47:58 +00:00
Alexander Heldt 3584758b86 Keep a deleted kind's colour, not just its name
A kind that is deleted keeps its tombstone so meals logged as it stay readable,
and the chart recovered its name from there — but not its colour, falling back
to grey. Grey is what "No kind" uses, so a deleted kind's food and unlabelled
food drew as the same colour: two distinct series, indistinguishable in the
stack and in the legend beneath it.

The tombstone has the colorIndex all along, so reading the whole record rather
than just the name fixes it. The check now pins the colour as well as the name,
since the name alone was what let this through.

Deleting a kind still leaves the meals alone — confirmed as the wanted
behaviour. This only makes that behaviour legible.
2026-09-22 10:42:30 +00:00
Alexander Heldt 18d3778241 Break today's food down by kind in the overview
The Food chart splits by kind but today's overview did not, so the one place
you look first still lumped dry and fresh into a single total.

It is a line under the stat tiles rather than part of the Meals tile. That tile
is about 90px wide with a 0.7rem sub-line, and two kinds will not sit in it
without wrapping into a mess — so the tile keeps the day's total, which is the
headline figure, and the breakdown gets the room it needs.

It follows the chart's conventions so the two read as one breakdown rather than
two arbitrary lists: the same layer order, "No kind" last, a kind deleted since
still named through its tombstone, and a meal logged without an amount adding
nothing.

Hidden unless a meal that day carries a kind, which keeps the overview
untouched for anyone not using them — the case the first assertion in the new
suite pins down.

This was a gap rather than a reversal: when the split was scoped to "the grams
chart only", the options named the counts chart, the by-hour heatmap and Timing
as staying put. The overview's food total was in neither list, so it was never
decided either way.
2026-09-22 10:38:47 +00:00
Alexander Heldt 5a08fb4510 Let a meal say what kind of food it was
Grams alone put dry and fresh in the same total, so the log could not show that
fresh had been creeping up or that a soft stomach followed a switch. A meal can
now carry a kind the user names themselves.

The whole thing is optional, and that constraint shaped most of it. "No kind"
is a real value rather than a missing one: it is what every meal already logged
carries, so nothing needed migrating; it is always offered in the picker; and
with no kinds defined the picker, the legend and the split are all absent, so
the app is byte-for-byte the one it was for anyone who never wants this. The
checks cover that case specifically, because it is the one nobody would notice
breaking.

Kinds are a third synced collection beside events and exercises, with the same
contract — uuid ids, per-item last-write-wins, tombstoned deletes — so renaming
a kind updates the meals logged as it, and deleting one leaves them readable
under the name the tombstone kept. FoodKindStore duplicates ExerciseStore
closely; Store and ExerciseStore were already near-twins, so a third in that
shape is this file's pattern and leaves two working collections untouched.
Folding all three into one store over a table name is the tidier end state and
a separate job.

Two decisions worth naming. The default kind is a flag on the kind rather than
a profile field: the profile is last-write-wins across the whole row, and this
codebase already carries a special case for pedigree_id because that dropped a
value once — per-item LWW means two devices that each choose a default resolve
to the newer instead. And each kind keeps a colorIndex fixed at creation, so
deleting one never repaints the charts of the kinds around it.

The bars stack by kind with a line fitted per kind. Each line sits at that
kind's own daily amount rather than at the top of its segment: the segment's
height is what the kind ate, but its position is an accident of what is stacked
beneath it. So a line can cross a segment it does not belong to — dashed and in
the kind's colour, with the figures named underneath either way.

One sentence per kind would grow with the list, so only kinds whose move beats
their own scatter get one and the rest fold into a clause. Both tests are ones
foodTrend already applied; nothing new is being claimed.

A guest labels a meal with a kind that exists but cannot add, rename or delete
one, exactly as with the exercise library.
2026-09-22 10:31:27 +00:00
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
Alexander Heldt 39343ff2a4 Join the tab row to the frozen date row
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.
2026-09-07 20:14:24 +00:00
Alexander Heldt de1e18e394 Stop tab switches jumping the page to the top
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.
2026-09-07 20:13:55 +00:00
Alexander Heldt e7b54b82fd Fit the tab labels on narrow phones
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.
2026-09-07 19:58:17 +00:00
Alexander Heldt 0dfbab82cf Split the page into five tabs
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.
2026-09-07 19:55:40 +00:00
Alexander Heldt 66f89b35a9 Keep showing a guest link's URL so it can be copied again
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.
2026-09-07 19:25:20 +00:00
Alexander Heldt babed44c25 Hide buttons and panels that were marked hidden
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.
2026-09-07 19:14:23 +00:00
Alexander Heldt da68b733e4 Let a day be left out of the stats
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.
2026-09-07 19:01:06 +00:00
Alexander Heldt e22031ed4f Add guest links for temporary shared access
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.
2026-09-07 11:19:20 +00:00
Alexander Heldt 103a5f9937 Mark now on the day-long charts
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.
2026-09-04 15:11:30 +00:00
Alexander Heldt 122897c732 Remove the walking goal
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.
2026-09-01 10:07:20 +00:00
Alexander Heldt 31e04fb9a1 Lift the chart window out of the Sleep panel
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.
2026-09-01 07:58:14 +00:00
Alexander Heldt 1c591bfb99 Drop the training grid's caption
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.
2026-09-01 07:54:30 +00:00
Alexander Heldt 9d8d1b6c0d Drop the timing and walking hint lines
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.
2026-09-01 07:50:59 +00:00
Alexander Heldt 83eb50960f Warm the awake timer text off pure white
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.
2026-09-01 07:36:49 +00:00
Alexander Heldt 9c34827387 White text on the awake timers in the dark theme
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.
2026-09-01 06:48:19 +00:00
Alexander Heldt 3baff5f6dc Tone down the awake pill
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.
2026-09-01 06:43:23 +00:00
Alexander Heldt 0ea9374b46 Give asleep a readable ink as well
--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.
2026-08-31 22:36:45 +00:00
Alexander Heldt f9becddbc6 Carry the sunshine to the two awake timers
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.
2026-08-31 22:33:44 +00:00
Alexander Heldt a7608829e9 Paint awake as sunshine, not accent
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.
2026-08-31 22:31:14 +00:00
Alexander Heldt 9d69bac73b Read the walks list newest first too
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.
2026-08-31 22:27:36 +00:00
Alexander Heldt 68840847f0 Read the sleep & wake list newest first
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.
2026-08-31 22:25:19 +00:00
19 changed files with 5809 additions and 629 deletions
+203 -1
View File
@@ -26,6 +26,16 @@ source-of-truth and sync between devices.
tombstones) via `POST /api/exercises/sync`. Training sessions are ordinary
events (`type: "training"`) referencing an exercise by id, so they ride the
event sync unchanged.
- Food kinds (`Dry`, `Fresh`) are a third synced collection with the same
contract, via `POST /api/foodkinds/sync`; a meal references one by
`foodKindId`. Empty means **no kind**, which is what every meal logged before
kinds existed carries — so nothing needed migrating and nobody is made to
classify their food. Which kind a new meal starts on is a flag on the kind
itself rather than a profile field: the profile is last-write-wins across the
whole row (see the `pedigree_id` special case below), and per-item LWW lets
two devices that each chose a default resolve to the newer instead of
fighting. Each kind also keeps a fixed `colorIndex`, so deleting one never
repaints the charts of the ones around it.
- The puppy's name and birthday are a per-account profile stored on the host
(`GET`/`PUT /api/config`), so a new device picks them up automatically instead
of being configured per-client. The client caches the last-seen values in
@@ -35,10 +45,72 @@ source-of-truth and sync between devices.
- All data is scoped to the signed-in account (see [Accounts](#accounts)): every
event, profile and photo carries a `user_id`, and `localStorage` is namespaced
per user so two accounts on one browser never mix.
- A day can be marked **not counted** (see [Days that don't
count](#days-that-dont-count)). The mark is itself an event
(`type: "day-excluded"`, timestamped at noon), so it syncs and un-marks by
tombstone like everything else.
A status pill in the header shows `syncing…` / `synced 2m ago` / `pending` /
`sync error` / `offline`. Tap it to force-sync.
## On screen
The panels are grouped into five tabs — **Today**, **Sleep**, **Walks**,
**Habits** (pee/poo/meal timing and counts) and **Growth** (weight, training,
notes) — so each screen holds one subject instead of all fourteen panels in one
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
exists, and hiding them directly would clobber it.
- `render()` still draws every panel on every pass, including the tabs you
can't see. Nothing measures layout — the charts scale through their `viewBox`
— so drawing into a hidden wrapper is safe, and it means a tab is never
briefly stale when you arrive on it.
- Tapping a panel's heading still folds it away, remembered across reloads, and
composes with tabs: tabs group, folding tunes what shows within a group. The
chosen tab is remembered the same way (device-global, like the theme).
- **Back returns to Today**, from any tab, in one press; a second press leaves
the app. Exactly one history entry is ever live — armed on leaving Today and
spent on returning, whether that return came from the back button or from
tapping the tab. An entry per switch is what a browser does unaided, and is
why tabbed apps get a reputation for trapping you: flick between tabs fifteen
times and it takes fifteen presses to escape. Two presses, always, from
anywhere.
## Layout
```
@@ -49,11 +121,18 @@ puppy-tracker/
│ ├── go.mod
│ ├── go.sum
│ ├── main.go # SQLite store, LWW sync, static file serving
│ ├── auth.go # accounts, sessions, invite-gated registration
│ ├── auth.go # accounts, sessions, invite-gated registration, guest links
│ ├── reminders.go # reminder rules, the evaluation loop, push subscriptions
│ ├── 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
@@ -65,6 +144,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
@@ -100,6 +210,98 @@ events, profile and photos.
when you pass `-secure-cookies` (enable it behind a TLS proxy), so passwords
aren't sent in the clear.
## Days that don't count
Not every logged day is equally trustworthy. A day someone else had the puppy —
a sitter who forgets half the pees, a stay at kennels — leaves a thin record
that reads exactly like a real one, and then drags the averages down and puts a
misleading trough in every chart. **Not counted**, in the overview panel's
heading, takes the day you're looking at out of the aggregates.
- **Nothing is deleted or hidden.** The day's overview, history and sleep/wake
list are unchanged — just dimmed and labelled. Navigate to it and it is all
still there.
- **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.
- **Charts keep the day's slot**, drawn as a hatch rather than a bar. Dropping
it would make consecutive bars stop being consecutive days, and an empty bar
would read as "the puppy barely slept" — the exact misreading being fixed.
- **Gaps that reach across a marked day are discarded, not measured.** With the
day's events gone, Tuesday's last pee sits next to Thursday's first, and
subtracting invents a thirty-hour gap that would blow out the Timing panel's
"longest" far worse than the sparse day did. Sleep and walk durations need no
such care — `sleepMsInRange` / `walkMsInRange` already clip to the day being
measured, so a nap running in from a marked day contributes only its counted
part.
- **Owner-only.** A guest can't decide their own thin day shouldn't count, nor
take a good one out of the averages; the server drops `day-excluded` events
arriving on a guest session and the client hides the control.
## Guest links
A dog sitter needs to log a pee; they do not need your password. **Settings →
Guest access** mints a link that does exactly the first thing.
- **It is a session, not an account.** Opening `/guest/<token>` mints an ordinary
session row against *your* `user_id`, tagged with the link it came from. Every
data path downstream — sync, photos, the profile — is scoped by `user_id` as
before, so a guest simply is you as far as the data is concerned. Only the
capability checks differ.
- **What a guest gets.** The whole app to read: every panel, every chart, all
history. They can log new events freely, and edit or delete the ones they
logged themselves. What they don't get is anything under Settings that belongs
to the account — the puppy profile, the pedigree id, reminders, other guest
links, and deleting the account. Those routes are behind `requireOwner` and
403 for a guest; the client hides the matching UI. The two device-local
preferences (dark mode, confetti) stay, since they are the guest's own browser
and not your account.
- **A guest cannot change your logs.** The upsert in `Store.sync` only lets a
guest update rows carrying their own link's id, so a sitter can fix up their
own entries and cannot rewrite or delete a single one of yours — including
everything logged before guest links existed. The check is on the link *id*,
not its label, because two links can easily both be called "Sitter". You keep
full control either way and can edit anything on your own account, theirs
included. The exercise library is the owner's for the same reason: a guest
logs training sessions against it but the server drops any exercise a guest
sends. In the app a guest opening someone else's entry gets a read-only view
rather than a form that would throw away what they typed.
- **It expires, and you can revoke it.** You pick the last day the link should
work; it stops at the end of that day in your own timezone. Sessions minted
from a link are capped at the link's own expiry, so one can never outlive it,
and every request re-checks that the link is still live — so revoking kicks
whoever is already using it out on their very next request, not whenever their
session happens to lapse. Revoking also deletes those session rows outright.
- **The URL stays available.** Settings lists each live link by label, expiry
and when it was last used, with the URL and a *Copy* button, so a link can be
re-sent without minting a new one and stranding whoever holds the old. That
means the token is stored, not just its hash — a deliberate trade, and not the
one you would make for a password or a session token: a guest link grants a
subset of what the same database already holds in plaintext, so whoever can
read `puppy.db` gains little from it, and it expires and can be revoked
besides. The lookup column stays a hash; the secret sits beside it.
- **Events say who logged them.** An event created through a link carries that
link's label (badged in the History log) and its id (which is what authorises
changes). The server stamps both from the session on insert and never reads
them off the wire, so neither can be forged; both are left out of the update
path, so a later edit by anyone keeps the original attribution.
- **A link is a bearer token — serve over HTTPS.** Anyone holding the URL can
redeem it until it expires. Send it over something private, and run behind TLS
(`-secure-cookies`) as above. When a link ends, the guest's browser drops its
cached copy of your history rather than keeping it around.
## Reminders
Opt-in push notifications for the two things that are easy to lose track of:
+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");
+167
View File
@@ -0,0 +1,167 @@
// Kinds of food: a library of user-named labels a meal can carry. The rules
// worth holding still are the ones that decide what happens to people who
// never use the feature, and what happens to history when a kind is deleted.
import { load } from "./extract.mjs";
import { suite, eq, ok, report } from "./assert.mjs";
let store = [];
let synced = 0, rendered = 0;
let nextId = 0;
const app = load({
names: [
"NO_KIND", "FOOD_COLORS",
"loadFoodKinds", "saveFoodKinds", "liveFoodKinds",
"addFoodKind", "updateFoodKind", "deleteFoodKind",
"setDefaultFoodKind", "defaultFoodKindId", "foodKindNames",
],
stubs: {
foodKindsKey: () => "k",
localStorage: {
getItem: () => JSON.stringify(store),
setItem: (_, v) => { store = JSON.parse(v); },
},
uuid: () => `id${++nextId}`,
scheduleSync: () => { synced++; },
render: () => { rendered++; },
},
});
const reset = () => { store = []; nextId = 0; };
const names = () => app.liveFoodKinds().map(k => k.name);
suite("an account with no kinds behaves as it always did");
{
reset();
eq(app.liveFoodKinds(), [], "no kinds to begin with");
eq(app.defaultFoodKindId(), app.NO_KIND, "…so a new meal starts with no kind");
eq(app.NO_KIND, "", "and 'no kind' is the empty string, which is what old meals carry");
}
suite("creating kinds");
{
reset();
const dry = app.addFoodKind("Dry");
const fresh = app.addFoodKind("Fresh");
eq(names(), ["Dry", "Fresh"], "listed in creation order, not alphabetical");
eq([dry.colorIndex, fresh.colorIndex], [0, 1], "each takes the next palette slot");
ok(synced > 0, "a new kind is queued for sync");
}
suite("the default");
{
reset();
const dry = app.addFoodKind("Dry");
const fresh = app.addFoodKind("Fresh");
eq(app.defaultFoodKindId(), app.NO_KIND, "nothing is default until you say so");
app.setDefaultFoodKind(dry.id);
eq(app.defaultFoodKindId(), dry.id, "the chosen kind becomes the default");
app.setDefaultFoodKind(fresh.id);
eq(app.defaultFoodKindId(), fresh.id, "choosing another moves it");
eq(app.liveFoodKinds().filter(k => k.isDefault).length, 1,
"…and unflags the old one, so there is never more than one");
app.setDefaultFoodKind(app.NO_KIND);
eq(app.defaultFoodKindId(), app.NO_KIND, "and it can be cleared back to no kind");
}
suite("two devices that each set a default");
{
// What a sync race leaves behind: both rows flagged, different timestamps.
// Resolving to the newer beats showing two defaults or picking at random.
reset();
store = [
{ id: "a", name: "Dry", colorIndex: 0, isDefault: true, updatedAt: 1000 },
{ id: "b", name: "Fresh", colorIndex: 1, isDefault: true, updatedAt: 2000 },
];
eq(app.defaultFoodKindId(), "b", "the more recent flag wins");
}
suite("renaming and deleting keep history readable");
{
reset();
const dry = app.addFoodKind("Dry");
app.updateFoodKind(dry.id, { name: "Dry kibble" });
eq(names(), ["Dry kibble"], "renaming changes the name in place");
eq(app.foodKindNames().get(dry.id), "Dry kibble",
"…and meals pointing at the id follow it, since they resolve by id");
app.deleteFoodKind(dry.id);
eq(names(), [], "a deleted kind leaves the picker");
eq(app.foodKindNames().get(dry.id), "Dry kibble",
"…but its name still resolves, so meals logged as it stay readable");
}
suite("colours survive a deletion");
{
// Deriving colour from position in the live list would repaint every past
// chart the moment a kind was removed. The index is fixed at creation.
reset();
app.addFoodKind("Dry");
const fresh = app.addFoodKind("Fresh");
const raw = app.addFoodKind("Raw");
eq(raw.colorIndex, 2, "the third kind takes the third slot");
app.deleteFoodKind(fresh.id);
const live = app.liveFoodKinds();
eq(live.map(k => k.colorIndex), [0, 2],
"deleting the middle kind leaves the others' colours alone");
eq(app.addFoodKind("Treats").colorIndex, 3,
"and the next kind does not reuse the freed slot");
}
suite("the palette wraps rather than running out");
{
reset();
for (let i = 0; i < app.FOOD_COLORS + 2; i++) app.addFoodKind(`K${i}`);
const live = app.liveFoodKinds();
eq(live.length, app.FOOD_COLORS + 2, "you can have more kinds than colours");
eq(live[app.FOOD_COLORS].colorIndex % app.FOOD_COLORS, 0,
"…and the palette wraps, so two share rather than one having none");
}
// ---------------------------------------------- the day's split in the overview
// The Meals tile keeps the day's total; this is the breakdown under it. The
// case that matters is the one where it must not appear at all.
{
let kinds = [];
let el = { hidden: false, textContent: "" };
const view = load({
names: ["NO_KIND", "renderDayFoodKinds"],
stubs: {
document: { getElementById: () => el },
liveFoodKinds: () => kinds,
foodKindNames: () => new Map(kinds.map(k => [k.id, k.name])),
},
});
const meal = (grams, foodKindId = "") => ({ type: "eat", grams, foodKindId });
const show = (evs) => { el = { hidden: false, textContent: "" }; view.renderDayFoodKinds(evs); return el; };
suite("the day's food split");
{
kinds = [];
eq(show([meal(180), meal(120)]).hidden, true,
"meals with no kind show no breakdown — the tile's total already says it");
eq(show([]).hidden, true, "a day with no meals shows nothing");
kinds = [{ id: "d", name: "Dry" }, { id: "f", name: "Fresh" }];
const both = show([meal(200, "d"), meal(100, "f"), meal(60, "d")]);
eq(both.hidden, false, "once a meal carries a kind, the breakdown appears");
eq(both.textContent, "Dry 260 g · Fresh 100 g", "…summed per kind, in the chart's order");
eq(show([meal(200, "d"), meal(50)]).textContent, "Dry 200 g · No kind 50 g",
"unlabelled food on a day that has kinds is named, not dropped");
kinds = [];
eq(show([meal(90, "gone")]).textContent, "Deleted kind 90 g",
"a kind deleted since still labels its food rather than vanishing");
kinds = [{ id: "d", name: "Dry" }];
eq(show([meal(0, "d"), meal(120, "d")]).textContent, "Dry 120 g",
"a meal logged without an amount adds nothing to the split");
}
}
export default report("food-kinds");
+335
View File
@@ -0,0 +1,335 @@
// 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");
}
// ---------------------------------------------------------------- by kind
// Splitting the bars must not change what the chart says for anyone who never
// defines a kind, and the per-kind caption must stay bounded as kinds are added.
{
let kinds = [];
const split = load({
names: ["NO_KIND", "FOOD_COLORS", "foodTrend", "foodTrendSentence",
"foodTrendMoves", "foodSeriesSentences", "foodSeriesFor"],
stubs: {
liveFoodKinds: () => kinds,
loadFoodKinds: () => kinds,
foodKindNames: () => new Map(kinds.map(k => [k.id, k.name])),
},
});
// days carrying a per-kind split, as weeklyData builds them.
const byKind = (rows) => rows.map(r => ({
grams: Object.values(r).reduce((s, v) => s + v, 0),
gramsByKind: r,
excluded: false,
meals: 0, mealsMissingGrams: 0,
}));
suite("an unsplit chart is unchanged");
{
kinds = [];
const days = byKind([{ "": 300 }, { "": 320 }, { "": 310 }, { "": 330 }, { "": 340 }, { "": 0 }]);
const series = split.foodSeriesFor(days);
eq(series.length, 1, "no kinds defined gives exactly one series");
eq(series[0].name, "No kind", "…the unnamed one");
const s = split.foodSeriesSentences(series, 7);
eq(s.length, 1, "…and one sentence, as before kinds existed");
ok(/daily intake/.test(s[0]), "…phrased as the whole intake, not as a kind");
}
suite("the split adds up");
{
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "f", name: "Fresh", colorIndex: 1 }];
const days = byKind([
{ d: 200, f: 100 }, { d: 210, f: 90 }, { d: 220, f: 80 },
{ d: 230, f: 70 }, { d: 240, f: 60 }, { d: 0, f: 0 },
]);
const series = split.foodSeriesFor(days);
eq(series.map(s => s.name), ["Dry", "Fresh"], "a series per kind, in creation order");
for (const d of days) {
const summed = series.reduce((acc, s) => acc + s.of(d), 0);
eq(summed, d.grams, "each day's segments sum to the day's own total");
}
}
suite("a kind with nothing logged is left out");
{
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "z", name: "Never used", colorIndex: 1 }];
const days = byKind([{ d: 200 }, { d: 210 }, { d: 220 }, { d: 230 }, { d: 0 }]);
eq(split.foodSeriesFor(days).map(s => s.name), ["Dry"],
"an unused kind gets no segment and no legend entry");
}
suite("a deleted kind's food still appears");
{
// The kind is gone from the picker but meals still point at it, and that
// food is real — it has to show under the name the tombstone kept.
kinds = [];
const namesOnly = load({
names: ["NO_KIND", "FOOD_COLORS", "foodTrend", "foodSeriesFor"],
stubs: {
liveFoodKinds: () => [],
// The tombstone: gone from the picker, still carrying name and colour.
loadFoodKinds: () => [{ id: "gone", name: "Old recipe", colorIndex: 2, deleted: true }],
},
});
const days = byKind([{ gone: 100 }, { gone: 110 }, { gone: 120 }, { gone: 130 }, { gone: 0 }]);
const series = namesOnly.foodSeriesFor(days);
eq(series.map(s => s.name), ["Old recipe"],
"it keeps its name rather than vanishing or reading as 'No kind'");
eq(series[0].colorIndex, 2,
"…and its colour, which grey would confuse with the 'No kind' series");
}
suite("the caption stays bounded as kinds are added");
{
kinds = [
{ id: "a", name: "Dry", colorIndex: 0 },
{ id: "b", name: "Fresh", colorIndex: 1 },
{ id: "c", name: "Raw", colorIndex: 2 },
{ id: "e", name: "Treats", colorIndex: 3 },
];
// Dry climbs clearly; the rest are flat or noise.
const days = byKind([
{ a: 100, b: 50, c: 40, e: 10 }, { a: 150, b: 52, c: 39, e: 11 },
{ a: 200, b: 49, c: 41, e: 10 }, { a: 250, b: 51, c: 40, e: 12 },
{ a: 300, b: 50, c: 40, e: 9 }, { a: 0, b: 0, c: 0, e: 0 },
]);
const series = split.foodSeriesFor(days);
const lines = split.foodSeriesSentences(series, 7);
ok(lines.some(l => /^Dry:/.test(l)), "the kind that moved gets its own sentence");
ok(lines.length <= 2, `four kinds give at most two lines, got ${lines.length}`);
ok(lines.some(l => /no clear trend/.test(l)),
"…and the rest are folded into one clause rather than a sentence each");
}
suite("no kind moves at all");
{
kinds = [{ id: "a", name: "Dry", colorIndex: 0 }, { id: "b", name: "Fresh", colorIndex: 1 }];
const days = byKind([
{ a: 200, b: 100 }, { a: 205, b: 98 }, { a: 198, b: 101 },
{ a: 202, b: 99 }, { a: 200, b: 100 }, { a: 0, b: 0 },
]);
const lines = split.foodSeriesSentences(split.foodSeriesFor(days), 7);
eq(lines.length, 1, "one line when nothing is claimable");
ok(/no kind shows a trend/.test(lines[0]), "…saying so plainly");
}
}
// ------------------------------------------------- the highlighted day's readout
// Tapping a bar selects that day; this is what the selection says. It reads off
// the existing selection rather than keeping its own, so the two cannot drift.
{
let kinds = [];
let selected = "2026-09-20";
let el = { hidden: false, textContent: "" };
const info = load({
names: ["NO_KIND", "FOOD_COLORS", "foodTrend", "foodSeriesFor", "renderFoodDayInfo"],
stubs: {
document: { getElementById: () => el },
selectedDay: () => new Date(selected + "T12:00:00"),
ymd: (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`,
liveFoodKinds: () => kinds,
loadFoodKinds: () => kinds,
foodKindNames: () => new Map(kinds.map(k => [k.id, k.name])),
},
});
const day = (n, byKind, excluded = false) => ({
ymd: `2026-09-${String(n).padStart(2, "0")}`,
date: new Date(2026, 8, n),
grams: Object.values(byKind).reduce((s, v) => s + v, 0),
gramsByKind: byKind, excluded, meals: 0, mealsMissingGrams: 0,
});
const read = (days) => {
el = { hidden: false, textContent: "" };
info.renderFoodDayInfo(days, info.foodSeriesFor(days));
return el;
};
suite("the highlighted day's breakdown");
{
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "f", name: "Fresh", colorIndex: 1 }];
const days = [
day(18, { d: 200, f: 100 }), day(19, { d: 210, f: 90 }), day(20, { d: 260, f: 100 }),
];
selected = "2026-09-20";
const r = read(days);
eq(r.hidden, false, "the selected day gets a readout");
ok(/Dry 260 g · Fresh 100 g/.test(r.textContent), "each kind's amount, in the stack's order");
ok(/360 g in total/.test(r.textContent), "…and the total, so you needn't add them up");
ok(/Sep 20/.test(r.textContent), "…named, so it is clear which bar it belongs to");
selected = "2026-09-18";
ok(/Dry 200 g/.test(read(days).textContent), "selecting another bar moves the readout");
// Out of the window entirely: the chart is not showing that day at all.
selected = "2026-08-01";
eq(read(days).hidden, true, "a day outside the window has no bar and so no readout");
}
suite("the days that say something else");
{
kinds = [{ id: "d", name: "Dry", colorIndex: 0 }, { id: "f", name: "Fresh", colorIndex: 1 }];
selected = "2026-09-20";
const withEmpty = [day(18, { d: 200, f: 100 }), day(19, { d: 210 }), day(20, {})];
ok(/no food logged/.test(read(withEmpty).textContent), "a day with no food says so");
const withExcluded = [day(18, { d: 200, f: 100 }), day(19, { d: 210 }), day(20, {}, true)];
ok(/not counted/.test(read(withExcluded).textContent),
"a day marked not counted says that instead of reading as empty");
}
suite("an unsplit chart gets the day total too");
{
// No kinds defined: there is still no hover on a phone, so the figure was
// only readable by eye off the axis.
kinds = [];
selected = "2026-09-20";
const days = [day(18, { "": 300 }), day(19, { "": 320 }), day(20, { "": 340 })];
const r = read(days);
eq(r.hidden, false, "the readout appears without any kinds defined");
eq(r.textContent, "Sun, Sep 20 — 340 g.", "…as the plain total, named by day");
ok(!/No kind/.test(r.textContent),
"…without inventing a kind name for food that has none");
ok(!/in total/.test(r.textContent),
"…and without saying the same number twice");
}
}
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");
+364 -34
View File
@@ -23,20 +23,50 @@ import (
const (
sessionCookie = "puppy_session"
sessionValidity = 30 * 24 * time.Hour
// A guest link's last_used is only refreshed this often, so "last used" can
// be shown in Settings without a write on every single request.
lastUsedResolution = 5 * time.Minute
// How far ahead a guest link may be set to expire. The owner picks the date,
// so this is only a backstop against a mistyped year turning a sitter's link
// into a permanent credential.
maxShareAhead = 365 * 24 * time.Hour
)
// ctxKey is an unexported type so our context values can't collide with any
// set elsewhere.
type ctxKey int
const userIDKey ctxKey = 0
const sessionKey ctxKey = 0
// User is the public shape returned to clients — never the password hash.
// Role is "owner" for a normal login and "guest" for a session minted from a
// share link, in which case Label names the link and Email is blanked (it is
// the owner's address, and a guest has no business seeing it).
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Role string `json:"role,omitempty"`
Label string `json:"label,omitempty"`
// ShareID is the guest's own link id, which is what decides the events they
// are allowed to change (see Store.sync). The client uses it to grey out
// everything logged by someone else.
ShareID string `json:"shareId,omitempty"`
// Expires is the guest session's end, in Unix milliseconds. Owner sessions
// leave it zero — they only end by logging out.
Expires int64 `json:"expires,omitempty"`
}
// session is a resolved cookie: who the request acts as, and whether it got
// there through a guest link. ShareID is empty for an owner session.
type session struct {
userID string
shareID string
label string
expires int64
}
func (s session) guest() bool { return s.shareID != "" }
// Auth owns everything account-related: the users/sessions tables, the shared
// invite code required to register, and whether session cookies are marked
// Secure (on behind TLS/a proxy). photosDir is needed so the first account can
@@ -121,8 +151,10 @@ func (a *Auth) verify(email, password string) (User, bool) {
}
// startSession mints a token, stores its hash, and returns the raw token for
// the cookie.
func (a *Auth) startSession(userID string) (string, error) {
// the cookie. shareID is empty for an owner login; for a guest it names the
// share link, and expires is capped at that link's own end so the session can
// never outlive the link it came from.
func (a *Auth) startSession(userID, shareID string, expires int64) (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
@@ -130,28 +162,53 @@ func (a *Auth) startSession(userID string) (string, error) {
token := hex.EncodeToString(raw)
now := time.Now()
_, err := a.db.Exec(
`INSERT INTO sessions (token, user_id, created, expires) VALUES (?, ?, ?, ?)`,
hashToken(token), userID, now.UnixMilli(), now.Add(sessionValidity).UnixMilli())
`INSERT INTO sessions (token, user_id, created, expires, share_id) VALUES (?, ?, ?, ?, ?)`,
hashToken(token), userID, now.UnixMilli(), expires, shareID)
if err != nil {
return "", err
}
return token, nil
}
// userForToken resolves a raw cookie token to a user id, honouring expiry.
func (a *Auth) userForToken(token string) (string, bool) {
// sessionForToken resolves a raw cookie token to the session it stands for,
// honouring expiry. A guest session is additionally only valid while its link
// is un-revoked and unexpired — checked here, on every request, so revoking a
// link kicks its live sessions out immediately rather than whenever their own
// row happens to lapse.
func (a *Auth) sessionForToken(token string) (session, bool) {
if token == "" {
return "", false
return session{}, false
}
var userID string
var expires int64
err := a.db.QueryRow(
`SELECT user_id, expires FROM sessions WHERE token = ?`, hashToken(token),
).Scan(&userID, &expires)
if err != nil || time.Now().UnixMilli() > expires {
return "", false
var s session
var expires, linkRevoked, linkExpires int64
err := a.db.QueryRow(`
SELECT s.user_id, s.expires, s.share_id,
COALESCE(l.revoked, 0), COALESCE(l.expires, 0), COALESCE(l.label, '')
FROM sessions s LEFT JOIN share_links l ON l.id = s.share_id
WHERE s.token = ?`, hashToken(token),
).Scan(&s.userID, &expires, &s.shareID, &linkRevoked, &linkExpires, &s.label)
now := time.Now().UnixMilli()
if err != nil || now > expires {
return session{}, false
}
if s.guest() {
if linkRevoked != 0 || now > linkExpires {
return session{}, false
}
s.expires = expires
a.touchShare(s.shareID, now)
}
return s, true
}
// touchShare records that a link was used, at lastUsedResolution granularity so
// an active guest doesn't cause a write per request.
func (a *Auth) touchShare(shareID string, now int64) {
if _, err := a.db.Exec(
`UPDATE share_links SET last_used = ? WHERE id = ? AND last_used < ?`,
now, shareID, now-lastUsedResolution.Milliseconds()); err != nil {
log.Printf("touch share %s: %v", shareID, err)
}
return userID, true
}
func (a *Auth) endSession(token string) {
@@ -209,7 +266,10 @@ func (a *Auth) adoptPhotos(userID string) error {
// ---------- cookies & middleware ----------
func (a *Auth) setCookie(w http.ResponseWriter, token string) {
// setCookie writes the session cookie. expires mirrors the session row's own
// end, so a guest's cookie lapses with the link rather than sitting around for
// the full 30 days pointing at a session the server already refuses.
func (a *Auth) setCookie(w http.ResponseWriter, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: token,
@@ -217,7 +277,7 @@ func (a *Auth) setCookie(w http.ResponseWriter, token string) {
HttpOnly: true,
Secure: a.secure,
SameSite: http.SameSiteLaxMode,
Expires: time.Now().Add(sessionValidity),
Expires: expires,
})
}
@@ -242,26 +302,50 @@ func cookieToken(r *http.Request) string {
}
// requireUser wraps a handler so it only runs for an authenticated request,
// stashing the user id in the context. Unauthenticated calls get a 401 that the
// client uses as its cue to show the login screen.
// stashing the resolved session in the context. Unauthenticated calls get a 401
// that the client uses as its cue to show the login screen.
func (a *Auth) requireUser(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID, ok := a.userForToken(cookieToken(r))
s, ok := a.sessionForToken(cookieToken(r))
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r.WithContext(context.WithValue(r.Context(), userIDKey, userID)))
next(w, r.WithContext(context.WithValue(r.Context(), sessionKey, s)))
}
}
// userID returns the authenticated user's id; only valid inside a requireUser
// handler.
func userID(r *http.Request) string {
id, _ := r.Context().Value(userIDKey).(string)
return id
// requireOwner is requireUser plus "and not through a guest link". It guards
// everything a temporary helper has no business touching: the profile, the
// owner's reminders, the share links themselves, and account deletion.
func (a *Auth) requireOwner(next http.HandlerFunc) http.HandlerFunc {
return a.requireUser(func(w http.ResponseWriter, r *http.Request) {
if isGuest(r) {
http.Error(w, "guest links cannot do this", http.StatusForbidden)
return
}
next(w, r)
})
}
// sessionOf returns the request's resolved session; only valid inside a
// requireUser handler.
func sessionOf(r *http.Request) session {
s, _ := r.Context().Value(sessionKey).(session)
return s
}
// userID returns the authenticated user's id — the owner's, for a guest
// session, which is what keeps all data scoping working unchanged.
func userID(r *http.Request) string { return sessionOf(r).userID }
// isGuest reports whether the request arrived through a share link.
func isGuest(r *http.Request) bool { return sessionOf(r).guest() }
// guestLabel is the share link's label, or empty for the owner. It is what gets
// stamped onto events the request creates.
func guestLabel(r *http.Request) string { return sessionOf(r).label }
// ---------- handlers ----------
type credentials struct {
@@ -349,15 +433,17 @@ func (a *Auth) handleLogin(w http.ResponseWriter, r *http.Request) {
a.issue(w, u)
}
// issue starts a session, sets the cookie, and returns the user.
// issue starts an owner session, sets the cookie, and returns the user.
func (a *Auth) issue(w http.ResponseWriter, u User) {
token, err := a.startSession(u.ID)
expires := time.Now().Add(sessionValidity)
token, err := a.startSession(u.ID, "", expires.UnixMilli())
if err != nil {
log.Printf("start session: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
a.setCookie(w, token)
a.setCookie(w, token, expires)
u.Role = "owner"
writeUser(w, u)
}
@@ -371,8 +457,10 @@ func (a *Auth) handleLogout(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// handleMe reports the current account. Wrapped in requireUser, so reaching it
// means the session is valid.
// handleMe reports the current account, and which role the caller holds over
// it. Wrapped in requireUser, so reaching it means the session is valid. The id
// is the owner's either way — it is what the client namespaces its local cache
// by — but a guest is told so, and never told whose account this is.
func (a *Auth) handleMe(w http.ResponseWriter, r *http.Request) {
var u User
err := a.db.QueryRow(
@@ -382,6 +470,15 @@ func (a *Auth) handleMe(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if s := sessionOf(r); s.guest() {
u.Email = ""
u.Role = "guest"
u.Label = s.label
u.ShareID = s.shareID
u.Expires = s.expires
} else {
u.Role = "owner"
}
writeUser(w, u)
}
@@ -395,8 +492,8 @@ func (a *Auth) checkPassword(userID, password string) bool {
}
// deleteAccount removes a user and everything owned by them: events, profile,
// reminders, push subscriptions, sessions, the user row, and their photo
// directory. The table wipes run in one transaction; photos are best-effort
// reminders, push subscriptions, share links, sessions, the user row, and their
// photo directory. The table wipes run in one transaction; photos are best-effort
// afterwards (orphaned files are harmless).
func (a *Auth) deleteAccount(userID string) error {
tx, err := a.db.Begin()
@@ -407,9 +504,11 @@ func (a *Auth) deleteAccount(userID string) error {
for _, q := range []string{
`DELETE FROM events WHERE user_id = ?`,
`DELETE FROM exercises WHERE user_id = ?`,
`DELETE FROM food_kinds WHERE user_id = ?`,
`DELETE FROM config WHERE user_id = ?`,
`DELETE FROM push_subscriptions WHERE user_id = ?`,
`DELETE FROM reminders WHERE user_id = ?`,
`DELETE FROM share_links WHERE user_id = ?`,
`DELETE FROM sessions WHERE user_id = ?`,
`DELETE FROM users WHERE id = ?`,
} {
@@ -447,3 +546,234 @@ func (a *Auth) handleDeleteAccount(w http.ResponseWriter, r *http.Request) {
a.clearCookie(w)
w.WriteHeader(http.StatusNoContent)
}
// ---------- guest links ----------
//
// A guest link lets the owner hand someone (a dog sitter, family for a weekend)
// the ability to log events without handing over their password. Redeeming one
// mints an ordinary session row against the *owner's* user_id, tagged with the
// link it came from — so every data path downstream (sync, photos, config) keeps
// working untouched, and only the capability checks differ by role.
//
// The token is kept, not just its hash, so Settings can show the URL again
// whenever the owner wants to re-send it. That is a deliberate trade the way it
// would not be for a password or a session token: a guest link grants a subset
// of what the same database already holds in plaintext, so whoever can read
// puppy.db gains very little from it, and the link expires and can be revoked
// besides. The `token` column stays a hash and remains the lookup key; `secret`
// is the copy handed back to the owner.
// ShareLink is the public shape of a guest link. Token carries the URL's secret
// and comes back on every listing, so the owner can copy the link again rather
// than having one chance at it when it is created.
type ShareLink struct {
ID string `json:"id"`
Label string `json:"label"`
Created int64 `json:"created"`
Expires int64 `json:"expires"`
LastUsed int64 `json:"lastUsed,omitempty"`
Token string `json:"token,omitempty"`
}
// createShare mints a link that stops working at expires (Unix milliseconds)
// and returns it with its one-time raw token attached.
func (a *Auth) createShare(userID, label string, expires int64) (ShareLink, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return ShareLink{}, err
}
token := hex.EncodeToString(raw)
now := time.Now()
link := ShareLink{
ID: newID(),
Label: label,
Created: now.UnixMilli(),
Expires: expires,
Token: token,
}
_, err := a.db.Exec(
`INSERT INTO share_links (id, user_id, token, secret, label, created, expires) VALUES (?, ?, ?, ?, ?, ?, ?)`,
link.ID, userID, hashToken(token), token, link.Label, link.Created, link.Expires)
if err != nil {
return ShareLink{}, err
}
return link, nil
}
// listShares returns the account's links that are still usable, each with its
// URL secret so Settings can offer the link for copying at any time. Revoked and
// lapsed ones are of no interest to the UI — the point of the list is "who can
// get in right now". A link created before secrets were kept comes back with an
// empty Token; the UI says so rather than showing a broken URL.
func (a *Auth) listShares(userID string) ([]ShareLink, error) {
rows, err := a.db.Query(`
SELECT id, label, created, expires, last_used, secret
FROM share_links
WHERE user_id = ? AND revoked = 0 AND expires > ?
ORDER BY created DESC`, userID, time.Now().UnixMilli())
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]ShareLink, 0)
for rows.Next() {
var l ShareLink
if err := rows.Scan(&l.ID, &l.Label, &l.Created, &l.Expires, &l.LastUsed, &l.Token); err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
// revokeShare kills a link and every session already minted from it. The
// user_id guard means one account can never revoke another's link.
func (a *Auth) revokeShare(userID, id string) error {
res, err := a.db.Exec(
`UPDATE share_links SET revoked = 1 WHERE id = ? AND user_id = ?`, id, userID)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err == nil && n == 0 {
return sql.ErrNoRows
}
// sessionForToken would reject these anyway, on the revoked flag; dropping
// the rows means a revoked link leaves nothing behind either way.
_, err = a.db.Exec(`DELETE FROM sessions WHERE share_id = ?`, id)
return err
}
// redeemShare exchanges a raw token for a session on the owner's account. The
// session is capped at the link's own expiry, so it cannot outlive it.
func (a *Auth) redeemShare(token string) (raw string, sessionEnd int64, ok bool) {
if token == "" {
return "", 0, false
}
var id, ownerID string
var expires, revoked int64
err := a.db.QueryRow(
`SELECT id, user_id, expires, revoked FROM share_links WHERE token = ?`, hashToken(token),
).Scan(&id, &ownerID, &expires, &revoked)
now := time.Now()
if err != nil || revoked != 0 || now.UnixMilli() > expires {
return "", 0, false
}
sessionEnd = now.Add(sessionValidity).UnixMilli()
if expires < sessionEnd {
sessionEnd = expires
}
raw, err = a.startSession(ownerID, id, sessionEnd)
if err != nil {
log.Printf("redeem share %s: %v", id, err)
return "", 0, false
}
if _, err := a.db.Exec(`UPDATE share_links SET last_used = ? WHERE id = ?`, now.UnixMilli(), id); err != nil {
log.Printf("stamp share %s: %v", id, err)
}
return raw, sessionEnd, true
}
type shareRequest struct {
Label string `json:"label"`
// Expires is when the link should stop working, in Unix milliseconds. The
// client computes it from the date the owner picked — end of that day in
// their own timezone, which is the only place that timezone is known.
Expires int64 `json:"expires"`
}
// handleShares lists (GET) and creates (POST) guest links. Wrapped in
// requireOwner: a guest cannot see, mint or extend links.
func (a *Auth) handleShares(w http.ResponseWriter, r *http.Request) {
writeJSON := func(v any) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(v)
}
switch r.Method {
case http.MethodGet:
links, err := a.listShares(userID(r))
if err != nil {
log.Printf("list shares: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
writeJSON(map[string]any{"links": links})
case http.MethodPost:
var req shareRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
label := strings.TrimSpace(req.Label)
if len(label) > 40 {
label = label[:40]
}
if label == "" {
label = "Guest"
}
now := time.Now()
if req.Expires <= now.UnixMilli() {
http.Error(w, "pick a date in the future", http.StatusBadRequest)
return
}
if req.Expires > now.Add(maxShareAhead).UnixMilli() {
http.Error(w, "that date is too far off", http.StatusBadRequest)
return
}
link, err := a.createShare(userID(r), label, req.Expires)
if err != nil {
log.Printf("create share: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
writeJSON(link)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
// handleShare revokes one link: DELETE /api/shares/<id>. Wrapped in
// requireOwner.
func (a *Auth) handleShare(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/shares/")
if id == "" || strings.Contains(id, "/") {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
if err := a.revokeShare(userID(r), id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
http.Error(w, "no such link", http.StatusNotFound)
return
}
log.Printf("revoke share %s: %v", id, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleRedeem is what a guest link actually points at: GET /guest/<token>.
// A plain navigation so tapping the link in a message just works — it sets the
// session cookie and bounces to the app, which keeps the token out of the
// address bar, out of bookmarks and out of the PWA's start URL. SameSite=Lax
// permits the cookie on a top-level GET like this one.
func (a *Auth) handleRedeem(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
token := strings.TrimPrefix(r.URL.Path, "/guest/")
raw, expires, ok := a.redeemShare(token)
if !ok {
// Nothing usable — send them to the app with a marker it renders as
// "this link has ended" rather than a login form they can't fill in.
http.Redirect(w, r, "/?guest=expired", http.StatusSeeOther)
return
}
a.setCookie(w, raw, time.UnixMilli(expires))
http.Redirect(w, r, "/", http.StatusSeeOther)
}
+802
View File
@@ -0,0 +1,802 @@
package main
import (
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
func testAuth(t *testing.T) *Auth {
t.Helper()
dir := t.TempDir()
db, err := openDB(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
return newAuth(db, "letmein", false, filepath.Join(dir, "photos"))
}
// testOwner registers an account and returns its id.
func testOwner(t *testing.T, a *Auth) string {
t.Helper()
u, err := a.createUser("owner@example.com", "hunter2hunter2")
if err != nil {
t.Fatalf("create user: %v", err)
}
return u.ID
}
// hoursAhead is an expiry that many hours from now, in Unix milliseconds —
// what the client sends after the owner picks a date.
func hoursAhead(h int) int64 {
return time.Now().Add(time.Duration(h) * time.Hour).UnixMilli()
}
// guestToken mints a link and redeems it, returning the raw session token a
// guest's cookie would carry.
func guestToken(t *testing.T, a *Auth, ownerID, label string, hours int) string {
t.Helper()
link, err := a.createShare(ownerID, label, hoursAhead(hours))
if err != nil {
t.Fatalf("create share: %v", err)
}
raw, _, ok := a.redeemShare(link.Token)
if !ok {
t.Fatal("redeem: fresh link was rejected")
}
return raw
}
// request builds a request carrying the given session cookie.
func request(method, path, cookie, body string) *http.Request {
r := httptest.NewRequest(method, path, strings.NewReader(body))
if cookie != "" {
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: cookie})
}
return r
}
func TestRedeemedLinkActsAsTheOwner(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
token := guestToken(t, a, ownerID, "Anna", 24)
s, ok := a.sessionForToken(token)
if !ok {
t.Fatal("session for a fresh guest token was rejected")
}
if s.userID != ownerID {
t.Errorf("guest session scoped to %q, want the owner %q", s.userID, ownerID)
}
if !s.guest() {
t.Error("session from a share link does not report itself as a guest")
}
if s.label != "Anna" {
t.Errorf("label = %q, want %q", s.label, "Anna")
}
}
func TestOwnerSessionIsNotAGuest(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
raw, err := a.startSession(ownerID, "", time.Now().Add(sessionValidity).UnixMilli())
if err != nil {
t.Fatalf("start session: %v", err)
}
s, ok := a.sessionForToken(raw)
if !ok {
t.Fatal("owner session was rejected")
}
if s.guest() || s.label != "" {
t.Errorf("owner session reports guest=%v label=%q, want false/empty", s.guest(), s.label)
}
}
func TestUnusableTokensAreRejected(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
if _, _, ok := a.redeemShare("not-a-real-token"); ok {
t.Error("a garbage token was redeemed")
}
if _, _, ok := a.redeemShare(""); ok {
t.Error("an empty token was redeemed")
}
// An expired link: backdate it past its own end.
link, err := a.createShare(ownerID, "Stale", hoursAhead(12))
if err != nil {
t.Fatalf("create share: %v", err)
}
if _, err := a.db.Exec(
`UPDATE share_links SET expires = ? WHERE id = ?`,
time.Now().Add(-time.Minute).UnixMilli(), link.ID); err != nil {
t.Fatalf("backdate: %v", err)
}
if _, _, ok := a.redeemShare(link.Token); ok {
t.Error("an expired link was redeemed")
}
// A revoked link.
revoked, err := a.createShare(ownerID, "Revoked", hoursAhead(12))
if err != nil {
t.Fatalf("create share: %v", err)
}
if err := a.revokeShare(ownerID, revoked.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
if _, _, ok := a.redeemShare(revoked.Token); ok {
t.Error("a revoked link was redeemed")
}
}
// Revocation has to bite on the next request, not whenever the guest's own
// session row happens to lapse — that is the whole point of being able to
// revoke.
func TestRevokingKillsLiveSessions(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
token, _, ok := a.redeemShare(link.Token)
if !ok {
t.Fatal("redeem: fresh link was rejected")
}
if _, ok := a.sessionForToken(token); !ok {
t.Fatal("session invalid before revoking")
}
if err := a.revokeShare(ownerID, link.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
if _, ok := a.sessionForToken(token); ok {
t.Error("session still valid after its link was revoked")
}
}
// A guest session must never outlive its link, however long the default
// session validity is.
func TestGuestSessionIsCappedAtTheLinkExpiry(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
link, err := a.createShare(ownerID, "Anna", hoursAhead(12))
if err != nil {
t.Fatalf("create share: %v", err)
}
_, sessionEnd, ok := a.redeemShare(link.Token)
if !ok {
t.Fatal("redeem: fresh link was rejected")
}
if sessionEnd != link.Expires {
t.Errorf("session ends at %d, want the link's own %d", sessionEnd, link.Expires)
}
}
func TestRevokeIsScopedToTheOwner(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
other, err := a.createUser("other@example.com", "hunter2hunter2")
if err != nil {
t.Fatalf("create user: %v", err)
}
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
if err := a.revokeShare(other.ID, link.ID); err == nil {
t.Error("another account revoked a link it does not own")
}
if _, _, ok := a.redeemShare(link.Token); !ok {
t.Error("link was revoked by an account that does not own it")
}
}
func TestListSharesHidesRevokedAndExpired(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
live, err := a.createShare(ownerID, "Live", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
gone, err := a.createShare(ownerID, "Gone", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
if err := a.revokeShare(ownerID, gone.ID); err != nil {
t.Fatalf("revoke: %v", err)
}
stale, err := a.createShare(ownerID, "Stale", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
if _, err := a.db.Exec(
`UPDATE share_links SET expires = ? WHERE id = ?`,
time.Now().Add(-time.Minute).UnixMilli(), stale.ID); err != nil {
t.Fatalf("backdate: %v", err)
}
links, err := a.listShares(ownerID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(links) != 1 || links[0].ID != live.ID {
t.Fatalf("listed %d link(s), want only the live one", len(links))
}
}
// Settings shows every live link's URL so it can be re-sent, which means a
// listing has to carry the same secret the link was created with — and that
// secret has to still work.
func TestListSharesReturnsAWorkingURL(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
links, err := a.listShares(ownerID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(links) != 1 {
t.Fatalf("listed %d link(s), want 1", len(links))
}
if links[0].Token != link.Token {
t.Fatalf("listing returned %q, want the issued secret %q", links[0].Token, link.Token)
}
if _, _, ok := a.redeemShare(links[0].Token); !ok {
t.Error("the secret handed back by the listing does not open the link")
}
}
// The lookup column stays a hash even though the secret is kept beside it, so
// the token in a URL is never what is matched against directly.
func TestLookupIsStillByHash(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
var stored string
if err := a.db.QueryRow(`SELECT token FROM share_links WHERE id = ?`, link.ID).Scan(&stored); err != nil {
t.Fatalf("read back: %v", err)
}
if stored != hashToken(link.Token) {
t.Error("the lookup column is not the hash of the issued token")
}
}
// A link made before secrets were kept has no URL to show. It must still work
// and still be revocable — only the copy-again affordance is unavailable.
func TestLinkWithoutAStoredSecretStillWorks(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
link, err := a.createShare(ownerID, "Legacy", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
// What the migration leaves behind for a pre-existing row.
if _, err := a.db.Exec(`UPDATE share_links SET secret = '' WHERE id = ?`, link.ID); err != nil {
t.Fatalf("clear secret: %v", err)
}
links, err := a.listShares(ownerID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(links) != 1 || links[0].Token != "" {
t.Fatalf("want the link listed with an empty token, got %+v", links)
}
if _, _, ok := a.redeemShare(link.Token); !ok {
t.Error("a link whose secret was never stored stopped working")
}
if err := a.revokeShare(ownerID, link.ID); err != nil {
t.Errorf("could not revoke it: %v", err)
}
}
func TestDeletingAnAccountDropsItsLinks(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
link, err := a.createShare(ownerID, "Anna", hoursAhead(24))
if err != nil {
t.Fatalf("create share: %v", err)
}
if err := a.deleteAccount(ownerID); err != nil {
t.Fatalf("delete account: %v", err)
}
var n int
if err := a.db.QueryRow(`SELECT COUNT(*) FROM share_links WHERE id = ?`, link.ID).Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
if n != 0 {
t.Error("the deleted account's guest links survived")
}
}
// ---------- capability gating ----------
func TestRequireOwnerBlocksGuests(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
guest := guestToken(t, a, ownerID, "Anna", 24)
owner, err := a.startSession(ownerID, "", time.Now().Add(sessionValidity).UnixMilli())
if err != nil {
t.Fatalf("start session: %v", err)
}
reached := false
h := a.requireOwner(func(w http.ResponseWriter, r *http.Request) { reached = true })
w := httptest.NewRecorder()
h(w, request(http.MethodPost, "/api/shares", guest, "{}"))
if w.Code != http.StatusForbidden {
t.Errorf("guest got %d, want %d", w.Code, http.StatusForbidden)
}
if reached {
t.Error("the guarded handler ran for a guest")
}
w = httptest.NewRecorder()
h(w, request(http.MethodPost, "/api/shares", owner, "{}"))
if w.Code != http.StatusOK || !reached {
t.Errorf("owner got %d and reached=%v, want 200 and true", w.Code, reached)
}
}
// A guest must still be able to do the thing the link exists for.
func TestRequireUserAllowsGuests(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
guest := guestToken(t, a, ownerID, "Anna", 24)
var sawUser, sawLabel string
h := a.requireUser(func(w http.ResponseWriter, r *http.Request) {
sawUser, sawLabel = userID(r), guestLabel(r)
})
w := httptest.NewRecorder()
h(w, request(http.MethodPost, "/api/events/sync", guest, "{}"))
if w.Code != http.StatusOK {
t.Fatalf("guest got %d on a shared route, want 200", w.Code)
}
if sawUser != ownerID {
t.Errorf("handler saw user %q, want the owner %q", sawUser, ownerID)
}
if sawLabel != "Anna" {
t.Errorf("handler saw label %q, want %q", sawLabel, "Anna")
}
}
func TestHandleMeHidesTheOwnerFromAGuest(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
guest := guestToken(t, a, ownerID, "Anna", 24)
w := httptest.NewRecorder()
a.requireUser(a.handleMe)(w, request(http.MethodGet, "/api/me", guest, ""))
if w.Code != http.StatusOK {
t.Fatalf("got %d, want 200", w.Code)
}
var u User
if err := json.NewDecoder(w.Body).Decode(&u); err != nil {
t.Fatalf("decode: %v", err)
}
if u.Email != "" {
t.Errorf("a guest was told the owner's email (%q)", u.Email)
}
if u.Role != "guest" || u.Label != "Anna" {
t.Errorf("role/label = %q/%q, want guest/Anna", u.Role, u.Label)
}
if u.ID != ownerID {
t.Errorf("id = %q, want the owner's %q so the client scopes its cache right", u.ID, ownerID)
}
}
// ---------- attribution ----------
func loggedBy(t *testing.T, db *sql.DB, id string) string {
t.Helper()
var by string
if err := db.QueryRow(`SELECT logged_by FROM events WHERE id = ?`, id).Scan(&by); err != nil {
t.Fatalf("read logged_by: %v", err)
}
return by
}
func TestAttributionIsStampedFromTheSession(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000},
}); err != nil {
t.Fatalf("guest sync: %v", err)
}
if got := loggedBy(t, a.db, "e1"); got != "Anna" {
t.Errorf("logged_by = %q, want %q", got, "Anna")
}
if _, err := store.sync(ownerID, "", "", []Event{
{ID: "e2", Type: "poo", At: 2000, UpdatedAt: 2000},
}); err != nil {
t.Fatalf("owner sync: %v", err)
}
if got := loggedBy(t, a.db, "e2"); got != "" {
t.Errorf("the owner's own event was attributed to %q", got)
}
}
// Attribution is decided once, by whoever logged the event. A later edit —
// by the owner or by another guest — must not rewrite it.
func TestAttributionSurvivesLaterEdits(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000},
}); err != nil {
t.Fatalf("guest sync: %v", err)
}
// The owner edits the note, bumping updatedAt so LWW takes the change.
if _, err := store.sync(ownerID, "", "", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "on the walk", UpdatedAt: 2000},
}); err != nil {
t.Fatalf("owner edit: %v", err)
}
if got := loggedBy(t, a.db, "e1"); got != "Anna" {
t.Errorf("logged_by = %q after an owner edit, want it to stay %q", got, "Anna")
}
// And the guest re-POSTing their own event leaves it alone too.
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "on the walk", UpdatedAt: 3000},
}); err != nil {
t.Fatalf("guest re-sync: %v", err)
}
if got := loggedBy(t, a.db, "e1"); got != "Anna" {
t.Errorf("logged_by = %q after the guest re-synced, want %q", got, "Anna")
}
}
// The value never comes off the wire, so a client cannot claim to be someone
// else — or launder its own events into looking like the owner's.
func TestAttributionCannotBeSetByTheClient(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
merged, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000, LoggedBy: ""},
{ID: "e2", Type: "poo", At: 2000, UpdatedAt: 2000, LoggedBy: "The Owner", LoggedByShare: "s-other"},
})
if err != nil {
t.Fatalf("sync: %v", err)
}
for _, id := range []string{"e1", "e2"} {
if got := loggedBy(t, a.db, id); got != "Anna" {
t.Errorf("%s: logged_by = %q, want the session's %q", id, got, "Anna")
}
}
// And the server's own answer carries the stamp back, so the client can
// render the badge without having to guess.
for _, e := range merged {
if e.LoggedBy != "Anna" {
t.Errorf("%s came back as %q, want %q", e.ID, e.LoggedBy, "Anna")
}
if e.LoggedByShare != "s1" {
t.Errorf("%s came back from link %q, want %q", e.ID, e.LoggedByShare, "s1")
}
}
}
// ---------- what a guest is allowed to change ----------
// eventNote reads back one event's note and tombstone flag — enough to tell
// whether an attempted edit or delete actually landed.
func eventState(t *testing.T, db *sql.DB, id string) (note string, deleted bool) {
t.Helper()
if err := db.QueryRow(`SELECT note, deleted FROM events WHERE id = ?`, id).Scan(&note, &deleted); err != nil {
t.Fatalf("read event %s: %v", id, err)
}
return note, deleted
}
// The point of the whole guard: a sitter must not be able to rewrite or delete
// what the owner logged, however their client asks.
func TestGuestCannotChangeTheOwnersEvents(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
// The owner logs something.
if _, err := store.sync(ownerID, "", "", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "mine", UpdatedAt: 1000},
}); err != nil {
t.Fatalf("owner sync: %v", err)
}
// A guest tries to edit it, with a much newer timestamp so last-write-wins
// alone would take the change.
merged, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "rewritten by the sitter", UpdatedAt: 9000},
})
if err != nil {
t.Fatalf("guest edit: %v", err)
}
if note, _ := eventState(t, a.db, "e1"); note != "mine" {
t.Errorf("a guest rewrote the owner's event: note = %q", note)
}
// The guest gets the stored version back, so an honest client can heal.
for _, e := range merged {
if e.ID == "e1" && e.Note != "mine" {
t.Errorf("server returned %q for the owner's event, want %q", e.Note, "mine")
}
}
// And cannot delete it either — a tombstone is just another update.
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "mine", UpdatedAt: 9001, Deleted: true},
}); err != nil {
t.Fatalf("guest delete: %v", err)
}
if _, deleted := eventState(t, a.db, "e1"); deleted {
t.Error("a guest deleted the owner's event")
}
}
func TestGuestCanChangeItsOwnEvents(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "oops", UpdatedAt: 1000},
}); err != nil {
t.Fatalf("guest sync: %v", err)
}
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "fixed", UpdatedAt: 2000},
}); err != nil {
t.Fatalf("guest edit: %v", err)
}
if note, _ := eventState(t, a.db, "e1"); note != "fixed" {
t.Errorf("a guest could not fix up their own entry: note = %q", note)
}
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "fixed", UpdatedAt: 3000, Deleted: true},
}); err != nil {
t.Fatalf("guest delete: %v", err)
}
if _, deleted := eventState(t, a.db, "e1"); !deleted {
t.Error("a guest could not delete their own entry")
}
}
// Two links can carry the same label ("Sitter"), so the id — not the label —
// has to be what authorises the change.
func TestGuestCannotChangeAnotherLinksEvents(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
if _, err := store.sync(ownerID, "Sitter", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "anna's", UpdatedAt: 1000},
}); err != nil {
t.Fatalf("first guest sync: %v", err)
}
// Same label, different link.
if _, err := store.sync(ownerID, "Sitter", "s2", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "bob's", UpdatedAt: 2000},
}); err != nil {
t.Fatalf("second guest sync: %v", err)
}
if note, _ := eventState(t, a.db, "e1"); note != "anna's" {
t.Errorf("one link's guest edited another's event: note = %q", note)
}
}
// The owner keeps full control of everything on their account, including what
// a guest logged.
func TestOwnerCanChangeAGuestsEvents(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
if _, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "sitter's", UpdatedAt: 1000},
}); err != nil {
t.Fatalf("guest sync: %v", err)
}
if _, err := store.sync(ownerID, "", "", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "corrected", UpdatedAt: 2000},
}); err != nil {
t.Fatalf("owner edit: %v", err)
}
if note, _ := eventState(t, a.db, "e1"); note != "corrected" {
t.Errorf("the owner could not edit a guest's event: note = %q", note)
}
// And delete it. A tombstone is just another update, so this rides the same
// clause — but it is the half that matters if a sitter logs something wrong
// and the owner wants it gone rather than fixed.
if _, err := store.sync(ownerID, "", "", []Event{
{ID: "e1", Type: "pee", At: 1000, Note: "corrected", UpdatedAt: 3000, Deleted: true},
}); err != nil {
t.Fatalf("owner delete: %v", err)
}
if _, deleted := eventState(t, a.db, "e1"); !deleted {
t.Error("the owner could not delete a guest's event")
}
}
// Marking a day as not counted is a judgment about the record, so it is the
// owner's — a sitter cannot decide their own thin day shouldn't count, nor
// quietly take a good day out of the averages.
func TestGuestCannotExcludeADay(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
merged, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "mark", Type: eventTypeDayExcluded, At: 1000, UpdatedAt: 1000},
{ID: "pee1", Type: "pee", At: 1000, UpdatedAt: 1000},
})
if err != nil {
t.Fatalf("guest sync: %v", err)
}
for _, e := range merged {
if e.Type == eventTypeDayExcluded {
t.Fatal("a guest marked a day as not counted")
}
}
// The rest of the same sync still lands — the mark is dropped, not the batch.
if len(merged) != 1 || merged[0].ID != "pee1" {
t.Errorf("dropping the mark cost the guest their other events: %+v", merged)
}
// The owner may, of course.
merged, err = store.sync(ownerID, "", "", []Event{
{ID: "mark", Type: eventTypeDayExcluded, At: 1000, UpdatedAt: 1000},
})
if err != nil {
t.Fatalf("owner sync: %v", err)
}
var found bool
for _, e := range merged {
if e.ID == "mark" && e.Type == eventTypeDayExcluded {
found = true
}
}
if !found {
t.Error("the owner could not mark a day as not counted")
}
}
// The food kinds are the owner's library, like the exercise list: a guest
// labels a meal with a kind that exists but does not invent or rename one.
func TestGuestCannotChangeFoodKinds(t *testing.T) {
a := testAuth(t)
kinds := newFoodKindStore(a.db)
ownerID := testOwner(t, a)
if _, err := kinds.sync(ownerID, []FoodKind{
{ID: "k1", Name: "Dry", IsDefault: true, UpdatedAt: 1000},
}); err != nil {
t.Fatalf("owner sync: %v", err)
}
// What the route hands the store for a guest: nothing incoming, everything
// back. Mirrors the exercises guard in main.go.
merged, err := kinds.sync(ownerID, nil)
if err != nil {
t.Fatalf("guest sync: %v", err)
}
if len(merged) != 1 || merged[0].Name != "Dry" {
t.Fatalf("a guest should still receive the library: %+v", merged)
}
if !merged[0].IsDefault {
t.Error("the default flag did not survive the round trip")
}
// And the owner can still rename it, which is the other half of the rule.
renamed, err := kinds.sync(ownerID, []FoodKind{
{ID: "k1", Name: "Dry kibble", IsDefault: true, UpdatedAt: 2000},
})
if err != nil {
t.Fatalf("owner rename: %v", err)
}
if renamed[0].Name != "Dry kibble" {
t.Errorf("owner could not rename a kind: %q", renamed[0].Name)
}
}
// A meal's kind rides the event sync like any other field, and an older client
// that doesn't know about kinds must not wipe one.
func TestFoodKindOnAnEventSurvivesSync(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
merged, err := store.sync(ownerID, "", "", []Event{
{ID: "e1", Type: "eat", At: 1000, Grams: 180, FoodKindID: "k1", UpdatedAt: 1000},
{ID: "e2", Type: "eat", At: 2000, Grams: 120, UpdatedAt: 2000}, // no kind, as before
})
if err != nil {
t.Fatalf("sync: %v", err)
}
byID := map[string]Event{}
for _, e := range merged {
byID[e.ID] = e
}
if byID["e1"].FoodKindID != "k1" {
t.Errorf("the kind did not round-trip: %q", byID["e1"].FoodKindID)
}
if byID["e2"].FoodKindID != "" {
t.Errorf("a meal with no kind gained one: %q", byID["e2"].FoodKindID)
}
}
// Guests still log freely — the guard is on changing what already exists.
func TestGuestCanStillAddEvents(t *testing.T) {
a := testAuth(t)
store := newStore(a.db)
ownerID := testOwner(t, a)
merged, err := store.sync(ownerID, "Anna", "s1", []Event{
{ID: "e1", Type: "pee", At: 1000, UpdatedAt: 1000},
})
if err != nil {
t.Fatalf("guest sync: %v", err)
}
if len(merged) != 1 || merged[0].ID != "e1" {
t.Fatalf("guest's new event did not land: %+v", merged)
}
}
// ---------- link expiry ----------
func TestShareExpiryIsWhateverWasAskedFor(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
want := hoursAhead(53) // an odd span no fixed duration would produce
link, err := a.createShare(ownerID, "Anna", want)
if err != nil {
t.Fatalf("create share: %v", err)
}
if link.Expires != want {
t.Errorf("link expires at %d, want the requested %d", link.Expires, want)
}
}
func TestCreateShareRejectsBadDates(t *testing.T) {
a := testAuth(t)
ownerID := testOwner(t, a)
owner, err := a.startSession(ownerID, "", time.Now().Add(sessionValidity).UnixMilli())
if err != nil {
t.Fatalf("start session: %v", err)
}
for _, tc := range []struct {
name string
expires int64
}{
{"in the past", time.Now().Add(-time.Hour).UnixMilli()},
{"missing", 0},
{"absurdly far off", time.Now().Add(5 * 365 * 24 * time.Hour).UnixMilli()},
} {
body := `{"label":"Anna","expires":` + strconv.FormatInt(tc.expires, 10) + `}`
w := httptest.NewRecorder()
a.requireOwner(a.handleShares)(w, request(http.MethodPost, "/api/shares", owner, body))
if w.Code != http.StatusBadRequest {
t.Errorf("%s: got %d, want %d", tc.name, w.Code, http.StatusBadRequest)
}
}
}
+324 -37
View File
@@ -32,8 +32,21 @@ type Event struct {
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
Grams float64 `json:"grams,omitempty"` // food eaten, for "eat" events
ExerciseID string `json:"exerciseId,omitempty"` // for "training" events
UpdatedAt int64 `json:"updatedAt"`
Deleted bool `json:"deleted,omitempty"`
// FoodKindID names which sort of food, for "eat" events. Empty is a real
// answer — "no kind" — and is what every meal logged before kinds existed
// carries, so none of them needed rewriting.
FoodKindID string `json:"foodKindId,omitempty"`
UpdatedAt int64 `json:"updatedAt"`
Deleted bool `json:"deleted,omitempty"`
// LoggedBy names the guest link an event was logged through, empty for the
// owner's own. It is stamped by the server from the session (see Store.sync)
// and never read off the wire, so a client can neither forge nor rewrite it.
LoggedBy string `json:"loggedBy,omitempty"`
// LoggedByShare is that link's id. LoggedBy is a label the owner typed and
// two links may well share one ("Sitter"), so the id — not the label — is
// what decides whether a guest may change this event. Sent to the client so
// it can grey out what it isn't allowed to touch; opaque and harmless.
LoggedByShare string `json:"loggedByShare,omitempty"`
}
// Exercise is a user-defined training exercise (e.g. "Sit", "Leash walking"):
@@ -48,6 +61,12 @@ type Exercise struct {
Deleted bool `json:"deleted,omitempty"`
}
// eventTypeDayExcluded marks a day the owner has taken out of the charts and
// averages — a sitter's thin day, a stay at kennels. It is an event so it rides
// the ordinary sync (per-item last-write-wins, tombstone to un-mark) rather than
// needing a table and endpoint of its own; the client reads it in app.js.
const eventTypeDayExcluded = "day-excluded"
var uuidRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
func validUUID(s string) bool { return uuidRE.MatchString(s) }
@@ -131,8 +150,11 @@ func newStore(db *sql.DB) *Store {
// sync merges one user's client events into the store using last-write-wins by
// UpdatedAt, then returns that user's full merged set (tombstones included, as
// they must propagate).
func (s *Store) sync(userID string, client []Event) ([]Event, error) {
// they must propagate). loggedBy/shareID describe the caller's session — the
// guest link's label and id, both empty for the owner. They are stamped onto
// events this call inserts, and shareID additionally decides which existing
// events the caller is allowed to change.
func (s *Store) sync(userID, loggedBy, shareID string, client []Event) ([]Event, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
@@ -141,19 +163,35 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
// The WHERE clause on the upsert is the last-write-wins rule: an incoming
// event only overwrites the stored one when its updatedAt is strictly newer.
// The `events.user_id = excluded.user_id` guard means one user can never
// clobber another's row even if a client forges a colliding event ID —
// the row stays put and, because reads are scoped, stays invisible to them.
// Two further guards ride on it:
//
// - events.user_id = excluded.user_id — one user can never clobber
// another's row even if a client forges a colliding event ID; the row
// stays put and, because reads are scoped, stays invisible to them.
// - the logged_by_share clause — an owner (excluded.logged_by_share = '')
// may change anything; a guest may only change events logged through
// their own link. So a sitter can fix up their own entries, and cannot
// edit or delete a single one of the owner's. A rejected row simply
// stays as it was, and the caller gets the stored version back.
//
// The two attribution columns are deliberately absent from the DO UPDATE SET
// list: attribution is decided once, by whoever first inserted the event, and
// a later edit by anyone leaves it alone. That is also what makes it
// unspoofable — a guest re-POSTs the owner's whole event list on every sync,
// but those rows already exist and so keep their stored values.
stmt, err := tx.Prepare(`
INSERT INTO events (id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO events (id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, user_id, logged_by, logged_by_share, food_kind_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
type = excluded.type, at = excluded.at, note = excluded.note,
photo_id = excluded.photo_id, weight = excluded.weight,
grams = excluded.grams, exercise_id = excluded.exercise_id,
food_kind_id = excluded.food_kind_id,
updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > events.updated
AND events.user_id = excluded.user_id`)
AND events.user_id = excluded.user_id
AND (excluded.logged_by_share = ''
OR events.logged_by_share = excluded.logged_by_share)`)
if err != nil {
return nil, err
}
@@ -163,8 +201,15 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
if ce.ID == "" {
continue
}
// Marking a day as not counted is a judgment about the record rather
// than something that happened to the puppy, so it belongs to the owner
// alongside everything else a guest may not decide. The client hides the
// control; this is what enforces it.
if shareID != "" && ce.Type == eventTypeDayExcluded {
continue
}
if _, err := stmt.Exec(
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.Grams, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID,
ce.ID, ce.Type, ce.At, ce.Note, ce.PhotoID, ce.Weight, ce.Grams, ce.ExerciseID, ce.UpdatedAt, ce.Deleted, userID, loggedBy, shareID, ce.FoodKindID,
); err != nil {
return nil, err
}
@@ -178,7 +223,7 @@ func (s *Store) sync(userID string, client []Event) ([]Event, error) {
// all returns one user's events, tombstones included.
func (s *Store) all(userID string) ([]Event, error) {
rows, err := s.db.Query(
`SELECT id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted
`SELECT id, type, at, note, photo_id, weight, grams, exercise_id, updated, deleted, logged_by, logged_by_share, food_kind_id
FROM events WHERE user_id = ?`, userID)
if err != nil {
return nil, err
@@ -188,7 +233,7 @@ func (s *Store) all(userID string) ([]Event, error) {
for rows.Next() {
var e Event
if err := rows.Scan(
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.Grams, &e.ExerciseID, &e.UpdatedAt, &e.Deleted,
&e.ID, &e.Type, &e.At, &e.Note, &e.PhotoID, &e.Weight, &e.Grams, &e.ExerciseID, &e.UpdatedAt, &e.Deleted, &e.LoggedBy, &e.LoggedByShare, &e.FoodKindID,
); err != nil {
return nil, err
}
@@ -197,6 +242,100 @@ func (s *Store) all(userID string) ([]Event, error) {
return out, rows.Err()
}
// FoodKind is a user-named sort of food ("Dry", "Fresh"), referenced by
// FoodKindID on an "eat" event. Empty means no kind, which is what every meal
// logged before kinds existed carries and what anyone who doesn't want to
// classify their food keeps carrying.
//
// Same contract as Exercise — UUID ids, last-write-wins on UpdatedAt,
// tombstoned deletes — plus two fields of its own:
//
// - IsDefault marks the kind the log dialog pre-selects. It lives here rather
// than in the profile because the profile is last-write-wins across the
// whole row, and this file already carries a special case for pedigree_id
// to stop a clock race dropping it. Per-item LWW needs no such case: two
// devices setting different defaults resolve to the newer one.
// - ColorIndex fixes which palette entry the charts give it, assigned at
// creation. Deriving colour from position in the live list would silently
// recolour every past chart the moment a kind was deleted.
type FoodKind struct {
ID string `json:"id"`
Name string `json:"name"`
IsDefault bool `json:"isDefault,omitempty"`
ColorIndex int `json:"colorIndex"`
UpdatedAt int64 `json:"updatedAt"`
Deleted bool `json:"deleted,omitempty"`
}
// FoodKindStore is ExerciseStore for food kinds. The duplication is deliberate:
// Store and ExerciseStore are already near-twins, so a third in the same shape
// is the pattern this file has established, and it leaves both working
// collections untouched. Folding all three into one store parameterised by
// table name is the tidier end state, and a separate job.
type FoodKindStore struct {
db *sql.DB
}
func newFoodKindStore(db *sql.DB) *FoodKindStore {
return &FoodKindStore{db: db}
}
func (s *FoodKindStore) sync(userID string, client []FoodKind) ([]FoodKind, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`
INSERT INTO food_kinds (id, name, is_default, color_index, updated, deleted, user_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name, is_default = excluded.is_default,
color_index = excluded.color_index,
updated = excluded.updated, deleted = excluded.deleted
WHERE excluded.updated > food_kinds.updated
AND food_kinds.user_id = excluded.user_id`)
if err != nil {
return nil, err
}
defer stmt.Close()
for _, k := range client {
if k.ID == "" {
continue
}
if _, err := stmt.Exec(
k.ID, k.Name, k.IsDefault, k.ColorIndex, k.UpdatedAt, k.Deleted, userID,
); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.all(userID)
}
func (s *FoodKindStore) all(userID string) ([]FoodKind, error) {
rows, err := s.db.Query(
`SELECT id, name, is_default, color_index, updated, deleted
FROM food_kinds WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]FoodKind, 0)
for rows.Next() {
var k FoodKind
if err := rows.Scan(&k.ID, &k.Name, &k.IsDefault, &k.ColorIndex, &k.UpdatedAt, &k.Deleted); err != nil {
return nil, err
}
out = append(out, k)
}
return out, rows.Err()
}
// ExerciseStore mirrors Store for the exercises collection: same LWW sync by
// UpdatedAt, same user_id guard against cross-user id collisions, same
// tombstone propagation.
@@ -289,17 +428,20 @@ func openDB(path string) (*sql.DB, error) {
// the first account adopts it (see Auth.adopt).
schema := `
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT '',
at INTEGER NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '',
photo_id TEXT NOT NULL DEFAULT '',
weight REAL NOT NULL DEFAULT 0,
grams REAL NOT NULL DEFAULT 0,
exercise_id TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT ''
id TEXT PRIMARY KEY,
type TEXT NOT NULL DEFAULT '',
at INTEGER NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '',
photo_id TEXT NOT NULL DEFAULT '',
weight REAL NOT NULL DEFAULT 0,
grams REAL NOT NULL DEFAULT 0,
exercise_id TEXT NOT NULL DEFAULT '',
updated INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT '',
logged_by TEXT NOT NULL DEFAULT '',
logged_by_share TEXT NOT NULL DEFAULT '',
food_kind_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id);
CREATE TABLE IF NOT EXISTS exercises (
@@ -311,6 +453,16 @@ func openDB(path string) (*sql.DB, error) {
user_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_exercises_user ON exercises(user_id);
CREATE TABLE IF NOT EXISTS food_kinds (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
is_default INTEGER NOT NULL DEFAULT 0,
color_index INTEGER NOT NULL DEFAULT 0,
updated INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
user_id TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_food_kinds_user ON food_kinds(user_id);
CREATE TABLE IF NOT EXISTS config (
user_id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
@@ -325,11 +477,24 @@ func openDB(path string) (*sql.DB, error) {
created INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created INTEGER NOT NULL,
expires INTEGER NOT NULL
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created INTEGER NOT NULL,
expires INTEGER NOT NULL,
share_id TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS share_links (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
secret TEXT NOT NULL DEFAULT '',
label TEXT NOT NULL DEFAULT '',
created INTEGER NOT NULL,
expires INTEGER NOT NULL,
last_used INTEGER NOT NULL DEFAULT 0,
revoked INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_share_links_user ON share_links(user_id);
CREATE TABLE IF NOT EXISTS pedigree_cache (
hundid TEXT PRIMARY KEY,
subject TEXT NOT NULL DEFAULT '',
@@ -401,6 +566,59 @@ func migrateSchema(db *sql.DB) error {
return err
}
}
// Guest links (see Auth.createShare). Every column defaults to the empty
// string, which is exactly what pre-guest-link rows mean: an event nobody
// but the owner logged, and a session that isn't a guest's.
hasLoggedBy, err := columnExists(db, "events", "logged_by")
if err != nil {
return err
}
if !hasLoggedBy {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN logged_by TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
hasLoggedByShare, err := columnExists(db, "events", "logged_by_share")
if err != nil {
return err
}
if !hasLoggedByShare {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN logged_by_share TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
// Guest links are re-showable in Settings, which means keeping the token
// itself and not only its hash (see Auth.createShare). Links made before
// this have an empty secret and simply cannot be shown again.
hasSecret, err := columnExists(db, "share_links", "secret")
if err != nil {
return err
}
if !hasSecret {
if _, err := db.Exec(`ALTER TABLE share_links ADD COLUMN secret TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
// Which sort of food a meal was. Empty on every existing row, which is
// exactly right: those meals have no kind, and none of them need rewriting.
hasFoodKind, err := columnExists(db, "events", "food_kind_id")
if err != nil {
return err
}
if !hasFoodKind {
if _, err := db.Exec(`ALTER TABLE events ADD COLUMN food_kind_id TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
hasShareID, err := columnExists(db, "sessions", "share_id")
if err != nil {
return err
}
if !hasShareID {
if _, err := db.Exec(`ALTER TABLE sessions ADD COLUMN share_id TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
oldConfig, err := columnExists(db, "config", "id")
if err != nil {
return err
@@ -494,7 +712,7 @@ func importEvents(db *sql.DB, path string) error {
// Imported as ownerless (user_id = ""); the first account to register adopts
// them. Mirrors how in-place schema migration parks legacy rows.
store := newStore(db)
if _, err := store.sync("", evs); err != nil {
if _, err := store.sync("", "", "", evs); err != nil {
return err
}
log.Printf("migrated %d events from %s", len(evs), path)
@@ -552,6 +770,14 @@ type exerciseSyncResponse struct {
Exercises []Exercise `json:"exercises"`
}
type foodKindSyncRequest struct {
FoodKinds []FoodKind `json:"foodKinds"`
}
type foodKindSyncResponse struct {
FoodKinds []FoodKind `json:"foodKinds"`
}
type cacheControlFS struct {
root http.FileSystem
}
@@ -654,6 +880,7 @@ func main() {
store := newStore(db)
configStore := newConfigStore(db)
exerciseStore := newExerciseStore(db)
foodKindStore := newFoodKindStore(db)
pedigrees := newPedManager(db)
photosDir := filepath.Join(filepath.Dir(*dataPath), "photos")
@@ -686,12 +913,24 @@ func main() {
case http.MethodGet:
auth.handleMe(w, r)
case http.MethodDelete:
// Deleting the account is the owner's alone, so this arm — and only
// this arm — is gated; a guest still needs the GET to learn its role.
if isGuest(r) {
http.Error(w, "guest links cannot do this", http.StatusForbidden)
return
}
auth.handleDeleteAccount(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}))
// Guest links: minting, listing and revoking are the owner's, redeeming is
// the unauthenticated entry point the link itself points at.
mux.HandleFunc("/api/shares", auth.requireOwner(auth.handleShares))
mux.HandleFunc("/api/shares/", auth.requireOwner(auth.handleShare))
mux.HandleFunc("/guest/", auth.handleRedeem)
mux.HandleFunc("/api/events/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
@@ -702,7 +941,7 @@ func main() {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
merged, err := store.sync(userID(r), req.Events)
merged, err := store.sync(userID(r), guestLabel(r), sessionOf(r).shareID, req.Events)
if err != nil {
log.Printf("sync: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
@@ -728,7 +967,16 @@ func main() {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
merged, err := exerciseStore.sync(userID(r), req.Exercises)
// Exercises are the owner's library, not a log: a guest logs training
// sessions against them (ordinary events) but does not get to rename or
// delete them. Dropping the incoming list makes this direction-only —
// the guest still receives the full set back. The client hides the
// editing UI to match; this is the part that enforces it.
incoming := req.Exercises
if isGuest(r) {
incoming = nil
}
merged, err := exerciseStore.sync(userID(r), incoming)
if err != nil {
log.Printf("exercises sync: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
@@ -739,6 +987,36 @@ func main() {
_ = json.NewEncoder(w).Encode(exerciseSyncResponse{Exercises: merged})
}))
// POST /api/foodkinds/sync — the same contract again for the food kinds a
// meal can be labelled with.
mux.HandleFunc("/api/foodkinds/sync", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req foodKindSyncRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&req); err != nil {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
return
}
// The library is the owner's, exactly as the exercise list is: a guest
// labels a meal with a kind that exists, but does not invent, rename or
// delete one. They still receive the full set, so the picker works.
incoming := req.FoodKinds
if isGuest(r) {
incoming = nil
}
merged, err := foodKindStore.sync(userID(r), incoming)
if err != nil {
log.Printf("food kinds sync: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(foodKindSyncResponse{FoodKinds: merged})
}))
// GET /api/config — return the caller's puppy profile.
// PUT /api/config — update it (last-write-wins by updatedAt).
mux.HandleFunc("/api/config", auth.requireUser(func(w http.ResponseWriter, r *http.Request) {
@@ -751,6 +1029,13 @@ func main() {
case http.MethodGet:
writeConfig(configStore.get(userID(r)))
case http.MethodPut, http.MethodPost:
// The profile (name, birthday, pedigree id) is the owner's to set.
// The GET above stays open — a guest needs the name and birthday to
// render the header at all.
if isGuest(r) {
http.Error(w, "guest links cannot do this", http.StatusForbidden)
return
}
var in Config
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&in); err != nil {
http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
@@ -800,13 +1085,15 @@ func main() {
// Push reminders. Registered only when the scheduler came up, so a server
// without a usable VAPID key 404s these rather than half-working — which is
// also what tells the client to hide the reminder UI entirely.
// also what tells the client to hide the reminder UI entirely. Owner-only:
// the reminders are the owner's own, and a guest device subscribing would
// route them to the sitter's lock screen.
if scheduler != nil {
mux.HandleFunc("/api/push/key", auth.requireUser(scheduler.handleKey))
mux.HandleFunc("/api/push/subscribe", auth.requireUser(scheduler.handleSubscribe))
mux.HandleFunc("/api/push/unsubscribe", auth.requireUser(scheduler.handleUnsubscribe))
mux.HandleFunc("/api/push/test", auth.requireUser(scheduler.handleTest))
mux.HandleFunc("/api/reminders", auth.requireUser(scheduler.handleReminders))
mux.HandleFunc("/api/push/key", auth.requireOwner(scheduler.handleKey))
mux.HandleFunc("/api/push/subscribe", auth.requireOwner(scheduler.handleSubscribe))
mux.HandleFunc("/api/push/unsubscribe", auth.requireOwner(scheduler.handleUnsubscribe))
mux.HandleFunc("/api/push/test", auth.requireOwner(scheduler.handleTest))
mux.HandleFunc("/api/reminders", auth.requireOwner(scheduler.handleReminders))
}
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
+1829 -231
View File
File diff suppressed because it is too large Load Diff
+31 -1
View File
@@ -1,6 +1,36 @@
[
{ "date": "2026-09-22", "text": "Tap a bar in the Food (grams) chart and a line under it spells that day out — “Sat, Sep 20 — 340 g”, or “Dry 260 g · Fresh 100 g · 360 g in total” once you are using kinds. A phone has nothing to hover over, so the amount for a given day was previously only readable by eye off the axis. It follows whichever day is highlighted, so the ← → arrows and the date picker move it too, and it says so plainly when a day has no food logged or is marked as not counted" },
{ "date": "2026-09-22", "text": "Meals can be labelled with a kind of food. Make up your own in Settings → Food kinds — dry, fresh, raw, whatever you feed — and pick one when you log a meal; tap the ★ beside one to have it chosen for you automatically. You can also invent a kind from inside the log dialog if you realise you need it mid-meal. All of it is optional: “No kind” is always offered, every meal you have already logged keeps working untouched, and with no kinds defined the app looks and behaves exactly as it did. Once you are using them, today's overview shows the day's food broken down — “Dry 260 g · Fresh 100 g” — under the stat tiles, and the Food (grams) chart splits each day's bar by kind with a legend, and draws a separate trend line for each, so you can see fresh creeping up while dry comes down. The sentence underneath names only the kinds that are actually moving and folds the rest into one clause, so it stays short however many kinds you have. Renaming a kind updates the meals logged as it; deleting one keeps them readable under the name it had. A guest can label a meal with a kind you have created but cannot add, rename or delete them" },
{ "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" },
{ "date": "2026-09-07", "text": "Guest links can be copied whenever you want. Settings → Guest access now shows each live link's URL next to it with a Copy button, instead of showing it once when you created it and never again. If you lose the message you sent, or want to pass the same link to someone else, you can just take it again rather than making a new one and leaving whoever already had the old one locked out. Links you made before this change can't be shown — only a scrambled form of those was kept — so revoke one and create a fresh one if you need its URL back" },
{ "date": "2026-09-07", "text": "Fixed buttons that were meant to be hidden but showed anyway. A guest opening an entry the owner logged saw Delete and Save on it — they never worked (the server refuses the change) but they had no business being there. The same fault had been quietly affecting three other things for a while: the 🌳 pedigree button appeared before you had set a pedigree ID, “Send a test notification” appeared when reminders weren't available, and the exercise dialog offered Delete while you were adding a new exercise rather than editing one. One styling rule was overriding every one of them" },
{ "date": "2026-09-07", "text": "A day can now be left out of the stats. Open the day, tap “⊘ Not counted” next to the overview heading, and it stops feeding the charts and averages — useful when someone else had the puppy and the record is thinner than the day really was, so it isn't fair to count it. Nothing is deleted or hidden: the day's own overview, history and sleep & wake list are exactly as they were, just dimmed and labelled, and you can switch it back at any time. In the day-by-day charts the day keeps its place but is drawn as a hatch instead of a bar, so a deliberate gap can't be misread as a day the puppy barely slept. Weigh-ins and notes still count wherever they fall — those are facts you recorded, not behaviour a sparse day distorts — so the weight curve and the Notes log are untouched. The Timing panel throws away gaps that reach across a skipped day rather than measuring them, which would otherwise turn two normal days into one enormous fake gap" },
{ "date": "2026-09-06", "text": "You can hand someone temporary access without giving them your login. Settings → “Guest access” creates a link — say who it's for and pick the last day it should work — and whoever opens it lands straight in the app on your dog, able to log events and read all the history and charts. They can't change your entries: a guest may fix up or delete what they logged themselves, but everything you logged is read-only to them, and so is the puppy profile, the pedigree ID, your reminders, the exercise list, other guest links and deleting the account. You can still edit anything on your own account, theirs included. The link is shown once when you make it, so copy it then; every live link is listed in Settings with when it expires and when it was last used, and Revoke cuts access off immediately, mid-session. Anything logged on a link is tagged with that link's name in the History log — “💧 Pee · Anna” — and the tag sticks even if you edit the entry afterwards" },
{ "date": "2026-09-04", "text": "The three day-long charts — “By hour of day”, “When sleeping” and “When walking” — now mark the current time with a small vertical line and caret. On the sleeping and walking rows it also shows where today's row stops, and it lines the same clock position up across every day above it" },
{ "date": "2026-09-01", "text": "Removed the walking goal from the Walk trend — the dashed target line, its legend chip and the ✓ that marked a day as met. It came from the “five-minute rule” (five minutes per month of age, twice a day), which is a widely repeated rule of thumb rather than veterinary guidance, and the app was stating it more confidently than it deserved. The chart is now just a record of what you walked, against yesterday and the average" },
{ "date": "2026-09-01", "text": "The 7d / 14d / 30d buttons have moved out of the Sleep panel onto their own “Charts cover” row, just under the Log event buttons. They always set the window for every chart on the page — training, timing, sleep, walks, pees/poos/meals — but sitting inside the Sleep panel made them look like a sleep setting" },
{ "date": "2026-09-01", "text": "Dropped the “Darker = more sessions that day” caption under the training consistency grid. Tapping a cell still opens that day" },
{ "date": "2026-09-01", "text": "Dropped two hint lines: the “Based on N pee gaps…” sentence under the Timing charts, and the “Rule of thumb at this age…” one in the Walks panel. The charts above them already say it. The age-based walking goal is still there, drawn as the goal line on the Walk trend" },
{ "date": "2026-09-01", "text": "On the dark theme the awake timers are a warm near-white now — both “Awake for” at the top and the pill in the day bar — instead of yellow text on a yellow-tinted pill. The light theme keeps its dark gold, where white would disappear into a near-white pill" },
{ "date": "2026-09-01", "text": "Toned the awake timer down: the pill in the day bar is a fainter yellow and its text a good deal darker, so “Awake for” is comfortable to read rather than technically legible. The Awake labels elsewhere darkened with it" },
{ "date": "2026-08-31", "text": "The asleep timer text is a deeper blue, so “Asleep for” in the day-bar pill and on the big card is properly readable against its own pale blue background instead of the near-invisible blue-on-blue it was — the same treatment the awake gold just got, and it applies to the Asleep labels in the Sleep & wake list too" },
{ "date": "2026-08-31", "text": "The awake timer is sunshine too now — both the big “Awake for” card and the pill in the day bar — so the whole app tells asleep and awake apart by night-blue against day-gold rather than by blue against purple" },
{ "date": "2026-08-31", "text": "Awake stretches in the Sleep & wake list are sunshine yellow now instead of the app's purple — the same idea as the ☀️ on the timer pill — so asleep and awake read as night and day down the list rather than as two shades of the same accent" },
{ "date": "2026-08-31", "text": "The Walks list reads newest first as well, so a walk in progress is the top row and every list in the app now runs the same way" },
{ "date": "2026-08-31", "text": "The Walks panel and its two charts have moved down the page, below “Pees, poos & meals”" },
{ "date": "2026-08-31", "text": "Sleep windows and Wake windows are one “Sleep & wake” panel now, with the two interleaved into a single list in time order — asleep 22:1006:05, awake until 08:30, asleep 45 min, and so on down the day. A wake window is just the gap between two sleeps, so the two lists were always halves of the same sequence, and reading them in order makes “only a 45-minute nap after two and a half hours up” obvious in a way two separate lists never did. Each row says which it is and carries a stripe in its colour; whichever one is still running keeps the highlight" },
{ "date": "2026-08-31", "text": "Sleep windows and Wake windows are one “Sleep & wake” panel now, with the two interleaved into a single list, newest first — awake since 12:30, asleep 11:4012:30, awake since 09:15, and so on back through the day. A wake window is just the gap between two sleeps, so the two lists were always halves of the same sequence, and reading them together makes “only a 45-minute nap after two and a half hours up” obvious in a way two separate lists never did. Whatever is happening right now is the first row, as in the history and notes logs. Each row says which it is and carries a stripe in its colour; whichever one is still running keeps the highlight" },
{ "date": "2026-08-31", "text": "The list of weigh-ins under the Weight chart folds away on its own now — tap “History” inside the panel to collapse just the rows and keep the latest figure and the curve on screen. It remembers the choice like the panels do, and folding the whole Weight panel still takes everything with it" },
{ "date": "2026-08-31", "text": "Walks now get the same two pattern views sleep has. “When … walks” is a day-per-row grid shaded where a walk was on, so you can see at a glance whether the routine is actually regular or drifts around. “Walk trend” draws the minutes walked so far at each point of the day against yesterday and the average over the picked window, with the age-based goal as a line and a ✓ once the day clears it — the line climbs only while a walk is on, so every step is one walk. Both appear under the Walks panel as soon as you have logged a walk, and stay out of the way until then" },
{ "date": "2026-08-31", "text": "The 7d / 14d / 30d buttons now set the window for the Timing panel too. Until now the typical, shortest and longest gaps between pees, poos and meals were always measured over the last 7 days whatever you picked; switch to 30d and they are measured over 30, which settles down the typical gap once there is a month of history to draw on" },
+451 -257
View File
@@ -21,6 +21,18 @@
</script>
</head>
<body>
<!-- Shared SVG defs. Inline SVGs in one document share an id space, so the
hatch every chart uses for a "not counted" day is defined once here
rather than repeated into each chart's markup. Colour comes from CSS,
so it follows the theme. -->
<svg width="0" height="0" aria-hidden="true" focusable="false" style="position:absolute">
<defs>
<pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<line class="hatch-line" x1="0" y1="0" x2="0" y2="6" />
</pattern>
</defs>
</svg>
<!-- Shown when a newer build's service worker is waiting. "Reload" activates
it and refreshes onto the new assets; "Later" dismisses until next time.
Suppressed on the very first install (see app.js). -->
@@ -40,7 +52,7 @@
<!-- Login / register gate. Shown until the session check succeeds; the app
(#app) stays hidden behind it so no puppy data paints while logged out. -->
<div id="auth-screen" class="auth-screen" hidden>
<div class="auth-card">
<div class="auth-card" id="auth-card">
<h1>🐶 Puppy Tracker</h1>
<p class="auth-sub" id="auth-sub">Sign in to continue</p>
<form id="auth-form">
@@ -61,6 +73,18 @@
<button type="button" id="auth-toggle-btn" class="linklike">Create one</button>
</p>
</div>
<!-- Shown instead of the form when a guest link has run out or been
revoked. A guest has no password to sign in with, so offering them
the form would only be confusing. -->
<div class="auth-card" id="guest-ended" hidden>
<h1>🐶 Puppy Tracker</h1>
<p class="auth-sub">This guest link has ended</p>
<p class="muted-note">
It either expired or was turned off by the owner. Ask them for a new
link to keep logging.
</p>
</div>
</div>
<div id="app" hidden>
@@ -77,33 +101,69 @@
</div>
</header>
<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" />
</span>
<button type="button" id="day-next" class="ghost" aria-label="Next day"></button>
<button type="button" id="day-today" class="ghost">Today</button>
</section>
<!-- Only ever shown to a guest, so it is obvious whose dog this is, under
which name their entries will appear, and when the link runs out. -->
<p id="guest-banner" class="guest-banner" hidden></p>
<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>
<main>
<section class="day-bar">
<!-- 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>
<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">
@@ -134,234 +194,280 @@
</div>
</section>
<section class="training" data-panel="training">
<h2>Training</h2>
<ul id="training-list" class="training-list"></ul>
<p id="training-empty" class="empty">No exercises yet. Add one to start tracking training.</p>
<button type="button" id="exercise-add" class="ghost training-add">Add exercise</button>
<div class="chart training-chart" id="training-chart-wrap" hidden>
<div class="chart-title">Consistency <span data-chart-days-label>(last 7 days)</span></div>
<svg id="chart-training" class="chart-svg" viewBox="0 0 320 60" role="img" aria-label="Training sessions per exercise per day"></svg>
<p class="muted-note">Darker = more sessions that day. Tap a cell to open that day.</p>
</div>
</section>
<!-- Five tabs so each screen holds one subject rather than all
fourteen panels in one column. The day bar and the log buttons stay
above them: logging has to be one tap from wherever you are. -->
<nav class="tabs" role="tablist" aria-label="Sections">
<button type="button" class="tab" role="tab" data-tab="today" id="tab-today" aria-controls="tabpanel-today" aria-selected="false" tabindex="-1">Today</button>
<button type="button" class="tab" role="tab" data-tab="sleep" id="tab-sleep" aria-controls="tabpanel-sleep" aria-selected="false" tabindex="-1">Sleep</button>
<button type="button" class="tab" role="tab" data-tab="walks" id="tab-walks" aria-controls="tabpanel-walks" aria-selected="false" tabindex="-1">Walks</button>
<button type="button" class="tab" role="tab" data-tab="habits" id="tab-habits" aria-controls="tabpanel-habits" aria-selected="false" tabindex="-1">Habits</button>
<button type="button" class="tab" role="tab" data-tab="growth" id="tab-growth" aria-controls="tabpanel-growth" aria-selected="false" tabindex="-1">Growth</button>
</nav>
<section class="overview" data-panel="overview">
<h2 id="overview-title">Today's overview</h2>
<div class="stats">
<div class="stat">
<div class="stat-label">Sleep</div>
<div class="stat-value" id="stat-sleep">0h 0m</div>
</div>
<div class="stat">
<div class="stat-label">Awake</div>
<div class="stat-value" id="stat-awake">0h 0m</div>
</div>
<div class="stat">
<div class="stat-label">Walks</div>
<div class="stat-value" id="stat-walk">0m</div>
<div class="stat-sub" id="stat-walk-count" hidden></div>
</div>
<div class="stat">
<div class="stat-label">Meals</div>
<div class="stat-value" id="stat-meals">0</div>
<div class="stat-sub" id="stat-meals-grams" hidden></div>
</div>
<div class="stat">
<div class="stat-label">Pees</div>
<div class="stat-value" id="stat-pees">0</div>
</div>
<div class="stat">
<div class="stat-label">Poos</div>
<div class="stat-value" id="stat-poos">0</div>
</div>
<div class="stat">
<div class="stat-label">Training</div>
<div class="stat-value" id="stat-training">0</div>
</div>
</div>
<div class="lasts">
<div class="last-row"><span>Last pee</span><span id="last-pee"></span></div>
<div class="last-row"><span>Last poo</span><span id="last-poo"></span></div>
<div class="last-row"><span>Last meal</span><span id="last-eat"></span></div>
<div class="last-row"><span>Last sleep</span><span id="last-sleep"></span></div>
<div class="last-row"><span>Last walk</span><span id="last-walk"></span></div>
</div>
</section>
<section class="timing" data-panel="timing">
<h2>Timing <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<!-- One range chart per type, drawn by drawTimingChart: the band spans
the shortest to the typical gap and the marker is how long it has
been since the last one, so a marker past the band reads as due. -->
<div class="timing-charts">
<div class="timing-item">
<div class="timing-name">Pees</div>
<svg id="timing-chart-pee" class="timing-chart" viewBox="0 0 320 50" role="img"></svg>
<p class="muted-note timing-note" id="timing-note-pee" hidden></p>
</div>
<div class="timing-item">
<div class="timing-name">Poos</div>
<svg id="timing-chart-poo" class="timing-chart" viewBox="0 0 320 50" role="img"></svg>
<p class="muted-note timing-note" id="timing-note-poo" hidden></p>
</div>
<div class="timing-item">
<div class="timing-name">Meals</div>
<svg id="timing-chart-eat" class="timing-chart" viewBox="0 0 320 50" role="img"></svg>
<p class="muted-note timing-note" id="timing-note-eat" hidden></p>
</div>
</div>
<!-- The axis is stretched (see drawTimingChart), so say what the middle
of a bar means rather than leave it to be inferred. -->
<p class="muted-note">The middle of every bar is that type's typical gap: left of it is sooner than usual, right of it is longer, and the faded stretch runs out to the longest gap in the window.</p>
<p class="muted-note timing-hint" id="timing-hint"></p>
</section>
<!-- Sleep and wake windows are the same boundaries read two ways — a wake
window is exactly the gap between two sleeps — so they interleave into
one alternating list rather than sitting in two panels that each show
half the day. Every row carries its state; the open one keeps the
highlight. -->
<section class="sleepwake" data-panel="sleep-wake">
<h2>Sleep &amp; wake</h2>
<ul id="sleep-wake-list" class="wake-list"></ul>
<p id="sleep-wake-empty" class="empty">No sleep or wake windows yet for this day.</p>
</section>
<section class="patterns" data-panel="sleep-daily">
<h2>Sleep <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<!-- Page-level, not a panel's: every panel below that covers more than
one day reads this, so it sits on its own row above them all rather
than inside one of them, where it read as that panel's own control
(see renderChartWindow). -->
<div class="chart-window">
<span class="chart-window-label">Charts cover</span>
<div class="chart-days-picker" role="group" aria-label="How many days the charts cover">
<button type="button" class="ghost" data-days="7">7d</button>
<button type="button" class="ghost" data-days="14">14d</button>
<button type="button" class="ghost" data-days="30">30d</button>
</div>
<div class="chart">
<div class="chart-title">Hours per day</div>
<svg id="chart-sleep" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Sleep hours per day"></svg>
</div>
</section>
</div>
<section class="patterns" data-panel="sleep-timeline">
<h2><span id="sleep-timeline-title">When sleeping</span> <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<svg id="chart-sleep-timeline" class="chart-svg" viewBox="0 0 320 125" role="img" aria-label="Sleep periods per day"></svg>
<p class="muted-note">Each row is a day, midnight to midnight; shaded = asleep. Tap a row to open that day.</p>
</section>
<section class="patterns" data-panel="sleep-trend">
<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-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>
<span class="lg trend-goal" id="legend-trend-goal" hidden><span class="sw"></span><span id="legend-trend-goal-text">Goal</span></span>
</div>
<p class="muted-note">Hours slept so far at each point of the day, against yesterday and the average over the picked chart window. The dashed tail continues today's line the way the average day usually plays out. The axis is stretched above 10h to give the hours around the goal more room.</p>
</section>
<!-- One panel for the three views of the same events: how many a day,
how much food went with them, and what hours they fall in. -->
<section class="patterns" data-panel="counts">
<h2>Pees, poos &amp; meals <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<div class="chart">
<div class="chart-title">Daily counts</div>
<svg id="chart-counts" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Pee, poo and meal counts per day"></svg>
<div class="legend legend-toggle" id="counts-metrics" role="group" aria-label="Which counts to show">
<label class="lg pee"><input type="checkbox" data-metric="pees" checked /><span class="sw"></span>Pees</label>
<label class="lg poo"><input type="checkbox" data-metric="poos" checked /><span class="sw"></span>Poos</label>
<label class="lg eat"><input type="checkbox" data-metric="meals" checked /><span class="sw"></span>Meals</label>
<div class="tab-panel" data-tab="today" id="tabpanel-today" role="tabpanel" aria-labelledby="tab-today" hidden>
<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,
while this panel is the selected day's summary and has the room. -->
<div class="overview-head">
<h2 id="overview-title">Today's overview</h2>
<button type="button" id="exclude-day" class="ghost exclude-btn"
aria-pressed="false"
title="Leave this day out of the charts and averages">⊘ Not counted</button>
</div>
</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>
</div>
<div class="chart">
<div class="chart-title">By hour of day</div>
<svg id="chart-hour-heatmap" class="chart-svg" viewBox="0 0 320 120" role="img" aria-label="Pee, poo and meal frequency by hour of day"></svg>
<p id="hour-heatmap-info" class="muted-note hour-point-info" aria-live="polite"></p>
<p class="muted-note">Darker = happens more often at that hour.</p>
</div>
</section>
<section class="walks" data-panel="walks">
<h2>Walks <span class="muted-note" id="walk-total"></span></h2>
<ul id="walk-list" class="wake-list"></ul>
<p id="walk-empty" class="empty">No walks yet for this day. Use 🦮 Walk start / 🏁 Walk end to time one.</p>
<!-- Age-based guidance ("the five-minute rule"), only when a birthday
is set and the puppy is still growing. -->
<p id="walk-goal" class="muted-note" hidden></p>
<div class="chart walk-chart" id="walk-chart-wrap" hidden>
<div class="chart-title">Minutes per day <span data-chart-days-label>(last 7 days)</span></div>
<svg id="chart-walk" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Minutes walked per day"></svg>
</div>
</section>
<!-- The two walk patterns mirror the sleep ones below, drawn from walk
windows instead of sleep windows. Both stay hidden until there is a
walk to draw, so they cost nothing to anyone not tracking walks. -->
<section class="patterns" data-panel="walk-timeline" hidden>
<h2><span id="walk-timeline-title">When walking</span> <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<svg id="chart-walk-timeline" class="chart-svg" viewBox="0 0 320 125" role="img" aria-label="Walks per day"></svg>
<p class="muted-note">Each row is a day, midnight to midnight; shaded = out on a walk. Tap a row to open that day.</p>
</section>
<section class="patterns" data-panel="walk-trend" hidden>
<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, the recent average, and the age-based daily goal"></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-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>
<span class="lg wtrend-goal" id="legend-wtrend-goal" hidden><span class="sw"></span><span id="legend-wtrend-goal-text">Goal</span></span>
</div>
<p class="muted-note">Minutes walked so far at each point of the day, against yesterday and the average over the picked window. The line climbs only while a walk is on, so every step is one walk.</p>
</section>
<!-- The day-window picker lives in this panel but governs every
day-window chart on the page — the training grid above, both sleep
patterns below, the counts panel and the walk chart — so changing it
here changes all of them (see renderChartWindow). -->
<section class="weight" data-panel="weight">
<h2>Weight</h2>
<div class="weight-summary">
<div class="stat">
<div class="stat-label">Latest</div>
<div class="stat-value" id="weight-latest"></div>
<p id="excluded-note" class="muted-note excluded-note" hidden>
Not counted in the charts and averages. Everything below is unchanged.
</p>
<div class="stats">
<div class="stat">
<div class="stat-label">Sleep</div>
<div class="stat-value" id="stat-sleep">0h 0m</div>
</div>
<div class="stat">
<div class="stat-label">Awake</div>
<div class="stat-value" id="stat-awake">0h 0m</div>
</div>
<div class="stat">
<div class="stat-label">Walks</div>
<div class="stat-value" id="stat-walk">0m</div>
<div class="stat-sub" id="stat-walk-count" hidden></div>
</div>
<div class="stat">
<div class="stat-label">Meals</div>
<div class="stat-value" id="stat-meals">0</div>
<div class="stat-sub" id="stat-meals-grams" hidden></div>
</div>
<div class="stat">
<div class="stat-label">Pees</div>
<div class="stat-value" id="stat-pees">0</div>
</div>
<div class="stat">
<div class="stat-label">Poos</div>
<div class="stat-value" id="stat-poos">0</div>
</div>
<div class="stat">
<div class="stat-label">Training</div>
<div class="stat-value" id="stat-training">0</div>
</div>
</div>
<div class="stat">
<div class="stat-label">Since last</div>
<div class="stat-value" id="weight-change"></div>
<!-- The day's food broken down by kind. Its own line rather than
inside the Meals tile: that tile is about 90px wide, and a
breakdown of two or three kinds will not sit in it. Hidden unless
a meal that day actually carries a kind. -->
<p id="stat-food-kinds" class="muted-note food-kind-split" hidden></p>
<div class="lasts">
<div class="last-row"><span>Last pee</span><span id="last-pee"></span></div>
<div class="last-row"><span>Last poo</span><span id="last-poo"></span></div>
<div class="last-row"><span>Last meal</span><span id="last-eat"></span></div>
<div class="last-row"><span>Last sleep</span><span id="last-sleep"></span></div>
<div class="last-row"><span>Last walk</span><span id="last-walk"></span></div>
</div>
</div>
<div class="chart">
<div class="chart-title">Weight (kg)</div>
<svg id="chart-weight" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Weight in kilograms over time"></svg>
<p id="weight-point-info" class="muted-note weight-point-info"></p>
</div>
<!-- Folds on its own h3, independently of the Weight panel around it:
the row list grows by one every weigh-in, and the summary and the
curve above it are what you usually want left on screen. -->
<div class="subpanel" data-panel="weight-history">
<h3>History</h3>
<ul id="weight-list" class="wake-list"></ul>
<p id="weight-empty" class="empty">No weigh-ins logged yet.</p>
</div>
</section>
</section>
<section class="notes-log" data-panel="notes">
<h2>Notes</h2>
<ul id="notes-list" class="event-list"></ul>
<p id="notes-empty" class="empty">No notes yet. Use the 📝 Note button to jot down things like vaccinations or vet visits — they'll be listed here across every day.</p>
</section>
<!-- Sleep and wake windows are the same boundaries read two ways — a wake
window is exactly the gap between two sleeps — so they interleave into
one alternating list rather than sitting in two panels that each show
half the day. Every row carries its state; the open one keeps the
highlight. -->
<section class="sleepwake" data-panel="sleep-wake">
<h2>Sleep &amp; wake</h2>
<ul id="sleep-wake-list" class="wake-list"></ul>
<p id="sleep-wake-empty" class="empty">No sleep or wake windows yet for this day.</p>
</section>
<section class="history" data-panel="history">
<h2>History</h2>
<ul id="event-list" class="event-list"></ul>
<p id="empty-state" class="empty">No events logged for this day.</p>
</section>
<section class="history" data-panel="history">
<h2>History</h2>
<ul id="event-list" class="event-list"></ul>
<p id="empty-state" class="empty">No events logged for this day.</p>
</section>
</div>
<div class="tab-panel" data-tab="sleep" id="tabpanel-sleep" role="tabpanel" aria-labelledby="tab-sleep" hidden>
<section class="patterns" data-panel="sleep-daily">
<h2>Sleep <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<div class="chart">
<div class="chart-title">Hours per day</div>
<svg id="chart-sleep" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Sleep hours per day"></svg>
</div>
</section>
<section class="patterns" data-panel="sleep-timeline">
<h2><span id="sleep-timeline-title">When sleeping</span> <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<svg id="chart-sleep-timeline" class="chart-svg" viewBox="0 0 320 125" role="img" aria-label="Sleep periods per day"></svg>
<p class="muted-note">Each row is a day, midnight to midnight; shaded = asleep. The marker is now. Tap a row to open that day.</p>
</section>
<section class="patterns" data-panel="sleep-trend">
<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" 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>
<span class="lg trend-goal" id="legend-trend-goal" hidden><span class="sw"></span><span id="legend-trend-goal-text">Goal</span></span>
</div>
<p class="muted-note">Hours slept so far at each point of the day, against yesterday and the average over the picked chart window. The dashed tail continues today's line the way the average day usually plays out. The axis is stretched above 10h to give the hours around the goal more room.</p>
</section>
</div>
<div class="tab-panel" data-tab="walks" id="tabpanel-walks" role="tabpanel" aria-labelledby="tab-walks" hidden>
<section class="walks" data-panel="walks">
<h2>Walks <span class="muted-note" id="walk-total"></span></h2>
<ul id="walk-list" class="wake-list"></ul>
<p id="walk-empty" class="empty">No walks yet for this day. Use 🦮 Walk start / 🏁 Walk end to time one.</p>
<div class="chart walk-chart" id="walk-chart-wrap" hidden>
<div class="chart-title">Minutes per day <span data-chart-days-label>(last 7 days)</span></div>
<svg id="chart-walk" class="chart-svg" viewBox="0 0 320 160" role="img" aria-label="Minutes walked per day"></svg>
</div>
</section>
<!-- The two walk patterns mirror the sleep ones on the Sleep tab, drawn
from walk windows instead of sleep windows. Both stay hidden until
there is a walk to draw, so they cost nothing to anyone not
tracking walks. -->
<section class="patterns" data-panel="walk-timeline" hidden>
<h2><span id="walk-timeline-title">When walking</span> <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<svg id="chart-walk-timeline" class="chart-svg" viewBox="0 0 320 125" role="img" aria-label="Walks per day"></svg>
<p class="muted-note">Each row is a day, midnight to midnight; shaded = out on a walk. The marker is now. Tap a row to open that day.</p>
</section>
<section class="patterns" data-panel="walk-trend" hidden>
<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" 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>
<p class="muted-note">Minutes walked so far at each point of the day, against yesterday and the average over the picked window. The line climbs only while a walk is on, so every step is one walk.</p>
</section>
</div>
<div class="tab-panel" data-tab="habits" id="tabpanel-habits" role="tabpanel" aria-labelledby="tab-habits" hidden>
<section class="timing" data-panel="timing">
<h2>Timing <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<!-- One range chart per type, drawn by drawTimingChart: the band spans
the shortest to the typical gap and the marker is how long it has
been since the last one, so a marker past the band reads as due. -->
<div class="timing-charts">
<div class="timing-item">
<div class="timing-name">Pees</div>
<svg id="timing-chart-pee" class="timing-chart" viewBox="0 0 320 50" role="img"></svg>
<p class="muted-note timing-note" id="timing-note-pee" hidden></p>
</div>
<div class="timing-item">
<div class="timing-name">Poos</div>
<svg id="timing-chart-poo" class="timing-chart" viewBox="0 0 320 50" role="img"></svg>
<p class="muted-note timing-note" id="timing-note-poo" hidden></p>
</div>
<div class="timing-item">
<div class="timing-name">Meals</div>
<svg id="timing-chart-eat" class="timing-chart" viewBox="0 0 320 50" role="img"></svg>
<p class="muted-note timing-note" id="timing-note-eat" hidden></p>
</div>
</div>
<!-- The axis is stretched (see drawTimingChart), so say what the middle
of a bar means rather than leave it to be inferred. -->
<p class="muted-note">The middle of every bar is that type's typical gap: left of it is sooner than usual, right of it is longer, and the faded stretch runs out to the longest gap in the window.</p>
</section>
<!-- One panel for the three views of the same events: how many a day,
how much food went with them, and what hours they fall in. -->
<section class="patterns" data-panel="counts">
<h2>Pees, poos &amp; meals <span class="muted-note" data-chart-days-label>(last 7 days)</span></h2>
<div class="chart">
<div class="chart-title">Daily counts</div>
<svg id="chart-counts" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Pee, poo and meal counts per day"></svg>
<div class="legend legend-toggle" id="counts-metrics" role="group" aria-label="Which counts to show">
<label class="lg pee"><input type="checkbox" data-metric="pees" checked /><span class="sw"></span>Pees</label>
<label class="lg poo"><input type="checkbox" data-metric="poos" checked /><span class="sw"></span>Poos</label>
<label class="lg eat"><input type="checkbox" data-metric="meals" checked /><span class="sw"></span>Meals</label>
</div>
</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, split by kind, with a trend line through each"></svg>
<div id="grams-legend" class="legend" hidden></div>
<!-- The highlighted day, broken down. Tapping a bar selects that
day (as it does on every chart), so this is what the selection
amounts to here — there is no hover on a phone, and the bar's
tooltip is unreachable. -->
<p id="grams-day-info" class="muted-note food-day-info" aria-live="polite" hidden></p>
<!-- 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>
<svg id="chart-hour-heatmap" class="chart-svg" viewBox="0 0 320 120" role="img" aria-label="Pee, poo and meal frequency by hour of day"></svg>
<p id="hour-heatmap-info" class="muted-note hour-point-info" aria-live="polite"></p>
<p class="muted-note">Darker = happens more often at that hour; the marker is now.</p>
</div>
</section>
</div>
<div class="tab-panel" data-tab="growth" id="tabpanel-growth" role="tabpanel" aria-labelledby="tab-growth" hidden>
<section class="weight" data-panel="weight">
<h2>Weight</h2>
<div class="weight-summary">
<div class="stat">
<div class="stat-label">Latest</div>
<div class="stat-value" id="weight-latest"></div>
</div>
<div class="stat">
<div class="stat-label">Since last</div>
<div class="stat-value" id="weight-change"></div>
</div>
</div>
<div class="chart">
<div class="chart-title">Weight (kg)</div>
<svg id="chart-weight" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Weight in kilograms over time"></svg>
<p id="weight-point-info" class="muted-note weight-point-info"></p>
</div>
<!-- Folds on its own h3, independently of the Weight panel around it:
the row list grows by one every weigh-in, and the summary and the
curve above it are what you usually want left on screen. -->
<div class="subpanel" data-panel="weight-history">
<h3>History</h3>
<ul id="weight-list" class="wake-list"></ul>
<p id="weight-empty" class="empty">No weigh-ins logged yet.</p>
</div>
</section>
<section class="training" data-panel="training">
<h2>Training</h2>
<ul id="training-list" class="training-list"></ul>
<p id="training-empty" class="empty">No exercises yet. Add one to start tracking training.</p>
<button type="button" id="exercise-add" class="ghost training-add">Add exercise</button>
<div class="chart training-chart" id="training-chart-wrap" hidden>
<div class="chart-title">Consistency <span data-chart-days-label>(last 7 days)</span></div>
<svg id="chart-training" class="chart-svg" viewBox="0 0 320 60" role="img" aria-label="Training sessions per exercise per day"></svg>
</div>
</section>
<section class="notes-log" data-panel="notes">
<h2>Notes</h2>
<ul id="notes-list" class="event-list"></ul>
<p id="notes-empty" class="empty">No notes yet. Use the 📝 Note button to jot down things like vaccinations or vet visits — they'll be listed here across every day.</p>
</section>
</div>
</main>
<footer class="app-footer">
@@ -416,17 +522,22 @@
<dialog id="settings-dialog">
<form method="dialog" id="settings-form">
<h3>Puppy settings</h3>
<label>Name
<input type="text" id="settings-name" placeholder="e.g. Rex" autocomplete="off" />
</label>
<label>Birthday
<input type="date" id="settings-birthday" />
</label>
<label>Pedigree ID
<input type="text" id="settings-pedigree" autocomplete="off" spellcheck="false"
placeholder="SKK chip or reg. number (optional)" />
</label>
<p class="settings-hint">Set your dog's SKK chip or registration number to unlock the 🌳 pedigree page.</p>
<!-- The profile is the owner's to set, so this block is hidden for a
guest; the two toggles below it are device-local preferences and
stay for everyone. -->
<div id="settings-profile">
<label>Name
<input type="text" id="settings-name" placeholder="e.g. Rex" autocomplete="off" />
</label>
<label>Birthday
<input type="date" id="settings-birthday" />
</label>
<label>Pedigree ID
<input type="text" id="settings-pedigree" autocomplete="off" spellcheck="false"
placeholder="SKK chip or reg. number (optional)" />
</label>
<p class="settings-hint">Set your dog's SKK chip or registration number to unlock the 🌳 pedigree page.</p>
</div>
<label class="toggle-row">
<span>Dark mode</span>
<input type="checkbox" id="settings-theme" role="switch" class="switch" />
@@ -446,12 +557,59 @@
<div id="reminders-rules" hidden></div>
<button type="button" id="reminders-test" class="ghost" hidden>Send a test notification</button>
</div>
<!-- The food kinds a meal can be labelled with. Owner-only, like the
exercise library. Empty by default and entirely optional: an
account with no kinds never sees a picker when logging. -->
<div id="food-kinds-section">
<hr class="settings-sep" />
<h4 class="settings-subhead">Food kinds</h4>
<p class="settings-hint">
Label a meal with the sort of food it was — dry, fresh, whatever you
feed. Optional: with none defined, nothing changes, and “No kind”
stays available even once you have some.
</p>
<ul id="food-kind-list" class="food-kind-list"></ul>
<p id="food-kind-empty" class="settings-hint">No kinds yet.</p>
<div class="kind-new">
<input type="text" id="food-kind-name" maxlength="30" autocomplete="off" placeholder="e.g. Dry" />
<button type="button" id="food-kind-add" class="ghost">Add</button>
</div>
</div>
<!-- Guest links: hand a dog sitter a URL that logs events on this
account without giving them the password. Owner-only. -->
<div id="guest-access">
<hr class="settings-sep" />
<h4 class="settings-subhead">Guest access</h4>
<p class="settings-hint">
A link that lets someone log events on this account — no password,
no account of their own. It stops working on its own, and you can
turn it off at any time.
</p>
<label>Who is it for?
<input type="text" id="guest-label" autocomplete="off" maxlength="40"
placeholder="e.g. Anna (sitter)" />
</label>
<label>Works until
<input type="date" id="guest-expires" />
</label>
<p class="settings-hint" id="guest-expires-hint"></p>
<button type="button" id="guest-create" class="ghost">Create link</button>
<p id="guest-error" class="auth-error" hidden></p>
<ul id="guest-list" class="guest-list"></ul>
<p id="guest-empty" class="settings-hint">No active links.</p>
</div>
<menu>
<button value="cancel" class="ghost">Cancel</button>
<button value="save" id="settings-save">Save</button>
</menu>
<hr class="settings-sep" />
<button type="button" id="delete-account-btn" class="danger danger-block">Delete account…</button>
<div id="settings-danger">
<hr class="settings-sep" />
<button type="button" id="delete-account-btn" class="danger danger-block">Delete account…</button>
</div>
</form>
</dialog>
@@ -509,6 +667,18 @@
<label id="note-grams-field" hidden>Amount (g)
<input type="number" id="note-grams" inputmode="numeric" step="1" min="0" placeholder="e.g. 80 — leave empty if unknown" />
</label>
<!-- Which sort of food. Only on a meal, and only once at least one kind
exists: someone who never defines one should never see it. "No
kind" is always an option and is where everyone starts. The chips
are built by renderKindPicker. -->
<div id="note-kind-field" class="kind-field" hidden>
<span class="kind-label">Kind</span>
<div id="note-kind-picker" class="kind-picker" role="radiogroup" aria-label="Kind of food"></div>
<div id="note-kind-new" class="kind-new" hidden>
<input type="text" id="note-kind-name" maxlength="30" autocomplete="off" placeholder="New kind, e.g. Fresh" />
<button type="button" id="note-kind-add" class="ghost">Add</button>
</div>
</div>
<label>Note
<textarea id="note-input" rows="4" placeholder="e.g. pee was instant, poo took 5min, ate 300g raw food"></textarea>
</label>
@@ -526,7 +696,14 @@
<dialog id="edit-dialog">
<form method="dialog" id="edit-form">
<h3>Edit event</h3>
<h3 id="edit-title">Edit event</h3>
<p id="edit-logged-by" class="settings-hint" hidden></p>
<!-- Shown to a guest looking at an entry that isn't theirs: the dialog
opens read-only rather than not opening at all, so the details are
still there to read. -->
<p id="edit-readonly" class="settings-hint" hidden>
This was logged on the owner's own account, so only they can change it.
</p>
<label>Time
<div class="time-row">
<input type="date" id="edit-date" />
@@ -539,6 +716,13 @@
<label id="edit-grams-field" hidden>Amount (g)
<input type="number" id="edit-grams" inputmode="numeric" step="1" min="0" />
</label>
<!-- The same picker, so a meal's kind can be corrected after the fact.
No "new kind" box here: inventing one belongs where you are
logging, not where you are fixing a typo. -->
<div id="edit-kind-field" class="kind-field" hidden>
<span class="kind-label">Kind</span>
<div id="edit-kind-picker" class="kind-picker" role="radiogroup" aria-label="Kind of food"></div>
</div>
<label>Note
<textarea id="edit-note" rows="4"></textarea>
</label>
@@ -561,6 +745,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>
+575 -68
View File
@@ -12,6 +12,20 @@
--weight: #2bb3a3;
--training: #b04ecf;
--walk: #3f9e63;
/* Asleep and awake each come in two values: the bright one for stripes,
tints and fills, and an -ink for text on those pale grounds. Both inks
flip with the theme; the brights don't. --sleep itself was doing both
jobs and failing the second 2.6:1 on the timer pill it painted. */
--sleep-ink: #4258cc;
--wake: #f2b705;
--wake-ink: #6f4d00;
/* The two timers, on the dark theme, take a near-white with a sixth of the
gold mixed into it (#fdf4da): warm enough to belong to the sunshine, far
enough from --wake-ink not to be yellow text on a yellow pill again. In
the light theme the same pill is near-white cream, so anything pale would
vanish and it keeps the dark ink. Separate from --wake-ink so the list's
AWAKE labels stay gold and go on matching their stripe. */
--wake-timer-ink: #6f4d00;
--note: #6f7a90;
--danger: #d64545;
--gain: #2e9e5b;
@@ -32,6 +46,9 @@
--muted: #9c9bab;
--accent: #a690ff;
--accent-soft: #2a2640;
--sleep-ink: #8ba4ff;
--wake-ink: #f5c451;
--wake-timer-ink: #fdf4da;
--border: #2c2a3a;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 16px rgba(0, 0, 0, 0.3);
}
@@ -43,12 +60,31 @@
--muted: #9c9bab;
--accent: #a690ff;
--accent-soft: #2a2640;
--sleep-ink: #8ba4ff;
--wake-ink: #f5c451;
--wake-timer-ink: #fdf4da;
--border: #2c2a3a;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 16px rgba(0, 0, 0, 0.3);
}
* { box-sizing: border-box; }
/* The user agent's [hidden] { display: none } loses to any author rule that
sets a display, so every element given one here went on rendering while
hidden. This had been patched one selector at a time as each case was
noticed .auth-screen[hidden], .snackbar[hidden], dialog label[hidden] and
seven more which left the ones nobody had hit yet still broken: the 🌳
pedigree button, the reminder rule rows, "send a test notification", the
pedigree disambiguation list, and Delete in both the exercise and event
dialogs. Stating it once replaces all ten of those rules and stops the next
hidden element joining the list.
!important, because the offenders aren't all the same weight:
`#reminders-rules { display: flex }` is an id selector and outranks any
attribute selector we could write. Nothing should ever want a hidden element
painted, so the blunt instrument is the right one here. */
[hidden] { display: none !important; }
html, body {
margin: 0;
padding: 0;
@@ -128,6 +164,83 @@ main {
padding: 16px 0 32px;
}
/* ---------- tabs ---------- */
/* Sticks directly under the day bar, whose measured height JS publishes as
--day-bar-h (its contents, and so its height, differ between phones). The
fallback keeps the bar usable for the frame before that lands. */
.tabs {
position: sticky;
/* One pixel under the day bar's height rather than exactly it: the inset is
free to be fractional while the measured height is a whole number, and a
sub-pixel shortfall would show as a hairline of page ground between the
two. The overlap is invisible the day bar paints on top, same colour. */
top: calc(env(safe-area-inset-top, 0px) + var(--day-bar-h, 52px) - 1px);
z-index: 55; /* under the day bar, over the panels */
display: flex;
gap: 4px;
padding: 6px 4px;
margin: -8px 0 0;
/* Same card as the day bar rather than the page ground, so the two read as
one thing once they are frozen together. */
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
/* At rest the two are separate cards with the log buttons between them. Scroll
the log buttons away and they meet, so the seam goes: the day bar squares its
bottom and gives up its shadow, the tab bar squares its top, and the pair
sits inside one rounded frame casting one shadow. JS adds .merged the moment
they touch CSS has no way to ask whether a sticky element is stuck. */
.day-bar.merged {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
box-shadow: none;
}
.tabs.merged {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.tabs .tab {
flex: 1;
min-width: 0;
padding: 8px 4px;
font-size: 0.85rem;
font-weight: 600;
background: transparent;
color: var(--muted);
border-radius: var(--radius);
/* Five labels across a 360px phone: let them shrink rather than wrap. */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tabs .tab.active {
background: var(--accent-soft);
color: var(--accent);
}
.tabs .tab:hover { filter: none; }
/* Five labels have to fit without truncating on the narrow phones, and the
budget is tight: at a 320px viewport (iPhone SE, smaller Androids) the body's
own 16px gutters, the bar's padding and four gaps leave about 45px of text
per tab, while "Growth" the widest label, roughly 3.3em wants 46px at the
default size. So the same breakpoint the day bar already uses trims the type
and the padding, which takes the widest label to about 40px against 43px of
room even on a 280px foldable cover screen. */
@media (max-width: 370px) {
.tabs { gap: 2px; padding-left: 2px; padding-right: 2px; }
.tabs .tab { font-size: 0.75rem; padding-left: 2px; padding-right: 2px; }
}
/* main's own gap sits between the pinned blocks and the tab area; each tab
then spaces its own panels the same way. */
.tab-panel {
display: flex;
flex-direction: column;
gap: 24px;
}
section {
background: var(--surface);
border-radius: var(--radius);
@@ -157,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
@@ -174,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;
@@ -196,56 +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;
}
.bar-clock[hidden] { display: none; }
/* 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); }
.bar-clock.awake { background: color-mix(in srgb, var(--accent) 18%, var(--surface)); color: var(--accent); }
.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); }
.big-clock.awake { background: linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--accent) 10%, var(--surface))); }
.big-clock.awake .bc-time { color: var(--accent); }
.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); }
/* 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
@@ -366,7 +548,6 @@ button.danger { background: var(--danger); }
font-size: 0.8rem;
font-weight: normal;
}
.timing-hint { margin: 10px 4px 0; line-height: 1.4; }
/* ---------- timing range charts ---------- */
.timing-charts {
@@ -474,24 +655,26 @@ textarea { resize: vertical; }
/* A stripe in the row's own colour, so an alternating list reads as alternating
before you get to the words. */
.ww.sleep-ww { border-left: 3px solid color-mix(in srgb, var(--sleep) 55%, transparent); }
.ww.wake-ww { border-left: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); }
.ww.wake-ww { border-left: 3px solid color-mix(in srgb, var(--wake) 75%, transparent); }
.ww.walk-ww { border-left: 3px solid color-mix(in srgb, var(--walk) 55%, transparent); }
.ww.sleep-ww .ww-tag { color: var(--sleep); }
.ww.wake-ww .ww-tag { color: var(--accent); }
.ww.sleep-ww .ww-tag { color: var(--sleep-ink); }
.ww.wake-ww .ww-tag { color: var(--wake-ink); }
.ww.walk-ww .ww-tag { color: var(--walk); }
.ww.ongoing .ww-tag { color: var(--accent); }
.ww.sleep-ww.ongoing {
border-color: var(--sleep);
background: color-mix(in srgb, var(--sleep) 14%, var(--surface));
}
.ww.sleep-ww.ongoing .ww-tag { color: var(--sleep); }
.ww.sleep-ww.ongoing .ww-tag { color: var(--sleep-ink); }
/* Spelled out like the other two: the faded stripe above would otherwise win
over the generic .ww.ongoing on specificity ties and leave the open awake
row without its full-strength edge. */
row without its full-strength edge. The tag needs saying too .ww.wake-ww
.ww-tag ties with .ww.ongoing .ww-tag and loses on order. */
.ww.wake-ww.ongoing {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 14%, var(--surface));
border-color: var(--wake);
background: color-mix(in srgb, var(--wake) 16%, var(--surface));
}
.ww.wake-ww.ongoing .ww-tag { color: var(--wake-ink); }
.ww.walk-ww.ongoing {
border-color: var(--walk);
background: color-mix(in srgb, var(--walk) 14%, var(--surface));
@@ -547,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; }
@@ -594,9 +781,6 @@ dialog::backdrop { background: rgba(0,0,0,0.4); }
dialog h3 { margin: 0 0 12px; }
dialog label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 10px; }
/* The display rule above beats the UA [hidden] rule, so hide explicitly
otherwise the weight/grams fields show on event types that don't use them. */
dialog label[hidden] { display: none; }
dialog label input, dialog label textarea { margin-top: 4px; }
dialog menu {
@@ -622,7 +806,6 @@ dialog menu {
flex-wrap: wrap;
gap: 10px;
}
.photo-preview[hidden] { display: none; }
.photo-thumb { position: relative; }
.photo-thumb img {
display: block;
@@ -696,11 +879,29 @@ dialog menu {
.time-row input { flex: 1 1 120px; min-width: 0; }
.time-row button { padding: 8px 12px; }
/* The chart window is a page-level control rather than a panel's, so its row
skips the card chrome the sections have: no surface, no shadow, just a label
and the buttons sitting on the page background between two cards. */
.chart-window {
display: flex;
align-items: center;
gap: 10px;
padding: 0 4px;
}
.chart-window-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
}
.chart-days-picker {
display: flex;
gap: 6px;
margin: -4px 0 14px;
}
/* No heading above it out here, so nothing to pull up under or clear beneath. */
.chart-window .chart-days-picker { margin: 0; }
.chart-days-picker button {
padding: 5px 12px;
font-size: 0.8rem;
@@ -820,8 +1021,6 @@ dialog menu {
background: var(--bg);
z-index: 50;
}
/* The display rule above beats the UA [hidden] rule, so hide explicitly. */
.auth-screen[hidden] { display: none; }
.auth-card {
width: 100%;
max-width: 360px;
@@ -890,6 +1089,143 @@ button.linklike:hover { text-decoration: underline; filter: none; }
}
.danger-text strong { color: var(--danger); }
/* ---------- days that don't count ---------- */
.overview-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.overview-head h2 { margin: 0; }
.exclude-btn {
flex: none;
padding: 4px 10px;
font-size: 0.78rem;
}
.exclude-btn.active {
background: var(--accent);
border-color: transparent;
color: #fff;
}
.excluded-note { margin: 8px 0 0; }
/* Sits between the stat tiles and the "last X" rows, so it reads as a
footnote to the Meals tile above it. */
.food-kind-split { margin: -8px 0 14px; }
/* Tied to the bar above it, so it sits closer to the chart than the trend
caption below and takes the accent to read as "the highlighted one". */
.food-day-info { margin: 6px 0 0; color: var(--accent); }
/* The day's own figures stay readable but visibly step back, so "this one is
not in the numbers" is legible from the day as well as from the charts. */
.overview.day-excluded .stats,
.overview.day-excluded .food-kind-split,
.overview.day-excluded .last-row { opacity: 0.55; }
/* The hatch every chart uses for a day that doesn't count. The pattern itself
is defined once in index.html; the stroke is set here so it follows the
theme, and the fill sits on a faint wash so the column reads as a marked
slot rather than as ink. */
.hatch-line {
stroke: var(--muted);
stroke-width: 2;
opacity: 0.5;
}
.bar-excluded {
fill: url(#hatch);
opacity: 0.45;
}
/* ---------- guest links ---------- */
.guest-list {
list-style: none;
margin: 12px 0 0;
padding: 0;
}
/* A link is two stacked rows: who and when on top, the URL underneath. */
.guest-item {
padding: 10px 0;
border-top: 1px solid var(--border);
}
.guest-item-row {
display: flex;
align-items: center;
gap: 10px;
}
.guest-item-main {
flex: 1;
min-width: 0;
}
.guest-item-label {
display: block;
font-weight: 600;
overflow-wrap: anywhere;
}
.guest-item-sub {
display: block;
color: var(--muted);
font-size: 0.8rem;
}
button.guest-revoke { color: var(--danger); flex: none; }
/* The URL, always available so the link can be re-sent. It is 90-odd characters
of hex, so it gets one truncated line rather than wrapping into a wall of it
the Copy button is the way it is meant to be taken, and tapping the text
selects the whole thing for anywhere the clipboard API is unavailable. */
.guest-item-url {
display: flex;
align-items: center;
gap: 8px;
margin: 6px 0 0;
}
.guest-url {
flex: 1;
min-width: 0;
font-size: 0.75rem;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: text;
}
button.guest-copy {
flex: none;
padding: 4px 10px;
font-size: 0.75rem;
}
/* Only a guest ever sees this, directly under the header. No bottom margin:
main's own top padding provides the gap to the first panel. */
.guest-banner {
margin: 4px 0 0;
padding: 8px 12px;
border-radius: var(--radius);
background: var(--accent-soft);
color: var(--text);
font-size: 0.85rem;
line-height: 1.4;
}
/* Who logged an event, when it came in on a guest link. Small caps so it reads
as a margin note against the event label rather than competing with it.
The row is a single non-wrapping line, so the badge is capped and ellipsised:
a long label ("Anna the neighbour's daughter") must not squeeze the note out. */
.event .by {
flex: none;
max-width: 10ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
background: var(--accent-soft);
border-radius: 999px;
padding: 2px 8px;
}
/* ---------- settings toggle switch ---------- */
.toggle-row {
display: flex;
@@ -937,7 +1273,6 @@ input.switch:checked::after { transform: translateX(18px); }
color: #fff;
box-shadow: var(--shadow);
}
.update-banner[hidden] { display: none; }
.update-banner-row {
display: flex;
align-items: center;
@@ -953,7 +1288,6 @@ input.switch:checked::after { transform: translateX(18px); }
line-height: 1.45;
opacity: 0.92;
}
.update-changelog[hidden] { display: none; }
.app-footer {
text-align: center;
@@ -1004,10 +1338,183 @@ input.switch:checked::after { transform: translateX(18px); }
.update-banner-btn:hover { filter: brightness(0.97); }
/* ---------- quick-log snackbar ---------- */
.snackbar {
/* ---------- food kinds ---------- */
/* A palette of its own. The app's semantic colours are spoken for --pee
yellow on a food bar would actively mislead so kinds get six hues that sit
around --eat's orange and stay apart from each other. A kind keeps its index
for life (see addFoodKind), so a deletion never repaints old charts; beyond
six, kinds share, which is a gentler failure than running out. */
:root {
--food-0: #ff9b3d;
--food-1: #2bb3a3;
--food-2: #b04ecf;
--food-3: #3f9e63;
--food-4: #e0603c;
--food-5: #5a7fd6;
}
[data-color="0"] { --food-color: var(--food-0); }
[data-color="1"] { --food-color: var(--food-1); }
[data-color="2"] { --food-color: var(--food-2); }
[data-color="3"] { --food-color: var(--food-3); }
[data-color="4"] { --food-color: var(--food-4); }
[data-color="5"] { --food-color: var(--food-5); }
/* A stacked segment, and the dashed fit through that kind's own amounts. Both
take the kind's colour from the data-color attribute set on the element. */
.chart-svg .bar-food-kind { fill: var(--food-color, var(--muted)); }
.chart-svg .food-trend {
stroke: var(--food-color, var(--weight));
stroke-width: 2;
stroke-dasharray: 5 3;
stroke-linecap: round;
fill: none;
}
.legend .sw.food-sw { background: var(--food-color, var(--muted)); }
.kind-field { margin-bottom: 10px; }
.kind-label {
display: block;
font-size: 0.85rem;
color: var(--muted);
margin-bottom: 4px;
}
.kind-picker {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
button.kind-chip {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 999px;
padding: 6px 12px;
font-size: 0.85rem;
font-weight: 600;
/* Names are free text, so a long one wraps the row rather than the chip. */
max-width: 100%;
overflow-wrap: anywhere;
}
/* The chosen chip fills with its own colour; "No kind" has none and falls back
to the accent, so it reads as a choice rather than as a colourless gap. */
button.kind-chip.active {
background: var(--food-color, var(--accent));
border-color: transparent;
color: #fff;
}
.kind-new {
display: flex;
gap: 8px;
margin-top: 8px;
}
.kind-new input { flex: 1; min-width: 0; }
.kind-new button { flex: none; padding: 8px 14px; }
.food-kind-list {
list-style: none;
margin: 10px 0 0;
padding: 0;
}
.food-kind-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-top: 1px solid var(--border);
}
.food-kind-swatch {
flex: none;
width: 14px;
height: 14px;
border-radius: 4px;
background: var(--food-color, var(--muted));
}
input.food-kind-name {
flex: 1;
min-width: 0;
width: auto; /* the global input rule sets 100%, which would push the row */
}
button.food-kind-default {
flex: none;
background: transparent;
color: var(--muted);
padding: 4px 6px;
font-size: 1.1rem;
line-height: 1;
}
button.food-kind-default.active { color: var(--wake); }
button.food-kind-delete { color: var(--danger); flex: none; }
/* ---------- 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;
@@ -1024,7 +1531,6 @@ input.switch:checked::after { transform: translateX(18px); }
pointer-events: none;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.snackbar[hidden] { display: none; }
.snackbar.show {
opacity: 1;
transform: translate(-50%, 0);
@@ -1066,6 +1572,13 @@ input.switch:checked::after { transform: translateX(18px); }
.chart-svg .stl-hit { fill: transparent; cursor: pointer; }
.chart-svg .stl-hit:hover { fill: var(--accent); fill-opacity: 0.08; }
/* "Now" rule on the midnight-to-midnight charts (actograms + hour heatmap):
ink over a surface-coloured underlay so it reads over any shaded window or
cell, and never eats a tap meant for the row underneath. */
.chart-svg .now-rule-ring { stroke: var(--surface); stroke-width: 3; pointer-events: none; }
.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; }
/* Sleep trend lines: today strongest, the reference curves lighter/dashed. */
.chart-svg .trend-today {
stroke: var(--sleep);
@@ -1130,17 +1643,9 @@ input.switch:checked::after { transform: translateX(18px); }
fill: none;
opacity: 0.85;
}
.chart-svg .wtrend-goal {
stroke: var(--walk);
stroke-width: 1.5;
stroke-dasharray: 2 3;
opacity: 0.55;
}
.lg.wtrend-today .sw { background: var(--walk); }
.lg.wtrend-yesterday .sw { background: var(--eat); }
.lg.wtrend-avg .sw { background: var(--weight); }
.lg.wtrend-goal .sw { background: color-mix(in srgb, var(--walk) 45%, var(--surface)); }
.lg[hidden] { display: none; }
.chart-svg .hm-cell { stroke: none; }
/* Focused block: an accent ring, drawn at full opacity so it stays visible on
@@ -1188,7 +1693,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 {
@@ -1209,11 +1714,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; }
+5
View File
@@ -80,6 +80,11 @@ self.addEventListener("fetch", (event) => {
// Other API calls: never cache — sync must reflect live server state.
if (url.pathname.includes("/api/")) return;
// Guest links: a one-shot secret URL that must reach the server to be
// redeemed, and that has no business being written into the asset cache
// under a key containing its token.
if (url.pathname.includes("/guest/")) return;
event.respondWith(
caches.match(req).then((cached) => {
if (cached) return cached;