commit d61e88fa29707145beeb97aeb75328e00efa6584 Author: Alexander Heldt Date: Sun Jun 21 17:41:56 2026 +0000 Light of day diff --git a/README.md b/README.md new file mode 100644 index 0000000..184499f --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# puppy-tracker + +A tiny offline-first PWA for tracking your puppy's sleep, meals, pees, and poos. +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. +- Service worker bypasses cache for `/api/*` so writes always hit the server + when online; static assets are still cached for offline use. + +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 +│ └── main.go # JSON-file store, LWW sync, static file serving +└── 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 + +# Hot-iterate (data in /tmp): +nix develop -c sh -c 'cd server && go run . -static ../src -data /tmp/puppy-events.json' +``` + +## 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; + }; + } + ]; + }; + }; +} +``` + +The server runs as a `DynamicUser` systemd unit. Data is stored at +`/var/lib/puppy-tracker/events.json` via `StateDirectory`. + +## Notes + +- No auth. Intended for a home LAN. If exposing publicly, terminate TLS and + authenticate with a reverse proxy in front (Caddy / nginx / Tailscale Funnel). diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..4e41dd2 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1781074563, + "narHash": "sha256-md8WlXOlfnIeHeOScMTTHFyf2d6iaTwPl2apR5EQ3P4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "9ae611a455b90cf061d8f332b977e387bda8e1ca", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..736db27 --- /dev/null +++ b/flake.nix @@ -0,0 +1,85 @@ +{ + description = "Puppy Tracker — offline-first puppy tracking app with server-side sync."; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + let + mkStatic = pkgs: + pkgs.stdenvNoCC.mkDerivation { + pname = "puppy-tracker-static"; + version = "0.2.0"; + src = ./src; + dontConfigure = true; + dontBuild = true; + installPhase = '' + mkdir -p $out/share/puppy-tracker + cp -r ./* $out/share/puppy-tracker/ + ''; + }; + + mkServer = pkgs: + pkgs.buildGoModule { + pname = "puppy-tracker-server"; + version = "0.2.0"; + src = ./server; + vendorHash = null; # no external dependencies + # Pure-Go build for a tiny static binary. + env.CGO_ENABLED = "0"; + ldflags = [ "-s" "-w" ]; + # The binary built from `module puppy-tracker` is `puppy-tracker`, + # rename so the executable name reflects its role. + postInstall = '' + mv $out/bin/puppy-tracker $out/bin/puppy-tracker-server + ''; + meta.mainProgram = "puppy-tracker-server"; + }; + in + { + nixosModules.default = import ./module.nix self; + nixosModules.puppy-tracker = self.nixosModules.default; + } + // + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + static = mkStatic pkgs; + server = mkServer pkgs; + in + { + packages.static = static; + packages.server = server; + # `default` bundles both so `nix build` produces a runnable directory. + packages.default = pkgs.symlinkJoin { + name = "puppy-tracker"; + paths = [ server static ]; + }; + + devShells.default = pkgs.mkShell { + packages = [ pkgs.go pkgs.python3 ]; + shellHook = '' + echo "puppy-tracker dev shell" + echo " cd server && go run . -static ../src -data /tmp/puppy-events.json" + ''; + }; + + # `nix run` → start the server, serving the bundled static files, + # storing data in $XDG_DATA_HOME/puppy-tracker/events.json. + apps.default = { + type = "app"; + program = toString (pkgs.writeShellScript "puppy-tracker-run" '' + data_dir="''${XDG_DATA_HOME:-$HOME/.local/share}/puppy-tracker" + mkdir -p "$data_dir" + exec ${server}/bin/puppy-tracker-server \ + -addr "''${PUPPY_ADDR:-:8080}" \ + -static ${static}/share/puppy-tracker \ + -data "$data_dir/events.json" + ''); + meta.description = "Run puppy-tracker locally (data in $XDG_DATA_HOME/puppy-tracker)"; + }; + } + ); +} diff --git a/module.nix b/module.nix new file mode 100644 index 0000000..4cd22eb --- /dev/null +++ b/module.nix @@ -0,0 +1,87 @@ +self: { config, lib, pkgs, ... }: + +let + cfg = config.services.puppy-tracker; + staticPkg = self.packages.${pkgs.system}.static; + serverPkg = self.packages.${pkgs.system}.server; +in +{ + options.services.puppy-tracker = { + enable = lib.mkEnableOption "Puppy Tracker (offline-first puppy tracking app with sync server)"; + + address = lib.mkOption { + type = lib.types.str; + default = "0.0.0.0"; + description = "Address the server listens on."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8080; + description = "TCP port the server listens on."; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to open the configured port in the firewall."; + }; + + package = lib.mkOption { + type = lib.types.package; + default = serverPkg; + defaultText = lib.literalExpression "puppy-tracker.packages.\${system}.server"; + description = "The puppy-tracker server package."; + }; + + staticPackage = lib.mkOption { + type = lib.types.package; + default = staticPkg; + defaultText = lib.literalExpression "puppy-tracker.packages.\${system}.static"; + description = "The puppy-tracker static-site package (HTML/CSS/JS)."; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.puppy-tracker = { + description = "Puppy Tracker server"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + serviceConfig = { + ExecStart = lib.concatStringsSep " " [ + "${cfg.package}/bin/puppy-tracker-server" + "-addr ${cfg.address}:${toString cfg.port}" + "-static ${cfg.staticPackage}/share/puppy-tracker" + "-data /var/lib/puppy-tracker/events.json" + ]; + + DynamicUser = true; + StateDirectory = "puppy-tracker"; + StateDirectoryMode = "0750"; + Restart = "on-failure"; + RestartSec = "2s"; + + # Hardening + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + PrivateDevices = true; + NoNewPrivileges = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + RestrictSUIDSGID = true; + RestrictRealtime = true; + LockPersonality = true; + MemoryDenyWriteExecute = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ "@system-service" "~@privileged @resources" ]; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; + }; + }; + + networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ]; + }; +} diff --git a/result b/result new file mode 120000 index 0000000..dc76805 --- /dev/null +++ b/result @@ -0,0 +1 @@ +/nix/store/v5hihy71szviny4w4qb18jmzhc9zb98w-puppy-tracker-server-0.2.0 \ No newline at end of file diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..681a6a6 --- /dev/null +++ b/server/go.mod @@ -0,0 +1,3 @@ +module puppy-tracker + +go 1.22 diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..f43cd6a --- /dev/null +++ b/server/main.go @@ -0,0 +1,291 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "io" + "io/fs" + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "time" +) + +type Event struct { + ID string `json:"id"` + Type string `json:"type"` + At int64 `json:"at"` + Note string `json:"note"` + PhotoID string `json:"photoId,omitempty"` + UpdatedAt int64 `json:"updatedAt"` + Deleted bool `json:"deleted,omitempty"` +} + +var uuidRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +func validUUID(s string) bool { return uuidRE.MatchString(s) } + +type Store struct { + path string + mu sync.Mutex + data map[string]Event +} + +func newStore(path string) (*Store, error) { + s := &Store{path: path, data: map[string]Event{}} + if err := s.load(); err != nil { + return nil, err + } + return s, nil +} + +func (s *Store) load() error { + f, err := os.Open(s.path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + defer f.Close() + var evs []Event + if err := json.NewDecoder(f).Decode(&evs); err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + for _, e := range evs { + s.data[e.ID] = e + } + return nil +} + +// Caller must hold s.mu. +func (s *Store) saveLocked() error { + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return err + } + tmp := s.path + ".tmp" + f, err := os.Create(tmp) + if err != nil { + return err + } + evs := make([]Event, 0, len(s.data)) + for _, e := range s.data { + evs = append(evs, e) + } + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + if err := enc.Encode(evs); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +// sync merges client events into the store using last-write-wins by UpdatedAt, +// then returns the full merged set. +func (s *Store) sync(client []Event) ([]Event, error) { + s.mu.Lock() + defer s.mu.Unlock() + for _, ce := range client { + if ce.ID == "" { + continue + } + existing, ok := s.data[ce.ID] + if !ok || ce.UpdatedAt > existing.UpdatedAt { + s.data[ce.ID] = ce + } + } + if err := s.saveLocked(); err != nil { + return nil, err + } + out := make([]Event, 0, len(s.data)) + for _, e := range s.data { + out = append(out, e) + } + return out, nil +} + +type syncRequest struct { + Events []Event `json:"events"` +} + +type syncResponse struct { + Events []Event `json:"events"` + ServerNow int64 `json:"serverNow"` +} + +type cacheControlFS struct { + root http.FileSystem +} + +func (c cacheControlFS) Open(name string) (http.File, error) { return c.root.Open(name) } + +func main() { + addr := flag.String("addr", ":8080", "listen address (e.g. :8080 or 0.0.0.0:8080)") + dataPath := flag.String("data", "events.json", "path to events JSON file") + staticDir := flag.String("static", "", "directory of static files to serve") + flag.Parse() + + store, err := newStore(*dataPath) + if err != nil { + log.Fatalf("load store: %v", err) + } + + photosDir := filepath.Join(filepath.Dir(*dataPath), "photos") + if err := os.MkdirAll(photosDir, 0o755); err != nil { + log.Fatalf("mkdir photos: %v", err) + } + + mux := http.NewServeMux() + + mux.HandleFunc("/api/events/sync", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req syncRequest + if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&req); err != nil { + http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest) + return + } + merged, err := store.sync(req.Events) + if err != nil { + log.Printf("sync: %v", err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(syncResponse{ + Events: merged, + ServerNow: time.Now().UnixMilli(), + }) + }) + + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }) + + // POST /api/photos — multipart upload with form fields `id` (UUID) and + // `file` (JPEG). The client generates the ID so the event referencing + // the photo can be written before the upload round-trips. + mux.HandleFunc("/api/photos", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 15<<20) + if err := r.ParseMultipartForm(15 << 20); err != nil { + http.Error(w, "parse: "+err.Error(), http.StatusBadRequest) + return + } + id := r.FormValue("id") + if !validUUID(id) { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + file, _, err := r.FormFile("file") + if err != nil { + http.Error(w, "no file: "+err.Error(), http.StatusBadRequest) + return + } + defer file.Close() + + dstPath := filepath.Join(photosDir, id+".jpg") + tmp := dstPath + ".tmp" + dst, err := os.Create(tmp) + if err != nil { + http.Error(w, "create: "+err.Error(), http.StatusInternalServerError) + return + } + if _, err := io.Copy(dst, file); err != nil { + dst.Close() + os.Remove(tmp) + http.Error(w, "copy: "+err.Error(), http.StatusInternalServerError) + return + } + if err := dst.Close(); err != nil { + os.Remove(tmp) + http.Error(w, "close: "+err.Error(), http.StatusInternalServerError) + return + } + if err := os.Rename(tmp, dstPath); err != nil { + os.Remove(tmp) + http.Error(w, "rename: "+err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(map[string]string{"id": id}) + }) + + // GET /api/photos/ — serves the JPEG. Photos are immutable per ID + // so we mark them as long-lived; both browser and SW can cache freely. + mux.HandleFunc("/api/photos/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + id := strings.TrimPrefix(r.URL.Path, "/api/photos/") + if !validUUID(id) { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + path := filepath.Join(photosDir, id+".jpg") + f, err := os.Open(path) + if err != nil { + http.NotFound(w, r) + return + } + defer f.Close() + stat, err := f.Stat() + if err != nil { + http.Error(w, "stat: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + http.ServeContent(w, r, path, stat.ModTime(), f) + }) + + if *staticDir != "" { + fileServer := http.FileServer(http.Dir(*staticDir)) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // PWA: sw.js and manifest.json must revalidate so updates propagate. + if r.URL.Path == "/sw.js" || r.URL.Path == "/manifest.json" { + w.Header().Set("Cache-Control", "no-cache") + } + // SPA fallback: unknown paths -> index.html (so deep links work). + if !strings.HasPrefix(r.URL.Path, "/api/") { + candidate := filepath.Join(*staticDir, filepath.FromSlash(r.URL.Path)) + if r.URL.Path != "/" { + if info, err := os.Stat(candidate); err != nil || info.IsDir() { + r.URL.Path = "/" + } + } + } + fileServer.ServeHTTP(w, r) + }) + } + + srv := &http.Server{ + Addr: *addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + log.Printf("puppy-tracker listening on %s (data=%s, static=%s)", *addr, *dataPath, *staticDir) + log.Fatal(srv.ListenAndServe()) +} diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..fbe7d4c --- /dev/null +++ b/src/app.js @@ -0,0 +1,1187 @@ +(() => { + "use strict"; + + const STORAGE_KEY = "puppy-tracker:events:v1"; + const SYNC_URL = "api/events/sync"; + const SYNC_DEBOUNCE_MS = 1200; + const SYNC_POLL_MS = 60_000; + + const EVENT_LABELS = { + "sleep-start": "Sleep start", + "sleep-end": "Sleep end", + "eat": "Ate", + "pee": "Pee", + "poo": "Poo", + }; + + // ---------- photos: IndexedDB store ---------- + // Schema: object store `photos` keyed by `id`, value `{id, blob, uploaded}`. + // - Photos taken locally are written with uploaded:false and queued for upload. + // - Photos fetched from server are cached with uploaded:true (server is source-of-truth). + const PHOTO_DB = "puppy-tracker"; + const PHOTO_STORE = "photos"; + let photoDB = null; + + function openPhotoDB() { + if (photoDB) return Promise.resolve(photoDB); + return new Promise((resolve, reject) => { + const req = indexedDB.open(PHOTO_DB, 1); + req.onupgradeneeded = (e) => { + const db = e.target.result; + if (!db.objectStoreNames.contains(PHOTO_STORE)) { + db.createObjectStore(PHOTO_STORE, { keyPath: "id" }); + } + }; + req.onsuccess = () => { photoDB = req.result; resolve(photoDB); }; + req.onerror = () => reject(req.error); + }); + } + + async function putPhoto(id, blob, uploaded) { + const db = await openPhotoDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(PHOTO_STORE, "readwrite"); + tx.objectStore(PHOTO_STORE).put({ id, blob, uploaded }); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + async function getPhoto(id) { + const db = await openPhotoDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(PHOTO_STORE, "readonly"); + const req = tx.objectStore(PHOTO_STORE).get(id); + req.onsuccess = () => resolve(req.result || null); + req.onerror = () => reject(req.error); + }); + } + + async function getAllPhotos() { + const db = await openPhotoDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(PHOTO_STORE, "readonly"); + const req = tx.objectStore(PHOTO_STORE).getAll(); + req.onsuccess = () => resolve(req.result || []); + req.onerror = () => reject(req.error); + }); + } + + async function deletePhoto(id) { + const db = await openPhotoDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(PHOTO_STORE, "readwrite"); + tx.objectStore(PHOTO_STORE).delete(id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + } + + // Resize an image File to a max dimension and return a JPEG Blob. + function resizeImage(file, maxDim = 1600, quality = 0.85) { + return new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(file); + img.onload = () => { + URL.revokeObjectURL(url); + const scale = Math.min(1, maxDim / Math.max(img.naturalWidth, img.naturalHeight)); + const w = Math.max(1, Math.round(img.naturalWidth * scale)); + const h = Math.max(1, Math.round(img.naturalHeight * scale)); + const canvas = document.createElement("canvas"); + canvas.width = w; canvas.height = h; + canvas.getContext("2d").drawImage(img, 0, 0, w, h); + canvas.toBlob( + (blob) => blob ? resolve(blob) : reject(new Error("toBlob returned null")), + "image/jpeg", + quality, + ); + }; + img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("image load failed")); }; + img.src = url; + }); + } + + async function uploadPhotoBlob(id, blob) { + const form = new FormData(); + form.append("id", id); + form.append("file", blob, `${id}.jpg`); + const res = await fetch("api/photos", { method: "POST", body: form }); + if (!res.ok) throw new Error(`upload HTTP ${res.status}`); + } + + async function syncPhotos() { + if (!navigator.onLine) return; + const all = await getAllPhotos(); + for (const p of all) { + if (p.uploaded) continue; + try { + await uploadPhotoBlob(p.id, p.blob); + await putPhoto(p.id, p.blob, true); + } catch (err) { + console.warn("photo upload failed", p.id, err); + // leave queued; next sync will retry + } + } + } + + // Resolve a photoId to a displayable URL. Prefers local cache; falls back + // to the server URL (the SW will cache it transparently). Returns null if + // we have nothing and the server doesn't either. + const photoURLCache = new Map(); // id -> object URL (lifetime = page session) + async function photoSrc(id) { + if (!id) return null; + if (photoURLCache.has(id)) return photoURLCache.get(id); + const local = await getPhoto(id); + if (local && local.blob) { + const url = URL.createObjectURL(local.blob); + photoURLCache.set(id, url); + return url; + } + // Fall back to server URL — let the browser/SW cache it. Also opportunistically + // pull it into IndexedDB so cold-offline-loads still see it. + if (navigator.onLine) { + try { + const res = await fetch(`api/photos/${encodeURIComponent(id)}`); + if (res.ok) { + const blob = await res.blob(); + await putPhoto(id, blob, true); + const url = URL.createObjectURL(blob); + photoURLCache.set(id, url); + return url; + } + } catch (_) { /* fall through */ } + } + return null; + } + + // crypto.randomUUID() is only exposed in secure contexts (HTTPS / localhost), + // so over plain HTTP on the LAN we need a fallback. crypto.getRandomValues + // is available everywhere; Math.random is the last resort. + function uuid() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { + const b = new Uint8Array(16); + crypto.getRandomValues(b); + b[6] = (b[6] & 0x0f) | 0x40; // version 4 + b[8] = (b[8] & 0x3f) | 0x80; // variant 10 + const h = [...b].map(x => x.toString(16).padStart(2, "0")); + return `${h.slice(0,4).join("")}-${h.slice(4,6).join("")}-${h.slice(6,8).join("")}-${h.slice(8,10).join("")}-${h.slice(10,16).join("")}`; + } + return `x-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`; + } + + // ---------- storage ---------- + // Internal "raw" storage includes deleted tombstones; UI uses live(). + function loadAll() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + // Backfill updatedAt for events written by an older client version. + return parsed.map(e => ({ + ...e, + updatedAt: Number.isFinite(e.updatedAt) ? e.updatedAt : (e.at || Date.now()), + })); + } catch { + return []; + } + } + + function saveAll(events) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(events)); + } + + function live() { + return loadAll().filter(e => !e.deleted); + } + + function addEvent(type, note, at, photoId) { + const events = loadAll(); + const now = Date.now(); + events.push({ + id: uuid(), + type, + at: Number.isFinite(at) ? at : now, + note: note || "", + photoId: photoId || "", + updatedAt: now, + }); + saveAll(events); + scheduleSync(); + render(); + } + + function updateEvent(id, patch) { + const events = loadAll().map(e => + e.id === id ? { ...e, ...patch, updatedAt: Date.now() } : e + ); + saveAll(events); + scheduleSync(); + render(); + } + + function deleteEvent(id) { + // Soft-delete so the deletion can propagate via sync. + const events = loadAll().map(e => + e.id === id ? { ...e, deleted: true, updatedAt: Date.now() } : e + ); + saveAll(events); + scheduleSync(); + render(); + } + + // ---------- helpers ---------- + function ymd(date) { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; + } + + function startOfDay(date) { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; + } + + function endOfDay(date) { + const d = new Date(date); + d.setHours(23, 59, 59, 999); + return d; + } + + function formatTime(ts) { + return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false }); + } + + const pad2 = n => String(n).padStart(2, "0"); + function toDateInput(ts) { + const d = new Date(ts); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; + } + function toTimeInput(ts) { + const d = new Date(ts); + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; + } + function fromDateTimeInputs(dateStr, timeStr) { + if (!dateStr || !timeStr) return NaN; + const [y, mo, d] = dateStr.split("-").map(Number); + const [h, mi] = timeStr.split(":").map(Number); + const t = new Date(y, mo - 1, d, h, mi).getTime(); + return Number.isFinite(t) ? t : NaN; + } + + function formatDuration(ms) { + if (ms <= 0) return "0m"; + const totalMin = Math.round(ms / 60000); + const h = Math.floor(totalMin / 60); + const m = totalMin % 60; + if (h === 0) return `${m}m`; + return `${h}h ${m}m`; + } + + function formatRelative(ts) { + if (!ts) return "—"; + const diff = Date.now() - ts; + if (diff < 60_000) return "just now"; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) { + const h = Math.floor(diff / 3_600_000); + const m = Math.floor((diff % 3_600_000) / 60_000); + return m ? `${h}h ${m}m ago` : `${h}h ago`; + } + const d = Math.floor(diff / 86_400_000); + return `${d}d ago`; + } + + function eventsForDay(events, day) { + const from = startOfDay(day).getTime(); + const to = endOfDay(day).getTime(); + return events + .filter(e => e.at >= from && e.at <= to) + .sort((a, b) => a.at - b.at); + } + + // Calculate sleep time within a given day. Sleep intervals are derived from + // consecutive sleep-start → sleep-end pairs across the whole event log, and + // clipped to the day window. Unmatched sleep-start = ongoing sleep (clipped to now). + function sleepMsInRange(events, fromTs, toTs) { + const sorted = [...events].sort((a, b) => a.at - b.at); + const intervals = []; + let openStart = null; + for (const e of sorted) { + if (e.type === "sleep-start" && openStart === null) { + openStart = e.at; + } else if (e.type === "sleep-end" && openStart !== null) { + intervals.push([openStart, e.at]); + openStart = null; + } + } + if (openStart !== null) intervals.push([openStart, Date.now()]); + + let total = 0; + for (const [start, end] of intervals) { + const s = Math.max(start, fromTs); + const ePt = Math.min(end, toTs); + if (ePt > s) total += ePt - s; + } + return total; + } + + function isCurrentlyAsleep(events) { + const sorted = [...events].sort((a, b) => a.at - b.at); + let asleep = false; + for (const e of sorted) { + if (e.type === "sleep-start") asleep = true; + else if (e.type === "sleep-end") asleep = false; + } + return asleep; + } + + // Current state derived from the *latest* sleep event. Used by the live + // counter at the top of the page. + function currentSleepState(events) { + let latest = null; + for (const e of events) { + if (e.type !== "sleep-start" && e.type !== "sleep-end") continue; + if (!latest || e.at > latest.at) latest = e; + } + if (!latest) return { state: null, since: 0 }; + return { + state: latest.type === "sleep-start" ? "asleep" : "awake", + since: latest.at, + }; + } + + function formatCounter(ms) { + if (ms < 0) ms = 0; + const totalSec = Math.floor(ms / 1000); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + const pp = n => String(n).padStart(2, "0"); + return h > 0 ? `${h}:${pp(m)}:${pp(s)}` : `${m}:${pp(s)}`; + } + + function lastEventOfType(events, type) { + let latest = null; + for (const e of events) { + if (e.type === type && (!latest || e.at > latest.at)) latest = e; + } + return latest; + } + + // Wake windows: time between a sleep-end and the next sleep-start. The final + // sleep-end with no following sleep-start = ongoing/open wake window. + function wakeWindows(events) { + const sorted = events + .filter(e => e.type === "sleep-start" || e.type === "sleep-end") + .sort((a, b) => a.at - b.at); + const out = []; + let waking = null; + for (const e of sorted) { + if (e.type === "sleep-end") { + waking = e.at; + } else if (e.type === "sleep-start" && waking !== null) { + out.push({ start: waking, end: e.at, ongoing: false }); + waking = null; + } + } + if (waking !== null) out.push({ start: waking, end: Date.now(), ongoing: true }); + return out; + } + + // Sleep windows: each sleep-start → next sleep-end pair. An unmatched + // sleep-start = ongoing/open sleep window. + function sleepWindows(events) { + const sorted = events + .filter(e => e.type === "sleep-start" || e.type === "sleep-end") + .sort((a, b) => a.at - b.at); + const out = []; + let sleeping = null; + for (const e of sorted) { + if (e.type === "sleep-start") { + sleeping = e.at; + } else if (e.type === "sleep-end" && sleeping !== null) { + out.push({ start: sleeping, end: e.at, ongoing: false }); + sleeping = null; + } + } + if (sleeping !== null) out.push({ start: sleeping, end: Date.now(), ongoing: true }); + return out; + } + + function sleepWindowsForDay(events, day) { + const dayStart = startOfDay(day).getTime(); + const dayEnd = endOfDay(day).getTime(); + const today = ymd(new Date()) === ymd(day); + // Keep the overlap filter so windows show on every day they touch, but + // display the *actual* start/end — a sleep from 23:00 yesterday to 07:00 + // today should show as "23:00 – 07:00 (8h)", not clipped at midnight. + return sleepWindows(events) + .filter(w => w.start <= dayEnd && w.end >= dayStart) + .map(w => ({ start: w.start, end: w.end, ongoing: w.ongoing && today })); + } + + // Wake windows that overlap the given day, clipped to that day for display. + // Only mark a window as "ongoing" if the selected day is today. + function wakeWindowsForDay(events, day) { + const dayStart = startOfDay(day).getTime(); + const dayEnd = endOfDay(day).getTime(); + const today = ymd(new Date()) === ymd(day); + // Same untrimmed-times policy as sleep windows: show the real start/end + // even if part of the window falls outside the selected day. + return wakeWindows(events) + .filter(w => w.start <= dayEnd && w.end >= dayStart) + .map(w => ({ start: w.start, end: w.end, ongoing: w.ongoing && today })); + } + + // ---------- rendering ---------- + const dayPicker = document.getElementById("day-picker"); + const eventList = document.getElementById("event-list"); + const emptyState = document.getElementById("empty-state"); + const statusEl = document.getElementById("online-status"); + + function selectedDay() { + const v = dayPicker.value; + if (v) { + const [y, m, d] = v.split("-").map(Number); + return new Date(y, m - 1, d); + } + return new Date(); + } + + function renderStats(events) { + const day = selectedDay(); + const from = startOfDay(day).getTime(); + const today = ymd(new Date()) === ymd(day); + const to = today ? Date.now() : endOfDay(day).getTime(); + + const sleepMs = sleepMsInRange(events, from, to); + const awakeMs = Math.max(0, (to - from) - sleepMs); + + const dayEvents = eventsForDay(events, day); + const count = (t) => dayEvents.filter(e => e.type === t).length; + + document.getElementById("stat-sleep").textContent = formatDuration(sleepMs); + document.getElementById("stat-awake").textContent = formatDuration(awakeMs); + document.getElementById("stat-meals").textContent = count("eat"); + document.getElementById("stat-pees").textContent = count("pee"); + document.getElementById("stat-poos").textContent = count("poo"); + } + + function renderLasts(events) { + const setLast = (id, type) => { + const ev = lastEventOfType(events, type); + document.getElementById(id).textContent = ev + ? `${formatTime(ev.at)} (${formatRelative(ev.at)})` + : "—"; + }; + setLast("last-pee", "pee"); + setLast("last-poo", "poo"); + setLast("last-eat", "eat"); + + const sortedSleep = events + .filter(e => e.type === "sleep-start" || e.type === "sleep-end") + .sort((a, b) => b.at - a.at); + const lastSleep = sortedSleep[0]; + document.getElementById("last-sleep").textContent = lastSleep + ? `${EVENT_LABELS[lastSleep.type]} at ${formatTime(lastSleep.at)} (${formatRelative(lastSleep.at)})` + : "—"; + + const row = document.getElementById("currently-row"); + const currently = document.getElementById("currently"); + if (isCurrentlyAsleep(events)) { + row.hidden = false; + currently.textContent = "😴 Asleep"; + } else { + row.hidden = true; + } + } + + // Tracks the latest sleep transition so the 1-second tick can update the + // counter without re-deriving from the event log. + let bigClockState = null; // "asleep" | "awake" | null + let bigClockSince = 0; + + function renderBigClock(events) { + const card = document.getElementById("big-clock"); + const label = document.getElementById("bc-label"); + const time = document.getElementById("bc-time"); + const since = document.getElementById("bc-since"); + const { state, since: ts } = currentSleepState(events); + bigClockState = state; + bigClockSince = ts; + if (!state) { + card.hidden = true; + return; + } + card.hidden = false; + card.classList.toggle("asleep", state === "asleep"); + card.classList.toggle("awake", state === "awake"); + label.textContent = state === "asleep" ? "Asleep for" : "Awake for"; + time.textContent = formatCounter(Date.now() - ts); + since.textContent = `since ${formatTime(ts)}`; + } + + function tickBigClock() { + if (!bigClockState) return; + const time = document.getElementById("bc-time"); + if (time) time.textContent = formatCounter(Date.now() - bigClockSince); + } + + function renderWindowList(listId, emptyId, windows, ongoingLabel, extraClass) { + const list = document.getElementById(listId); + const empty = document.getElementById(emptyId); + list.innerHTML = ""; + if (windows.length === 0) { + empty.hidden = false; + return; + } + empty.hidden = true; + for (const w of windows) { + const li = document.createElement("li"); + li.className = `ww ${extraClass}` + (w.ongoing ? " ongoing" : ""); + const range = document.createElement("span"); + range.className = "ww-range"; + range.textContent = w.ongoing + ? `from ${formatTime(w.start)}` + : `${formatTime(w.start)} – ${formatTime(w.end)}`; + const dur = document.createElement("span"); + dur.className = "ww-dur"; + dur.textContent = formatDuration(w.end - w.start); + li.appendChild(range); + li.appendChild(dur); + if (w.ongoing) { + const tag = document.createElement("span"); + tag.className = "ww-tag"; + tag.textContent = ongoingLabel; + li.appendChild(tag); + } + list.appendChild(li); + } + } + + function renderSleepWindows(events) { + renderWindowList( + "sleep-list", "sleep-empty", + sleepWindowsForDay(events, selectedDay()), + "Asleep", "sleep-ww", + ); + } + + function renderWakeWindows(events) { + renderWindowList( + "wake-list", "wake-empty", + wakeWindowsForDay(events, selectedDay()), + "Awake", "wake-ww", + ); + } + + function renderHistory(events) { + const dayEvents = eventsForDay(events, selectedDay()).reverse(); + eventList.innerHTML = ""; + if (dayEvents.length === 0) { + emptyState.hidden = false; + return; + } + emptyState.hidden = true; + for (const ev of dayEvents) { + const li = document.createElement("li"); + li.className = "event"; + li.dataset.type = ev.type; + li.dataset.id = ev.id; + li.innerHTML = ` + + ${formatTime(ev.at)} + ${EVENT_LABELS[ev.type] || ev.type} + + `; + li.querySelector(".note").textContent = ev.note || ""; + li.addEventListener("click", () => openEditDialog(ev)); + + if (ev.photoId) { + const img = document.createElement("img"); + img.className = "thumb"; + img.alt = "photo"; + img.loading = "lazy"; + img.dataset.photoId = ev.photoId; + img.addEventListener("click", (e) => { + e.stopPropagation(); // don't open the edit dialog + openLightbox(ev.photoId); + }); + li.appendChild(img); + photoSrc(ev.photoId).then(url => { if (url) img.src = url; }); + } + + eventList.appendChild(li); + } + } + + // ---------- lightbox ---------- + const lightbox = document.getElementById("lightbox"); + const lightboxImg = document.getElementById("lightbox-img"); + const lightboxClose = document.getElementById("lightbox-close"); + + async function openLightbox(photoId) { + const url = await photoSrc(photoId); + if (!url) { alert("Photo not yet available (still uploading?)."); return; } + lightboxImg.src = url; + lightbox.showModal(); + } + + lightboxClose.addEventListener("click", () => lightbox.close()); + // Click on backdrop closes the lightbox too. + lightbox.addEventListener("click", (e) => { + if (e.target === lightbox) lightbox.close(); + }); + + // ---------- weekly charts ---------- + function weeklyData(events) { + const today = startOfDay(new Date()); + const now = Date.now(); + const days = []; + for (let i = 6; i >= 0; i--) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const from = startOfDay(d).getTime(); + const to = (i === 0) ? now : endOfDay(d).getTime(); + const sleepMs = sleepMsInRange(events, from, to); + const dayEvents = eventsForDay(events, d); + days.push({ + date: d, + ymd: ymd(d), + sleepHours: sleepMs / 3_600_000, + pees: dayEvents.filter(e => e.type === "pee").length, + poos: dayEvents.filter(e => e.type === "poo").length, + meals: dayEvents.filter(e => e.type === "eat").length, + }); + } + return days; + } + + function dayLabel(date, isToday) { + if (isToday) return "Today"; + return date.toLocaleDateString(undefined, { weekday: "short" }); + } + + // Pick a chart Y maximum and tick count so every tick label is a clean + // whole number (avoids 0, 0, 1, 1, 2 from rounding fractional steps). + function niceAxis(rawMax) { + if (!(rawMax > 0)) return { yMax: 1, steps: 1 }; + if (rawMax <= 4) { + const m = Math.ceil(rawMax); + return { yMax: m, steps: m }; + } + if (rawMax <= 10) { + const m = Math.ceil(rawMax / 2) * 2; + return { yMax: m, steps: m / 2 }; + } + const m = Math.ceil(rawMax / 5) * 5; + return { yMax: m, steps: 5 }; + } + + function escapeText(s) { + return String(s).replace(/[&<>"']/g, c => ( + { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c] + )); + } + + function setChartSVG(svg, parts) { + svg.innerHTML = parts.join(""); + svg.querySelectorAll(".bar[data-day]").forEach(b => { + b.addEventListener("click", () => { + dayPicker.value = b.dataset.day; + render(); + const hist = document.querySelector(".history"); + if (hist) hist.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + }); + } + + function drawSleepChart(days) { + const svg = document.getElementById("chart-sleep"); + const W = 320, H = 160; + const ML = 26, MR = 6, MT = 10, MB = 26; + const innerW = W - ML - MR; + const innerH = H - MT - MB; + + const rawMax = Math.max(...days.map(d => d.sleepHours)); + const { yMax, steps: ySteps } = niceAxis(rawMax); + + const gap = 6; + const barW = (innerW - (days.length - 1) * gap) / days.length; + + const parts = []; + for (let i = 0; i <= ySteps; i++) { + const y = MT + innerH * (1 - i / ySteps); + const v = Math.round(yMax * i / ySteps * 10) / 10; + const vText = v % 1 === 0 ? v : v.toFixed(1); + parts.push(``); + parts.push(`${vText}h`); + } + + days.forEach((d, i) => { + const isToday = i === days.length - 1; + const x = ML + i * (barW + gap); + const h = (d.sleepHours / yMax) * innerH; + const y = MT + innerH - h; + const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${d.sleepHours.toFixed(1)}h`; + parts.push( + `` + + `${escapeText(title)}` + ); + parts.push( + `` + + `${escapeText(dayLabel(d.date, isToday))}` + ); + }); + + setChartSVG(svg, parts); + } + + function drawCountsChart(days) { + const svg = document.getElementById("chart-counts"); + const W = 320, H = 180; + const ML = 22, MR = 6, MT = 10, MB = 26; + const innerW = W - ML - MR; + const innerH = H - MT - MB; + + const rawMax = Math.max(...days.flatMap(d => [d.pees, d.poos, d.meals])); + const { yMax, steps: ySteps } = niceAxis(rawMax); + + const groupGap = 6; + const innerBarGap = 2; + const groupW = (innerW - (days.length - 1) * groupGap) / days.length; + const barW = (groupW - 2 * innerBarGap) / 3; + + const parts = []; + for (let i = 0; i <= ySteps; i++) { + const y = MT + innerH * (1 - i / ySteps); + const v = Math.round(yMax * i / ySteps); + parts.push(``); + parts.push(`${v}`); + } + + const series = [ + { key: "pees", label: "Pees", cls: "bar-pee" }, + { key: "poos", label: "Poos", cls: "bar-poo" }, + { key: "meals", label: "Meals", cls: "bar-eat" }, + ]; + + days.forEach((d, i) => { + const isToday = i === days.length - 1; + const groupX = ML + i * (groupW + groupGap); + + series.forEach((s, j) => { + const val = d[s.key]; + const x = groupX + j * (barW + innerBarGap); + const h = (val / yMax) * innerH; + const y = MT + innerH - h; + const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${s.label}: ${val}`; + parts.push( + `` + + `${escapeText(title)}` + ); + }); + + parts.push( + `` + + `${escapeText(dayLabel(d.date, isToday))}` + ); + }); + + setChartSVG(svg, parts); + } + + function renderWeekly(events) { + const days = weeklyData(events); + drawSleepChart(days); + drawCountsChart(days); + } + + function renderDayBar() { + const day = selectedDay(); + const isToday = ymd(day) === ymd(new Date()); + document.getElementById("day-next").disabled = isToday; + document.getElementById("day-today").disabled = isToday; + const title = document.getElementById("overview-title"); + if (title) { + title.textContent = isToday + ? "Today's overview" + : day.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" }); + } + } + + function render() { + const events = live(); + renderDayBar(); + renderBigClock(events); + renderStats(events); + renderLasts(events); + renderSleepWindows(events); + renderWakeWindows(events); + renderWeekly(events); + renderHistory(events); + } + + // ---------- sync ---------- + let syncTimer = null; + let syncing = false; + let lastError = null; + let lastSynced = 0; + + function setStatus(state) { + statusEl.classList.remove("offline", "syncing", "error", "pending"); + if (!navigator.onLine) { + statusEl.textContent = "offline"; + statusEl.classList.add("offline"); + return; + } + switch (state) { + case "syncing": + statusEl.textContent = "syncing…"; + statusEl.classList.add("syncing"); + break; + case "error": + statusEl.textContent = "sync error"; + statusEl.classList.add("error"); + statusEl.title = lastError || ""; + break; + case "pending": + statusEl.textContent = "pending"; + statusEl.classList.add("pending"); + break; + default: + statusEl.textContent = lastSynced + ? `synced ${formatRelative(lastSynced)}` + : "synced"; + statusEl.title = ""; + } + } + + function scheduleSync() { + if (!navigator.onLine) { setStatus("pending"); return; } + setStatus("pending"); + clearTimeout(syncTimer); + syncTimer = setTimeout(sync, SYNC_DEBOUNCE_MS); + } + + // Merge server response back into local storage. Anything local with a newer + // updatedAt than the server's copy wins — that covers events the user added + // during the in-flight sync request. + function mergeServer(serverEvents) { + const localById = new Map(loadAll().map(e => [e.id, e])); + const merged = new Map(); + for (const se of serverEvents) { + if (se && se.id) merged.set(se.id, se); + } + for (const [id, le] of localById) { + const se = merged.get(id); + if (!se || (le.updatedAt || 0) > (se.updatedAt || 0)) { + merged.set(id, le); + } + } + saveAll([...merged.values()]); + } + + async function sync() { + if (syncing) return; + if (!navigator.onLine) { setStatus("pending"); return; } + syncing = true; + setStatus("syncing"); + try { + // Push queued photos first so events that reference them won't return + // 404s when other clients try to fetch. + await syncPhotos(); + + const res = await fetch(SYNC_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ events: loadAll() }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json(); + if (Array.isArray(body.events)) { + mergeServer(body.events); + lastSynced = Date.now(); + lastError = null; + render(); + } + setStatus("synced"); + } catch (err) { + lastError = err.message || String(err); + console.warn("sync failed:", lastError); + setStatus("error"); + } finally { + syncing = false; + } + } + + // ---------- dialogs ---------- + const noteDialog = document.getElementById("note-dialog"); + const noteForm = document.getElementById("note-form"); + const noteInput = document.getElementById("note-input"); + const noteDate = document.getElementById("note-date"); + const noteTime = document.getElementById("note-time"); + const noteTitle = document.getElementById("note-title"); + const notePhotoInput = document.getElementById("note-photo-input"); + const notePhotoBtn = document.getElementById("note-photo-btn"); + const notePhotoClear = document.getElementById("note-photo-clear"); + const notePhotoPreview = document.getElementById("note-photo-preview"); + let pendingType = null; + let notePhotoBlob = null; // pending blob for the dialog (not yet committed) + let notePhotoURL = null; // current preview object URL + + function clearNotePhoto() { + notePhotoBlob = null; + if (notePhotoURL) { URL.revokeObjectURL(notePhotoURL); notePhotoURL = null; } + notePhotoPreview.hidden = true; + notePhotoPreview.innerHTML = ""; + notePhotoClear.hidden = true; + notePhotoBtn.textContent = "📷 Add photo"; + notePhotoInput.value = ""; + } + + function openNoteDialog(type) { + pendingType = type; + noteInput.value = ""; + const now = Date.now(); + noteDate.value = toDateInput(now); + noteTime.value = toTimeInput(now); + noteTitle.textContent = `Log ${EVENT_LABELS[type]}`; + clearNotePhoto(); + noteDialog.showModal(); + setTimeout(() => noteInput.focus(), 50); + } + + function noteDialogAt() { + const parsed = fromDateTimeInputs(noteDate.value, noteTime.value); + return Number.isFinite(parsed) ? parsed : Date.now(); + } + + document.getElementById("note-time-now").addEventListener("click", () => { + const now = Date.now(); + noteDate.value = toDateInput(now); + noteTime.value = toTimeInput(now); + }); + + notePhotoBtn.addEventListener("click", () => notePhotoInput.click()); + notePhotoClear.addEventListener("click", () => clearNotePhoto()); + notePhotoInput.addEventListener("change", async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + notePhotoBlob = await resizeImage(file); + if (notePhotoURL) URL.revokeObjectURL(notePhotoURL); + notePhotoURL = URL.createObjectURL(notePhotoBlob); + notePhotoPreview.innerHTML = ``; + notePhotoPreview.hidden = false; + notePhotoClear.hidden = false; + notePhotoBtn.textContent = "📷 Replace photo"; + } catch (err) { + alert("Couldn't process that photo: " + err.message); + } + }); + + document.getElementById("note-save").addEventListener("click", async (e) => { + e.preventDefault(); + if (!pendingType) { noteDialog.close(); return; } + let photoId = ""; + if (notePhotoBlob) { + photoId = uuid(); + try { await putPhoto(photoId, notePhotoBlob, false); } + catch (err) { + alert("Couldn't store photo locally: " + err.message); + return; + } + } + addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), photoId); + pendingType = null; + clearNotePhoto(); + noteDialog.close(); + }); + noteForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => { + e.preventDefault(); + pendingType = null; + clearNotePhoto(); + noteDialog.close(); + }); + + // Edit dialog + const editDialog = document.getElementById("edit-dialog"); + const editForm = document.getElementById("edit-form"); + const editDate = document.getElementById("edit-date"); + const editTime = document.getElementById("edit-time"); + const editNote = document.getElementById("edit-note"); + const editDelete = document.getElementById("edit-delete"); + const editPhotoInput = document.getElementById("edit-photo-input"); + const editPhotoBtn = document.getElementById("edit-photo-btn"); + const editPhotoClear = document.getElementById("edit-photo-clear"); + const editPhotoPreview = document.getElementById("edit-photo-preview"); + let editingId = null; + let editPhotoId = ""; // current photoId for this event + let editPhotoBlob = null; // new blob chosen in this session + let editPhotoURL = null; + let editPhotoCleared = false; // user removed an existing photo + + function setEditPreviewFromURL(url) { + if (!url) { editPhotoPreview.hidden = true; editPhotoPreview.innerHTML = ""; return; } + editPhotoPreview.innerHTML = ``; + editPhotoPreview.hidden = false; + } + + function clearEditPhotoLocalState() { + editPhotoBlob = null; + if (editPhotoURL) { URL.revokeObjectURL(editPhotoURL); editPhotoURL = null; } + editPhotoInput.value = ""; + } + + async function openEditDialog(ev) { + editingId = ev.id; + editDate.value = toDateInput(ev.at); + editTime.value = toTimeInput(ev.at); + editNote.value = ev.note || ""; + editPhotoId = ev.photoId || ""; + editPhotoCleared = false; + clearEditPhotoLocalState(); + if (editPhotoId) { + const url = await photoSrc(editPhotoId); + setEditPreviewFromURL(url); + editPhotoBtn.textContent = "📷 Replace photo"; + editPhotoClear.hidden = false; + } else { + setEditPreviewFromURL(null); + editPhotoBtn.textContent = "📷 Add photo"; + editPhotoClear.hidden = true; + } + editDialog.showModal(); + } + + editPhotoBtn.addEventListener("click", () => editPhotoInput.click()); + editPhotoClear.addEventListener("click", () => { + editPhotoCleared = true; + clearEditPhotoLocalState(); + setEditPreviewFromURL(null); + editPhotoBtn.textContent = "📷 Add photo"; + editPhotoClear.hidden = true; + }); + editPhotoInput.addEventListener("change", async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + editPhotoBlob = await resizeImage(file); + if (editPhotoURL) URL.revokeObjectURL(editPhotoURL); + editPhotoURL = URL.createObjectURL(editPhotoBlob); + setEditPreviewFromURL(editPhotoURL); + editPhotoCleared = true; // a new photo supersedes any existing one + editPhotoBtn.textContent = "📷 Replace photo"; + editPhotoClear.hidden = false; + } catch (err) { + alert("Couldn't process that photo: " + err.message); + } + }); + + editForm.querySelector('button[value="save"]').addEventListener("click", async (e) => { + e.preventDefault(); + if (!editingId) { editDialog.close(); return; } + const newAt = fromDateTimeInputs(editDate.value, editTime.value); + const patch = { + at: Number.isFinite(newAt) ? newAt : undefined, + note: editNote.value.trim(), + }; + if (editPhotoBlob) { + const newId = uuid(); + try { await putPhoto(newId, editPhotoBlob, false); } + catch (err) { alert("Couldn't store photo locally: " + err.message); return; } + patch.photoId = newId; + } else if (editPhotoCleared) { + patch.photoId = ""; + } + updateEvent(editingId, patch); + editingId = null; + clearEditPhotoLocalState(); + editDialog.close(); + }); + + editForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => { + e.preventDefault(); + editingId = null; + clearEditPhotoLocalState(); + editDialog.close(); + }); + + editDelete.addEventListener("click", (e) => { + e.preventDefault(); + if (editingId && confirm("Delete this event?")) { + deleteEvent(editingId); + } + editingId = null; + clearEditPhotoLocalState(); + editDialog.close(); + }); + + // ---------- wiring ---------- + document.querySelectorAll("button.action").forEach(btn => { + btn.addEventListener("click", () => openNoteDialog(btn.dataset.type)); + }); + + dayPicker.value = ymd(new Date()); + dayPicker.addEventListener("change", render); + + function shiftSelectedDay(days) { + const d = selectedDay(); + d.setDate(d.getDate() + days); + dayPicker.value = ymd(d); + render(); + } + document.getElementById("day-prev").addEventListener("click", () => shiftSelectedDay(-1)); + document.getElementById("day-next").addEventListener("click", () => shiftSelectedDay(+1)); + document.getElementById("day-today").addEventListener("click", () => { + dayPicker.value = ymd(new Date()); + render(); + }); + + // Clicking the status pill forces an immediate sync. + statusEl.style.cursor = "pointer"; + statusEl.title = "Click to sync now"; + statusEl.addEventListener("click", () => { clearTimeout(syncTimer); sync(); }); + + window.addEventListener("online", () => { setStatus(); sync(); }); + window.addEventListener("offline", () => setStatus()); + + // Live-update relative times and (eventually) sync status text. + setInterval(() => { + const evs = live(); + renderBigClock(evs); + renderStats(evs); + renderLasts(evs); + renderSleepWindows(evs); + renderWakeWindows(evs); + renderWeekly(evs); + if (navigator.onLine && !syncing) setStatus(); + }, 60_000); + + // Big-clock counter updates once a second without re-deriving state. + setInterval(tickBigClock, 1000); + + // Periodic pull from server so other clients' changes show up. + setInterval(sync, SYNC_POLL_MS); + + // Service worker + if ("serviceWorker" in navigator) { + window.addEventListener("load", () => { + navigator.serviceWorker.register("sw.js").catch(err => console.error("SW", err)); + }); + } + + // First paint + initial sync. + setStatus(); + render(); + sync(); +})(); diff --git a/src/icon.svg b/src/icon.svg new file mode 100644 index 0000000..9dadf86 --- /dev/null +++ b/src/icon.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..3579e4f --- /dev/null +++ b/src/index.html @@ -0,0 +1,173 @@ + + + + + + + Puppy Tracker + + + + + + +
+

