Alexander Heldt 0ebaa11c93 Stretch the Sleep trend y-axis above 10h
The interesting detail sits near the goal, in the upper teens of hours,
so the scale is piecewise: 0–10h shares a compressed 26% of the height
with 2h gridlines, and everything above gets the rest with 1h
gridlines. Falls back to linear while the axis is too short to split.
The caption notes the stretched axis.
2026-07-15 19:06:16 +00:00
2026-07-13 20:34:07 +00:00
2026-06-21 17:41:56 +00:00
2026-07-09 18:20:36 +00:00
2026-07-09 18:20:36 +00:00
2026-06-21 17:41:56 +00:00

puppy-tracker

A tiny offline-first PWA for tracking your puppy's sleep, 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 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
└── src/              # the web app
    ├── index.html
    ├── app.js
    ├── style.css
    ├── sw.js
    ├── manifest.json
    └── icon.svg

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.

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";
            # 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/).

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.3 MiB
Languages
JavaScript 52.5%
Go 24.4%
CSS 12.4%
HTML 8.4%
Nix 2.3%