(() => {
"use strict";
// Storage is namespaced per account so two people sharing a browser (or one
// person logging out and back in as someone else) never see each other's
// cached events/profile. currentUser is set by the auth gate before the app
// boots, so these are only ever called once a user is known.
let currentUser = null;
const eventsKey = () => `puppy-tracker:${currentUser.id}:events:v1`;
const configKey = () => `puppy-tracker:${currentUser.id}:config:v1`;
const exercisesKey = () => `puppy-tracker:${currentUser.id}:exercises:v1`;
const SYNC_URL = "api/events/sync";
const SYNC_DEBOUNCE_MS = 1200;
const SYNC_POLL_MS = 60_000;
const EVENT_LABELS = {
"sleep-start": "Sleep start",
"sleep-end": "Sleep end",
"eat": "Ate",
"pee": "Pee",
"poo": "Poo",
"weight": "Weigh-in",
"training": "Training",
};
// ---------- photos: IndexedDB store ----------
// Schema: object store `photos` keyed by `id`, value `{id, blob, uploaded}`.
// - Photos taken locally are written with uploaded:false and queued for upload.
// - Photos fetched from server are cached with uploaded:true (server is source-of-truth).
const PHOTO_DB = "puppy-tracker";
const PHOTO_STORE = "photos";
let photoDB = null;
function openPhotoDB() {
if (photoDB) return Promise.resolve(photoDB);
return new Promise((resolve, reject) => {
const req = indexedDB.open(PHOTO_DB, 1);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(PHOTO_STORE)) {
db.createObjectStore(PHOTO_STORE, { keyPath: "id" });
}
};
req.onsuccess = () => { photoDB = req.result; resolve(photoDB); };
req.onerror = () => reject(req.error);
});
}
async function putPhoto(id, blob, uploaded) {
const db = await openPhotoDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(PHOTO_STORE, "readwrite");
tx.objectStore(PHOTO_STORE).put({ id, blob, uploaded });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function getPhoto(id) {
const db = await openPhotoDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(PHOTO_STORE, "readonly");
const req = tx.objectStore(PHOTO_STORE).get(id);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error);
});
}
async function getAllPhotos() {
const db = await openPhotoDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(PHOTO_STORE, "readonly");
const req = tx.objectStore(PHOTO_STORE).getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = () => reject(req.error);
});
}
async function deletePhoto(id) {
const db = await openPhotoDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(PHOTO_STORE, "readwrite");
tx.objectStore(PHOTO_STORE).delete(id);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
// Resize an image File to a max dimension and return a JPEG Blob.
function resizeImage(file, maxDim = 1600, quality = 0.85) {
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
const scale = Math.min(1, maxDim / Math.max(img.naturalWidth, img.naturalHeight));
const w = Math.max(1, Math.round(img.naturalWidth * scale));
const h = Math.max(1, Math.round(img.naturalHeight * scale));
const canvas = document.createElement("canvas");
canvas.width = w; canvas.height = h;
canvas.getContext("2d").drawImage(img, 0, 0, w, h);
canvas.toBlob(
(blob) => blob ? resolve(blob) : reject(new Error("toBlob returned null")),
"image/jpeg",
quality,
);
};
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("image load failed")); };
img.src = url;
});
}
async function uploadPhotoBlob(id, blob) {
const form = new FormData();
form.append("id", id);
form.append("file", blob, `${id}.jpg`);
const res = await fetch("api/photos", { method: "POST", body: form });
if (!res.ok) throw new Error(`upload HTTP ${res.status}`);
}
async function syncPhotos() {
if (!navigator.onLine) return;
const all = await getAllPhotos();
for (const p of all) {
if (p.uploaded) continue;
try {
await uploadPhotoBlob(p.id, p.blob);
await putPhoto(p.id, p.blob, true);
} catch (err) {
console.warn("photo upload failed", p.id, err);
// leave queued; next sync will retry
}
}
}
// Resolve a photoId to a displayable URL. Prefers local cache; falls back
// to the server URL (the SW will cache it transparently). Returns null if
// we have nothing and the server doesn't either.
const photoURLCache = new Map(); // id -> object URL (lifetime = page session)
async function photoSrc(id) {
if (!id) return null;
if (photoURLCache.has(id)) return photoURLCache.get(id);
const local = await getPhoto(id);
if (local && local.blob) {
const url = URL.createObjectURL(local.blob);
photoURLCache.set(id, url);
return url;
}
// Fall back to server URL — let the browser/SW cache it. Also opportunistically
// pull it into IndexedDB so cold-offline-loads still see it.
if (navigator.onLine) {
try {
const res = await fetch(`api/photos/${encodeURIComponent(id)}`);
if (res.ok) {
const blob = await res.blob();
await putPhoto(id, blob, true);
const url = URL.createObjectURL(blob);
photoURLCache.set(id, url);
return url;
}
} catch (_) { /* fall through */ }
}
return null;
}
// crypto.randomUUID() is only exposed in secure contexts (HTTPS / localhost),
// so over plain HTTP on the LAN we need a fallback. crypto.getRandomValues
// is available everywhere; Math.random is the last resort.
function uuid() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") {
const b = new Uint8Array(16);
crypto.getRandomValues(b);
b[6] = (b[6] & 0x0f) | 0x40; // version 4
b[8] = (b[8] & 0x3f) | 0x80; // variant 10
const h = [...b].map(x => x.toString(16).padStart(2, "0"));
return `${h.slice(0,4).join("")}-${h.slice(4,6).join("")}-${h.slice(6,8).join("")}-${h.slice(8,10).join("")}-${h.slice(10,16).join("")}`;
}
return `x-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`;
}
// ---------- storage ----------
// Internal "raw" storage includes deleted tombstones; UI uses live().
function loadAll() {
try {
const raw = localStorage.getItem(eventsKey());
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
// Backfill updatedAt for events written by an older client version.
return parsed.map(e => ({
...e,
updatedAt: Number.isFinite(e.updatedAt) ? e.updatedAt : (e.at || Date.now()),
}));
} catch {
return [];
}
}
function saveAll(events) {
localStorage.setItem(eventsKey(), JSON.stringify(events));
}
function live() {
return loadAll().filter(e => !e.deleted);
}
// ---------- config (puppy name + birthday) ----------
// The shared profile lives on the host so every client sees the same values.
// localStorage is just a cache for instant paint + offline; the server is the
// source of truth, reconciled by last-write-wins on updatedAt (see syncConfig).
function loadConfig() {
try {
const parsed = JSON.parse(localStorage.getItem(configKey()));
if (!parsed || typeof parsed !== "object") return { name: "", birthday: "", pedigreeId: "", updatedAt: 0 };
return {
name: parsed.name || "",
birthday: parsed.birthday || "",
pedigreeId: parsed.pedigreeId || "",
updatedAt: Number.isFinite(parsed.updatedAt) ? parsed.updatedAt : 0,
};
} catch {
return { name: "", birthday: "", pedigreeId: "", updatedAt: 0 };
}
}
function saveConfig(cfg) {
localStorage.setItem(configKey(), JSON.stringify(cfg));
}
// Reconcile a local and a server profile. Name/birthday/updatedAt are plain
// last-write-wins by timestamp. The pedigree id is sticky: a non-empty value
// never loses to an empty one — so it can't be dropped by a clock race between
// devices — and when both are set the newer profile's id wins with the rest.
// (The server merge mirrors this, so a set id is only changed, never cleared,
// by sync.)
function reconcileConfig(local, server) {
const base = server.updatedAt >= local.updatedAt ? server : local;
return {
name: base.name,
birthday: base.birthday,
pedigreeId: (local.pedigreeId && server.pedigreeId)
? base.pedigreeId
: (local.pedigreeId || server.pedigreeId),
updatedAt: base.updatedAt,
};
}
function sameConfig(a, b) {
return a.name === b.name && a.birthday === b.birthday
&& a.pedigreeId === b.pedigreeId && a.updatedAt === b.updatedAt;
}
// Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday,
// measured at `at` (defaults to now — pass a weigh-in's timestamp for its age
// at that point). Returns null for a missing/invalid birthday or a date before it.
function ageParts(birthday, at) {
if (!birthday) return null;
const [y, mo, d] = birthday.split("-").map(Number);
if (!y || !mo || !d) return null;
const birth = startOfDay(new Date(y, mo - 1, d));
const ref = startOfDay(new Date(Number.isFinite(at) ? at : Date.now()));
if (birth > ref) return null;
const days = Math.floor((ref - birth) / 86_400_000);
const weeks = Math.floor(days / 7);
let months = (ref.getFullYear() - birth.getFullYear()) * 12 +
(ref.getMonth() - birth.getMonth());
if (ref.getDate() < birth.getDate()) months--;
if (months < 0) months = 0;
// Whole weeks left over after the calendar months (so "N months and M weeks").
const anchor = startOfDay(new Date(birth.getFullYear(),
birth.getMonth() + months, birth.getDate()));
const remWeeks = Math.floor((ref - anchor) / 86_400_000 / 7);
return { days, weeks, months, remWeeks };
}
// e.g. "16 weeks (3 months and 3 weeks) old". Weeks are the headline unit while
// the puppy is young; once it's 4+ months the week count is just noise, so we
// drop it and lead with the months form ("4 months and 1 week old").
function formatAge(birthday, at) {
const a = ageParts(birthday, at);
if (!a) return "";
const wk = `${a.weeks} week${a.weeks === 1 ? "" : "s"}`;
if (a.months < 1) return `${wk} old`;
const mo = `${a.months} month${a.months === 1 ? "" : "s"}`;
const rw = a.remWeeks > 0
? ` and ${a.remWeeks} week${a.remWeeks === 1 ? "" : "s"}` : "";
const monthPhrase = `${mo}${rw}`;
if (a.months >= 4) return `${monthPhrase} old`;
return `${wk} (${monthPhrase}) old`;
}
// Compact one-line age for space-tight spots (header): keeps the weeks + months
// breakdown but abbreviated — "16 wk · 3 mo 3 wk", "5 mo 2 wk", "3 wk".
function formatAgeShort(birthday, at) {
const a = ageParts(birthday, at);
if (!a) return "";
const wk = `${a.weeks} wk`;
if (a.months < 1) return wk;
const mo = `${a.months} mo${a.remWeeks > 0 ? ` ${a.remWeeks} wk` : ""}`;
if (a.months >= 4) return mo;
return `${wk} · ${mo}`;
}
// Weeks-only age for dense lists (weight rows): "16 wk".
function formatAgeWeeks(birthday, at) {
const a = ageParts(birthday, at);
return a ? `${a.weeks} wk` : "";
}
// Rough age-based daily sleep goal (hours), for the trend chart's goal band:
// 0–8 weeks 20–22h, 8–16 weeks 18–20h, then 16–18h to 6 months and 14–16h to
// 12 months. No birthday (or an adult dog) → no goal.
function sleepTargetFor(birthday) {
const a = ageParts(birthday);
if (!a) return null;
if (a.weeks < 8) return { lo: 20, hi: 22, label: "0–8 weeks" };
if (a.weeks < 16) return { lo: 18, hi: 20, label: "8–16 weeks" };
if (a.months < 6) return { lo: 16, hi: 18, label: "4–6 months" };
if (a.months < 12) return { lo: 14, hi: 16, label: "6–12 months" };
return null;
}
function addEvent(type, note, at, { photoId, weight, grams, exerciseId } = {}) {
const events = loadAll();
const now = Date.now();
const ev = {
id: uuid(),
type,
at: Number.isFinite(at) ? at : now,
note: note || "",
photoId: photoId || "",
weight: Number.isFinite(weight) ? weight : undefined,
grams: Number.isFinite(grams) ? grams : undefined,
exerciseId: exerciseId || "",
updatedAt: now,
};
events.push(ev);
saveAll(events);
scheduleSync();
render();
return ev;
}
function updateEvent(id, patch) {
const events = loadAll().map(e =>
e.id === id ? { ...e, ...patch, updatedAt: Date.now() } : e
);
saveAll(events);
scheduleSync();
render();
}
function deleteEvent(id) {
// Soft-delete so the deletion can propagate via sync.
const events = loadAll().map(e =>
e.id === id ? { ...e, deleted: true, updatedAt: Date.now() } : e
);
saveAll(events);
scheduleSync();
render();
}
// ---------- exercises (training definitions) ----------
// User-defined training exercises ("Sit", "Leash walking", …), each with
// optional instruction text. They sync like events: UUID ids, last-write-wins
// on updatedAt, tombstoned deletes — but as their own collection, since they
// are definitions rather than things that happened at a point in time.
function loadExercises() {
try {
const parsed = JSON.parse(localStorage.getItem(exercisesKey()));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
function saveExercises(list) {
localStorage.setItem(exercisesKey(), JSON.stringify(list));
}
function liveExercises() {
return loadExercises()
.filter(x => !x.deleted)
.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
}
function addExercise(name, note) {
const list = loadExercises();
list.push({ id: uuid(), name, note: note || "", updatedAt: Date.now() });
saveExercises(list);
scheduleSync();
render();
}
function updateExercise(id, patch) {
saveExercises(loadExercises().map(x =>
x.id === id ? { ...x, ...patch, updatedAt: Date.now() } : x
));
scheduleSync();
render();
}
function deleteExercise(id) {
// Tombstone, like events. Logged training sessions keep referencing the id;
// name lookups still resolve through the tombstone (see exerciseNames).
saveExercises(loadExercises().map(x =>
x.id === id ? { ...x, deleted: true, updatedAt: Date.now() } : x
));
scheduleSync();
render();
}
// id -> name across *all* exercises, tombstones included, so history rows for
// a deleted exercise still show its name instead of a generic "Training".
function exerciseNames() {
return new Map(loadExercises().map(x => [x.id, x.name]));
}
// ---------- helpers ----------
function ymd(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function startOfDay(date) {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
}
function endOfDay(date) {
const d = new Date(date);
d.setHours(23, 59, 59, 999);
return d;
}
function formatTime(ts) {
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
}
const pad2 = n => String(n).padStart(2, "0");
function toDateInput(ts) {
const d = new Date(ts);
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
function toTimeInput(ts) {
const d = new Date(ts);
return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
function fromDateTimeInputs(dateStr, timeStr) {
if (!dateStr || !timeStr) return NaN;
const [y, mo, d] = dateStr.split("-").map(Number);
const [h, mi] = timeStr.split(":").map(Number);
const t = new Date(y, mo - 1, d, h, mi).getTime();
return Number.isFinite(t) ? t : NaN;
}
function formatDuration(ms) {
if (ms <= 0) return "0m";
const totalMin = Math.round(ms / 60000);
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
if (h === 0) return `${m}m`;
return `${h}h ${m}m`;
}
// kg with up to 2 decimals, trailing zeros stripped (5.20 -> "5.2 kg").
function formatWeight(kg) {
return `${Math.round(kg * 100) / 100} kg`;
}
function formatRelative(ts) {
if (!ts) return "—";
const diff = Date.now() - ts;
if (diff < 60_000) return "just now";
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
if (diff < 86_400_000) {
const h = Math.floor(diff / 3_600_000);
const m = Math.floor((diff % 3_600_000) / 60_000);
return m ? `${h}h ${m}m ago` : `${h}h ago`;
}
const d = Math.floor(diff / 86_400_000);
return `${d}d ago`;
}
function eventsForDay(events, day) {
const from = startOfDay(day).getTime();
const to = endOfDay(day).getTime();
return events
.filter(e => e.at >= from && e.at <= to)
.sort((a, b) => a.at - b.at);
}
// Calculate sleep time within a given day. Sleep intervals are derived from
// consecutive sleep-start → sleep-end pairs across the whole event log, and
// clipped to the day window. Unmatched sleep-start = ongoing sleep (clipped to now).
function sleepMsInRange(events, fromTs, toTs) {
const sorted = [...events].sort((a, b) => a.at - b.at);
const intervals = [];
let openStart = null;
for (const e of sorted) {
if (e.type === "sleep-start" && openStart === null) {
openStart = e.at;
} else if (e.type === "sleep-end" && openStart !== null) {
intervals.push([openStart, e.at]);
openStart = null;
}
}
if (openStart !== null) intervals.push([openStart, Date.now()]);
let total = 0;
for (const [start, end] of intervals) {
const s = Math.max(start, fromTs);
const ePt = Math.min(end, toTs);
if (ePt > s) total += ePt - s;
}
return total;
}
// Current state derived from the *latest* sleep event. The single source of
// truth for both the big clock and the "Currently" row. For two boundary
// events sharing the same `at` (common once "now" events are minute-floored),
// the one logged later (higher updatedAt) wins, so the tie resolves the same
// way everywhere it's read.
function currentSleepState(events) {
let latest = null;
for (const e of events) {
if (e.type !== "sleep-start" && e.type !== "sleep-end") continue;
if (!latest ||
e.at > latest.at ||
(e.at === latest.at && (e.updatedAt || 0) > (latest.updatedAt || 0))) {
latest = e;
}
}
if (!latest) return { state: null, since: 0 };
return {
state: latest.type === "sleep-start" ? "asleep" : "awake",
since: latest.at,
};
}
function formatCounter(ms) {
if (ms < 0) ms = 0;
const totalSec = Math.floor(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
const pp = n => String(n).padStart(2, "0");
return h > 0 ? `${h}:${pp(m)}:${pp(s)}` : `${m}:${pp(s)}`;
}
function lastEventOfType(events, type) {
let latest = null;
for (const e of events) {
if (e.type === type && (!latest || e.at > latest.at)) latest = e;
}
return latest;
}
// Wake windows: time between a sleep-end and the next sleep-start. The final
// sleep-end with no following sleep-start = ongoing/open wake window.
function wakeWindows(events) {
const sorted = events
.filter(e => e.type === "sleep-start" || e.type === "sleep-end")
.sort((a, b) => a.at - b.at);
const out = [];
let waking = null;
for (const e of sorted) {
if (e.type === "sleep-end") {
waking = e.at;
} else if (e.type === "sleep-start" && waking !== null) {
out.push({ start: waking, end: e.at, ongoing: false });
waking = null;
}
}
if (waking !== null) out.push({ start: waking, end: Date.now(), ongoing: true });
return out;
}
// Sleep windows: each sleep-start → next sleep-end pair. An unmatched
// sleep-start = ongoing/open sleep window.
function sleepWindows(events) {
const sorted = events
.filter(e => e.type === "sleep-start" || e.type === "sleep-end")
.sort((a, b) => a.at - b.at);
const out = [];
let sleeping = null;
for (const e of sorted) {
if (e.type === "sleep-start") {
sleeping = e.at;
} else if (e.type === "sleep-end" && sleeping !== null) {
out.push({ start: sleeping, end: e.at, ongoing: false });
sleeping = null;
}
}
if (sleeping !== null) out.push({ start: sleeping, end: Date.now(), ongoing: true });
return out;
}
function sleepWindowsForDay(events, day) {
const dayStart = startOfDay(day).getTime();
const dayEnd = endOfDay(day).getTime();
const today = ymd(new Date()) === ymd(day);
// Keep the overlap filter so windows show on every day they touch, but
// display the *actual* start/end — a sleep from 23:00 yesterday to 07:00
// today should show as "23:00 – 07:00 (8h)", not clipped at midnight.
return sleepWindows(events)
.filter(w => w.start <= dayEnd && w.end >= dayStart)
.map(w => ({ start: w.start, end: w.end, ongoing: w.ongoing && today }));
}
// Wake windows that overlap the given day, clipped to that day for display.
// Only mark a window as "ongoing" if the selected day is today.
function wakeWindowsForDay(events, day) {
const dayStart = startOfDay(day).getTime();
const dayEnd = endOfDay(day).getTime();
const today = ymd(new Date()) === ymd(day);
// Same untrimmed-times policy as sleep windows: show the real start/end
// even if part of the window falls outside the selected day.
return wakeWindows(events)
.filter(w => w.start <= dayEnd && w.end >= dayStart)
.map(w => ({ start: w.start, end: w.end, ongoing: w.ongoing && today }));
}
// ---------- rendering ----------
const dayPicker = document.getElementById("day-picker");
const eventList = document.getElementById("event-list");
const emptyState = document.getElementById("empty-state");
const statusEl = document.getElementById("online-status");
function selectedDay() {
const v = dayPicker.value;
if (v) {
const [y, m, d] = v.split("-").map(Number);
return new Date(y, m - 1, d);
}
return new Date();
}
function renderStats(events) {
const day = selectedDay();
const from = startOfDay(day).getTime();
const today = ymd(new Date()) === ymd(day);
const to = today ? Date.now() : endOfDay(day).getTime();
const sleepMs = sleepMsInRange(events, from, to);
const awakeMs = Math.max(0, (to - from) - sleepMs);
const dayEvents = eventsForDay(events, day);
const count = (t) => dayEvents.filter(e => e.type === t).length;
document.getElementById("stat-sleep").textContent = formatDuration(sleepMs);
document.getElementById("stat-awake").textContent = formatDuration(awakeMs);
document.getElementById("stat-meals").textContent = count("eat");
const gramsTotal = dayEvents
.filter(e => e.type === "eat" && Number.isFinite(e.grams))
.reduce((s, e) => s + e.grams, 0);
const gramsEl = document.getElementById("stat-meals-grams");
gramsEl.textContent = gramsTotal > 0 ? `${Math.round(gramsTotal)} g` : "";
gramsEl.hidden = !(gramsTotal > 0);
document.getElementById("stat-pees").textContent = count("pee");
document.getElementById("stat-poos").textContent = count("poo");
document.getElementById("stat-training").textContent = count("training");
}
// Gaps (ms) between consecutive events of `type` logged within the last
// `days` days, sorted ascending. These intervals are what tell you how
// often the puppy needs to go out.
function gapsBetween(events, type, days = 7) {
const cutoff = startOfDay(new Date());
cutoff.setDate(cutoff.getDate() - (days - 1));
const from = cutoff.getTime();
const times = events
.filter(e => e.type === type && e.at >= from)
.map(e => e.at)
.sort((a, b) => a - b);
const gaps = [];
for (let i = 1; i < times.length; i++) gaps.push(times[i] - times[i - 1]);
return gaps.sort((a, b) => a - b);
}
// Median is used for the "typical" gap because it ignores the single long
// overnight gap each day, so it reflects real daytime frequency.
function median(sorted) {
if (sorted.length === 0) return null;
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
function renderTiming(events) {
const peeGaps = gapsBetween(events, "pee");
const pooGaps = gapsBetween(events, "poo");
const show = (id, ms) => {
document.getElementById(id).textContent = ms == null ? "—" : formatDuration(ms);
};
show("gap-pee", median(peeGaps));
show("gap-pee-min", peeGaps[0] ?? null);
show("gap-poo", median(pooGaps));
show("gap-poo-min", pooGaps[0] ?? null);
const hint = document.getElementById("timing-hint");
const typicalPee = median(peeGaps);
if (typicalPee != null) {
hint.textContent =
`Based on ${peeGaps.length} pee gap${peeGaps.length === 1 ? "" : "s"}. ` +
`Aim to take the puppy out a little before the typical ${formatDuration(typicalPee)} mark.`;
} else {
hint.textContent = "Log a few more pees and poos to see typical timings.";
}
}
function renderLasts(events) {
const setLast = (id, type) => {
const ev = lastEventOfType(events, type);
document.getElementById(id).textContent = ev
? `${formatTime(ev.at)} (${formatRelative(ev.at)})`
: "—";
};
setLast("last-pee", "pee");
setLast("last-poo", "poo");
setLast("last-eat", "eat");
const sortedSleep = events
.filter(e => e.type === "sleep-start" || e.type === "sleep-end")
.sort((a, b) => b.at - a.at);
const lastSleep = sortedSleep[0];
document.getElementById("last-sleep").textContent = lastSleep
? `${EVENT_LABELS[lastSleep.type]} at ${formatTime(lastSleep.at)} (${formatRelative(lastSleep.at)})`
: "—";
}
// Tracks the latest sleep transition so the 1-second tick can update the
// counter without re-deriving from the event log.
let bigClockState = null; // "asleep" | "awake" | null
let bigClockSince = 0;
// The counter renders twice: the big card at the top of the page and the
// compact pill in the frozen day bar. The pill only *shows* once the big
// card is scrolled out of sight (see updateBarClockMode); while the card is
// visible the pill is invisible but keeps its slot so the bar never shifts.
function updateBarClockMode() {
const pill = document.getElementById("bar-clock");
if (!bigClockState || pill.hidden) return;
const card = document.getElementById("big-clock");
const bar = document.querySelector(".day-bar");
const cardVisible =
card.getBoundingClientRect().bottom > bar.getBoundingClientRect().bottom;
pill.classList.toggle("standby", cardVisible);
}
function renderBigClock(events) {
const card = document.getElementById("big-clock");
const label = document.getElementById("bc-label");
const time = document.getElementById("bc-time");
const since = document.getElementById("bc-since");
const pill = document.getElementById("bar-clock");
const icon = document.getElementById("bar-clock-icon");
const ptime = document.getElementById("bar-clock-time");
const { state, since: ts } = currentSleepState(events);
bigClockState = state;
bigClockSince = ts;
if (!state) {
card.hidden = true;
pill.hidden = true;
return;
}
card.hidden = false;
pill.hidden = false;
card.classList.toggle("asleep", state === "asleep");
card.classList.toggle("awake", state === "awake");
pill.classList.toggle("asleep", state === "asleep");
pill.classList.toggle("awake", state === "awake");
label.textContent = state === "asleep" ? "Asleep for" : "Awake for";
const counter = formatCounter(Date.now() - ts);
time.textContent = counter;
ptime.textContent = counter;
since.textContent = `since ${formatTime(ts)}`;
icon.textContent = state === "asleep" ? "😴" : "☀️";
pill.title = `${state === "asleep" ? "Asleep" : "Awake"} since ${formatTime(ts)}`;
updateBarClockMode();
}
function tickBigClock() {
if (!bigClockState) return;
const counter = formatCounter(Date.now() - bigClockSince);
const time = document.getElementById("bc-time");
const ptime = document.getElementById("bar-clock-time");
if (time) time.textContent = counter;
if (ptime) ptime.textContent = counter;
}
function renderWindowList(listId, emptyId, windows, ongoingLabel, extraClass) {
const list = document.getElementById(listId);
const empty = document.getElementById(emptyId);
list.innerHTML = "";
if (windows.length === 0) {
empty.hidden = false;
return;
}
empty.hidden = true;
for (const w of windows) {
const li = document.createElement("li");
li.className = `ww ${extraClass}` + (w.ongoing ? " ongoing" : "");
const range = document.createElement("span");
range.className = "ww-range";
range.textContent = w.ongoing
? `from ${formatTime(w.start)}`
: `${formatTime(w.start)} – ${formatTime(w.end)}`;
const dur = document.createElement("span");
dur.className = "ww-dur";
dur.textContent = formatDuration(w.end - w.start);
li.appendChild(range);
li.appendChild(dur);
if (w.ongoing) {
const tag = document.createElement("span");
tag.className = "ww-tag";
tag.textContent = ongoingLabel;
li.appendChild(tag);
}
list.appendChild(li);
}
}
function renderSleepWindows(events) {
renderWindowList(
"sleep-list", "sleep-empty",
sleepWindowsForDay(events, selectedDay()),
"Asleep", "sleep-ww",
);
}
function renderWakeWindows(events) {
renderWindowList(
"wake-list", "wake-empty",
wakeWindowsForDay(events, selectedDay()),
"Awake", "wake-ww",
);
}
function renderHistory(events) {
const dayEvents = eventsForDay(events, selectedDay()).reverse();
const exNames = exerciseNames();
eventList.innerHTML = "";
if (dayEvents.length === 0) {
emptyState.hidden = false;
return;
}
emptyState.hidden = true;
for (const ev of dayEvents) {
let label = EVENT_LABELS[ev.type] || ev.type;
if (ev.type === "training" && exNames.get(ev.exerciseId)) {
label = `Training · ${exNames.get(ev.exerciseId)}`;
}
const li = document.createElement("li");
li.className = "event";
li.dataset.type = ev.type;
li.dataset.id = ev.id;
li.innerHTML = `
${formatTime(ev.at)}
${escapeText(label)}
`;
const noteEl = li.querySelector(".note");
if (ev.type === "weight" && Number.isFinite(ev.weight)) {
noteEl.textContent = ev.note ? `${formatWeight(ev.weight)} · ${ev.note}` : formatWeight(ev.weight);
} else if (ev.type === "eat" && Number.isFinite(ev.grams) && ev.grams > 0) {
const g = `${Math.round(ev.grams)} g`;
noteEl.textContent = ev.note ? `${g} · ${ev.note}` : g;
} else {
noteEl.textContent = ev.note || "";
}
li.addEventListener("click", () => openEditDialog(ev));
for (const pid of photoIdsOf(ev)) {
const img = document.createElement("img");
img.className = "thumb";
img.alt = "photo";
img.loading = "lazy";
img.dataset.photoId = pid;
img.addEventListener("click", (e) => {
e.stopPropagation(); // don't open the edit dialog
openLightbox(pid);
});
li.appendChild(img);
photoSrc(pid).then(url => { if (url) img.src = url; });
}
eventList.appendChild(li);
}
}
// ---------- lightbox ----------
const lightbox = document.getElementById("lightbox");
const lightboxImg = document.getElementById("lightbox-img");
const lightboxClose = document.getElementById("lightbox-close");
async function openLightbox(photoId) {
const url = await photoSrc(photoId);
if (!url) { alert("Photo not yet available (still uploading?)."); return; }
lightboxImg.src = url;
lightbox.showModal();
}
lightboxClose.addEventListener("click", () => lightbox.close());
// Click on backdrop closes the lightbox too.
lightbox.addEventListener("click", (e) => {
if (e.target === lightbox) lightbox.close();
});
// ---------- chart window ----------
// How many days the rolling charts cover. Device-global (like the theme),
// picked via the 7d/14d/30d buttons in the charts card and applied to every
// day-window chart: daily bars, sleep timeline, hour heatmap, training grid.
const CHART_DAYS_KEY = "puppy-tracker:chart-days:v1";
const CHART_DAY_CHOICES = [7, 14, 30];
function chartDays() {
const v = Number(localStorage.getItem(CHART_DAYS_KEY));
return CHART_DAY_CHOICES.includes(v) ? v : 7;
}
function setChartDays(n) {
try { localStorage.setItem(CHART_DAYS_KEY, String(n)); } catch { /* ignore */ }
render();
}
// Sync every "(last N days)" header and the picker's active button.
function renderChartWindow() {
const n = chartDays();
const title = document.getElementById("daily-charts-title");
if (title) title.textContent = `Last ${n} days`;
document.querySelectorAll("[data-chart-days-label]").forEach(el => {
el.textContent = `(last ${n} days)`;
});
document.querySelectorAll(".chart-days-picker button").forEach(b => {
b.classList.toggle("active", Number(b.dataset.days) === n);
});
}
// ---------- daily charts ----------
function weeklyData(events) {
const today = startOfDay(new Date());
const now = Date.now();
const days = [];
for (let i = chartDays() - 1; i >= 0; i--) {
const d = new Date(today);
d.setDate(d.getDate() - i);
const from = startOfDay(d).getTime();
const to = (i === 0) ? now : endOfDay(d).getTime();
const sleepMs = sleepMsInRange(events, from, to);
const dayEvents = eventsForDay(events, d);
days.push({
date: d,
ymd: ymd(d),
sleepHours: sleepMs / 3_600_000,
pees: dayEvents.filter(e => e.type === "pee").length,
poos: dayEvents.filter(e => e.type === "poo").length,
meals: dayEvents.filter(e => e.type === "eat").length,
grams: dayEvents
.filter(e => e.type === "eat" && Number.isFinite(e.grams))
.reduce((s, e) => s + e.grams, 0),
});
}
return days;
}
function dayLabel(date, isToday) {
if (isToday) return "Today";
return date.toLocaleDateString(undefined, { weekday: "short" });
}
// Wider windows can't fit a label under every bar: label today and every
// 2nd (or 4th) day counting back from it, depending on the window.
function showDayLabel(i, len) {
const every = len <= 8 ? 1 : len <= 16 ? 2 : 4;
return (len - 1 - i) % every === 0;
}
// Pick a chart Y maximum and tick count so every tick label is a clean
// whole number (avoids 0, 0, 1, 1, 2 from rounding fractional steps):
// 1-unit gridlines up to 10, 2-unit up to 20, 5-unit beyond.
function niceAxis(rawMax) {
if (!(rawMax > 0)) return { yMax: 1, steps: 1 };
if (rawMax <= 10) {
const m = Math.ceil(rawMax);
return { yMax: m, steps: m };
}
if (rawMax <= 20) {
const m = Math.ceil(rawMax / 2) * 2;
return { yMax: m, steps: m / 2 };
}
const m = Math.ceil(rawMax / 5) * 5;
return { yMax: m, steps: m / 5 };
}
// Grams axis: 0-based with a "nice" step so tick labels stay round whatever
// the daily totals are (tens of grams for a tiny puppy, hundreds+ later).
// Aims for ~10 segments so day-to-day differences of a few grams show.
function niceAxisGrams(rawMax) {
if (!(rawMax > 0)) return { yMax: 100, steps: 4 };
const rawStep = rawMax / 10;
const mag = Math.pow(10, Math.floor(Math.log10(rawStep)));
const norm = rawStep / mag;
const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag;
const yMax = Math.ceil(rawMax / step) * step;
return { yMax, steps: Math.max(1, Math.round(yMax / step)) };
}
// Sleep-specific axis: 1-hour granularity, capped at 24h/day. Shared by the
// sleep bars and the trend chart; both thin out labels when steps get dense.
function niceAxisSleepHours(rawMax) {
if (!(rawMax > 0)) return { yMax: 2, steps: 2 };
const m = Math.min(24, Math.max(2, Math.ceil(rawMax)));
return { yMax: m, steps: m };
}
function escapeText(s) {
return String(s).replace(/[&<>"']/g, c => (
{ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]
));
}
function setChartSVG(svg, parts) {
svg.innerHTML = parts.join("");
svg.querySelectorAll(".bar[data-day]").forEach(b => {
b.addEventListener("click", () => {
dayPicker.value = b.dataset.day;
render();
});
});
}
function drawSleepChart(days) {
const svg = document.getElementById("chart-sleep");
const W = 320, H = 160;
const ML = 26, MR = 6, MT = 10, MB = 26;
const innerW = W - ML - MR;
const innerH = H - MT - MB;
const rawMax = Math.max(...days.map(d => d.sleepHours));
const { yMax, steps: ySteps } = niceAxisSleepHours(rawMax);
const gap = days.length > 14 ? 2 : 4;
const barW = (innerW - (days.length - 1) * gap) / days.length;
// Label every 1h gridline; smaller text once the axis is dense so the
// labels stay apart (this chart's plot area is shorter than the trend's).
const yLabelCls = ySteps > 12 ? "y-dense" : "";
const parts = [];
for (let i = 0; i <= ySteps; i++) {
const y = MT + innerH * (1 - i / ySteps);
const v = Math.round(yMax * i / ySteps * 10) / 10;
const vText = v % 1 === 0 ? v : v.toFixed(1);
parts.push(``);
parts.push(`${vText}h`);
}
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(``);
}
parts.push(
`` +
`${escapeText(title)}`
);
// 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(
`` +
`${escapeText(dayLabel(d.date, isToday))}`
);
}
});
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(``);
parts.push(`${v}`);
}
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(``);
}
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(
`` +
`${escapeText(title)}`
);
});
if (showDayLabel(i, days.length) || isSel) {
parts.push(
`` +
`${escapeText(dayLabel(d.date, isToday))}`
);
}
});
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(``);
parts.push(`${vText}`);
}
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(``);
}
parts.push(
`` +
`${escapeText(title)}`
);
if (showDayLabel(i, days.length) || isSel) {
parts.push(
`` +
`${escapeText(dayLabel(d.date, isToday))}`
);
}
});
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(``);
const anchor = hr === 0 ? "start" : hr === 24 ? "end" : "middle";
parts.push(`${hr}h`);
}
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(``);
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(``);
}
const label = isToday ? "Today" : `${day.toLocaleDateString(undefined, { weekday: "short" })} ${day.getDate()}`;
parts.push(`${escapeText(label)}`);
const title = day.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" });
parts.push(`${escapeText(title)}`);
}
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(
`` +
`${escapeText(`${s.label} · ${range}: ${c}`)}`
);
}
parts.push(`${s.label}`);
});
const yAxis = H - MB + 12;
for (const hr of [0, 6, 12, 18]) {
const x = ML + (hr / 24) * innerW;
parts.push(`${hr}h`);
}
parts.push(`24h`);
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(
`` +
`${escapeText(`Goal ${target.lo}–${target.hi}h (${target.label})`)}`
);
}
for (const v of yTicks) {
const y = yOf(v);
parts.push(``);
parts.push(`${v}h`);
}
for (const hr of [0, 6, 12, 18, 24]) {
const x = xOf(hr);
parts.push(``);
const anchor = hr === 0 ? "start" : hr === 24 ? "end" : "middle";
parts.push(`${pad2(hr)}`);
}
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(`${escapeText(title)}`);
}
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(``);
parts.push(`${Math.round(v * 10) / 10}`);
}
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(``);
}
// 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(``);
});
weights.forEach((w, i) => {
const detail = weightPointInfo(w, birthday);
parts.push(
`` +
`${escapeText(detail)}`
);
});
const fmtX = (t) => new Date(t).toLocaleDateString(undefined, { month: "short", day: "numeric" });
parts.push(`${escapeText(fmtX(t0))}`);
if (tSpan > 0) {
parts.push(`${escapeText(fmtX(t1))}`);
}
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(
``
);
}
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(
`` +
`${escapeText(title)}`
);
}
const name = x.name.length > 12 ? x.name.slice(0, 11) + "…" : x.name;
parts.push(`${escapeText(name)}`);
});
const yAxis = H - MB + 12;
parts.push(`${escapeText(dayList[0].toLocaleDateString(undefined, { month: "short", day: "numeric" }))}`);
parts.push(`Today`);
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
// , which the CSS treats as an override. The
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 /- 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();
})();
})();