"How long after eating did he poo?" is answerable from the log, but only by reading two times off the screen and subtracting them — and the pair is often on different days, so it is rarely on screen together at all. Hold one row, hold another, and a bar along the bottom does the subtraction and keeps it until you clear it, which is what lets you change day between the two picks. Any row that is a single moment can be picked: history, notes, weigh-ins. Sleep and walk rows cannot, being spans — measuring from one would need a rule about which end, and a rule you have to remember is worse than the feature. The picks are a module-level variable rather than storage. A measurement is a question you are asking now, not a setting; but module-level is also what carries it through the re-render a background sync causes every minute, which would otherwise wipe a half-made measurement. Ids that stop resolving — deleted here, tombstoned by another device — leave the pick on the next render instead of lingering as half a pair. Two additions beyond what was asked. Holding a picked row unpicks it: that is not a third selection but an undo of one, and without it a mis-press costs a clear. And the reading is ordered by time rather than by which was pressed first, so it is always chronological and never negative — pressing upward through a log is the natural way to read it. The press mechanics are all load-bearing: a finger that travels is a scroll and cancels, a fired press swallows the click that would otherwise also open the edit dialog, and the platform's own long-press menu is suppressed. That last part needs user-select: none on the rows, which costs the ability to select a note's text to copy. Worth stating plainly — it is a real loss, taken because holding a row now means something else. checks/extract.mjs gained getters for mutable bindings while writing the checks for this. It only ever returned a let's value at load time, so measurePick went stale the moment the code reassigned it and the checks were quietly asserting against a snapshot. Any future check reading a mutable binding would have hit the same thing.
410 lines
22 KiB
Markdown
410 lines
22 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. They are frozen at the top on every tab, and
|
|
they are the only place a timer appears. There used to be a big card at the
|
|
top of Today as well, with the pills standing by until it scrolled out of
|
|
sight; a timer you have to scroll to, on one tab in five, is not doing the
|
|
job a timer is for. Having one place to render them is also what lets them
|
|
carry seconds — they are the display now, not a summary of one.
|
|
- **The day picker is a month grid of the app's own**, not the browser's. The
|
|
native one is a sheet covering the screen, and the reason to change day is to
|
|
see what the figures did on it — so this is a small panel under the bar, with
|
|
the overview still visible and updating as you move. `←` and `→` stay in the
|
|
bar for the common ±1 day; the grid handles jumps and carries *Today*, which
|
|
is what freed the width to fit two timers on one row. The hidden
|
|
`<input type="date">` remains the value everything reads; only its own picker
|
|
is no longer opened.
|
|
- The bar can still wrap, and does below about 300px. Its height changes when
|
|
it does and the tab bar sticks to that height, which is why `--day-bar-h` is
|
|
kept current by a `ResizeObserver` rather than measured once.
|
|
- **Long-press two event rows to measure between them.** "How long after eating
|
|
did he poo?" is answerable from the log, but only by reading two times off the
|
|
screen and subtracting — and the pair is often on different days, so it is
|
|
rarely on screen together. A bar along the bottom holds the gap until you
|
|
clear it, so changing day mid-measurement is fine. Any row that is one event
|
|
at one moment can be picked: history, notes, weigh-ins. Sleep and walk rows
|
|
cannot, being spans rather than moments. A third pick is refused while two are
|
|
held; pressing a picked row unpicks it. The picks live in a variable rather
|
|
than `localStorage` — a measurement is a question you are asking now, not a
|
|
setting — but being module-level is what carries them through the re-render a
|
|
background sync causes every minute. Long-press has no keyboard equivalent, so
|
|
this is touch and mouse only.
|
|
- Each tab is a `.tab-panel` wrapper around the existing sections. The
|
|
**wrapper** is what gets hidden, never the sections: `walk-timeline` and
|
|
`walk-trend` carry their own `hidden`, set by `renderWalkPatterns` once a walk
|
|
exists, and hiding them directly would clobber it.
|
|
- `render()` still draws every panel on every pass, including the tabs you
|
|
can't see. Nothing measures layout — the charts scale through their `viewBox`
|
|
— so drawing into a hidden wrapper is safe, and it means a tab is never
|
|
briefly stale when you arrive on it.
|
|
- Tapping a panel's heading still folds it away, remembered across reloads, and
|
|
composes with tabs: tabs group, folding tunes what shows within a group. The
|
|
chosen tab is remembered the same way (device-global, like the theme).
|
|
- **Back returns to Today**, from any tab, in one press; a second press leaves
|
|
the app. Exactly one history entry is ever live — armed on leaving Today and
|
|
spent on returning, whether that return came from the back button or from
|
|
tapping the tab. An entry per switch is what a browser does unaided, and is
|
|
why tabbed apps get a reputation for trapping you: flick between tabs fifteen
|
|
times and it takes fifteen presses to escape. Two presses, always, from
|
|
anywhere.
|
|
|
|
## Layout
|
|
|
|
```
|
|
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
|
|
├── checks/ # frontend checks (see Checks); `node checks/run.mjs`
|
|
│ ├── run.mjs # runs every suite, exits non-zero on a failure
|
|
│ ├── extract.mjs # pulls declarations out of app.js so checks run real code
|
|
│ ├── assert.mjs
|
|
│ ├── excluded-days.mjs
|
|
│ ├── calendar.mjs
|
|
│ └── layout.mjs
|
|
└── src/ # the web app
|
|
├── index.html
|
|
├── app.js
|
|
├── style.css
|
|
├── sw.js
|
|
├── manifest.json
|
|
├── changelog.json
|
|
├── icon.svg
|
|
└── icon-180.png, icon-192.png, icon-512.png
|
|
```
|
|
|
|
## Checks
|
|
|
|
```sh
|
|
nix develop -c sh -c 'cd server && go test ./...' # the server
|
|
nix develop -c node checks/run.mjs # the frontend
|
|
```
|
|
|
|
The server has `go test`; `checks/` is the other half. The frontend has no
|
|
build step and no test framework, so these are plain scripts with no
|
|
dependencies beyond the `nodejs` already in the devShell. They cover the things
|
|
that are invisible until they bite:
|
|
|
|
- **Arithmetic behind the charts** — what a day marked *not counted* does and
|
|
does not take out of the numbers, and the month grid's week starts, leap
|
|
years and month boundaries.
|
|
- **Structure** — that every panel still sits in exactly one tab (losing one
|
|
while shuffling tabs is silent), that the stylesheet's braces and comments
|
|
balance, that the global `[hidden]` rule is still there.
|
|
- **Width budgets** — whether the tab labels and the two day-bar timers still
|
|
fit the phones people use.
|
|
|
|
Two things worth knowing about them. They **extract the real functions out of
|
|
`src/app.js`** rather than copying them, so a check cannot quietly go on
|
|
testing a stale copy — rename a function and `checks/extract.mjs` throws by
|
|
name. And the width figures are **estimates, not measurements**: there is no
|
|
browser in the loop, so the layout numbers are read out of `style.css` and the
|
|
text is sized from per-character advances, with a couple of pixels of headroom
|
|
demanded because the estimate is only good to a few percent. They will catch a
|
|
sixth tab or a longer label; they will not settle a two-pixel question. A real
|
|
phone is still the arbiter of anything visual.
|
|
|
|
## Run locally
|
|
|
|
```sh
|
|
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.
|
|
- **Marking a day is not the same as deleting its events**, and only two panels
|
|
are handed a filtered list (by-hour and training, which bucket individual
|
|
events and care about neither day boundaries nor spans). The rest take the
|
|
whole log and exclude days themselves, because three things break if the
|
|
events simply go: "how long since the last pee" is a question about *now* and
|
|
answered from the wrong event; the trend curve for the day you are *looking
|
|
at* collapses to zero; and a nap from 23:00 on a marked day to 07:00 on the
|
|
next loses its `sleep-start`, leaving a dangling `sleep-end` and costing the
|
|
next day — which isn't marked — seven hours it really slept.
|
|
- **What keeps counting** is weight and notes. A weigh-in and a vet note are
|
|
records of fact, not behaviour a sparse logger distorts, so they stay on the
|
|
weight curve and in the Notes log.
|
|
- **Charts keep the day's slot**, drawn as a hatch rather than a bar. Dropping
|
|
it would make consecutive bars stop being consecutive days, and an empty bar
|
|
would read as "the puppy barely slept" — the exact misreading being fixed.
|
|
- **Gaps that reach across a marked day are discarded, not measured.** With the
|
|
day's events gone, Tuesday's last pee sits next to Thursday's first, and
|
|
subtracting invents a thirty-hour gap that would blow out the Timing panel's
|
|
"longest" far worse than the sparse day did. Sleep and walk durations need no
|
|
such care — `sleepMsInRange` / `walkMsInRange` already clip to the day being
|
|
measured, so a nap running in from a marked day contributes only its counted
|
|
part.
|
|
- **Owner-only.** A guest can't decide their own thin day shouldn't count, nor
|
|
take a good one out of the averages; the server drops `day-excluded` events
|
|
arriving on a guest session and the client hides the control.
|
|
|
|
## Guest links
|
|
|
|
A dog sitter needs to log a pee; they do not need your password. **Settings →
|
|
Guest access** mints a link that does exactly the first thing.
|
|
|
|
- **It is a session, not an account.** Opening `/guest/<token>` mints an ordinary
|
|
session row against *your* `user_id`, tagged with the link it came from. Every
|
|
data path downstream — sync, photos, the profile — is scoped by `user_id` as
|
|
before, so a guest simply is you as far as the data is concerned. Only the
|
|
capability checks differ.
|
|
- **What a guest gets.** The whole app to read: every panel, every chart, all
|
|
history. They can log new events freely, and edit or delete the ones they
|
|
logged themselves. What they don't get is anything under Settings that belongs
|
|
to the account — the puppy profile, the pedigree id, reminders, other guest
|
|
links, and deleting the account. Those routes are behind `requireOwner` and
|
|
403 for a guest; the client hides the matching UI. The two device-local
|
|
preferences (dark mode, confetti) stay, since they are the guest's own browser
|
|
and not your account.
|
|
- **A guest cannot change your logs.** The upsert in `Store.sync` only lets a
|
|
guest update rows carrying their own link's id, so a sitter can fix up their
|
|
own entries and cannot rewrite or delete a single one of yours — including
|
|
everything logged before guest links existed. The check is on the link *id*,
|
|
not its label, because two links can easily both be called "Sitter". You keep
|
|
full control either way and can edit anything on your own account, theirs
|
|
included. The exercise library is the owner's for the same reason: a guest
|
|
logs training sessions against it but the server drops any exercise a guest
|
|
sends. In the app a guest opening someone else's entry gets a read-only view
|
|
rather than a form that would throw away what they typed.
|
|
- **It expires, and you can revoke it.** You pick the last day the link should
|
|
work; it stops at the end of that day in your own timezone. Sessions minted
|
|
from a link are capped at the link's own expiry, so one can never outlive it,
|
|
and every request re-checks that the link is still live — so revoking kicks
|
|
whoever is already using it out on their very next request, not whenever their
|
|
session happens to lapse. Revoking also deletes those session rows outright.
|
|
- **The URL stays available.** Settings lists each live link by label, expiry
|
|
and when it was last used, with the URL and a *Copy* button, so a link can be
|
|
re-sent without minting a new one and stranding whoever holds the old. That
|
|
means the token is stored, not just its hash — a deliberate trade, and not the
|
|
one you would make for a password or a session token: a guest link grants a
|
|
subset of what the same database already holds in plaintext, so whoever can
|
|
read `puppy.db` gains little from it, and it expires and can be revoked
|
|
besides. The lookup column stays a hash; the secret sits beside it.
|
|
- **Events say who logged them.** An event created through a link carries that
|
|
link's label (badged in the History log) and its id (which is what authorises
|
|
changes). The server stamps both from the session on insert and never reads
|
|
them off the wire, so neither can be forged; both are left out of the update
|
|
path, so a later edit by anyone keeps the original attribution.
|
|
- **A link is a bearer token — serve over HTTPS.** Anyone holding the URL can
|
|
redeem it until it expires. Send it over something private, and run behind TLS
|
|
(`-secure-cookies`) as above. When a link ends, the guest's browser drops its
|
|
cached copy of your history rather than keeping it around.
|
|
|
|
## Reminders
|
|
|
|
Opt-in push notifications for the two things that are easy to lose track of:
|
|
"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.
|