Commit Graph
96 Commits
Author SHA1 Message Date
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
Alexander Heldt 02be8ce3e7 Interleave the sleep and wake lists, move walks down
Sleep windows and Wake windows were two panels showing two halves of one
sequence: a wake window is precisely the gap between two sleeps, so the panels
alternated with each other and neither could be read as the day's rhythm on its
own. Answering "how long was he up before that nap?" meant looking at one panel,
holding a time in your head, and finding it in the other.

They are one list now, the two window sets sorted together by start time, which
is all the interleaving that alternating-by-construction needs. Each row states
whether it is asleep or awake and carries a stripe in its colour, so the
alternation reads before the words do, and the row still running keeps the
highlight it had. renderWindowList grew the ability for a window to bring its
own class and label; the single-kind lists still pass one of each for the whole
list and tag only the open row, where "Walking" is news rather than a repeat of
the heading it sits under.

One rule had to be spelled out: the new per-kind stripe ties on specificity with
the generic .ww.ongoing, and being later would have won, leaving an open awake
row with a faded edge instead of its full-strength one.

The walks group — the panel and both its patterns — moves below "Pees, poos &
meals". The three stay together: they were grouped under Walks deliberately, and
leaving the patterns behind would strand two walk charts among the sleep ones.

Both old panel keys go stale, so anyone who had folded Sleep windows or Wake
windows gets the merged panel open, as with any panel that is new to a build.
2026-08-31 22:21:43 +00:00
Alexander Heldt 50d0d8a726 Let the weigh-in list fold on its own
The Weight panel holds three things, and only one of them grows: the latest
figure and the curve stay one screen forever, while the row list gains a line
every weigh-in until it pushes everything else off. Folding the panel to get rid
of it also takes away the two parts worth keeping.

So the fold mechanism now works on any keyed element inside main, not only on a
section, and the list moves into a block that folds by its own h3 while the
panel keeps folding by its h2. The two nest without special handling — separate
keys, and a folded section hides the block along with the rest, so the block's
own state is simply there again when the section reopens.

The CSS generalises from section.collapsible to a class, but scoped to main:
the pedigree tree uses .collapsed for its own branches, and an unscoped
selector would have folded every card in it.
2026-08-31 22:11:27 +00:00
Alexander Heldt 8f9e8783fa Give walks the two sleep pattern views
Walks had the overview tile, the window list and minutes per day, but not the
two views that answer the questions sleep already answers: when in the day does
it actually happen, and is today keeping up.

"When … walks" is the sleep actogram drawn from walk windows, so the drawing
moves into drawActogram(svgId, windows, barCls) and both timelines become one
line each. Nothing about that picture was ever sleep-specific — it clips
windows to day rows and shades them — so the third caller costs nothing.

"Walk trend" is the sleep trend's shape with three deliberate differences.
Today and yesterday are built from their own walk boundaries rather than hourly
samples, so the steps stay square: a 30-minute walk is a step, not an hour-wide
ramp. The average keeps hourly sampling, since a mean over many days is a
smooth reference with no steps of its own to preserve. And there is no
projected tail — sleep is a state the puppy drifts back into, so continuing
today's line by what the average day adds is a fair guess, while walks are
decisions, and projecting them would be predicting the handler, not the dog.

The goal is a line, not a band: the five-minute rule gives one number to reach
with no upper bound a band would imply. With no projection to carry it, the ✓
that marks being on track goes on today's own chip.

The axis stays linear where the sleep trend stretches above 10h. That stretch
exists because sleep's interesting hours crowd a 16h goal; a walk total reads
the same at 10 minutes as at 60.

Both panels sit with the other walk views rather than beside their sleep twins,
following the grouping-by-subject the charts just moved to, and both hide
themselves until a walk exists so they cost nothing to anyone not logging them.
2026-08-31 22:07:38 +00:00
Alexander Heldt 079eb41672 Apply the chart window to the timing panel
The 7d/14d/30d picker already drove every chart on the page, but the timing
panel was not a chart and got missed: gapsBetween defaulted to a literal 7 and
the heading said "(last 7 days)" as fixed text, so picking 30d widened the
counts and heatmap while the pee, poo and meal gaps under them stayed on a
week. Two panels describing the same events over different windows is the kind
of disagreement nobody thinks to check for.

The default is now chartDays(), read at call time so it tracks the picker
rather than freezing at load, and the heading joins the other panels in
deriving its "(last N days)" from the same place. The two sentences that
quoted seven days — the empty-row fallback and the range tooltip — say the
picked number, and the note about the faded stretch says "the window" rather
than "the week", which is no longer always true.

An explicit days argument still wins, so a future caller wanting a fixed
window is not forced through the picker.
2026-08-31 22:01:19 +00:00
Alexander Heldt e037ab2716 Group the charts by subject, not by window
"Last 7 days" collected four charts for no better reason than sharing an axis,
which put sleep hours a full panel away from the two sleep patterns and the
counts chart nowhere near the by-hour view of the same events.

Each chart now sits with its subject. Sleep hours per day becomes its own Sleep
panel directly above the sleep timeline, so the three sleep views read in
sequence. Daily counts and Food join the heatmap in one "Pees, poos & meals"
panel: how many a day, how much food went with them, and what hours they fall
in are three views of one set of events and belong on one card. Minutes walked
per day goes to the Walks panel, which is the same move applied to the chart
the split didn't mention.

The day-window picker stays a single control, in the Sleep panel, because it
was never scoped to the panel holding it — it drives the training grid, both
sleep patterns and the counts panel too. That is unchanged, and the panel it
lives in occupies the slot the old one did, so it has not moved on screen. A
comment says so, since a global control sitting inside one card does not
announce itself. Duplicating it into each panel would work as-is (both the
labels and the buttons are addressed by querySelectorAll) if reaching it ever
becomes a scroll.

Both new panels take new data-panel keys, so anyone who had the old panels
folded gets the new ones open rather than inheriting a collapse they chose for
something else. The dead #daily-charts-title lookup goes with the heading.
2026-08-31 21:51:33 +00:00
Alexander Heldt 7a1c0f3808 Shorten the weigh-in label to "Weight"
"Weigh-in" was the longest label in the app and, since it moved into a
third-width button, close enough to the edge to wrap on a mid-size phone.
"Weight" clears it with room to spare and loses nothing: the section, the
chart and the dialog field are all called Weight already.

