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.
218 lines
10 KiB
Markdown
218 lines
10 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 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
|
|
│ ├── 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.
|
|
|
|
## 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.
|