Weight
+No weigh-ins logged yet.
+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(`
No weigh-ins logged yet.
+