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
+308 -5
View File
@@ -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 @@
<span class="label">${EVENT_LABELS[ev.type] || ev.type}</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));
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(`<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() {
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();
})();