Files
puppy-tracker/src/app.js
T
Alexander Heldt 52c50c97b1 Celebrate a logged pee/poo with emoji confetti
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.
2026-08-01 18:18:26 +00:00

3621 lines
140 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
"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:
// 08 weeks 2022h, 816 weeks 1820h, then 1618h to 6 months and 1416h 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: "08 weeks" };
if (a.weeks < 16) return { lo: 18, hi: 20, label: "816 weeks" };
if (a.months < 6) return { lo: 16, hi: 18, label: "46 months" };
if (a.months < 12) return { lo: 14, hi: 16, label: "612 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 => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 : ""); // clears if not a repeat
}
// Highlight (or unhighlight) every element (tree card or fan wedge) that is the
// same dog as `key`.
function togglePedHighlight(key) {
const els = pedTree.querySelectorAll("[data-dogkey]");
let lit = false;
els.forEach((c) => {
if (c.dataset.dogkey === key && c.classList.contains("ped-lit")) lit = true;
});
els.forEach((c) => c.classList.remove("ped-lit"));
if (!lit) els.forEach((c) => { if (c.dataset.dogkey === key) c.classList.add("ped-lit"); });
}
function buildPedNode(nodes, pos, depth) {
const n = nodes[String(pos)];
const hasSire = !!nodes[String(pos * 2)];
const hasDam = !!nodes[String(pos * 2 + 1)];
if (!n && !hasSire && !hasDam) return null;
const li = document.createElement("li");
const card = document.createElement("div");
card.className = "ped-card";
const nameEl = document.createElement("div");
nameEl.className = "ped-name";
nameEl.textContent = n ? (n.name || "(unnamed)") : "Unknown";
if (!n) nameEl.classList.add("ped-unknown");
card.append(nameEl);
if (n && n.titles) {
const t = document.createElement("div");
t.className = "ped-titles";
t.textContent = n.titles;
card.append(t);
}
if (n && n.reg) {
const r = document.createElement("div");
r.className = "ped-reg";
r.textContent = n.reg;
card.append(r);
}
// Mark dogs that fill more than one position (pedigree collapse). A ×N badge
// shows how many times, a stable colour ties the copies together, and tapping
// the card lights up every place this dog appears.
const key = dogKey(n);
if (key && pedRepeat[key] > 1) {
card.classList.add("ped-repeat");
card.dataset.dogkey = key;
card.style.setProperty("--repeat-hue", hueFor(key));
const badge = document.createElement("span");
badge.className = "ped-repeat-badge";
badge.textContent = "×" + pedRepeat[key];
badge.title = "Appears " + pedRepeat[key] + " times in this pedigree — tap to highlight them all";
card.append(badge);
card.addEventListener("click", (e) => {
if (e.target.closest(".ped-toggle")) return; // let the expander do its job
togglePedHighlight(key);
});
}
li.append(card);
if (hasSire || hasDam) {
const kids = document.createElement("ul");
const s = buildPedNode(nodes, pos * 2, depth + 1);
const d = buildPedNode(nodes, pos * 2 + 1, depth + 1);
if (s) { s.classList.add("ped-sire"); kids.append(s); }
if (d) { d.classList.add("ped-dam"); kids.append(d); }
const collapsed = depth >= PED_OPEN_DEPTH;
if (collapsed) li.classList.add("collapsed");
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "ped-toggle";
toggle.setAttribute("aria-label", "Toggle ancestors");
toggle.textContent = collapsed ? "+" : "";
toggle.addEventListener("click", () => {
const nowCollapsed = li.classList.toggle("collapsed");
toggle.textContent = nowCollapsed ? "+" : "";
});
card.prepend(toggle);
li.append(kids);
}
return li;
}
// ---------- changelog dialog ----------
// Shows the *loaded* build's full changelog: the plain URL is served
// cache-first by the controlling service worker, so the list always matches
// the version actually running (the update banner handles what's newer).
const changelogDialog = document.getElementById("changelog-dialog");
const changelogList = document.getElementById("changelog-list");
const changelogEmpty = document.getElementById("changelog-empty");
function formatChangelogDate(iso) {
const [y, m, d] = (iso || "").split("-").map(Number);
if (!y || !m || !d) return iso || "";
return new Date(y, m - 1, d).toLocaleDateString(undefined, {
year: "numeric", month: "short", day: "numeric",
});
}
document.getElementById("changelog-btn").addEventListener("click", async () => {
let entries = [];
try {
const res = await fetch("changelog.json");
if (res.ok) {
const body = await res.json();
if (Array.isArray(body)) entries = body.filter(e => e && e.text);
}
} catch { /* fall through to empty state */ }
changelogList.innerHTML = "";
changelogEmpty.hidden = entries.length > 0;
let lastDate = null;
for (const e of entries) {
if (e.date !== lastDate) {
lastDate = e.date;
const dt = document.createElement("li");
dt.className = "changelog-date";
dt.textContent = formatChangelogDate(e.date);
changelogList.appendChild(dt);
}
const li = document.createElement("li");
li.className = "changelog-entry";
li.textContent = e.text;
changelogList.appendChild(li);
}
changelogDialog.showModal();
});
// ---------- exercise dialog (add / edit a training exercise) ----------
const exerciseDialog = document.getElementById("exercise-dialog");
const exerciseForm = document.getElementById("exercise-form");
const exerciseTitle = document.getElementById("exercise-title");
const exerciseName = document.getElementById("exercise-name");
const exerciseNote = document.getElementById("exercise-note");
const exerciseDelete = document.getElementById("exercise-delete");
let editingExerciseId = null;
function openExerciseDialog(ex) {
editingExerciseId = ex ? ex.id : null;
exerciseTitle.textContent = ex ? "Edit exercise" : "Add exercise";
exerciseName.value = ex ? ex.name : "";
exerciseNote.value = ex ? (ex.note || "") : "";
exerciseDelete.hidden = !ex;
exerciseDialog.showModal();
setTimeout(() => exerciseName.focus(), 50);
}
document.getElementById("exercise-add").addEventListener("click", () => openExerciseDialog(null));
exerciseForm.querySelector('button[value="save"]').addEventListener("click", (e) => {
e.preventDefault();
const name = exerciseName.value.trim();
if (!name) { alert("Give the exercise a name."); return; }
const note = exerciseNote.value.trim();
if (editingExerciseId) updateExercise(editingExerciseId, { name, note });
else addExercise(name, note);
editingExerciseId = null;
exerciseDialog.close();
});
exerciseForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => {
e.preventDefault();
editingExerciseId = null;
exerciseDialog.close();
});
exerciseDelete.addEventListener("click", (e) => {
e.preventDefault();
if (editingExerciseId && confirm("Delete this exercise? Logged sessions stay in history.")) {
deleteExercise(editingExerciseId);
}
editingExerciseId = null;
exerciseDialog.close();
});
// ---------- delete account ----------
const deleteAccountDialog = document.getElementById("delete-account-dialog");
const deleteAccountPassword = document.getElementById("delete-account-password");
const deleteAccountError = document.getElementById("delete-account-error");
const deleteAccountConfirm = document.getElementById("delete-account-confirm");
document.getElementById("delete-account-btn").addEventListener("click", () => {
settingsDialog.close();
deleteAccountPassword.value = "";
deleteAccountError.hidden = true;
deleteAccountDialog.showModal();
setTimeout(() => deleteAccountPassword.focus(), 50);
});
deleteAccountConfirm.addEventListener("click", async () => {
deleteAccountError.hidden = true;
deleteAccountConfirm.disabled = true;
try {
const res = await fetch("api/me", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: deleteAccountPassword.value }),
});
if (!res.ok) {
const msg = (await res.text()).trim();
throw new Error(res.status === 401 ? "Incorrect password" : (msg || `HTTP ${res.status}`));
}
// Account is gone server-side. Wipe this user's local cache before the
// reload drops us back on the login screen.
try {
localStorage.removeItem(eventsKey());
localStorage.removeItem(configKey());
localStorage.removeItem(exercisesKey());
} catch { /* ignore */ }
clearUser();
deleteAccountDialog.close();
location.reload();
} catch (err) {
deleteAccountError.textContent = err.message || "Something went wrong";
deleteAccountError.hidden = false;
} finally {
deleteAccountConfirm.disabled = false;
}
});
// ---------- quick-log snackbar ----------
// A single tap on a quick-action logs the event at the current instant, then
// shows a brief snackbar to undo it or add detail — so the two-tap dialog flow
// is never required for a plain pee/poo/meal/sleep boundary. Weigh-ins still
// open the dialog since they need a value.
const snackbar = document.getElementById("snackbar");
const snackbarMsg = document.getElementById("snackbar-msg");
const snackbarUndo = document.getElementById("snackbar-undo");
const snackbarNote = document.getElementById("snackbar-note");
let snackbarEvent = null;
let snackbarTimer = null;
function hideSnackbar() {
clearTimeout(snackbarTimer);
snackbarTimer = null;
snackbarEvent = null;
snackbar.classList.remove("show");
snackbar.hidden = true;
}
function showSnackbar(msg, ev) {
snackbarEvent = ev;
snackbarMsg.textContent = msg;
snackbar.hidden = false;
void snackbar.offsetWidth; // reflow so the fade-in transition runs
snackbar.classList.add("show");
clearTimeout(snackbarTimer);
snackbarTimer = setTimeout(hideSnackbar, 5000);
}
function quickLog(type, originEl) {
const ev = addEvent(type, "", Date.now());
showSnackbar(`${EVENT_LABELS[type]} logged`, ev);
if (type === "pee" || type === "poo") pottyConfetti(type, originEl);
}
// A little burst of 💧/💩 from the tapped button when a pee/poo is logged.
// Pure DOM + CSS; particles remove themselves when their animation ends.
// Skipped when disabled in Settings or the user prefers reduced motion.
// Device-local preference (like the theme), on by default.
const CONFETTI_KEY = "puppy-tracker:confetti:v1";
function confettiEnabled() {
try { return localStorage.getItem(CONFETTI_KEY) !== "off"; } catch { return true; }
}
function setConfettiEnabled(on) {
try { localStorage.setItem(CONFETTI_KEY, on ? "on" : "off"); } catch { /* ignore */ }
}
let confettiLayerEl = null;
function confettiLayer() {
if (!confettiLayerEl) {
confettiLayerEl = document.createElement("div");
confettiLayerEl.id = "confetti-layer";
document.body.appendChild(confettiLayerEl);
}
return confettiLayerEl;
}
function pottyConfetti(type, originEl) {
if (!confettiEnabled()) return;
if (window.matchMedia && matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const emoji = type === "poo" ? "💩" : "💧";
const layer = confettiLayer();
const r = originEl && originEl.getBoundingClientRect
? originEl.getBoundingClientRect()
: { left: innerWidth / 2, top: innerHeight / 2, width: 0, height: 0 };
const ox = r.left + r.width / 2, oy = r.top + r.height / 2;
for (let i = 0; i < 16; i++) {
const piece = document.createElement("span");
piece.className = "confetti-piece";
piece.textContent = emoji;
const ang = Math.random() * Math.PI * 2;
const dist = 60 + Math.random() * 130;
piece.style.left = `${ox}px`;
piece.style.top = `${oy}px`;
piece.style.setProperty("--dx", `${Math.round(Math.cos(ang) * dist)}px`);
piece.style.setProperty("--dy", `${Math.round(Math.sin(ang) * dist - 50)}px`); // bias upward
piece.style.setProperty("--rot", `${Math.round(Math.random() * 720 - 360)}deg`);
piece.style.fontSize = `${Math.round(14 + Math.random() * 16)}px`;
piece.style.animationDuration = `${Math.round(900 + Math.random() * 600)}ms`;
piece.addEventListener("animationend", () => piece.remove());
layer.appendChild(piece);
}
}
snackbarUndo.addEventListener("click", () => {
if (snackbarEvent) deleteEvent(snackbarEvent.id);
hideSnackbar();
});
snackbarNote.addEventListener("click", () => {
const ev = snackbarEvent;
hideSnackbar();
if (ev) openEditDialog(ev);
});
// ---------- collapsible panels ----------
// Each main section carrying a data-panel key can be folded by clicking its
// heading; collapsed keys are remembered across reloads. State is device-global
// (like the theme), so a single non-namespaced key is fine — it's not per-user
// data. Only collapsed panels are stored, so newly added panels default open.
const PANELS_KEY = "puppy-tracker:panels:v1";
function loadPanelState() {
try {
const s = JSON.parse(localStorage.getItem(PANELS_KEY));
return s && typeof s === "object" ? s : {};
} catch { return {}; }
}
function savePanelState(state) {
try { localStorage.setItem(PANELS_KEY, JSON.stringify(state)); } catch { /* ignore */ }
}
function initPanels() {
const state = loadPanelState();
document.querySelectorAll("main section[data-panel]").forEach(section => {
const key = section.dataset.panel;
const h2 = section.querySelector(":scope > h2");
if (!h2) return;
section.classList.add("collapsible");
const collapsed = !!state[key];
section.classList.toggle("collapsed", collapsed);
h2.setAttribute("role", "button");
h2.setAttribute("tabindex", "0");
h2.setAttribute("aria-expanded", String(!collapsed));
const toggle = () => {
const nowCollapsed = section.classList.toggle("collapsed");
h2.setAttribute("aria-expanded", String(!nowCollapsed));
const s = loadPanelState();
if (nowCollapsed) s[key] = true; else delete s[key];
savePanelState(s);
};
h2.addEventListener("click", toggle);
h2.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); }
});
});
}
initPanels();
// ---------- wiring ----------
document.querySelectorAll("button.action").forEach(btn => {
btn.addEventListener("click", () => {
const type = btn.dataset.type;
// Weigh-ins need a typed value and meals ask for grams, so those two
// keep the full dialog.
if (type === "weight" || type === "eat") { openNoteDialog(type); return; }
quickLog(type, btn);
});
});
document.querySelectorAll(".chart-days-picker button").forEach(b => {
b.addEventListener("click", () => setChartDays(Number(b.dataset.days)));
});
// Swap the timer pill in/out of the frozen bar as the big card scrolls
// past. rAF-throttled: scroll events fire far more often than we can paint.
{
let queued = false;
const onScroll = () => {
if (queued) return;
queued = true;
requestAnimationFrame(() => { queued = false; updateBarClockMode(); });
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll, { passive: true });
}
dayPicker.value = ymd(new Date());
dayPicker.addEventListener("change", render);
// The face opens the hidden input's native picker (iOS 16+ has showPicker;
// focus() is the fallback and is what pops the picker on older iOS anyway).
document.getElementById("day-date-face").addEventListener("click", () => {
try {
if (typeof dayPicker.showPicker === "function") dayPicker.showPicker();
else dayPicker.focus();
} catch {
dayPicker.focus();
}
});
function shiftSelectedDay(days) {
const d = selectedDay();
d.setDate(d.getDate() + days);
dayPicker.value = ymd(d);
render();
}
document.getElementById("day-prev").addEventListener("click", () => shiftSelectedDay(-1));
document.getElementById("day-next").addEventListener("click", () => shiftSelectedDay(+1));
document.getElementById("day-today").addEventListener("click", () => {
dayPicker.value = ymd(new Date());
render();
});
// Clicking the status pill forces an immediate sync.
statusEl.style.cursor = "pointer";
statusEl.title = "Click to sync now";
statusEl.addEventListener("click", () => { clearTimeout(syncTimer); sync(); });
window.addEventListener("online", () => { setStatus(); sync(); syncConfig(); });
window.addEventListener("offline", () => setStatus());
// Service worker (independent of auth). The worker no longer auto-activates a
// new build; instead we detect the waiting worker and let the user choose when
// to swap onto fresh assets, so a long-open tab isn't left running stale JS.
if ("serviceWorker" in navigator) {
const updateBanner = document.getElementById("update-banner");
const updateReload = document.getElementById("update-reload");
const updateLater = document.getElementById("update-later");
let waitingWorker = null;
// Whether there was already a controlling worker when the page loaded. On a
// brand-new install there isn't, and clients.claim() fires an initial
// controllerchange we must NOT reload on (there's nothing to refresh to).
const hadController = !!navigator.serviceWorker.controller;
let reloadRequested = false; // user pressed Reload → controllerchange reloads
let refreshing = false; // guard against a reload loop
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (refreshing) return;
if (!hadController && !reloadRequested) return; // first-install claim
refreshing = true;
location.reload();
});
// What the waiting build changes compared to the running one. The plain
// URL is answered by the *old* controlling worker cache-first, i.e. the
// loaded build's changelog; the cache-busting query misses every SW cache
// and hits the network, i.e. the new build's changelog. Entries in the
// fresh copy that the cached one lacks are exactly "new since this build".
async function changelogDiff() {
const load = async (url) => {
try {
const res = await fetch(url);
if (!res.ok) return null;
const body = await res.json();
return Array.isArray(body) ? body : null;
} catch {
return null;
}
};
const current = await load("changelog.json");
const fresh = await load(`changelog.json?v=${Date.now()}`);
if (!fresh) return [];
const seen = new Set((current || []).map(e => e && e.text));
return fresh.filter(e => e && e.text && !seen.has(e.text));
}
function showUpdateBanner(worker) {
waitingWorker = worker;
updateBanner.hidden = false;
const list = document.getElementById("update-changelog");
list.hidden = true;
list.innerHTML = "";
changelogDiff().then(entries => {
if (entries.length === 0) return;
for (const e of entries.slice(0, 6)) {
const li = document.createElement("li");
li.textContent = e.text;
list.appendChild(li);
}
list.hidden = false;
});
}
updateReload.addEventListener("click", () => {
reloadRequested = true;
updateBanner.hidden = true;
// Tell the waiting worker to activate; controllerchange then reloads us.
if (waitingWorker) waitingWorker.postMessage({ type: "SKIP_WAITING" });
});
// "Later" just dismisses; the next update (or reload) surfaces it again.
updateLater.addEventListener("click", () => { updateBanner.hidden = true; });
// Only prompt when a *previous* worker was already in control — that check
// is what suppresses the banner on the very first install.
function trackInstalling(worker) {
worker.addEventListener("statechange", () => {
if (worker.state === "installed" && navigator.serviceWorker.controller) {
showUpdateBanner(worker);
}
});
}
function watchForUpdate(reg) {
// A worker may already be waiting from a previous session's update.
if (reg.waiting && navigator.serviceWorker.controller) showUpdateBanner(reg.waiting);
reg.addEventListener("updatefound", () => {
if (reg.installing) trackInstalling(reg.installing);
});
// Browsers only auto-check for a new worker on navigation, so also poll
// hourly and whenever the tab becomes visible again.
setInterval(() => reg.update().catch(() => {}), 60 * 60 * 1000);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") reg.update().catch(() => {});
});
}
window.addEventListener("load", () => {
navigator.serviceWorker.register("sw.js")
.then(watchForUpdate)
.catch(err => console.error("SW", err));
});
}
// ---------- auth gate ----------
// The tracker only boots once we know who the user is. startApp() does the
// first paint, initial sync, and starts the periodic timers — guarded so it
// runs at most once per page load even if login and the session check race.
const authScreen = document.getElementById("auth-screen");
const appEl = document.getElementById("app");
const authForm = document.getElementById("auth-form");
const authEmail = document.getElementById("auth-email");
const authPassword = document.getElementById("auth-password");
const authInvite = document.getElementById("auth-invite");
const authInviteFld= document.getElementById("auth-invite-field");
const authError = document.getElementById("auth-error");
const authSubmit = document.getElementById("auth-submit");
const authSub = document.getElementById("auth-sub");
const authToggleBtn= document.getElementById("auth-toggle-btn");
const authToggleTxt= document.getElementById("auth-toggle-text");
let authMode = "login"; // or "register"
let appStarted = false;
// Remember who was last signed in so an offline reload can still open the
// app against the cached data instead of stranding the user on a login screen
// it can't verify. Cleared only on an explicit logout or a server 401.
const SESSION_KEY = "puppy-tracker:session:v1";
function setUser(u) {
currentUser = u;
try { localStorage.setItem(SESSION_KEY, JSON.stringify(u)); } catch { /* ignore */ }
}
function clearUser() {
currentUser = null;
try { localStorage.removeItem(SESSION_KEY); } catch { /* ignore */ }
}
function cachedUser() {
try {
const u = JSON.parse(localStorage.getItem(SESSION_KEY));
return u && u.id ? u : null;
} catch { return null; }
}
function startApp() {
if (appStarted) return;
appStarted = true;
// Live-update relative times and (eventually) sync status text.
setInterval(() => {
const evs = live();
renderHeader();
renderBigClock(evs);
renderActionHints(evs);
renderStats(evs);
renderLasts(evs);
renderTiming(evs);
renderSleepWindows(evs);
renderWakeWindows(evs);
renderWeekly(evs);
renderSleepTimeline(evs);
renderSleepTrend(evs);
renderHourHeatmap(evs);
renderTraining(evs);
if (navigator.onLine && !syncing) setStatus();
}, 60_000);
setInterval(tickBigClock, 1000);
setInterval(sync, SYNC_POLL_MS);
setInterval(syncConfig, SYNC_POLL_MS);
setStatus();
render();
refreshPedigreeButton();
sync();
syncConfig();
}
function showAuth() {
appEl.hidden = true;
authScreen.hidden = false;
}
function showApp() {
authScreen.hidden = true;
appEl.hidden = false;
}
// Called when the server reports we're no longer authenticated (expired or
// revoked session). Drop back to the login screen without wiping the local
// cache — logging back in as the same user picks it straight back up.
function handleLoggedOut() {
clearUser();
setStatus("offline");
showAuth();
}
function renderAuthMode() {
const reg = authMode === "register";
authInviteFld.hidden = !reg;
authInvite.required = reg;
authSubmit.textContent = reg ? "Create account" : "Sign in";
authSub.textContent = reg ? "Create your account" : "Sign in to continue";
authToggleTxt.textContent = reg ? "Already have an account?" : "No account yet?";
authToggleBtn.textContent = reg ? "Sign in" : "Create one";
authPassword.autocomplete = reg ? "new-password" : "current-password";
authError.hidden = true;
}
authToggleBtn.addEventListener("click", () => {
authMode = authMode === "login" ? "register" : "login";
renderAuthMode();
});
authForm.addEventListener("submit", async (e) => {
e.preventDefault();
authError.hidden = true;
authSubmit.disabled = true;
const body = { email: authEmail.value.trim(), password: authPassword.value };
if (authMode === "register") body.invite = authInvite.value.trim();
try {
const res = await fetch(authMode === "register" ? "api/register" : "api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const msg = (await res.text()).trim();
throw new Error(msg || `HTTP ${res.status}`);
}
setUser(await res.json());
authForm.reset();
showApp();
startApp();
} catch (err) {
authError.textContent = err.message || "Something went wrong";
authError.hidden = false;
} finally {
authSubmit.disabled = false;
}
});
document.getElementById("logout-btn").addEventListener("click", async () => {
try { await fetch("api/logout", { method: "POST" }); } catch { /* ignore */ }
clearUser();
// Full reload is the simplest way to clear in-memory app state and timers.
location.reload();
});
// On load, ask the server who we are. A valid session boots straight into the
// app. A 401 means log in. A network failure (offline PWA) falls back to the
// last cached session so offline data stays reachable — a later sync will
// 401 and bounce to login if that session has actually gone stale.
(async function bootstrap() {
try {
const res = await fetch("api/me");
if (res.ok) {
setUser(await res.json());
showApp();
startApp();
return;
}
clearUser(); // explicit 401/403: session is gone
} catch {
const cached = cachedUser();
if (cached) {
currentUser = cached;
showApp();
startApp();
return;
}
}
renderAuthMode();
showAuth();
})();
})();