EVENT_LABELS follows the button rather than only the button changing. Every
type's button label and its log label are the same string today, and weight
would have been the one exception — "⚖️ Weight" to log it, "Weigh-in" in the
history row and "Log Weigh-in" on the dialog. The prose that says "weigh-ins"
as a plural noun is left alone; it reads as English, not as a label.

The changelog entry for the rows is amended again rather than gaining a
neighbour, for the same reason as last time: it is one description of one
panel, and the rename is part of what that panel now looks like.
2026-08-31 21:42:20 +00:00
Alexander Heldt 09b9b62d06 Move Ate down to the dialog row
Pee and Poo take a row of their own and Ate joins Weigh-in and Note across the
bottom, which regroups the panel by what a tap does rather than by what the
event means: the bottom row is now exactly the three buttons that open a
dialog to type a value, and the row above it exactly the two that log on one
tap. Ate was the odd one out under the old split — it sat with the pees and
poos but behaved like a weigh-in.

That hands the longest label in the app, "⚖️ Weigh-in", a third-width button.
The narrow-phone padding trim already in place was written for "🍽️ Ate" in
that slot and now serves Weigh-in instead; on a mid-size phone the label is
close enough to the edge that it may wrap to two lines, which costs a little
row height and nothing else. Shortening it to "Weight" is the fix if it does.

The changelog entry describing the rows is corrected in place rather than
answered with a second entry. The update banner diffs by entry text, so an
edited entry is already surfaced as new to anyone coming from the previous
build — and one accurate description of the layout beats two entries whose
combined reading is only correct if you apply them in order.
2026-08-31 21:39:41 +00:00
Alexander Heldt d551bb96eb Bracket sleep and walk pairs in the history log
A pair reads as two unrelated rows in the day's log: "Walk start 08:00" and
"Walk end 08:30" sit apart from each other and, with a pee logged in between,
apart from anything that happened while the puppy was out.

A dotted rail down the margin ties the pair together, in that pair's colour.
It runs the full span rather than only touching the two boundary rows, so
whatever was logged in between falls inside the bracket — a pee taken on a
walk now reads as having happened during it, which is the more useful claim
and comes for free once the rail spans rows at all.

Which rows a span covers comes from the same sleepWindowsForDay /
walkWindowsForDay the Sleep and Walks panels draw, so a pair crossing midnight
is treated identically in both places. Where a span's boundary isn't a row in
this day — an ongoing walk, or one that ran over from yesterday — that end of
the rail is left open and runs off the edge of the list, rather than anchoring
on a row that didn't start or end anything. A boundary with no partner in the
day draws nothing: a rail needs two rows to tie.

The roles are named for the rendered list, which runs newest-first, so a span's
last event is its top row and the rail is built downward from there.

It costs no layout. The rail is drawn in the padding the section already has,
left of the cards, so no row indents or moves; the anchored end stops level
with the row's dot and the open ends overshoot the list gap so consecutive
segments join into one line.
2026-08-31 21:32:06 +00:00
Alexander Heldt f162ac5732 Track walks as timed exercise
A walk is a start and an end, so it reuses the shape sleep already has rather
than inventing one: walk-start / walk-end events, paired into windows, with a
trailing unmatched start meaning "out right now". The server stores type as an
opaque string, so nothing there changes and the events ride the existing sync.

Pairing boundaries into windows was written out twice already, once for sleep
and once for its inverse, so this pulls the scan into pairWindows(open, close)
and makes all three callers of it. Same for the latest-boundary lookup behind
currentSleepState, which currentWalkState now shares — including the updatedAt
tie-break, which matters as soon as a start and an end land in the same minute.

They are called walks, not exercise. "Exercise" is already taken by the
training definitions (their own synced collection, and exerciseId on training
events), and two meanings of the word in one app would be worse than the
slightly narrower name.

The day's total leads the overview tile with the count underneath, since the
question is how much exercise the puppy got rather than how many outings it
took. The Walks panel lists the day's windows and carries the total in its
heading so a collapsed panel still answers it, and the daily charts gain a
minutes-per-day bar chart that stays hidden until there is a walk to draw —
the grams chart's rule. The panel also states the five-minute rule for the
puppy's current age, the same way the sleep trend states a goal band.

Walk boundaries answer to the walk state, not the sleep one, so "Walk end" is
disabled with no walk running and stays undimmed mid-walk even while the puppy
is logged asleep.
2026-08-31 21:23:40 +00:00
Alexander Heldt ead575df2c Group the log buttons into rows
The buttons flowed into one auto-fit grid, so how they grouped was whatever
the viewport's column count happened to produce. At two columns — every
phone — seven buttons left the last one stranded alone on a fifth row, and
between roughly 504px and 584px the grid goes to three columns and splits
"Sleep start" from "Sleep end" across a wrap, which is the one pairing that
has to stay together: the two halves are read as a unit, and one of them is
always the disabled twin of the other.

Each row is now stated outright instead. The timed pair takes a row of its
own, the one-tap moments go three across, and the two that open a dialog to
type a value take the last row. Nothing is orphaned at any width and the
pairing cannot come apart.

The rows group by what the buttons mean rather than by what tapping them
does, which puts "Ate" with the pees and poos even though it opens the grams
dialog like a weigh-in. Meaning is what you are scanning for at 3am; the
dialog is a detail you meet after the tap.

Three columns leave about 55px of text room at 320px wide, so the narrow
breakpoint that already trims the day bar trims that row's side padding too,
rather than letting a button wrap to two lines.
2026-08-31 21:22:38 +00:00
Alexander Heldt 17ce68da08 Show the longest gap in the timing charts
The bar gains a faded stretch from the typical gap out to the longest one of
the window, behind the solid shortest-to-typical part. Where the solid ends is
the median, so the boundary marks "typical" without another mark on the track.

