# puppy-tracker A tiny offline-first PWA for tracking your puppy's sleep, meals, pees, poos, and weight. 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. - 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 └── src/ # the web app ├── index.html ├── app.js ├── style.css ├── sw.js ├── manifest.json └── icon.svg ``` ## 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. - **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: ```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"; # 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.