Track age and weight
This commit is contained in:
+308
-5
@@ -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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user