🐶 Puppy Tracker

+
+
+ +
+ + +
+

Log event

+
+ + + + + +
+
+ +
+ + + + +
+ +
+

Today's overview

+
+
+
Sleep
+
0h 0m
+
+
+
Awake
+
0h 0m
+
+
+
Meals
+
0
+
+
+
Pees
+
0
+
+
+
Poos
+
0
+
+
+ +
+
Last pee
+
Last poo
+
Last meal
+
Last sleep
+ +
+
+ +
+

Sleep windows

+
    +

    No sleep windows yet for this day.

    +
    + +
    +

    Wake windows

    +
      +

      No wake windows yet for this day.

      +
      + +
      +

      Last 7 days

      +
      +
      Sleep (hours)
      + +
      +
      +
      Daily counts
      + +
      + Pees + Poos + Meals +
      +
      +
      + +
      +

      History

      +
        +

        No events logged for this day.

        +
        +
        + + +
        +

        Add note

        + + +
        + + + + +
        + + + + +
        +
        + + +
        +

        Edit event

        + + +
        + + + + +
        + + + + + +
        +
        + + + + + + + + + diff --git a/src/manifest.json b/src/manifest.json new file mode 100644 index 0000000..0ca5430 --- /dev/null +++ b/src/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "Puppy Tracker", + "short_name": "Puppy", + "description": "Track your puppy's sleep, meals, and bathroom breaks.", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#f7f5ff", + "theme_color": "#7c5cff", + "icons": [ + { + "src": "icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..25f15de --- /dev/null +++ b/src/style.css @@ -0,0 +1,473 @@ +:root { + --bg: #f7f5ff; + --surface: #ffffff; + --text: #1c1b22; + --muted: #6b6b78; + --accent: #7c5cff; + --accent-soft: #ebe6ff; + --sleep: #6c8cff; + --eat: #ff9b3d; + --pee: #ffd23f; + --poo: #8a5a3b; + --danger: #d64545; + --border: #e9e6f5; + --radius: 12px; + --shadow: 0 1px 2px rgba(20, 14, 60, 0.05), 0 4px 16px rgba(20, 14, 60, 0.05); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #14131a; + --surface: #1f1e28; + --text: #ebeaf2; + --muted: #9c9bab; + --accent: #a690ff; + --accent-soft: #2a2640; + --border: #2c2a3a; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 16px rgba(0, 0, 0, 0.3); + } +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.4; +} + +body { + max-width: 720px; + margin: 0 auto; + padding: env(safe-area-inset-top, 0) 16px env(safe-area-inset-bottom, 0) 16px; +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 4px 8px; +} + +h1 { + font-size: 1.5rem; + margin: 0; +} + +h2 { + font-size: 1rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); + margin: 0 0 12px; +} + +main { + display: flex; + flex-direction: column; + gap: 24px; + padding: 16px 0 32px; +} + +section { + background: var(--surface); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 16px; +} + +.big-clock { + text-align: center; + padding: 24px 16px; +} + +.day-bar { + display: flex; + gap: 8px; + align-items: center; + padding: 8px 10px; +} +.day-bar input[type="date"] { + flex: 1; + min-width: 0; + font-variant-numeric: tabular-nums; +} +.day-bar button { + padding: 8px 14px; + flex-shrink: 0; +} +.day-bar button:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.big-clock .bc-label { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + margin-bottom: 6px; +} +.big-clock .bc-time { + font-size: 3.25rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 1.05; + letter-spacing: -0.01em; +} +.big-clock .bc-since { + margin-top: 6px; + font-size: 0.8rem; + color: var(--muted); + font-variant-numeric: tabular-nums; +} +.big-clock.asleep { background: linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--sleep) 10%, var(--surface))); } +.big-clock.asleep .bc-time { color: var(--sleep); } +.big-clock.awake { background: linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--accent) 10%, var(--surface))); } +.big-clock.awake .bc-time { color: var(--accent); } + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; +} + +button { + font: inherit; + border: none; + border-radius: var(--radius); + padding: 14px 12px; + cursor: pointer; + background: var(--accent); + color: white; + font-weight: 600; + transition: transform 0.08s ease, filter 0.15s ease; + display: inline-flex; + align-items: center; + justify-content: center; +} + +button:active { transform: scale(0.97); } +button:hover { filter: brightness(1.05); } + +button.action.sleep { background: var(--sleep); } +button.action.eat { background: var(--eat); } +button.action.pee { background: var(--pee); color: #2b240a; } +button.action.poo { background: var(--poo); } + +button.ghost { + background: transparent; + color: var(--text); + border: 1px solid var(--border); + font-weight: 500; + padding: 10px 14px; +} + +button.danger { background: var(--danger); } + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); + gap: 10px; + margin-bottom: 16px; +} + +.stat { + background: var(--accent-soft); + padding: 12px; + border-radius: var(--radius); + text-align: center; +} + +.stat-label { + font-size: 0.75rem; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.stat-value { + font-size: 1.25rem; + font-weight: 700; + margin-top: 4px; +} + +.lasts { + display: flex; + flex-direction: column; + gap: 6px; +} + +.last-row { + display: flex; + justify-content: space-between; + padding: 6px 4px; + font-size: 0.95rem; + border-bottom: 1px dashed var(--border); +} +.last-row:last-child { border-bottom: none; } +.last-row span:first-child { color: var(--muted); } + +.history-controls { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.history-controls label { color: var(--muted); font-size: 0.85rem; } + +input[type="date"], input[type="datetime-local"], textarea { + font: inherit; + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 10px; + width: 100%; +} + +textarea { resize: vertical; } + +.event-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.wake-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} +.ww { + display: flex; + align-items: center; + gap: 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 12px; +} +.ww .ww-range { + font-variant-numeric: tabular-nums; + color: var(--muted); + min-width: 120px; +} +.ww .ww-dur { + font-weight: 600; + flex: 1; +} +.ww.ongoing { + border-color: var(--accent); + background: var(--accent-soft); +} +.ww.ongoing .ww-tag { + font-size: 0.75rem; + color: var(--accent); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.ww.sleep-ww.ongoing { + border-color: var(--sleep); + background: color-mix(in srgb, var(--sleep) 14%, var(--surface)); +} +.ww.sleep-ww.ongoing .ww-tag { color: var(--sleep); } + +.event { + display: flex; + align-items: center; + gap: 10px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 10px 12px; + cursor: pointer; +} + +.event .dot { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} +.event[data-type="sleep-start"] .dot, +.event[data-type="sleep-end"] .dot { background: var(--sleep); } +.event[data-type="eat"] .dot { background: var(--eat); } +.event[data-type="pee"] .dot { background: var(--pee); } +.event[data-type="poo"] .dot { background: var(--poo); } + +.event .time { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 60px; } +.event .label { font-weight: 600; min-width: 110px; } +.event .note { color: var(--muted); font-size: 0.9rem; flex: 1; } + +.empty { + color: var(--muted); + text-align: center; + padding: 12px 0; +} + +.status-pill { + font-size: 0.75rem; + padding: 4px 10px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-weight: 600; +} +.status-pill.offline { background: #ffe0e0; color: #b03030; } +.status-pill.error { background: #ffe0e0; color: #b03030; } +.status-pill.syncing { background: #e0eaff; color: #2a4ab0; } +.status-pill.pending { background: #fff1d4; color: #8a5a00; } +@media (prefers-color-scheme: dark) { + .status-pill.offline { background: #4a1f1f; color: #ff9090; } + .status-pill.error { background: #4a1f1f; color: #ff9090; } + .status-pill.syncing { background: #1f2c4a; color: #9eb6ff; } + .status-pill.pending { background: #4a3a1f; color: #ffd28a; } +} + +dialog { + border: none; + border-radius: var(--radius); + padding: 20px; + background: var(--surface); + color: var(--text); + box-shadow: var(--shadow); + max-width: 480px; + width: calc(100% - 32px); +} + +dialog::backdrop { background: rgba(0,0,0,0.4); } + +dialog h3 { margin: 0 0 12px; } + +dialog label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 10px; } +dialog label input, dialog label textarea { margin-top: 4px; } + +dialog menu { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 0; + margin: 14px 0 0; +} + +.photo-field { + margin-top: 10px; + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} +.photo-field button { padding: 8px 12px; } +.photo-preview { + margin-top: 8px; + width: 100%; +} +.photo-preview img { + display: block; + max-width: 100%; + max-height: 240px; + border-radius: 8px; + border: 1px solid var(--border); +} + +.event .thumb { + width: 40px; + height: 40px; + border-radius: 6px; + object-fit: cover; + border: 1px solid var(--border); + flex-shrink: 0; + cursor: zoom-in; + background: var(--accent-soft); +} + +#lightbox { + background: #000; + padding: 0; + border: none; + max-width: 100vw; + max-height: 100vh; + width: auto; + height: auto; +} +#lightbox::backdrop { background: rgba(0,0,0,0.85); } +#lightbox img { + display: block; + max-width: 95vw; + max-height: 95vh; + object-fit: contain; +} +.lightbox-close { + position: absolute; + top: 8px; + right: 8px; + background: rgba(0,0,0,0.5); + color: #fff; + font-size: 1.5rem; + line-height: 1; + padding: 4px 12px 6px; + border-radius: 999px; + font-weight: 400; +} + +.time-row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} +.time-row input { flex: 1 1 120px; min-width: 0; } +.time-row button { padding: 8px 12px; } + +.chart { margin-bottom: 16px; } +.chart:last-child { margin-bottom: 0; } +.chart-title { + font-size: 0.8rem; + color: var(--muted); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.chart-svg { display: block; width: 100%; height: auto; } +.chart-svg text { fill: var(--muted); font-size: 9px; } +.chart-svg .grid { + stroke: var(--border); + stroke-dasharray: 2 3; +} +.chart-svg .bar { cursor: pointer; } +.chart-svg .bar-faded { opacity: 0.7; } +.chart-svg .bar-sleep { fill: var(--sleep); } +.chart-svg .bar-pee { fill: var(--pee); } +.chart-svg .bar-poo { fill: var(--poo); } +.chart-svg .bar-eat { fill: var(--eat); } + +.legend { + display: flex; + gap: 14px; + font-size: 0.8rem; + color: var(--muted); + margin-top: 8px; + flex-wrap: wrap; +} +.lg { display: inline-flex; align-items: center; gap: 5px; } +.lg .sw { + width: 10px; + height: 10px; + border-radius: 2px; + display: inline-block; +} +.lg.pee .sw { background: var(--pee); } +.lg.poo .sw { background: var(--poo); } +.lg.eat .sw { background: var(--eat); } diff --git a/src/sw.js b/src/sw.js new file mode 100644 index 0000000..77b2fff --- /dev/null +++ b/src/sw.js @@ -0,0 +1,75 @@ +const CACHE = "puppy-tracker-v1"; +const PHOTO_CACHE = "puppy-tracker-photos-v1"; +const ASSETS = [ + "./", + "./index.html", + "./style.css", + "./app.js", + "./manifest.json", + "./icon.svg", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE).then((cache) => cache.addAll(ASSETS)) + ); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys + .filter((k) => k !== CACHE && k !== PHOTO_CACHE) + .map((k) => caches.delete(k)) + ) + ) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + const req = event.request; + if (req.method !== "GET") return; + + const url = new URL(req.url); + + // Photos are immutable per UUID — cache-first forever and stuff every + // successful fetch into a dedicated cache so they survive between visits + // and are available offline once seen. + if (url.pathname.includes("/api/photos/")) { + event.respondWith( + caches.open(PHOTO_CACHE).then(async (cache) => { + const cached = await cache.match(req); + if (cached) return cached; + try { + const res = await fetch(req); + if (res && res.ok) cache.put(req, res.clone()); + return res; + } catch (err) { + return cached || Response.error(); + } + }) + ); + return; + } + + // Other API calls: never cache — sync must reflect live server state. + if (url.pathname.includes("/api/")) return; + + event.respondWith( + caches.match(req).then((cached) => { + if (cached) return cached; + return fetch(req) + .then((res) => { + if (res && res.ok && url.origin === self.location.origin) { + const clone = res.clone(); + caches.open(CACHE).then((c) => c.put(req, clone)); + } + return res; + }) + .catch(() => caches.match("./index.html")); + }) + ); +});