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.
342 lines
18 KiB
Markdown
342 lines
18 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.
|
|
|
|
## 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. Two of them no longer fit beside the day
|
|
controls on a narrow phone, so the bar wraps; the timers and the controls are
|
|
each a group, so it wraps between them rather than stranding "Today" on a
|
|
line of its own. The bar's 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. The big card on Today shows
|
|
whichever of the two is the more immediate — the walk, when there is one,
|
|
since you are necessarily awake on a walk.
|
|
- 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
|
|
|
|
```
|
|
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 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:
|
|
"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.
|