Track age and weight
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
+121
@@ -22,6 +22,7 @@ type Event struct {
|
|||||||
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"`
|
||||||
|
Weight float64 `json:"weight,omitempty"` // kilograms, for "weight" events
|
||||||
UpdatedAt int64 `json:"updatedAt"`
|
UpdatedAt int64 `json:"updatedAt"`
|
||||||
Deleted bool `json:"deleted,omitempty"`
|
Deleted bool `json:"deleted,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,84 @@ var uuidRE = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4
|
|||||||
|
|
||||||
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
@@ -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();
|
||||||
})();
|
})();
|
||||||
|
|||||||
+50
-1
@@ -12,8 +12,14 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>🐶 Puppy Tracker</h1>
|
<div class="title">
|
||||||
|
<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 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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user