Extending the linear axis to reach it does not work. Gaps are heavily skewed —
one long overnight gap a night against daytime gaps a tenth its length — and
on a linear scale the everyday range collapsed into the first tenth of the
track, to the point that four different "since" values rendered as the same
picture. So the axis is stretched the way the sleep trend's is above 10h: the
typical gap is pinned to the middle, shortest-to-typical takes the left half
and typical-to-longest the right. Every row then reads the same way, left of
centre sooner than usual and right of centre longer, however extreme that
row's tail is. A note in the panel says as much, since a stretched axis is not
something to leave to inference.

The right edge now being the longest gap retires the old cap at twice the
typical: the marker's chevron means "past the longest gap yet" rather than
past an arbitrary cutoff.
2026-08-24 15:06:12 +00:00
Alexander Heldt 8d0ebdd4bc Fix the heatmap caption needing several taps
A tap fires the emulated mouseenter and then the click on the same block, and
the click handler was a toggle: the first of the pair selected the block, the
second cleared it, so one tap left the caption exactly where it started. It
only appeared on a later tap that arrived without a fresh mouseenter, which is
what made it look like taps were being missed.

The click now selects rather than toggles, so running both handlers for one
tap is a no-op — the same idempotent shape the weight chart's hit targets
already use. Tap-again-to-clear goes with it: telling a hover-set selection
apart from a click-set one is more machinery than the affordance is worth, and
the caption keeping its last block matches the weight chart's caption. The
changelog entry that promised clearing is corrected in place, since the
behaviour it describes never worked.
2026-08-24 13:43:06 +00:00
Alexander Heldt b42ea2e309 Draw each timing row as a range chart
A pair of numbers per type says how long the gaps are but not where the puppy
currently sits between them, which is the thing you actually want when
deciding whether to go out now. Each row becomes a small chart instead: a band
from the shortest to the typical gap, a marker for time since the last one,
and a scale of [min(shortest, since), max(typical, since)] so the marker is
free to land inside the band, off its left end (just went) or off its right
end (due).

Three cases the layout has to survive. A gap in tracking can leave "since" at
days against a typical gap of hours, so past twice the typical the marker
parks at the right edge behind a chevron and its label keeps the real number —
squashing the band to a sliver would be worse, and clamping silently would
read as being in range. A band too narrow to hang a label off each end carries
both values centred over its middle rather than pinned to the track ends,
where they would imply a spread the band does not have. Under two events there
is no gap at all, so the row falls back to a sentence instead of an axis with
nothing on it.

The marker is ink over a surface-coloured ring so it stays legible on any band
colour in either theme, and the gap between a label's word and its value is an
explicit dx: a trailing space inside a tspan does not survive XML whitespace
normalisation.
2026-08-24 13:14:45 +00:00
Alexander Heldt 29b961c1b0 Add meal gaps to the timing panel
Meals have the same shape as the pee and poo intervals already shown here —
gapsBetween() is type-agnostic, so it is two more rows and one more call. The
window, the median-based "typical" and the em-dash empty state all come along
unchanged.

The heading drops "Bathroom", which stops being accurate once feeding is in
the panel. data-panel stays "timing", so a collapsed panel stays collapsed
across the rename.

