From f14a749169bdcf19e4baa638391d28ed0268e08f Mon Sep 17 00:00:00 2001 From: Alexander Heldt Date: Sat, 4 Jul 2026 09:20:31 +0000 Subject: [PATCH] Track age and weight --- README.md | 9 +- server/main.go | 135 +++++++++++++++++++-- src/app.js | 313 ++++++++++++++++++++++++++++++++++++++++++++++++- src/index.html | 53 ++++++++- src/style.css | 45 ++++++- src/sw.js | 2 +- 6 files changed, 540 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 184499f..29f0483 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # puppy-tracker -A tiny offline-first PWA for tracking your puppy's sleep, meals, pees, and poos. +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. @@ -16,6 +17,12 @@ source-of-truth and sync between devices. merged set. - 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 shared 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. A status pill in the header shows `syncing…` / `synced 2m ago` / `pending` / `sync error` / `offline`. Tap it to force-sync. diff --git a/server/main.go b/server/main.go index f43cd6a..9e3e6bf 100644 --- a/server/main.go +++ b/server/main.go @@ -17,19 +17,98 @@ import ( ) 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"` + ID string `json:"id"` + Type string `json:"type"` + At int64 `json:"at"` + Note string `json:"note"` + PhotoID string `json:"photoId,omitempty"` + Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events + 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) } +var birthdayRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) + +func validBirthday(s string) bool { return s == "" || birthdayRE.MatchString(s) } + +// Config is the shared puppy profile (name + birthday). It lives on the host so +// every client sees the same values without configuring each device. UpdatedAt +// drives last-write-wins, mirroring how events sync. +type Config struct { + Name string `json:"name"` + Birthday string `json:"birthday"` + UpdatedAt int64 `json:"updatedAt"` +} + +type ConfigStore struct { + path string + mu sync.Mutex + cfg Config +} + +func newConfigStore(path string) (*ConfigStore, error) { + cs := &ConfigStore{path: path} + f, err := os.Open(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return cs, nil + } + return nil, err + } + defer f.Close() + if err := json.NewDecoder(f).Decode(&cs.cfg); err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + return cs, nil +} + +func (cs *ConfigStore) get() Config { + cs.mu.Lock() + defer cs.mu.Unlock() + return cs.cfg +} + +// merge applies an incoming config with last-write-wins by UpdatedAt and +// returns the resulting stored config (which the caller sends back). +func (cs *ConfigStore) merge(in Config) (Config, error) { + cs.mu.Lock() + defer cs.mu.Unlock() + if in.UpdatedAt > cs.cfg.UpdatedAt { + cs.cfg = in + if err := cs.saveLocked(); err != nil { + return cs.cfg, err + } + } + return cs.cfg, nil +} + +// Caller must hold cs.mu. +func (cs *ConfigStore) saveLocked() error { + if err := os.MkdirAll(filepath.Dir(cs.path), 0o755); err != nil { + return err + } + tmp := cs.path + ".tmp" + f, err := os.Create(tmp) + if err != nil { + return err + } + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + if err := enc.Encode(cs.cfg); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(tmp, cs.path) +} + type Store struct { path string mu sync.Mutex @@ -143,6 +222,11 @@ func main() { log.Fatalf("load store: %v", err) } + configStore, err := newConfigStore(filepath.Join(filepath.Dir(*dataPath), "config.json")) + if err != nil { + log.Fatalf("load config: %v", err) + } + photosDir := filepath.Join(filepath.Dir(*dataPath), "photos") if err := os.MkdirAll(photosDir, 0o755); err != nil { log.Fatalf("mkdir photos: %v", err) @@ -174,6 +258,43 @@ func main() { }) }) + // GET /api/config — return the shared puppy profile. + // PUT /api/config — update it (last-write-wins by updatedAt). + mux.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) { + writeConfig := func(c Config) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(c) + } + switch r.Method { + case http.MethodGet: + writeConfig(configStore.get()) + case http.MethodPut, http.MethodPost: + var in Config + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&in); err != nil { + http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest) + return + } + in.Name = strings.TrimSpace(in.Name) + if len(in.Name) > 100 { + in.Name = in.Name[:100] + } + if !validBirthday(in.Birthday) { + http.Error(w, "invalid birthday (want YYYY-MM-DD)", http.StatusBadRequest) + return + } + merged, err := configStore.merge(in) + if err != nil { + log.Printf("config save: %v", err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + writeConfig(merged) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) diff --git a/src/app.js b/src/app.js index 9dd7338..6a21c5d 100644 --- a/src/app.js +++ b/src/app.js @@ -2,6 +2,7 @@ "use strict"; const STORAGE_KEY = "puppy-tracker:events:v1"; + const CONFIG_KEY = "puppy-tracker:config:v1"; const SYNC_URL = "api/events/sync"; const SYNC_DEBOUNCE_MS = 1200; const SYNC_POLL_MS = 60_000; @@ -12,6 +13,7 @@ "eat": "Ate", "pee": "Pee", "poo": "Poo", + "weight": "Weigh-in", }; // ---------- photos: IndexedDB store ---------- @@ -198,7 +200,56 @@ return loadAll().filter(e => !e.deleted); } - function addEvent(type, note, at, photoId) { + // ---------- config (puppy name + birthday) ---------- + // The shared profile lives on the host so every client sees the same values. + // localStorage is just a cache for instant paint + offline; the server is the + // source of truth, reconciled by last-write-wins on updatedAt (see syncConfig). + function loadConfig() { + try { + const parsed = JSON.parse(localStorage.getItem(CONFIG_KEY)); + if (!parsed || typeof parsed !== "object") return { name: "", birthday: "", updatedAt: 0 }; + return { + name: parsed.name || "", + birthday: parsed.birthday || "", + updatedAt: Number.isFinite(parsed.updatedAt) ? parsed.updatedAt : 0, + }; + } catch { + return { name: "", birthday: "", updatedAt: 0 }; + } + } + + function saveConfig(cfg) { + localStorage.setItem(CONFIG_KEY, JSON.stringify(cfg)); + } + + // Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday. + // Returns null for a missing/invalid/future birthday. + function ageParts(birthday) { + if (!birthday) return null; + const [y, mo, d] = birthday.split("-").map(Number); + if (!y || !mo || !d) return null; + const birth = startOfDay(new Date(y, mo - 1, d)); + const now = startOfDay(new Date()); + if (birth > now) return null; + const days = Math.floor((now - birth) / 86_400_000); + const weeks = Math.floor(days / 7); + let months = (now.getFullYear() - birth.getFullYear()) * 12 + + (now.getMonth() - birth.getMonth()); + if (now.getDate() < birth.getDate()) months--; + if (months < 0) months = 0; + return { days, weeks, months }; + } + + function formatAge(birthday) { + const a = ageParts(birthday); + if (!a) return ""; + const wk = `${a.weeks} week${a.weeks === 1 ? "" : "s"}`; + if (a.months < 1) return `${wk} old`; + const mo = `${a.months} month${a.months === 1 ? "" : "s"}`; + return `${wk} · ${mo} old`; + } + + function addEvent(type, note, at, photoId, weight) { const events = loadAll(); const now = Date.now(); events.push({ @@ -207,6 +258,7 @@ at: Number.isFinite(at) ? at : now, note: note || "", photoId: photoId || "", + weight: Number.isFinite(weight) ? weight : undefined, updatedAt: now, }); saveAll(events); @@ -283,6 +335,11 @@ return `${h}h ${m}m`; } + // kg with up to 2 decimals, trailing zeros stripped (5.20 -> "5.2 kg"). + function formatWeight(kg) { + return `${Math.round(kg * 100) / 100} kg`; + } + function formatRelative(ts) { if (!ts) return "—"; const diff = Date.now() - ts; @@ -646,7 +703,12 @@ ${EVENT_LABELS[ev.type] || ev.type} `; - li.querySelector(".note").textContent = ev.note || ""; + const noteEl = li.querySelector(".note"); + if (ev.type === "weight" && Number.isFinite(ev.weight)) { + noteEl.textContent = ev.note ? `${formatWeight(ev.weight)} · ${ev.note}` : formatWeight(ev.weight); + } else { + noteEl.textContent = ev.note || ""; + } li.addEventListener("click", () => openEditDialog(ev)); if (ev.photoId) { @@ -859,6 +921,129 @@ drawCountsChart(days); } + // ---------- weight ---------- + // Pick a "nice" kg axis that frames the data with a little headroom rather + // than forcing 0-based (a puppy going 5→8 kg would otherwise look flat). + function niceWeightAxis(min, max) { + if (!(max > 0)) return { lo: 0, hi: 1, steps: 1 }; + if (min === max) { min = Math.max(0, min - 0.5); max = max + 0.5; } + const span = max - min; + let lo = Math.max(0, min - span * 0.15); + let hi = max + span * 0.15; + const rawStep = (hi - lo) / 4; + const mag = Math.pow(10, Math.floor(Math.log10(rawStep))); + const norm = rawStep / mag; + const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag; + lo = Math.floor(lo / step) * step; + hi = Math.ceil(hi / step) * step; + return { lo, hi, steps: Math.max(1, Math.round((hi - lo) / step)) }; + } + + function drawWeightChart(weights) { + const svg = document.getElementById("chart-weight"); + const W = 320, H = 180; + const ML = 30, MR = 8, MT = 10, MB = 24; + const innerW = W - ML - MR; + const innerH = H - MT - MB; + + if (weights.length === 0) { svg.innerHTML = ""; return; } + + const vals = weights.map(w => w.weight); + const { lo, hi, steps } = niceWeightAxis(Math.min(...vals), Math.max(...vals)); + + const t0 = weights[0].at; + const t1 = weights[weights.length - 1].at; + const tSpan = t1 - t0; + const xOf = (t) => tSpan > 0 ? ML + ((t - t0) / tSpan) * innerW : ML + innerW / 2; + const yOf = (v) => MT + innerH * (1 - (v - lo) / (hi - lo)); + + const parts = []; + for (let i = 0; i <= steps; i++) { + const v = lo + (hi - lo) * i / steps; + const y = yOf(v); + parts.push(``); + parts.push(`${Math.round(v * 10) / 10}`); + } + + if (weights.length > 1) { + const d = weights + .map((w, i) => `${i === 0 ? "M" : "L"}${xOf(w.at).toFixed(1)} ${yOf(w.weight).toFixed(1)}`) + .join(" "); + parts.push(``); + } + + weights.forEach(w => { + const title = `${new Date(w.at).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })} — ${formatWeight(w.weight)}`; + parts.push( + `` + + `${escapeText(title)}` + ); + }); + + const fmtX = (t) => new Date(t).toLocaleDateString(undefined, { month: "short", day: "numeric" }); + parts.push(`${escapeText(fmtX(t0))}`); + if (tSpan > 0) { + parts.push(`${escapeText(fmtX(t1))}`); + } + + svg.innerHTML = parts.join(""); + } + + function renderWeight(events) { + const weights = events + .filter(e => e.type === "weight" && Number.isFinite(e.weight)) + .sort((a, b) => a.at - b.at); + + const empty = document.getElementById("weight-empty"); + const latestEl = document.getElementById("weight-latest"); + const changeEl = document.getElementById("weight-change"); + const list = document.getElementById("weight-list"); + + list.innerHTML = ""; + changeEl.classList.remove("up", "down"); + + if (weights.length === 0) { + empty.hidden = false; + latestEl.textContent = "—"; + changeEl.textContent = "—"; + drawWeightChart([]); + return; + } + empty.hidden = true; + + const latest = weights[weights.length - 1]; + latestEl.textContent = formatWeight(latest.weight); + + if (weights.length >= 2) { + const d = latest.weight - weights[weights.length - 2].weight; + const rounded = Math.round(d * 100) / 100; + const arrow = d > 0 ? "▲" : d < 0 ? "▼" : "▬"; + changeEl.textContent = `${arrow} ${d > 0 ? "+" : ""}${rounded} kg`; + changeEl.classList.toggle("up", d > 0); + changeEl.classList.toggle("down", d < 0); + } else { + changeEl.textContent = "—"; + } + + // Most-recent-first log; tap a row to edit that weigh-in. + for (const w of [...weights].reverse()) { + const li = document.createElement("li"); + li.className = "ww weight-ww"; + const date = document.createElement("span"); + date.className = "ww-range"; + date.textContent = new Date(w.at).toLocaleDateString(undefined, { month: "short", day: "numeric" }); + const val = document.createElement("span"); + val.className = "ww-dur"; + val.textContent = formatWeight(w.weight); + li.appendChild(date); + li.appendChild(val); + li.addEventListener("click", () => openEditDialog(w)); + list.appendChild(li); + } + + drawWeightChart(weights); + } + function renderDayBar() { const day = selectedDay(); const isToday = ymd(day) === ymd(new Date()); @@ -872,8 +1057,20 @@ } } + function renderHeader() { + const cfg = loadConfig(); + const title = document.getElementById("app-title"); + const ageEl = document.getElementById("puppy-age"); + title.textContent = cfg.name ? `🐶 ${cfg.name}` : "🐶 Puppy Tracker"; + document.title = cfg.name ? `${cfg.name} · Puppy Tracker` : "Puppy Tracker"; + const ageText = formatAge(cfg.birthday); + ageEl.textContent = ageText; + ageEl.hidden = !ageText; + } + function render() { const events = live(); + renderHeader(); renderDayBar(); renderBigClock(events); renderStats(events); @@ -882,6 +1079,7 @@ renderSleepWindows(events); renderWakeWindows(events); renderWeekly(events); + renderWeight(events); renderHistory(events); } @@ -978,6 +1176,50 @@ } } + // ---------- config sync ---------- + // Reconcile the local config cache with the host. Whichever side has the + // newer updatedAt wins: adopt the server's copy, or push ours if it's ahead + // (e.g. edited on this device while another client hadn't changed it). This + // self-heals a failed push — the local copy stays newer and re-pushes next tick. + async function syncConfig() { + if (!navigator.onLine) return; + const local = loadConfig(); + try { + const res = await fetch("api/config"); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json(); + const server = { + name: body.name || "", + birthday: body.birthday || "", + updatedAt: Number.isFinite(body.updatedAt) ? body.updatedAt : 0, + }; + if (server.updatedAt > local.updatedAt) { + saveConfig(server); + renderHeader(); + } else if (local.updatedAt > server.updatedAt) { + await pushConfig(local); + } + } catch (err) { + console.warn("config sync failed:", err); + } + } + + async function pushConfig(cfg) { + if (!navigator.onLine) return; + const res = await fetch("api/config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(cfg), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = await res.json(); + // Adopt the server's answer if it turned out to be newer (another client won). + if (Number.isFinite(body.updatedAt) && body.updatedAt > cfg.updatedAt) { + saveConfig({ name: body.name || "", birthday: body.birthday || "", updatedAt: body.updatedAt }); + renderHeader(); + } + } + // ---------- dialogs ---------- const noteDialog = document.getElementById("note-dialog"); const noteForm = document.getElementById("note-form"); @@ -989,6 +1231,8 @@ const notePhotoBtn = document.getElementById("note-photo-btn"); const notePhotoClear = document.getElementById("note-photo-clear"); const notePhotoPreview = document.getElementById("note-photo-preview"); + const noteWeightField = document.getElementById("note-weight-field"); + const noteWeight = document.getElementById("note-weight"); let pendingType = null; let notePhotoBlob = null; // pending blob for the dialog (not yet committed) let notePhotoURL = null; // current preview object URL @@ -1010,9 +1254,12 @@ noteDate.value = toDateInput(now); noteTime.value = toTimeInput(now); noteTitle.textContent = `Log ${EVENT_LABELS[type]}`; + const isWeight = type === "weight"; + noteWeightField.hidden = !isWeight; + noteWeight.value = ""; clearNotePhoto(); noteDialog.showModal(); - setTimeout(() => noteInput.focus(), 50); + setTimeout(() => (isWeight ? noteWeight : noteInput).focus(), 50); } function noteDialogAt() { @@ -1047,6 +1294,12 @@ document.getElementById("note-save").addEventListener("click", async (e) => { e.preventDefault(); if (!pendingType) { noteDialog.close(); return; } + let weight; + if (pendingType === "weight") { + weight = parseFloat(noteWeight.value); + if (!(weight > 0)) { alert("Enter a weight in kilograms."); return; } + weight = Math.round(weight * 100) / 100; + } let photoId = ""; if (notePhotoBlob) { photoId = uuid(); @@ -1056,7 +1309,7 @@ return; } } - addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), photoId); + addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), photoId, weight); pendingType = null; clearNotePhoto(); noteDialog.close(); @@ -1079,6 +1332,8 @@ const editPhotoBtn = document.getElementById("edit-photo-btn"); const editPhotoClear = document.getElementById("edit-photo-clear"); const editPhotoPreview = document.getElementById("edit-photo-preview"); + const editWeightField = document.getElementById("edit-weight-field"); + const editWeight = document.getElementById("edit-weight"); let editingId = null; let editPhotoId = ""; // current photoId for this event let editPhotoBlob = null; // new blob chosen in this session @@ -1102,6 +1357,8 @@ editDate.value = toDateInput(ev.at); editTime.value = toTimeInput(ev.at); editNote.value = ev.note || ""; + editWeightField.hidden = ev.type !== "weight"; + editWeight.value = (ev.type === "weight" && Number.isFinite(ev.weight)) ? ev.weight : ""; editPhotoId = ev.photoId || ""; editPhotoCleared = false; clearEditPhotoLocalState(); @@ -1150,6 +1407,11 @@ at: Number.isFinite(newAt) ? newAt : undefined, note: editNote.value.trim(), }; + if (!editWeightField.hidden) { + const kg = parseFloat(editWeight.value); + if (!(kg > 0)) { alert("Enter a weight in kilograms."); return; } + patch.weight = Math.round(kg * 100) / 100; + } if (editPhotoBlob) { const newId = uuid(); try { await putPhoto(newId, editPhotoBlob, false); } @@ -1181,6 +1443,44 @@ editDialog.close(); }); + // Settings dialog (puppy name + birthday) + const settingsDialog = document.getElementById("settings-dialog"); + const settingsForm = document.getElementById("settings-form"); + const settingsName = document.getElementById("settings-name"); + const settingsBirthday = document.getElementById("settings-birthday"); + + function openSettingsDialog() { + const cfg = loadConfig(); + settingsName.value = cfg.name; + settingsBirthday.value = cfg.birthday; + settingsDialog.showModal(); + setTimeout(() => settingsName.focus(), 50); + } + + document.getElementById("settings-btn").addEventListener("click", openSettingsDialog); + + settingsForm.querySelector('button[value="save"]').addEventListener("click", async (e) => { + e.preventDefault(); + const cfg = { + name: settingsName.value.trim(), + birthday: settingsBirthday.value, + updatedAt: Date.now(), + }; + saveConfig(cfg); // cache locally for instant + offline paint + renderHeader(); + settingsDialog.close(); + try { + await pushConfig(cfg); + } catch (err) { + console.warn("config save failed:", err); + // Kept locally; syncConfig retries automatically once the host is reachable. + } + }); + settingsForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => { + e.preventDefault(); + settingsDialog.close(); + }); + // ---------- wiring ---------- document.querySelectorAll("button.action").forEach(btn => { btn.addEventListener("click", () => openNoteDialog(btn.dataset.type)); @@ -1207,12 +1507,13 @@ statusEl.title = "Click to sync now"; statusEl.addEventListener("click", () => { clearTimeout(syncTimer); sync(); }); - window.addEventListener("online", () => { setStatus(); sync(); }); + window.addEventListener("online", () => { setStatus(); sync(); syncConfig(); }); window.addEventListener("offline", () => setStatus()); // Live-update relative times and (eventually) sync status text. setInterval(() => { const evs = live(); + renderHeader(); renderBigClock(evs); renderStats(evs); renderLasts(evs); @@ -1228,6 +1529,7 @@ // Periodic pull from server so other clients' changes show up. setInterval(sync, SYNC_POLL_MS); + setInterval(syncConfig, SYNC_POLL_MS); // Service worker if ("serviceWorker" in navigator) { @@ -1240,4 +1542,5 @@ setStatus(); render(); sync(); + syncConfig(); })(); diff --git a/src/index.html b/src/index.html index 0d8546d..23189bf 100644 --- a/src/index.html +++ b/src/index.html @@ -12,8 +12,14 @@
-

