Track age and weight

This commit is contained in:
Alexander Heldt
2026-07-04 09:20:31 +00:00
parent 709a051afb
commit f14a749169
6 changed files with 540 additions and 17 deletions
+8 -1
View File
@@ -1,6 +1,7 @@
# puppy-tracker # 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 The browser is the primary client; a small Go server provides a shared
source-of-truth and sync between devices. source-of-truth and sync between devices.
@@ -16,6 +17,12 @@ source-of-truth and sync between devices.
merged set. merged set.
- Service worker bypasses cache for `/api/*` so writes always hit the server - Service worker bypasses cache for `/api/*` so writes always hit the server
when online; static assets are still cached for offline use. 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` / A status pill in the header shows `syncing…` / `synced 2m ago` / `pending` /
`sync error` / `offline`. Tap it to force-sync. `sync error` / `offline`. Tap it to force-sync.
+128 -7
View File
@@ -17,19 +17,98 @@ import (
) )
type Event struct { type Event struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` Type string `json:"type"`
At int64 `json:"at"` At int64 `json:"at"`
Note string `json:"note"` Note string `json:"note"`
PhotoID string `json:"photoId,omitempty"` PhotoID string `json:"photoId,omitempty"`
UpdatedAt int64 `json:"updatedAt"` Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
Deleted bool `json:"deleted,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}$`) 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) } 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 { type Store struct {
path string path string
mu sync.Mutex mu sync.Mutex
@@ -143,6 +222,11 @@ func main() {
log.Fatalf("load store: %v", err) 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") photosDir := filepath.Join(filepath.Dir(*dataPath), "photos")
if err := os.MkdirAll(photosDir, 0o755); err != nil { if err := os.MkdirAll(photosDir, 0o755); err != nil {
log.Fatalf("mkdir photos: %v", err) 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) { mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok")) w.Write([]byte("ok"))
}) })
+308 -5
View File
@@ -2,6 +2,7 @@
"use strict"; "use strict";
const STORAGE_KEY = "puppy-tracker:events:v1"; const STORAGE_KEY = "puppy-tracker:events:v1";
const CONFIG_KEY = "puppy-tracker:config:v1";
const SYNC_URL = "api/events/sync"; const SYNC_URL = "api/events/sync";
const SYNC_DEBOUNCE_MS = 1200; const SYNC_DEBOUNCE_MS = 1200;
const SYNC_POLL_MS = 60_000; const SYNC_POLL_MS = 60_000;
@@ -12,6 +13,7 @@
"eat": "Ate", "eat": "Ate",
"pee": "Pee", "pee": "Pee",
"poo": "Poo", "poo": "Poo",
"weight": "Weigh-in",
}; };
// ---------- photos: IndexedDB store ---------- // ---------- photos: IndexedDB store ----------
@@ -198,7 +200,56 @@
return loadAll().filter(e => !e.deleted); 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 events = loadAll();
const now = Date.now(); const now = Date.now();
events.push({ events.push({
@@ -207,6 +258,7 @@
at: Number.isFinite(at) ? at : now, at: Number.isFinite(at) ? at : now,
note: note || "", note: note || "",
photoId: photoId || "", photoId: photoId || "",
weight: Number.isFinite(weight) ? weight : undefined,
updatedAt: now, updatedAt: now,
}); });
saveAll(events); saveAll(events);
@@ -283,6 +335,11 @@
return `${h}h ${m}m`; 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) { function formatRelative(ts) {
if (!ts) return "—"; if (!ts) return "—";
const diff = Date.now() - ts; const diff = Date.now() - ts;
@@ -646,7 +703,12 @@
<span class="label">${EVENT_LABELS[ev.type] || ev.type}</span> <span class="label">${EVENT_LABELS[ev.type] || ev.type}</span>
<span class="note"></span> <span class="note"></span>
`; `;
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)); li.addEventListener("click", () => openEditDialog(ev));
if (ev.photoId) { if (ev.photoId) {
@@ -859,6 +921,129 @@
drawCountsChart(days); 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(`<line class="grid" x1="${ML}" y1="${y}" x2="${W - MR}" y2="${y}"/>`);
parts.push(`<text x="${ML - 4}" y="${y + 3}" text-anchor="end">${Math.round(v * 10) / 10}</text>`);
}
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(`<path class="weight-line" d="${d}"/>`);
}
weights.forEach(w => {
const title = `${new Date(w.at).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })} — ${formatWeight(w.weight)}`;
parts.push(
`<circle class="weight-dot" cx="${xOf(w.at).toFixed(1)}" cy="${yOf(w.weight).toFixed(1)}" r="3.5">` +
`<title>${escapeText(title)}</title></circle>`
);
});
const fmtX = (t) => new Date(t).toLocaleDateString(undefined, { month: "short", day: "numeric" });
parts.push(`<text x="${ML}" y="${H - MB + 16}" text-anchor="start">${escapeText(fmtX(t0))}</text>`);
if (tSpan > 0) {
parts.push(`<text x="${W - MR}" y="${H - MB + 16}" text-anchor="end">${escapeText(fmtX(t1))}</text>`);
}
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() { function renderDayBar() {
const day = selectedDay(); const day = selectedDay();
const isToday = ymd(day) === ymd(new Date()); 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() { function render() {
const events = live(); const events = live();
renderHeader();
renderDayBar(); renderDayBar();
renderBigClock(events); renderBigClock(events);
renderStats(events); renderStats(events);
@@ -882,6 +1079,7 @@
renderSleepWindows(events); renderSleepWindows(events);
renderWakeWindows(events); renderWakeWindows(events);
renderWeekly(events); renderWeekly(events);
renderWeight(events);
renderHistory(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 ---------- // ---------- dialogs ----------
const noteDialog = document.getElementById("note-dialog"); const noteDialog = document.getElementById("note-dialog");
const noteForm = document.getElementById("note-form"); const noteForm = document.getElementById("note-form");
@@ -989,6 +1231,8 @@
const notePhotoBtn = document.getElementById("note-photo-btn"); const notePhotoBtn = document.getElementById("note-photo-btn");
const notePhotoClear = document.getElementById("note-photo-clear"); const notePhotoClear = document.getElementById("note-photo-clear");
const notePhotoPreview = document.getElementById("note-photo-preview"); const notePhotoPreview = document.getElementById("note-photo-preview");
const noteWeightField = document.getElementById("note-weight-field");
const noteWeight = document.getElementById("note-weight");
let pendingType = null; let pendingType = null;
let notePhotoBlob = null; // pending blob for the dialog (not yet committed) let notePhotoBlob = null; // pending blob for the dialog (not yet committed)
let notePhotoURL = null; // current preview object URL let notePhotoURL = null; // current preview object URL
@@ -1010,9 +1254,12 @@
noteDate.value = toDateInput(now); noteDate.value = toDateInput(now);
noteTime.value = toTimeInput(now); noteTime.value = toTimeInput(now);
noteTitle.textContent = `Log ${EVENT_LABELS[type]}`; noteTitle.textContent = `Log ${EVENT_LABELS[type]}`;
const isWeight = type === "weight";
noteWeightField.hidden = !isWeight;
noteWeight.value = "";
clearNotePhoto(); clearNotePhoto();
noteDialog.showModal(); noteDialog.showModal();
setTimeout(() => noteInput.focus(), 50); setTimeout(() => (isWeight ? noteWeight : noteInput).focus(), 50);
} }
function noteDialogAt() { function noteDialogAt() {
@@ -1047,6 +1294,12 @@
document.getElementById("note-save").addEventListener("click", async (e) => { document.getElementById("note-save").addEventListener("click", async (e) => {
e.preventDefault(); e.preventDefault();
if (!pendingType) { noteDialog.close(); return; } 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 = ""; let photoId = "";
if (notePhotoBlob) { if (notePhotoBlob) {
photoId = uuid(); photoId = uuid();
@@ -1056,7 +1309,7 @@
return; return;
} }
} }
addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), photoId); addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), photoId, weight);
pendingType = null; pendingType = null;
clearNotePhoto(); clearNotePhoto();
noteDialog.close(); noteDialog.close();
@@ -1079,6 +1332,8 @@
const editPhotoBtn = document.getElementById("edit-photo-btn"); const editPhotoBtn = document.getElementById("edit-photo-btn");
const editPhotoClear = document.getElementById("edit-photo-clear"); const editPhotoClear = document.getElementById("edit-photo-clear");
const editPhotoPreview = document.getElementById("edit-photo-preview"); const editPhotoPreview = document.getElementById("edit-photo-preview");
const editWeightField = document.getElementById("edit-weight-field");
const editWeight = document.getElementById("edit-weight");
let editingId = null; let editingId = null;
let editPhotoId = ""; // current photoId for this event let editPhotoId = ""; // current photoId for this event
let editPhotoBlob = null; // new blob chosen in this session let editPhotoBlob = null; // new blob chosen in this session
@@ -1102,6 +1357,8 @@
editDate.value = toDateInput(ev.at); editDate.value = toDateInput(ev.at);
editTime.value = toTimeInput(ev.at); editTime.value = toTimeInput(ev.at);
editNote.value = ev.note || ""; editNote.value = ev.note || "";
editWeightField.hidden = ev.type !== "weight";
editWeight.value = (ev.type === "weight" && Number.isFinite(ev.weight)) ? ev.weight : "";
editPhotoId = ev.photoId || ""; editPhotoId = ev.photoId || "";
editPhotoCleared = false; editPhotoCleared = false;
clearEditPhotoLocalState(); clearEditPhotoLocalState();
@@ -1150,6 +1407,11 @@
at: Number.isFinite(newAt) ? newAt : undefined, at: Number.isFinite(newAt) ? newAt : undefined,
note: editNote.value.trim(), 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) { if (editPhotoBlob) {
const newId = uuid(); const newId = uuid();
try { await putPhoto(newId, editPhotoBlob, false); } try { await putPhoto(newId, editPhotoBlob, false); }
@@ -1181,6 +1443,44 @@
editDialog.close(); 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 ---------- // ---------- wiring ----------
document.querySelectorAll("button.action").forEach(btn => { document.querySelectorAll("button.action").forEach(btn => {
btn.addEventListener("click", () => openNoteDialog(btn.dataset.type)); btn.addEventListener("click", () => openNoteDialog(btn.dataset.type));
@@ -1207,12 +1507,13 @@
statusEl.title = "Click to sync now"; statusEl.title = "Click to sync now";
statusEl.addEventListener("click", () => { clearTimeout(syncTimer); sync(); }); statusEl.addEventListener("click", () => { clearTimeout(syncTimer); sync(); });
window.addEventListener("online", () => { setStatus(); sync(); }); window.addEventListener("online", () => { setStatus(); sync(); syncConfig(); });
window.addEventListener("offline", () => setStatus()); window.addEventListener("offline", () => setStatus());
// Live-update relative times and (eventually) sync status text. // Live-update relative times and (eventually) sync status text.
setInterval(() => { setInterval(() => {
const evs = live(); const evs = live();
renderHeader();
renderBigClock(evs); renderBigClock(evs);
renderStats(evs); renderStats(evs);
renderLasts(evs); renderLasts(evs);
@@ -1228,6 +1529,7 @@
// Periodic pull from server so other clients' changes show up. // Periodic pull from server so other clients' changes show up.
setInterval(sync, SYNC_POLL_MS); setInterval(sync, SYNC_POLL_MS);
setInterval(syncConfig, SYNC_POLL_MS);
// Service worker // Service worker
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
@@ -1240,4 +1542,5 @@
setStatus(); setStatus();
render(); render();
sync(); sync();
syncConfig();
})(); })();
+51 -2
View File
@@ -12,8 +12,14 @@
</head> </head>
<body> <body>
<header> <header>
<h1>🐶 Puppy Tracker</h1> <div class="title">
<div id="online-status" class="status-pill"></div> <h1 id="app-title">🐶 Puppy Tracker</h1>
<div id="puppy-age" class="puppy-age" hidden></div>
</div>
<div class="header-actions">
<button type="button" id="settings-btn" class="ghost icon-btn" aria-label="Settings" title="Settings">⚙️</button>
<div id="online-status" class="status-pill"></div>
</div>
</header> </header>
<main> <main>
@@ -31,6 +37,7 @@
<button class="action eat" data-type="eat">🍽️ Ate</button> <button class="action eat" data-type="eat">🍽️ Ate</button>
<button class="action pee" data-type="pee">💧 Pee</button> <button class="action pee" data-type="pee">💧 Pee</button>
<button class="action poo" data-type="poo">💩 Poo</button> <button class="action poo" data-type="poo">💩 Poo</button>
<button class="action weight" data-type="weight">⚖️ Weigh-in</button>
</div> </div>
</section> </section>
@@ -115,6 +122,26 @@
</div> </div>
</section> </section>
<section class="weight">
<h2>Weight</h2>
<div class="weight-summary">
<div class="stat">
<div class="stat-label">Latest</div>
<div class="stat-value" id="weight-latest"></div>
</div>
<div class="stat">
<div class="stat-label">Since last</div>
<div class="stat-value" id="weight-change"></div>
</div>
</div>
<div class="chart">
<div class="chart-title">Weight (kg)</div>
<svg id="chart-weight" class="chart-svg" viewBox="0 0 320 180" role="img" aria-label="Weight in kilograms over time"></svg>
</div>
<ul id="weight-list" class="wake-list"></ul>
<p id="weight-empty" class="empty">No weigh-ins logged yet.</p>
</section>
<section class="history"> <section class="history">
<h2>History</h2> <h2>History</h2>
<ul id="event-list" class="event-list"></ul> <ul id="event-list" class="event-list"></ul>
@@ -122,6 +149,22 @@
</section> </section>
</main> </main>
<dialog id="settings-dialog">
<form method="dialog" id="settings-form">
<h3>Puppy settings</h3>
<label>Name
<input type="text" id="settings-name" placeholder="e.g. Rex" autocomplete="off" />
</label>
<label>Birthday
<input type="date" id="settings-birthday" />
</label>
<menu>
<button value="cancel" class="ghost">Cancel</button>
<button value="save" id="settings-save">Save</button>
</menu>
</form>
</dialog>
<dialog id="note-dialog"> <dialog id="note-dialog">
<form method="dialog" id="note-form"> <form method="dialog" id="note-form">
<h3 id="note-title">Add note</h3> <h3 id="note-title">Add note</h3>
@@ -132,6 +175,9 @@
<button type="button" id="note-time-now" class="ghost">Now</button> <button type="button" id="note-time-now" class="ghost">Now</button>
</div> </div>
</label> </label>
<label id="note-weight-field" hidden>Weight (kg)
<input type="number" id="note-weight" inputmode="decimal" step="0.01" min="0" placeholder="e.g. 5.2" />
</label>
<label>Note <label>Note
<textarea id="note-input" rows="4" placeholder="e.g. pee was instant, poo took 5min, ate 300g raw food"></textarea> <textarea id="note-input" rows="4" placeholder="e.g. pee was instant, poo took 5min, ate 300g raw food"></textarea>
</label> </label>
@@ -157,6 +203,9 @@
<input type="time" id="edit-time" lang="en-GB" /> <input type="time" id="edit-time" lang="en-GB" />
</div> </div>
</label> </label>
<label id="edit-weight-field" hidden>Weight (kg)
<input type="number" id="edit-weight" inputmode="decimal" step="0.01" min="0" />
</label>
<label>Note <label>Note
<textarea id="edit-note" rows="4"></textarea> <textarea id="edit-note" rows="4"></textarea>
</label> </label>
+44 -1
View File
@@ -9,7 +9,9 @@
--eat: #ff9b3d; --eat: #ff9b3d;
--pee: #ffd23f; --pee: #ffd23f;
--poo: #8a5a3b; --poo: #8a5a3b;
--weight: #2bb3a3;
--danger: #d64545; --danger: #d64545;
--gain: #2e9e5b;
--border: #e9e6f5; --border: #e9e6f5;
--radius: 12px; --radius: 12px;
--shadow: 0 1px 2px rgba(20, 14, 60, 0.05), 0 4px 16px rgba(20, 14, 60, 0.05); --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; 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 { h2 {
font-size: 1rem; font-size: 1rem;
text-transform: uppercase; text-transform: uppercase;
@@ -156,6 +184,7 @@ button.action.sleep { background: var(--sleep); }
button.action.eat { background: var(--eat); } button.action.eat { background: var(--eat); }
button.action.pee { background: var(--pee); color: #2b240a; } button.action.pee { background: var(--pee); color: #2b240a; }
button.action.poo { background: var(--poo); } button.action.poo { background: var(--poo); }
button.action.weight { background: var(--weight); }
button.ghost { button.ghost {
background: transparent; background: transparent;
@@ -227,7 +256,7 @@ button.danger { background: var(--danger); }
.history-controls label { color: var(--muted); font-size: 0.85rem; } .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; font: inherit;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
@@ -313,6 +342,7 @@ textarea { resize: vertical; }
.event[data-type="eat"] .dot { background: var(--eat); } .event[data-type="eat"] .dot { background: var(--eat); }
.event[data-type="pee"] .dot { background: var(--pee); } .event[data-type="pee"] .dot { background: var(--pee); }
.event[data-type="poo"] .dot { background: var(--poo); } .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 .time { font-variant-numeric: tabular-nums; color: var(--muted); min-width: 60px; }
.event .label { font-weight: 600; min-width: 110px; } .event .label { font-weight: 600; min-width: 110px; }
@@ -459,6 +489,19 @@ dialog menu {
.chart-svg .bar-pee { fill: var(--pee); } .chart-svg .bar-pee { fill: var(--pee); }
.chart-svg .bar-poo { fill: var(--poo); } .chart-svg .bar-poo { fill: var(--poo); }
.chart-svg .bar-eat { fill: var(--eat); } .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 { .legend {
display: flex; display: flex;
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = "puppy-tracker-v1"; const CACHE = "puppy-tracker-v4";
const PHOTO_CACHE = "puppy-tracker-photos-v1"; const PHOTO_CACHE = "puppy-tracker-photos-v1";
const ASSETS = [ const ASSETS = [
"./", "./",