52c50c97b1
Tapping the pee or poo quick-log button now sets off a short burst of 💧/💩 particles from the button — pure DOM + CSS, pointer-events:none so it never blocks taps, particles self-remove on animation end. It honours prefers-reduced-motion, and there's a "Pee/poo confetti" switch in Settings (device-local, on by default) to turn it off.
3621 lines
140 KiB
JavaScript
3621 lines
140 KiB
JavaScript
(() => {
|
||
"use strict";
|
||
|
||
// Storage is namespaced per account so two people sharing a browser (or one
|
||
// person logging out and back in as someone else) never see each other's
|
||
// cached events/profile. currentUser is set by the auth gate before the app
|
||
// boots, so these are only ever called once a user is known.
|
||
let currentUser = null;
|
||
const eventsKey = () => `puppy-tracker:${currentUser.id}:events:v1`;
|
||
const configKey = () => `puppy-tracker:${currentUser.id}:config:v1`;
|
||
const exercisesKey = () => `puppy-tracker:${currentUser.id}:exercises:v1`;
|
||
const SYNC_URL = "api/events/sync";
|
||
const SYNC_DEBOUNCE_MS = 1200;
|
||
const SYNC_POLL_MS = 60_000;
|
||
|
||
const EVENT_LABELS = {
|
||
"sleep-start": "Sleep start",
|
||
"sleep-end": "Sleep end",
|
||
"eat": "Ate",
|
||
"pee": "Pee",
|
||
"poo": "Poo",
|
||
"weight": "Weigh-in",
|
||
"training": "Training",
|
||
};
|
||
|
||
// ---------- photos: IndexedDB store ----------
|
||
// Schema: object store `photos` keyed by `id`, value `{id, blob, uploaded}`.
|
||
// - Photos taken locally are written with uploaded:false and queued for upload.
|
||
// - Photos fetched from server are cached with uploaded:true (server is source-of-truth).
|
||
const PHOTO_DB = "puppy-tracker";
|
||
const PHOTO_STORE = "photos";
|
||
let photoDB = null;
|
||
|
||
function openPhotoDB() {
|
||
if (photoDB) return Promise.resolve(photoDB);
|
||
return new Promise((resolve, reject) => {
|
||
const req = indexedDB.open(PHOTO_DB, 1);
|
||
req.onupgradeneeded = (e) => {
|
||
const db = e.target.result;
|
||
if (!db.objectStoreNames.contains(PHOTO_STORE)) {
|
||
db.createObjectStore(PHOTO_STORE, { keyPath: "id" });
|
||
}
|
||
};
|
||
req.onsuccess = () => { photoDB = req.result; resolve(photoDB); };
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
}
|
||
|
||
async function putPhoto(id, blob, uploaded) {
|
||
const db = await openPhotoDB();
|
||
return new Promise((resolve, reject) => {
|
||
const tx = db.transaction(PHOTO_STORE, "readwrite");
|
||
tx.objectStore(PHOTO_STORE).put({ id, blob, uploaded });
|
||
tx.oncomplete = () => resolve();
|
||
tx.onerror = () => reject(tx.error);
|
||
});
|
||
}
|
||
|
||
async function getPhoto(id) {
|
||
const db = await openPhotoDB();
|
||
return new Promise((resolve, reject) => {
|
||
const tx = db.transaction(PHOTO_STORE, "readonly");
|
||
const req = tx.objectStore(PHOTO_STORE).get(id);
|
||
req.onsuccess = () => resolve(req.result || null);
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
}
|
||
|
||
async function getAllPhotos() {
|
||
const db = await openPhotoDB();
|
||
return new Promise((resolve, reject) => {
|
||
const tx = db.transaction(PHOTO_STORE, "readonly");
|
||
const req = tx.objectStore(PHOTO_STORE).getAll();
|
||
req.onsuccess = () => resolve(req.result || []);
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
}
|
||
|
||
async function deletePhoto(id) {
|
||
const db = await openPhotoDB();
|
||
return new Promise((resolve, reject) => {
|
||
const tx = db.transaction(PHOTO_STORE, "readwrite");
|
||
tx.objectStore(PHOTO_STORE).delete(id);
|
||
tx.oncomplete = () => resolve();
|
||
tx.onerror = () => reject(tx.error);
|
||
});
|
||
}
|
||
|
||
// Resize an image File to a max dimension and return a JPEG Blob.
|
||
function resizeImage(file, maxDim = 1600, quality = 0.85) {
|
||
return new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
const url = URL.createObjectURL(file);
|
||
img.onload = () => {
|
||
URL.revokeObjectURL(url);
|
||
const scale = Math.min(1, maxDim / Math.max(img.naturalWidth, img.naturalHeight));
|
||
const w = Math.max(1, Math.round(img.naturalWidth * scale));
|
||
const h = Math.max(1, Math.round(img.naturalHeight * scale));
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = w; canvas.height = h;
|
||
canvas.getContext("2d").drawImage(img, 0, 0, w, h);
|
||
canvas.toBlob(
|
||
(blob) => blob ? resolve(blob) : reject(new Error("toBlob returned null")),
|
||
"image/jpeg",
|
||
quality,
|
||
);
|
||
};
|
||
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("image load failed")); };
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
async function uploadPhotoBlob(id, blob) {
|
||
const form = new FormData();
|
||
form.append("id", id);
|
||
form.append("file", blob, `${id}.jpg`);
|
||
const res = await fetch("api/photos", { method: "POST", body: form });
|
||
if (!res.ok) throw new Error(`upload HTTP ${res.status}`);
|
||
}
|
||
|
||
async function syncPhotos() {
|
||
if (!navigator.onLine) return;
|
||
const all = await getAllPhotos();
|
||
for (const p of all) {
|
||
if (p.uploaded) continue;
|
||
try {
|
||
await uploadPhotoBlob(p.id, p.blob);
|
||
await putPhoto(p.id, p.blob, true);
|
||
} catch (err) {
|
||
console.warn("photo upload failed", p.id, err);
|
||
// leave queued; next sync will retry
|
||
}
|
||
}
|
||
}
|
||
|
||
// Resolve a photoId to a displayable URL. Prefers local cache; falls back
|
||
// to the server URL (the SW will cache it transparently). Returns null if
|
||
// we have nothing and the server doesn't either.
|
||
const photoURLCache = new Map(); // id -> object URL (lifetime = page session)
|
||
async function photoSrc(id) {
|
||
if (!id) return null;
|
||
if (photoURLCache.has(id)) return photoURLCache.get(id);
|
||
const local = await getPhoto(id);
|
||
if (local && local.blob) {
|
||
const url = URL.createObjectURL(local.blob);
|
||
photoURLCache.set(id, url);
|
||
return url;
|
||
}
|
||
// Fall back to server URL — let the browser/SW cache it. Also opportunistically
|
||
// pull it into IndexedDB so cold-offline-loads still see it.
|
||
if (navigator.onLine) {
|
||
try {
|
||
const res = await fetch(`api/photos/${encodeURIComponent(id)}`);
|
||
if (res.ok) {
|
||
const blob = await res.blob();
|
||
await putPhoto(id, blob, true);
|
||
const url = URL.createObjectURL(blob);
|
||
photoURLCache.set(id, url);
|
||
return url;
|
||
}
|
||
} catch (_) { /* fall through */ }
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// crypto.randomUUID() is only exposed in secure contexts (HTTPS / localhost),
|
||
// so over plain HTTP on the LAN we need a fallback. crypto.getRandomValues
|
||
// is available everywhere; Math.random is the last resort.
|
||
function uuid() {
|
||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||
return crypto.randomUUID();
|
||
}
|
||
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
|
||
const b = new Uint8Array(16);
|
||
crypto.getRandomValues(b);
|
||
b[6] = (b[6] & 0x0f) | 0x40; // version 4
|
||
b[8] = (b[8] & 0x3f) | 0x80; // variant 10
|
||
const h = [...b].map(x => x.toString(16).padStart(2, "0"));
|
||
return `${h.slice(0,4).join("")}-${h.slice(4,6).join("")}-${h.slice(6,8).join("")}-${h.slice(8,10).join("")}-${h.slice(10,16).join("")}`;
|
||
}
|
||
return `x-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
|
||
}
|
||
|
||
// ---------- storage ----------
|
||
// Internal "raw" storage includes deleted tombstones; UI uses live().
|
||
function loadAll() {
|
||
try {
|
||
const raw = localStorage.getItem(eventsKey());
|
||
if (!raw) return [];
|
||
const parsed = JSON.parse(raw);
|
||
if (!Array.isArray(parsed)) return [];
|
||
// Backfill updatedAt for events written by an older client version.
|
||
return parsed.map(e => ({
|
||
...e,
|
||
updatedAt: Number.isFinite(e.updatedAt) ? e.updatedAt : (e.at || Date.now()),
|
||
}));
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function saveAll(events) {
|
||
localStorage.setItem(eventsKey(), JSON.stringify(events));
|
||
}
|
||
|
||
function live() {
|
||
return loadAll().filter(e => !e.deleted);
|
||
}
|
||
|
||
// ---------- config (puppy name + birthday) ----------
|
||
// The shared profile lives on the host so every client sees the same values.
|
||
// localStorage is just a cache for instant paint + offline; the server is the
|
||
// source of truth, reconciled by last-write-wins on updatedAt (see syncConfig).
|
||
function loadConfig() {
|
||
try {
|
||
const parsed = JSON.parse(localStorage.getItem(configKey()));
|
||
if (!parsed || typeof parsed !== "object") return { name: "", birthday: "", pedigreeId: "", updatedAt: 0 };
|
||
return {
|
||
name: parsed.name || "",
|
||
birthday: parsed.birthday || "",
|
||
pedigreeId: parsed.pedigreeId || "",
|
||
updatedAt: Number.isFinite(parsed.updatedAt) ? parsed.updatedAt : 0,
|
||
};
|
||
} catch {
|
||
return { name: "", birthday: "", pedigreeId: "", updatedAt: 0 };
|
||
}
|
||
}
|
||
|
||
function saveConfig(cfg) {
|
||
localStorage.setItem(configKey(), JSON.stringify(cfg));
|
||
}
|
||
|
||
// Reconcile a local and a server profile. Name/birthday/updatedAt are plain
|
||
// last-write-wins by timestamp. The pedigree id is sticky: a non-empty value
|
||
// never loses to an empty one — so it can't be dropped by a clock race between
|
||
// devices — and when both are set the newer profile's id wins with the rest.
|
||
// (The server merge mirrors this, so a set id is only changed, never cleared,
|
||
// by sync.)
|
||
function reconcileConfig(local, server) {
|
||
const base = server.updatedAt >= local.updatedAt ? server : local;
|
||
return {
|
||
name: base.name,
|
||
birthday: base.birthday,
|
||
pedigreeId: (local.pedigreeId && server.pedigreeId)
|
||
? base.pedigreeId
|
||
: (local.pedigreeId || server.pedigreeId),
|
||
updatedAt: base.updatedAt,
|
||
};
|
||
}
|
||
function sameConfig(a, b) {
|
||
return a.name === b.name && a.birthday === b.birthday
|
||
&& a.pedigreeId === b.pedigreeId && a.updatedAt === b.updatedAt;
|
||
}
|
||
|
||
// Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday,
|
||
// measured at `at` (defaults to now — pass a weigh-in's timestamp for its age
|
||
// at that point). Returns null for a missing/invalid birthday or a date before it.
|
||
function ageParts(birthday, at) {
|
||
if (!birthday) return null;
|
||
const [y, mo, d] = birthday.split("-").map(Number);
|
||
if (!y || !mo || !d) return null;
|
||
const birth = startOfDay(new Date(y, mo - 1, d));
|
||
const ref = startOfDay(new Date(Number.isFinite(at) ? at : Date.now()));
|
||
if (birth > ref) return null;
|
||
const days = Math.floor((ref - birth) / 86_400_000);
|
||
const weeks = Math.floor(days / 7);
|
||
let months = (ref.getFullYear() - birth.getFullYear()) * 12 +
|
||
(ref.getMonth() - birth.getMonth());
|
||
if (ref.getDate() < birth.getDate()) months--;
|
||
if (months < 0) months = 0;
|
||
// Whole weeks left over after the calendar months (so "N months and M weeks").
|
||
const anchor = startOfDay(new Date(birth.getFullYear(),
|
||
birth.getMonth() + months, birth.getDate()));
|
||
const remWeeks = Math.floor((ref - anchor) / 86_400_000 / 7);
|
||
return { days, weeks, months, remWeeks };
|
||
}
|
||
|
||
// e.g. "16 weeks (3 months and 3 weeks) old". Weeks are the headline unit while
|
||
// the puppy is young; once it's 4+ months the week count is just noise, so we
|
||
// drop it and lead with the months form ("4 months and 1 week old").
|
||
function formatAge(birthday, at) {
|
||
const a = ageParts(birthday, at);
|
||
if (!a) return "";
|
||
const wk = `${a.weeks} week${a.weeks === 1 ? "" : "s"}`;
|
||
if (a.months < 1) return `${wk} old`;
|
||
const mo = `${a.months} month${a.months === 1 ? "" : "s"}`;
|
||
const rw = a.remWeeks > 0
|
||
? ` and ${a.remWeeks} week${a.remWeeks === 1 ? "" : "s"}` : "";
|
||
const monthPhrase = `${mo}${rw}`;
|
||
if (a.months >= 4) return `${monthPhrase} old`;
|
||
return `${wk} (${monthPhrase}) old`;
|
||
}
|
||
|
||
// Compact one-line age for space-tight spots (header): keeps the weeks + months
|
||
// breakdown but abbreviated — "16 wk · 3 mo 3 wk", "5 mo 2 wk", "3 wk".
|
||
function formatAgeShort(birthday, at) {
|
||
const a = ageParts(birthday, at);
|
||
if (!a) return "";
|
||
const wk = `${a.weeks} wk`;
|
||
if (a.months < 1) return wk;
|
||
const mo = `${a.months} mo${a.remWeeks > 0 ? ` ${a.remWeeks} wk` : ""}`;
|
||
if (a.months >= 4) return mo;
|
||
return `${wk} · ${mo}`;
|
||
}
|
||
|
||
// Weeks-only age for dense lists (weight rows): "16 wk".
|
||
function formatAgeWeeks(birthday, at) {
|
||
const a = ageParts(birthday, at);
|
||
return a ? `${a.weeks} wk` : "";
|
||
}
|
||
|
||
// Rough age-based daily sleep goal (hours), for the trend chart's goal band:
|
||
// 0–8 weeks 20–22h, 8–16 weeks 18–20h, then 16–18h to 6 months and 14–16h to
|
||
// 12 months. No birthday (or an adult dog) → no goal.
|
||
function sleepTargetFor(birthday) {
|
||
const a = ageParts(birthday);
|
||
if (!a) return null;
|
||
if (a.weeks < 8) return { lo: 20, hi: 22, label: "0–8 weeks" };
|
||
if (a.weeks < 16) return { lo: 18, hi: 20, label: "8–16 weeks" };
|
||
if (a.months < 6) return { lo: 16, hi: 18, label: "4–6 months" };
|
||
if (a.months < 12) return { lo: 14, hi: 16, label: "6–12 months" };
|
||
return null;
|
||
}
|
||
|
||
function addEvent(type, note, at, { photoId, weight, grams, exerciseId } = {}) {
|
||
const events = loadAll();
|
||
const now = Date.now();
|
||
const ev = {
|
||
id: uuid(),
|
||
type,
|
||
at: Number.isFinite(at) ? at : now,
|
||
note: note || "",
|
||
photoId: photoId || "",
|
||
weight: Number.isFinite(weight) ? weight : undefined,
|
||
grams: Number.isFinite(grams) ? grams : undefined,
|
||
exerciseId: exerciseId || "",
|
||
updatedAt: now,
|
||
};
|
||
events.push(ev);
|
||
saveAll(events);
|
||
scheduleSync();
|
||
render();
|
||
return ev;
|
||
}
|
||
|
||
function updateEvent(id, patch) {
|
||
const events = loadAll().map(e =>
|
||
e.id === id ? { ...e, ...patch, updatedAt: Date.now() } : e
|
||
);
|
||
saveAll(events);
|
||
scheduleSync();
|
||
render();
|
||
}
|
||
|
||
function deleteEvent(id) {
|
||
// Soft-delete so the deletion can propagate via sync.
|
||
const events = loadAll().map(e =>
|
||
e.id === id ? { ...e, deleted: true, updatedAt: Date.now() } : e
|
||
);
|
||
saveAll(events);
|
||
scheduleSync();
|
||
render();
|
||
}
|
||
|
||
// ---------- exercises (training definitions) ----------
|
||
// User-defined training exercises ("Sit", "Leash walking", …), each with
|
||
// optional instruction text. They sync like events: UUID ids, last-write-wins
|
||
// on updatedAt, tombstoned deletes — but as their own collection, since they
|
||
// are definitions rather than things that happened at a point in time.
|
||
function loadExercises() {
|
||
try {
|
||
const parsed = JSON.parse(localStorage.getItem(exercisesKey()));
|
||
return Array.isArray(parsed) ? parsed : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function saveExercises(list) {
|
||
localStorage.setItem(exercisesKey(), JSON.stringify(list));
|
||
}
|
||
|
||
function liveExercises() {
|
||
return loadExercises()
|
||
.filter(x => !x.deleted)
|
||
.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
|
||
}
|
||
|
||
function addExercise(name, note) {
|
||
const list = loadExercises();
|
||
list.push({ id: uuid(), name, note: note || "", updatedAt: Date.now() });
|
||
saveExercises(list);
|
||
scheduleSync();
|
||
render();
|
||
}
|
||
|
||
function updateExercise(id, patch) {
|
||
saveExercises(loadExercises().map(x =>
|
||
x.id === id ? { ...x, ...patch, updatedAt: Date.now() } : x
|
||
));
|
||
scheduleSync();
|
||
render();
|
||
}
|
||
|
||
function deleteExercise(id) {
|
||
// Tombstone, like events. Logged training sessions keep referencing the id;
|
||
// name lookups still resolve through the tombstone (see exerciseNames).
|
||
saveExercises(loadExercises().map(x =>
|
||
x.id === id ? { ...x, deleted: true, updatedAt: Date.now() } : x
|
||
));
|
||
scheduleSync();
|
||
render();
|
||
}
|
||
|
||
// id -> name across *all* exercises, tombstones included, so history rows for
|
||
// a deleted exercise still show its name instead of a generic "Training".
|
||
function exerciseNames() {
|
||
return new Map(loadExercises().map(x => [x.id, x.name]));
|
||
}
|
||
|
||
// ---------- helpers ----------
|
||
function ymd(date) {
|
||
const y = date.getFullYear();
|
||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||
const d = String(date.getDate()).padStart(2, "0");
|
||
return `${y}-${m}-${d}`;
|
||
}
|
||
|
||
function startOfDay(date) {
|
||
const d = new Date(date);
|
||
d.setHours(0, 0, 0, 0);
|
||
return d;
|
||
}
|
||
|
||
function endOfDay(date) {
|
||
const d = new Date(date);
|
||
d.setHours(23, 59, 59, 999);
|
||
return d;
|
||
}
|
||
|
||
function formatTime(ts) {
|
||
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
|
||
}
|
||
|
||
const pad2 = n => String(n).padStart(2, "0");
|
||
function toDateInput(ts) {
|
||
const d = new Date(ts);
|
||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||
}
|
||
function toTimeInput(ts) {
|
||
const d = new Date(ts);
|
||
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||
}
|
||
function fromDateTimeInputs(dateStr, timeStr) {
|
||
if (!dateStr || !timeStr) return NaN;
|
||
const [y, mo, d] = dateStr.split("-").map(Number);
|
||
const [h, mi] = timeStr.split(":").map(Number);
|
||
const t = new Date(y, mo - 1, d, h, mi).getTime();
|
||
return Number.isFinite(t) ? t : NaN;
|
||
}
|
||
|
||
function formatDuration(ms) {
|
||
if (ms <= 0) return "0m";
|
||
const totalMin = Math.round(ms / 60000);
|
||
const h = Math.floor(totalMin / 60);
|
||
const m = totalMin % 60;
|
||
if (h === 0) return `${m}m`;
|
||
return `${h}h ${m}m`;
|
||
}
|
||
|
||
// kg with up to 2 decimals, trailing zeros stripped (5.20 -> "5.2 kg").
|
||
function formatWeight(kg) {
|
||
return `${Math.round(kg * 100) / 100} kg`;
|
||
}
|
||
|
||
function formatRelative(ts) {
|
||
if (!ts) return "—";
|
||
const diff = Date.now() - ts;
|
||
if (diff < 60_000) return "just now";
|
||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||
if (diff < 86_400_000) {
|
||
const h = Math.floor(diff / 3_600_000);
|
||
const m = Math.floor((diff % 3_600_000) / 60_000);
|
||
return m ? `${h}h ${m}m ago` : `${h}h ago`;
|
||
}
|
||
const d = Math.floor(diff / 86_400_000);
|
||
return `${d}d ago`;
|
||
}
|
||
|
||
function eventsForDay(events, day) {
|
||
const from = startOfDay(day).getTime();
|
||
const to = endOfDay(day).getTime();
|
||
return events
|
||
.filter(e => e.at >= from && e.at <= to)
|
||
.sort((a, b) => a.at - b.at);
|
||
}
|
||
|
||
// Calculate sleep time within a given day. Sleep intervals are derived from
|
||
// consecutive sleep-start → sleep-end pairs across the whole event log, and
|
||
// clipped to the day window. Unmatched sleep-start = ongoing sleep (clipped to now).
|
||
function sleepMsInRange(events, fromTs, toTs) {
|
||
const sorted = [...events].sort((a, b) => a.at - b.at);
|
||
const intervals = [];
|
||
let openStart = null;
|
||
for (const e of sorted) {
|
||
if (e.type === "sleep-start" && openStart === null) {
|
||
openStart = e.at;
|
||
} else if (e.type === "sleep-end" && openStart !== null) {
|
||
intervals.push([openStart, e.at]);
|
||
openStart = null;
|
||
}
|
||
}
|
||
if (openStart !== null) intervals.push([openStart, Date.now()]);
|
||
|
||
let total = 0;
|
||
for (const [start, end] of intervals) {
|
||
const s = Math.max(start, fromTs);
|
||
const ePt = Math.min(end, toTs);
|
||
if (ePt > s) total += ePt - s;
|
||
}
|
||
return total;
|
||
}
|
||
|
||
// Current state derived from the *latest* sleep event. The single source of
|
||
// truth for both the big clock and the "Currently" row. For two boundary
|
||
// 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 currentSleepState(events) {
|
||
let latest = null;
|
||
for (const e of events) {
|
||
if (e.type !== "sleep-start" && e.type !== "sleep-end") continue;
|
||
if (!latest ||
|
||
e.at > latest.at ||
|
||
(e.at === latest.at && (e.updatedAt || 0) > (latest.updatedAt || 0))) {
|
||
latest = e;
|
||
}
|
||
}
|
||
if (!latest) return { state: null, since: 0 };
|
||
return {
|
||
state: latest.type === "sleep-start" ? "asleep" : "awake",
|
||
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;
|
||
}
|
||
|
||
// 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) {
|
||
const sorted = events
|
||
.filter(e => e.type === "sleep-start" || e.type === "sleep-end")
|
||
.sort((a, b) => a.at - b.at);
|
||
const out = [];
|
||
let waking = null;
|
||
for (const e of sorted) {
|
||
if (e.type === "sleep-end") {
|
||
waking = e.at;
|
||
} else if (e.type === "sleep-start" && waking !== null) {
|
||
out.push({ start: waking, end: e.at, ongoing: false });
|
||
waking = null;
|
||
}
|
||
}
|
||
if (waking !== null) out.push({ start: waking, end: Date.now(), ongoing: true });
|
||
return out;
|
||
}
|
||
|
||
// Sleep windows: each sleep-start → next sleep-end pair. An unmatched
|
||
// sleep-start = ongoing/open sleep window.
|
||
function sleepWindows(events) {
|
||
const sorted = events
|
||
.filter(e => e.type === "sleep-start" || e.type === "sleep-end")
|
||
.sort((a, b) => a.at - b.at);
|
||
const out = [];
|
||
let sleeping = null;
|
||
for (const e of sorted) {
|
||
if (e.type === "sleep-start") {
|
||
sleeping = e.at;
|
||
} else if (e.type === "sleep-end" && sleeping !== null) {
|
||
out.push({ start: sleeping, end: e.at, ongoing: false });
|
||
sleeping = null;
|
||
}
|
||
}
|
||
if (sleeping !== null) out.push({ start: sleeping, end: Date.now(), ongoing: true });
|
||
return out;
|
||
}
|
||
|
||
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 }));
|
||
}
|
||
|
||
// ---------- 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);
|
||
document.getElementById("stat-pees").textContent = count("pee");
|
||
document.getElementById("stat-poos").textContent = count("poo");
|
||
document.getElementById("stat-training").textContent = count("training");
|
||
}
|
||
|
||
// Gaps (ms) between consecutive events of `type` logged within the last
|
||
// `days` days, sorted ascending. These intervals are what tell you how
|
||
// often the puppy needs to go out.
|
||
function gapsBetween(events, type, days = 7) {
|
||
const cutoff = startOfDay(new Date());
|
||
cutoff.setDate(cutoff.getDate() - (days - 1));
|
||
const from = cutoff.getTime();
|
||
const times = events
|
||
.filter(e => e.type === type && e.at >= from)
|
||
.map(e => e.at)
|
||
.sort((a, b) => a - b);
|
||
const gaps = [];
|
||
for (let i = 1; i < times.length; i++) gaps.push(times[i] - times[i - 1]);
|
||
return gaps.sort((a, b) => a - b);
|
||
}
|
||
|
||
// Median is used for the "typical" gap because it ignores the single long
|
||
// overnight gap each day, so it reflects real daytime frequency.
|
||
function median(sorted) {
|
||
if (sorted.length === 0) return null;
|
||
const mid = Math.floor(sorted.length / 2);
|
||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||
}
|
||
|
||
function renderTiming(events) {
|
||
const peeGaps = gapsBetween(events, "pee");
|
||
const pooGaps = gapsBetween(events, "poo");
|
||
const show = (id, ms) => {
|
||
document.getElementById(id).textContent = ms == null ? "—" : formatDuration(ms);
|
||
};
|
||
show("gap-pee", median(peeGaps));
|
||
show("gap-pee-min", peeGaps[0] ?? null);
|
||
show("gap-poo", median(pooGaps));
|
||
show("gap-poo-min", pooGaps[0] ?? null);
|
||
|
||
const hint = document.getElementById("timing-hint");
|
||
const typicalPee = median(peeGaps);
|
||
if (typicalPee != null) {
|
||
hint.textContent =
|
||
`Based on ${peeGaps.length} pee gap${peeGaps.length === 1 ? "" : "s"}. ` +
|
||
`Aim to take the puppy out a little before the typical ${formatDuration(typicalPee)} mark.`;
|
||
} else {
|
||
hint.textContent = "Log a few more pees and poos to see typical timings.";
|
||
}
|
||
}
|
||
|
||
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)})`
|
||
: "—";
|
||
}
|
||
|
||
// Tracks the latest sleep transition so the 1-second tick can update the
|
||
// counter without re-deriving from the event log.
|
||
let bigClockState = null; // "asleep" | "awake" | null
|
||
let bigClockSince = 0;
|
||
|
||
// The counter renders twice: the big card at the top of the page and the
|
||
// compact pill in the frozen day bar. The pill only *shows* once the big
|
||
// card is scrolled out of sight (see updateBarClockMode); while the card is
|
||
// visible the pill is invisible but keeps its slot so the bar never shifts.
|
||
function updateBarClockMode() {
|
||
const pill = document.getElementById("bar-clock");
|
||
if (!bigClockState || pill.hidden) return;
|
||
const card = document.getElementById("big-clock");
|
||
const bar = document.querySelector(".day-bar");
|
||
const cardVisible =
|
||
card.getBoundingClientRect().bottom > bar.getBoundingClientRect().bottom;
|
||
pill.classList.toggle("standby", cardVisible);
|
||
}
|
||
|
||
function renderBigClock(events) {
|
||
const card = document.getElementById("big-clock");
|
||
const label = document.getElementById("bc-label");
|
||
const time = document.getElementById("bc-time");
|
||
const since = document.getElementById("bc-since");
|
||
const pill = document.getElementById("bar-clock");
|
||
const icon = document.getElementById("bar-clock-icon");
|
||
const ptime = document.getElementById("bar-clock-time");
|
||
const { state, since: ts } = currentSleepState(events);
|
||
bigClockState = state;
|
||
bigClockSince = ts;
|
||
if (!state) {
|
||
card.hidden = true;
|
||
pill.hidden = true;
|
||
return;
|
||
}
|
||
card.hidden = false;
|
||
pill.hidden = false;
|
||
card.classList.toggle("asleep", state === "asleep");
|
||
card.classList.toggle("awake", state === "awake");
|
||
pill.classList.toggle("asleep", state === "asleep");
|
||
pill.classList.toggle("awake", state === "awake");
|
||
label.textContent = state === "asleep" ? "Asleep for" : "Awake for";
|
||
const counter = formatCounter(Date.now() - ts);
|
||
time.textContent = counter;
|
||
ptime.textContent = counter;
|
||
since.textContent = `since ${formatTime(ts)}`;
|
||
icon.textContent = state === "asleep" ? "😴" : "☀️";
|
||
pill.title = `${state === "asleep" ? "Asleep" : "Awake"} since ${formatTime(ts)}`;
|
||
updateBarClockMode();
|
||
}
|
||
|
||
function tickBigClock() {
|
||
if (!bigClockState) return;
|
||
const counter = formatCounter(Date.now() - bigClockSince);
|
||
const time = document.getElementById("bc-time");
|
||
const ptime = document.getElementById("bar-clock-time");
|
||
if (time) time.textContent = counter;
|
||
if (ptime) ptime.textContent = counter;
|
||
}
|
||
|
||
function renderWindowList(listId, emptyId, windows, ongoingLabel, extraClass) {
|
||
const list = document.getElementById(listId);
|
||
const empty = document.getElementById(emptyId);
|
||
list.innerHTML = "";
|
||
if (windows.length === 0) {
|
||
empty.hidden = false;
|
||
return;
|
||
}
|
||
empty.hidden = true;
|
||
for (const w of windows) {
|
||
const li = document.createElement("li");
|
||
li.className = `ww ${extraClass}` + (w.ongoing ? " ongoing" : "");
|
||
const range = document.createElement("span");
|
||
range.className = "ww-range";
|
||
range.textContent = w.ongoing
|
||
? `from ${formatTime(w.start)}`
|
||
: `${formatTime(w.start)} – ${formatTime(w.end)}`;
|
||
const dur = document.createElement("span");
|
||
dur.className = "ww-dur";
|
||
dur.textContent = formatDuration(w.end - w.start);
|
||
li.appendChild(range);
|
||
li.appendChild(dur);
|
||
if (w.ongoing) {
|
||
const tag = document.createElement("span");
|
||
tag.className = "ww-tag";
|
||
tag.textContent = ongoingLabel;
|
||
li.appendChild(tag);
|
||
}
|
||
list.appendChild(li);
|
||
}
|
||
}
|
||
|
||
function renderSleepWindows(events) {
|
||
renderWindowList(
|
||
"sleep-list", "sleep-empty",
|
||
sleepWindowsForDay(events, selectedDay()),
|
||
"Asleep", "sleep-ww",
|
||
);
|
||
}
|
||
|
||
function renderWakeWindows(events) {
|
||
renderWindowList(
|
||
"wake-list", "wake-empty",
|
||
wakeWindowsForDay(events, selectedDay()),
|
||
"Awake", "wake-ww",
|
||
);
|
||
}
|
||
|
||
function renderHistory(events) {
|
||
const dayEvents = eventsForDay(events, selectedDay()).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;
|
||
li.innerHTML = `
|
||
<span class="dot"></span>
|
||
<span class="time">${formatTime(ev.at)}</span>
|
||
<span class="label">${escapeText(label)}</span>
|
||
<span class="note"></span>
|
||
`;
|
||
const noteEl = li.querySelector(".note");
|
||
if (ev.type === "weight" && Number.isFinite(ev.weight)) {
|
||
noteEl.textContent = ev.note ? `${formatWeight(ev.weight)} · ${ev.note}` : formatWeight(ev.weight);
|
||
} else if (ev.type === "eat" && Number.isFinite(ev.grams) && ev.grams > 0) {
|
||
const g = `${Math.round(ev.grams)} g`;
|
||
noteEl.textContent = ev.note ? `${g} · ${ev.note}` : g;
|
||
} else {
|
||
noteEl.textContent = ev.note || "";
|
||
}
|
||
li.addEventListener("click", () => openEditDialog(ev));
|
||
|
||
for (const pid of photoIdsOf(ev)) {
|
||
const img = document.createElement("img");
|
||
img.className = "thumb";
|
||
img.alt = "photo";
|
||
img.loading = "lazy";
|
||
img.dataset.photoId = pid;
|
||
img.addEventListener("click", (e) => {
|
||
e.stopPropagation(); // don't open the edit dialog
|
||
openLightbox(pid);
|
||
});
|
||
li.appendChild(img);
|
||
photoSrc(pid).then(url => { if (url) img.src = url; });
|
||
}
|
||
|
||
eventList.appendChild(li);
|
||
}
|
||
}
|
||
|
||
// ---------- 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();
|
||
}
|
||
|
||
// Sync every "(last N days)" header and the picker's active button.
|
||
function renderChartWindow() {
|
||
const n = chartDays();
|
||
const title = document.getElementById("daily-charts-title");
|
||
if (title) title.textContent = `Last ${n} days`;
|
||
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);
|
||
});
|
||
}
|
||
|
||
// ---------- daily charts ----------
|
||
function weeklyData(events) {
|
||
const today = startOfDay(new Date());
|
||
const now = Date.now();
|
||
const days = [];
|
||
for (let i = chartDays() - 1; i >= 0; i--) {
|
||
const d = new Date(today);
|
||
d.setDate(d.getDate() - i);
|
||
const from = startOfDay(d).getTime();
|
||
const to = (i === 0) ? now : endOfDay(d).getTime();
|
||
const sleepMs = sleepMsInRange(events, from, to);
|
||
const dayEvents = eventsForDay(events, d);
|
||
days.push({
|
||
date: d,
|
||
ymd: ymd(d),
|
||
sleepHours: sleepMs / 3_600_000,
|
||
pees: dayEvents.filter(e => e.type === "pee").length,
|
||
poos: dayEvents.filter(e => e.type === "poo").length,
|
||
meals: dayEvents.filter(e => e.type === "eat").length,
|
||
grams: dayEvents
|
||
.filter(e => e.type === "eat" && Number.isFinite(e.grams))
|
||
.reduce((s, e) => s + e.grams, 0),
|
||
});
|
||
}
|
||
return days;
|
||
}
|
||
|
||
function dayLabel(date, isToday) {
|
||
if (isToday) return "Today";
|
||
return date.toLocaleDateString(undefined, { weekday: "short" });
|
||
}
|
||
|
||
// Wider windows can't fit a label under every bar: label today and every
|
||
// 2nd (or 4th) day counting back from it, depending on the window.
|
||
function showDayLabel(i, len) {
|
||
const every = len <= 8 ? 1 : len <= 16 ? 2 : 4;
|
||
return (len - 1 - i) % every === 0;
|
||
}
|
||
|
||
// Pick a chart Y maximum and tick count so every tick label is a clean
|
||
// whole number (avoids 0, 0, 1, 1, 2 from rounding fractional steps):
|
||
// 1-unit gridlines up to 10, 2-unit up to 20, 5-unit beyond.
|
||
function niceAxis(rawMax) {
|
||
if (!(rawMax > 0)) return { yMax: 1, steps: 1 };
|
||
if (rawMax <= 10) {
|
||
const m = Math.ceil(rawMax);
|
||
return { yMax: m, steps: m };
|
||
}
|
||
if (rawMax <= 20) {
|
||
const m = Math.ceil(rawMax / 2) * 2;
|
||
return { yMax: m, steps: m / 2 };
|
||
}
|
||
const m = Math.ceil(rawMax / 5) * 5;
|
||
return { yMax: m, steps: m / 5 };
|
||
}
|
||
|
||
// Grams axis: 0-based with a "nice" step so tick labels stay round whatever
|
||
// the daily totals are (tens of grams for a tiny puppy, hundreds+ later).
|
||
// Aims for ~10 segments so day-to-day differences of a few grams show.
|
||
function niceAxisGrams(rawMax) {
|
||
if (!(rawMax > 0)) return { yMax: 100, steps: 4 };
|
||
const rawStep = rawMax / 10;
|
||
const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
||
const norm = rawStep / mag;
|
||
const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag;
|
||
const yMax = Math.ceil(rawMax / step) * step;
|
||
return { yMax, steps: Math.max(1, Math.round(yMax / step)) };
|
||
}
|
||
|
||
// Sleep-specific axis: 1-hour granularity, capped at 24h/day. Shared by the
|
||
// sleep bars and the trend chart; both thin out labels when steps get dense.
|
||
function niceAxisSleepHours(rawMax) {
|
||
if (!(rawMax > 0)) return { yMax: 2, steps: 2 };
|
||
const m = Math.min(24, Math.max(2, Math.ceil(rawMax)));
|
||
return { yMax: m, steps: m };
|
||
}
|
||
|
||
function escapeText(s) {
|
||
return String(s).replace(/[&<>"']/g, c => (
|
||
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]
|
||
));
|
||
}
|
||
|
||
function setChartSVG(svg, parts) {
|
||
svg.innerHTML = parts.join("");
|
||
svg.querySelectorAll(".bar[data-day]").forEach(b => {
|
||
b.addEventListener("click", () => {
|
||
dayPicker.value = b.dataset.day;
|
||
render();
|
||
});
|
||
});
|
||
}
|
||
|
||
function drawSleepChart(days) {
|
||
const svg = document.getElementById("chart-sleep");
|
||
const W = 320, H = 160;
|
||
const ML = 26, MR = 6, MT = 10, MB = 26;
|
||
const innerW = W - ML - MR;
|
||
const innerH = H - MT - MB;
|
||
|
||
const rawMax = Math.max(...days.map(d => d.sleepHours));
|
||
const { yMax, steps: ySteps } = niceAxisSleepHours(rawMax);
|
||
|
||
const gap = days.length > 14 ? 2 : 4;
|
||
const barW = (innerW - (days.length - 1) * gap) / days.length;
|
||
|
||
// Label every 1h gridline; smaller text once the axis is dense so the
|
||
// labels stay apart (this chart's plot area is shorter than the trend's).
|
||
const yLabelCls = ySteps > 12 ? "y-dense" : "";
|
||
const parts = [];
|
||
for (let i = 0; i <= ySteps; i++) {
|
||
const y = MT + innerH * (1 - i / ySteps);
|
||
const v = Math.round(yMax * i / ySteps * 10) / 10;
|
||
const vText = v % 1 === 0 ? v : v.toFixed(1);
|
||
parts.push(`<line class="grid" x1="${ML}" y1="${y}" x2="${W - MR}" y2="${y}"/>`);
|
||
parts.push(`<text class="${yLabelCls}" x="${ML - 4}" y="${y + 3}" text-anchor="end">${vText}h</text>`);
|
||
}
|
||
|
||
const selYmd = ymd(selectedDay());
|
||
days.forEach((d, i) => {
|
||
const isToday = i === days.length - 1;
|
||
const isSel = d.ymd === selYmd;
|
||
const x = ML + i * (barW + gap);
|
||
const h = (d.sleepHours / yMax) * innerH;
|
||
const y = MT + innerH - h;
|
||
const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${d.sleepHours.toFixed(1)}h`;
|
||
if (isSel) {
|
||
parts.push(`<rect class="day-highlight" x="${(x - gap / 2).toFixed(1)}" y="${MT}" width="${(barW + gap).toFixed(1)}" height="${innerH}" rx="3"/>`);
|
||
}
|
||
parts.push(
|
||
`<rect class="bar bar-sleep ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
||
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
|
||
`<title>${escapeText(title)}</title></rect>`
|
||
);
|
||
// The selected day always gets a label (accent-colored), even on wide
|
||
// windows that would otherwise skip it.
|
||
if (showDayLabel(i, days.length) || isSel) {
|
||
parts.push(
|
||
`<text class="${isSel ? "day-label-sel" : ""}" x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
||
`${escapeText(dayLabel(d.date, isToday))}</text>`
|
||
);
|
||
}
|
||
});
|
||
|
||
setChartSVG(svg, parts);
|
||
}
|
||
|
||
function drawCountsChart(days) {
|
||
const svg = document.getElementById("chart-counts");
|
||
const W = 320, H = 180;
|
||
const ML = 22, MR = 6, MT = 10, MB = 26;
|
||
const innerW = W - ML - MR;
|
||
const innerH = H - MT - MB;
|
||
|
||
const rawMax = Math.max(...days.flatMap(d => [d.pees, d.poos, d.meals]));
|
||
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 - 2 * innerBarGap) / 3;
|
||
|
||
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 series = [
|
||
{ key: "pees", label: "Pees", cls: "bar-pee" },
|
||
{ key: "poos", label: "Poos", cls: "bar-poo" },
|
||
{ key: "meals", label: "Meals", cls: "bar-eat" },
|
||
];
|
||
|
||
const selYmd = ymd(selectedDay());
|
||
days.forEach((d, i) => {
|
||
const isToday = i === days.length - 1;
|
||
const isSel = d.ymd === selYmd;
|
||
const groupX = ML + i * (groupW + groupGap);
|
||
|
||
if (isSel) {
|
||
parts.push(`<rect class="day-highlight" x="${(groupX - groupGap / 2).toFixed(1)}" y="${MT}" width="${(groupW + groupGap).toFixed(1)}" height="${innerH}" rx="3"/>`);
|
||
}
|
||
series.forEach((s, j) => {
|
||
const val = d[s.key];
|
||
const x = groupX + j * (barW + innerBarGap);
|
||
const h = (val / yMax) * innerH;
|
||
const y = MT + innerH - h;
|
||
const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${s.label}: ${val}`;
|
||
parts.push(
|
||
`<rect class="bar ${s.cls} ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
||
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="2">` +
|
||
`<title>${escapeText(title)}</title></rect>`
|
||
);
|
||
});
|
||
|
||
if (showDayLabel(i, days.length) || isSel) {
|
||
parts.push(
|
||
`<text class="${isSel ? "day-label-sel" : ""}" x="${groupX + groupW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
||
`${escapeText(dayLabel(d.date, isToday))}</text>`
|
||
);
|
||
}
|
||
});
|
||
|
||
setChartSVG(svg, parts);
|
||
}
|
||
|
||
// Grams of food per day. Hidden entirely until any meal in the window has an
|
||
// amount logged, so the weekly card doesn't grow an empty chart.
|
||
function drawGramsChart(days) {
|
||
const wrap = document.getElementById("grams-chart-wrap");
|
||
const svg = document.getElementById("chart-grams");
|
||
if (!days.some(d => d.grams > 0)) { wrap.hidden = true; return; }
|
||
wrap.hidden = false;
|
||
|
||
const W = 320, H = 160;
|
||
const ML = 34, MR = 6, MT = 10, MB = 26;
|
||
const innerW = W - ML - MR;
|
||
const innerH = H - MT - MB;
|
||
|
||
const { yMax, steps: ySteps } = niceAxisGrams(Math.max(...days.map(d => d.grams)));
|
||
|
||
const gap = days.length > 14 ? 2 : 4;
|
||
const barW = (innerW - (days.length - 1) * gap) / days.length;
|
||
|
||
const parts = [];
|
||
for (let i = 0; i <= ySteps; i++) {
|
||
const y = MT + innerH * (1 - i / ySteps);
|
||
const v = yMax * i / ySteps;
|
||
const vText = v % 1 === 0 ? v : v.toFixed(1);
|
||
parts.push(`<line class="grid" x1="${ML}" y1="${y}" x2="${W - MR}" y2="${y}"/>`);
|
||
parts.push(`<text x="${ML - 4}" y="${y + 3}" text-anchor="end">${vText}</text>`);
|
||
}
|
||
|
||
const selYmd = ymd(selectedDay());
|
||
days.forEach((d, i) => {
|
||
const isToday = i === days.length - 1;
|
||
const isSel = d.ymd === selYmd;
|
||
const x = ML + i * (barW + gap);
|
||
const h = (d.grams / yMax) * innerH;
|
||
const y = MT + innerH - h;
|
||
const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${Math.round(d.grams)} g`;
|
||
if (isSel) {
|
||
parts.push(`<rect class="day-highlight" x="${(x - gap / 2).toFixed(1)}" y="${MT}" width="${(barW + gap).toFixed(1)}" height="${innerH}" rx="3"/>`);
|
||
}
|
||
parts.push(
|
||
`<rect class="bar bar-eat ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
|
||
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
|
||
`<title>${escapeText(title)}</title></rect>`
|
||
);
|
||
if (showDayLabel(i, days.length) || isSel) {
|
||
parts.push(
|
||
`<text class="${isSel ? "day-label-sel" : ""}" x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
|
||
`${escapeText(dayLabel(d.date, isToday))}</text>`
|
||
);
|
||
}
|
||
});
|
||
|
||
setChartSVG(svg, parts);
|
||
}
|
||
|
||
function renderWeekly(events) {
|
||
const days = weeklyData(events);
|
||
drawSleepChart(days);
|
||
drawCountsChart(days);
|
||
drawGramsChart(days);
|
||
}
|
||
|
||
// ---------- pattern charts ----------
|
||
|
||
// Actogram: one row per day (oldest at top), a midnight-to-midnight track with
|
||
// the puppy's sleep shaded. Sleep windows are clipped to each day, so a night
|
||
// that crosses midnight shows correctly split across two rows. Today's open
|
||
// sleep runs to now (sleepWindows already clips ongoing sleep to Date.now()).
|
||
function renderSleepTimeline(events) {
|
||
const svg = document.getElementById("chart-sleep-timeline");
|
||
if (!svg) return;
|
||
const N = chartDays();
|
||
const W = 320;
|
||
const ML = 44, MR = 8, MT = 16, MB = 4;
|
||
const innerW = W - ML - MR;
|
||
const rowGap = 2;
|
||
// Fixed row height, chart grows with the window (13px × 14 days matches
|
||
// the original 228-high viewBox; wider windows use thinner rows).
|
||
const rowH = N > 14 ? 9 : 13;
|
||
const H = MT + MB + N * rowH + (N - 1) * rowGap;
|
||
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
||
const dayMs = 86_400_000;
|
||
|
||
const today = startOfDay(new Date());
|
||
const windows = sleepWindows(events);
|
||
const xOf = (frac) => ML + frac * innerW;
|
||
|
||
const parts = [];
|
||
for (const hr of [0, 6, 12, 18, 24]) {
|
||
const x = xOf(hr / 24);
|
||
parts.push(`<line class="grid" x1="${x.toFixed(1)}" y1="${MT}" x2="${x.toFixed(1)}" y2="${H - MB}"/>`);
|
||
const anchor = hr === 0 ? "start" : hr === 24 ? "end" : "middle";
|
||
parts.push(`<text x="${x.toFixed(1)}" y="${MT - 5}" text-anchor="${anchor}">${hr}h</text>`);
|
||
}
|
||
|
||
const selYmd = ymd(selectedDay());
|
||
for (let i = 0; i < N; i++) {
|
||
const day = new Date(today);
|
||
day.setDate(day.getDate() - (N - 1 - i)); // oldest at top, today at bottom
|
||
const dayStart = startOfDay(day).getTime();
|
||
const dayEnd = dayStart + dayMs;
|
||
const isToday = ymd(day) === ymd(new Date());
|
||
const isSel = ymd(day) === selYmd;
|
||
const y = MT + i * (rowH + rowGap);
|
||
|
||
parts.push(`<rect class="stl-track${isSel ? " stl-selected" : ""}" x="${ML}" y="${y.toFixed(1)}" width="${innerW}" height="${rowH.toFixed(1)}" rx="2"/>`);
|
||
|
||
for (const wdw of windows) {
|
||
const s = Math.max(wdw.start, dayStart);
|
||
const e = Math.min(wdw.end, dayEnd);
|
||
if (e <= s) continue;
|
||
const x = xOf((s - dayStart) / dayMs);
|
||
const wpx = ((e - s) / dayMs) * innerW;
|
||
parts.push(`<rect class="stl-sleep" x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${Math.max(0.6, wpx).toFixed(1)}" height="${rowH.toFixed(1)}" rx="1.5"/>`);
|
||
}
|
||
|
||
const label = isToday ? "Today" : `${day.toLocaleDateString(undefined, { weekday: "short" })} ${day.getDate()}`;
|
||
parts.push(`<text class="stl-day ${isToday ? "stl-today" : ""}${isSel ? " stl-sel" : ""}" x="${ML - 6}" y="${(y + rowH / 2 + 3).toFixed(1)}" text-anchor="end">${escapeText(label)}</text>`);
|
||
|
||
const title = day.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" });
|
||
parts.push(`<rect class="bar stl-hit" data-day="${ymd(day)}" x="${ML}" y="${y.toFixed(1)}" width="${innerW}" height="${rowH.toFixed(1)}"><title>${escapeText(title)}</title></rect>`);
|
||
}
|
||
|
||
setChartSVG(svg, parts); // wires the .bar[data-day] click → select that day
|
||
}
|
||
|
||
// 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 from = startOfDay(new Date(Date.now() - (chartDays() - 1) * 86_400_000)).getTime();
|
||
|
||
const series = [
|
||
{ type: "pee", label: "Pees", cls: "hm-pee" },
|
||
{ type: "poo", label: "Poos", cls: "hm-poo" },
|
||
{ type: "eat", label: "Meals", cls: "hm-eat" },
|
||
];
|
||
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;
|
||
|
||
const parts = [];
|
||
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 range = `${pad2(h)}:00–${pad2((h + 1) % 24)}:00`;
|
||
parts.push(
|
||
`<rect class="hm-cell ${s.cls}" 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)}">` +
|
||
`<title>${escapeText(`${s.label} · ${range}: ${c}`)}</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.join("");
|
||
}
|
||
|
||
// ---------- sleep trend ----------
|
||
// Cumulative hours slept as the selected day progresses: that day's running
|
||
// total (up to now when it's today, else the full 24h) against the previous
|
||
// day's full curve and the mean of the N full days before it, where N
|
||
// follows the 7/14/30 chart-days picker.
|
||
// Each curve is a list of { x: hour-of-day 0..24, y: cumulative hours }.
|
||
function sleepTrendCurves(events) {
|
||
const HOUR = 3_600_000;
|
||
const windows = sleepWindows(events); // ongoing sleep already clipped to now
|
||
const sleptMs = (from, to) => {
|
||
let total = 0;
|
||
for (const w of windows) {
|
||
const s = Math.max(w.start, from);
|
||
const e = Math.min(w.end, to);
|
||
if (e > s) total += e - s;
|
||
}
|
||
return total;
|
||
};
|
||
const day = selectedDay();
|
||
const isToday = ymd(day) === ymd(new Date());
|
||
const dayStartTs = (daysAgo) => {
|
||
const d = startOfDay(day);
|
||
d.setDate(d.getDate() - daysAgo);
|
||
return d.getTime();
|
||
};
|
||
// capTs (today only) truncates the curve at "now" with a final fractional
|
||
// point, so the line visibly ends where the day currently stands.
|
||
const curveFor = (start, capTs) => {
|
||
const pts = [];
|
||
for (let h = 0; h <= 24; h++) {
|
||
const to = start + h * HOUR;
|
||
if (capTs != null && to >= capTs) {
|
||
pts.push({ x: (capTs - start) / HOUR, y: sleptMs(start, capTs) / HOUR });
|
||
break;
|
||
}
|
||
pts.push({ x: h, y: sleptMs(start, to) / HOUR });
|
||
}
|
||
return pts;
|
||
};
|
||
|
||
// A past day is complete, so its curve runs the full 24h uncapped.
|
||
const today = curveFor(dayStartTs(0), isToday ? Date.now() : null);
|
||
|
||
const yesterdayCurve = curveFor(dayStartTs(1));
|
||
const yesterday = yesterdayCurve[24].y > 0 ? yesterdayCurve : null;
|
||
|
||
// Mean of the last N full days, skipping days with no sleep at all so a
|
||
// young log (or a tracking gap) doesn't drag the average toward zero.
|
||
const avgDays = chartDays();
|
||
const dayCurves = [];
|
||
for (let i = 1; i <= avgDays; i++) {
|
||
const c = curveFor(dayStartTs(i));
|
||
if (c[24].y > 0) dayCurves.push(c);
|
||
}
|
||
let avg = null;
|
||
if (dayCurves.length > 0) {
|
||
avg = [];
|
||
for (let h = 0; h <= 24; h++) {
|
||
avg.push({ x: h, y: dayCurves.reduce((s, c) => s + c[h].y, 0) / dayCurves.length });
|
||
}
|
||
}
|
||
|
||
// Where today is likely to end up: continue today's line by adding what
|
||
// the average day typically adds between now and midnight. That respects
|
||
// the time-of-day rhythm (night sleep, nap clusters), unlike a linear
|
||
// rate extrapolation, which overshoots wildly just after a long night.
|
||
// No history → no average → no projection. Past days are already complete,
|
||
// so there is nothing to project.
|
||
let projected = null;
|
||
if (avg && isToday) {
|
||
const nowPt = today[today.length - 1];
|
||
const avgAt = (x) => {
|
||
const lo = Math.floor(x);
|
||
const hi = Math.min(24, lo + 1);
|
||
return avg[lo].y + (avg[hi].y - avg[lo].y) * (x - lo);
|
||
};
|
||
projected = [{ x: nowPt.x, y: nowPt.y }];
|
||
for (let h = Math.ceil(nowPt.x); h <= 24; h++) {
|
||
if (h <= nowPt.x) continue; // now landing exactly on an hour boundary
|
||
projected.push({ x: h, y: nowPt.y + avgAt(h) - avgAt(nowPt.x) });
|
||
}
|
||
}
|
||
|
||
// Labels follow the selection so a past day reads as its date, not "Today".
|
||
const fmtDay = (daysAgo) =>
|
||
new Date(dayStartTs(daysAgo)).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||
const dayLabel = isToday ? "Today" : fmtDay(0);
|
||
const prevDayLabel = isToday ? "Yesterday" : fmtDay(1);
|
||
|
||
return { today, yesterday, avg, avgDays, projected, dayLabel, prevDayLabel };
|
||
}
|
||
|
||
function drawSleepTrendChart(curves, target) {
|
||
const svg = document.getElementById("chart-sleep-trend");
|
||
if (!svg) return;
|
||
const W = 320, H = 220;
|
||
const ML = 26, MR = 8, MT = 10, MB = 22;
|
||
const innerW = W - ML - MR;
|
||
const innerH = H - MT - MB;
|
||
|
||
const series = [
|
||
// Reference lines first so today (and its projected tail) draw on top.
|
||
{ pts: curves.avg, cls: "trend-avg", label: `${curves.avgDays}-day average` },
|
||
{ pts: curves.yesterday, cls: "trend-yesterday", label: curves.prevDayLabel },
|
||
{ pts: curves.projected, cls: "trend-projected", label: "Projected end of day" },
|
||
{ pts: curves.today, cls: "trend-today", label: curves.dayLabel },
|
||
].filter(s => s.pts && s.pts.length > 1);
|
||
|
||
// The axis must reach the goal band's top even when the data doesn't yet.
|
||
const rawMax = Math.max(
|
||
target ? target.hi : 0,
|
||
...series.flatMap(s => s.pts.map(p => p.y)),
|
||
);
|
||
const { yMax } = niceAxisSleepHours(rawMax);
|
||
|
||
// The interesting detail sits near the goal, in the upper teens of hours,
|
||
// so the y scale is piecewise: everything up to BREAK shares a compressed
|
||
// slice of the height and the hours above it get all the rest. Linear
|
||
// again while the axis is still too short for a split to mean anything.
|
||
const BREAK = 10, LOW_FRAC = 0.26;
|
||
const split = yMax > BREAK + 2;
|
||
const frac = (v) => !split
|
||
? v / yMax
|
||
: v <= BREAK
|
||
? (v / BREAK) * LOW_FRAC
|
||
: LOW_FRAC + ((v - BREAK) / (yMax - BREAK)) * (1 - LOW_FRAC);
|
||
const xOf = (hour) => ML + (hour / 24) * innerW;
|
||
const yOf = (v) => MT + innerH * (1 - frac(v));
|
||
|
||
// Gridlines: every 1h in the stretched region above the break, every 2h
|
||
// in the compressed region below it (1h everywhere when linear).
|
||
const yTicks = [];
|
||
if (split) {
|
||
for (let v = 0; v < BREAK; v += 2) yTicks.push(v);
|
||
for (let v = BREAK; v <= yMax; v++) yTicks.push(v);
|
||
} else {
|
||
for (let v = 0; v <= yMax; v++) yTicks.push(v);
|
||
}
|
||
// Smaller text once the axis is dense so the labels stay apart.
|
||
const yLabelCls = yTicks.length > 16 ? "y-dense" : "";
|
||
const parts = [];
|
||
|
||
// Age-based goal band, behind everything: today's projection should land
|
||
// inside it by midnight.
|
||
if (target) {
|
||
const yTop = yOf(target.hi);
|
||
const yBot = yOf(target.lo);
|
||
parts.push(
|
||
`<rect class="trend-goal" x="${ML}" y="${yTop.toFixed(1)}" ` +
|
||
`width="${innerW}" height="${(yBot - yTop).toFixed(1)}">` +
|
||
`<title>${escapeText(`Goal ${target.lo}–${target.hi}h (${target.label})`)}</title></rect>`
|
||
);
|
||
}
|
||
for (const v of yTicks) {
|
||
const y = yOf(v);
|
||
parts.push(`<line class="grid" x1="${ML}" y1="${y.toFixed(1)}" x2="${W - MR}" y2="${y.toFixed(1)}"/>`);
|
||
parts.push(`<text class="${yLabelCls}" x="${ML - 4}" y="${(y + 3).toFixed(1)}" text-anchor="end">${v}h</text>`);
|
||
}
|
||
for (const hr of [0, 6, 12, 18, 24]) {
|
||
const x = xOf(hr);
|
||
parts.push(`<line class="grid" x1="${x.toFixed(1)}" y1="${MT}" x2="${x.toFixed(1)}" y2="${MT + innerH}"/>`);
|
||
const anchor = hr === 0 ? "start" : hr === 24 ? "end" : "middle";
|
||
parts.push(`<text x="${x.toFixed(1)}" y="${H - MB + 14}" text-anchor="${anchor}">${pad2(hr)}</text>`);
|
||
}
|
||
|
||
for (const s of series) {
|
||
const d = s.pts
|
||
.map((p, i) => `${i === 0 ? "M" : "L"}${xOf(p.x).toFixed(1)} ${yOf(p.y).toFixed(1)}`)
|
||
.join(" ");
|
||
const last = s.pts[s.pts.length - 1];
|
||
const title = `${s.label} — ${last.y.toFixed(1)}h slept`;
|
||
parts.push(`<path class="${s.cls}" d="${d}"><title>${escapeText(title)}</title></path>`);
|
||
}
|
||
|
||
svg.innerHTML = parts.join("");
|
||
}
|
||
|
||
function renderSleepTrend(events) {
|
||
const curves = sleepTrendCurves(events);
|
||
const target = sleepTargetFor(loadConfig().birthday);
|
||
drawSleepTrendChart(curves, target);
|
||
// Drop legend chips for missing reference lines (no data for them yet),
|
||
// keep the average chip's label in step with the chart-days picker, and
|
||
// write each curve's slept-hours total into its chip.
|
||
const chip = (id) => document.getElementById(id);
|
||
const hrs = (pts) => `${pts[pts.length - 1].y.toFixed(1)}h`;
|
||
chip("legend-trend-today-text").textContent = `${curves.dayLabel} ${hrs(curves.today)}`;
|
||
const yLegend = chip("legend-trend-yesterday");
|
||
yLegend.hidden = !curves.yesterday;
|
||
if (curves.yesterday) {
|
||
chip("legend-trend-yesterday-text").textContent = `${curves.prevDayLabel} ${hrs(curves.yesterday)}`;
|
||
}
|
||
const aLegend = chip("legend-trend-avg");
|
||
aLegend.hidden = !curves.avg;
|
||
if (curves.avg) {
|
||
chip("legend-trend-avg-text").textContent = `${curves.avgDays}-day avg ${hrs(curves.avg)}`;
|
||
}
|
||
const pLegend = chip("legend-trend-projected");
|
||
pLegend.hidden = !curves.projected;
|
||
if (curves.projected) {
|
||
// ✓ when the projection reaches at least the goal's lower bound.
|
||
let mark = "";
|
||
if (target) {
|
||
const end = curves.projected[curves.projected.length - 1].y;
|
||
mark = end >= target.lo ? " ✓" : " ▽";
|
||
pLegend.title = end >= target.lo
|
||
? "On track for the sleep goal"
|
||
: "Projected below the sleep goal";
|
||
}
|
||
chip("legend-trend-projected-text").textContent = `Projected ~${hrs(curves.projected)}${mark}`;
|
||
}
|
||
const gLegend = chip("legend-trend-goal");
|
||
gLegend.hidden = !target;
|
||
if (target) {
|
||
chip("legend-trend-goal-text").textContent =
|
||
`Goal ${target.lo}–${target.hi}h (${target.label})`;
|
||
}
|
||
}
|
||
|
||
// ---------- weight ----------
|
||
// Pick a "nice" kg axis that frames the data with a little headroom rather
|
||
// than forcing 0-based (a puppy going 5→8 kg would otherwise look flat).
|
||
function niceWeightAxis(min, max) {
|
||
if (!(max > 0)) return { lo: 0, hi: 1, steps: 1 };
|
||
if (min === max) { min = Math.max(0, min - 0.5); max = max + 0.5; }
|
||
const span = max - min;
|
||
let lo = Math.max(0, min - span * 0.15);
|
||
let hi = max + span * 0.15;
|
||
const rawStep = (hi - lo) / 4;
|
||
const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
||
const norm = rawStep / mag;
|
||
const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag;
|
||
lo = Math.floor(lo / step) * step;
|
||
hi = Math.ceil(hi / step) * step;
|
||
return { lo, hi, steps: Math.max(1, Math.round((hi - lo) / step)) };
|
||
}
|
||
|
||
// Human-readable detail for one weigh-in: date, weight, and the puppy's age
|
||
// at that date (omitted if no birthday is configured).
|
||
function weightPointInfo(w, birthday) {
|
||
const dateStr = new Date(w.at).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
|
||
const age = formatAge(birthday, w.at);
|
||
return `${dateStr} — ${formatWeight(w.weight)}${age ? ` · ${age}` : ""}`;
|
||
}
|
||
|
||
function drawWeightChart(weights, birthday) {
|
||
const svg = document.getElementById("chart-weight");
|
||
const info = document.getElementById("weight-point-info");
|
||
const W = 320, H = 180;
|
||
const ML = 30, MR = 8, MT = 10, MB = 24;
|
||
const innerW = W - ML - MR;
|
||
const innerH = H - MT - MB;
|
||
|
||
if (weights.length === 0) { svg.innerHTML = ""; info.textContent = ""; return; }
|
||
|
||
const vals = weights.map(w => w.weight);
|
||
const { lo, hi, steps } = niceWeightAxis(Math.min(...vals), Math.max(...vals));
|
||
|
||
const t0 = weights[0].at;
|
||
const t1 = weights[weights.length - 1].at;
|
||
const tSpan = t1 - t0;
|
||
const xOf = (t) => tSpan > 0 ? ML + ((t - t0) / tSpan) * innerW : ML + innerW / 2;
|
||
const yOf = (v) => MT + innerH * (1 - (v - lo) / (hi - lo));
|
||
|
||
const parts = [];
|
||
for (let i = 0; i <= steps; i++) {
|
||
const v = lo + (hi - lo) * i / steps;
|
||
const y = yOf(v);
|
||
parts.push(`<line class="grid" x1="${ML}" y1="${y}" x2="${W - MR}" y2="${y}"/>`);
|
||
parts.push(`<text x="${ML - 4}" y="${y + 3}" text-anchor="end">${Math.round(v * 10) / 10}</text>`);
|
||
}
|
||
|
||
if (weights.length > 1) {
|
||
const d = weights
|
||
.map((w, i) => `${i === 0 ? "M" : "L"}${xOf(w.at).toFixed(1)} ${yOf(w.weight).toFixed(1)}`)
|
||
.join(" ");
|
||
parts.push(`<path class="weight-line" d="${d}"/>`);
|
||
}
|
||
|
||
// Visible dots, then larger transparent hit targets on top (easier to tap
|
||
// on touch, and they carry the tooltip + click detail).
|
||
weights.forEach(w => {
|
||
parts.push(`<circle class="weight-dot" cx="${xOf(w.at).toFixed(1)}" cy="${yOf(w.weight).toFixed(1)}" r="3.5"/>`);
|
||
});
|
||
weights.forEach((w, i) => {
|
||
const detail = weightPointInfo(w, birthday);
|
||
parts.push(
|
||
`<circle class="weight-hit" data-i="${i}" cx="${xOf(w.at).toFixed(1)}" cy="${yOf(w.weight).toFixed(1)}" r="10">` +
|
||
`<title>${escapeText(detail)}</title></circle>`
|
||
);
|
||
});
|
||
|
||
const fmtX = (t) => new Date(t).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||
parts.push(`<text x="${ML}" y="${H - MB + 16}" text-anchor="start">${escapeText(fmtX(t0))}</text>`);
|
||
if (tSpan > 0) {
|
||
parts.push(`<text x="${W - MR}" y="${H - MB + 16}" text-anchor="end">${escapeText(fmtX(t1))}</text>`);
|
||
}
|
||
|
||
svg.innerHTML = parts.join("");
|
||
|
||
// Default the caption to the most recent weigh-in; hover/tap focuses a point.
|
||
const hits = svg.querySelectorAll(".weight-hit");
|
||
const focus = (i) => {
|
||
info.textContent = weightPointInfo(weights[i], birthday);
|
||
hits.forEach(h => h.classList.toggle("active", Number(h.dataset.i) === i));
|
||
};
|
||
hits.forEach(h => {
|
||
const i = Number(h.dataset.i);
|
||
h.addEventListener("mouseenter", () => focus(i));
|
||
h.addEventListener("click", () => focus(i));
|
||
});
|
||
info.textContent = weightPointInfo(weights[weights.length - 1], birthday);
|
||
}
|
||
|
||
function renderWeight(events) {
|
||
const weights = events
|
||
.filter(e => e.type === "weight" && Number.isFinite(e.weight))
|
||
.sort((a, b) => a.at - b.at);
|
||
|
||
const empty = document.getElementById("weight-empty");
|
||
const latestEl = document.getElementById("weight-latest");
|
||
const changeEl = document.getElementById("weight-change");
|
||
const list = document.getElementById("weight-list");
|
||
|
||
const birthday = loadConfig().birthday;
|
||
|
||
list.innerHTML = "";
|
||
changeEl.classList.remove("up", "down");
|
||
|
||
if (weights.length === 0) {
|
||
empty.hidden = false;
|
||
latestEl.textContent = "—";
|
||
changeEl.textContent = "—";
|
||
drawWeightChart([], birthday);
|
||
return;
|
||
}
|
||
empty.hidden = true;
|
||
|
||
const latest = weights[weights.length - 1];
|
||
latestEl.textContent = formatWeight(latest.weight);
|
||
|
||
if (weights.length >= 2) {
|
||
const d = latest.weight - weights[weights.length - 2].weight;
|
||
const rounded = Math.round(d * 100) / 100;
|
||
const arrow = d > 0 ? "▲" : d < 0 ? "▼" : "▬";
|
||
changeEl.textContent = `${arrow} ${d > 0 ? "+" : ""}${rounded} kg`;
|
||
changeEl.classList.toggle("up", d > 0);
|
||
changeEl.classList.toggle("down", d < 0);
|
||
} else {
|
||
changeEl.textContent = "—";
|
||
}
|
||
|
||
// Most-recent-first log; tap a row to edit that weigh-in.
|
||
for (const w of [...weights].reverse()) {
|
||
const li = document.createElement("li");
|
||
li.className = "ww weight-ww";
|
||
const date = document.createElement("span");
|
||
date.className = "ww-range";
|
||
const age = formatAgeWeeks(birthday, w.at);
|
||
const dateStr = new Date(w.at).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||
date.textContent = age ? `${dateStr} · ${age}` : dateStr;
|
||
const val = document.createElement("span");
|
||
val.className = "ww-dur";
|
||
val.textContent = formatWeight(w.weight);
|
||
li.appendChild(date);
|
||
li.appendChild(val);
|
||
li.addEventListener("click", () => openEditDialog(w));
|
||
list.appendChild(li);
|
||
}
|
||
|
||
drawWeightChart(weights, birthday);
|
||
}
|
||
|
||
// ---------- training ----------
|
||
// Per-exercise stats plus a consistency heatmap. Everything here is rolling
|
||
// (last session / last 7 days / streak / chart window) rather than scoped to
|
||
// the day picker — the point is keeping the habit up, not reviewing one day.
|
||
const expandedExercises = new Set(); // ids showing their instructions (per page load)
|
||
|
||
function trainingStreakDays(times) {
|
||
// Consecutive days with ≥1 session, counting back from today — or from
|
||
// yesterday, so a streak isn't shown as broken before today's session
|
||
// has had a chance to happen.
|
||
const days = new Set(times.map(t => ymd(new Date(t))));
|
||
const d = new Date();
|
||
if (!days.has(ymd(d))) d.setDate(d.getDate() - 1);
|
||
let streak = 0;
|
||
while (days.has(ymd(d))) { streak++; d.setDate(d.getDate() - 1); }
|
||
return streak;
|
||
}
|
||
|
||
function exerciseMeta(times) {
|
||
if (times.length === 0) return "not yet trained";
|
||
const weekFrom = startOfDay(new Date());
|
||
weekFrom.setDate(weekFrom.getDate() - 6);
|
||
const week = times.filter(t => t >= weekFrom.getTime()).length;
|
||
const parts = [`last ${formatRelative(Math.max(...times))}`, `${week}× this week`];
|
||
const streak = trainingStreakDays(times);
|
||
if (streak >= 2) parts.push(`🔥 ${streak}-day streak`);
|
||
return parts.join(" · ");
|
||
}
|
||
|
||
// Exercise × day grid: one row per exercise, one cell per day, opacity scaled
|
||
// by that day's session count. Same idea as the hour heatmap, with days for
|
||
// columns. Cells click through to the day picker via setChartSVG.
|
||
function renderTrainingHeatmap(exercises, events) {
|
||
const wrap = document.getElementById("training-chart-wrap");
|
||
const svg = document.getElementById("chart-training");
|
||
if (exercises.length === 0) { wrap.hidden = true; return; }
|
||
wrap.hidden = false;
|
||
|
||
const N = chartDays();
|
||
const from = startOfDay(new Date());
|
||
from.setDate(from.getDate() - (N - 1));
|
||
const dayList = [];
|
||
const dayIndex = new Map(); // ymd -> column
|
||
for (let i = 0; i < N; i++) {
|
||
const d = new Date(from);
|
||
d.setDate(d.getDate() + i);
|
||
dayList.push(d);
|
||
dayIndex.set(ymd(d), i);
|
||
}
|
||
const exIndex = new Map(exercises.map((x, i) => [x.id, i]));
|
||
const counts = exercises.map(() => new Array(N).fill(0));
|
||
for (const e of events) {
|
||
if (e.type !== "training") continue;
|
||
const r = exIndex.get(e.exerciseId);
|
||
const c = dayIndex.get(ymd(new Date(e.at)));
|
||
if (r !== undefined && c !== undefined) counts[r][c]++;
|
||
}
|
||
|
||
const W = 320;
|
||
const ML = 70, MR = 8, MT = 6, MB = 18;
|
||
const rowH = 16, rowGap = 4;
|
||
const H = MT + exercises.length * (rowH + rowGap) - rowGap + MB;
|
||
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
||
const innerW = W - ML - MR;
|
||
const cellW = innerW / N;
|
||
|
||
const parts = [];
|
||
const selCol = dayIndex.get(ymd(selectedDay()));
|
||
if (selCol !== undefined) {
|
||
parts.push(
|
||
`<rect class="day-highlight" x="${(ML + selCol * cellW - 0.75).toFixed(1)}" y="${MT - 2}" ` +
|
||
`width="${cellW.toFixed(1)}" height="${(H - MT - MB + 4).toFixed(1)}" rx="3"/>`
|
||
);
|
||
}
|
||
exercises.forEach((x, r) => {
|
||
const y = MT + r * (rowH + rowGap);
|
||
const max = Math.max(1, ...counts[r]);
|
||
for (let c = 0; c < N; c++) {
|
||
const n = counts[r][c];
|
||
const op = n === 0 ? 0.06 : 0.35 + 0.65 * (n / max);
|
||
const d = dayList[c];
|
||
const title = `${x.name} · ${d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })}: ${n}`;
|
||
parts.push(
|
||
`<rect class="bar hm-cell hm-training" data-day="${ymd(d)}" ` +
|
||
`x="${(ML + c * cellW).toFixed(1)}" y="${y.toFixed(1)}" ` +
|
||
`width="${(cellW - 1.5).toFixed(1)}" height="${rowH}" rx="2" fill-opacity="${op.toFixed(2)}">` +
|
||
`<title>${escapeText(title)}</title></rect>`
|
||
);
|
||
}
|
||
const name = x.name.length > 12 ? x.name.slice(0, 11) + "…" : x.name;
|
||
parts.push(`<text x="${ML - 6}" y="${(y + rowH / 2 + 3).toFixed(1)}" text-anchor="end">${escapeText(name)}</text>`);
|
||
});
|
||
|
||
const yAxis = H - MB + 12;
|
||
parts.push(`<text x="${ML}" y="${yAxis}" text-anchor="start">${escapeText(dayList[0].toLocaleDateString(undefined, { month: "short", day: "numeric" }))}</text>`);
|
||
parts.push(`<text x="${ML + innerW}" y="${yAxis}" text-anchor="end">Today</text>`);
|
||
|
||
setChartSVG(svg, parts);
|
||
}
|
||
|
||
function renderTraining(events) {
|
||
const list = document.getElementById("training-list");
|
||
const empty = document.getElementById("training-empty");
|
||
const exercises = liveExercises();
|
||
list.innerHTML = "";
|
||
empty.hidden = exercises.length > 0;
|
||
|
||
for (const ex of exercises) {
|
||
const times = events
|
||
.filter(e => e.type === "training" && e.exerciseId === ex.id)
|
||
.map(e => e.at);
|
||
|
||
const li = document.createElement("li");
|
||
li.className = "exercise" + (expandedExercises.has(ex.id) ? " expanded" : "");
|
||
|
||
const row = document.createElement("div");
|
||
row.className = "ex-row";
|
||
const main = document.createElement("div");
|
||
main.className = "ex-main";
|
||
const nameEl = document.createElement("span");
|
||
nameEl.className = "ex-name";
|
||
nameEl.textContent = ex.name;
|
||
const metaEl = document.createElement("span");
|
||
metaEl.className = "ex-meta";
|
||
metaEl.textContent = exerciseMeta(times);
|
||
main.appendChild(nameEl);
|
||
main.appendChild(metaEl);
|
||
|
||
const logBtn = document.createElement("button");
|
||
logBtn.type = "button";
|
||
logBtn.className = "ex-log";
|
||
logBtn.textContent = "Log";
|
||
logBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
// Log onto the selected day (at the current time of day), so a session
|
||
// that was forgotten yesterday can be back-filled from that day's view.
|
||
const day = selectedDay();
|
||
const onToday = ymd(day) === ymd(new Date());
|
||
let at = Date.now();
|
||
if (!onToday) {
|
||
const now = new Date();
|
||
at = new Date(day.getFullYear(), day.getMonth(), day.getDate(),
|
||
now.getHours(), now.getMinutes(), now.getSeconds()).getTime();
|
||
}
|
||
const ev = addEvent("training", "", at, { exerciseId: ex.id });
|
||
const dayStr = day.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||
showSnackbar(onToday ? `${ex.name} logged` : `${ex.name} logged for ${dayStr}`, ev);
|
||
});
|
||
|
||
row.appendChild(main);
|
||
row.appendChild(logBtn);
|
||
row.addEventListener("click", () => {
|
||
if (expandedExercises.has(ex.id)) expandedExercises.delete(ex.id);
|
||
else expandedExercises.add(ex.id);
|
||
li.classList.toggle("expanded");
|
||
});
|
||
|
||
const detail = document.createElement("div");
|
||
detail.className = "ex-detail";
|
||
const noteEl = document.createElement("p");
|
||
noteEl.className = "ex-note";
|
||
noteEl.textContent = ex.note || "No instructions yet — tap Edit to add how to train this.";
|
||
const editBtn = document.createElement("button");
|
||
editBtn.type = "button";
|
||
editBtn.className = "ghost ex-edit";
|
||
editBtn.textContent = "Edit";
|
||
editBtn.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
openExerciseDialog(ex);
|
||
});
|
||
detail.appendChild(noteEl);
|
||
detail.appendChild(editBtn);
|
||
|
||
li.appendChild(row);
|
||
li.appendChild(detail);
|
||
list.appendChild(li);
|
||
}
|
||
|
||
renderTrainingHeatmap(exercises, events);
|
||
}
|
||
|
||
function renderDayBar() {
|
||
const day = selectedDay();
|
||
const isToday = ymd(day) === ymd(new Date());
|
||
document.getElementById("day-next").disabled = isToday;
|
||
document.getElementById("day-today").disabled = isToday;
|
||
// Year-less date on the picker's face — the year is implicit and the
|
||
// saved width keeps the bar on one row on small phones.
|
||
document.getElementById("day-date-face").textContent =
|
||
day.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||
const title = document.getElementById("overview-title");
|
||
if (title) {
|
||
title.textContent = isToday
|
||
? "Today's overview"
|
||
: day.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" });
|
||
}
|
||
}
|
||
|
||
function renderHeader() {
|
||
const cfg = loadConfig();
|
||
const title = document.getElementById("app-title");
|
||
const ageEl = document.getElementById("puppy-age");
|
||
title.textContent = cfg.name ? `🐶 ${cfg.name}` : "🐶 Puppy Tracker";
|
||
document.title = cfg.name ? `${cfg.name} · Puppy Tracker` : "Puppy Tracker";
|
||
const ageText = formatAgeShort(cfg.birthday);
|
||
ageEl.textContent = ageText;
|
||
ageEl.hidden = !ageText;
|
||
// Use the puppy's name in the sleep-timeline heading rather than assuming a
|
||
// gender; fall back to a neutral phrase when no name is configured.
|
||
const sleepTitle = document.getElementById("sleep-timeline-title");
|
||
if (sleepTitle) sleepTitle.textContent = cfg.name ? `When ${cfg.name} sleeps` : "When sleeping";
|
||
}
|
||
|
||
// Dim the quick actions that don't fit the current sleep state — a nudge
|
||
// toward the likely next tap, never a block: everything stays clickable so
|
||
// corrections (a missed sleep-end, a mid-nap pee) are always possible.
|
||
function renderActionHints(events) {
|
||
const { state } = currentSleepState(events);
|
||
document.querySelectorAll("button.action").forEach(btn => {
|
||
const type = btn.dataset.type;
|
||
const unlikely =
|
||
state === "asleep" ? type !== "sleep-end"
|
||
: state === "awake" ? type === "sleep-end"
|
||
: false; // no sleep history yet — no hints to give
|
||
btn.classList.toggle("unlikely", unlikely);
|
||
});
|
||
}
|
||
|
||
function render() {
|
||
const events = live();
|
||
renderHeader();
|
||
renderDayBar();
|
||
renderChartWindow();
|
||
renderBigClock(events);
|
||
renderActionHints(events);
|
||
renderStats(events);
|
||
renderLasts(events);
|
||
renderTiming(events);
|
||
renderSleepWindows(events);
|
||
renderWakeWindows(events);
|
||
renderWeekly(events);
|
||
renderSleepTimeline(events);
|
||
renderSleepTrend(events);
|
||
renderHourHeatmap(events);
|
||
renderTraining(events);
|
||
renderWeight(events);
|
||
renderHistory(events);
|
||
}
|
||
|
||
// ---------- sync ----------
|
||
let syncTimer = null;
|
||
let syncing = false;
|
||
let lastError = null;
|
||
let lastSynced = 0;
|
||
|
||
function setStatus(state) {
|
||
statusEl.classList.remove("offline", "syncing", "error", "pending");
|
||
if (!navigator.onLine) {
|
||
statusEl.textContent = "offline";
|
||
statusEl.classList.add("offline");
|
||
return;
|
||
}
|
||
switch (state) {
|
||
case "syncing":
|
||
statusEl.textContent = "syncing…";
|
||
statusEl.classList.add("syncing");
|
||
break;
|
||
case "error":
|
||
statusEl.textContent = "sync error";
|
||
statusEl.classList.add("error");
|
||
statusEl.title = lastError || "";
|
||
break;
|
||
case "pending":
|
||
statusEl.textContent = "pending";
|
||
statusEl.classList.add("pending");
|
||
break;
|
||
default:
|
||
statusEl.textContent = lastSynced
|
||
? `synced ${formatRelative(lastSynced)}`
|
||
: "synced";
|
||
statusEl.title = "";
|
||
}
|
||
}
|
||
|
||
function scheduleSync() {
|
||
if (!navigator.onLine) { setStatus("pending"); return; }
|
||
setStatus("pending");
|
||
clearTimeout(syncTimer);
|
||
syncTimer = setTimeout(sync, SYNC_DEBOUNCE_MS);
|
||
}
|
||
|
||
// Merge a server response back into local storage. Anything local with a
|
||
// newer updatedAt than the server's copy wins — that covers items the user
|
||
// added/edited during the in-flight sync request. Shared by the events and
|
||
// exercises collections, which follow the same LWW contract.
|
||
function mergeSynced(serverItems, load, save) {
|
||
const localById = new Map(load().map(e => [e.id, e]));
|
||
const merged = new Map();
|
||
for (const se of serverItems) {
|
||
if (se && se.id) merged.set(se.id, se);
|
||
}
|
||
for (const [id, le] of localById) {
|
||
const se = merged.get(id);
|
||
if (!se || (le.updatedAt || 0) > (se.updatedAt || 0)) {
|
||
merged.set(id, le);
|
||
}
|
||
}
|
||
save([...merged.values()]);
|
||
}
|
||
|
||
async function sync() {
|
||
if (!currentUser) return;
|
||
if (syncing) return;
|
||
if (!navigator.onLine) { setStatus("pending"); return; }
|
||
syncing = true;
|
||
setStatus("syncing");
|
||
try {
|
||
// Push queued photos first so events that reference them won't return
|
||
// 404s when other clients try to fetch.
|
||
await syncPhotos();
|
||
|
||
const res = await fetch(SYNC_URL, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ events: loadAll() }),
|
||
});
|
||
if (res.status === 401) { handleLoggedOut(); return; }
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const body = await res.json();
|
||
|
||
const exRes = await fetch("api/exercises/sync", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ exercises: loadExercises() }),
|
||
});
|
||
if (exRes.status === 401) { handleLoggedOut(); return; }
|
||
if (!exRes.ok) throw new Error(`HTTP ${exRes.status}`);
|
||
const exBody = await exRes.json();
|
||
|
||
if (Array.isArray(exBody.exercises)) {
|
||
mergeSynced(exBody.exercises, loadExercises, saveExercises);
|
||
}
|
||
if (Array.isArray(body.events)) {
|
||
mergeSynced(body.events, loadAll, saveAll);
|
||
}
|
||
lastSynced = Date.now();
|
||
lastError = null;
|
||
render();
|
||
setStatus("synced");
|
||
} catch (err) {
|
||
lastError = err.message || String(err);
|
||
console.warn("sync failed:", lastError);
|
||
setStatus("error");
|
||
} finally {
|
||
syncing = false;
|
||
}
|
||
}
|
||
|
||
// ---------- config sync ----------
|
||
// Reconcile the local config cache with the host. Whichever side has the
|
||
// newer updatedAt wins: adopt the server's copy, or push ours if it's ahead
|
||
// (e.g. edited on this device while another client hadn't changed it). This
|
||
// self-heals a failed push — the local copy stays newer and re-pushes next tick.
|
||
async function syncConfig() {
|
||
if (!currentUser) return;
|
||
if (!navigator.onLine) return;
|
||
const local = loadConfig();
|
||
try {
|
||
const res = await fetch("api/config");
|
||
if (res.status === 401) { handleLoggedOut(); return; }
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const body = await res.json();
|
||
const server = {
|
||
name: body.name || "",
|
||
birthday: body.birthday || "",
|
||
pedigreeId: body.pedigreeId || "",
|
||
updatedAt: Number.isFinite(body.updatedAt) ? body.updatedAt : 0,
|
||
};
|
||
const merged = reconcileConfig(local, server);
|
||
if (!sameConfig(merged, local)) {
|
||
saveConfig(merged);
|
||
renderHeader();
|
||
refreshPedigreeButton();
|
||
}
|
||
if (!sameConfig(merged, server)) {
|
||
await pushConfig(merged);
|
||
}
|
||
} catch (err) {
|
||
console.warn("config sync failed:", err);
|
||
}
|
||
}
|
||
|
||
async function pushConfig(cfg) {
|
||
if (!navigator.onLine) return;
|
||
const res = await fetch("api/config", {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(cfg),
|
||
});
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
const body = await res.json();
|
||
// Adopt the server's answer if it turned out to be newer (another client won).
|
||
if (Number.isFinite(body.updatedAt) && body.updatedAt > cfg.updatedAt) {
|
||
saveConfig({ name: body.name || "", birthday: body.birthday || "", pedigreeId: body.pedigreeId || "", updatedAt: body.updatedAt });
|
||
renderHeader();
|
||
}
|
||
}
|
||
|
||
// ---------- dialogs ----------
|
||
const noteDialog = document.getElementById("note-dialog");
|
||
const noteForm = document.getElementById("note-form");
|
||
const noteInput = document.getElementById("note-input");
|
||
const noteDate = document.getElementById("note-date");
|
||
const noteTime = document.getElementById("note-time");
|
||
const noteTitle = document.getElementById("note-title");
|
||
const notePhotoInput = document.getElementById("note-photo-input");
|
||
const notePhotoBtn = document.getElementById("note-photo-btn");
|
||
const notePhotoPreview = document.getElementById("note-photo-preview");
|
||
const noteWeightField = document.getElementById("note-weight-field");
|
||
const noteWeight = document.getElementById("note-weight");
|
||
const noteGramsField = document.getElementById("note-grams-field");
|
||
const noteGrams = document.getElementById("note-grams");
|
||
let pendingType = null;
|
||
let notePhotos = []; // pending photos for this dialog: [{ blob, url }]
|
||
// Whether the user has manually touched the date/time fields. While false the
|
||
// dialog logs at the exact current millisecond rather than the minute-floored
|
||
// input, so a just-logged event doesn't look up to ~59s old.
|
||
let noteTimeEdited = false;
|
||
|
||
// An event can carry several photos: photoId holds their UUIDs
|
||
// comma-separated. A legacy single id is just a one-element list, and the
|
||
// server passes the string through untouched (photos themselves are
|
||
// uploaded and fetched individually by UUID).
|
||
function photoIdsOf(ev) {
|
||
return (ev.photoId || "").split(",").filter(Boolean);
|
||
}
|
||
|
||
// A preview thumbnail with a remove button; shared by both dialogs.
|
||
function photoThumb(url, onRemove) {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "photo-thumb";
|
||
const img = document.createElement("img");
|
||
img.alt = "";
|
||
if (url) img.src = url;
|
||
const rm = document.createElement("button");
|
||
rm.type = "button";
|
||
rm.className = "photo-thumb-remove";
|
||
rm.setAttribute("aria-label", "Remove photo");
|
||
rm.textContent = "×";
|
||
rm.addEventListener("click", onRemove);
|
||
wrap.appendChild(img);
|
||
wrap.appendChild(rm);
|
||
return wrap;
|
||
}
|
||
|
||
function renderNotePhotos() {
|
||
notePhotoPreview.innerHTML = "";
|
||
notePhotoPreview.hidden = notePhotos.length === 0;
|
||
notePhotos.forEach((p, i) => {
|
||
notePhotoPreview.appendChild(photoThumb(p.url, () => {
|
||
URL.revokeObjectURL(p.url);
|
||
notePhotos.splice(i, 1);
|
||
renderNotePhotos();
|
||
}));
|
||
});
|
||
}
|
||
|
||
function clearNotePhotos() {
|
||
for (const p of notePhotos) URL.revokeObjectURL(p.url);
|
||
notePhotos = [];
|
||
renderNotePhotos();
|
||
notePhotoInput.value = "";
|
||
}
|
||
|
||
function openNoteDialog(type) {
|
||
pendingType = type;
|
||
noteInput.value = "";
|
||
noteTimeEdited = false;
|
||
const now = Date.now();
|
||
noteDate.value = toDateInput(now);
|
||
noteTime.value = toTimeInput(now);
|
||
noteTitle.textContent = `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; }
|
||
let weight;
|
||
if (pendingType === "weight") {
|
||
weight = parseFloat(noteWeight.value);
|
||
if (!(weight > 0)) { alert("Enter a weight in kilograms."); return; }
|
||
weight = Math.round(weight * 100) / 100;
|
||
}
|
||
let grams;
|
||
if (pendingType === "eat" && noteGrams.value.trim() !== "") {
|
||
const g = parseFloat(noteGrams.value);
|
||
if (!(g > 0)) { alert("Enter the amount in grams, or leave it empty."); return; }
|
||
grams = Math.round(g);
|
||
}
|
||
const photoIds = [];
|
||
for (const p of notePhotos) {
|
||
const id = uuid();
|
||
try { await putPhoto(id, p.blob, false); }
|
||
catch (err) {
|
||
alert("Couldn't store photo locally: " + err.message);
|
||
return;
|
||
}
|
||
photoIds.push(id);
|
||
}
|
||
addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), { photoId: photoIds.join(","), weight, grams });
|
||
pendingType = null;
|
||
clearNotePhotos();
|
||
noteDialog.close();
|
||
});
|
||
noteForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
pendingType = null;
|
||
clearNotePhotos();
|
||
noteDialog.close();
|
||
});
|
||
|
||
// Edit dialog
|
||
const editDialog = document.getElementById("edit-dialog");
|
||
const editForm = document.getElementById("edit-form");
|
||
const editDate = document.getElementById("edit-date");
|
||
const editTime = document.getElementById("edit-time");
|
||
const editNote = document.getElementById("edit-note");
|
||
const editDelete = document.getElementById("edit-delete");
|
||
const editPhotoInput = document.getElementById("edit-photo-input");
|
||
const editPhotoBtn = document.getElementById("edit-photo-btn");
|
||
const editPhotoPreview = document.getElementById("edit-photo-preview");
|
||
const editWeightField = document.getElementById("edit-weight-field");
|
||
const editWeight = document.getElementById("edit-weight");
|
||
const editGramsField = document.getElementById("edit-grams-field");
|
||
const editGrams = document.getElementById("edit-grams");
|
||
let editingId = null;
|
||
// The dialog's working set of photos, in display order. Existing photos are
|
||
// { id, url } (url from photoSrc's page-lifetime cache — never revoked here);
|
||
// newly picked ones are { blob, url } with a fresh object URL we own.
|
||
let editPhotos = [];
|
||
|
||
function renderEditPhotos() {
|
||
editPhotoPreview.innerHTML = "";
|
||
editPhotoPreview.hidden = editPhotos.length === 0;
|
||
editPhotos.forEach((p, i) => {
|
||
editPhotoPreview.appendChild(photoThumb(p.url, () => {
|
||
if (p.blob) URL.revokeObjectURL(p.url);
|
||
editPhotos.splice(i, 1);
|
||
renderEditPhotos();
|
||
}));
|
||
});
|
||
}
|
||
|
||
function resetEditPhotos() {
|
||
for (const p of editPhotos) if (p.blob) URL.revokeObjectURL(p.url);
|
||
editPhotos = [];
|
||
editPhotoInput.value = "";
|
||
}
|
||
|
||
async function openEditDialog(ev) {
|
||
editingId = ev.id;
|
||
editDate.value = toDateInput(ev.at);
|
||
editTime.value = toTimeInput(ev.at);
|
||
editNote.value = ev.note || "";
|
||
editWeightField.hidden = ev.type !== "weight";
|
||
editWeight.value = (ev.type === "weight" && Number.isFinite(ev.weight)) ? ev.weight : "";
|
||
editGramsField.hidden = ev.type !== "eat";
|
||
editGrams.value = (ev.type === "eat" && Number.isFinite(ev.grams) && ev.grams > 0) ? ev.grams : "";
|
||
resetEditPhotos();
|
||
for (const id of photoIdsOf(ev)) {
|
||
editPhotos.push({ id, url: await photoSrc(id) });
|
||
}
|
||
renderEditPhotos();
|
||
editDialog.showModal();
|
||
// Without an explicit focus the browser autofocuses the first control —
|
||
// the date input — and iOS immediately pops the calendar over the form.
|
||
// Land on the first relevant text field instead (same trick and delay as
|
||
// openNoteDialog: synchronous focus can be lost while the dialog settles).
|
||
const focusTarget = !editWeightField.hidden ? editWeight
|
||
: !editGramsField.hidden ? editGrams
|
||
: editNote;
|
||
setTimeout(() => focusTarget.focus(), 50);
|
||
}
|
||
|
||
editPhotoBtn.addEventListener("click", () => editPhotoInput.click());
|
||
editPhotoInput.addEventListener("change", async (e) => {
|
||
for (const file of Array.from(e.target.files || [])) {
|
||
try {
|
||
const blob = await resizeImage(file);
|
||
editPhotos.push({ blob, url: URL.createObjectURL(blob) });
|
||
} catch (err) {
|
||
alert("Couldn't process that photo: " + err.message);
|
||
}
|
||
}
|
||
editPhotoInput.value = "";
|
||
renderEditPhotos();
|
||
});
|
||
|
||
editForm.querySelector('button[value="save"]').addEventListener("click", async (e) => {
|
||
e.preventDefault();
|
||
if (!editingId) { editDialog.close(); return; }
|
||
const newAt = fromDateTimeInputs(editDate.value, editTime.value);
|
||
const patch = {
|
||
at: Number.isFinite(newAt) ? newAt : undefined,
|
||
note: editNote.value.trim(),
|
||
};
|
||
if (!editWeightField.hidden) {
|
||
const kg = parseFloat(editWeight.value);
|
||
if (!(kg > 0)) { alert("Enter a weight in kilograms."); return; }
|
||
patch.weight = Math.round(kg * 100) / 100;
|
||
}
|
||
if (!editGramsField.hidden) {
|
||
if (editGrams.value.trim() === "") {
|
||
patch.grams = undefined; // cleared → drop the amount
|
||
} else {
|
||
const g = parseFloat(editGrams.value);
|
||
if (!(g > 0)) { alert("Enter the amount in grams, or leave it empty."); return; }
|
||
patch.grams = Math.round(g);
|
||
}
|
||
}
|
||
const photoIds = [];
|
||
for (const p of editPhotos) {
|
||
if (p.id) { photoIds.push(p.id); continue; }
|
||
const id = uuid();
|
||
try { await putPhoto(id, p.blob, false); }
|
||
catch (err) { alert("Couldn't store photo locally: " + err.message); return; }
|
||
photoIds.push(id);
|
||
}
|
||
patch.photoId = photoIds.join(",");
|
||
updateEvent(editingId, patch);
|
||
editingId = null;
|
||
resetEditPhotos();
|
||
editDialog.close();
|
||
});
|
||
|
||
editForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
editingId = null;
|
||
resetEditPhotos();
|
||
editDialog.close();
|
||
});
|
||
|
||
editDelete.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
if (editingId && confirm("Delete this event?")) {
|
||
deleteEvent(editingId);
|
||
}
|
||
editingId = null;
|
||
resetEditPhotos();
|
||
editDialog.close();
|
||
});
|
||
|
||
// Settings dialog (puppy name + birthday)
|
||
// ---------- theme ----------
|
||
// Preference is device-global (not per user). No stored choice → follow the
|
||
// OS via the prefers-color-scheme media query; a choice sets data-theme on
|
||
// <html>, which the CSS treats as an override. The <head> applies any saved
|
||
// choice before first paint; this just resolves state and reacts to the toggle.
|
||
const THEME_KEY = "puppy-tracker:theme";
|
||
const prefersDark = () =>
|
||
window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||
function effectiveTheme() {
|
||
const s = localStorage.getItem(THEME_KEY);
|
||
return s === "light" || s === "dark" ? s : (prefersDark() ? "dark" : "light");
|
||
}
|
||
function setTheme(theme) {
|
||
document.documentElement.dataset.theme = theme;
|
||
try { localStorage.setItem(THEME_KEY, theme); } catch { /* ignore */ }
|
||
}
|
||
|
||
const settingsDialog = document.getElementById("settings-dialog");
|
||
const settingsForm = document.getElementById("settings-form");
|
||
const settingsName = document.getElementById("settings-name");
|
||
const settingsBirthday = document.getElementById("settings-birthday");
|
||
const settingsPedigree = document.getElementById("settings-pedigree");
|
||
const settingsTheme = document.getElementById("settings-theme");
|
||
const settingsConfetti = document.getElementById("settings-confetti");
|
||
|
||
// Apply live so the toggle previews immediately (independent of Save/Cancel).
|
||
settingsTheme.addEventListener("change", () => {
|
||
setTheme(settingsTheme.checked ? "dark" : "light");
|
||
});
|
||
|
||
function openSettingsDialog() {
|
||
const cfg = loadConfig();
|
||
settingsName.value = cfg.name;
|
||
settingsBirthday.value = cfg.birthday;
|
||
settingsPedigree.value = cfg.pedigreeId;
|
||
settingsTheme.checked = effectiveTheme() === "dark";
|
||
settingsConfetti.checked = confettiEnabled();
|
||
settingsDialog.showModal();
|
||
setTimeout(() => settingsName.focus(), 50);
|
||
}
|
||
|
||
document.getElementById("settings-btn").addEventListener("click", openSettingsDialog);
|
||
|
||
settingsForm.querySelector('button[value="save"]').addEventListener("click", async (e) => {
|
||
e.preventDefault();
|
||
const cfg = {
|
||
name: settingsName.value.trim(),
|
||
birthday: settingsBirthday.value,
|
||
pedigreeId: settingsPedigree.value.trim(),
|
||
updatedAt: Date.now(),
|
||
};
|
||
saveConfig(cfg); // cache locally for instant + offline paint
|
||
setConfettiEnabled(settingsConfetti.checked); // device-local, not synced
|
||
renderHeader();
|
||
refreshPedigreeButton();
|
||
settingsDialog.close();
|
||
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();
|
||
});
|
||
|
||
// ---------- 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 : " |