The tab row was painted in the page ground while the day bar above it is a card, and the two sat as separate bars with the day bar's rounded corners cutting between them — so the frozen top of the screen read as two mismatched strips rather than one thing. It takes the same card now: surface colour, radius and shadow. Once the log buttons have scrolled away and the two bars meet, they lose the seam and share one frame — the day bar squares its bottom corners and gives up its shadow, the tab row squares its top, and the pair casts a single shadow below. Scrolled back to the top they are two cards again, which is right: the log buttons genuinely sit between them there. CSS has no way to ask whether a sticky element is currently stuck, so a class is toggled from JS on the simplest available test — whether the two are touching. It rides the rAF-throttled scroll handler that already existed for the timer pill, so it adds no listener, and it is re-checked on a tab switch (a shorter tab can leave the page too short to stay scrolled) and when the day bar changes height. The tab row's sticky offset is now a pixel less than the day bar's height. The safe-area inset is free to be fractional while the measured height is a whole number, and that shortfall would show as a hairline of page ground between the two; the overlap it costs is invisible, since the day bar paints on top in the same colour.
5245 lines
212 KiB
JavaScript
5245 lines
212 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;
|
||
// A guest is someone here on a share link (see "guest links" in the README):
|
||
// the same data as the owner, minus everything under Settings that is the
|
||
// owner's to decide. currentUser.id is the owner's either way, which is what
|
||
// keeps the cache keys below pointing at the right data.
|
||
const isGuest = () => currentUser?.role === "guest";
|
||
const guestLabel = () => (isGuest() ? (currentUser.label || "") : "");
|
||
const guestShareId = () => (isGuest() ? (currentUser.shareId || "") : "");
|
||
|
||
// Who may change an already-logged event. The owner may change anything on
|
||
// their own account; a guest may only touch what they logged themselves, so a
|
||
// sitter can fix up their own entries and cannot rewrite or delete a single
|
||
// one of the owner's. Keyed on the link id rather than its label, because two
|
||
// links can easily carry the same label ("Sitter"). The server enforces the
|
||
// same rule in Store.sync — this is what keeps the UI honest about it.
|
||
const canEditEvent = (ev) => !isGuest() || ev.loggedByShare === guestShareId();
|
||
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,
|
||
// Only set when logging through a guest link, and only so the badge and
|
||
// the "you may edit this" check work before the first sync: the server
|
||
// stamps both authoritatively on insert, and mergeSynced keeps our copy
|
||
// when updatedAt ties, so without a local stamp neither would settle
|
||
// until some later edit. Both sides compute the same values, so the two
|
||
// never disagree.
|
||
loggedBy: guestLabel(),
|
||
loggedByShare: guestShareId(),
|
||
};
|
||
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;
|
||
}
|
||
|
||
// ---------- days that don't count ----------
|
||
// Not every logged day is equally trustworthy: a day someone else had the
|
||
// puppy produces a thin record that reads exactly like a real one, and then
|
||
// drags down every average. Marking a day "not counted" keeps its data
|
||
// untouched and fully visible on the day itself, but takes it out of
|
||
// everything that aggregates across days.
|
||
//
|
||
// A mark is an ordinary event, the way a training session is: it rides the
|
||
// existing sync with per-item last-write-wins and tombstones (un-marking is
|
||
// just a delete), so it needs no table, endpoint or contract of its own.
|
||
// Timestamped at noon so the day it lands on is unambiguous under the same
|
||
// ymd() bucketing every other event uses.
|
||
const EXCLUDED_TYPE = "day-excluded";
|
||
|
||
// Behaviour is what a sparse logger distorts. A weigh-in and a note are
|
||
// records of fact, so they keep counting even on a day that doesn't.
|
||
const ALWAYS_COUNTS = new Set(["weight", "note"]);
|
||
|
||
// Refreshed once per render() and read like chartDays() — the chart drawers
|
||
// need to know which day slots to hatch, not just which events to drop.
|
||
let excludedSet = new Set();
|
||
|
||
function excludedDays(events) {
|
||
return new Set(
|
||
events.filter(e => e.type === EXCLUDED_TYPE).map(e => ymd(new Date(e.at)))
|
||
);
|
||
}
|
||
|
||
const isExcluded = (date) => excludedSet.has(ymd(date));
|
||
|
||
// The event list the cross-day panels see.
|
||
function countedEvents(events) {
|
||
if (excludedSet.size === 0) return events;
|
||
return events.filter(e =>
|
||
ALWAYS_COUNTS.has(e.type) || !excludedSet.has(ymd(new Date(e.at))));
|
||
}
|
||
|
||
// True if any part of [from, to] falls on an excluded day. Used to throw away
|
||
// measurements that reach across one — see gapsBetween.
|
||
function spansExcluded(from, to) {
|
||
if (excludedSet.size === 0) return false;
|
||
for (let d = startOfDay(new Date(from)); d.getTime() <= to; d.setDate(d.getDate() + 1)) {
|
||
if (isExcluded(d)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
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 }));
|
||
}
|
||
|
||
// ---------- 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. Defaults to the picked chart window, so
|
||
// the 7d/14d/30d buttons widen the timing panel along with the charts —
|
||
// read at call time, not at definition, so a change takes on the next render.
|
||
function gapsBetween(events, type, days = chartDays()) {
|
||
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++) {
|
||
// A gap reaching across a day that doesn't count is not a real gap: the
|
||
// events in between were dropped, so Tuesday's last pee now sits next to
|
||
// Thursday's first and the subtraction invents thirty hours. Discarding
|
||
// the pair is the only honest answer — measuring it would blow out the
|
||
// "longest" figure far worse than the sparse day this feature exists to
|
||
// take out of the numbers.
|
||
if (spansExcluded(times[i - 1], times[i])) continue;
|
||
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) {
|
||
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 = gapsBetween(events, row.type);
|
||
const last = lastEventOfType(events, row.type);
|
||
drawTimingChart(svg, note, row, gaps, last ? Math.max(0, Date.now() - last.at) : null);
|
||
}
|
||
}
|
||
|
||
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 ${chartDays()} 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 ${chartDays()} 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");
|
||
// The card lives on the Today tab, so on any other tab it isn't on screen
|
||
// at all and the pill is the only timer there is. Asked explicitly rather
|
||
// than left to the rect: a hidden element measures as zeroes, which would
|
||
// give the right answer here by coincidence rather than by rule.
|
||
const cardOnThisTab = card.offsetParent !== null;
|
||
const cardVisible = cardOnThisTab &&
|
||
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) {
|
||
// A window may bring its own class and label, which is how the merged
|
||
// sleep/wake list states every row's kind. A single-kind list passes one
|
||
// class and one label for the lot and only tags the open row, where
|
||
// "Walking" is news rather than a repetition of the heading.
|
||
const cls = w.cls || extraClass;
|
||
const tag = w.tag || (w.ongoing ? ongoingLabel : "");
|
||
const li = document.createElement("li");
|
||
li.className = `ww ${cls}` + (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 (tag) {
|
||
const tagEl = document.createElement("span");
|
||
tagEl.className = "ww-tag";
|
||
tagEl.textContent = tag;
|
||
li.appendChild(tagEl);
|
||
}
|
||
list.appendChild(li);
|
||
}
|
||
}
|
||
|
||
// Sleep and wake windows are the same boundaries read two ways — a wake
|
||
// window is precisely the gap between two sleeps — so they alternate by
|
||
// construction and sorting the two sets by start time interleaves them back
|
||
// into the day's actual rhythm, "45 minutes asleep after two and a half hours
|
||
// up" in one glance instead of a comparison between two lists.
|
||
//
|
||
// Newest first, like the history and notes logs: the row still running is
|
||
// then the first one you see, and today's list grows downward from where you
|
||
// are rather than pushing the current state further away with every window.
|
||
function sleepWakeWindowsForDay(events, day) {
|
||
return [
|
||
...sleepWindowsForDay(events, day).map(w => ({ ...w, cls: "sleep-ww", tag: "Asleep" })),
|
||
...wakeWindowsForDay(events, day).map(w => ({ ...w, cls: "wake-ww", tag: "Awake" })),
|
||
].sort((a, b) => b.start - a.start);
|
||
}
|
||
|
||
function renderSleepWake(events) {
|
||
renderWindowList(
|
||
"sleep-wake-list", "sleep-wake-empty",
|
||
sleepWakeWindowsForDay(events, selectedDay()),
|
||
"", "",
|
||
);
|
||
}
|
||
|
||
function renderWalks(events) {
|
||
const day = selectedDay();
|
||
const windows = walkWindowsForDay(events, day);
|
||
// Newest first, like every other list in the app, so a walk in progress is
|
||
// the first row. Reversed here rather than in walkWindowsForDay, whose
|
||
// other callers — the day's total and the history rails — read the windows
|
||
// as a set and would only be made harder to follow by a display order.
|
||
renderWindowList(
|
||
"walk-list", "walk-empty",
|
||
[...windows].reverse(),
|
||
"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)})` : "";
|
||
}
|
||
|
||
// 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();
|
||
// The "not counted" mark is bookkeeping about the day, not something that
|
||
// happened to the puppy, so it never appears as a row in the log.
|
||
const chronological = eventsForDay(events, day).filter(e => e.type !== EXCLUDED_TYPE);
|
||
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>
|
||
`;
|
||
// Logged through a guest link: say whose, so a row you don't remember
|
||
// making has an explanation attached to it.
|
||
if (ev.loggedBy) {
|
||
const by = document.createElement("span");
|
||
by.className = "by";
|
||
by.textContent = ev.loggedBy;
|
||
by.title = `Logged by ${ev.loggedBy} on a guest link`; // the badge is truncated
|
||
li.querySelector(".label").after(by);
|
||
}
|
||
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();
|
||
// A day marked "not counted" keeps its slot on the axis — dropping it
|
||
// would make consecutive bars stop being consecutive days — but carries
|
||
// no figures. Zeroing them here rather than in each chart means every
|
||
// axis maximum, total and tooltip downstream is already right, and the
|
||
// drawers only have to decide what to paint in the empty slot.
|
||
const excluded = isExcluded(d);
|
||
const sleepMs = excluded ? 0 : sleepMsInRange(events, from, to);
|
||
const dayEvents = excluded ? [] : eventsForDay(events, d);
|
||
days.push({
|
||
date: d,
|
||
ymd: ymd(d),
|
||
excluded,
|
||
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: excluded ? 0 : walkMsInRange(events, from, to) / 60_000,
|
||
});
|
||
}
|
||
return days;
|
||
}
|
||
|
||
// A day that doesn't count gets a hatched column where its bar would be, so
|
||
// the gap reads as deliberate rather than as a day the puppy barely did
|
||
// anything. Shared by all four daily bar charts, which differ only in height.
|
||
function excludedSlot(d, x, barW, top, innerH) {
|
||
const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — not counted`;
|
||
return `<rect class="bar bar-excluded" data-day="${d.ymd}" x="${x}" y="${top}" ` +
|
||
`width="${barW}" height="${innerH}" rx="3"><title>${escapeText(title)}</title></rect>`;
|
||
}
|
||
|
||
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"/>`);
|
||
}
|
||
if (d.excluded) {
|
||
parts.push(excludedSlot(d, x, barW, MT, innerH));
|
||
} else {
|
||
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"/>`);
|
||
}
|
||
if (d.excluded) {
|
||
// One hatch across the whole group rather than three empty bars.
|
||
parts.push(excludedSlot(d, groupX, groupW, MT, innerH));
|
||
} else {
|
||
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"/>`);
|
||
}
|
||
if (d.excluded) {
|
||
parts.push(excludedSlot(d, x, barW, MT, innerH));
|
||
} else {
|
||
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"/>`);
|
||
}
|
||
if (d.excluded) {
|
||
parts.push(excludedSlot(d, x, barW, MT, innerH));
|
||
} else {
|
||
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 ----------
|
||
|
||
// Where "now" falls in the day, 0..1 — the shared x for the charts that run
|
||
// midnight to midnight.
|
||
function nowDayFraction() {
|
||
const now = new Date();
|
||
return (now.getTime() - startOfDay(now).getTime()) / 86_400_000;
|
||
}
|
||
|
||
// Vertical "now" rule for those charts: ink over a surface-coloured underlay
|
||
// so it stays legible whatever bar or cell it crosses, with a caret hung
|
||
// above the track so it doesn't read as one more gridline. Append the parts
|
||
// last so the rule sits on top; it ignores pointer events, so the rows and
|
||
// cells underneath stay tappable.
|
||
function nowRuleParts(x, yTop, yBottom) {
|
||
const xs = x.toFixed(1);
|
||
return [
|
||
`<line class="now-rule-ring" x1="${xs}" y1="${yTop}" x2="${xs}" y2="${yBottom}"/>`,
|
||
`<line class="now-rule" x1="${xs}" y1="${yTop}" x2="${xs}" y2="${yBottom}"/>`,
|
||
`<path class="now-rule-cap" d="M${(x - 3).toFixed(1)} ${yTop - 4} ` +
|
||
`L${(x + 3).toFixed(1)} ${yTop - 4} L${xs} ${yTop} Z"/>`,
|
||
];
|
||
}
|
||
|
||
// Actogram: one row per day (oldest at top), a midnight-to-midnight track
|
||
// with the given windows shaded. Windows are clipped to each day, so one that
|
||
// crosses midnight shows correctly split across two rows, and an open one
|
||
// runs to now (both window builders already clip ongoing to Date.now()).
|
||
// Sleep and walks are the same picture drawn from different windows, so the
|
||
// drawing lives here once and each caller brings its windows and their fill.
|
||
function drawActogram(svgId, windows, barCls) {
|
||
const svg = document.getElementById(svgId);
|
||
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 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"/>`);
|
||
|
||
// A day that doesn't count keeps its row — the rows are a calendar, so
|
||
// dropping one would misalign every day above it — but is hatched right
|
||
// across instead of showing the sparse windows that made it untrustworthy.
|
||
if (isExcluded(day)) {
|
||
parts.push(`<rect class="bar-excluded" x="${ML}" y="${y.toFixed(1)}" width="${innerW}" height="${rowH.toFixed(1)}" rx="2"/>`);
|
||
} else {
|
||
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="${barCls}" 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 dateText = day.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" });
|
||
const title = isExcluded(day) ? `${dateText} — not counted` : dateText;
|
||
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>`);
|
||
}
|
||
|
||
// The window always ends today, so one rule marks both the live edge of the
|
||
// bottom row and the same time of day on every row above it.
|
||
parts.push(...nowRuleParts(xOf(nowDayFraction()), MT, H - MB));
|
||
|
||
setChartSVG(svg, parts); // wires the .bar[data-day] click → select that day
|
||
}
|
||
|
||
function renderSleepTimeline(events) {
|
||
drawActogram("chart-sleep-timeline", sleepWindows(events), "stl-sleep");
|
||
}
|
||
|
||
// The walk patterns are only worth a panel once there is a walk to draw, so
|
||
// both hide themselves rather than showing an empty day grid to someone who
|
||
// doesn't track walks.
|
||
function renderWalkPatterns(events) {
|
||
const windows = walkWindows(events);
|
||
const any = windows.length > 0;
|
||
for (const key of ["walk-timeline", "walk-trend"]) {
|
||
const section = document.querySelector(`section[data-panel="${key}"]`);
|
||
if (section) section.hidden = !any;
|
||
}
|
||
if (!any) return;
|
||
drawActogram("chart-walk-timeline", windows, "wtl-walk");
|
||
renderWalkTrend(events);
|
||
}
|
||
|
||
// Cumulative minutes walked through the day, as a step per walk: flat while
|
||
// the puppy is in, climbing only while a walk is on. Today and yesterday are
|
||
// built from their own walk boundaries so the steps stay square; the average
|
||
// is sampled hourly instead, since a mean over many days is a smooth
|
||
// reference with no steps of its own to preserve.
|
||
//
|
||
// Unlike the sleep trend there is no projected tail. Sleep is a state the
|
||
// puppy drifts back into, so "what the average day adds from here" is a fair
|
||
// guess; walks are decisions, and projecting them would be predicting the
|
||
// handler, not the dog.
|
||
function walkTrendCurves(events) {
|
||
const HOUR = 3_600_000, MIN = 60_000, DAY = 86_400_000;
|
||
const windows = walkWindows(events); // an ongoing walk is already clipped to now
|
||
const walkedMs = (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 dayAgo = (daysAgo) => {
|
||
const d = startOfDay(day);
|
||
d.setDate(d.getDate() - daysAgo);
|
||
return d;
|
||
};
|
||
const dayStartTs = (daysAgo) => dayAgo(daysAgo).getTime();
|
||
const pointsAt = (start, stops) =>
|
||
stops.map(t => ({ x: (t - start) / HOUR, y: walkedMs(start, t) / MIN }));
|
||
|
||
// Every boundary falling inside the day, plus the day's two ends, is a
|
||
// corner of the step line. capTs (today only) ends it at "now".
|
||
const curveFor = (start, capTs) => {
|
||
const end = capTs != null ? capTs : start + DAY;
|
||
const stops = new Set([start, end]);
|
||
for (const w of windows) {
|
||
if (w.end <= start || w.start >= end) continue;
|
||
stops.add(Math.max(w.start, start));
|
||
stops.add(Math.min(w.end, end));
|
||
}
|
||
return pointsAt(start, [...stops].sort((a, b) => a - b));
|
||
};
|
||
const hourlyFor = (start) => {
|
||
const stops = [];
|
||
for (let h = 0; h <= 24; h++) stops.push(start + h * HOUR);
|
||
return pointsAt(start, stops);
|
||
};
|
||
const totalOf = (pts) => pts[pts.length - 1].y;
|
||
|
||
const today = curveFor(dayStartTs(0), isToday ? Date.now() : null);
|
||
// Same as the sleep trend: a day that doesn't count is dropped as a
|
||
// comparison rather than drawn flat at zero.
|
||
const prev = curveFor(dayStartTs(1));
|
||
const yesterday = (!isExcluded(dayAgo(1)) && totalOf(prev) > 0) ? prev : null;
|
||
|
||
// Mean of the last N days, skipping days that don't count and days with no
|
||
// walk at all, so a gap in logging doesn't drag the average toward zero.
|
||
const avgDays = chartDays();
|
||
const dayCurves = [];
|
||
for (let i = 1; i <= avgDays; i++) {
|
||
if (isExcluded(dayAgo(i))) continue;
|
||
const c = hourlyFor(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 });
|
||
}
|
||
}
|
||
|
||
const fmtDay = (daysAgo) =>
|
||
new Date(dayStartTs(daysAgo)).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||
return {
|
||
today, yesterday, avg, avgDays,
|
||
dayLabel: isToday ? "Today" : fmtDay(0),
|
||
prevDayLabel: isToday ? "Yesterday" : fmtDay(1),
|
||
};
|
||
}
|
||
|
||
function drawWalkTrendChart(curves) {
|
||
const svg = document.getElementById("chart-walk-trend");
|
||
if (!svg) return;
|
||
const W = 320, H = 180;
|
||
const ML = 30, MR = 8, MT = 10, MB = 22;
|
||
const innerW = W - ML - MR;
|
||
const innerH = H - MT - MB;
|
||
|
||
const series = [
|
||
// Reference lines first so today draws on top of them.
|
||
{ pts: curves.avg, cls: "wtrend-avg", label: `${curves.avgDays}-day average` },
|
||
{ pts: curves.yesterday, cls: "wtrend-yesterday", label: curves.prevDayLabel },
|
||
{ pts: curves.today, cls: "wtrend-today", label: curves.dayLabel },
|
||
].filter(s => s.pts && s.pts.length > 1);
|
||
|
||
const rawMax = Math.max(...series.flatMap(s => s.pts.map(p => p.y)));
|
||
const { yMax, steps } = niceAxisLinear(rawMax);
|
||
// Minutes are linear all the way down, so no split axis: the sleep trend
|
||
// stretches its top because the interesting hours cluster near a 16h goal,
|
||
// while a walk total is as readable at 10 minutes as at 60.
|
||
const xOf = (hour) => ML + (hour / 24) * innerW;
|
||
const yOf = (v) => MT + innerH * (1 - v / yMax);
|
||
|
||
const parts = [];
|
||
for (let i = 0; i <= steps; i++) {
|
||
const v = yMax * i / steps;
|
||
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 x="${ML - 4}" y="${(y + 3).toFixed(1)}" text-anchor="end">${Math.round(v)}m</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];
|
||
parts.push(
|
||
`<path class="${s.cls}" d="${d}">` +
|
||
`<title>${escapeText(`${s.label} — ${Math.round(last.y)} min walked`)}</title></path>`
|
||
);
|
||
}
|
||
|
||
svg.innerHTML = parts.join("");
|
||
}
|
||
|
||
function renderWalkTrend(events) {
|
||
const curves = walkTrendCurves(events);
|
||
drawWalkTrendChart(curves);
|
||
|
||
const chip = (id) => document.getElementById(id);
|
||
const mins = (pts) => `${Math.round(pts[pts.length - 1].y)} min`;
|
||
chip("legend-wtrend-today-text").textContent = `${curves.dayLabel} ${mins(curves.today)}`;
|
||
|
||
const yLegend = chip("legend-wtrend-yesterday");
|
||
yLegend.hidden = !curves.yesterday;
|
||
if (curves.yesterday) {
|
||
chip("legend-wtrend-yesterday-text").textContent = `${curves.prevDayLabel} ${mins(curves.yesterday)}`;
|
||
}
|
||
const aLegend = chip("legend-wtrend-avg");
|
||
aLegend.hidden = !curves.avg;
|
||
if (curves.avg) {
|
||
chip("legend-wtrend-avg-text").textContent = `${curves.avgDays}-day avg ${mins(curves.avg)}`;
|
||
}
|
||
}
|
||
|
||
// 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, nowRuleParts(ML + nowDayFraction() * innerW, MT, H - MB)).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 dayAgo = (daysAgo) => {
|
||
const d = startOfDay(day);
|
||
d.setDate(d.getDate() - daysAgo);
|
||
return d;
|
||
};
|
||
const dayStartTs = (daysAgo) => dayAgo(daysAgo).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);
|
||
|
||
// A day that doesn't count is no comparison at all, so it is dropped
|
||
// outright rather than drawn as a flat line at zero. Checked explicitly
|
||
// rather than leaning on the "any sleep at all" guard below: a nap running
|
||
// in from the previous, counted day would give an excluded day a non-zero
|
||
// total and sneak it back in.
|
||
const yesterdayCurve = curveFor(dayStartTs(1));
|
||
const yesterday =
|
||
(!isExcluded(dayAgo(1)) && yesterdayCurve[24].y > 0) ? yesterdayCurve : null;
|
||
|
||
// Mean of the last N full days, skipping days that don't count and 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++) {
|
||
if (isExcluded(dayAgo(i))) continue;
|
||
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 d = dayList[c];
|
||
const dateText = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||
// A day that doesn't count would otherwise show as the palest cell —
|
||
// indistinguishable from "trained nothing that day", which is the one
|
||
// reading the mark is there to rule out.
|
||
if (isExcluded(d)) {
|
||
parts.push(
|
||
`<rect class="bar bar-excluded" data-day="${ymd(d)}" ` +
|
||
`x="${(ML + c * cellW).toFixed(1)}" y="${y.toFixed(1)}" ` +
|
||
`width="${(cellW - 1.5).toFixed(1)}" height="${rowH}" rx="2">` +
|
||
`<title>${escapeText(`${x.name} · ${dateText}: not counted`)}</title></rect>`
|
||
);
|
||
continue;
|
||
}
|
||
const op = n === 0 ? 0.06 : 0.35 + 0.65 * (n / max);
|
||
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(`${x.name} · ${dateText}: ${n}`)}</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;
|
||
// Adding to the library is the owner's, like editing it.
|
||
document.getElementById("exercise-add").hidden = isGuest();
|
||
|
||
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
|
||
|| (isGuest() ? "No instructions for this one yet."
|
||
: "No instructions yet — tap Edit to add how to train this.");
|
||
detail.appendChild(noteEl);
|
||
// The exercise library is the owner's, not a log: a guest trains against
|
||
// it and reads the instructions, but doesn't get to rename or delete
|
||
// anything in it. The server drops guest-sent exercises to match.
|
||
if (!isGuest()) {
|
||
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(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" });
|
||
}
|
||
renderExcludeToggle(day);
|
||
}
|
||
|
||
// The "not counted" switch, and the overview's own dimmed state while it is
|
||
// on, so the mark is legible from the day itself and not only from the charts.
|
||
function renderExcludeToggle(day) {
|
||
const btn = document.getElementById("exclude-day");
|
||
const note = document.getElementById("excluded-note");
|
||
const panel = document.querySelector('[data-panel="overview"]');
|
||
// Deciding a day doesn't count is a judgment about the owner's own record,
|
||
// so it sits with the rest of what a guest cannot do.
|
||
btn.hidden = isGuest();
|
||
const off = isExcluded(day);
|
||
btn.setAttribute("aria-pressed", String(off));
|
||
btn.classList.toggle("active", off);
|
||
note.hidden = !off;
|
||
panel.classList.toggle("day-excluded", off);
|
||
}
|
||
|
||
// Marking is adding an event; un-marking is deleting it — so both ride the
|
||
// ordinary sync, offline included, with no special casing anywhere.
|
||
function toggleExcludedDay(day) {
|
||
const key = ymd(day);
|
||
const existing = live().filter(
|
||
e => e.type === EXCLUDED_TYPE && ymd(new Date(e.at)) === key);
|
||
if (existing.length > 0) {
|
||
// Plural in principle: two devices could each mark the same day offline.
|
||
for (const e of existing) deleteEvent(e.id);
|
||
} else {
|
||
// Noon, so the day it lands on survives any clock or timezone wobble.
|
||
const at = startOfDay(day);
|
||
at.setHours(12, 0, 0, 0);
|
||
addEvent(EXCLUDED_TYPE, "", at.getTime());
|
||
}
|
||
}
|
||
|
||
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";
|
||
const walkTitle = document.getElementById("walk-timeline-title");
|
||
if (walkTitle) walkTitle.textContent = cfg.name ? `When ${cfg.name} walks` : "When walking";
|
||
// The guest banner names the dog too, so it follows the profile in.
|
||
renderGuestBanner();
|
||
}
|
||
|
||
// 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();
|
||
// Which days don't count is read straight off the event list, and settles
|
||
// before anything draws — the chart drawers consult excludedSet directly.
|
||
excludedSet = excludedDays(events);
|
||
const counted = countedEvents(events);
|
||
renderHeader();
|
||
renderDayBar();
|
||
renderChartWindow();
|
||
// Day-scoped panels get the full list: you navigated to this day, so you
|
||
// should see what is actually on it, marked or not.
|
||
renderBigClock(events);
|
||
renderActionHints(events);
|
||
renderStats(events);
|
||
renderLasts(events);
|
||
renderSleepWake(events);
|
||
renderWalks(events);
|
||
renderHistory(events);
|
||
// Weight and notes are records of fact rather than behaviour a sparse
|
||
// logger distorts, so they count everywhere regardless.
|
||
renderWeight(events);
|
||
renderNotes(events);
|
||
// Everything that aggregates across days works from the counted list.
|
||
renderTiming(counted);
|
||
renderWeekly(counted);
|
||
renderSleepTimeline(counted);
|
||
renderWalkPatterns(counted);
|
||
renderSleepTrend(counted);
|
||
renderHourHeatmap(counted);
|
||
renderTraining(counted);
|
||
}
|
||
|
||
// ---------- 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.
|
||
//
|
||
// serverWins overrides that for items we are not allowed to change: the
|
||
// server will have refused the write, so keeping our newer local copy would
|
||
// leave the screen showing an edit that never happened and re-posting it
|
||
// forever. Taking the server's version instead makes the client heal itself.
|
||
function mergeSynced(serverItems, load, save, serverWins = () => false) {
|
||
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 && serverWins(se)) continue;
|
||
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" },
|
||
// A guest receives the exercise library but never writes to it, so it
|
||
// sends nothing rather than posting a list the server would discard.
|
||
body: JSON.stringify({ exercises: isGuest() ? [] : 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)) {
|
||
// Same reasoning as the events below: a guest's local exercise edits
|
||
// can never land, so the server's copy is always the truth.
|
||
mergeSynced(exBody.exercises, loadExercises, saveExercises, isGuest);
|
||
}
|
||
if (Array.isArray(body.events)) {
|
||
mergeSynced(body.events, loadAll, saveAll, (ev) => !canEditEvent(ev));
|
||
}
|
||
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 editLoggedBy = document.getElementById("edit-logged-by");
|
||
const editReadOnly = document.getElementById("edit-readonly");
|
||
const editTitle = document.getElementById("edit-title");
|
||
const editDelete = document.getElementById("edit-delete");
|
||
const editSave = document.querySelector('#edit-form button[value="save"]');
|
||
const editCancel = document.querySelector('#edit-form button[value="cancel"]');
|
||
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 = "";
|
||
}
|
||
|
||
// Turns the edit dialog into a viewer: every field disabled, Save and Delete
|
||
// gone, only Cancel left. Used when a guest opens an entry that isn't theirs
|
||
// — the server would refuse the change anyway (Store.sync), and a form that
|
||
// silently discards what you typed is worse than one that says it is closed.
|
||
function setEditReadOnly(on) {
|
||
editReadOnly.hidden = !on;
|
||
editTitle.textContent = on ? "Event" : "Edit event";
|
||
for (const el of [editDate, editTime, editNote, editWeight, editGrams, editPhotoBtn]) {
|
||
el.disabled = on;
|
||
}
|
||
editDelete.hidden = on;
|
||
editSave.hidden = on;
|
||
editCancel.textContent = on ? "Close" : "Cancel";
|
||
}
|
||
|
||
async function openEditDialog(ev) {
|
||
editingId = ev.id;
|
||
editLoggedBy.hidden = !ev.loggedBy;
|
||
if (ev.loggedBy) editLoggedBy.textContent = `Logged by ${ev.loggedBy} on a guest link.`;
|
||
setEditReadOnly(!canEditEvent(ev));
|
||
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");
|
||
const settingsProfile = document.getElementById("settings-profile");
|
||
const settingsDanger = document.getElementById("settings-danger");
|
||
|
||
// 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();
|
||
// A guest keeps the two device-local preferences and loses everything that
|
||
// belongs to the account — profile, reminders, guest links, deletion.
|
||
const guest = isGuest();
|
||
settingsProfile.hidden = guest;
|
||
settingsDanger.hidden = guest;
|
||
guestAccess.hidden = guest;
|
||
settingsDialog.showModal();
|
||
refreshRemindersUI();
|
||
if (!guest) {
|
||
// Re-defaulted per open: the app can sit on screen for days, and a date
|
||
// that was "tomorrow" when it launched may be in the past by now.
|
||
resetGuestExpiry();
|
||
refreshGuestLinks();
|
||
}
|
||
setTimeout(() => (guest ? settingsTheme : settingsName).focus(), 50);
|
||
}
|
||
|
||
document.getElementById("settings-btn").addEventListener("click", openSettingsDialog);
|
||
|
||
settingsForm.querySelector('button[value="save"]').addEventListener("click", async (e) => {
|
||
e.preventDefault();
|
||
// A guest only ever had the two device-local toggles on screen. Saving the
|
||
// profile from here would push the blanked-out fields over the owner's.
|
||
if (isGuest()) {
|
||
setConfettiEnabled(settingsConfetti.checked);
|
||
settingsDialog.close();
|
||
return;
|
||
}
|
||
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();
|
||
});
|
||
|
||
// ---------- guest links ----------
|
||
// Hand someone a URL that logs events on this account without giving them the
|
||
// password. The server holds only a hash of the token (server/auth.go), so the
|
||
// URL is shown once, right after it is minted, and never again.
|
||
|
||
const guestAccess = document.getElementById("guest-access");
|
||
const guestLabelIn = document.getElementById("guest-label");
|
||
const guestCreate = document.getElementById("guest-create");
|
||
const guestError = document.getElementById("guest-error");
|
||
const guestListEl = document.getElementById("guest-list");
|
||
const guestEmpty = document.getElementById("guest-empty");
|
||
const guestExpires = document.getElementById("guest-expires");
|
||
const guestExpHint = document.getElementById("guest-expires-hint");
|
||
|
||
// The owner picks a date; the link dies at the end of it. Working in whole
|
||
// days is what people actually mean ("she has him until Sunday"), and doing
|
||
// the conversion here is the only place the guest's timezone is known — the
|
||
// server just stores the instant it is handed.
|
||
function guestExpiryMs() {
|
||
const [y, m, d] = (guestExpires.value || "").split("-").map(Number);
|
||
if (!y || !m || !d) return 0;
|
||
return endOfDay(new Date(y, m - 1, d)).getTime();
|
||
}
|
||
|
||
function renderGuestExpiryHint() {
|
||
const ms = guestExpiryMs();
|
||
if (!ms) { guestExpHint.textContent = "Pick the last day the link should work."; return; }
|
||
if (ms <= Date.now()) { guestExpHint.textContent = "That date has already passed."; return; }
|
||
guestExpHint.textContent = `Stops working ${formatWhen(ms)}.`;
|
||
}
|
||
|
||
// Default to tomorrow: the common case is a sitter for the day, and a link
|
||
// that dies at midnight tonight is rarely what anyone wants.
|
||
function resetGuestExpiry() {
|
||
guestExpires.min = toDateInput(Date.now());
|
||
guestExpires.value = toDateInput(Date.now() + 86_400_000);
|
||
renderGuestExpiryHint();
|
||
}
|
||
guestExpires.addEventListener("change", renderGuestExpiryHint);
|
||
resetGuestExpiry();
|
||
|
||
// The server never sees its own public origin (it may sit behind any proxy),
|
||
// so the URL is composed here, against wherever this page is actually served.
|
||
function guestURL(token) {
|
||
return new URL(`guest/${token}`, location.href).href;
|
||
}
|
||
|
||
// Day and month as well as the weekday: a 7-day link expires on the same
|
||
// weekday it was made, which "Sun 09:00" alone wouldn't tell apart.
|
||
function formatWhen(ms) {
|
||
return new Date(ms).toLocaleString(undefined, {
|
||
weekday: "short", day: "numeric", month: "short",
|
||
hour: "2-digit", minute: "2-digit",
|
||
});
|
||
}
|
||
|
||
function renderGuestLinks(links) {
|
||
guestListEl.innerHTML = "";
|
||
guestEmpty.textContent = "No active links."; // may hold a stale error
|
||
guestEmpty.hidden = links.length > 0;
|
||
for (const l of links) {
|
||
const li = document.createElement("li");
|
||
li.className = "guest-item";
|
||
const used = l.lastUsed
|
||
? `last used ${formatRelative(l.lastUsed)}`
|
||
: "never used";
|
||
li.innerHTML = `
|
||
<div class="guest-item-row">
|
||
<span class="guest-item-main">
|
||
<span class="guest-item-label"></span>
|
||
<span class="guest-item-sub"></span>
|
||
</span>
|
||
</div>
|
||
`;
|
||
li.querySelector(".guest-item-label").textContent = l.label;
|
||
li.querySelector(".guest-item-sub").textContent = `expires ${formatWhen(l.expires)} · ${used}`;
|
||
const revoke = document.createElement("button");
|
||
revoke.type = "button";
|
||
revoke.className = "linklike guest-revoke";
|
||
revoke.textContent = "Revoke";
|
||
revoke.addEventListener("click", () => revokeGuestLink(l.id, l.label));
|
||
li.querySelector(".guest-item-row").appendChild(revoke);
|
||
|
||
// The URL stays available for as long as the link does, so it can be
|
||
// re-sent without minting a new one and stranding whoever holds the old.
|
||
if (l.token) {
|
||
const url = guestURL(l.token);
|
||
const box = document.createElement("div");
|
||
box.className = "guest-item-url";
|
||
const code = document.createElement("code");
|
||
code.className = "guest-url";
|
||
code.textContent = url;
|
||
code.title = url; // the line is truncated; the tooltip isn't
|
||
// Tapping the URL selects the whole of it, so it can be copied by hand
|
||
// where the clipboard API isn't available (it needs a secure context).
|
||
code.addEventListener("click", () => selectContents(code));
|
||
const copy = document.createElement("button");
|
||
copy.type = "button";
|
||
copy.className = "ghost guest-copy";
|
||
copy.textContent = "Copy";
|
||
copy.addEventListener("click", () => copyGuestURL(copy, code));
|
||
box.appendChild(code);
|
||
box.appendChild(copy);
|
||
li.appendChild(box);
|
||
} else {
|
||
// Links minted before the URL was kept: only their hash was stored, so
|
||
// there is nothing to show. Revoking and making a new one is the fix.
|
||
const note = document.createElement("p");
|
||
note.className = "settings-hint guest-item-url";
|
||
note.textContent = "Made before links could be re-shown — revoke it and create a new one to get a copyable URL.";
|
||
li.appendChild(note);
|
||
}
|
||
guestListEl.appendChild(li);
|
||
}
|
||
}
|
||
|
||
async function refreshGuestLinks() {
|
||
guestError.hidden = true;
|
||
try {
|
||
const res = await fetch("api/shares");
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const body = await res.json();
|
||
renderGuestLinks(Array.isArray(body.links) ? body.links : []);
|
||
} catch (err) {
|
||
// Offline is the common case here and not worth an error: the list is
|
||
// server state, so it simply isn't knowable right now.
|
||
guestListEl.innerHTML = "";
|
||
guestEmpty.hidden = false;
|
||
guestEmpty.textContent = navigator.onLine
|
||
? "Couldn't load your links."
|
||
: "Offline — guest links need a connection.";
|
||
}
|
||
}
|
||
|
||
guestCreate.addEventListener("click", async () => {
|
||
guestError.hidden = true;
|
||
const expires = guestExpiryMs();
|
||
if (!expires || expires <= Date.now()) {
|
||
guestError.textContent = "Pick a date in the future for the link to stop working.";
|
||
guestError.hidden = false;
|
||
return;
|
||
}
|
||
guestCreate.disabled = true;
|
||
try {
|
||
const res = await fetch("api/shares", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ label: guestLabelIn.value.trim(), expires }),
|
||
});
|
||
if (!res.ok) throw new Error((await res.text()).trim() || `HTTP ${res.status}`);
|
||
await res.json();
|
||
guestLabelIn.value = "";
|
||
resetGuestExpiry();
|
||
// Nothing special to do with the new link: the list shows every link's
|
||
// URL, so it simply appears there like the rest.
|
||
await refreshGuestLinks();
|
||
} catch (err) {
|
||
guestError.textContent = err.message || "Couldn't create a link";
|
||
guestError.hidden = false;
|
||
} finally {
|
||
guestCreate.disabled = false;
|
||
}
|
||
});
|
||
|
||
function selectContents(el) {
|
||
const range = document.createRange();
|
||
range.selectNodeContents(el);
|
||
const sel = getSelection();
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
}
|
||
|
||
async function copyGuestURL(btn, code) {
|
||
try {
|
||
await navigator.clipboard.writeText(code.textContent);
|
||
btn.textContent = "Copied";
|
||
setTimeout(() => { btn.textContent = "Copy"; }, 1500);
|
||
} catch {
|
||
// The clipboard API needs a secure context and can be refused outright.
|
||
// The URL is on screen either way, so select it and let them copy by hand.
|
||
selectContents(code);
|
||
}
|
||
}
|
||
|
||
async function revokeGuestLink(id, label) {
|
||
if (!confirm(`Turn off the link for “${label}”? Whoever has it loses access straight away.`)) return;
|
||
try {
|
||
const res = await fetch(`api/shares/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||
if (!res.ok && res.status !== 404) throw new Error(`HTTP ${res.status}`);
|
||
await refreshGuestLinks();
|
||
} catch (err) {
|
||
guestError.textContent = err.message || "Couldn't revoke that link";
|
||
guestError.hidden = false;
|
||
}
|
||
}
|
||
|
||
// ---------- 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() {
|
||
// Reminders belong to the account, not the device: a guest subscribing here
|
||
// would route the owner's reminders to the sitter's lock screen. The server
|
||
// refuses these routes for a guest anyway; bailing early keeps the section
|
||
// from flashing up on iOS, where it appears before any request is made.
|
||
if (isGuest()) return;
|
||
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 : " |