cdd701f0b4f24463948823932c568c33a97eee6d
The big asleep/awake card returns below the frozen bar, and the bar pill becomes its twin: both tick together, but the pill stays visibility-hidden — slot reserved, so the bar never shifts — while the card is on screen, appearing only once the card scrolls out of sight (rAF-throttled scroll check against the bar's bottom edge).
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
updatedAttimestamp. - Mutations (add / edit / delete) happen against
localStoragefirst, 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 onupdatedAtand 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 legacyevents.json/config.jsonsitting 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 inlocalStoragefor offline/instant paint and reconciles with the server by last-write-wins onupdatedAt. 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, andlocalStorageis 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
sessionstable and set as anHttpOnlycookie./api/*(exceptlogin/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
Securewhen 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.
Description
Languages
JavaScript
52.5%
Go
24.4%
CSS
12.4%
HTML
8.4%
Nix
2.3%