The hint keeps keying off pees: it is advice about when to take the puppy out,
which meal spacing has nothing to say about. Only its empty-state text widens.
2026-08-24 12:59:52 +00:00
Alexander Heldt 2a14c34010 Make the by-hour heatmap blocks tappable
The count behind a block was only ever reachable through its <title> tooltip,
which needs a pointer — on a phone there was no way to find out whether a dark
block meant two meals or five. Each cell now carries a transparent hit rect
that names it in a caption under the chart ("3 meals between 07:00 and
08:00"), the same string the tooltip shows, so hover and tap agree. The
focused cell takes an accent ring and tapping it again clears it.

The hit rect claims the 1px spacing between cells and half the gap to the
neighbouring rows, which takes the target from ~13x31 to ~13x38 CSS px on a
360px-wide screen; the rows still tile without overlapping and stay clear of
the hour labels. Width is capped by fitting 24 hours across the chart, so a
mis-tap lands on a neighbouring hour — the caption names the range it hit,
which makes that self-correcting.

The focused cell is held outside the render so a background sync can't wipe
what is being read; its count is recomputed each pass, so the caption stays
current. The caption is aria-live, which also gives screen readers a route to
the numbers that role="img" on the svg otherwise closes off.
2026-08-24 12:56:11 +00:00
Alexander Heldt d81c20ac9b Make the day-bar sleep pill a one-tap toggle
Once the big timer scrolls away, the pill in the day bar is the only thing
still showing the sleep state — but acting on it meant scrolling back up to
the quick actions. Tapping the pill now logs the boundary that ends the state
it displays: sleep-end while asleep, sleep-start while awake. It routes
through quickLog(), so the snackbar and its undo behave exactly as they do
from the buttons, and the state is read from currentSleepState() rather than
the cached bigClockState so a tap can't act on a stale render.

The pill becomes a real <button>, which brings keyboard operation along for
free. In standby it is visibility:hidden, so while the big card is on screen
it is neither clickable nor tab-reachable, and the toggle is live exactly when
the pill is visible.

The .day-bar button padding and :disabled rules are scoped to :not(.bar-clock)
so they don't start outranking the pill's own sizing, and the default accent
button background is reset since the asleep/awake classes paint it.
2026-08-21 06:35:53 +00:00
Alexander Heldt 51d015c231 Add push reminders for sleep, pee, poo and meals
A closed PWA has no timers, so reminders are evaluated on the server: the
event log is already there (clients sync on every mutation), and a ticker
re-checks each enabled rule once a minute and pushes the ones that are due.

Two rule shapes. "sleep" measures from the last sleep-end and fires only
while the puppy is awake. "pee"/"poo"/"eat" measure from the newest event of
that type and stay quiet while the puppy is asleep — otherwise they nag all
night, and suppressing them means an overdue rule instead fires promptly on
waking, which is when it actually matters. Sleep state is derived exactly the
way currentSleepState() does in app.js, tie-break included, so both sides
always agree. Rules read the event's own timestamp rather than when it synced,
so a pee logged offline at 03:10 cancels the reminder retroactively.

Every push carries a tag, so a repeat replaces the previous notification
instead of stacking another one on the lock screen. last_fired is server-owned
and not writable by a client, so a stale device can't force a re-fire.

Web Push is implemented directly rather than pulled in as a dependency: RFC
8291 encryption in the RFC 8188 aes128gcm coding with an RFC 8292 VAPID token,
stdlib only, checked against the RFC 8291 test vector. The key is generated
into vapid.json beside the DB or supplied via -vapid-key; without one the
server logs a warning, skips registering the routes, and the client hides the
UI. Subscriptions a push service reports as 404/410 are dropped.

PNG icons are added because iOS gates push on a Home Screen install and
rejects SVG for apple-touch-icon, and Android has no notification icon
without them.
2026-08-20 17:19:18 +00:00
Alexander Heldt 93d6ea27a7 Disable the sleep boundary that repeats the last one
While asleep, tapping "Sleep start" again can only produce a zero-length
sleep window, and likewise "Sleep end" while awake — so renderActionHints
now disables that button outright instead of merely dimming it, with a
title explaining why ("Already asleep" / "Already awake"). Every other
action stays clickable, and a genuinely missed boundary is still fixable
from the event log, which accepts any time. With no sleep history yet,
either boundary remains a valid first event.

Also adds nodejs to the dev shell for `node --check` on src/*.js.
2026-08-18 18:35:14 +00:00
Alexander Heldt f9894abfc9 Add free-text day notes with an always-visible notes log
A new 📝 Note quick-action logs a free-text note (with a date, optional
photo, and any text), defaulting to the day you're viewing. Notes are a
distinct "note" event type, so they edit, sync and delete like any other
event, and a new Notes section lists them newest-first across every day —
independent of the day picker — so records like vaccinations stay visible
whatever day you're looking at.
2026-08-02 13:37:39 +00:00
Alexander Heldt 09d9d38c12 Add metric checkboxes to the daily counts chart
The Daily counts legend is now a Pees/Poos/Meals checkbox group, so you
can hide metrics and focus on just the ones you care about. The choice is
persisted per device, at least one metric is always kept visible, and the
y-axis and bar widths adapt to the selected metrics.
2026-08-02 13:26:38 +00:00
Alexander Heldt a44c75d2d4 Let the confetti fade at its peak instead of falling back
Drop the descent phase: each emoji now rises to its peak, slows (ease-out) and
fades out at the top rather than arcing back down. Removes the now-unused --endY.
2026-08-01 18:28:40 +00:00
Alexander Heldt e2f99590f1 Make the pee/poo confetti a bottom-up fireworks fountain
Rework the effect: instead of a small burst from the tapped button, 30 emoji
launch from across the bottom of the screen, rise to random peaks while
drifting sideways, then arc back down and fade — staggered launches and varied
speed/size/rotation for a fuller, firework feel. Still pointer-events:none,
self-removing, and gated by the Settings toggle and reduced-motion.
2026-08-01 18:24:33 +00:00
Alexander Heldt 52c50c97b1 Celebrate a logged pee/poo with emoji confetti
Tapping the pee or poo quick-log button now sets off a short burst of 💧/💩
particles from the button — pure DOM + CSS, pointer-events:none so it never
blocks taps, particles self-remove on animation end. It honours
prefers-reduced-motion, and there's a "Pee/poo confetti" switch in Settings
(device-local, on by default) to turn it off.
2026-08-01 18:18:26 +00:00
Alexander Heldt 1ed325b834 Keep the header and weight rows tidy when the age is long
The age counter can read "16 weeks (3 months and 3 weeks) old", which broke
two tight layouts: the header title never truncated, so a long name (or the
wide status pill) collided with the action buttons and the age wrapped to
three lines; and each weight-log row embedded the full age, wrapping to two
lines.

Add compact age formatters and use them where space is tight: formatAgeShort
("16 wk · 3 mo 3 wk") in the header, formatAgeWeeks ("16 wk") in weight rows;
the verbose form stays on the roomy weight-chart caption. Make the header
robust — the title flexes and the name ellipsis-truncates so the buttons are
never pushed, while the age wraps rather than truncating so it's never cut
off. Weight rows keep the date/age on one line with the value pinned right.
2026-08-01 18:11:03 +00:00
Alexander Heldt 69e312175b Add a radial fan-chart view of the pedigree
Add a Fan / Tree toggle in the pedigree header. The fan places the dog in a
centre disc with each generation as a ring fanning outward, so up to nine
generations fit in one compact circle instead of a tree that doubles in
width every generation. Inner rings are labelled; tap any wedge for that
dog's details. Pedigree-collapse dogs keep their stable hue, and tapping one
lights up every wedge of that dog. The existing zoom applies to the fan too.

Render dispatch is factored into renderPedigree(); the collapse-highlight
now works on any element (tree card or fan wedge) carrying a data-dogkey.
2026-07-26 11:13:16 +00:00
Alexander Heldt a2c9aa9716 Add a collapse/expand-all toggle to the pedigree
A header button folds the whole tree down to just the dog or opens every
branch at once, alongside the per-dog toggles. Grouped with the zoom
controls, which the header now wraps on narrow screens.
2026-07-26 10:50:12 +00:00
Alexander Heldt 12a5bd0548 Make the pedigree tree zoomable
Add zoom to the pedigree: −/reset/+ buttons in the header, ctrl/⌘ + wheel,
and two-finger pinch. Scaling uses the CSS `zoom` property (not transform)
so the container reflows and every part stays reachable by scrolling at any
level. Clamped to 40–160% and the level is remembered per device.
2026-07-26 10:49:54 +00:00
Alexander Heldt 01d64b682b Flag pedigree collapse with repeat badges
When a dog fills more than one position in the pedigree (pedigree collapse),
mark each copy with a ×N badge and a stable per-dog colour, and let tapping
any copy highlight every place that dog appears. Identity is the registration
number (falling back to name), so the same ancestor at different positions is
treated as one. The summary line also reports how many ancestors recur.
2026-07-26 10:39:51 +00:00
Alexander Heldt 8157e95066 Render the pedigree top-down as a family tree
Replace the indented ancestry list with a top-down chart: the dog on top,
sire (left) and dam (right) branching below, connected by lines drawn with
each node's CSS ::before/::after (a nested <ul>/<li> tree). It shows three
generations by default and each dog expands to trace the line further back;
deeper levels get wide, so the view scrolls horizontally.
2026-07-26 10:39:28 +00:00
Alexander Heldt 86e51bb851 Make the pedigree id sticky across profile sync
The dog id in the synced profile shares one last-write-wins timestamp with
name and birthday, and that timestamp comes from each device's own clock.
A device whose profile was stamped later (clock skew, or a more recent
name/birthday edit) would refuse to adopt a newer server profile that had
just gained an id, or even push its empty id back over the stored one — so
the pedigree id set on one device never reached the others.

Make the id sticky on both sides of sync: an empty value never clears a set
one, and a device/server adopts an id it is missing regardless of the blob
timestamp. When both sides have an id, the newer profile still wins, so the
dog can still be changed. Name and birthday keep plain last-write-wins.
2026-07-26 10:19:01 +00:00
Alexander Heldt c2f74e64c8 Gate pedigree behind a dog id set in settings
The pedigree view is now opt-in and tied to your own dog rather than an
always-present free-text search. Add the dog's SKK chip or registration
number in Settings (it rides the synced profile alongside name and
birthday); the 🌳 button stays hidden until one is set, then opens the
page and loads that dog's ancestry directly.

Make repeat opens cheap: memoise the id->hundid resolution server-side so
a cached tree is served without contacting SKK at all, and mirror the
finished tree in localStorage so the page paints instantly and shows the
last-known tree offline.

Adds config.pedigree_id (with an in-place migration for existing DBs).
2026-07-26 09:36:49 +00:00
Alexander Heldt 26ebe3bd86 Add pedigree lookup and ancestry tree page
New 🌳 Pedigree view: enter a dog's ISO chip or SKK registration number
and see its ancestry rendered as a tree. SKK has no public API, so the
server drives SKK Hunddata like a browser: it resolves the input to an
internal hundid via the Hund_sok.aspx/HundData page-method, renders 7
generations per pedigree page, parses the rowspan grid into ahnentafel
positions, and follows each generation's leaves deeper by reading their
hundid out of the __doPostBack response viewstate.

A lookup returns the first 7 generations immediately and crawls deeper in
the background; the client polls and fills the tree in as ancestors
arrive. Finished trees are cached per dog in a new pedigree_cache table
(pedigrees don't change), so a dog is crawled once and repeats are instant.
The endpoints sit behind auth like the rest of /api/*, and the crawl is
kept polite (warmed session, delay between requests, one coalesced job per
dog, hard caps).
2026-07-26 09:22:14 +00:00
Alexander Heldt 374e630d8f Show the age counter as weeks with a months breakdown
Weeks and calendar months disagreed at a glance ("16 weeks" but only
"3 months"). Now the counter reads "16 weeks (3 months and 3 weeks)
old" so the two units line up, and drops the weeks past 4 months to
show just the months form.
2026-07-24 15:17:09 +00:00
Alexander Heldt 22a5ea76aa Make the Sleep trend chart follow the selected day
The curves were always computed relative to today regardless of the day
picker. Anchor them to selectedDay() instead: a past day shows its full
24h curve against the day before it and the average of the days leading
up to it, with no now-cap and no projection. Legend and tooltip labels
show the actual dates when viewing a past day.
2026-07-23 08:45:26 +00:00
Alexander Heldt 0ebaa11c93 Stretch the Sleep trend y-axis above 10h
The interesting detail sits near the goal, in the upper teens of hours,
so the scale is piecewise: 0–10h shares a compressed 26% of the height
with 2h gridlines, and everything above gets the rest with 1h
gridlines. Falls back to linear while the axis is too short to split.
The caption notes the stretched axis.
2026-07-15 19:06:16 +00:00
Alexander Heldt 897311465c Show the age-based sleep goal as a band in the Sleep trend chart
The puppy's age (from the configured birthday) picks the daily goal —
0–8 weeks 20–22h, 8–16 weeks 18–20h, 16–18h to 6 months, 14–16h to 12
months — drawn as a shaded band behind the curves, with a legend chip
naming the range. The Projected chip gains a ✓/▽ marker for whether
today's projection reaches the goal's lower bound, and the y-axis
always extends to cover the band. No birthday (or an adult dog) means
no band, unchanged from before.
2026-07-15 17:35:55 +00:00
Alexander Heldt 8fe8f9d417 Log training sessions onto the selected day, not always today
The Log button stamps the selected day at the current time of day, so a
forgotten session can be back-filled from that day's view. The snackbar
says which day it landed on when it isn't today; Undo/Add note still
work on it.
2026-07-15 13:26:30 +00:00
Alexander Heldt c24d59e672 Make the Sleep trend yesterday line orange
Gray sank into the dashed grid lines; --eat orange stands apart from
today's blue and the average's teal in both themes.
2026-07-15 13:24:55 +00:00
Alexander Heldt a1a6ec8720 Give the Sleep trend average line its own color
All three blue lines were hard to tell apart; the N-day average is now
teal (the --weight token) and slightly more opaque, while the projected
tail stays blue on purpose — it continues today's line.
2026-07-15 13:18:42 +00:00
Alexander Heldt 9b6ee20b07 Don't scroll to the history when a chart day is tapped
Selecting the day (and the new selected-day highlight) is enough;
the scroll jump lost your place in the charts.
2026-07-15 12:48:23 +00:00
Alexander Heldt 3e3eee678e Show a meal's grams in the history list
Same pattern as weigh-ins: "80 g · note" or just "80 g" when there's
no note.
2026-07-15 12:47:39 +00:00
Alexander Heldt 6d54ecf238 Label every 1h gridline on both sleep charts
Instead of thinning labels on dense axes, keep them all and drop the
label font to 7px past 12 steps (bars) / 16 steps (trend).
2026-07-15 12:45:53 +00:00
Alexander Heldt 83af641a12 Use 1-hour gridlines on the daily Sleep bar chart
niceAxisSleepHours drops from 2-hour to 1-hour steps and is shared with
the trend chart again (the trend-only helper it duplicated is gone).
Labels thin to every other gridline past 12 steps — this chart's plot
area is shorter than the trend's.
2026-07-15 10:56:16 +00:00
Alexander Heldt ba6e2c5e7d Make the Sleep trend chart taller with a finer 1-hour y-axis
220px viewBox (was 160) and 1-hour gridlines via a trend-specific axis
helper, so curves that run close together separate visually. Labels
drop to every other gridline past 16 steps to keep the text readable.
2026-07-15 10:49:27 +00:00
Alexander Heldt 0e24ac989d Show each curve's slept hours in the Sleep trend legend
Today, Yesterday and the N-day average chips now carry their totals
(e.g. "Yesterday 12.8h"), matching the projected chip.
2026-07-15 10:43:22 +00:00
Alexander Heldt ab0e51108c Project today's end-of-day sleep total on the Sleep trend chart
A dashed tail continues today's line from now to midnight by adding the
increments the N-day average curve makes over the same stretch, so the
projection follows the usual daily rhythm instead of extrapolating the
current rate (which overshoots right after a long night). The legend
shows the projected total; with no history there's no average and no
projection.
2026-07-15 10:36:22 +00:00
Alexander Heldt cebe923d68 Highlight the selected day in the per-day charts
A soft accent band behind the selected day's bars/cells (sleep, counts,
food, training heatmap) — visible even when the day's values are zero —
plus an accent-colored day label, and an accent ring on the selected
row of the sleep timeline. The band tracks the day picker, so tapping
into a chart immediately shows which slice you landed on.
2026-07-15 10:24:48 +00:00
Alexander Heldt 8dc4fca4e2 Add a Sleep trend chart: cumulative sleep today vs yesterday and the recent average
Answers "is the puppy behind on sleep right now?": cumulative hours
slept sampled at each hour boundary, today's line ending at the current
moment, with yesterday and the mean of the last N days as reference
curves. N follows the 7/14/30 chart-days picker, and days with no sleep
logged are skipped so a young log doesn't drag the average down.
2026-07-15 10:18:08 +00:00
Alexander Heldt b030cfb72a Dim quick actions that don't fit the current sleep state
Asleep: everything but Sleep end fades; awake: only Sleep end fades; no
sleep history: nothing. Buttons stay clickable — it's a visual nudge for
the thumb, not a lockout, so corrections still work.
2026-07-15 10:17:20 +00:00
Alexander Heldt 7cb59a7a23 Focus a text field when the edit dialog opens, never the date input
showModal() with no explicit focus autofocuses the first control — the
date input — which on iOS pops the native calendar over the form. Focus
weight/grams/note per event type instead, like openNoteDialog does.
2026-07-15 10:16:56 +00:00
Alexander Heldt 131346a7c7 Hide dialog weight/grams fields on event types that don't use them
The JS already sets .hidden per type, but 'dialog label { display: block }'
outranks the UA [hidden] rule, so the fields rendered anyway. Add the
explicit [hidden] override, matching the pattern used elsewhere in the file.
2026-07-15 10:16:29 +00:00
Alexander Heldt b608dfc342 Show a compact year-less date in the day bar so it fits small iPhones
The native date input can't be told to drop the year, so a button face
shows e.g. "Jul 10" and the real input sits hidden behind it, still
holding the value and opening the native picker on tap.
2026-07-15 10:13:19 +00:00
Alexander Heldt cdd701f0b4 Bring back the big timer; the bar pill takes over on scroll
The big asleep/awake card returns below the frozen bar, and the bar
pill becomes its twin: both tick together, but the pill stays
visibility-hidden — slot reserved, so the bar never shifts — while the
card is on screen, appearing only once the card scrolls out of sight
(rAF-throttled scroll check against the bar's bottom edge).
2026-07-13 21:31:29 +00:00
Alexander Heldt 8981214e55 Fit the whole top bar on one row
Timer, day arrows, date picker and Today all share a single non-wrapping
row: the timer slims to 1rem, buttons and gaps tighten, arrows sit
around the picker, and a narrow-phone media query compacts further.
2026-07-13 21:26:07 +00:00
Alexander Heldt b6301156f9 Let the chart window be picked: 7, 14 or 30 days (default 7)
A 7d/14d/30d picker in the charts card sets how many days every
rolling chart covers — daily bars, sleep timeline, hour heatmap and
training consistency grid. The choice is stored per device like the
theme; headers show the current window, day labels thin out and bar
gaps tighten as the window widens, and the sleep timeline grows rows
to fit.
2026-07-13 21:20:31 +00:00
Alexander Heldt 6b12820592 Pin the timer left in the day bar with the slack before the day controls 2026-07-13 20:56:12 +00:00
Alexander Heldt cee4455651 Make the food chart y-axis much finer
The grams axis now targets ~10 segments instead of ~4, so a 240 g day
gets 25 g gridlines; steps stay round numbers at any scale.
2026-07-13 20:52:48 +00:00
Alexander Heldt 695c030f33 Make the timer pill bigger and put it first in the day bar 2026-07-13 20:50:41 +00:00
Alexander Heldt a0e084dae8 Extend the daily charts from 7 to 14 days
Sleep, daily counts and food grams now share a 14-day window like the
pattern charts. Bar gaps tighten to fit 14 columns and the x-axis
labels today plus every second day so labels don't collide; tooltips
and click-through per bar are unchanged.
2026-07-13 20:45:22 +00:00
Alexander Heldt c103082ca5 Replace the big clock with a timer pill in the frozen day bar
The full-width asleep/awake card becomes a compact colored pill next to
the day controls: state emoji plus the live counter, with "since" moved
to the tooltip. One glance at the pinned bar now gives both the day and
the current state.
2026-07-13 20:40:01 +00:00
Alexander Heldt 0009e63d23 Freeze the day picker at the top of the page
The day bar moves above everything else and sticks while scrolling, so
switching days never requires scrolling back up. A fixed strip painted
over the safe-area inset keeps content from peeking through above the
stuck bar on notched phones in standalone mode.
2026-07-13 20:39:06 +00:00
Alexander Heldt a202f3e929 Support multiple photos per event
photoId now holds one or more photo UUIDs, comma-separated. The server
never interprets the field (photos are uploaded and served individually
by UUID), so no schema change is needed and legacy single-photo events
are already valid one-element lists.

Both dialogs let you keep adding photos, previewed as thumbnails with a
per-photo remove button; the file input allows multi-select and no
longer forces the camera, so the gallery is available too. History rows
show every photo, each opening in the lightbox.
2026-07-13 20:34:07 +00:00
Alexander Heldt 3a829161e3 Track food eaten by weight in grams
Logging a meal now opens the dialog (like weigh-ins) with an optional
Amount (g) field; existing meals can get an amount via the edit dialog,
where clearing the field drops it. The overview's Meals tile shows the
day's total grams, and the weekly card gains a Food (grams) chart with
a self-scaling axis that stays hidden until any meal has an amount.

Events carry a new grams field (REAL column, auto-migrated); addEvent's
growing optional parameters are folded into an options object.
2026-07-13 20:28:53 +00:00
Alexander Heldt 68964b55e4 Add a Changelog button at the bottom of the page
Opens a dialog listing the loaded build's full changelog, grouped by
date. Fetched cache-first through the service worker, so the list always
matches the running version; the update banner still covers what a
waiting build adds.
2026-07-12 17:36:44 +00:00
Alexander Heldt c8dceb14d4 Add CLAUDE.md with changelog upkeep reminder 2026-07-12 17:30:41 +00:00
Alexander Heldt 1c4bea51df Increase the daily counts chart y-axis granularity
1-unit gridlines up to 10, 2-unit up to 20, 5-unit beyond — previously
anything above 10 was squeezed into five segments.
2026-07-12 17:30:16 +00:00
Alexander Heldt 094af3906d Show a changelog in the update banner
changelog.json is a hand-maintained, newest-first list of user-visible
changes; it is part of the SW asset cache and the build hash, so even a
changelog-only edit rolls a new version.

When the update banner appears, the app fetches changelog.json twice:
the plain URL is answered cache-first by the old controlling worker
(the running build's copy) while a cache-busting query bypasses every
SW cache and hits the network (the waiting build's copy). Entries the
fresh copy has that the cached one lacks are exactly what the update
brings, and are listed under the banner message.
2026-07-12 17:29:32 +00:00
Alexander Heldt 2b4731185c Keep PWA builds coherent by bypassing the HTTP cache
The service worker populated a new build's cache with plain addAll(),
which the browser may satisfy from its HTTP cache — and static assets
were served without Cache-Control, so Safari's heuristic caching could
hold app.js for days. Together that could install a mixed build: a
fresh index.html whose buttons reference listeners a stale app.js never
registers.

Install now fetches assets with cache: "reload", and the server marks
all static assets no-cache (revalidation is a cheap 304; fast/offline
loads are the SW cache's job anyway).
2026-07-12 17:13:13 +00:00
Alexander Heldt ced415c3a5 Make Save the exercise dialog's default button
All three menu buttons were implicit submit buttons, so pressing Enter
(or the iOS keyboard's "Go") triggered implicit form submission against
the first one — the hidden Delete button — which closed the dialog and
silently discarded the typed exercise. Delete and Cancel are now
type="button", leaving Save as the form's default button.
2026-07-12 17:12:59 +00:00
Alexander Heldt c1ece16347 Remove the redundant "Currently" row from the overview
The big clock already shows the asleep/awake state prominently.
2026-07-12 16:54:02 +00:00
Alexander Heldt 2e817a086d Add training tracking: exercises with instructions, one-tap session log, consistency overview
Exercises (name + how-to note) are a new synced collection with the same
LWW/tombstone contract as events, served by POST /api/exercises/sync.
Training sessions are ordinary events (type "training") referencing an
exercise by id, so they ride the existing event sync unchanged.

The Training panel lists each exercise with last-trained / this-week /
streak stats, expandable instructions, and a one-tap Log button with the
usual undo/add-note snackbar. An exercise-by-day heatmap shows the last
14 days of consistency, and history and the daily overview count
training sessions like any other event.
2026-07-12 16:54:02 +00:00
Alexander Heldt 561b98b64f Make info panels collapsible with remembered state
Each main section with a data-panel key can now be folded by clicking its
heading (rotating chevron, keyboard-accessible via role/tabindex/aria-expanded
and Enter/Space). Collapsed keys are persisted in localStorage and restored on
load, so the layout stays how you left it across reloads.

State is device-global like the theme (a single non-namespaced key, not
per-user data), and only collapsed panels are stored — so any panel added
later defaults to open.
2026-07-10 17:03:29 +00:00
Alexander Heldt f4ce7dcb54 Add sleep timeline and by-hour heatmaps
Two new charts over the last 14 days, in the existing hand-rolled SVG style:

- "When he sleeps": an actogram with one row per day (oldest at top, Today
  at bottom) and a midnight-to-midnight track with sleep shaded. Sleep
  windows are clipped per day so a night crossing midnight splits across two
  rows, and today's open sleep runs to now. Tapping a row selects that day.
- "By hour of day": pee/poo/meal frequency as three 24-cell heatmap rows,
  each cell shaded by how often that event lands in that hour — surfacing
  daily rhythm the median gap can't show.

Both re-render on the one-minute tick so the current day keeps filling in.
2026-07-10 17:03:29 +00:00
Alexander Heldt 16799ad7c7 Render sw.js with a per-build asset hash so updates are detected
The update banner only fires when the browser sees sw.js change, but the
cache name was a hand-bumped constant — so a deploy that touched only
app.js/index.html/style.css left sw.js byte-identical, no new worker
installed, and the banner never showed (and cached assets never refreshed).

Have the server render sw.js at serve time, substituting a __BUILD_HASH__
placeholder with a SHA-256 over the assets the worker caches (index.html,
style.css, app.js, manifest.json, icon.svg). Any asset change now yields a
new cache name and a byte-different sw.js, which is exactly the signal that
makes the browser install a new worker. The hash is memoised and only
recomputed when a file's size/modtime changes, so it needs no server
restart. Served unsubstituted (dev/static host), sw.js stays a valid constant.
2026-07-10 12:55:01 +00:00
Alexander Heldt d267ebef86 Log an event that happened "now" in one tap
Logging a quick pee/poo/meal/sleep boundary took two taps: open the note
dialog, then Save. Make a single tap on a quick-action log the event
immediately at the current time.

Non-weight quick actions now call addEvent(type, "", Date.now()) directly
and show a brief snackbar with Undo (deletes the just-added event) and Add
note (opens the edit dialog for it), so notes, photos and custom times are
never lost. Weigh-ins still open the dialog since they need a value.
addEvent now returns the created event so the snackbar can reference it.
2026-07-10 12:44:41 +00:00
Alexander Heldt 01ad078b2a Stamp "now" events to the second, not the minute
The time input has no seconds and fromDateTimeInputs floors to :00, so an
event logged at "now" was stored up to ~59s in the past — the big-clock
counter would jump to e.g. "0:40" the instant a sleep boundary was logged.

Track whether the user has actually touched the date/time fields (reset in
openNoteDialog and the "Now" button, set on manual change/input). When
untouched, noteDialogAt() returns the exact Date.now(); an explicitly
picked time is still parsed from the inputs (minute precision is fine there).
2026-07-10 12:36:30 +00:00
Alexander Heldt 8f4034ef47 Fix "Currently" row disagreeing with the big clock about sleep state
The "Currently" row derived its state from isCurrentlyAsleep() (ascending
stable sort, then fold) while the big clock used currentSleepState()
(max-by-`at` with a strict >). When two sleep boundary events shared the
same `at`, the two broke the tie differently, so the row could show
"Asleep" during a wake window while the clock said "Awake".

Make both read from the single currentSleepState() source, and give it a
deterministic tie-breaker: for equal `at`, the later updatedAt (most
recently logged boundary) wins. Remove the now-unused isCurrentlyAsleep().
2026-07-10 12:33:00 +00:00
Alexander Heldt dbcac0653e Prompt to reload when a new version is available
The service worker used to skipWaiting() on install and claim clients on
activate, so a new build's assets swapped in silently and a long-open tab
kept running stale JS. Switch to the standard update flow: the worker now
waits until the page sends it a SKIP_WAITING message, and the page shows a
"A new version is available — Reload / Later" banner when a new worker
reaches "installed" while one is already controlling the tab (that
controller check suppresses the first-install prompt).

Reload posts SKIP_WAITING and reloads on controllerchange (guarded against
a reload loop and against the initial clients.claim on a fresh install);
Later dismisses until the next update. Since browsers only auto-check on
navigation, also poll registration.update() hourly and on visibilitychange.
Bump the cache to v9 so the old cache is cleaned up on activate.
2026-07-10 12:30:30 +00:00
Alexander Heldt 9c0427a1ec Add light/dark mode toggle in settings
Settings gains a "Dark mode" switch. Theme preference is device-global
(localStorage), independent of accounts. With no explicit choice the app keeps
following the OS via prefers-color-scheme; picking a mode sets data-theme on
<html>, which the CSS treats as an override (attribute selector beats the media
query). A tiny <head> script applies a saved choice before first paint to avoid
a light/dark flash. Toggling previews live, independent of Save/Cancel.

Bumps the service-worker cache. Verified in a headless-browser run: default
follows OS, enabling dark swaps the palette, the choice persists across reload,
and toggling back restores light.
2026-07-09 19:41:30 +00:00
Alexander Heldt 5c016ca49e Add self-service account deletion
Settings → Delete account removes the signed-in account and everything it
owns. DELETE /api/me re-checks the password (guarding an unattended session),
then wipes the user's events, config, sessions and user row in one transaction
and removes their photos/<user_id>/ directory. The client clears the account's
local cache and returns to the login screen.

Bumps the service-worker cache so clients pick up the new UI.

Verified: wrong password is rejected (401, data intact); correct password
returns 204, invalidates the session, drops all rows to zero and removes the
photo dir; the email can be re-registered afterwards. Confirmed end to end in
a headless-browser run of the Settings → delete flow.
2026-07-09 19:20:40 +00:00
Alexander Heldt acf2931fb4 Add accounts and multi-tenancy
Every event, profile and photo is now scoped to a signed-in account, so
separate people can track separate puppies on one server.

Server:
- users + sessions tables; bcrypt passwords; random session tokens stored
  hashed and set as an HttpOnly cookie. Middleware gates /api/* behind a
  valid session.
- register/login/logout/me endpoints. Registration requires a shared invite
  code (-invite-code / PUPPY_INVITE_CODE); empty disables it.
- events, config and photos are keyed by user_id; the sync upsert guards
  against cross-user overwrites and reads are scoped, so accounts are isolated.
  Photos live under photos/<user_id>/ and are only served to their owner.
- in-place schema migration adds user_id and reshapes config; legacy
  single-tenant data (including imported events.json) is parked ownerless and
  adopted by the first account to register.

Client:
- login/register gate in front of the app; the tracker only boots once the
  session check resolves. localStorage is namespaced per user.
- 401s bounce back to login; an offline reload falls back to the last cached
  session so offline-first still works. Logout clears the session and reloads.

Deployment:
- module.nix gains inviteCodeFile (secret via EnvironmentFile) and
  secureCookies options.

Verified end to end (curl + a headless-browser run of the auth flow):
isolation between accounts, invite enforcement, first-user adoption, photo
ownership, and session persistence across reload.
2026-07-09 18:20:36 +00:00
Alexander Heldt 9207aaa4aa Store events and config in SQLite
Replace the JSON-file event and config stores with a SQLite database
(modernc.org/sqlite, pure-Go so the static build keeps CGO_ENABLED=0).
Last-write-wins now rides on the upsert's WHERE clause rather than a
Go-side map compare; the sync protocol and HTTP handlers are unchanged.

On first start the server auto-imports any legacy events.json/config.json
sitting in the data dir, renaming them to *.imported. The -data flag now
points at puppy.db; photos still live on the filesystem alongside it.
2026-07-09 16:56:35 +00:00
Alexander Heldt da692d84da Show age in weight graph 2026-07-04 09:27:13 +00:00
Alexander Heldt f14a749169 Track age and weight 2026-07-04 09:20:31 +00:00
Alexander Heldt 709a051afb Add more statistics 2026-06-25 14:24:46 +00:00
Alexander Heldt 06c51bb715 Make y-axis of sleep graph more granular 2026-06-21 17:51:33 +00:00
Alexander Heldt d61e88fa29 Light of day 2026-06-21 17:41:56 +00:00