Alexander Heldt 556e4d75a8 Measure the time between two events by long-pressing them
"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.
2026-09-21 20:53:56 +00:00
2026-06-21 17:41:56 +00:00
2026-06-21 17:41:56 +00:00

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): 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). 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

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

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.

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:

{
  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.
S
Description
No description provided
Readme
1,007 KiB
Languages
JavaScript 51.6%
Go 29%
CSS 10.7%
HTML 7.3%
Nix 1.4%