The server has go test; the frontend had nothing, and the things most likely to break there are the ones hardest to see: the arithmetic behind the charts, a panel lost while shuffling tabs, a label that truncates on a phone none of us owns. There is no browser in this loop, so these are what can be checked without one. They read the real code rather than copying it. The app is one long IIFE with nothing exported, and adding a module system or a build step to make it testable would be a large change in service of a small one — so checks/extract.mjs reads src/app.js, brace-matches the declarations a check asks for, and evaluates them. Rename a function and it throws by name. A check quietly exercising a stale copy of the code would be worse than no check, and that is the failure mode this avoids. calendarGridStart is pulled out of renderCalendar as part of this. It is the one line of the month grid that is easy to get wrong and impossible to notice — a month starting on the week's first day needs no backing up, one starting the day before needs six — so it earns a name and a test. The width figures are estimates, not measurements: layout numbers come out of style.css so they cannot drift, text is sized from per-character advances, and the pass mark demands a few pixels of headroom because the estimate is only good to a few percent. They will catch a sixth tab or a longer label. They will not settle a two-pixel question, and nothing here replaces looking at a phone. No new dependencies: nodejs is already in the devShell for `node --check`, and checks/ sits outside src/ so it is not served with the app.
5397 lines
219 KiB
JavaScript
5397 lines
219 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 sleepState = null; // "asleep" | "awake" | null
|
||
let sleepSince = 0;
|
||
let walkSince = 0; // start of the walk in progress, 0 when none is
|
||
|
||
// The walk currently in progress, if any. pairWindows already marks an
|
||
// unmatched walk-start as ongoing, so this is just the last such window —
|
||
// there can only be one, since a start closes the previous pair.
|
||
function currentWalkStart(events) {
|
||
const open = walkWindows(events).find(w => w.ongoing);
|
||
return open ? open.start : 0;
|
||
}
|
||
|
||
// Both timers live in the frozen bar and nowhere else. There used to be a big
|
||
// card at the top of Today as well, with the pills standing by until it
|
||
// scrolled out of sight — but a timer you have to scroll to, on one tab out
|
||
// of five, is not doing the job a timer is for. The bar shows them always,
|
||
// and having only one place to render them is what lets them carry seconds
|
||
// again: they are the display now, not a summary of one.
|
||
function renderTimers(events) {
|
||
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);
|
||
sleepState = state;
|
||
sleepSince = ts;
|
||
|
||
// The two are independent: the walk pill comes and goes with the walk, and
|
||
// the sleep pill is unaffected by it.
|
||
walkSince = currentWalkStart(events);
|
||
renderWalkPill();
|
||
|
||
if (!state) { pill.hidden = true; return; }
|
||
pill.hidden = false;
|
||
pill.classList.toggle("asleep", state === "asleep");
|
||
pill.classList.toggle("awake", state === "awake");
|
||
ptime.textContent = formatCounter(Date.now() - 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()}.`);
|
||
}
|
||
|
||
function renderWalkPill() {
|
||
const pill = document.getElementById("bar-walk");
|
||
const wtime = document.getElementById("bar-walk-time");
|
||
if (!walkSince) { pill.hidden = true; return; }
|
||
pill.hidden = false;
|
||
wtime.textContent = formatCounter(Date.now() - walkSince);
|
||
pill.title = `Walking since ${formatTime(walkSince)} — tap to log walk end`;
|
||
pill.setAttribute("aria-label", `Walking since ${formatTime(walkSince)}. Log walk end.`);
|
||
}
|
||
|
||
function tickTimers() {
|
||
if (sleepState) {
|
||
const ptime = document.getElementById("bar-clock-time");
|
||
if (ptime) ptime.textContent = formatCounter(Date.now() - sleepSince);
|
||
}
|
||
if (walkSince) {
|
||
const wtime = document.getElementById("bar-walk-time");
|
||
if (wtime) wtime.textContent = formatCounter(Date.now() - walkSince);
|
||
}
|
||
}
|
||
|
||
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("cal-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.
|
||
renderTimers(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);
|
||
// These take the whole list even though they aggregate, because each
|
||
// already knows about marked days and does something more precise with
|
||
// them than dropping their events would:
|
||
//
|
||
// - gapsBetween throws away a gap that *touches* a marked day, and
|
||
// "how long since the last one" is a question about now, not about
|
||
// the window — with the events gone it answered from the wrong one.
|
||
// - the trend curves skip marked days when averaging, but the curve for
|
||
// the day you are looking at is about that day; marking it means
|
||
// "don't let it drag the average", not "pretend nothing happened".
|
||
// - weeklyData and the actograms zero and hatch a marked day themselves,
|
||
// and need the events either side of it: a nap from 23:00 on a marked
|
||
// day to 07:00 on the next belongs, for those seven hours, to the next
|
||
// day — which is not marked and should show them. Dropping the
|
||
// sleep-start left a dangling sleep-end and lost the window entirely.
|
||
renderTiming(events);
|
||
renderWeekly(events);
|
||
renderSleepTimeline(events);
|
||
renderWalkPatterns(events);
|
||
renderSleepTrend(events);
|
||
// These two bucket individual events with no notion of a day boundary or a
|
||
// span, so removing the marked days' events is exactly the right tool.
|
||
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 : " |