🐶 Puppy Tracker

-
+
+

🐶 Puppy Tracker

+ +
+
+ +
+
@@ -31,6 +37,7 @@ + @@ -115,6 +122,26 @@ +
+

Weight

+
+
+
Latest
+
+
+
+
Since last
+
+
+
+
+
Weight (kg)
+ +
+
    +

    No weigh-ins logged yet.

    +
    +

    History

      @@ -122,6 +149,22 @@
      + +
      +

      Puppy settings

      + + + + + + +
      +
      +

      Add note

      @@ -132,6 +175,9 @@ + @@ -157,6 +203,9 @@ + diff --git a/src/style.css b/src/style.css index 50dddb9..f7de84e 100644 --- a/src/style.css +++ b/src/style.css @@ -9,7 +9,9 @@ --eat: #ff9b3d; --pee: #ffd23f; --poo: #8a5a3b; + --weight: #2bb3a3; --danger: #d64545; + --gain: #2e9e5b; --border: #e9e6f5; --radius: 12px; --shadow: 0 1px 2px rgba(20, 14, 60, 0.05), 0 4px 16px rgba(20, 14, 60, 0.05); @@ -57,6 +59,32 @@ h1 { margin: 0; } +.title { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.puppy-age { + font-size: 0.8rem; + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.header-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +.icon-btn { + padding: 6px 10px; + font-size: 1.1rem; + line-height: 1; +} + h2 { font-size: 1rem; text-transform: uppercase; @@ -156,6 +184,7 @@ 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.action.weight { background: var(--weight); } button.ghost { background: transparent; @@ -227,7 +256,7 @@ button.danger { background: var(--danger); } .history-controls label { color: var(--muted); font-size: 0.85rem; } -input[type="date"], input[type="datetime-local"], textarea { +input[type="date"], input[type="datetime-local"], input[type="text"], input[type="number"], textarea { font: inherit; background: var(--bg); color: var(--text); @@ -313,6 +342,7 @@ textarea { resize: vertical; } .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[data-type="weight"] .dot { background: var(--weight); } .event .time { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 60px; } .event .label { font-weight: 600; min-width: 110px; } @@ -459,6 +489,19 @@ dialog menu { .chart-svg .bar-pee { fill: var(--pee); } .chart-svg .bar-poo { fill: var(--poo); } .chart-svg .bar-eat { fill: var(--eat); } +.chart-svg .weight-line { stroke: var(--weight); stroke-width: 2; fill: none; } +.chart-svg .weight-dot { fill: var(--weight); } + +.weight-summary { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + margin-bottom: 16px; +} +.weight-summary .stat-value.up { color: var(--gain); } +.weight-summary .stat-value.down { color: var(--danger); } +.ww.weight-ww { cursor: pointer; } +.ww.weight-ww .ww-dur { text-align: right; } .legend { display: flex; diff --git a/src/sw.js b/src/sw.js index 77b2fff..6fec2b3 100644 --- a/src/sw.js +++ b/src/sw.js @@ -1,4 +1,4 @@ -const CACHE = "puppy-tracker-v1"; +const CACHE = "puppy-tracker-v4"; const PHOTO_CACHE = "puppy-tracker-photos-v1"; const ASSETS = [ "./",