Files
puppy-tracker/README.md
T
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

301 lines
16 KiB
Markdown

# puppy-tracker
A tiny offline-first PWA for tracking your puppy's sleep, walks, meals, pees,
poos, weight, and training.
The browser is the primary client; a small Go server provides a shared
source-of-truth and sync between devices.
## How sync works
- Each event has a UUID and an `updatedAt` timestamp.
- Mutations (add / edit / delete) happen against `localStorage` first, so the
app keeps working when offline. Deletes are recorded as tombstones so they
can propagate.
- On app load, on `online`, on every mutation (debounced), and every 60 s, the
client POSTs its full event list to `/api/events/sync`. The server merges
it with its own copy using last-write-wins on `updatedAt` and returns the
merged set.
- The server keeps its copy in a SQLite database (`puppy.db`); events and the
shared profile are separate tables, and last-write-wins is enforced by the
upsert itself. On first start it auto-imports any legacy `events.json` /
`config.json` sitting alongside it, renaming them to `*.imported`.
- Service worker bypasses cache for `/api/*` so writes always hit the server
when online; static assets are still cached for offline use.
- Training exercises (name + how-to instructions) are their own synced
collection with the same contract as events (UUIDs, last-write-wins,
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.
- 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
`localStorage` for offline/instant paint and reconciles with the server by
last-write-wins on `updatedAt`. The age shown in the header (in weeks and
months) is derived from the birthday.
- 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.
## Layout
```
puppy-tracker/
├── flake.nix # packages (server, static, default), devShell, nixosModule
├── module.nix # systemd unit, StateDirectory, hardening
├── server/
│ ├── go.mod
│ ├── go.sum
│ ├── main.go # SQLite store, LWW sync, static file serving
│ ├── 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
└── src/ # the web app
├── index.html
├── app.js
├── style.css
├── sw.js
├── manifest.json
├── changelog.json
├── icon.svg
└── icon-180.png, icon-192.png, icon-512.png
```
## Run locally
```sh
nix run # http://localhost:8080, data in $XDG_DATA_HOME/puppy-tracker
PUPPY_ADDR=:9000 nix run # custom port
# Registration needs an invite code (see Accounts). Set it in the environment:
PUPPY_INVITE_CODE=letmein nix run
# Hot-iterate (data in /tmp):
nix develop -c sh -c 'cd server && go run . -static ../src -data /tmp/puppy.db -invite-code letmein'
```
## Accounts
The app is multi-tenant: each person signs in and sees only their own puppy's
events, profile and photos.
- **Sessions.** Passwords are hashed with bcrypt; login mints a random session
token stored (hashed) in the `sessions` table and set as an `HttpOnly` cookie.
`/api/*` (except `login`/`register`/`logout`) requires a valid session.
- **Registration is invite-gated.** Sign-up requires the shared secret passed via
`-invite-code` / `PUPPY_INVITE_CODE`. With no code set, registration is
disabled (existing accounts can still log in). Share the code with whoever you
want to give an account.
- **First account adopts existing data.** When accounts are introduced on a DB
that already had single-tenant data (or that imported a legacy `events.json`),
the first account to register inherits all of it — events, profile and photos.
- **Self-service deletion.** Settings → *Delete account* removes the signed-in
account and everything it owns (`DELETE /api/me`, re-confirming the password):
events, profile, sessions and the photo directory are all wiped.
- **Serve over HTTPS in production.** Session cookies are only marked `Secure`
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.
- **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 is shown once.** Only a hash of the token is stored, exactly as with
session tokens, so a leaked database yields no working links — and the app
cannot show you the URL again later. Settings lists each live link by label,
expiry and when it was last used.
- **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:
"time to sleep" and "nothing logged for a while". Turn them on per rule in
**Settings**.
- **Evaluated on the server.** A closed PWA has no timers, so the browser cannot
remind you of anything on its own. The server already holds the event log
(clients sync on every mutation), so a goroutine re-checks every enabled rule
once a minute and pushes the ones that have come 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. Each rule has its own interval and repeats at that interval while
it stays overdue.
- **Quiet while the puppy sleeps.** The event rules are suppressed whenever the
latest sleep boundary says "asleep", which is what keeps them from nagging all
night — and means an overdue rule fires promptly on waking instead. The server
derives sleep state exactly the way `currentSleepState()` does in `app.js`,
tie-break included, so both sides always agree.
- **One notification per rule.** Every push carries a `tag`, so a repeat replaces
the previous notification instead of stacking another one on the lock screen.
- **Late syncs cancel a reminder retroactively.** Rules measure from the event's
own timestamp, not from when the server heard about it, so a pee logged offline
at 03:10 and synced at 03:40 resets the clock as if it had arrived on time.
- **Web Push is implemented directly** (`server/webpush.go`): RFC 8291 message
encryption in the RFC 8188 `aes128gcm` content encoding, authorized with an
RFC 8292 VAPID token. It is stdlib-only, and checked against the RFC 8291
test vector in `webpush_test.go`. Subscriptions the push service reports as
`404`/`410` are deleted.
### Requirements
- **HTTPS.** Push needs a secure context — the same reverse proxy you need for
`secureCookies`.
- **On iOS the app must be added to the Home Screen** (16.4+). Safari tabs have
no `PushManager` at all; the app detects this and says so instead of showing a
toggle that cannot work. iOS also drops subscriptions periodically, so the
client re-subscribes and re-registers its endpoint on every launch.
- **A VAPID key.** Generated into `vapid.json` next to `puppy.db` on first start,
or supplied via `-vapid-key` / `PUPPY_VAPID_KEY`. Browsers pin this key at
subscribe time: replacing it invalidates every existing subscription. If no key
can be established the server logs a warning and comes up without reminders —
the `/api/push/*` and `/api/reminders` routes are simply not registered, which
is also how the client knows to hide the UI.
Settings has a *Send a test notification* button, which is the only practical way
to tell "never subscribed" apart from "subscribed but not delivering" — push
failures are invisible from the browser side, especially on iOS.
## Pedigree lookup
Set your dog's SKK chip or registration number in **Settings** (it rides the
synced profile, next to name and birthday). Once set, a 🌳 button appears that
opens a page rendering that dog's ancestry as a tree.
- SKK has no public API, so the server drives the interactive site the way a
browser would: it resolves the id to SKK's internal dog id, fetches the
pedigree page (7 generations per request), and follows each generation's leaves
deeper. A lookup returns the first generations immediately and keeps crawling in
the background; the client polls and fills the tree in as ancestors arrive.
- Because a deep crawl is dozens of sequential upstream requests, finished trees
are cached per dog in the `pedigree_cache` table (pedigrees don't change), and
the id→dog resolution is memoised, so a dog is only ever crawled once and repeat
opens hit SKK zero times. The client also mirrors the finished tree in
`localStorage`, so the page paints instantly and shows the last-known tree even
offline.
- The lookup is behind auth like the rest of `/api/*`; the first trace of a new
dog needs to reach SKK, but after that it works from cache (including offline).
## Use it on NixOS
In your system flake:
```nix
{
inputs.puppy-tracker.url = "path:/path/to/puppy-tracker";
outputs = { self, nixpkgs, puppy-tracker, ... }: {
nixosConfigurations.my-host = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
puppy-tracker.nixosModules.default
{
services.puppy-tracker = {
enable = true;
port = 8080;
openFirewall = true;
# Registration secret, kept out of the Nix store. The file holds:
# PUPPY_INVITE_CODE=some-shared-secret
inviteCodeFile = "/run/secrets/puppy-invite-code";
# Optional. Without it the server generates and keeps its own Web Push
# key in /var/lib/puppy-tracker. The file holds:
# PUPPY_VAPID_KEY=base64url-p256-private-key
vapidKeyFile = "/run/secrets/puppy-vapid-key";
# Enable once you terminate TLS in front of the service.
secureCookies = false;
};
}
];
};
};
}
```
The server runs as a `DynamicUser` systemd unit. Data is stored in a SQLite
database at `/var/lib/puppy-tracker/puppy.db` via `StateDirectory` (with photos
alongside it under `photos/`, and a generated `vapid.json` if no `vapidKeyFile`
is set).
## Notes
- Accounts gate access, but there is no built-in TLS. If exposing publicly,
terminate TLS with a reverse proxy in front (Caddy / nginx / Tailscale Funnel)
and set `secureCookies = true`. Without HTTPS, passwords and session cookies
travel in the clear.