"Last 7 days" collected four charts for no better reason than sharing an axis, which put sleep hours a full panel away from the two sleep patterns and the counts chart nowhere near the by-hour view of the same events. Each chart now sits with its subject. Sleep hours per day becomes its own Sleep panel directly above the sleep timeline, so the three sleep views read in sequence. Daily counts and Food join the heatmap in one "Pees, poos & meals" panel: how many a day, how much food went with them, and what hours they fall in are three views of one set of events and belong on one card. Minutes walked per day goes to the Walks panel, which is the same move applied to the chart the split didn't mention. The day-window picker stays a single control, in the Sleep panel, because it was never scoped to the panel holding it — it drives the training grid, both sleep patterns and the counts panel too. That is unchanged, and the panel it lives in occupies the slot the old one did, so it has not moved on screen. A comment says so, since a global control sitting inside one card does not announce itself. Duplicating it into each panel would work as-is (both the labels and the buttons are addressed by querySelectorAll) if reaching it ever becomes a scroll. Both new panels take new data-panel keys, so anyone who had the old panels folded gets the new ones open rather than inheriting a collapse they chose for something else. The dead #daily-charts-title lookup goes with the heading.
4386 lines
173 KiB
JavaScript
4386 lines
173 KiB
JavaScript
(() => {
|
||
"use strict";
|
||
|
||
// Storage is namespaced per account so two people sharing a browser (or one
|
||
// person logging out and back in as someone else) never see each other's
|
||
// cached events/profile. currentUser is set by the auth gate before the app
|
||
// boots, so these are only ever called once a user is known.
|
||
let currentUser = null;
|
||
const eventsKey = () => `puppy-tracker:${currentUser.id}:events:v1`;
|
||
const configKey = () => `puppy-tracker:${currentUser.id}:config:v1`;
|
||
const exercisesKey = () => `puppy-tracker:${currentUser.id}:exercises:v1`;
|
||
const SYNC_URL = "api/events/sync";
|
||
const SYNC_DEBOUNCE_MS = 1200;
|
||
const SYNC_POLL_MS = 60_000;
|
||
|
||
const EVENT_LABELS = {
|
||
"sleep-start": "Sleep start",
|
||
"sleep-end": "Sleep end",
|
||
"walk-start": "Walk start",
|
||
"walk-end": "Walk end",
|
||
"eat": "Ate",
|
||
"pee": "Pee",
|
||
"poo": "Poo",
|
||
"weight": "Weight",
|
||
"training": "Training",
|
||
"note": "Note",
|
||
};
|
||
|
||
// ---------- photos: IndexedDB store ----------
|
||
// Schema: object store `photos` keyed by `id`, value `{id, blob, uploaded}`.
|
||
// - Photos taken locally are written with uploaded:false and queued for upload.
|
||
// - Photos fetched from server are cached with uploaded:true (server is source-of-truth).
|
||
const PHOTO_DB = "puppy-tracker";
|
||
const PHOTO_STORE = "photos";
|
||
let photoDB = null;
|
||
|
||
function openPhotoDB() {
|
||
if (photoDB) return Promise.resolve(photoDB);
|
||
return new Promise((resolve, reject) => {
|
||
const req = indexedDB.open(PHOTO_DB, 1);
|
||
req.onupgradeneeded = (e) => {
|
||
const db = e.target.result;
|
||
if (!db.objectStoreNames.contains(PHOTO_STORE)) {
|
||
db.createObjectStore(PHOTO_STORE, { keyPath: "id" });
|
||
}
|
||
};
|
||
req.onsuccess = () => { photoDB = req.result; resolve(photoDB); };
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
}
|
||
|
||
async function putPhoto(id, blob, uploaded) {
|
||
const db = await openPhotoDB();
|
||
return new Promise((resolve, reject) => {
|
||
const tx = db.transaction(PHOTO_STORE, "readwrite");
|
||
tx.objectStore(PHOTO_STORE).put({ id, blob, uploaded });
|
||
tx.oncomplete = () => resolve();
|
||
tx.onerror = () => reject(tx.error);
|
||
});
|
||
}
|
||
|
||
async function getPhoto(id) {
|
||
const db = await openPhotoDB();
|
||
return new Promise((resolve, reject) => {
|
||
const tx = db.transaction(PHOTO_STORE, "readonly");
|
||
const req = tx.objectStore(PHOTO_STORE).get(id);
|
||
req.onsuccess = () => resolve(req.result || null);
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
}
|
||
|
||
async function getAllPhotos() {
|
||
const db = await openPhotoDB();
|
||
return new Promise((resolve, reject) => {
|
||
const tx = db.transaction(PHOTO_STORE, "readonly");
|
||
const req = tx.objectStore(PHOTO_STORE).getAll();
|
||
req.onsuccess = () => resolve(req.result || []);
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
}
|
||
|
||
async function deletePhoto(id) {
|
||
const db = await openPhotoDB();
|
||
return new Promise((resolve, reject) => {
|
||
const tx = db.transaction(PHOTO_STORE, "readwrite");
|
||
tx.objectStore(PHOTO_STORE).delete(id);
|
||
tx.oncomplete = () => resolve();
|
||
tx.onerror = () => reject(tx.error);
|
||
});
|
||
}
|
||
|
||
// Resize an image File to a max dimension and return a JPEG Blob.
|
||
function resizeImage(file, maxDim = 1600, quality = 0.85) {
|
||
return new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
const url = URL.createObjectURL(file);
|
||
img.onload = () => {
|
||
URL.revokeObjectURL(url);
|
||
const scale = Math.min(1, maxDim / Math.max(img.naturalWidth, img.naturalHeight));
|
||
const w = Math.max(1, Math.round(img.naturalWidth * scale));
|
||
const h = Math.max(1, Math.round(img.naturalHeight * scale));
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = w; canvas.height = h;
|
||
canvas.getContext("2d").drawImage(img, 0, 0, w, h);
|
||
canvas.toBlob(
|
||
(blob) => blob ? resolve(blob) : reject(new Error("toBlob returned null")),
|
||
"image/jpeg",
|
||
quality,
|
||
);
|
||
};
|
||
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("image load failed")); };
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
async function uploadPhotoBlob(id, blob) {
|
||
const form = new FormData();
|
||
form.append("id", id);
|
||
form.append("file", blob, `${id}.jpg`);
|
||
const res = await fetch("api/photos", { method: "POST", body: form });
|
||
if (!res.ok) throw new Error(`upload HTTP ${res.status}`);
|
||
}
|
||
|
||
async function syncPhotos() {
|
||
if (!navigator.onLine) return;
|
||
const all = await getAllPhotos();
|
||
for (const p of all) {
|
||
if (p.uploaded) continue;
|
||
try {
|
||
await uploadPhotoBlob(p.id, p.blob);
|
||
await putPhoto(p.id, p.blob, true);
|
||
} catch (err) {
|
||
console.warn("photo upload failed", p.id, err);
|
||
// leave queued; next sync will retry
|
||
}
|
||
}
|
||
}
|
||
|
||
// Resolve a photoId to a displayable URL. Prefers local cache; falls back
|
||
// to the server URL (the SW will cache it transparently). Returns null if
|
||
// we have nothing and the server doesn't either.
|
||
const photoURLCache = new Map(); // id -> object URL (lifetime = page session)
|
||
async function photoSrc(id) {
|
||
if (!id) return null;
|
||
if (photoURLCache.has(id)) return photoURLCache.get(id);
|
||
const local = await getPhoto(id);
|
||
if (local && local.blob) {
|
||
const url = URL.createObjectURL(local.blob);
|
||
photoURLCache.set(id, url);
|
||
return url;
|
||
}
|
||
// Fall back to server URL — let the browser/SW cache it. Also opportunistically
|
||
// pull it into IndexedDB so cold-offline-loads still see it.
|
||
if (navigator.onLine) {
|
||
try {
|
||
const res = await fetch(`api/photos/${encodeURIComponent(id)}`);
|
||
if (res.ok) {
|
||
const blob = await res.blob();
|
||
await putPhoto(id, blob, true);
|
||
const url = URL.createObjectURL(blob);
|
||
photoURLCache.set(id, url);
|
||
return url;
|
||
}
|
||
} catch (_) { /* fall through */ }
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// crypto.randomUUID() is only exposed in secure contexts (HTTPS / localhost),
|
||
// so over plain HTTP on the LAN we need a fallback. crypto.getRandomValues
|
||
// is available everywhere; Math.random is the last resort.
|
||
function uuid() {
|
||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||
return crypto.randomUUID();
|
||
}
|
||
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
|
||
const b = new Uint8Array(16);
|
||
crypto.getRandomValues(b);
|
||
b[6] = (b[6] & 0x0f) | 0x40; // version 4
|
||
b[8] = (b[8] & 0x3f) | 0x80; // variant 10
|
||
const h = [...b].map(x => x.toString(16).padStart(2, "0"));
|
||
return `${h.slice(0,4).join("")}-${h.slice(4,6).join("")}-${h.slice(6,8).join("")}-${h.slice(8,10).join("")}-${h.slice(10,16).join("")}`;
|
||
}
|
||
return `x-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
|
||
}
|
||
|
||
// ---------- storage ----------
|
||
// Internal "raw" storage includes deleted tombstones; UI uses live().
|
||
function loadAll() {
|
||
try {
|
||
const raw = localStorage.getItem(eventsKey());
|
||
if (!raw) return [];
|
||
const parsed = JSON.parse(raw);
|
||
if (!Array.isArray(parsed)) return [];
|
||
// Backfill updatedAt for events written by an older client version.
|
||
return parsed.map(e => ({
|
||
...e,
|
||
updatedAt: Number.isFinite(e.updatedAt) ? e.updatedAt : (e.at || Date.now()),
|
||
}));
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function saveAll(events) {
|
||
localStorage.setItem(eventsKey(), JSON.stringify(events));
|
||
}
|
||
|
||
function live() {
|
||
return loadAll().filter(e => !e.deleted);
|
||
}
|
||
|
||
// ---------- 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(configKey()));
|
||
if (!parsed || typeof parsed !== "object") return { name: "", birthday: "", pedigreeId: "", updatedAt: 0 };
|
||
return {
|
||
name: parsed.name || "",
|
||
birthday: parsed.birthday || "",
|
||
pedigreeId: parsed.pedigreeId || "",
|
||
updatedAt: Number.isFinite(parsed.updatedAt) ? parsed.updatedAt : 0,
|
||
};
|
||
} catch {
|
||
return { name: "", birthday: "", pedigreeId: "", updatedAt: 0 };
|
||
}
|
||
}
|
||
|
||
function saveConfig(cfg) {
|
||
localStorage.setItem(configKey(), JSON.stringify(cfg));
|
||
}
|
||
|
||
// Reconcile a local and a server profile. Name/birthday/updatedAt are plain
|
||
// last-write-wins by timestamp. The pedigree id is sticky: a non-empty value
|
||
// never loses to an empty one — so it can't be dropped by a clock race between
|
||
// devices — and when both are set the newer profile's id wins with the rest.
|
||
// (The server merge mirrors this, so a set id is only changed, never cleared,
|
||
// by sync.)
|
||
function reconcileConfig(local, server) {
|
||
const base = server.updatedAt >= local.updatedAt ? server : local;
|
||
return {
|
||
name: base.name,
|
||
birthday: base.birthday,
|
||
pedigreeId: (local.pedigreeId && server.pedigreeId)
|
||
? base.pedigreeId
|
||
: (local.pedigreeId || server.pedigreeId),
|
||
updatedAt: base.updatedAt,
|
||
};
|
||
}
|
||
function sameConfig(a, b) {
|
||
return a.name === b.name && a.birthday === b.birthday
|
||
&& a.pedigreeId === b.pedigreeId && a.updatedAt === b.updatedAt;
|
||
}
|
||
|
||
// Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday,
|
||
// measured at `at` (defaults to now — pass a weigh-in's timestamp for its age
|
||
// at that point). Returns null for a missing/invalid birthday or a date before it.
|
||
function ageParts(birthday, at) {
|
||
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 ref = startOfDay(new Date(Number.isFinite(at) ? at : Date.now()));
|
||
if (birth > ref) return null;
|
||
const days = Math.floor((ref - birth) / 86_400_000);
|
||
const weeks = Math.floor(days / 7);
|
||
let months = (ref.getFullYear() - birth.getFullYear()) * 12 +
|
||
(ref.getMonth() - birth.getMonth());
|
||
if (ref.getDate() < birth.getDate()) months--;
|
||
if (months < 0) months = 0;
|
||
// Whole weeks left over after the calendar months (so "N months and M weeks").
|
||
const anchor = startOfDay(new Date(birth.getFullYear(),
|
||
birth.getMonth() + months, birth.getDate()));
|
||
const remWeeks = Math.floor((ref - anchor) / 86_400_000 / 7);
|
||
return { days, weeks, months, remWeeks };
|
||
}
|
||
|
||
// e.g. "16 weeks (3 months and 3 weeks) old". Weeks are the headline unit while
|
||
// the puppy is young; once it's 4+ months the week count is just noise, so we
|
||
// drop it and lead with the months form ("4 months and 1 week old").
|
||
function formatAge(birthday, at) {
|
||
const a = ageParts(birthday, at);
|
||
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"}`;
|
||
const rw = a.remWeeks > 0
|
||
? ` and ${a.remWeeks} week${a.remWeeks === 1 ? "" : "s"}` : "";
|
||
const monthPhrase = `${mo}${rw}`;
|
||
if (a.months >= 4) return `${monthPhrase} old`;
|
||
return `${wk} (${monthPhrase}) old`;
|
||
}
|
||
|
||
// Compact one-line age for space-tight spots (header): keeps the weeks + months
|
||
// breakdown but abbreviated — "16 wk · 3 mo 3 wk", "5 mo 2 wk", "3 wk".
|
||
function formatAgeShort(birthday, at) {
|
||
const a = ageParts(birthday, at);
|
||
if (!a) return "";
|
||
const wk = `${a.weeks} wk`;
|
||
if (a.months < 1) return wk;
|
||
const mo = `${a.months} mo${a.remWeeks > 0 ? ` ${a.remWeeks} wk` : ""}`;
|
||
if (a.months >= 4) return mo;
|
||
return `${wk} · ${mo}`;
|
||
}
|
||
|
||
// Weeks-only age for dense lists (weight rows): "16 wk".
|
||
function formatAgeWeeks(birthday, at) {
|
||
const a = ageParts(birthday, at);
|
||
return a ? `${a.weeks} wk` : "";
|
||
}
|
||
|
||
// Rough age-based daily sleep goal (hours), for the trend chart's goal band:
|
||
// 0–8 weeks 20–22h, 8–16 weeks 18–20h, then 16–18h to 6 months and 14–16h to
|
||
// 12 months. No birthday (or an adult dog) → no goal.
|
||
function sleepTargetFor(birthday) {
|
||
const a = ageParts(birthday);
|
||
if (!a) return null;
|
||
if (a.weeks < 8) return { lo: 20, hi: 22, label: "0–8 weeks" };
|
||
if (a.weeks < 16) return { lo: 18, hi: 20, label: "8–16 weeks" };
|
||
if (a.months < 6) return { lo: 16, hi: 18, label: "4–6 months" };
|
||
if (a.months < 12) return { lo: 14, hi: 16, label: "6–12 months" };
|
||
return null;
|
||
}
|
||
|
||
function addEvent(type, note, at, { photoId, weight, grams, exerciseId } = {}) {
|
||
const events = loadAll();
|
||
const now = Date.now();
|
||
const ev = {
|
||
id: uuid(),
|
||
type,
|
||
at: Number.isFinite(at) ? at : now,
|
||
note: note || "",
|
||
photoId: photoId || "",
|
||
weight: Number.isFinite(weight) ? weight : undefined,
|
||
grams: Number.isFinite(grams) ? grams : undefined,
|
||
exerciseId: exerciseId || "",
|
||
updatedAt: now,
|
||
};
|
||
events.push(ev);
|
||
saveAll(events);
|
||
scheduleSync();
|
||
render();
|
||
return ev;
|
||
}
|
||
|
||
function updateEvent(id, patch) {
|
||
const events = loadAll().map(e =>
|
||
e.id === id ? { ...e, ...patch, updatedAt: Date.now() } : e
|
||
);
|
||
saveAll(events);
|
||
scheduleSync();
|
||
render();
|
||
}
|
||
|
||
function deleteEvent(id) {
|
||
// Soft-delete so the deletion can propagate via sync.
|
||
const events = loadAll().map(e =>
|
||
e.id === id ? { ...e, deleted: true, updatedAt: Date.now() } : e
|
||
);
|
||
saveAll(events);
|
||
scheduleSync();
|
||
render();
|
||
}
|
||
|
||
// ---------- exercises (training definitions) ----------
|
||
// User-defined training exercises ("Sit", "Leash walking", …), each with
|
||
// optional instruction text. They sync like events: UUID ids, last-write-wins
|
||
// on updatedAt, tombstoned deletes — but as their own collection, since they
|
||
// are definitions rather than things that happened at a point in time.
|
||
function loadExercises() {
|
||
try {
|
||
const parsed = JSON.parse(localStorage.getItem(exercisesKey()));
|
||
return Array.isArray(parsed) ? parsed : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function saveExercises(list) {
|
||
localStorage.setItem(exercisesKey(), JSON.stringify(list));
|
||
}
|
||
|
||
function liveExercises() {
|
||
return loadExercises()
|
||
.filter(x => !x.deleted)
|
||
.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
|
||
}
|
||
|
||
function addExercise(name, note) {
|
||
const list = loadExercises();
|
||
list.push({ id: uuid(), name, note: note || "", updatedAt: Date.now() });
|
||
saveExercises(list);
|
||
scheduleSync();
|
||
render();
|
||
}
|
||
|
||
function updateExercise(id, patch) {
|
||
saveExercises(loadExercises().map(x =>
|
||
x.id === id ? { ...x, ...patch, updatedAt: Date.now() } : x
|
||
));
|
||
scheduleSync();
|
||
render();
|
||
}
|
||
|
||
function deleteExercise(id) {
|
||
// Tombstone, like events. Logged training sessions keep referencing the id;
|
||
// name lookups still resolve through the tombstone (see exerciseNames).
|
||
saveExercises(loadExercises().map(x =>
|
||
x.id === id ? { ...x, deleted: true, updatedAt: Date.now() } : x
|
||
));
|
||
scheduleSync();
|
||
render();
|
||
}
|
||
|
||
// id -> name across *all* exercises, tombstones included, so history rows for
|
||
// a deleted exercise still show its name instead of a generic "Training".
|
||
function exerciseNames() {
|
||
return new Map(loadExercises().map(x => [x.id, x.name]));
|
||
}
|
||
|
||
// ---------- helpers ----------
|
||
function ymd(date) {
|
||
const y = date.getFullYear();
|
||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||
const d = String(date.getDate()).padStart(2, "0");
|
||
return `${y}-${m}-${d}`;
|
||
}
|
||
|
||
function startOfDay(date) {
|
||
const d = new Date(date);
|
||
d.setHours(0, 0, 0, 0);
|
||
return d;
|
||
}
|
||
|
||
function endOfDay(date) {
|
||
const d = new Date(date);
|
||
d.setHours(23, 59, 59, 999);
|
||
return d;
|
||
}
|
||
|
||
function formatTime(ts) {
|
||
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
|
||
}
|
||
|
||
const pad2 = n => String(n).padStart(2, "0");
|
||
function toDateInput(ts) {
|
||
const d = new Date(ts);
|
||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||
}
|
||
function toTimeInput(ts) {
|
||
const d = new Date(ts);
|
||
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||
}
|
||
function fromDateTimeInputs(dateStr, timeStr) {
|
||
if (!dateStr || !timeStr) return NaN;
|
||
const [y, mo, d] = dateStr.split("-").map(Number);
|
||
const [h, mi] = timeStr.split(":").map(Number);
|
||
const t = new Date(y, mo - 1, d, h, mi).getTime();
|
||
return Number.isFinite(t) ? t : NaN;
|
||
}
|
||
|
||
function formatDuration(ms) {
|
||
if (ms <= 0) return "0m";
|
||
const totalMin = Math.round(ms / 60000);
|
||
const h = Math.floor(totalMin / 60);
|
||
const m = totalMin % 60;
|
||
if (h === 0) return `${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) {
|
||
if (!ts) return "—";
|
||
const diff = Date.now() - ts;
|
||
if (diff < 60_000) return "just now";
|
||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||
if (diff < 86_400_000) {
|
||
const h = Math.floor(diff / 3_600_000);
|
||
const m = Math.floor((diff % 3_600_000) / 60_000);
|
||
return m ? `${h}h ${m}m ago` : `${h}h ago`;
|
||
}
|
||
const d = Math.floor(diff / 86_400_000);
|
||
return `${d}d ago`;
|
||
}
|
||
|
||
function eventsForDay(events, day) {
|
||
const from = startOfDay(day).getTime();
|
||
const to = endOfDay(day).getTime();
|
||
return events
|
||
.filter(e => e.at >= from && e.at <= to)
|
||
.sort((a, b) => a.at - b.at);
|
||
}
|
||
|
||
// Calculate sleep time within a given day. Sleep intervals are derived from
|
||
// consecutive sleep-start → sleep-end pairs across the whole event log, and
|
||
// clipped to the day window. Unmatched sleep-start = ongoing sleep (clipped to now).
|
||
function sleepMsInRange(events, fromTs, toTs) {
|
||
const sorted = [...events].sort((a, b) => a.at - b.at);
|
||
const intervals = [];
|
||
let openStart = null;
|
||
for (const e of sorted) {
|
||
if (e.type === "sleep-start" && openStart === null) {
|
||
openStart = e.at;
|
||
} else if (e.type === "sleep-end" && openStart !== null) {
|
||
intervals.push([openStart, e.at]);
|
||
openStart = null;
|
||
}
|
||
}
|
||
if (openStart !== null) intervals.push([openStart, Date.now()]);
|
||
|
||
let total = 0;
|
||
for (const [start, end] of intervals) {
|
||
const s = Math.max(start, fromTs);
|
||
const ePt = Math.min(end, toTs);
|
||
if (ePt > s) total += ePt - s;
|
||
}
|
||
return total;
|
||
}
|
||
|
||
// The newest of a set of boundary event types. For two events sharing the
|
||
// same `at` (common once "now" events are minute-floored), the one logged
|
||
// later (higher updatedAt) wins, so the tie resolves the same way everywhere
|
||
// it's read.
|
||
function latestOfTypes(events, types) {
|
||
let latest = null;
|
||
for (const e of events) {
|
||
if (!types.includes(e.type)) continue;
|
||
if (!latest ||
|
||
e.at > latest.at ||
|
||
(e.at === latest.at && (e.updatedAt || 0) > (latest.updatedAt || 0))) {
|
||
latest = e;
|
||
}
|
||
}
|
||
return latest;
|
||
}
|
||
|
||
// Current state derived from the *latest* sleep event. The single source of
|
||
// truth for both the big clock and the "Currently" row.
|
||
function currentSleepState(events) {
|
||
const latest = latestOfTypes(events, ["sleep-start", "sleep-end"]);
|
||
if (!latest) return { state: null, since: 0 };
|
||
return {
|
||
state: latest.type === "sleep-start" ? "asleep" : "awake",
|
||
since: latest.at,
|
||
};
|
||
}
|
||
|
||
// Same idea for walks: the newest walk boundary says whether one is running.
|
||
function currentWalkState(events) {
|
||
const latest = latestOfTypes(events, ["walk-start", "walk-end"]);
|
||
if (!latest) return { walking: false, since: 0 };
|
||
return { walking: latest.type === "walk-start", since: latest.at };
|
||
}
|
||
|
||
function formatCounter(ms) {
|
||
if (ms < 0) ms = 0;
|
||
const totalSec = Math.floor(ms / 1000);
|
||
const h = Math.floor(totalSec / 3600);
|
||
const m = Math.floor((totalSec % 3600) / 60);
|
||
const s = totalSec % 60;
|
||
const pp = n => String(n).padStart(2, "0");
|
||
return h > 0 ? `${h}:${pp(m)}:${pp(s)}` : `${m}:${pp(s)}`;
|
||
}
|
||
|
||
function lastEventOfType(events, type) {
|
||
let latest = null;
|
||
for (const e of events) {
|
||
if (e.type === type && (!latest || e.at > latest.at)) latest = e;
|
||
}
|
||
return latest;
|
||
}
|
||
|
||
// Pair up boundary events into windows: every `openType` runs until the next
|
||
// `closeType`. A trailing unmatched `openType` is an ongoing/open window,
|
||
// measured to now. Only the two given types are considered, so the same scan
|
||
// produces sleep windows, their inverse (wake windows) and walks.
|
||
function pairWindows(events, openType, closeType) {
|
||
const sorted = events
|
||
.filter(e => e.type === openType || e.type === closeType)
|
||
.sort((a, b) => a.at - b.at);
|
||
const out = [];
|
||
let open = null;
|
||
for (const e of sorted) {
|
||
if (e.type === openType) {
|
||
open = e.at;
|
||
} else if (open !== null) {
|
||
out.push({ start: open, end: e.at, ongoing: false });
|
||
open = null;
|
||
}
|
||
}
|
||
if (open !== null) out.push({ start: open, end: Date.now(), ongoing: true });
|
||
return out;
|
||
}
|
||
|
||
// Wake windows: time between a sleep-end and the next sleep-start. The final
|
||
// sleep-end with no following sleep-start = ongoing/open wake window.
|
||
function wakeWindows(events) {
|
||
return pairWindows(events, "sleep-end", "sleep-start");
|
||
}
|
||
|
||
// Sleep windows: each sleep-start → next sleep-end pair. An unmatched
|
||
// sleep-start = ongoing/open sleep window.
|
||
function sleepWindows(events) {
|
||
return pairWindows(events, "sleep-start", "sleep-end");
|
||
}
|
||
|
||
// Walks: each walk-start → next walk-end pair, ongoing while out.
|
||
function walkWindows(events) {
|
||
return pairWindows(events, "walk-start", "walk-end");
|
||
}
|
||
|
||
// Time spent walking inside [fromTs, toTs], clipping windows that straddle
|
||
// the edges — the same treatment sleepMsInRange gives sleep.
|
||
function walkMsInRange(events, fromTs, toTs) {
|
||
let total = 0;
|
||
for (const w of walkWindows(events)) {
|
||
const s = Math.max(w.start, fromTs);
|
||
const e = Math.min(w.end, toTs);
|
||
if (e > s) total += e - s;
|
||
}
|
||
return total;
|
||
}
|
||
|
||
function sleepWindowsForDay(events, day) {
|
||
const dayStart = startOfDay(day).getTime();
|
||
const dayEnd = endOfDay(day).getTime();
|
||
const today = ymd(new Date()) === ymd(day);
|
||
// Keep the overlap filter so windows show on every day they touch, but
|
||
// display the *actual* start/end — a sleep from 23:00 yesterday to 07:00
|
||
// today should show as "23:00 – 07:00 (8h)", not clipped at midnight.
|
||
return sleepWindows(events)
|
||
.filter(w => w.start <= dayEnd && w.end >= dayStart)
|
||
.map(w => ({ start: w.start, end: w.end, ongoing: w.ongoing && today }));
|
||
}
|
||
|
||
// Wake windows that overlap the given day, clipped to that day for display.
|
||
// Only mark a window as "ongoing" if the selected day is today.
|
||
function wakeWindowsForDay(events, day) {
|
||
const dayStart = startOfDay(day).getTime();
|
||
const dayEnd = endOfDay(day).getTime();
|
||
const today = ymd(new Date()) === ymd(day);
|
||
// Same untrimmed-times policy as sleep windows: show the real start/end
|
||
// even if part of the window falls outside the selected day.
|
||
return wakeWindows(events)
|
||
.filter(w => w.start <= dayEnd && w.end >= dayStart)
|
||
.map(w => ({ start: w.start, end: w.end, ongoing: w.ongoing && today }));
|
||
}
|
||
|
||
// Walks that overlap the given day. Same untrimmed-times policy as the sleep
|
||
// and wake lists: a walk that crosses midnight shows its real start and end.
|
||
function walkWindowsForDay(events, day) {
|
||
const dayStart = startOfDay(day).getTime();
|
||
const dayEnd = endOfDay(day).getTime();
|
||
const today = ymd(new Date()) === ymd(day);
|
||
return walkWindows(events)
|
||
.filter(w => w.start <= dayEnd && w.end >= dayStart)
|
||
.map(w => ({ start: w.start, end: w.end, ongoing: w.ongoing && today }));
|
||
}
|
||
|
||
// Rough age-based walking guideline (the widely used "five-minute rule"):
|
||
// about 5 minutes per month of age per walk, twice a day, until the puppy is
|
||
// grown. Returns null without a birthday or once it's a year old, the same
|
||
// way sleepTargetFor bows out.
|
||
function walkTargetFor(birthday) {
|
||
const a = ageParts(birthday);
|
||
if (!a || a.months >= 12) return null;
|
||
// Under a month of counted age the rule has nothing to say yet; treat it
|
||
// as one "month" so the advice stays a short outing rather than zero.
|
||
const months = Math.max(1, a.months);
|
||
return { perWalk: months * 5, walks: 2, total: months * 10 };
|
||
}
|
||
|
||
// ---------- rendering ----------
|
||
const dayPicker = document.getElementById("day-picker");
|
||
const eventList = document.getElementById("event-list");
|
||
const emptyState = document.getElementById("empty-state");
|
||
const statusEl = document.getElementById("online-status");
|
||
|
||
function selectedDay() {
|
||
const v = dayPicker.value;
|
||
if (v) {
|
||
const [y, m, d] = v.split("-").map(Number);
|
||
return new Date(y, m - 1, d);
|
||
}
|
||
return new Date();
|
||
}
|
||
|
||
function renderStats(events) {
|
||
const day = selectedDay();
|
||
const from = startOfDay(day).getTime();
|
||
const today = ymd(new Date()) === ymd(day);
|
||
const to = today ? Date.now() : endOfDay(day).getTime();
|
||
|
||
const sleepMs = sleepMsInRange(events, from, to);
|
||
const awakeMs = Math.max(0, (to - from) - sleepMs);
|
||
|
||
const dayEvents = eventsForDay(events, day);
|
||
const count = (t) => dayEvents.filter(e => e.type === t).length;
|
||
|
||
document.getElementById("stat-sleep").textContent = formatDuration(sleepMs);
|
||
document.getElementById("stat-awake").textContent = formatDuration(awakeMs);
|
||
document.getElementById("stat-meals").textContent = count("eat");
|
||
const gramsTotal = dayEvents
|
||
.filter(e => e.type === "eat" && Number.isFinite(e.grams))
|
||
.reduce((s, e) => s + e.grams, 0);
|
||
const gramsEl = document.getElementById("stat-meals-grams");
|
||
gramsEl.textContent = gramsTotal > 0 ? `${Math.round(gramsTotal)} g` : "";
|
||
gramsEl.hidden = !(gramsTotal > 0);
|
||
// Walk time is a duration, not a count, so the tile leads with it and puts
|
||
// "N walks" underneath — the day's exercise at a glance.
|
||
document.getElementById("stat-walk").textContent = formatDuration(walkMsInRange(events, from, to));
|
||
const walks = walkWindowsForDay(events, day).length;
|
||
const walkCountEl = document.getElementById("stat-walk-count");
|
||
walkCountEl.textContent = `${walks} walk${walks === 1 ? "" : "s"}`;
|
||
walkCountEl.hidden = walks === 0;
|
||
document.getElementById("stat-pees").textContent = count("pee");
|
||
document.getElementById("stat-poos").textContent = count("poo");
|
||
document.getElementById("stat-training").textContent = count("training");
|
||
}
|
||
|
||
// Gaps (ms) between consecutive events of `type` logged within the last
|
||
// `days` days, sorted ascending. These intervals are what tell you how
|
||
// often the puppy needs to go out.
|
||
function gapsBetween(events, type, days = 7) {
|
||
const cutoff = startOfDay(new Date());
|
||
cutoff.setDate(cutoff.getDate() - (days - 1));
|
||
const from = cutoff.getTime();
|
||
const times = events
|
||
.filter(e => e.type === type && e.at >= from)
|
||
.map(e => e.at)
|
||
.sort((a, b) => a - b);
|
||
const gaps = [];
|
||
for (let i = 1; i < times.length; i++) gaps.push(times[i] - times[i - 1]);
|
||
return gaps.sort((a, b) => a - b);
|
||
}
|
||
|
||
// Median is used for the "typical" gap because it ignores the single long
|
||
// overnight gap each day, so it reflects real daytime frequency.
|
||
function median(sorted) {
|
||
if (sorted.length === 0) return null;
|
||
const mid = Math.floor(sorted.length / 2);
|
||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||
}
|
||
|
||
// Each type gets a small range chart: a band spanning the shortest to the
|
||
// typical gap over the window, and a marker for how long it has been since
|
||
// the last one. The marker is free to sit outside the band — just went (left
|
||
// of shortest) or overdue (right of typical) — which is the reading that
|
||
// decides whether to take the puppy out now.
|
||
const TIMING_ROWS = [
|
||
{ type: "pee", label: "Pees", noun: "pee", cls: "tm-pee" },
|
||
{ type: "poo", label: "Poos", noun: "poo", cls: "tm-poo" },
|
||
{ type: "eat", label: "Meals", noun: "meal", cls: "tm-eat" },
|
||
];
|
||
|
||
function renderTiming(events) {
|
||
const peeGaps = gapsBetween(events, "pee");
|
||
|
||
for (const row of TIMING_ROWS) {
|
||
const svg = document.getElementById(`timing-chart-${row.type}`);
|
||
const note = document.getElementById(`timing-note-${row.type}`);
|
||
if (!svg || !note) continue;
|
||
const gaps = row.type === "pee" ? peeGaps : gapsBetween(events, row.type);
|
||
const last = lastEventOfType(events, row.type);
|
||
drawTimingChart(svg, note, row, gaps, last ? Math.max(0, Date.now() - last.at) : null);
|
||
}
|
||
|
||
const hint = document.getElementById("timing-hint");
|
||
const typicalPee = median(peeGaps);
|
||
if (typicalPee != null) {
|
||
hint.textContent =
|
||
`Based on ${peeGaps.length} pee gap${peeGaps.length === 1 ? "" : "s"}. ` +
|
||
`Aim to take the puppy out a little before the typical ${formatDuration(typicalPee)} mark.`;
|
||
} else {
|
||
hint.textContent = "Log a few more pees, poos and meals to see typical timings.";
|
||
}
|
||
}
|
||
|
||
function drawTimingChart(svg, note, row, gaps, since) {
|
||
const typical = median(gaps);
|
||
const shortest = gaps[0] ?? null;
|
||
const longest = gaps[gaps.length - 1] ?? null;
|
||
|
||
// Fewer than two events in the window means there is no gap to draw, so the
|
||
// row falls back to a sentence rather than an axis with nothing on it.
|
||
if (typical == null) {
|
||
svg.innerHTML = "";
|
||
svg.style.display = "none";
|
||
note.hidden = false;
|
||
note.textContent = since == null
|
||
? `No ${row.noun}s logged in the last 7 days.`
|
||
: `One ${row.noun} logged, ${formatDuration(since)} ago — log another to see the typical gap.`;
|
||
return;
|
||
}
|
||
svg.style.display = "";
|
||
note.hidden = true;
|
||
|
||
const W = 320, H = 50, ML = 26, MR = 26;
|
||
const innerW = W - ML - MR;
|
||
const trackY = 20, trackH = 8;
|
||
|
||
// Gaps are heavily skewed: one long overnight gap a night sits alongside
|
||
// daytime gaps a tenth its length, and on a plain linear axis it squashes
|
||
// the everyday range — the part you actually read — into the first tenth of
|
||
// the track. So the axis is stretched, the way the sleep trend's is above
|
||
// 10h: the typical gap is pinned to the middle, the shortest-to-typical
|
||
// stretch gets the left half and typical-to-longest the right. Reading it
|
||
// is then the same in every row — left of centre is sooner than usual,
|
||
// right of centre is longer — however extreme that row's tail happens to be.
|
||
const over = since != null && since > longest;
|
||
const cur = since == null ? null : Math.min(since, longest);
|
||
|
||
const lo = Math.min(shortest, cur ?? shortest);
|
||
const halfW = innerW / 2;
|
||
const mid = ML + halfW;
|
||
const xOf = (v) => {
|
||
if (v <= typical) return typical > lo ? ML + ((v - lo) / (typical - lo)) * halfW : mid;
|
||
return longest > typical ? mid + ((v - typical) / (longest - typical)) * halfW : mid;
|
||
};
|
||
|
||
// Two bands: the full spread of gaps in the window, and inside it the solid
|
||
// stretch from the shortest to the typical one. Where the solid ends is the
|
||
// median, so the boundary itself marks "typical" without another mark.
|
||
const xShort = xOf(shortest), xTypical = xOf(typical), xLong = xOf(longest);
|
||
const dot = (a, b) => b - a < trackH + 2;
|
||
const bandRect = (cls, a, b, extra) => {
|
||
const w = Math.max(trackH + 2, b - a);
|
||
const x = dot(a, b) ? (a + b) / 2 - w / 2 : a;
|
||
return `<rect class="${cls}" x="${x.toFixed(1)}" y="${trackY}" width="${w.toFixed(1)}" ` +
|
||
`height="${trackH}" rx="${trackH / 2}">${extra}</rect>`;
|
||
};
|
||
|
||
const parts = [
|
||
`<rect class="tm-track" x="${ML}" y="${trackY}" width="${innerW}" height="${trackH}" rx="${trackH / 2}"/>`,
|
||
bandRect(`tm-range ${row.cls}`, xShort, xLong,
|
||
`<title>${escapeText(`${formatDuration(shortest)}–${formatDuration(longest)} between ${row.noun}s over the last 7 days`)}</title>`),
|
||
bandRect(`tm-band ${row.cls}`, xShort, xTypical,
|
||
`<title>${escapeText(`Typically ${formatDuration(typical)} between ${row.noun}s`)}</title>`),
|
||
];
|
||
|
||
// Ticks tie the summary line under the track to the three points it names.
|
||
const tickY = trackY + trackH + 5;
|
||
const tick = (x) =>
|
||
`<line class="tm-tick" x1="${x.toFixed(1)}" y1="${tickY}" x2="${x.toFixed(1)}" y2="${tickY + 4}"/>`;
|
||
const labelY = tickY + 13;
|
||
// Whitespace inside a <tspan> is at the mercy of XML normalisation (an
|
||
//   does not survive every renderer either), so the gap between a word
|
||
// and its value is an explicit dx offset.
|
||
const word = (w, dx) => `<tspan class="tm-word"${dx ? ` dx="${dx}"` : ""}>${w}</tspan>`;
|
||
const val = (w, ms, dx) => `${word(w, dx)}<tspan dx="3">${escapeText(formatDuration(ms))}</tspan>`;
|
||
|
||
// Three values will not fit as labels hung off their own points, so they
|
||
// read as one centred line and the ticks carry the positions.
|
||
if (longest - shortest < 60_000) {
|
||
parts.push(tick(xTypical));
|
||
parts.push(`<text x="${(W / 2).toFixed(1)}" y="${labelY}" text-anchor="middle">${val("typical", typical)}</text>`);
|
||
} else {
|
||
parts.push(tick(xShort), tick(xTypical), tick(xLong));
|
||
parts.push(
|
||
`<text x="${(W / 2).toFixed(1)}" y="${labelY}" text-anchor="middle">` +
|
||
`${val("shortest", shortest)}${word("·", 4)}${val("typical", typical, 4)}` +
|
||
`${word("·", 4)}${val("longest", longest, 4)}</text>`
|
||
);
|
||
}
|
||
|
||
if (cur != null) {
|
||
const x = over ? W - MR : xOf(cur);
|
||
const y1 = trackY - 5, y2 = trackY + trackH + 5;
|
||
// Surface-coloured underlay: a ring that keeps the marker legible
|
||
// wherever on the band it lands.
|
||
parts.push(`<line class="tm-now-ring" x1="${x.toFixed(1)}" y1="${y1}" x2="${x.toFixed(1)}" y2="${y2}"/>`);
|
||
parts.push(
|
||
`<line class="tm-now" x1="${x.toFixed(1)}" y1="${y1}" x2="${x.toFixed(1)}" y2="${y2}">` +
|
||
`<title>${escapeText(`${formatDuration(since)} since the last ${row.noun}`)}</title></line>`
|
||
);
|
||
if (over) {
|
||
parts.push(
|
||
`<path class="tm-over" d="M${(x + 5).toFixed(1)} ${trackY} ` +
|
||
`L${(x + 12).toFixed(1)} ${trackY + trackH / 2} L${(x + 5).toFixed(1)} ${trackY + trackH} Z"/>`
|
||
);
|
||
}
|
||
const anchor = x < ML + 55 ? "start" : (x > W - MR - 55 ? "end" : "middle");
|
||
parts.push(
|
||
`<text class="tm-now-label" x="${x.toFixed(1)}" y="11" text-anchor="${anchor}">` +
|
||
`${escapeText(`${formatDuration(since)} ago`)}</text>`
|
||
);
|
||
}
|
||
|
||
svg.innerHTML = parts.join("");
|
||
svg.setAttribute("aria-label",
|
||
`${row.label}: typically ${formatDuration(typical)} between ${row.noun}s, ` +
|
||
`shortest ${formatDuration(shortest)}, longest ${formatDuration(longest)}` +
|
||
(since == null ? "" : `, ${formatDuration(since)} since the last one`) + ".");
|
||
}
|
||
|
||
function renderLasts(events) {
|
||
const setLast = (id, type) => {
|
||
const ev = lastEventOfType(events, type);
|
||
document.getElementById(id).textContent = ev
|
||
? `${formatTime(ev.at)} (${formatRelative(ev.at)})`
|
||
: "—";
|
||
};
|
||
setLast("last-pee", "pee");
|
||
setLast("last-poo", "poo");
|
||
setLast("last-eat", "eat");
|
||
|
||
const sortedSleep = events
|
||
.filter(e => e.type === "sleep-start" || e.type === "sleep-end")
|
||
.sort((a, b) => b.at - a.at);
|
||
const lastSleep = sortedSleep[0];
|
||
document.getElementById("last-sleep").textContent = lastSleep
|
||
? `${EVENT_LABELS[lastSleep.type]} at ${formatTime(lastSleep.at)} (${formatRelative(lastSleep.at)})`
|
||
: "—";
|
||
|
||
const lastWalk = latestOfTypes(events, ["walk-start", "walk-end"]);
|
||
document.getElementById("last-walk").textContent = lastWalk
|
||
? `${EVENT_LABELS[lastWalk.type]} at ${formatTime(lastWalk.at)} (${formatRelative(lastWalk.at)})`
|
||
: "—";
|
||
}
|
||
|
||
// Tracks the latest sleep transition so the 1-second tick can update the
|
||
// counter without re-deriving from the event log.
|
||
let bigClockState = null; // "asleep" | "awake" | null
|
||
let bigClockSince = 0;
|
||
|
||
// The counter renders twice: the big card at the top of the page and the
|
||
// compact pill in the frozen day bar. The pill only *shows* once the big
|
||
// card is scrolled out of sight (see updateBarClockMode); while the card is
|
||
// visible the pill is invisible but keeps its slot so the bar never shifts.
|
||
function updateBarClockMode() {
|
||
const pill = document.getElementById("bar-clock");
|
||
if (!bigClockState || pill.hidden) return;
|
||
const card = document.getElementById("big-clock");
|
||
const bar = document.querySelector(".day-bar");
|
||
const cardVisible =
|
||
card.getBoundingClientRect().bottom > bar.getBoundingClientRect().bottom;
|
||
pill.classList.toggle("standby", cardVisible);
|
||
}
|
||
|
||
function renderBigClock(events) {
|
||
const card = document.getElementById("big-clock");
|
||
const label = document.getElementById("bc-label");
|
||
const time = document.getElementById("bc-time");
|
||
const since = document.getElementById("bc-since");
|
||
const pill = document.getElementById("bar-clock");
|
||
const icon = document.getElementById("bar-clock-icon");
|
||
const ptime = document.getElementById("bar-clock-time");
|
||
const { state, since: ts } = currentSleepState(events);
|
||
bigClockState = state;
|
||
bigClockSince = ts;
|
||
if (!state) {
|
||
card.hidden = true;
|
||
pill.hidden = true;
|
||
return;
|
||
}
|
||
card.hidden = false;
|
||
pill.hidden = false;
|
||
card.classList.toggle("asleep", state === "asleep");
|
||
card.classList.toggle("awake", state === "awake");
|
||
pill.classList.toggle("asleep", state === "asleep");
|
||
pill.classList.toggle("awake", state === "awake");
|
||
label.textContent = state === "asleep" ? "Asleep for" : "Awake for";
|
||
const counter = formatCounter(Date.now() - ts);
|
||
time.textContent = counter;
|
||
ptime.textContent = counter;
|
||
since.textContent = `since ${formatTime(ts)}`;
|
||
icon.textContent = state === "asleep" ? "😴" : "☀️";
|
||
const flip = state === "asleep" ? "Sleep end" : "Sleep start";
|
||
pill.title = `${state === "asleep" ? "Asleep" : "Awake"} since ${formatTime(ts)} — tap to log ${flip.toLowerCase()}`;
|
||
pill.setAttribute("aria-label", `${state === "asleep" ? "Asleep" : "Awake"} since ${formatTime(ts)}. Log ${flip.toLowerCase()}.`);
|
||
updateBarClockMode();
|
||
}
|
||
|
||
function tickBigClock() {
|
||
if (!bigClockState) return;
|
||
const counter = formatCounter(Date.now() - bigClockSince);
|
||
const time = document.getElementById("bc-time");
|
||
const ptime = document.getElementById("bar-clock-time");
|
||
if (time) time.textContent = counter;
|
||
if (ptime) ptime.textContent = counter;
|
||
}
|
||
|
||
function renderWindowList(listId, emptyId, windows, ongoingLabel, extraClass) {
|
||
const list = document.getElementById(listId);
|
||
const empty = document.getElementById(emptyId);
|
||
list.innerHTML = "";
|
||
if (windows.length === 0) {
|
||
empty.hidden = false;
|
||
return;
|
||
}
|
||
empty.hidden = true;
|
||
for (const w of windows) {
|
||
const li = document.createElement("li");
|
||
li.className = `ww ${extraClass}` + (w.ongoing ? " ongoing" : "");
|
||
const range = document.createElement("span");
|
||
range.className = "ww-range";
|
||
range.textContent = w.ongoing
|
||
? `from ${formatTime(w.start)}`
|
||
: `${formatTime(w.start)} – ${formatTime(w.end)}`;
|
||
const dur = document.createElement("span");
|
||
dur.className = "ww-dur";
|
||
dur.textContent = formatDuration(w.end - w.start);
|
||
li.appendChild(range);
|
||
li.appendChild(dur);
|
||
if (w.ongoing) {
|
||
const tag = document.createElement("span");
|
||
tag.className = "ww-tag";
|
||
tag.textContent = ongoingLabel;
|
||
li.appendChild(tag);
|
||
}
|
||
list.appendChild(li);
|
||
}
|
||
}
|
||
|
||
function renderSleepWindows(events) {
|
||
renderWindowList(
|
||
"sleep-list", "sleep-empty",
|
||
sleepWindowsForDay(events, selectedDay()),
|
||
"Asleep", "sleep-ww",
|
||
);
|
||
}
|
||
|
||
function renderWakeWindows(events) {
|
||
renderWindowList(
|
||
"wake-list", "wake-empty",
|
||
wakeWindowsForDay(events, selectedDay()),
|
||
"Awake", "wake-ww",
|
||
);
|
||
}
|
||
|
||
function renderWalks(events) {
|
||
const day = selectedDay();
|
||
const windows = walkWindowsForDay(events, day);
|
||
renderWindowList(
|
||
"walk-list", "walk-empty",
|
||
windows,
|
||
"Walking", "walk-ww",
|
||
);
|
||
|
||
// Total next to the heading, so a collapsed panel still answers "how much
|
||
// did we walk?".
|
||
const from = startOfDay(day).getTime();
|
||
const to = ymd(new Date()) === ymd(day) ? Date.now() : endOfDay(day).getTime();
|
||
const totalMs = walkMsInRange(events, from, to);
|
||
document.getElementById("walk-total").textContent =
|
||
windows.length ? `(${formatDuration(totalMs)})` : "";
|
||
|
||
const goal = walkTargetFor(loadConfig().birthday);
|
||
const goalEl = document.getElementById("walk-goal");
|
||
goalEl.textContent = goal
|
||
? `Rule of thumb at this age: about ${goal.perWalk} min per walk, ${goal.walks}× a day (~${goal.total} min).`
|
||
: "";
|
||
goalEl.hidden = !goal;
|
||
}
|
||
|
||
// Which history rows sit inside a sleep or walk window, so the list can run a
|
||
// dotted rail down the margin from a start row to its end row. It reuses the
|
||
// windows the Sleep and Walks panels already draw, so a pair that crosses
|
||
// midnight is treated the same way here as there. Rows logged in between — a
|
||
// pee taken on a walk — fall inside the bracket, which is the point: the rail
|
||
// says "this happened during that", not merely "these two are a pair".
|
||
//
|
||
// Roles are named for where the row sits in the *rendered* list, which runs
|
||
// newest-first, so a span's chronologically last event is its top row. A span
|
||
// whose boundary isn't itself a row here (an ongoing walk, or one that runs
|
||
// past midnight) leaves that end open, and the rail runs off the list edge
|
||
// rather than stopping at a row that didn't end anything.
|
||
function historyRails(events, dayEvents, day) {
|
||
const rails = new Map();
|
||
const spans = [
|
||
...sleepWindowsForDay(events, day).map(w => ({ ...w, kind: "sleep" })),
|
||
...walkWindowsForDay(events, day).map(w => ({ ...w, kind: "walk" })),
|
||
];
|
||
for (const span of spans) {
|
||
const inside = dayEvents.filter(e => e.at >= span.start && e.at <= span.end);
|
||
if (inside.length < 2) continue; // nothing to tie to
|
||
const closedTop = inside[inside.length - 1].at >= span.end;
|
||
const closedBottom = inside[0].at <= span.start;
|
||
inside.forEach((e, i) => {
|
||
const role =
|
||
i === inside.length - 1 ? (closedTop ? "top" : "mid")
|
||
: i === 0 ? (closedBottom ? "bottom" : "mid")
|
||
: "mid";
|
||
// Sleep is added first, so the (in practice impossible) overlap of a
|
||
// walk and a sleep paints as the walk.
|
||
rails.set(e.id, { kind: span.kind, role });
|
||
});
|
||
}
|
||
return rails;
|
||
}
|
||
|
||
function renderHistory(events) {
|
||
const day = selectedDay();
|
||
const chronological = eventsForDay(events, day);
|
||
const rails = historyRails(events, chronological, day);
|
||
const dayEvents = [...chronological].reverse();
|
||
const exNames = exerciseNames();
|
||
eventList.innerHTML = "";
|
||
if (dayEvents.length === 0) {
|
||
emptyState.hidden = false;
|
||
return;
|
||
}
|
||
emptyState.hidden = true;
|
||
for (const ev of dayEvents) {
|
||
let label = EVENT_LABELS[ev.type] || ev.type;
|
||
if (ev.type === "training" && exNames.get(ev.exerciseId)) {
|
||
label = `Training · ${exNames.get(ev.exerciseId)}`;
|
||
}
|
||
const li = document.createElement("li");
|
||
li.className = "event";
|
||
li.dataset.type = ev.type;
|
||
li.dataset.id = ev.id;
|
||
const rail = rails.get(ev.id);
|
||
if (rail) li.classList.add("rail", `rail-${rail.kind}`, `rail-${rail.role}`);
|
||
li.innerHTML = `
|
||
<span class="dot"></span>
|
||
<span class="time">${formatTime(ev.at)}</span>
|
||
<span class="label">${escapeText(label)}</span>
|
||
<span class="note"></span>
|
||
`;
|
||
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 if (ev.type === "eat" && Number.isFinite(ev.grams) && ev.grams > 0) {
|
||
const g = `${Math.round(ev.grams)} g`;
|
||
noteEl.textContent = ev.note ? `${g} · ${ev.note}` : g;
|
||
} else {
|
||
noteEl.textContent = ev.note || "";
|
||
}
|
||
li.addEventListener("click", () => openEditDialog(ev));
|
||
|
||
for (const pid of photoIdsOf(ev)) {
|
||
const img = document.createElement("img");
|
||
img.className = "thumb";
|
||
img.alt = "photo";
|
||
img.loading = "lazy";
|
||
img.dataset.photoId = pid;
|
||
img.addEventListener("click", (e) => {
|
||
e.stopPropagation(); // don't open the edit dialog
|
||
openLightbox(pid);
|
||
});
|
||
li.appendChild(img);
|
||
photoSrc(pid).then(url => { if (url) img.src = url; });
|
||
}
|
||
|
||
eventList.appendChild(li);
|
||
}
|
||
}
|
||
|
||
// Cross-day log of free-text notes (vaccinations, vet visits, milestones…),
|
||
// newest first. Unlike History this ignores the day picker so the record is
|
||
// always visible regardless of which day you're viewing.
|
||
function renderNotes(events) {
|
||
const notes = events
|
||
.filter(e => e.type === "note")
|
||
.sort((a, b) => b.at - a.at);
|
||
const list = document.getElementById("notes-list");
|
||
const empty = document.getElementById("notes-empty");
|
||
list.innerHTML = "";
|
||
if (notes.length === 0) { empty.hidden = false; return; }
|
||
empty.hidden = true;
|
||
|
||
const birthday = loadConfig().birthday;
|
||
for (const ev of notes) {
|
||
const li = document.createElement("li");
|
||
li.className = "event";
|
||
li.dataset.type = "note";
|
||
li.dataset.id = ev.id;
|
||
const dateStr = new Date(ev.at).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||
const age = formatAgeWeeks(birthday, ev.at);
|
||
li.innerHTML = `
|
||
<span class="dot"></span>
|
||
<span class="note-date">${escapeText(age ? `${dateStr} · ${age}` : dateStr)}</span>
|
||
<span class="note-text"></span>
|
||
`;
|
||
li.querySelector(".note-text").textContent = ev.note || "";
|
||
li.addEventListener("click", () => openEditDialog(ev));
|
||
|
||
for (const pid of photoIdsOf(ev)) {
|
||
const img = document.createElement("img");
|
||
img.className = "thumb";
|
||
img.alt = "photo";
|
||
img.loading = "lazy";
|
||
img.dataset.photoId = pid;
|
||
img.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
openLightbox(pid);
|
||
});
|
||
li.appendChild(img);
|
||
photoSrc(pid).then(url => { if (url) img.src = url; });
|
||
}
|
||
|
||
list.appendChild(li);
|
||
}
|
||
}
|
||
|
||
// ---------- lightbox ----------
|
||
const lightbox = document.getElementById("lightbox");
|
||
const lightboxImg = document.getElementById("lightbox-img");
|
||
const lightboxClose = document.getElementById("lightbox-close");
|
||
|
||
async function openLightbox(photoId) {
|
||
const url = await photoSrc(photoId);
|
||
if (!url) { alert("Photo not yet available (still uploading?)."); return; }
|
||
lightboxImg.src = url;
|
||
lightbox.showModal();
|
||
}
|
||
|
||
lightboxClose.addEventListener("click", () => lightbox.close());
|
||
// Click on backdrop closes the lightbox too.
|
||
lightbox.addEventListener("click", (e) => {
|
||
if (e.target === lightbox) lightbox.close();
|
||
});
|
||
|
||
// ---------- chart window ----------
|
||
// How many days the rolling charts cover. Device-global (like the theme),
|
||
// picked via the 7d/14d/30d buttons in the charts card and applied to every
|
||
// day-window chart: daily bars, sleep timeline, hour heatmap, training grid.
|
||
const CHART_DAYS_KEY = "puppy-tracker:chart-days:v1";
|
||
const CHART_DAY_CHOICES = [7, 14, 30];
|
||
function chartDays() {
|
||
const v = Number(localStorage.getItem(CHART_DAYS_KEY));
|
||
return CHART_DAY_CHOICES.includes(v) ? v : 7;
|
||
}
|
||
function setChartDays(n) {
|
||
try { localStorage.setItem(CHART_DAYS_KEY, String(n)); } catch { /* ignore */ }
|
||
render();
|
||
}
|
||
|
||
// Which metrics the daily-counts chart shows. Device-global, toggled via the
|
||
// checkboxes under the chart. At least one is always kept on so the chart is
|
||
// never empty; an invalid/empty stored value falls back to all three.
|
||
const COUNTS_METRICS_KEY = "puppy-tracker:counts-metrics:v1";
|
||
const COUNTS_METRIC_KEYS = ["pees", "poos", "meals"];
|
||
function countsMetrics() {
|
||
let stored;
|
||
try { stored = JSON.parse(localStorage.getItem(COUNTS_METRICS_KEY)); } catch { /* ignore */ }
|
||
const on = Array.isArray(stored)
|
||
? COUNTS_METRIC_KEYS.filter(k => stored.includes(k))
|
||
: [];
|
||
return on.length ? on : COUNTS_METRIC_KEYS.slice();
|
||
}
|
||
function setCountsMetrics(keys) {
|
||
// Never let the user hide everything — keep at least one metric visible.
|
||
const on = COUNTS_METRIC_KEYS.filter(k => keys.includes(k));
|
||
if (!on.length) { renderChartWindow(); return; } // restore the checkbox we just rejected
|
||
try { localStorage.setItem(COUNTS_METRICS_KEY, JSON.stringify(on)); } catch { /* ignore */ }
|
||
render();
|
||
}
|
||
|
||
// Sync every "(last N days)" header and the picker's active button.
|
||
function renderChartWindow() {
|
||
const n = chartDays();
|
||
document.querySelectorAll("[data-chart-days-label]").forEach(el => {
|
||
el.textContent = `(last ${n} days)`;
|
||
});
|
||
document.querySelectorAll(".chart-days-picker button").forEach(b => {
|
||
b.classList.toggle("active", Number(b.dataset.days) === n);
|
||
});
|
||
const on = countsMetrics();
|
||
document.querySelectorAll("#counts-metrics input[data-metric]").forEach(cb => {
|
||
cb.checked = on.includes(cb.dataset.metric);
|
||
});
|
||
}
|
||
|
||
// ---------- daily charts ----------
|
||
function weeklyData(events) {
|
||
const today = startOfDay(new Date());
|
||
const now = Date.now();
|
||
const days = [];
|
||
for (let i = chartDays() - 1; i >= 0; i--) {
|
||
const d = new Date(today);
|
||
d.setDate(d.getDate() - i);
|
||
const from = startOfDay(d).getTime();
|
||
const to = (i === 0) ? now : endOfDay(d).getTime();
|
||
const sleepMs = sleepMsInRange(events, from, to);
|
||
const dayEvents = eventsForDay(events, d);
|
||
days.push({
|
||
date: d,
|
||
ymd: ymd(d),
|
||
sleepHours: sleepMs / 3_600_000,
|
||
pees: dayEvents.filter(e => e.type === "pee").length,
|
||
poos: dayEvents.filter(e => e.type === "poo").length,
|
||
meals: dayEvents.filter(e => e.type === "eat").length,
|
||
grams: dayEvents
|
||
.filter(e => e.type === "eat" && Number.isFinite(e.grams))
|
||
.reduce((s, e) => s + e.grams, 0),
|
||
walkMinutes: walkMsInRange(events, from, to) / 60_000,
|
||
});
|
||
}
|
||
return days;
|
||
}
|
||
|
||
function dayLabel(date, isToday) {
|
||
if (isToday) return "Today";
|
||
return date.toLocaleDateString(undefined, { weekday: "short" });
|
||
}
|
||
|
||
// Wider windows can't fit a label under every bar: label today and every
|
||
// 2nd (or 4th) day counting back from it, depending on the window.
|
||
function showDayLabel(i, len) {
|
||
const every = len <= 8 ? 1 : len <= 16 ? 2 : 4;
|
||
return (len - 1 - i) % every === 0;
|
||
}
|
||
|
||
// Pick a chart Y maximum and tick count so every tick label is a clean
|
||
// whole number (avoids 0, 0, 1, 1, 2 from rounding fractional steps):
|
||
// 1-unit gridlines up to 10, 2-unit up to 20, 5-unit beyond.
|
||
function niceAxis(rawMax) {
|
||
if (!(rawMax > 0)) return { yMax: 1, steps: 1 };
|
||
if (rawMax <= 10) {
|
||
const m = Math.ceil(rawMax);
|
||
return { yMax: m, steps: m };
|
||
}
|
||
if (rawMax <= 20) {
|
||
const m = Math.ceil(rawMax / 2) * 2;
|
||
return { yMax: m, steps: m / 2 };
|
||
}
|
||
const m = Math.ceil(rawMax / 5) * 5;
|
||
return { yMax: m, steps: m / 5 };
|
||
}
|
||
|
||
// Magnitude-agnostic 0-based axis with a "nice" step, so tick labels stay
|
||
// round whatever the daily totals are (tens of grams for a tiny puppy,
|
||
// hundreds+ later; minutes walked likewise). Aims for ~10 segments so small
|
||
// day-to-day differences still show.
|
||
function niceAxisLinear(rawMax) {
|
||
if (!(rawMax > 0)) return { yMax: 100, steps: 4 };
|
||
const rawStep = rawMax / 10;
|
||
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;
|
||
const yMax = Math.ceil(rawMax / step) * step;
|
||
return { yMax, steps: Math.max(1, Math.round(yMax / step)) };
|
||
}
|
||
|
||
// Sleep-specific axis: 1-hour granularity, capped at 24h/day. Shared by the
|
||
// sleep bars and the trend chart; both thin out labels when steps get dense.
|
||
function niceAxisSleepHours(rawMax) {
|
||
if (!(rawMax > 0)) return { yMax: 2, steps: 2 };
|
||
const m = Math.min(24, Math.max(2, Math.ceil(rawMax)));
|
||
return { yMax: m, steps: m };
|
||
}
|
||
|
||
function escapeText(s) {
|
||
return String(s).replace(/[&<>"']/g, c => (
|
||
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]
|
||
));
|
||
}
|
||
|
||
function setChartSVG(svg, parts) {
|
||
svg.innerHTML = parts.join("");
|
||
svg.querySelectorAll(".bar[data-day]").forEach(b => {
|
||
b.addEventListener("click", () => {
|
||
dayPicker.value = b.dataset.day;
|
||
render();
|
||
});
|
||
});
|
||
}
|
||
|
||
function drawSleepChart(days) {
|
||
const svg = document.getElementById("chart-sleep");
|
||
const W = 320, H = 160;
|
||
const ML = 26, MR = 6, MT = 10, MB = 26;
|
||
const innerW = W - ML - MR;
|
||
const innerH = H - MT - MB;
|
||
|
||
const rawMax = Math.max(...days.map(d => d.sleepHours));
|
||
const { yMax, steps: ySteps } = niceAxisSleepHours(rawMax);
|
||
|
||
const gap = days.length > 14 ? 2 : 4;
|
||
const barW = (innerW - (days.length - 1) * gap) / days.length;
|
||
|
||
// Label every 1h gridline; smaller text once the axis is dense so the
|
||
// labels stay apart (this chart's plot area is shorter than the trend's).
|
||
const yLabelCls = ySteps > 12 ? "y-dense" : "";
|
||
const parts = [];
|
||
for (let i = 0; i <= ySteps; i++) {
|
||
const y = MT + innerH * (1 - i / ySteps);
|
||
const v = Math.round(yMax * i / ySteps * 10) / 10;
|
||
const vText = v % 1 === 0 ? v : v.toFixed(1);
|
||
parts.push(`<line class="grid" x1="${ML}" y1="${y}" x2="${W - MR}" y2="${y}"/>`);
|
||
parts.push(`<text class="${yLabelCls}" x="${ML - 4}" y="${y + 3}" text-anchor="end">${vText}h</text>`);
|
||
}
|
||
|
||
const selYmd = ymd(selectedDay());
|
||
days.forEach((d, i) => {
|
||
const isToday = i === days.length - 1;
|
||
const isSel = d.ymd === selYmd;
|
||
const x = ML + i * (barW + gap);
|
||
const h = (d.sleepHours / yMax) * innerH;
|
||
const y = MT + innerH - h;
|
||
const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${d.sleepHours.toFixed(1)}h`;
|
||
if (isSel) {
|
||
parts.push(`<rect class="day-highlight" x="${(x - gap / 2).toFixed(1)}" y="${MT}" width="${(barW + gap).toFixed(1)}" height="${innerH}" rx="3"/>`);
|
||
}
|
||
parts.push(
|
||
`<rect class="bar bar-sleep ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
||
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
|
||
`<title>${escapeText(title)}</title></rect>`
|
||
);
|
||
// The selected day always gets a label (accent-colored), even on wide
|
||
// windows that would otherwise skip it.
|
||
if (showDayLabel(i, days.length) || isSel) {
|
||
parts.push(
|
||
`<text class="${isSel ? "day-label-sel" : ""}" x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
||
`${escapeText(dayLabel(d.date, isToday))}</text>`
|
||
);
|
||
}
|
||
});
|
||
|
||
setChartSVG(svg, parts);
|
||
}
|
||
|
||
function drawCountsChart(days) {
|
||
const svg = document.getElementById("chart-counts");
|
||
const W = 320, H = 180;
|
||
const ML = 22, MR = 6, MT = 10, MB = 26;
|
||
const innerW = W - ML - MR;
|
||
const innerH = H - MT - MB;
|
||
|
||
const series = [
|
||
{ key: "pees", label: "Pees", cls: "bar-pee" },
|
||
{ key: "poos", label: "Poos", cls: "bar-poo" },
|
||
{ key: "meals", label: "Meals", cls: "bar-eat" },
|
||
].filter(s => countsMetrics().includes(s.key));
|
||
|
||
const rawMax = Math.max(0, ...days.flatMap(d => series.map(s => d[s.key])));
|
||
const { yMax, steps: ySteps } = niceAxis(rawMax);
|
||
|
||
const groupGap = days.length > 14 ? 2 : 4;
|
||
const innerBarGap = days.length > 14 ? 0.5 : 1.5;
|
||
const groupW = (innerW - (days.length - 1) * groupGap) / days.length;
|
||
const barW = (groupW - (series.length - 1) * innerBarGap) / series.length;
|
||
|
||
const parts = [];
|
||
for (let i = 0; i <= ySteps; i++) {
|
||
const y = MT + innerH * (1 - i / ySteps);
|
||
const v = Math.round(yMax * i / ySteps);
|
||
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">${v}</text>`);
|
||
}
|
||
|
||
const selYmd = ymd(selectedDay());
|
||
days.forEach((d, i) => {
|
||
const isToday = i === days.length - 1;
|
||
const isSel = d.ymd === selYmd;
|
||
const groupX = ML + i * (groupW + groupGap);
|
||
|
||
if (isSel) {
|
||
parts.push(`<rect class="day-highlight" x="${(groupX - groupGap / 2).toFixed(1)}" y="${MT}" width="${(groupW + groupGap).toFixed(1)}" height="${innerH}" rx="3"/>`);
|
||
}
|
||
series.forEach((s, j) => {
|
||
const val = d[s.key];
|
||
const x = groupX + j * (barW + innerBarGap);
|
||
const h = (val / yMax) * innerH;
|
||
const y = MT + innerH - h;
|
||
const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${s.label}: ${val}`;
|
||
parts.push(
|
||
`<rect class="bar ${s.cls} ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
||
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="2">` +
|
||
`<title>${escapeText(title)}</title></rect>`
|
||
);
|
||
});
|
||
|
||
if (showDayLabel(i, days.length) || isSel) {
|
||
parts.push(
|
||
`<text class="${isSel ? "day-label-sel" : ""}" x="${groupX + groupW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
||
`${escapeText(dayLabel(d.date, isToday))}</text>`
|
||
);
|
||
}
|
||
});
|
||
|
||
setChartSVG(svg, parts);
|
||
}
|
||
|
||
// Grams of food per day. Hidden entirely until any meal in the window has an
|
||
// amount logged, so the weekly card doesn't grow an empty chart.
|
||
function drawGramsChart(days) {
|
||
const wrap = document.getElementById("grams-chart-wrap");
|
||
const svg = document.getElementById("chart-grams");
|
||
if (!days.some(d => d.grams > 0)) { wrap.hidden = true; return; }
|
||
wrap.hidden = false;
|
||
|
||
const W = 320, H = 160;
|
||
const ML = 34, MR = 6, MT = 10, MB = 26;
|
||
const innerW = W - ML - MR;
|
||
const innerH = H - MT - MB;
|
||
|
||
const { yMax, steps: ySteps } = niceAxisLinear(Math.max(...days.map(d => d.grams)));
|
||
|
||
const gap = days.length > 14 ? 2 : 4;
|
||
const barW = (innerW - (days.length - 1) * gap) / days.length;
|
||
|
||
const parts = [];
|
||
for (let i = 0; i <= ySteps; i++) {
|
||
const y = MT + innerH * (1 - i / ySteps);
|
||
const v = yMax * i / ySteps;
|
||
const vText = v % 1 === 0 ? v : v.toFixed(1);
|
||
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">${vText}</text>`);
|
||
}
|
||
|
||
const selYmd = ymd(selectedDay());
|
||
days.forEach((d, i) => {
|
||
const isToday = i === days.length - 1;
|
||
const isSel = d.ymd === selYmd;
|
||
const x = ML + i * (barW + gap);
|
||
const h = (d.grams / yMax) * innerH;
|
||
const y = MT + innerH - h;
|
||
const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${Math.round(d.grams)} g`;
|
||
if (isSel) {
|
||
parts.push(`<rect class="day-highlight" x="${(x - gap / 2).toFixed(1)}" y="${MT}" width="${(barW + gap).toFixed(1)}" height="${innerH}" rx="3"/>`);
|
||
}
|
||
parts.push(
|
||
`<rect class="bar bar-eat ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
||
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
|
||
`<title>${escapeText(title)}</title></rect>`
|
||
);
|
||
if (showDayLabel(i, days.length) || isSel) {
|
||
parts.push(
|
||
`<text class="${isSel ? "day-label-sel" : ""}" x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
||
`${escapeText(dayLabel(d.date, isToday))}</text>`
|
||
);
|
||
}
|
||
});
|
||
|
||
setChartSVG(svg, parts);
|
||
}
|
||
|
||
// Minutes walked per day. Hidden until there's a walk to show, like the
|
||
// grams chart — no point in an empty panel for someone who doesn't log walks.
|
||
function drawWalkChart(days) {
|
||
const wrap = document.getElementById("walk-chart-wrap");
|
||
const svg = document.getElementById("chart-walk");
|
||
if (!days.some(d => d.walkMinutes > 0)) { wrap.hidden = true; return; }
|
||
wrap.hidden = false;
|
||
|
||
const W = 320, H = 160;
|
||
const ML = 30, MR = 6, MT = 10, MB = 26;
|
||
const innerW = W - ML - MR;
|
||
const innerH = H - MT - MB;
|
||
|
||
const { yMax, steps: ySteps } = niceAxisLinear(Math.max(...days.map(d => d.walkMinutes)));
|
||
|
||
const gap = days.length > 14 ? 2 : 4;
|
||
const barW = (innerW - (days.length - 1) * gap) / days.length;
|
||
|
||
const parts = [];
|
||
for (let i = 0; i <= ySteps; i++) {
|
||
const y = MT + innerH * (1 - i / ySteps);
|
||
const v = yMax * i / ySteps;
|
||
const vText = v % 1 === 0 ? v : v.toFixed(1);
|
||
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">${vText}</text>`);
|
||
}
|
||
|
||
const selYmd = ymd(selectedDay());
|
||
days.forEach((d, i) => {
|
||
const isToday = i === days.length - 1;
|
||
const isSel = d.ymd === selYmd;
|
||
const x = ML + i * (barW + gap);
|
||
const h = (d.walkMinutes / yMax) * innerH;
|
||
const y = MT + innerH - h;
|
||
const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${formatDuration(d.walkMinutes * 60_000)}`;
|
||
if (isSel) {
|
||
parts.push(`<rect class="day-highlight" x="${(x - gap / 2).toFixed(1)}" y="${MT}" width="${(barW + gap).toFixed(1)}" height="${innerH}" rx="3"/>`);
|
||
}
|
||
parts.push(
|
||
`<rect class="bar bar-walk ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
||
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
|
||
`<title>${escapeText(title)}</title></rect>`
|
||
);
|
||
if (showDayLabel(i, days.length) || isSel) {
|
||
parts.push(
|
||
`<text class="${isSel ? "day-label-sel" : ""}" x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
||
`${escapeText(dayLabel(d.date, isToday))}</text>`
|
||
);
|
||
}
|
||
});
|
||
|
||
setChartSVG(svg, parts);
|
||
}
|
||
|
||
function renderWeekly(events) {
|
||
const days = weeklyData(events);
|
||
drawSleepChart(days);
|
||
drawCountsChart(days);
|
||
drawWalkChart(days);
|
||
drawGramsChart(days);
|
||
}
|
||
|
||
// ---------- pattern charts ----------
|
||
|
||
// Actogram: one row per day (oldest at top), a midnight-to-midnight track with
|
||
// the puppy's sleep shaded. Sleep windows are clipped to each day, so a night
|
||
// that crosses midnight shows correctly split across two rows. Today's open
|
||
// sleep runs to now (sleepWindows already clips ongoing sleep to Date.now()).
|
||
function renderSleepTimeline(events) {
|
||
const svg = document.getElementById("chart-sleep-timeline");
|
||
if (!svg) return;
|
||
const N = chartDays();
|
||
const W = 320;
|
||
const ML = 44, MR = 8, MT = 16, MB = 4;
|
||
const innerW = W - ML - MR;
|
||
const rowGap = 2;
|
||
// Fixed row height, chart grows with the window (13px × 14 days matches
|
||
// the original 228-high viewBox; wider windows use thinner rows).
|
||
const rowH = N > 14 ? 9 : 13;
|
||
const H = MT + MB + N * rowH + (N - 1) * rowGap;
|
||
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
||
const dayMs = 86_400_000;
|
||
|
||
const today = startOfDay(new Date());
|
||
const windows = sleepWindows(events);
|
||
const xOf = (frac) => ML + frac * innerW;
|
||
|
||
const parts = [];
|
||
for (const hr of [0, 6, 12, 18, 24]) {
|
||
const x = xOf(hr / 24);
|
||
parts.push(`<line class="grid" x1="${x.toFixed(1)}" y1="${MT}" x2="${x.toFixed(1)}" y2="${H - MB}"/>`);
|
||
const anchor = hr === 0 ? "start" : hr === 24 ? "end" : "middle";
|
||
parts.push(`<text x="${x.toFixed(1)}" y="${MT - 5}" text-anchor="${anchor}">${hr}h</text>`);
|
||
}
|
||
|
||
const selYmd = ymd(selectedDay());
|
||
for (let i = 0; i < N; i++) {
|
||
const day = new Date(today);
|
||
day.setDate(day.getDate() - (N - 1 - i)); // oldest at top, today at bottom
|
||
const dayStart = startOfDay(day).getTime();
|
||
const dayEnd = dayStart + dayMs;
|
||
const isToday = ymd(day) === ymd(new Date());
|
||
const isSel = ymd(day) === selYmd;
|
||
const y = MT + i * (rowH + rowGap);
|
||
|
||
parts.push(`<rect class="stl-track${isSel ? " stl-selected" : ""}" x="${ML}" y="${y.toFixed(1)}" width="${innerW}" height="${rowH.toFixed(1)}" rx="2"/>`);
|
||
|
||
for (const wdw of windows) {
|
||
const s = Math.max(wdw.start, dayStart);
|
||
const e = Math.min(wdw.end, dayEnd);
|
||
if (e <= s) continue;
|
||
const x = xOf((s - dayStart) / dayMs);
|
||
const wpx = ((e - s) / dayMs) * innerW;
|
||
parts.push(`<rect class="stl-sleep" x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${Math.max(0.6, wpx).toFixed(1)}" height="${rowH.toFixed(1)}" rx="1.5"/>`);
|
||
}
|
||
|
||
const label = isToday ? "Today" : `${day.toLocaleDateString(undefined, { weekday: "short" })} ${day.getDate()}`;
|
||
parts.push(`<text class="stl-day ${isToday ? "stl-today" : ""}${isSel ? " stl-sel" : ""}" x="${ML - 6}" y="${(y + rowH / 2 + 3).toFixed(1)}" text-anchor="end">${escapeText(label)}</text>`);
|
||
|
||
const title = day.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" });
|
||
parts.push(`<rect class="bar stl-hit" data-day="${ymd(day)}" x="${ML}" y="${y.toFixed(1)}" width="${innerW}" height="${rowH.toFixed(1)}"><title>${escapeText(title)}</title></rect>`);
|
||
}
|
||
|
||
setChartSVG(svg, parts); // wires the .bar[data-day] click → select that day
|
||
}
|
||
|
||
// Which heatmap block is focused, remembered across re-renders so a background
|
||
// sync doesn't wipe the block the user just tapped. The counts behind it are
|
||
// recomputed every render, so the readout stays current.
|
||
let hourCellSel = null; // `${type}-${hour}`, or null for nothing focused
|
||
const HOUR_CELL_HINT = "Tap a block to see how many it counts.";
|
||
|
||
// Hour-of-day heatmap: one row per event type, 24 cells shaded by how often
|
||
// that event lands in each hour across the window. Reveals daily rhythm that
|
||
// the median gap can't show (e.g. "always poos ~7am and ~6pm").
|
||
function renderHourHeatmap(events) {
|
||
const svg = document.getElementById("chart-hour-heatmap");
|
||
if (!svg) return;
|
||
const info = document.getElementById("hour-heatmap-info");
|
||
const from = startOfDay(new Date(Date.now() - (chartDays() - 1) * 86_400_000)).getTime();
|
||
|
||
const series = [
|
||
{ type: "pee", label: "Pees", cls: "hm-pee", one: "pee", many: "pees" },
|
||
{ type: "poo", label: "Poos", cls: "hm-poo", one: "poo", many: "poos" },
|
||
{ type: "eat", label: "Meals", cls: "hm-eat", one: "meal", many: "meals" },
|
||
];
|
||
const counts = {};
|
||
for (const s of series) counts[s.type] = new Array(24).fill(0);
|
||
for (const e of events) {
|
||
if (e.at < from) continue;
|
||
if (counts[e.type]) counts[e.type][new Date(e.at).getHours()]++;
|
||
}
|
||
|
||
const W = 320, H = 120;
|
||
const ML = 40, MR = 8, MT = 6, MB = 18;
|
||
const innerW = W - ML - MR;
|
||
const rowGap = 6;
|
||
const rowH = (H - MT - MB - (series.length - 1) * rowGap) / series.length;
|
||
const cellW = innerW / 24;
|
||
|
||
// "3 meals between 07:00 and 08:00" — self-describing, so the same string
|
||
// serves as the pointer tooltip and as the tap readout under the chart.
|
||
const cellText = (s, h, c) =>
|
||
`${c === 0 ? "No" : c} ${c === 1 ? s.one : s.many} between ` +
|
||
`${pad2(h)}:00 and ${pad2((h + 1) % 24)}:00`;
|
||
|
||
const parts = [];
|
||
const hits = []; // appended last so they sit above every row
|
||
const details = {}; // cell key → readout text
|
||
series.forEach((s, r) => {
|
||
const y = MT + r * (rowH + rowGap);
|
||
const max = Math.max(1, ...counts[s.type]);
|
||
for (let h = 0; h < 24; h++) {
|
||
const c = counts[s.type][h];
|
||
const op = c === 0 ? 0.06 : 0.2 + 0.8 * (c / max);
|
||
const x = ML + h * cellW;
|
||
const key = `${s.type}-${h}`;
|
||
details[key] = cellText(s, h, c);
|
||
parts.push(
|
||
`<rect class="hm-cell ${s.cls}" data-cell="${key}" x="${x.toFixed(1)}" y="${y.toFixed(1)}" ` +
|
||
`width="${(cellW - 1).toFixed(1)}" height="${rowH.toFixed(1)}" rx="1.5" fill-opacity="${op.toFixed(2)}"/>`
|
||
);
|
||
// A cell is only ~12px wide on a phone, so the tap target claims the
|
||
// spacing between cells and half the gap to the neighbouring rows.
|
||
hits.push(
|
||
`<rect class="hm-hit" data-cell="${key}" x="${x.toFixed(1)}" y="${(y - rowGap / 2).toFixed(1)}" ` +
|
||
`width="${cellW.toFixed(1)}" height="${(rowH + rowGap).toFixed(1)}">` +
|
||
`<title>${escapeText(details[key])}</title></rect>`
|
||
);
|
||
}
|
||
parts.push(`<text x="${ML - 6}" y="${(y + rowH / 2 + 3).toFixed(1)}" text-anchor="end">${s.label}</text>`);
|
||
});
|
||
|
||
const yAxis = H - MB + 12;
|
||
for (const hr of [0, 6, 12, 18]) {
|
||
const x = ML + (hr / 24) * innerW;
|
||
parts.push(`<text x="${x.toFixed(1)}" y="${yAxis}" text-anchor="${hr === 0 ? "start" : "middle"}">${hr}h</text>`);
|
||
}
|
||
parts.push(`<text x="${(ML + innerW).toFixed(1)}" y="${yAxis}" text-anchor="end">24h</text>`);
|
||
|
||
svg.innerHTML = parts.concat(hits).join("");
|
||
|
||
// <title> tooltips only ever show on a pointer, which left phones with no
|
||
// way to read a block's count. Tapping (or hovering) a block names it in
|
||
// the caption under the chart.
|
||
const cells = svg.querySelectorAll(".hm-cell[data-cell]");
|
||
const focusCell = (key) => {
|
||
hourCellSel = details[key] ? key : null;
|
||
cells.forEach(c => c.classList.toggle("hm-active", c.dataset.cell === hourCellSel));
|
||
if (info) info.textContent = hourCellSel ? details[hourCellSel] : HOUR_CELL_HINT;
|
||
};
|
||
svg.querySelectorAll(".hm-hit").forEach(hit => {
|
||
const key = hit.dataset.cell;
|
||
// Select, never toggle. A tap fires the emulated mouseenter and then the
|
||
// click on the same block, so a toggle here selected on the first of the
|
||
// pair and cleared on the second — the count only appeared on a later tap
|
||
// that arrived without a fresh mouseenter. Selecting twice is a no-op.
|
||
hit.addEventListener("click", () => focusCell(key));
|
||
hit.addEventListener("mouseenter", () => focusCell(key));
|
||
});
|
||
focusCell(hourCellSel);
|
||
}
|
||
|
||
// ---------- sleep trend ----------
|
||
// Cumulative hours slept as the selected day progresses: that day's running
|
||
// total (up to now when it's today, else the full 24h) against the previous
|
||
// day's full curve and the mean of the N full days before it, where N
|
||
// follows the 7/14/30 chart-days picker.
|
||
// Each curve is a list of { x: hour-of-day 0..24, y: cumulative hours }.
|
||
function sleepTrendCurves(events) {
|
||
const HOUR = 3_600_000;
|
||
const windows = sleepWindows(events); // ongoing sleep already clipped to now
|
||
const sleptMs = (from, to) => {
|
||
let total = 0;
|
||
for (const w of windows) {
|
||
const s = Math.max(w.start, from);
|
||
const e = Math.min(w.end, to);
|
||
if (e > s) total += e - s;
|
||
}
|
||
return total;
|
||
};
|
||
const day = selectedDay();
|
||
const isToday = ymd(day) === ymd(new Date());
|
||
const dayStartTs = (daysAgo) => {
|
||
const d = startOfDay(day);
|
||
d.setDate(d.getDate() - daysAgo);
|
||
return d.getTime();
|
||
};
|
||
// capTs (today only) truncates the curve at "now" with a final fractional
|
||
// point, so the line visibly ends where the day currently stands.
|
||
const curveFor = (start, capTs) => {
|
||
const pts = [];
|
||
for (let h = 0; h <= 24; h++) {
|
||
const to = start + h * HOUR;
|
||
if (capTs != null && to >= capTs) {
|
||
pts.push({ x: (capTs - start) / HOUR, y: sleptMs(start, capTs) / HOUR });
|
||
break;
|
||
}
|
||
pts.push({ x: h, y: sleptMs(start, to) / HOUR });
|
||
}
|
||
return pts;
|
||
};
|
||
|
||
// A past day is complete, so its curve runs the full 24h uncapped.
|
||
const today = curveFor(dayStartTs(0), isToday ? Date.now() : null);
|
||
|
||
const yesterdayCurve = curveFor(dayStartTs(1));
|
||
const yesterday = yesterdayCurve[24].y > 0 ? yesterdayCurve : null;
|
||
|
||
// Mean of the last N full days, skipping days with no sleep at all so a
|
||
// young log (or a tracking gap) doesn't drag the average toward zero.
|
||
const avgDays = chartDays();
|
||
const dayCurves = [];
|
||
for (let i = 1; i <= avgDays; i++) {
|
||
const c = curveFor(dayStartTs(i));
|
||
if (c[24].y > 0) dayCurves.push(c);
|
||
}
|
||
let avg = null;
|
||
if (dayCurves.length > 0) {
|
||
avg = [];
|
||
for (let h = 0; h <= 24; h++) {
|
||
avg.push({ x: h, y: dayCurves.reduce((s, c) => s + c[h].y, 0) / dayCurves.length });
|
||
}
|
||
}
|
||
|
||
// Where today is likely to end up: continue today's line by adding what
|
||
// the average day typically adds between now and midnight. That respects
|
||
// the time-of-day rhythm (night sleep, nap clusters), unlike a linear
|
||
// rate extrapolation, which overshoots wildly just after a long night.
|
||
// No history → no average → no projection. Past days are already complete,
|
||
// so there is nothing to project.
|
||
let projected = null;
|
||
if (avg && isToday) {
|
||
const nowPt = today[today.length - 1];
|
||
const avgAt = (x) => {
|
||
const lo = Math.floor(x);
|
||
const hi = Math.min(24, lo + 1);
|
||
return avg[lo].y + (avg[hi].y - avg[lo].y) * (x - lo);
|
||
};
|
||
projected = [{ x: nowPt.x, y: nowPt.y }];
|
||
for (let h = Math.ceil(nowPt.x); h <= 24; h++) {
|
||
if (h <= nowPt.x) continue; // now landing exactly on an hour boundary
|
||
projected.push({ x: h, y: nowPt.y + avgAt(h) - avgAt(nowPt.x) });
|
||
}
|
||
}
|
||
|
||
// Labels follow the selection so a past day reads as its date, not "Today".
|
||
const fmtDay = (daysAgo) =>
|
||
new Date(dayStartTs(daysAgo)).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||
const dayLabel = isToday ? "Today" : fmtDay(0);
|
||
const prevDayLabel = isToday ? "Yesterday" : fmtDay(1);
|
||
|
||
return { today, yesterday, avg, avgDays, projected, dayLabel, prevDayLabel };
|
||
}
|
||
|
||
function drawSleepTrendChart(curves, target) {
|
||
const svg = document.getElementById("chart-sleep-trend");
|
||
if (!svg) return;
|
||
const W = 320, H = 220;
|
||
const ML = 26, MR = 8, MT = 10, MB = 22;
|
||
const innerW = W - ML - MR;
|
||
const innerH = H - MT - MB;
|
||
|
||
const series = [
|
||
// Reference lines first so today (and its projected tail) draw on top.
|
||
{ pts: curves.avg, cls: "trend-avg", label: `${curves.avgDays}-day average` },
|
||
{ pts: curves.yesterday, cls: "trend-yesterday", label: curves.prevDayLabel },
|
||
{ pts: curves.projected, cls: "trend-projected", label: "Projected end of day" },
|
||
{ pts: curves.today, cls: "trend-today", label: curves.dayLabel },
|
||
].filter(s => s.pts && s.pts.length > 1);
|
||
|
||
// The axis must reach the goal band's top even when the data doesn't yet.
|
||
const rawMax = Math.max(
|
||
target ? target.hi : 0,
|
||
...series.flatMap(s => s.pts.map(p => p.y)),
|
||
);
|
||
const { yMax } = niceAxisSleepHours(rawMax);
|
||
|
||
// The interesting detail sits near the goal, in the upper teens of hours,
|
||
// so the y scale is piecewise: everything up to BREAK shares a compressed
|
||
// slice of the height and the hours above it get all the rest. Linear
|
||
// again while the axis is still too short for a split to mean anything.
|
||
const BREAK = 10, LOW_FRAC = 0.26;
|
||
const split = yMax > BREAK + 2;
|
||
const frac = (v) => !split
|
||
? v / yMax
|
||
: v <= BREAK
|
||
? (v / BREAK) * LOW_FRAC
|
||
: LOW_FRAC + ((v - BREAK) / (yMax - BREAK)) * (1 - LOW_FRAC);
|
||
const xOf = (hour) => ML + (hour / 24) * innerW;
|
||
const yOf = (v) => MT + innerH * (1 - frac(v));
|
||
|
||
// Gridlines: every 1h in the stretched region above the break, every 2h
|
||
// in the compressed region below it (1h everywhere when linear).
|
||
const yTicks = [];
|
||
if (split) {
|
||
for (let v = 0; v < BREAK; v += 2) yTicks.push(v);
|
||
for (let v = BREAK; v <= yMax; v++) yTicks.push(v);
|
||
} else {
|
||
for (let v = 0; v <= yMax; v++) yTicks.push(v);
|
||
}
|
||
// Smaller text once the axis is dense so the labels stay apart.
|
||
const yLabelCls = yTicks.length > 16 ? "y-dense" : "";
|
||
const parts = [];
|
||
|
||
// Age-based goal band, behind everything: today's projection should land
|
||
// inside it by midnight.
|
||
if (target) {
|
||
const yTop = yOf(target.hi);
|
||
const yBot = yOf(target.lo);
|
||
parts.push(
|
||
`<rect class="trend-goal" x="${ML}" y="${yTop.toFixed(1)}" ` +
|
||
`width="${innerW}" height="${(yBot - yTop).toFixed(1)}">` +
|
||
`<title>${escapeText(`Goal ${target.lo}–${target.hi}h (${target.label})`)}</title></rect>`
|
||
);
|
||
}
|
||
for (const v of yTicks) {
|
||
const y = yOf(v);
|
||
parts.push(`<line class="grid" x1="${ML}" y1="${y.toFixed(1)}" x2="${W - MR}" y2="${y.toFixed(1)}"/>`);
|
||
parts.push(`<text class="${yLabelCls}" x="${ML - 4}" y="${(y + 3).toFixed(1)}" text-anchor="end">${v}h</text>`);
|
||
}
|
||
for (const hr of [0, 6, 12, 18, 24]) {
|
||
const x = xOf(hr);
|
||
parts.push(`<line class="grid" x1="${x.toFixed(1)}" y1="${MT}" x2="${x.toFixed(1)}" y2="${MT + innerH}"/>`);
|
||
const anchor = hr === 0 ? "start" : hr === 24 ? "end" : "middle";
|
||
parts.push(`<text x="${x.toFixed(1)}" y="${H - MB + 14}" text-anchor="${anchor}">${pad2(hr)}</text>`);
|
||
}
|
||
|
||
for (const s of series) {
|
||
const d = s.pts
|
||
.map((p, i) => `${i === 0 ? "M" : "L"}${xOf(p.x).toFixed(1)} ${yOf(p.y).toFixed(1)}`)
|
||
.join(" ");
|
||
const last = s.pts[s.pts.length - 1];
|
||
const title = `${s.label} — ${last.y.toFixed(1)}h slept`;
|
||
parts.push(`<path class="${s.cls}" d="${d}"><title>${escapeText(title)}</title></path>`);
|
||
}
|
||
|
||
svg.innerHTML = parts.join("");
|
||
}
|
||
|
||
function renderSleepTrend(events) {
|
||
const curves = sleepTrendCurves(events);
|
||
const target = sleepTargetFor(loadConfig().birthday);
|
||
drawSleepTrendChart(curves, target);
|
||
// Drop legend chips for missing reference lines (no data for them yet),
|
||
// keep the average chip's label in step with the chart-days picker, and
|
||
// write each curve's slept-hours total into its chip.
|
||
const chip = (id) => document.getElementById(id);
|
||
const hrs = (pts) => `${pts[pts.length - 1].y.toFixed(1)}h`;
|
||
chip("legend-trend-today-text").textContent = `${curves.dayLabel} ${hrs(curves.today)}`;
|
||
const yLegend = chip("legend-trend-yesterday");
|
||
yLegend.hidden = !curves.yesterday;
|
||
if (curves.yesterday) {
|
||
chip("legend-trend-yesterday-text").textContent = `${curves.prevDayLabel} ${hrs(curves.yesterday)}`;
|
||
}
|
||
const aLegend = chip("legend-trend-avg");
|
||
aLegend.hidden = !curves.avg;
|
||
if (curves.avg) {
|
||
chip("legend-trend-avg-text").textContent = `${curves.avgDays}-day avg ${hrs(curves.avg)}`;
|
||
}
|
||
const pLegend = chip("legend-trend-projected");
|
||
pLegend.hidden = !curves.projected;
|
||
if (curves.projected) {
|
||
// ✓ when the projection reaches at least the goal's lower bound.
|
||
let mark = "";
|
||
if (target) {
|
||
const end = curves.projected[curves.projected.length - 1].y;
|
||
mark = end >= target.lo ? " ✓" : " ▽";
|
||
pLegend.title = end >= target.lo
|
||
? "On track for the sleep goal"
|
||
: "Projected below the sleep goal";
|
||
}
|
||
chip("legend-trend-projected-text").textContent = `Projected ~${hrs(curves.projected)}${mark}`;
|
||
}
|
||
const gLegend = chip("legend-trend-goal");
|
||
gLegend.hidden = !target;
|
||
if (target) {
|
||
chip("legend-trend-goal-text").textContent =
|
||
`Goal ${target.lo}–${target.hi}h (${target.label})`;
|
||
}
|
||
}
|
||
|
||
// ---------- 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)) };
|
||
}
|
||
|
||
// Human-readable detail for one weigh-in: date, weight, and the puppy's age
|
||
// at that date (omitted if no birthday is configured).
|
||
function weightPointInfo(w, birthday) {
|
||
const dateStr = new Date(w.at).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||
const age = formatAge(birthday, w.at);
|
||
return `${dateStr} — ${formatWeight(w.weight)}${age ? ` · ${age}` : ""}`;
|
||
}
|
||
|
||
function drawWeightChart(weights, birthday) {
|
||
const svg = document.getElementById("chart-weight");
|
||
const info = document.getElementById("weight-point-info");
|
||
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 = ""; info.textContent = ""; 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}"/>`);
|
||
}
|
||
|
||
// Visible dots, then larger transparent hit targets on top (easier to tap
|
||
// on touch, and they carry the tooltip + click detail).
|
||
weights.forEach(w => {
|
||
parts.push(`<circle class="weight-dot" cx="${xOf(w.at).toFixed(1)}" cy="${yOf(w.weight).toFixed(1)}" r="3.5"/>`);
|
||
});
|
||
weights.forEach((w, i) => {
|
||
const detail = weightPointInfo(w, birthday);
|
||
parts.push(
|
||
`<circle class="weight-hit" data-i="${i}" cx="${xOf(w.at).toFixed(1)}" cy="${yOf(w.weight).toFixed(1)}" r="10">` +
|
||
`<title>${escapeText(detail)}</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("");
|
||
|
||
// Default the caption to the most recent weigh-in; hover/tap focuses a point.
|
||
const hits = svg.querySelectorAll(".weight-hit");
|
||
const focus = (i) => {
|
||
info.textContent = weightPointInfo(weights[i], birthday);
|
||
hits.forEach(h => h.classList.toggle("active", Number(h.dataset.i) === i));
|
||
};
|
||
hits.forEach(h => {
|
||
const i = Number(h.dataset.i);
|
||
h.addEventListener("mouseenter", () => focus(i));
|
||
h.addEventListener("click", () => focus(i));
|
||
});
|
||
info.textContent = weightPointInfo(weights[weights.length - 1], birthday);
|
||
}
|
||
|
||
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");
|
||
|
||
const birthday = loadConfig().birthday;
|
||
|
||
list.innerHTML = "";
|
||
changeEl.classList.remove("up", "down");
|
||
|
||
if (weights.length === 0) {
|
||
empty.hidden = false;
|
||
latestEl.textContent = "—";
|
||
changeEl.textContent = "—";
|
||
drawWeightChart([], birthday);
|
||
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";
|
||
const age = formatAgeWeeks(birthday, w.at);
|
||
const dateStr = new Date(w.at).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||
date.textContent = age ? `${dateStr} · ${age}` : dateStr;
|
||
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, birthday);
|
||
}
|
||
|
||
// ---------- training ----------
|
||
// Per-exercise stats plus a consistency heatmap. Everything here is rolling
|
||
// (last session / last 7 days / streak / chart window) rather than scoped to
|
||
// the day picker — the point is keeping the habit up, not reviewing one day.
|
||
const expandedExercises = new Set(); // ids showing their instructions (per page load)
|
||
|
||
function trainingStreakDays(times) {
|
||
// Consecutive days with ≥1 session, counting back from today — or from
|
||
// yesterday, so a streak isn't shown as broken before today's session
|
||
// has had a chance to happen.
|
||
const days = new Set(times.map(t => ymd(new Date(t))));
|
||
const d = new Date();
|
||
if (!days.has(ymd(d))) d.setDate(d.getDate() - 1);
|
||
let streak = 0;
|
||
while (days.has(ymd(d))) { streak++; d.setDate(d.getDate() - 1); }
|
||
return streak;
|
||
}
|
||
|
||
function exerciseMeta(times) {
|
||
if (times.length === 0) return "not yet trained";
|
||
const weekFrom = startOfDay(new Date());
|
||
weekFrom.setDate(weekFrom.getDate() - 6);
|
||
const week = times.filter(t => t >= weekFrom.getTime()).length;
|
||
const parts = [`last ${formatRelative(Math.max(...times))}`, `${week}× this week`];
|
||
const streak = trainingStreakDays(times);
|
||
if (streak >= 2) parts.push(`🔥 ${streak}-day streak`);
|
||
return parts.join(" · ");
|
||
}
|
||
|
||
// Exercise × day grid: one row per exercise, one cell per day, opacity scaled
|
||
// by that day's session count. Same idea as the hour heatmap, with days for
|
||
// columns. Cells click through to the day picker via setChartSVG.
|
||
function renderTrainingHeatmap(exercises, events) {
|
||
const wrap = document.getElementById("training-chart-wrap");
|
||
const svg = document.getElementById("chart-training");
|
||
if (exercises.length === 0) { wrap.hidden = true; return; }
|
||
wrap.hidden = false;
|
||
|
||
const N = chartDays();
|
||
const from = startOfDay(new Date());
|
||
from.setDate(from.getDate() - (N - 1));
|
||
const dayList = [];
|
||
const dayIndex = new Map(); // ymd -> column
|
||
for (let i = 0; i < N; i++) {
|
||
const d = new Date(from);
|
||
d.setDate(d.getDate() + i);
|
||
dayList.push(d);
|
||
dayIndex.set(ymd(d), i);
|
||
}
|
||
const exIndex = new Map(exercises.map((x, i) => [x.id, i]));
|
||
const counts = exercises.map(() => new Array(N).fill(0));
|
||
for (const e of events) {
|
||
if (e.type !== "training") continue;
|
||
const r = exIndex.get(e.exerciseId);
|
||
const c = dayIndex.get(ymd(new Date(e.at)));
|
||
if (r !== undefined && c !== undefined) counts[r][c]++;
|
||
}
|
||
|
||
const W = 320;
|
||
const ML = 70, MR = 8, MT = 6, MB = 18;
|
||
const rowH = 16, rowGap = 4;
|
||
const H = MT + exercises.length * (rowH + rowGap) - rowGap + MB;
|
||
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
||
const innerW = W - ML - MR;
|
||
const cellW = innerW / N;
|
||
|
||
const parts = [];
|
||
const selCol = dayIndex.get(ymd(selectedDay()));
|
||
if (selCol !== undefined) {
|
||
parts.push(
|
||
`<rect class="day-highlight" x="${(ML + selCol * cellW - 0.75).toFixed(1)}" y="${MT - 2}" ` +
|
||
`width="${cellW.toFixed(1)}" height="${(H - MT - MB + 4).toFixed(1)}" rx="3"/>`
|
||
);
|
||
}
|
||
exercises.forEach((x, r) => {
|
||
const y = MT + r * (rowH + rowGap);
|
||
const max = Math.max(1, ...counts[r]);
|
||
for (let c = 0; c < N; c++) {
|
||
const n = counts[r][c];
|
||
const op = n === 0 ? 0.06 : 0.35 + 0.65 * (n / max);
|
||
const d = dayList[c];
|
||
const title = `${x.name} · ${d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })}: ${n}`;
|
||
parts.push(
|
||
`<rect class="bar hm-cell hm-training" data-day="${ymd(d)}" ` +
|
||
`x="${(ML + c * cellW).toFixed(1)}" y="${y.toFixed(1)}" ` +
|
||
`width="${(cellW - 1.5).toFixed(1)}" height="${rowH}" rx="2" fill-opacity="${op.toFixed(2)}">` +
|
||
`<title>${escapeText(title)}</title></rect>`
|
||
);
|
||
}
|
||
const name = x.name.length > 12 ? x.name.slice(0, 11) + "…" : x.name;
|
||
parts.push(`<text x="${ML - 6}" y="${(y + rowH / 2 + 3).toFixed(1)}" text-anchor="end">${escapeText(name)}</text>`);
|
||
});
|
||
|
||
const yAxis = H - MB + 12;
|
||
parts.push(`<text x="${ML}" y="${yAxis}" text-anchor="start">${escapeText(dayList[0].toLocaleDateString(undefined, { month: "short", day: "numeric" }))}</text>`);
|
||
parts.push(`<text x="${ML + innerW}" y="${yAxis}" text-anchor="end">Today</text>`);
|
||
|
||
setChartSVG(svg, parts);
|
||
}
|
||
|
||
function renderTraining(events) {
|
||
const list = document.getElementById("training-list");
|
||
const empty = document.getElementById("training-empty");
|
||
const exercises = liveExercises();
|
||
list.innerHTML = "";
|
||
empty.hidden = exercises.length > 0;
|
||
|
||
for (const ex of exercises) {
|
||
const times = events
|
||
.filter(e => e.type === "training" && e.exerciseId === ex.id)
|
||
.map(e => e.at);
|
||
|
||
const li = document.createElement("li");
|
||
li.className = "exercise" + (expandedExercises.has(ex.id) ? " expanded" : "");
|
||
|
||
const row = document.createElement("div");
|
||
row.className = "ex-row";
|
||
const main = document.createElement("div");
|
||
main.className = "ex-main";
|
||
const nameEl = document.createElement("span");
|
||
nameEl.className = "ex-name";
|
||
nameEl.textContent = ex.name;
|
||
const metaEl = document.createElement("span");
|
||
metaEl.className = "ex-meta";
|
||
metaEl.textContent = exerciseMeta(times);
|
||
main.appendChild(nameEl);
|
||
main.appendChild(metaEl);
|
||
|
||
const logBtn = document.createElement("button");
|
||
logBtn.type = "button";
|
||
logBtn.className = "ex-log";
|
||
logBtn.textContent = "Log";
|
||
logBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
// Log onto the selected day (at the current time of day), so a session
|
||
// that was forgotten yesterday can be back-filled from that day's view.
|
||
const day = selectedDay();
|
||
const onToday = ymd(day) === ymd(new Date());
|
||
let at = Date.now();
|
||
if (!onToday) {
|
||
const now = new Date();
|
||
at = new Date(day.getFullYear(), day.getMonth(), day.getDate(),
|
||
now.getHours(), now.getMinutes(), now.getSeconds()).getTime();
|
||
}
|
||
const ev = addEvent("training", "", at, { exerciseId: ex.id });
|
||
const dayStr = day.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||
showSnackbar(onToday ? `${ex.name} logged` : `${ex.name} logged for ${dayStr}`, ev);
|
||
});
|
||
|
||
row.appendChild(main);
|
||
row.appendChild(logBtn);
|
||
row.addEventListener("click", () => {
|
||
if (expandedExercises.has(ex.id)) expandedExercises.delete(ex.id);
|
||
else expandedExercises.add(ex.id);
|
||
li.classList.toggle("expanded");
|
||
});
|
||
|
||
const detail = document.createElement("div");
|
||
detail.className = "ex-detail";
|
||
const noteEl = document.createElement("p");
|
||
noteEl.className = "ex-note";
|
||
noteEl.textContent = ex.note || "No instructions yet — tap Edit to add how to train this.";
|
||
const editBtn = document.createElement("button");
|
||
editBtn.type = "button";
|
||
editBtn.className = "ghost ex-edit";
|
||
editBtn.textContent = "Edit";
|
||
editBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
openExerciseDialog(ex);
|
||
});
|
||
detail.appendChild(noteEl);
|
||
detail.appendChild(editBtn);
|
||
|
||
li.appendChild(row);
|
||
li.appendChild(detail);
|
||
list.appendChild(li);
|
||
}
|
||
|
||
renderTrainingHeatmap(exercises, events);
|
||
}
|
||
|
||
function renderDayBar() {
|
||
const day = selectedDay();
|
||
const isToday = ymd(day) === ymd(new Date());
|
||
document.getElementById("day-next").disabled = isToday;
|
||
document.getElementById("day-today").disabled = isToday;
|
||
// Year-less date on the picker's face — the year is implicit and the
|
||
// saved width keeps the bar on one row on small phones.
|
||
document.getElementById("day-date-face").textContent =
|
||
day.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||
const title = document.getElementById("overview-title");
|
||
if (title) {
|
||
title.textContent = isToday
|
||
? "Today's overview"
|
||
: day.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" });
|
||
}
|
||
}
|
||
|
||
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 = formatAgeShort(cfg.birthday);
|
||
ageEl.textContent = ageText;
|
||
ageEl.hidden = !ageText;
|
||
// Use the puppy's name in the sleep-timeline heading rather than assuming a
|
||
// gender; fall back to a neutral phrase when no name is configured.
|
||
const sleepTitle = document.getElementById("sleep-timeline-title");
|
||
if (sleepTitle) sleepTitle.textContent = cfg.name ? `When ${cfg.name} sleeps` : "When sleeping";
|
||
}
|
||
|
||
// Dim the quick actions that don't fit the current state — a nudge
|
||
// toward the likely next tap. A boundary that would just repeat the latest
|
||
// one of its pair (a second "sleep start" while already asleep, a "walk end"
|
||
// with no walk running) is disabled outright, since it can only produce a
|
||
// zero-length window. Everything else stays clickable so
|
||
// corrections (a mid-nap pee) are never blocked, and a genuinely missed
|
||
// boundary is still fixable from the event log, which accepts any time.
|
||
function renderActionHints(events) {
|
||
const { state } = currentSleepState(events);
|
||
const { walking } = currentWalkState(events);
|
||
document.querySelectorAll("button.action").forEach(btn => {
|
||
const type = btn.dataset.type;
|
||
const isWalk = type === "walk-start" || type === "walk-end";
|
||
// Walks are a start→end pair of their own, so they answer to the walk
|
||
// state rather than the sleep one: only the boundary that flips it can
|
||
// produce a window. Mid-walk, ending it is the obvious next tap.
|
||
const repeat = isWalk
|
||
? (walking ? type === "walk-start" : type === "walk-end")
|
||
: state === "asleep" ? type === "sleep-start"
|
||
: state === "awake" ? type === "sleep-end"
|
||
: false; // no sleep history yet — either boundary is a fine first event
|
||
const unlikely = !repeat && (
|
||
isWalk ? (!walking && state === "asleep")
|
||
: state === "asleep" ? type !== "sleep-end"
|
||
: state === "awake" ? type === "sleep-end"
|
||
: false // no sleep history yet — no hints to give
|
||
);
|
||
btn.classList.toggle("unlikely", unlikely);
|
||
btn.disabled = repeat;
|
||
btn.title = !repeat ? ""
|
||
: isWalk ? (walking ? "Already on a walk" : "No walk in progress")
|
||
: type === "sleep-start" ? "Already asleep" : "Already awake";
|
||
});
|
||
}
|
||
|
||
function render() {
|
||
const events = live();
|
||
renderHeader();
|
||
renderDayBar();
|
||
renderChartWindow();
|
||
renderBigClock(events);
|
||
renderActionHints(events);
|
||
renderStats(events);
|
||
renderLasts(events);
|
||
renderTiming(events);
|
||
renderSleepWindows(events);
|
||
renderWakeWindows(events);
|
||
renderWalks(events);
|
||
renderWeekly(events);
|
||
renderSleepTimeline(events);
|
||
renderSleepTrend(events);
|
||
renderHourHeatmap(events);
|
||
renderTraining(events);
|
||
renderWeight(events);
|
||
renderNotes(events);
|
||
renderHistory(events);
|
||
}
|
||
|
||
// ---------- sync ----------
|
||
let syncTimer = null;
|
||
let syncing = false;
|
||
let lastError = null;
|
||
let lastSynced = 0;
|
||
|
||
function setStatus(state) {
|
||
statusEl.classList.remove("offline", "syncing", "error", "pending");
|
||
if (!navigator.onLine) {
|
||
statusEl.textContent = "offline";
|
||
statusEl.classList.add("offline");
|
||
return;
|
||
}
|
||
switch (state) {
|
||
case "syncing":
|
||
statusEl.textContent = "syncing…";
|
||
statusEl.classList.add("syncing");
|
||
break;
|
||
case "error":
|
||
statusEl.textContent = "sync error";
|
||
statusEl.classList.add("error");
|
||
statusEl.title = lastError || "";
|
||
break;
|
||
case "pending":
|
||
statusEl.textContent = "pending";
|
||
statusEl.classList.add("pending");
|
||
break;
|
||
default:
|
||
statusEl.textContent = lastSynced
|
||
? `synced ${formatRelative(lastSynced)}`
|
||
: "synced";
|
||
statusEl.title = "";
|
||
}
|
||
}
|
||
|
||
function scheduleSync() {
|
||
if (!navigator.onLine) { setStatus("pending"); return; }
|
||
setStatus("pending");
|
||
clearTimeout(syncTimer);
|
||
syncTimer = setTimeout(sync, SYNC_DEBOUNCE_MS);
|
||
}
|
||
|
||
// Merge a server response back into local storage. Anything local with a
|
||
// newer updatedAt than the server's copy wins — that covers items the user
|
||
// added/edited during the in-flight sync request. Shared by the events and
|
||
// exercises collections, which follow the same LWW contract.
|
||
function mergeSynced(serverItems, load, save) {
|
||
const localById = new Map(load().map(e => [e.id, e]));
|
||
const merged = new Map();
|
||
for (const se of serverItems) {
|
||
if (se && se.id) merged.set(se.id, se);
|
||
}
|
||
for (const [id, le] of localById) {
|
||
const se = merged.get(id);
|
||
if (!se || (le.updatedAt || 0) > (se.updatedAt || 0)) {
|
||
merged.set(id, le);
|
||
}
|
||
}
|
||
save([...merged.values()]);
|
||
}
|
||
|
||
async function sync() {
|
||
if (!currentUser) return;
|
||
if (syncing) return;
|
||
if (!navigator.onLine) { setStatus("pending"); return; }
|
||
syncing = true;
|
||
setStatus("syncing");
|
||
try {
|
||
// Push queued photos first so events that reference them won't return
|
||
// 404s when other clients try to fetch.
|
||
await syncPhotos();
|
||
|
||
const res = await fetch(SYNC_URL, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ events: loadAll() }),
|
||
});
|
||
if (res.status === 401) { handleLoggedOut(); return; }
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const body = await res.json();
|
||
|
||
const exRes = await fetch("api/exercises/sync", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ exercises: loadExercises() }),
|
||
});
|
||
if (exRes.status === 401) { handleLoggedOut(); return; }
|
||
if (!exRes.ok) throw new Error(`HTTP ${exRes.status}`);
|
||
const exBody = await exRes.json();
|
||
|
||
if (Array.isArray(exBody.exercises)) {
|
||
mergeSynced(exBody.exercises, loadExercises, saveExercises);
|
||
}
|
||
if (Array.isArray(body.events)) {
|
||
mergeSynced(body.events, loadAll, saveAll);
|
||
}
|
||
lastSynced = Date.now();
|
||
lastError = null;
|
||
render();
|
||
setStatus("synced");
|
||
} catch (err) {
|
||
lastError = err.message || String(err);
|
||
console.warn("sync failed:", lastError);
|
||
setStatus("error");
|
||
} finally {
|
||
syncing = false;
|
||
}
|
||
}
|
||
|
||
// ---------- 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 (!currentUser) return;
|
||
if (!navigator.onLine) return;
|
||
const local = loadConfig();
|
||
try {
|
||
const res = await fetch("api/config");
|
||
if (res.status === 401) { handleLoggedOut(); return; }
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const body = await res.json();
|
||
const server = {
|
||
name: body.name || "",
|
||
birthday: body.birthday || "",
|
||
pedigreeId: body.pedigreeId || "",
|
||
updatedAt: Number.isFinite(body.updatedAt) ? body.updatedAt : 0,
|
||
};
|
||
const merged = reconcileConfig(local, server);
|
||
if (!sameConfig(merged, local)) {
|
||
saveConfig(merged);
|
||
renderHeader();
|
||
refreshPedigreeButton();
|
||
}
|
||
if (!sameConfig(merged, server)) {
|
||
await pushConfig(merged);
|
||
}
|
||
} 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 || "", pedigreeId: body.pedigreeId || "", updatedAt: body.updatedAt });
|
||
renderHeader();
|
||
}
|
||
}
|
||
|
||
// ---------- dialogs ----------
|
||
const noteDialog = document.getElementById("note-dialog");
|
||
const noteForm = document.getElementById("note-form");
|
||
const noteInput = document.getElementById("note-input");
|
||
const noteDate = document.getElementById("note-date");
|
||
const noteTime = document.getElementById("note-time");
|
||
const noteTitle = document.getElementById("note-title");
|
||
const notePhotoInput = document.getElementById("note-photo-input");
|
||
const notePhotoBtn = document.getElementById("note-photo-btn");
|
||
const notePhotoPreview = document.getElementById("note-photo-preview");
|
||
const noteWeightField = document.getElementById("note-weight-field");
|
||
const noteWeight = document.getElementById("note-weight");
|
||
const noteGramsField = document.getElementById("note-grams-field");
|
||
const noteGrams = document.getElementById("note-grams");
|
||
let pendingType = null;
|
||
let notePhotos = []; // pending photos for this dialog: [{ blob, url }]
|
||
// Whether the user has manually touched the date/time fields. While false the
|
||
// dialog logs at the exact current millisecond rather than the minute-floored
|
||
// input, so a just-logged event doesn't look up to ~59s old.
|
||
let noteTimeEdited = false;
|
||
|
||
// An event can carry several photos: photoId holds their UUIDs
|
||
// comma-separated. A legacy single id is just a one-element list, and the
|
||
// server passes the string through untouched (photos themselves are
|
||
// uploaded and fetched individually by UUID).
|
||
function photoIdsOf(ev) {
|
||
return (ev.photoId || "").split(",").filter(Boolean);
|
||
}
|
||
|
||
// A preview thumbnail with a remove button; shared by both dialogs.
|
||
function photoThumb(url, onRemove) {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "photo-thumb";
|
||
const img = document.createElement("img");
|
||
img.alt = "";
|
||
if (url) img.src = url;
|
||
const rm = document.createElement("button");
|
||
rm.type = "button";
|
||
rm.className = "photo-thumb-remove";
|
||
rm.setAttribute("aria-label", "Remove photo");
|
||
rm.textContent = "×";
|
||
rm.addEventListener("click", onRemove);
|
||
wrap.appendChild(img);
|
||
wrap.appendChild(rm);
|
||
return wrap;
|
||
}
|
||
|
||
function renderNotePhotos() {
|
||
notePhotoPreview.innerHTML = "";
|
||
notePhotoPreview.hidden = notePhotos.length === 0;
|
||
notePhotos.forEach((p, i) => {
|
||
notePhotoPreview.appendChild(photoThumb(p.url, () => {
|
||
URL.revokeObjectURL(p.url);
|
||
notePhotos.splice(i, 1);
|
||
renderNotePhotos();
|
||
}));
|
||
});
|
||
}
|
||
|
||
function clearNotePhotos() {
|
||
for (const p of notePhotos) URL.revokeObjectURL(p.url);
|
||
notePhotos = [];
|
||
renderNotePhotos();
|
||
notePhotoInput.value = "";
|
||
}
|
||
|
||
function openNoteDialog(type) {
|
||
pendingType = type;
|
||
noteInput.value = "";
|
||
const isNote = type === "note";
|
||
// A note is about a day, so default it to the day you're viewing (at the
|
||
// current clock time). The logging types default to "now"; leaving
|
||
// noteTimeEdited false lets noteDialogAt() stamp the exact instant.
|
||
let base = Date.now();
|
||
if (isNote) {
|
||
const day = selectedDay();
|
||
const now = new Date();
|
||
day.setHours(now.getHours(), now.getMinutes(), 0, 0);
|
||
base = day.getTime();
|
||
}
|
||
noteTimeEdited = isNote;
|
||
noteDate.value = toDateInput(base);
|
||
noteTime.value = toTimeInput(base);
|
||
noteTitle.textContent = isNote ? "Add note" : `Log ${EVENT_LABELS[type]}`;
|
||
const isWeight = type === "weight";
|
||
const isEat = type === "eat";
|
||
noteWeightField.hidden = !isWeight;
|
||
noteWeight.value = "";
|
||
noteGramsField.hidden = !isEat;
|
||
noteGrams.value = "";
|
||
clearNotePhotos();
|
||
noteDialog.showModal();
|
||
setTimeout(() => (isWeight ? noteWeight : isEat ? noteGrams : noteInput).focus(), 50);
|
||
}
|
||
|
||
function noteDialogAt() {
|
||
// Untouched time → stamp the exact current instant (sub-minute accurate).
|
||
// Once the user picks a time, honor the input (minute precision is fine).
|
||
if (!noteTimeEdited) return Date.now();
|
||
const parsed = fromDateTimeInputs(noteDate.value, noteTime.value);
|
||
return Number.isFinite(parsed) ? parsed : Date.now();
|
||
}
|
||
|
||
document.getElementById("note-time-now").addEventListener("click", () => {
|
||
const now = Date.now();
|
||
noteDate.value = toDateInput(now);
|
||
noteTime.value = toTimeInput(now);
|
||
noteTimeEdited = false; // "Now" means log at the current instant again
|
||
});
|
||
|
||
[noteDate, noteTime].forEach(el => {
|
||
const markEdited = () => { noteTimeEdited = true; };
|
||
el.addEventListener("change", markEdited);
|
||
el.addEventListener("input", markEdited);
|
||
});
|
||
|
||
notePhotoBtn.addEventListener("click", () => notePhotoInput.click());
|
||
notePhotoInput.addEventListener("change", async (e) => {
|
||
for (const file of Array.from(e.target.files || [])) {
|
||
try {
|
||
const blob = await resizeImage(file);
|
||
notePhotos.push({ blob, url: URL.createObjectURL(blob) });
|
||
} catch (err) {
|
||
alert("Couldn't process that photo: " + err.message);
|
||
}
|
||
}
|
||
notePhotoInput.value = "";
|
||
renderNotePhotos();
|
||
});
|
||
|
||
document.getElementById("note-save").addEventListener("click", async (e) => {
|
||
e.preventDefault();
|
||
if (!pendingType) { noteDialog.close(); return; }
|
||
if (pendingType === "note" && noteInput.value.trim() === "" && notePhotos.length === 0) {
|
||
alert("Write something for the note, or add a photo.");
|
||
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 grams;
|
||
if (pendingType === "eat" && noteGrams.value.trim() !== "") {
|
||
const g = parseFloat(noteGrams.value);
|
||
if (!(g > 0)) { alert("Enter the amount in grams, or leave it empty."); return; }
|
||
grams = Math.round(g);
|
||
}
|
||
const photoIds = [];
|
||
for (const p of notePhotos) {
|
||
const id = uuid();
|
||
try { await putPhoto(id, p.blob, false); }
|
||
catch (err) {
|
||
alert("Couldn't store photo locally: " + err.message);
|
||
return;
|
||
}
|
||
photoIds.push(id);
|
||
}
|
||
addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), { photoId: photoIds.join(","), weight, grams });
|
||
pendingType = null;
|
||
clearNotePhotos();
|
||
noteDialog.close();
|
||
});
|
||
noteForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
pendingType = null;
|
||
clearNotePhotos();
|
||
noteDialog.close();
|
||
});
|
||
|
||
// Edit dialog
|
||
const editDialog = document.getElementById("edit-dialog");
|
||
const editForm = document.getElementById("edit-form");
|
||
const editDate = document.getElementById("edit-date");
|
||
const editTime = document.getElementById("edit-time");
|
||
const editNote = document.getElementById("edit-note");
|
||
const editDelete = document.getElementById("edit-delete");
|
||
const editPhotoInput = document.getElementById("edit-photo-input");
|
||
const editPhotoBtn = document.getElementById("edit-photo-btn");
|
||
const editPhotoPreview = document.getElementById("edit-photo-preview");
|
||
const editWeightField = document.getElementById("edit-weight-field");
|
||
const editWeight = document.getElementById("edit-weight");
|
||
const editGramsField = document.getElementById("edit-grams-field");
|
||
const editGrams = document.getElementById("edit-grams");
|
||
let editingId = null;
|
||
// The dialog's working set of photos, in display order. Existing photos are
|
||
// { id, url } (url from photoSrc's page-lifetime cache — never revoked here);
|
||
// newly picked ones are { blob, url } with a fresh object URL we own.
|
||
let editPhotos = [];
|
||
|
||
function renderEditPhotos() {
|
||
editPhotoPreview.innerHTML = "";
|
||
editPhotoPreview.hidden = editPhotos.length === 0;
|
||
editPhotos.forEach((p, i) => {
|
||
editPhotoPreview.appendChild(photoThumb(p.url, () => {
|
||
if (p.blob) URL.revokeObjectURL(p.url);
|
||
editPhotos.splice(i, 1);
|
||
renderEditPhotos();
|
||
}));
|
||
});
|
||
}
|
||
|
||
function resetEditPhotos() {
|
||
for (const p of editPhotos) if (p.blob) URL.revokeObjectURL(p.url);
|
||
editPhotos = [];
|
||
editPhotoInput.value = "";
|
||
}
|
||
|
||
async function openEditDialog(ev) {
|
||
editingId = ev.id;
|
||
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 : "";
|
||
editGramsField.hidden = ev.type !== "eat";
|
||
editGrams.value = (ev.type === "eat" && Number.isFinite(ev.grams) && ev.grams > 0) ? ev.grams : "";
|
||
resetEditPhotos();
|
||
for (const id of photoIdsOf(ev)) {
|
||
editPhotos.push({ id, url: await photoSrc(id) });
|
||
}
|
||
renderEditPhotos();
|
||
editDialog.showModal();
|
||
// Without an explicit focus the browser autofocuses the first control —
|
||
// the date input — and iOS immediately pops the calendar over the form.
|
||
// Land on the first relevant text field instead (same trick and delay as
|
||
// openNoteDialog: synchronous focus can be lost while the dialog settles).
|
||
const focusTarget = !editWeightField.hidden ? editWeight
|
||
: !editGramsField.hidden ? editGrams
|
||
: editNote;
|
||
setTimeout(() => focusTarget.focus(), 50);
|
||
}
|
||
|
||
editPhotoBtn.addEventListener("click", () => editPhotoInput.click());
|
||
editPhotoInput.addEventListener("change", async (e) => {
|
||
for (const file of Array.from(e.target.files || [])) {
|
||
try {
|
||
const blob = await resizeImage(file);
|
||
editPhotos.push({ blob, url: URL.createObjectURL(blob) });
|
||
} catch (err) {
|
||
alert("Couldn't process that photo: " + err.message);
|
||
}
|
||
}
|
||
editPhotoInput.value = "";
|
||
renderEditPhotos();
|
||
});
|
||
|
||
editForm.querySelector('button[value="save"]').addEventListener("click", async (e) => {
|
||
e.preventDefault();
|
||
if (!editingId) { editDialog.close(); return; }
|
||
const newAt = fromDateTimeInputs(editDate.value, editTime.value);
|
||
const patch = {
|
||
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 (!editGramsField.hidden) {
|
||
if (editGrams.value.trim() === "") {
|
||
patch.grams = undefined; // cleared → drop the amount
|
||
} else {
|
||
const g = parseFloat(editGrams.value);
|
||
if (!(g > 0)) { alert("Enter the amount in grams, or leave it empty."); return; }
|
||
patch.grams = Math.round(g);
|
||
}
|
||
}
|
||
const photoIds = [];
|
||
for (const p of editPhotos) {
|
||
if (p.id) { photoIds.push(p.id); continue; }
|
||
const id = uuid();
|
||
try { await putPhoto(id, p.blob, false); }
|
||
catch (err) { alert("Couldn't store photo locally: " + err.message); return; }
|
||
photoIds.push(id);
|
||
}
|
||
patch.photoId = photoIds.join(",");
|
||
updateEvent(editingId, patch);
|
||
editingId = null;
|
||
resetEditPhotos();
|
||
editDialog.close();
|
||
});
|
||
|
||
editForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
editingId = null;
|
||
resetEditPhotos();
|
||
editDialog.close();
|
||
});
|
||
|
||
editDelete.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
if (editingId && confirm("Delete this event?")) {
|
||
deleteEvent(editingId);
|
||
}
|
||
editingId = null;
|
||
resetEditPhotos();
|
||
editDialog.close();
|
||
});
|
||
|
||
// Settings dialog (puppy name + birthday)
|
||
// ---------- theme ----------
|
||
// Preference is device-global (not per user). No stored choice → follow the
|
||
// OS via the prefers-color-scheme media query; a choice sets data-theme on
|
||
// <html>, which the CSS treats as an override. The <head> applies any saved
|
||
// choice before first paint; this just resolves state and reacts to the toggle.
|
||
const THEME_KEY = "puppy-tracker:theme";
|
||
const prefersDark = () =>
|
||
window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||
function effectiveTheme() {
|
||
const s = localStorage.getItem(THEME_KEY);
|
||
return s === "light" || s === "dark" ? s : (prefersDark() ? "dark" : "light");
|
||
}
|
||
function setTheme(theme) {
|
||
document.documentElement.dataset.theme = theme;
|
||
try { localStorage.setItem(THEME_KEY, theme); } catch { /* ignore */ }
|
||
}
|
||
|
||
const settingsDialog = document.getElementById("settings-dialog");
|
||
const settingsForm = document.getElementById("settings-form");
|
||
const settingsName = document.getElementById("settings-name");
|
||
const settingsBirthday = document.getElementById("settings-birthday");
|
||
const settingsPedigree = document.getElementById("settings-pedigree");
|
||
const settingsTheme = document.getElementById("settings-theme");
|
||
const settingsConfetti = document.getElementById("settings-confetti");
|
||
|
||
// Apply live so the toggle previews immediately (independent of Save/Cancel).
|
||
settingsTheme.addEventListener("change", () => {
|
||
setTheme(settingsTheme.checked ? "dark" : "light");
|
||
});
|
||
|
||
function openSettingsDialog() {
|
||
const cfg = loadConfig();
|
||
settingsName.value = cfg.name;
|
||
settingsBirthday.value = cfg.birthday;
|
||
settingsPedigree.value = cfg.pedigreeId;
|
||
settingsTheme.checked = effectiveTheme() === "dark";
|
||
settingsConfetti.checked = confettiEnabled();
|
||
settingsDialog.showModal();
|
||
refreshRemindersUI();
|
||
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,
|
||
pedigreeId: settingsPedigree.value.trim(),
|
||
updatedAt: Date.now(),
|
||
};
|
||
saveConfig(cfg); // cache locally for instant + offline paint
|
||
setConfettiEnabled(settingsConfetti.checked); // device-local, not synced
|
||
renderHeader();
|
||
refreshPedigreeButton();
|
||
settingsDialog.close();
|
||
// Rules are saved with the rest of Settings; the notification toggle itself
|
||
// already acted when it was flipped, since permission needs a user gesture.
|
||
if (!remindersSection.hidden && !remindersRules.hidden) {
|
||
try {
|
||
await saveReminderRules(collectReminderRules());
|
||
} catch (err) {
|
||
console.warn("saving reminders failed:", err);
|
||
}
|
||
}
|
||
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();
|
||
});
|
||
|
||
// ---------- reminders ----------
|
||
// The server decides when a reminder is due and pushes it (server/reminders.go);
|
||
// this side only manages the browser's push subscription and the rule settings.
|
||
// Nothing here can fire a notification on its own — a closed PWA has no timers,
|
||
// which is the whole reason the evaluation lives on the host.
|
||
|
||
const REMINDER_LABELS = {
|
||
sleep: "Time to sleep, awake for",
|
||
pee: "Time for pee, none for",
|
||
poo: "Time for poo, none for",
|
||
eat: "Time for a meal, none for",
|
||
};
|
||
|
||
const remindersSection = document.getElementById("reminders-section");
|
||
const remindersToggle = document.getElementById("reminders-enabled");
|
||
const remindersHint = document.getElementById("reminders-hint");
|
||
const remindersRules = document.getElementById("reminders-rules");
|
||
const remindersTest = document.getElementById("reminders-test");
|
||
|
||
let pushKey = null; // VAPID public key, once the server has given us one
|
||
let reminderRules = []; // last-known rule set, re-rendered into the dialog
|
||
|
||
// iOS only exposes push to a PWA that was added to the Home Screen; in a plain
|
||
// Safari tab PushManager doesn't exist at all, so the toggle would be dead.
|
||
const isStandalone = () =>
|
||
window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
|
||
const isIOS = () =>
|
||
/iP(hone|ad|od)/.test(navigator.userAgent) ||
|
||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||
|
||
const pushSupported = () =>
|
||
"serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||
|
||
// applicationServerKey wants raw bytes, not the base64url the server sends.
|
||
function b64UrlToBytes(s) {
|
||
const pad = "=".repeat((4 - (s.length % 4)) % 4);
|
||
const bin = atob((s + pad).replace(/-/g, "+").replace(/_/g, "/"));
|
||
const out = new Uint8Array(bin.length);
|
||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||
return out;
|
||
}
|
||
|
||
function bytesToB64Url(bytes) {
|
||
let bin = "";
|
||
for (const b of bytes) bin += String.fromCharCode(b);
|
||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||
}
|
||
|
||
// subscribeForPush returns a live subscription, registering it with the host.
|
||
// It runs on every launch, not just when the toggle is flipped: iOS silently
|
||
// drops subscriptions, and a stale endpoint fails invisibly until re-posted.
|
||
async function subscribeForPush() {
|
||
const reg = await navigator.serviceWorker.ready;
|
||
let sub = await reg.pushManager.getSubscription();
|
||
|
||
// A subscription made under a different VAPID key can't be reused — the
|
||
// browser rejects re-subscribing with a new key — so drop it first. Only
|
||
// when we can positively see a mismatch, though: if a browser doesn't
|
||
// expose options, re-subscribing blindly would mint a fresh endpoint on
|
||
// every launch and strand the old row on the server.
|
||
if (sub) {
|
||
const existing = sub.options && sub.options.applicationServerKey;
|
||
if (existing && bytesToB64Url(new Uint8Array(existing)) !== pushKey) {
|
||
try { await sub.unsubscribe(); } catch { /* ignore */ }
|
||
sub = null;
|
||
}
|
||
}
|
||
if (!sub) {
|
||
sub = await reg.pushManager.subscribe({
|
||
userVisibleOnly: true,
|
||
applicationServerKey: b64UrlToBytes(pushKey),
|
||
});
|
||
}
|
||
const res = await fetch("api/push/subscribe", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(sub),
|
||
});
|
||
if (!res.ok) throw new Error(`subscribe failed: ${res.status}`);
|
||
return sub;
|
||
}
|
||
|
||
async function unsubscribeFromPush() {
|
||
const reg = await navigator.serviceWorker.ready;
|
||
const sub = await reg.pushManager.getSubscription();
|
||
if (!sub) return;
|
||
// Tell the host first: if the local unsubscribe succeeds but the POST never
|
||
// lands, the server would keep pushing to an endpoint nobody listens on.
|
||
try {
|
||
await fetch("api/push/unsubscribe", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||
});
|
||
} catch { /* the endpoint dies on its own once the browser drops it */ }
|
||
try { await sub.unsubscribe(); } catch { /* ignore */ }
|
||
}
|
||
|
||
async function loadReminderRules() {
|
||
const res = await fetch("api/reminders");
|
||
if (!res.ok) throw new Error(`reminders: ${res.status}`);
|
||
const body = await res.json();
|
||
reminderRules = body.reminders || [];
|
||
return reminderRules;
|
||
}
|
||
|
||
async function saveReminderRules(rules) {
|
||
const res = await fetch("api/reminders", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ reminders: rules }),
|
||
});
|
||
if (!res.ok) throw new Error(`reminders: ${res.status}`);
|
||
// The server clamps intervals, so adopt what it actually stored.
|
||
reminderRules = (await res.json()).reminders || rules;
|
||
}
|
||
|
||
function renderReminderRules() {
|
||
remindersRules.replaceChildren();
|
||
for (const rule of reminderRules) {
|
||
const row = document.createElement("div");
|
||
row.className = "reminder-row";
|
||
row.dataset.kind = rule.kind;
|
||
|
||
const name = document.createElement("label");
|
||
name.className = "reminder-name";
|
||
const on = document.createElement("input");
|
||
on.type = "checkbox";
|
||
on.className = "switch reminder-on";
|
||
on.setAttribute("role", "switch");
|
||
on.checked = rule.enabled;
|
||
const text = document.createElement("span");
|
||
text.textContent = REMINDER_LABELS[rule.kind] || rule.kind;
|
||
name.append(on, text);
|
||
|
||
const after = document.createElement("span");
|
||
after.className = "reminder-after";
|
||
const mins = document.createElement("input");
|
||
mins.type = "number";
|
||
mins.className = "reminder-mins";
|
||
mins.min = "5";
|
||
mins.max = "1440";
|
||
mins.step = "5";
|
||
mins.value = String(rule.intervalMin);
|
||
const unit = document.createElement("span");
|
||
unit.textContent = "min";
|
||
after.append(mins, unit);
|
||
|
||
row.append(name, after);
|
||
remindersRules.append(row);
|
||
}
|
||
}
|
||
|
||
// collectReminderRules reads the dialog back into the rule shape the API takes.
|
||
function collectReminderRules() {
|
||
return [...remindersRules.querySelectorAll(".reminder-row")].map((row) => ({
|
||
kind: row.dataset.kind,
|
||
enabled: row.querySelector(".reminder-on").checked,
|
||
intervalMin: Number(row.querySelector(".reminder-mins").value) || 60,
|
||
}));
|
||
}
|
||
|
||
function setReminderHint(text) {
|
||
remindersHint.textContent = text || "";
|
||
remindersHint.hidden = !text;
|
||
}
|
||
|
||
// Reflects permission + subscription state: the rules only matter once there
|
||
// is somewhere to deliver them.
|
||
function showReminderControls(subscribed) {
|
||
remindersToggle.checked = subscribed;
|
||
remindersRules.hidden = !subscribed;
|
||
remindersTest.hidden = !subscribed;
|
||
}
|
||
|
||
// initReminders runs once at startup. It establishes whether reminders are
|
||
// available at all (the server may have no VAPID key, the browser may have no
|
||
// push support) and refreshes an existing subscription.
|
||
async function initReminders() {
|
||
if (!pushSupported()) {
|
||
// On iOS this is the Home Screen requirement rather than a missing feature,
|
||
// and it's worth saying so — the toggle is otherwise just absent.
|
||
if (isIOS() && !isStandalone()) {
|
||
remindersSection.hidden = false;
|
||
remindersToggle.disabled = true;
|
||
setReminderHint("Add Puppy Tracker to your Home Screen to enable reminders.");
|
||
}
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch("api/push/key");
|
||
if (!res.ok) return; // host has push disabled; leave the section hidden
|
||
pushKey = (await res.json()).key;
|
||
} catch {
|
||
return; // offline at boot: try again next launch
|
||
}
|
||
if (!pushKey) return;
|
||
remindersSection.hidden = false;
|
||
|
||
if (Notification.permission !== "granted") {
|
||
showReminderControls(false);
|
||
return;
|
||
}
|
||
try {
|
||
await subscribeForPush();
|
||
showReminderControls(true);
|
||
} catch (err) {
|
||
console.warn("push re-subscribe failed:", err);
|
||
showReminderControls(false);
|
||
}
|
||
}
|
||
|
||
// The toggle acts immediately rather than on Save: requesting notification
|
||
// permission has to happen inside a user gesture, and on iOS a deferred
|
||
// request is simply ignored.
|
||
remindersToggle.addEventListener("change", async () => {
|
||
if (!remindersToggle.checked) {
|
||
setReminderHint("");
|
||
showReminderControls(false);
|
||
await unsubscribeFromPush();
|
||
return;
|
||
}
|
||
remindersToggle.disabled = true;
|
||
try {
|
||
const permission = await Notification.requestPermission();
|
||
if (permission !== "granted") {
|
||
showReminderControls(false);
|
||
setReminderHint(
|
||
permission === "denied"
|
||
? "Notifications are blocked for this app in your browser settings."
|
||
: "Notifications need permission to work."
|
||
);
|
||
return;
|
||
}
|
||
await subscribeForPush();
|
||
await loadReminderRules();
|
||
renderReminderRules();
|
||
showReminderControls(true);
|
||
setReminderHint("");
|
||
} catch (err) {
|
||
console.warn("enabling reminders failed:", err);
|
||
showReminderControls(false);
|
||
setReminderHint("Couldn't enable reminders. Try again once you're online.");
|
||
} finally {
|
||
remindersToggle.disabled = false;
|
||
}
|
||
});
|
||
|
||
remindersTest.addEventListener("click", async () => {
|
||
remindersTest.disabled = true;
|
||
try {
|
||
const res = await fetch("api/push/test", { method: "POST" });
|
||
setReminderHint(res.ok ? "Test sent." : "Couldn't send a test notification.");
|
||
} catch {
|
||
setReminderHint("Couldn't send a test notification.");
|
||
} finally {
|
||
remindersTest.disabled = false;
|
||
}
|
||
});
|
||
|
||
// Called when the settings dialog opens, so the rows show what the host has.
|
||
async function refreshRemindersUI() {
|
||
if (remindersSection.hidden || !pushKey) return;
|
||
try {
|
||
await loadReminderRules();
|
||
renderReminderRules();
|
||
} catch (err) {
|
||
console.warn("loading reminders failed:", err);
|
||
}
|
||
}
|
||
|
||
// ---------- pedigree lookup ----------
|
||
// A separate full-screen view that resolves a dog against SKK Hunddata by
|
||
// chip / registration number / name and renders its ancestry as a tree. The
|
||
// server returns the first generations immediately and crawls deeper in the
|
||
// background; we poll for that and re-render as ancestors arrive. Online-only.
|
||
const pedScreen = document.getElementById("pedigree-screen");
|
||
const pedStatus = document.getElementById("pedigree-status");
|
||
const pedChoose = document.getElementById("pedigree-choose");
|
||
const pedSubject = document.getElementById("pedigree-subject");
|
||
const pedTree = document.getElementById("pedigree-tree");
|
||
const pedBtn = document.getElementById("pedigree-btn");
|
||
const pedRefresh = document.getElementById("pedigree-refresh");
|
||
const pedRepeatNote = document.getElementById("pedigree-repeat-note");
|
||
|
||
const PED_OPEN_DEPTH = 3; // show 3 generations expanded by default; deeper collapses
|
||
let pedPollTimer = null;
|
||
let pedNodes = {}; // latest ancestry map, for the progress count
|
||
|
||
// ---- zoom ----
|
||
// The tree gets very wide when expanded, so it's zoomable: buttons, ctrl/⌘ +
|
||
// wheel, and pinch. We scale via the CSS `zoom` property (not transform) so the
|
||
// scroll container reflows and every part stays reachable. The level persists.
|
||
const pedZoomOut = document.getElementById("ped-zoom-out");
|
||
const pedZoomIn = document.getElementById("ped-zoom-in");
|
||
const pedZoomReset = document.getElementById("ped-zoom-reset");
|
||
const PED_ZOOM_MIN = 0.4, PED_ZOOM_MAX = 1.6;
|
||
const pedZoomKey = () => `puppy-tracker:${currentUser.id}:pedigree-zoom:v1`;
|
||
let pedZoom = 1;
|
||
|
||
function applyPedZoom() {
|
||
pedZoom = Math.min(PED_ZOOM_MAX, Math.max(PED_ZOOM_MIN, Math.round(pedZoom * 100) / 100));
|
||
pedTree.style.zoom = pedZoom;
|
||
pedZoomReset.textContent = Math.round(pedZoom * 100) + "%";
|
||
try { localStorage.setItem(pedZoomKey(), String(pedZoom)); } catch { /* ignore */ }
|
||
}
|
||
function pedZoomBy(delta) { pedZoom += delta; applyPedZoom(); }
|
||
|
||
pedZoomIn.addEventListener("click", () => pedZoomBy(0.2));
|
||
pedZoomOut.addEventListener("click", () => pedZoomBy(-0.2));
|
||
pedZoomReset.addEventListener("click", () => { pedZoom = 1; applyPedZoom(); });
|
||
|
||
// ---- collapse / expand all ----
|
||
const pedFoldAll = document.getElementById("ped-foldall");
|
||
function pedSetAll(collapsed) {
|
||
pedTree.querySelectorAll("li").forEach((li) => {
|
||
if (!li.querySelector(":scope > ul")) return; // no ancestors to fold
|
||
li.classList.toggle("collapsed", collapsed);
|
||
const t = li.querySelector(":scope > .ped-card > .ped-toggle");
|
||
if (t) t.textContent = collapsed ? "+" : "−";
|
||
});
|
||
pedFoldAll.textContent = collapsed ? "Expand all" : "Collapse all";
|
||
}
|
||
pedFoldAll.addEventListener("click", () => pedSetAll(pedFoldAll.textContent[0] === "C"));
|
||
|
||
// ---- view toggle: top-down tree vs radial fan ----
|
||
const pedViewBtn = document.getElementById("ped-view");
|
||
const pedCaption = document.getElementById("pedigree-caption");
|
||
const pedViewKey = () => `puppy-tracker:${currentUser.id}:pedigree-view:v1`;
|
||
let pedView = "tree";
|
||
try { const v = localStorage.getItem(pedViewKey()); if (v === "fan" || v === "tree") pedView = v; } catch { /* ignore */ }
|
||
|
||
// The fan has no per-branch folding, so hide that control in fan mode; the
|
||
// toggle always offers the *other* view.
|
||
function updatePedViewControls() {
|
||
pedViewBtn.textContent = pedView === "fan" ? "Tree view" : "Fan view";
|
||
pedFoldAll.hidden = pedView === "fan";
|
||
if (pedView !== "fan" && pedCaption) { pedCaption.hidden = true; }
|
||
}
|
||
pedViewBtn.addEventListener("click", () => {
|
||
pedView = pedView === "fan" ? "tree" : "fan";
|
||
try { localStorage.setItem(pedViewKey(), pedView); } catch { /* ignore */ }
|
||
renderPedigree(pedNodes);
|
||
});
|
||
|
||
// Trackpad/desktop: ctrl or ⌘ + wheel zooms instead of scrolling the page.
|
||
pedTree.addEventListener("wheel", (e) => {
|
||
if (!e.ctrlKey && !e.metaKey) return;
|
||
e.preventDefault();
|
||
pedZoomBy(e.deltaY < 0 ? 0.1 : -0.1);
|
||
}, { passive: false });
|
||
// Touch: two-finger pinch.
|
||
let pinchDist = 0, pinchZoom = 1;
|
||
const touchDist = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY);
|
||
pedTree.addEventListener("touchstart", (e) => {
|
||
if (e.touches.length === 2) { pinchDist = touchDist(e.touches); pinchZoom = pedZoom; }
|
||
}, { passive: true });
|
||
pedTree.addEventListener("touchmove", (e) => {
|
||
if (e.touches.length === 2 && pinchDist > 0) {
|
||
e.preventDefault();
|
||
pedZoom = pinchZoom * (touchDist(e.touches) / pinchDist);
|
||
applyPedZoom();
|
||
}
|
||
}, { passive: false });
|
||
pedTree.addEventListener("touchend", (e) => { if (e.touches.length < 2) pinchDist = 0; });
|
||
|
||
// The looked-up tree is cached locally per dog id, so reopening the page paints
|
||
// instantly and still shows the last-known tree offline. The server caches it
|
||
// too (per dog, permanently); this is just the client-side mirror.
|
||
const pedCacheKey = (id) => `puppy-tracker:${currentUser.id}:pedigree:${id}:v1`;
|
||
function loadPedCache(id) {
|
||
try { return JSON.parse(localStorage.getItem(pedCacheKey(id))) || null; } catch { return null; }
|
||
}
|
||
function savePedCache(id, subject, nodes) {
|
||
try { localStorage.setItem(pedCacheKey(id), JSON.stringify({ subject, nodes })); } catch { /* ignore */ }
|
||
}
|
||
|
||
// Show the 🌳 button only once a pedigree id is set in Settings.
|
||
function refreshPedigreeButton() {
|
||
pedBtn.hidden = !loadConfig().pedigreeId;
|
||
}
|
||
|
||
function openPedigree() {
|
||
const id = loadConfig().pedigreeId;
|
||
appEl.hidden = true;
|
||
pedScreen.hidden = false;
|
||
const saved = parseFloat(localStorage.getItem(pedZoomKey()));
|
||
pedZoom = Number.isFinite(saved) ? saved : 1;
|
||
applyPedZoom();
|
||
if (!id) { setPedStatus("Set your dog's SKK id in Settings to see its pedigree.", ""); return; }
|
||
lookupPedigree(id);
|
||
}
|
||
function closePedigree() {
|
||
stopPedPoll();
|
||
pedScreen.hidden = true;
|
||
appEl.hidden = false;
|
||
}
|
||
function stopPedPoll() {
|
||
if (pedPollTimer) { clearTimeout(pedPollTimer); pedPollTimer = null; }
|
||
}
|
||
|
||
pedBtn.addEventListener("click", openPedigree);
|
||
document.getElementById("pedigree-back").addEventListener("click", closePedigree);
|
||
pedRefresh.addEventListener("click", () => {
|
||
const id = loadConfig().pedigreeId;
|
||
if (id) lookupPedigree(id);
|
||
});
|
||
|
||
async function lookupPedigree(q) {
|
||
stopPedPoll();
|
||
pedChoose.hidden = true; pedChoose.textContent = "";
|
||
pedSubject.hidden = true; pedSubject.textContent = "";
|
||
pedTree.textContent = "";
|
||
pedNodes = {};
|
||
|
||
// Paint the cached tree first so the page is instant (and works offline).
|
||
const cached = loadPedCache(q);
|
||
if (cached && cached.nodes) {
|
||
renderSubject(cached.subject);
|
||
renderPedigree(cached.nodes);
|
||
}
|
||
if (!navigator.onLine) {
|
||
setPedStatus(cached ? "Offline — showing the last saved pedigree." : "Pedigree needs an internet connection.",
|
||
cached ? "" : "error");
|
||
return;
|
||
}
|
||
setPedStatus(cached ? "Refreshing…" : "Looking up…", "busy");
|
||
let res;
|
||
try {
|
||
res = await fetch("api/pedigree", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ q }),
|
||
});
|
||
} catch {
|
||
setPedStatus(cached ? "Offline — showing the last saved pedigree." : "Couldn't reach the server. Try again.",
|
||
cached ? "" : "error");
|
||
return;
|
||
}
|
||
if (res.status === 401) { handleLoggedOut(); return; }
|
||
if (res.status === 404) {
|
||
setPedStatus("Couldn't find that dog in SKK — check the ID in Settings.", "error");
|
||
return;
|
||
}
|
||
if (!res.ok) {
|
||
const msg = (await res.text().catch(() => "")).trim();
|
||
setPedStatus(msg || `Lookup failed (HTTP ${res.status}).`, "error");
|
||
return;
|
||
}
|
||
const data = await res.json();
|
||
if (data.status === "choose") { renderChoose(data.matches || []); return; }
|
||
renderSubject(data.subject);
|
||
renderPedigree(data.nodes || {});
|
||
if (data.status === "done") {
|
||
savePedCache(q, data.subject, data.nodes || {});
|
||
setPedDone();
|
||
} else {
|
||
setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy");
|
||
pollPedigree(data.jobId, q, data.subject);
|
||
}
|
||
}
|
||
|
||
function pollPedigree(jobId, q, subject) {
|
||
stopPedPoll();
|
||
const tick = async () => {
|
||
let res;
|
||
try { res = await fetch(`api/pedigree/status?job=${encodeURIComponent(jobId)}`); }
|
||
catch { pedPollTimer = setTimeout(tick, 3000); return; }
|
||
if (res.status === 401) { handleLoggedOut(); return; }
|
||
if (!res.ok) { setPedStatus("Lost track of the pedigree crawl.", "error"); return; }
|
||
const data = await res.json();
|
||
renderPedigree(data.nodes || {});
|
||
if (data.status === "done") { savePedCache(q, subject, data.nodes || {}); setPedDone(); return; }
|
||
if (data.status === "error") { setPedStatus(data.error || "Pedigree crawl failed.", "error"); return; }
|
||
setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy");
|
||
pedPollTimer = setTimeout(tick, 1500);
|
||
};
|
||
pedPollTimer = setTimeout(tick, 1500);
|
||
}
|
||
|
||
// Counts derived from the ancestry map we already hold, so the summary is
|
||
// right whether it came from a fresh crawl, a poll, or a cache hit. Distinct
|
||
// ancestors are keyed by registration number (pedigree collapse means one dog
|
||
// fills many positions); "generations back" is the depth of the deepest
|
||
// position (floor(log2(pos)), since sire = 2·pos and dam = 2·pos+1).
|
||
function pedCounts() {
|
||
const counts = new Map();
|
||
let maxPos = 1;
|
||
for (const k in pedNodes) {
|
||
const n = pedNodes[k];
|
||
const key = n.reg || n.name;
|
||
if (key) counts.set(key, (counts.get(key) || 0) + 1);
|
||
const p = Number(k);
|
||
if (p > maxPos) maxPos = p;
|
||
}
|
||
let repeated = 0;
|
||
counts.forEach((c) => { if (c > 1) repeated++; });
|
||
return { distinct: counts.size, repeated, gens: Math.floor(Math.log2(maxPos)) };
|
||
}
|
||
function pedCountText() {
|
||
const { distinct: a, repeated: r, gens: g } = pedCounts();
|
||
let s = `${a} ancestor${a === 1 ? "" : "s"} back ${g} generation${g === 1 ? "" : "s"}`;
|
||
if (r > 0) s += `, ${r} appearing more than once`;
|
||
return s;
|
||
}
|
||
function setPedDone() { setPedStatus(`Traced ${pedCountText()}.`, "done"); }
|
||
function setPedStatus(text, kind) {
|
||
pedStatus.hidden = false;
|
||
pedStatus.textContent = text;
|
||
pedStatus.className = "pedigree-status" + (kind ? " " + kind : "");
|
||
}
|
||
|
||
function renderSubject(s) {
|
||
if (!s) { pedSubject.hidden = true; return; }
|
||
pedSubject.hidden = false;
|
||
pedSubject.textContent = "";
|
||
const name = document.createElement("div");
|
||
name.className = "ped-subject-name";
|
||
name.textContent = s.name || "(unnamed)";
|
||
const meta = document.createElement("div");
|
||
meta.className = "ped-subject-meta";
|
||
const bits = [];
|
||
if (s.breed) bits.push(s.breed);
|
||
if (s.reg) bits.push(s.reg);
|
||
if (s.sex) bits.push(s.sex === "H" ? "♂" : s.sex === "T" ? "♀" : s.sex);
|
||
meta.textContent = bits.join(" · ");
|
||
pedSubject.append(name, meta);
|
||
}
|
||
|
||
function renderChoose(matches) {
|
||
pedTree.textContent = "";
|
||
pedSubject.hidden = true;
|
||
setPedStatus(`${matches.length} matches — pick one:`, "");
|
||
pedChoose.hidden = false;
|
||
pedChoose.textContent = "";
|
||
matches.slice(0, 50).forEach((m) => {
|
||
const b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "ped-match";
|
||
const nm = (m.hundnamn || "").trim() || "(unnamed)";
|
||
const nameEl = document.createElement("span");
|
||
nameEl.className = "ped-match-name";
|
||
nameEl.textContent = nm;
|
||
const metaEl = document.createElement("span");
|
||
metaEl.className = "ped-match-meta";
|
||
metaEl.textContent = [m.Regnr, m.rastext].filter(Boolean).join(" · ");
|
||
b.append(nameEl, metaEl);
|
||
b.addEventListener("click", () => {
|
||
pedChoose.hidden = true;
|
||
lookupPedigree((m.Regnr || "").trim() || nm);
|
||
});
|
||
pedChoose.append(b);
|
||
});
|
||
}
|
||
|
||
// The tree is ahnentafel-indexed: the dog is position 1, its sire 2n and dam
|
||
// 2n+1. We render it top-down like a family tree — the dog on top, parents
|
||
// branching below — as a nested <ul>/<li> so CSS can draw the connectors.
|
||
// Built recursively (sire left, dam right) and collapsed below PED_OPEN_DEPTH.
|
||
// A dog's identity for spotting pedigree collapse: its registration number,
|
||
// or its name when it has none. Ancestors sharing a key are the same dog.
|
||
function dogKey(n) { return n ? (n.reg || n.name || "") : ""; }
|
||
// Stable hue per repeated dog so its badge/highlight colour is consistent
|
||
// everywhere it appears.
|
||
function hueFor(s) {
|
||
let h = 0;
|
||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
|
||
return h % 360;
|
||
}
|
||
let pedRepeat = {}; // dogKey -> occurrence count, for the current tree
|
||
|
||
// Render the ancestry in whichever view is active (top-down tree or radial
|
||
// fan). Shared prep — cache the nodes and count pedigree collapse — lives here.
|
||
function renderPedigree(nodes) {
|
||
pedNodes = nodes;
|
||
pedRepeat = {};
|
||
for (const k in nodes) {
|
||
const id = dogKey(nodes[k]);
|
||
if (id) pedRepeat[id] = (pedRepeat[id] || 0) + 1;
|
||
}
|
||
if (pedRepeatNote) pedRepeatNote.hidden = !Object.values(pedRepeat).some((c) => c > 1);
|
||
pedTree.textContent = "";
|
||
if (pedView === "fan") renderFan(nodes); else renderTree(nodes);
|
||
updatePedViewControls();
|
||
}
|
||
|
||
function renderTree(nodes) {
|
||
const root = buildPedNode(nodes, 1, 0);
|
||
if (!root) return;
|
||
const ul = document.createElement("ul");
|
||
ul.className = "ped-tree-h";
|
||
ul.append(root);
|
||
pedTree.append(ul);
|
||
if (pedFoldAll) pedFoldAll.textContent = "Collapse all"; // fresh tree starts partly open
|
||
}
|
||
|
||
// ---- radial fan chart ----
|
||
// The dog sits in a centre disc; each generation is a ring fanning outward.
|
||
// A position p is at generation g = floor(log2 p); within that ring it takes
|
||
// the wedge (idx=p-2^g) of 2^g equal slices, which nests each dog's parents
|
||
// directly outside it. Labels only fit on the inner rings; deeper wedges are
|
||
// colour only, with details on tap. Repeated dogs (pedigree collapse) carry
|
||
// their stable hue so tapping one lights up every wedge of that dog.
|
||
const FAN_MAX_GEN = 9;
|
||
const SVGNS = "http://www.w3.org/2000/svg";
|
||
const fanPolar = (r, deg) => {
|
||
const a = (deg - 90) * Math.PI / 180;
|
||
return [r * Math.cos(a), r * Math.sin(a)];
|
||
};
|
||
const fanNum = (n) => Math.round(n * 100) / 100;
|
||
|
||
function renderFan(nodes) {
|
||
let maxGen = 0;
|
||
for (const k in nodes) { const g = Math.floor(Math.log2(Number(k))); if (g > maxGen) maxGen = g; }
|
||
maxGen = Math.min(maxGen, FAN_MAX_GEN);
|
||
|
||
const r0 = 46;
|
||
const radii = [r0];
|
||
for (let g = 1; g <= maxGen; g++) radii[g] = radii[g - 1] + Math.max(24, 50 - g * 3);
|
||
const R = radii[maxGen] || r0;
|
||
const pad = 4;
|
||
const box = (R + pad) * 2;
|
||
|
||
const svg = document.createElementNS(SVGNS, "svg");
|
||
svg.setAttribute("class", "ped-fan");
|
||
svg.setAttribute("viewBox", `${-R - pad} ${-R - pad} ${box} ${box}`);
|
||
svg.setAttribute("width", box);
|
||
svg.setAttribute("height", box);
|
||
|
||
for (let g = 1; g <= maxGen; g++) {
|
||
const count = 2 ** g, degPer = 360 / count, ri = radii[g - 1], ro = radii[g];
|
||
for (let idx = 0; idx < count; idx++) {
|
||
const n = nodes[String(count + idx)];
|
||
if (!n) continue;
|
||
const a0 = idx * degPer, a1 = a0 + degPer, large = (a1 - a0) > 180 ? 1 : 0;
|
||
const [x1, y1] = fanPolar(ri, a0), [x2, y2] = fanPolar(ro, a0);
|
||
const [x3, y3] = fanPolar(ro, a1), [x4, y4] = fanPolar(ri, a1);
|
||
const path = document.createElementNS(SVGNS, "path");
|
||
path.setAttribute("d",
|
||
`M${fanNum(x2)} ${fanNum(y2)}A${fanNum(ro)} ${fanNum(ro)} 0 ${large} 1 ${fanNum(x3)} ${fanNum(y3)}` +
|
||
`L${fanNum(x4)} ${fanNum(y4)}A${fanNum(ri)} ${fanNum(ri)} 0 ${large} 0 ${fanNum(x1)} ${fanNum(y1)}Z`);
|
||
path.setAttribute("class", "ped-wedge");
|
||
path.style.setProperty("--gen", g);
|
||
const key = dogKey(n);
|
||
if (key && pedRepeat[key] > 1) {
|
||
path.dataset.dogkey = key;
|
||
path.style.setProperty("--repeat-hue", hueFor(key));
|
||
path.classList.add("ped-wedge-repeat");
|
||
}
|
||
const title = document.createElementNS(SVGNS, "title");
|
||
title.textContent = fanTitle(n, key);
|
||
path.append(title);
|
||
path.addEventListener("click", () => selectFan(count + idx));
|
||
svg.append(path);
|
||
if (degPer >= 20) fanLabel(svg, n, (a0 + a1) / 2, ri, ro, degPer);
|
||
}
|
||
}
|
||
|
||
// centre disc = the dog
|
||
const c = document.createElementNS(SVGNS, "circle");
|
||
c.setAttribute("r", r0);
|
||
c.setAttribute("class", "ped-fan-center");
|
||
c.addEventListener("click", () => selectFan(1));
|
||
svg.append(c);
|
||
fanCenterLabel(svg, nodes["1"], r0);
|
||
|
||
pedTree.append(svg);
|
||
if (pedCaption) {
|
||
pedCaption.hidden = false;
|
||
pedCaption.textContent = "Tap a wedge for its dog. Zoom to read the outer rings.";
|
||
}
|
||
}
|
||
|
||
function fanTitle(n, key) {
|
||
let t = n.name || "(unnamed)";
|
||
if (n.reg) t += " — " + n.reg;
|
||
if (key && pedRepeat[key] > 1) t += " (×" + pedRepeat[key] + ")";
|
||
return t;
|
||
}
|
||
|
||
function fanLabel(svg, n, midA, ri, ro, degPer) {
|
||
let rot = midA - 90;
|
||
if (rot > 90 && rot < 270) rot -= 180; // keep upright
|
||
const [px, py] = fanPolar((ri + ro) / 2, midA);
|
||
const t = document.createElementNS(SVGNS, "text");
|
||
t.setAttribute("class", "ped-wedge-label");
|
||
t.setAttribute("transform", `translate(${fanNum(px)} ${fanNum(py)}) rotate(${fanNum(rot)})`);
|
||
const room = Math.floor((ro - ri) / 6.2); // chars that fit along the ring
|
||
t.textContent = fanTrunc(n.name || (n.reg || "?"), Math.max(6, room));
|
||
svg.append(t);
|
||
}
|
||
|
||
function fanCenterLabel(svg, n, r0) {
|
||
if (!n) return;
|
||
const words = (n.name || "Dog").split(" ");
|
||
const lines = [];
|
||
let line = "";
|
||
for (const w of words) {
|
||
if ((line + " " + w).trim().length > 12) { if (line) lines.push(line); line = w; }
|
||
else line = (line ? line + " " : "") + w;
|
||
}
|
||
if (line) lines.push(line);
|
||
const shown = lines.slice(0, 3);
|
||
const t = document.createElementNS(SVGNS, "text");
|
||
t.setAttribute("class", "ped-fan-center-label");
|
||
t.setAttribute("text-anchor", "middle");
|
||
const lh = 12, y0 = -(shown.length - 1) * lh / 2;
|
||
shown.forEach((ln, i) => {
|
||
const ts = document.createElementNS(SVGNS, "tspan");
|
||
ts.setAttribute("x", "0");
|
||
ts.setAttribute("y", fanNum(y0 + i * lh));
|
||
ts.textContent = ln;
|
||
t.append(ts);
|
||
});
|
||
svg.append(t);
|
||
}
|
||
|
||
function fanTrunc(s, max) { return s.length > max ? s.slice(0, max - 1) + "…" : s; }
|
||
|
||
function selectFan(pos) {
|
||
const n = pedNodes[String(pos)];
|
||
if (!n || !pedCaption) return;
|
||
const key = dogKey(n);
|
||
const bits = [];
|
||
if (n.reg) bits.push(n.reg);
|
||
if (n.titles) bits.push(n.titles);
|
||
if (key && pedRepeat[key] > 1) bits.push("appears ×" + pedRepeat[key]);
|
||
pedCaption.hidden = false;
|
||
pedCaption.textContent = (n.name || "(unnamed)") + (bits.length ? " — " + bits.join(" · ") : "");
|
||
togglePedHighlight(key && pedRepeat[key] > 1 ? key : " |