(() => { "use strict"; // Storage is namespaced per account so two people sharing a browser (or one // person logging out and back in as someone else) never see each other's // cached events/profile. currentUser is set by the auth gate before the app // boots, so these are only ever called once a user is known. let currentUser = null; // A guest is someone here on a share link (see "guest links" in the README): // the same data as the owner, minus everything under Settings that is the // owner's to decide. currentUser.id is the owner's either way, which is what // keeps the cache keys below pointing at the right data. const isGuest = () => currentUser?.role === "guest"; const guestLabel = () => (isGuest() ? (currentUser.label || "") : ""); const guestShareId = () => (isGuest() ? (currentUser.shareId || "") : ""); // Who may change an already-logged event. The owner may change anything on // their own account; a guest may only touch what they logged themselves, so a // sitter can fix up their own entries and cannot rewrite or delete a single // one of the owner's. Keyed on the link id rather than its label, because two // links can easily carry the same label ("Sitter"). The server enforces the // same rule in Store.sync — this is what keeps the UI honest about it. const canEditEvent = (ev) => !isGuest() || ev.loggedByShare === guestShareId(); const eventsKey = () => `puppy-tracker:${currentUser.id}:events:v1`; const configKey = () => `puppy-tracker:${currentUser.id}:config:v1`; const exercisesKey = () => `puppy-tracker:${currentUser.id}:exercises:v1`; const SYNC_URL = "api/events/sync"; const SYNC_DEBOUNCE_MS = 1200; const SYNC_POLL_MS = 60_000; const EVENT_LABELS = { "sleep-start": "Sleep start", "sleep-end": "Sleep end", "walk-start": "Walk start", "walk-end": "Walk end", "eat": "Ate", "pee": "Pee", "poo": "Poo", "weight": "Weight", "training": "Training", "note": "Note", }; // ---------- photos: IndexedDB store ---------- // Schema: object store `photos` keyed by `id`, value `{id, blob, uploaded}`. // - Photos taken locally are written with uploaded:false and queued for upload. // - Photos fetched from server are cached with uploaded:true (server is source-of-truth). const PHOTO_DB = "puppy-tracker"; const PHOTO_STORE = "photos"; let photoDB = null; function openPhotoDB() { if (photoDB) return Promise.resolve(photoDB); return new Promise((resolve, reject) => { const req = indexedDB.open(PHOTO_DB, 1); req.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains(PHOTO_STORE)) { db.createObjectStore(PHOTO_STORE, { keyPath: "id" }); } }; req.onsuccess = () => { photoDB = req.result; resolve(photoDB); }; req.onerror = () => reject(req.error); }); } async function putPhoto(id, blob, uploaded) { const db = await openPhotoDB(); return new Promise((resolve, reject) => { const tx = db.transaction(PHOTO_STORE, "readwrite"); tx.objectStore(PHOTO_STORE).put({ id, blob, uploaded }); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } async function getPhoto(id) { const db = await openPhotoDB(); return new Promise((resolve, reject) => { const tx = db.transaction(PHOTO_STORE, "readonly"); const req = tx.objectStore(PHOTO_STORE).get(id); req.onsuccess = () => resolve(req.result || null); req.onerror = () => reject(req.error); }); } async function getAllPhotos() { const db = await openPhotoDB(); return new Promise((resolve, reject) => { const tx = db.transaction(PHOTO_STORE, "readonly"); const req = tx.objectStore(PHOTO_STORE).getAll(); req.onsuccess = () => resolve(req.result || []); req.onerror = () => reject(req.error); }); } async function deletePhoto(id) { const db = await openPhotoDB(); return new Promise((resolve, reject) => { const tx = db.transaction(PHOTO_STORE, "readwrite"); tx.objectStore(PHOTO_STORE).delete(id); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } // Resize an image File to a max dimension and return a JPEG Blob. function resizeImage(file, maxDim = 1600, quality = 0.85) { return new Promise((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { URL.revokeObjectURL(url); const scale = Math.min(1, maxDim / Math.max(img.naturalWidth, img.naturalHeight)); const w = Math.max(1, Math.round(img.naturalWidth * scale)); const h = Math.max(1, Math.round(img.naturalHeight * scale)); const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; canvas.getContext("2d").drawImage(img, 0, 0, w, h); canvas.toBlob( (blob) => blob ? resolve(blob) : reject(new Error("toBlob returned null")), "image/jpeg", quality, ); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("image load failed")); }; img.src = url; }); } async function uploadPhotoBlob(id, blob) { const form = new FormData(); form.append("id", id); form.append("file", blob, `${id}.jpg`); const res = await fetch("api/photos", { method: "POST", body: form }); if (!res.ok) throw new Error(`upload HTTP ${res.status}`); } async function syncPhotos() { if (!navigator.onLine) return; const all = await getAllPhotos(); for (const p of all) { if (p.uploaded) continue; try { await uploadPhotoBlob(p.id, p.blob); await putPhoto(p.id, p.blob, true); } catch (err) { console.warn("photo upload failed", p.id, err); // leave queued; next sync will retry } } } // Resolve a photoId to a displayable URL. Prefers local cache; falls back // to the server URL (the SW will cache it transparently). Returns null if // we have nothing and the server doesn't either. const photoURLCache = new Map(); // id -> object URL (lifetime = page session) async function photoSrc(id) { if (!id) return null; if (photoURLCache.has(id)) return photoURLCache.get(id); const local = await getPhoto(id); if (local && local.blob) { const url = URL.createObjectURL(local.blob); photoURLCache.set(id, url); return url; } // Fall back to server URL — let the browser/SW cache it. Also opportunistically // pull it into IndexedDB so cold-offline-loads still see it. if (navigator.onLine) { try { const res = await fetch(`api/photos/${encodeURIComponent(id)}`); if (res.ok) { const blob = await res.blob(); await putPhoto(id, blob, true); const url = URL.createObjectURL(blob); photoURLCache.set(id, url); return url; } } catch (_) { /* fall through */ } } return null; } // crypto.randomUUID() is only exposed in secure contexts (HTTPS / localhost), // so over plain HTTP on the LAN we need a fallback. crypto.getRandomValues // is available everywhere; Math.random is the last resort. function uuid() { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { const b = new Uint8Array(16); crypto.getRandomValues(b); b[6] = (b[6] & 0x0f) | 0x40; // version 4 b[8] = (b[8] & 0x3f) | 0x80; // variant 10 const h = [...b].map(x => x.toString(16).padStart(2, "0")); return `${h.slice(0,4).join("")}-${h.slice(4,6).join("")}-${h.slice(6,8).join("")}-${h.slice(8,10).join("")}-${h.slice(10,16).join("")}`; } return `x-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`; } // ---------- storage ---------- // Internal "raw" storage includes deleted tombstones; UI uses live(). function loadAll() { try { const raw = localStorage.getItem(eventsKey()); if (!raw) return []; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; // Backfill updatedAt for events written by an older client version. return parsed.map(e => ({ ...e, updatedAt: Number.isFinite(e.updatedAt) ? e.updatedAt : (e.at || Date.now()), })); } catch { return []; } } function saveAll(events) { localStorage.setItem(eventsKey(), JSON.stringify(events)); } function live() { return loadAll().filter(e => !e.deleted); } // ---------- config (puppy name + birthday) ---------- // The shared profile lives on the host so every client sees the same values. // localStorage is just a cache for instant paint + offline; the server is the // source of truth, reconciled by last-write-wins on updatedAt (see syncConfig). function loadConfig() { try { const parsed = JSON.parse(localStorage.getItem(configKey())); if (!parsed || typeof parsed !== "object") return { name: "", birthday: "", pedigreeId: "", updatedAt: 0 }; return { name: parsed.name || "", birthday: parsed.birthday || "", pedigreeId: parsed.pedigreeId || "", updatedAt: Number.isFinite(parsed.updatedAt) ? parsed.updatedAt : 0, }; } catch { return { name: "", birthday: "", pedigreeId: "", updatedAt: 0 }; } } function saveConfig(cfg) { localStorage.setItem(configKey(), JSON.stringify(cfg)); } // Reconcile a local and a server profile. Name/birthday/updatedAt are plain // last-write-wins by timestamp. The pedigree id is sticky: a non-empty value // never loses to an empty one — so it can't be dropped by a clock race between // devices — and when both are set the newer profile's id wins with the rest. // (The server merge mirrors this, so a set id is only changed, never cleared, // by sync.) function reconcileConfig(local, server) { const base = server.updatedAt >= local.updatedAt ? server : local; return { name: base.name, birthday: base.birthday, pedigreeId: (local.pedigreeId && server.pedigreeId) ? base.pedigreeId : (local.pedigreeId || server.pedigreeId), updatedAt: base.updatedAt, }; } function sameConfig(a, b) { return a.name === b.name && a.birthday === b.birthday && a.pedigreeId === b.pedigreeId && a.updatedAt === b.updatedAt; } // Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday, // measured at `at` (defaults to now — pass a weigh-in's timestamp for its age // at that point). Returns null for a missing/invalid birthday or a date before it. function ageParts(birthday, at) { if (!birthday) return null; const [y, mo, d] = birthday.split("-").map(Number); if (!y || !mo || !d) return null; const birth = startOfDay(new Date(y, mo - 1, d)); const ref = startOfDay(new Date(Number.isFinite(at) ? at : Date.now())); if (birth > ref) return null; const days = Math.floor((ref - birth) / 86_400_000); const weeks = Math.floor(days / 7); let months = (ref.getFullYear() - birth.getFullYear()) * 12 + (ref.getMonth() - birth.getMonth()); if (ref.getDate() < birth.getDate()) months--; if (months < 0) months = 0; // Whole weeks left over after the calendar months (so "N months and M weeks"). const anchor = startOfDay(new Date(birth.getFullYear(), birth.getMonth() + months, birth.getDate())); const remWeeks = Math.floor((ref - anchor) / 86_400_000 / 7); return { days, weeks, months, remWeeks }; } // e.g. "16 weeks (3 months and 3 weeks) old". Weeks are the headline unit while // the puppy is young; once it's 4+ months the week count is just noise, so we // drop it and lead with the months form ("4 months and 1 week old"). function formatAge(birthday, at) { const a = ageParts(birthday, at); if (!a) return ""; const wk = `${a.weeks} week${a.weeks === 1 ? "" : "s"}`; if (a.months < 1) return `${wk} old`; const mo = `${a.months} month${a.months === 1 ? "" : "s"}`; const rw = a.remWeeks > 0 ? ` and ${a.remWeeks} week${a.remWeeks === 1 ? "" : "s"}` : ""; const monthPhrase = `${mo}${rw}`; if (a.months >= 4) return `${monthPhrase} old`; return `${wk} (${monthPhrase}) old`; } // Compact one-line age for space-tight spots (header): keeps the weeks + months // breakdown but abbreviated — "16 wk · 3 mo 3 wk", "5 mo 2 wk", "3 wk". function formatAgeShort(birthday, at) { const a = ageParts(birthday, at); if (!a) return ""; const wk = `${a.weeks} wk`; if (a.months < 1) return wk; const mo = `${a.months} mo${a.remWeeks > 0 ? ` ${a.remWeeks} wk` : ""}`; if (a.months >= 4) return mo; return `${wk} · ${mo}`; } // Weeks-only age for dense lists (weight rows): "16 wk". function formatAgeWeeks(birthday, at) { const a = ageParts(birthday, at); return a ? `${a.weeks} wk` : ""; } // Rough age-based daily sleep goal (hours), for the trend chart's goal band: // 0–8 weeks 20–22h, 8–16 weeks 18–20h, then 16–18h to 6 months and 14–16h to // 12 months. No birthday (or an adult dog) → no goal. function sleepTargetFor(birthday) { const a = ageParts(birthday); if (!a) return null; if (a.weeks < 8) return { lo: 20, hi: 22, label: "0–8 weeks" }; if (a.weeks < 16) return { lo: 18, hi: 20, label: "8–16 weeks" }; if (a.months < 6) return { lo: 16, hi: 18, label: "4–6 months" }; if (a.months < 12) return { lo: 14, hi: 16, label: "6–12 months" }; return null; } function addEvent(type, note, at, { photoId, weight, grams, exerciseId } = {}) { const events = loadAll(); const now = Date.now(); const ev = { id: uuid(), type, at: Number.isFinite(at) ? at : now, note: note || "", photoId: photoId || "", weight: Number.isFinite(weight) ? weight : undefined, grams: Number.isFinite(grams) ? grams : undefined, exerciseId: exerciseId || "", updatedAt: now, // Only set when logging through a guest link, and only so the badge and // the "you may edit this" check work before the first sync: the server // stamps both authoritatively on insert, and mergeSynced keeps our copy // when updatedAt ties, so without a local stamp neither would settle // until some later edit. Both sides compute the same values, so the two // never disagree. loggedBy: guestLabel(), loggedByShare: guestShareId(), }; events.push(ev); saveAll(events); scheduleSync(); render(); return ev; } function updateEvent(id, patch) { const events = loadAll().map(e => e.id === id ? { ...e, ...patch, updatedAt: Date.now() } : e ); saveAll(events); scheduleSync(); render(); } function deleteEvent(id) { // Soft-delete so the deletion can propagate via sync. const events = loadAll().map(e => e.id === id ? { ...e, deleted: true, updatedAt: Date.now() } : e ); saveAll(events); scheduleSync(); render(); } // ---------- exercises (training definitions) ---------- // User-defined training exercises ("Sit", "Leash walking", …), each with // optional instruction text. They sync like events: UUID ids, last-write-wins // on updatedAt, tombstoned deletes — but as their own collection, since they // are definitions rather than things that happened at a point in time. function loadExercises() { try { const parsed = JSON.parse(localStorage.getItem(exercisesKey())); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } function saveExercises(list) { localStorage.setItem(exercisesKey(), JSON.stringify(list)); } function liveExercises() { return loadExercises() .filter(x => !x.deleted) .sort((a, b) => (a.name || "").localeCompare(b.name || "")); } function addExercise(name, note) { const list = loadExercises(); list.push({ id: uuid(), name, note: note || "", updatedAt: Date.now() }); saveExercises(list); scheduleSync(); render(); } function updateExercise(id, patch) { saveExercises(loadExercises().map(x => x.id === id ? { ...x, ...patch, updatedAt: Date.now() } : x )); scheduleSync(); render(); } function deleteExercise(id) { // Tombstone, like events. Logged training sessions keep referencing the id; // name lookups still resolve through the tombstone (see exerciseNames). saveExercises(loadExercises().map(x => x.id === id ? { ...x, deleted: true, updatedAt: Date.now() } : x )); scheduleSync(); render(); } // id -> name across *all* exercises, tombstones included, so history rows for // a deleted exercise still show its name instead of a generic "Training". function exerciseNames() { return new Map(loadExercises().map(x => [x.id, x.name])); } // ---------- helpers ---------- function ymd(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, "0"); const d = String(date.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; } function startOfDay(date) { const d = new Date(date); d.setHours(0, 0, 0, 0); return d; } function endOfDay(date) { const d = new Date(date); d.setHours(23, 59, 59, 999); return d; } // ---------- days that don't count ---------- // Not every logged day is equally trustworthy: a day someone else had the // puppy produces a thin record that reads exactly like a real one, and then // drags down every average. Marking a day "not counted" keeps its data // untouched and fully visible on the day itself, but takes it out of // everything that aggregates across days. // // A mark is an ordinary event, the way a training session is: it rides the // existing sync with per-item last-write-wins and tombstones (un-marking is // just a delete), so it needs no table, endpoint or contract of its own. // Timestamped at noon so the day it lands on is unambiguous under the same // ymd() bucketing every other event uses. const EXCLUDED_TYPE = "day-excluded"; // Behaviour is what a sparse logger distorts. A weigh-in and a note are // records of fact, so they keep counting even on a day that doesn't. const ALWAYS_COUNTS = new Set(["weight", "note"]); // Refreshed once per render() and read like chartDays() — the chart drawers // need to know which day slots to hatch, not just which events to drop. let excludedSet = new Set(); function excludedDays(events) { return new Set( events.filter(e => e.type === EXCLUDED_TYPE).map(e => ymd(new Date(e.at))) ); } const isExcluded = (date) => excludedSet.has(ymd(date)); // The event list the cross-day panels see. function countedEvents(events) { if (excludedSet.size === 0) return events; return events.filter(e => ALWAYS_COUNTS.has(e.type) || !excludedSet.has(ymd(new Date(e.at)))); } // True if any part of [from, to] falls on an excluded day. Used to throw away // measurements that reach across one — see gapsBetween. function spansExcluded(from, to) { if (excludedSet.size === 0) return false; for (let d = startOfDay(new Date(from)); d.getTime() <= to; d.setDate(d.getDate() + 1)) { if (isExcluded(d)) return true; } return false; } function formatTime(ts) { return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false }); } const pad2 = n => String(n).padStart(2, "0"); function toDateInput(ts) { const d = new Date(ts); return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } function toTimeInput(ts) { const d = new Date(ts); return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; } function fromDateTimeInputs(dateStr, timeStr) { if (!dateStr || !timeStr) return NaN; const [y, mo, d] = dateStr.split("-").map(Number); const [h, mi] = timeStr.split(":").map(Number); const t = new Date(y, mo - 1, d, h, mi).getTime(); return Number.isFinite(t) ? t : NaN; } function formatDuration(ms) { if (ms <= 0) return "0m"; const totalMin = Math.round(ms / 60000); const h = Math.floor(totalMin / 60); const m = totalMin % 60; if (h === 0) return `${m}m`; return `${h}h ${m}m`; } // kg with up to 2 decimals, trailing zeros stripped (5.20 -> "5.2 kg"). function formatWeight(kg) { return `${Math.round(kg * 100) / 100} kg`; } function formatRelative(ts) { if (!ts) return "—"; const diff = Date.now() - ts; if (diff < 60_000) return "just now"; if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; if (diff < 86_400_000) { const h = Math.floor(diff / 3_600_000); const m = Math.floor((diff % 3_600_000) / 60_000); return m ? `${h}h ${m}m ago` : `${h}h ago`; } const d = Math.floor(diff / 86_400_000); return `${d}d ago`; } function eventsForDay(events, day) { const from = startOfDay(day).getTime(); const to = endOfDay(day).getTime(); return events .filter(e => e.at >= from && e.at <= to) .sort((a, b) => a.at - b.at); } // Calculate sleep time within a given day. Sleep intervals are derived from // consecutive sleep-start → sleep-end pairs across the whole event log, and // clipped to the day window. Unmatched sleep-start = ongoing sleep (clipped to now). function sleepMsInRange(events, fromTs, toTs) { const sorted = [...events].sort((a, b) => a.at - b.at); const intervals = []; let openStart = null; for (const e of sorted) { if (e.type === "sleep-start" && openStart === null) { openStart = e.at; } else if (e.type === "sleep-end" && openStart !== null) { intervals.push([openStart, e.at]); openStart = null; } } if (openStart !== null) intervals.push([openStart, Date.now()]); let total = 0; for (const [start, end] of intervals) { const s = Math.max(start, fromTs); const ePt = Math.min(end, toTs); if (ePt > s) total += ePt - s; } return total; } // The newest of a set of boundary event types. For two events sharing the // same `at` (common once "now" events are minute-floored), the one logged // later (higher updatedAt) wins, so the tie resolves the same way everywhere // it's read. function latestOfTypes(events, types) { let latest = null; for (const e of events) { if (!types.includes(e.type)) continue; if (!latest || e.at > latest.at || (e.at === latest.at && (e.updatedAt || 0) > (latest.updatedAt || 0))) { latest = e; } } return latest; } // Current state derived from the *latest* sleep event. The single source of // truth for both the big clock and the "Currently" row. function currentSleepState(events) { const latest = latestOfTypes(events, ["sleep-start", "sleep-end"]); if (!latest) return { state: null, since: 0 }; return { state: latest.type === "sleep-start" ? "asleep" : "awake", since: latest.at, }; } // Same idea for walks: the newest walk boundary says whether one is running. function currentWalkState(events) { const latest = latestOfTypes(events, ["walk-start", "walk-end"]); if (!latest) return { walking: false, since: 0 }; return { walking: latest.type === "walk-start", since: latest.at }; } function formatCounter(ms) { if (ms < 0) ms = 0; const totalSec = Math.floor(ms / 1000); const h = Math.floor(totalSec / 3600); const m = Math.floor((totalSec % 3600) / 60); const s = totalSec % 60; const pp = n => String(n).padStart(2, "0"); return h > 0 ? `${h}:${pp(m)}:${pp(s)}` : `${m}:${pp(s)}`; } function lastEventOfType(events, type) { let latest = null; for (const e of events) { if (e.type === type && (!latest || e.at > latest.at)) latest = e; } return latest; } // Pair up boundary events into windows: every `openType` runs until the next // `closeType`. A trailing unmatched `openType` is an ongoing/open window, // measured to now. Only the two given types are considered, so the same scan // produces sleep windows, their inverse (wake windows) and walks. function pairWindows(events, openType, closeType) { const sorted = events .filter(e => e.type === openType || e.type === closeType) .sort((a, b) => a.at - b.at); const out = []; let open = null; for (const e of sorted) { if (e.type === openType) { open = e.at; } else if (open !== null) { out.push({ start: open, end: e.at, ongoing: false }); open = null; } } if (open !== null) out.push({ start: open, end: Date.now(), ongoing: true }); return out; } // Wake windows: time between a sleep-end and the next sleep-start. The final // sleep-end with no following sleep-start = ongoing/open wake window. function wakeWindows(events) { return pairWindows(events, "sleep-end", "sleep-start"); } // Sleep windows: each sleep-start → next sleep-end pair. An unmatched // sleep-start = ongoing/open sleep window. function sleepWindows(events) { return pairWindows(events, "sleep-start", "sleep-end"); } // Walks: each walk-start → next walk-end pair, ongoing while out. function walkWindows(events) { return pairWindows(events, "walk-start", "walk-end"); } // Time spent walking inside [fromTs, toTs], clipping windows that straddle // the edges — the same treatment sleepMsInRange gives sleep. function walkMsInRange(events, fromTs, toTs) { let total = 0; for (const w of walkWindows(events)) { const s = Math.max(w.start, fromTs); const e = Math.min(w.end, toTs); if (e > s) total += e - s; } return total; } function sleepWindowsForDay(events, day) { const dayStart = startOfDay(day).getTime(); const dayEnd = endOfDay(day).getTime(); const today = ymd(new Date()) === ymd(day); // Keep the overlap filter so windows show on every day they touch, but // display the *actual* start/end — a sleep from 23:00 yesterday to 07:00 // today should show as "23:00 – 07:00 (8h)", not clipped at midnight. return sleepWindows(events) .filter(w => w.start <= dayEnd && w.end >= dayStart) .map(w => ({ start: w.start, end: w.end, ongoing: w.ongoing && today })); } // Wake windows that overlap the given day, clipped to that day for display. // Only mark a window as "ongoing" if the selected day is today. function wakeWindowsForDay(events, day) { const dayStart = startOfDay(day).getTime(); const dayEnd = endOfDay(day).getTime(); const today = ymd(new Date()) === ymd(day); // Same untrimmed-times policy as sleep windows: show the real start/end // even if part of the window falls outside the selected day. return wakeWindows(events) .filter(w => w.start <= dayEnd && w.end >= dayStart) .map(w => ({ start: w.start, end: w.end, ongoing: w.ongoing && today })); } // Walks that overlap the given day. Same untrimmed-times policy as the sleep // and wake lists: a walk that crosses midnight shows its real start and end. function walkWindowsForDay(events, day) { const dayStart = startOfDay(day).getTime(); const dayEnd = endOfDay(day).getTime(); const today = ymd(new Date()) === ymd(day); return walkWindows(events) .filter(w => w.start <= dayEnd && w.end >= dayStart) .map(w => ({ start: w.start, end: w.end, ongoing: w.ongoing && today })); } // ---------- rendering ---------- const dayPicker = document.getElementById("day-picker"); const eventList = document.getElementById("event-list"); const emptyState = document.getElementById("empty-state"); const statusEl = document.getElementById("online-status"); function selectedDay() { const v = dayPicker.value; if (v) { const [y, m, d] = v.split("-").map(Number); return new Date(y, m - 1, d); } return new Date(); } function renderStats(events) { const day = selectedDay(); const from = startOfDay(day).getTime(); const today = ymd(new Date()) === ymd(day); const to = today ? Date.now() : endOfDay(day).getTime(); const sleepMs = sleepMsInRange(events, from, to); const awakeMs = Math.max(0, (to - from) - sleepMs); const dayEvents = eventsForDay(events, day); const count = (t) => dayEvents.filter(e => e.type === t).length; document.getElementById("stat-sleep").textContent = formatDuration(sleepMs); document.getElementById("stat-awake").textContent = formatDuration(awakeMs); document.getElementById("stat-meals").textContent = count("eat"); const gramsTotal = dayEvents .filter(e => e.type === "eat" && Number.isFinite(e.grams)) .reduce((s, e) => s + e.grams, 0); const gramsEl = document.getElementById("stat-meals-grams"); gramsEl.textContent = gramsTotal > 0 ? `${Math.round(gramsTotal)} g` : ""; gramsEl.hidden = !(gramsTotal > 0); // Walk time is a duration, not a count, so the tile leads with it and puts // "N walks" underneath — the day's exercise at a glance. document.getElementById("stat-walk").textContent = formatDuration(walkMsInRange(events, from, to)); const walks = walkWindowsForDay(events, day).length; const walkCountEl = document.getElementById("stat-walk-count"); walkCountEl.textContent = `${walks} walk${walks === 1 ? "" : "s"}`; walkCountEl.hidden = walks === 0; document.getElementById("stat-pees").textContent = count("pee"); document.getElementById("stat-poos").textContent = count("poo"); document.getElementById("stat-training").textContent = count("training"); } // Gaps (ms) between consecutive events of `type` logged within the last // `days` days, sorted ascending. These intervals are what tell you how // often the puppy needs to go out. Defaults to the picked chart window, so // the 7d/14d/30d buttons widen the timing panel along with the charts — // read at call time, not at definition, so a change takes on the next render. function gapsBetween(events, type, days = chartDays()) { const cutoff = startOfDay(new Date()); cutoff.setDate(cutoff.getDate() - (days - 1)); const from = cutoff.getTime(); const times = events .filter(e => e.type === type && e.at >= from) .map(e => e.at) .sort((a, b) => a - b); const gaps = []; for (let i = 1; i < times.length; i++) { // A gap reaching across a day that doesn't count is not a real gap: the // events in between were dropped, so Tuesday's last pee now sits next to // Thursday's first and the subtraction invents thirty hours. Discarding // the pair is the only honest answer — measuring it would blow out the // "longest" figure far worse than the sparse day this feature exists to // take out of the numbers. if (spansExcluded(times[i - 1], times[i])) continue; gaps.push(times[i] - times[i - 1]); } return gaps.sort((a, b) => a - b); } // Median is used for the "typical" gap because it ignores the single long // overnight gap each day, so it reflects real daytime frequency. function median(sorted) { if (sorted.length === 0) return null; const mid = Math.floor(sorted.length / 2); return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; } // Each type gets a small range chart: a band spanning the shortest to the // typical gap over the window, and a marker for how long it has been since // the last one. The marker is free to sit outside the band — just went (left // of shortest) or overdue (right of typical) — which is the reading that // decides whether to take the puppy out now. const TIMING_ROWS = [ { type: "pee", label: "Pees", noun: "pee", cls: "tm-pee" }, { type: "poo", label: "Poos", noun: "poo", cls: "tm-poo" }, { type: "eat", label: "Meals", noun: "meal", cls: "tm-eat" }, ]; function renderTiming(events) { for (const row of TIMING_ROWS) { const svg = document.getElementById(`timing-chart-${row.type}`); const note = document.getElementById(`timing-note-${row.type}`); if (!svg || !note) continue; const gaps = gapsBetween(events, row.type); const last = lastEventOfType(events, row.type); drawTimingChart(svg, note, row, gaps, last ? Math.max(0, Date.now() - last.at) : null); } } function drawTimingChart(svg, note, row, gaps, since) { const typical = median(gaps); const shortest = gaps[0] ?? null; const longest = gaps[gaps.length - 1] ?? null; // Fewer than two events in the window means there is no gap to draw, so the // row falls back to a sentence rather than an axis with nothing on it. if (typical == null) { svg.innerHTML = ""; svg.style.display = "none"; note.hidden = false; note.textContent = since == null ? `No ${row.noun}s logged in the last ${chartDays()} days.` : `One ${row.noun} logged, ${formatDuration(since)} ago — log another to see the typical gap.`; return; } svg.style.display = ""; note.hidden = true; const W = 320, H = 50, ML = 26, MR = 26; const innerW = W - ML - MR; const trackY = 20, trackH = 8; // Gaps are heavily skewed: one long overnight gap a night sits alongside // daytime gaps a tenth its length, and on a plain linear axis it squashes // the everyday range — the part you actually read — into the first tenth of // the track. So the axis is stretched, the way the sleep trend's is above // 10h: the typical gap is pinned to the middle, the shortest-to-typical // stretch gets the left half and typical-to-longest the right. Reading it // is then the same in every row — left of centre is sooner than usual, // right of centre is longer — however extreme that row's tail happens to be. const over = since != null && since > longest; const cur = since == null ? null : Math.min(since, longest); const lo = Math.min(shortest, cur ?? shortest); const halfW = innerW / 2; const mid = ML + halfW; const xOf = (v) => { if (v <= typical) return typical > lo ? ML + ((v - lo) / (typical - lo)) * halfW : mid; return longest > typical ? mid + ((v - typical) / (longest - typical)) * halfW : mid; }; // Two bands: the full spread of gaps in the window, and inside it the solid // stretch from the shortest to the typical one. Where the solid ends is the // median, so the boundary itself marks "typical" without another mark. const xShort = xOf(shortest), xTypical = xOf(typical), xLong = xOf(longest); const dot = (a, b) => b - a < trackH + 2; const bandRect = (cls, a, b, extra) => { const w = Math.max(trackH + 2, b - a); const x = dot(a, b) ? (a + b) / 2 - w / 2 : a; return `${extra}`; }; const parts = [ ``, bandRect(`tm-range ${row.cls}`, xShort, xLong, `${escapeText(`${formatDuration(shortest)}–${formatDuration(longest)} between ${row.noun}s over the last ${chartDays()} days`)}`), bandRect(`tm-band ${row.cls}`, xShort, xTypical, `${escapeText(`Typically ${formatDuration(typical)} between ${row.noun}s`)}`), ]; // Ticks tie the summary line under the track to the three points it names. const tickY = trackY + trackH + 5; const tick = (x) => ``; const labelY = tickY + 13; // Whitespace inside a is at the mercy of XML normalisation (an //   does not survive every renderer either), so the gap between a word // and its value is an explicit dx offset. const word = (w, dx) => `${w}`; const val = (w, ms, dx) => `${word(w, dx)}${escapeText(formatDuration(ms))}`; // Three values will not fit as labels hung off their own points, so they // read as one centred line and the ticks carry the positions. if (longest - shortest < 60_000) { parts.push(tick(xTypical)); parts.push(`${val("typical", typical)}`); } else { parts.push(tick(xShort), tick(xTypical), tick(xLong)); parts.push( `` + `${val("shortest", shortest)}${word("·", 4)}${val("typical", typical, 4)}` + `${word("·", 4)}${val("longest", longest, 4)}` ); } if (cur != null) { const x = over ? W - MR : xOf(cur); const y1 = trackY - 5, y2 = trackY + trackH + 5; // Surface-coloured underlay: a ring that keeps the marker legible // wherever on the band it lands. parts.push(``); parts.push( `` + `${escapeText(`${formatDuration(since)} since the last ${row.noun}`)}` ); if (over) { parts.push( `` ); } const anchor = x < ML + 55 ? "start" : (x > W - MR - 55 ? "end" : "middle"); parts.push( `` + `${escapeText(`${formatDuration(since)} ago`)}` ); } svg.innerHTML = parts.join(""); svg.setAttribute("aria-label", `${row.label}: typically ${formatDuration(typical)} between ${row.noun}s, ` + `shortest ${formatDuration(shortest)}, longest ${formatDuration(longest)}` + (since == null ? "" : `, ${formatDuration(since)} since the last one`) + "."); } function renderLasts(events) { const setLast = (id, type) => { const ev = lastEventOfType(events, type); document.getElementById(id).textContent = ev ? `${formatTime(ev.at)} (${formatRelative(ev.at)})` : "—"; }; setLast("last-pee", "pee"); setLast("last-poo", "poo"); setLast("last-eat", "eat"); const sortedSleep = events .filter(e => e.type === "sleep-start" || e.type === "sleep-end") .sort((a, b) => b.at - a.at); const lastSleep = sortedSleep[0]; document.getElementById("last-sleep").textContent = lastSleep ? `${EVENT_LABELS[lastSleep.type]} at ${formatTime(lastSleep.at)} (${formatRelative(lastSleep.at)})` : "—"; const lastWalk = latestOfTypes(events, ["walk-start", "walk-end"]); document.getElementById("last-walk").textContent = lastWalk ? `${EVENT_LABELS[lastWalk.type]} at ${formatTime(lastWalk.at)} (${formatRelative(lastWalk.at)})` : "—"; } // Tracks the latest sleep transition so the 1-second tick can update the // counter without re-deriving from the event log. let bigClockState = null; // "asleep" | "awake" | null let bigClockSince = 0; // The counter renders twice: the big card at the top of the page and the // compact pill in the frozen day bar. The pill only *shows* once the big // card is scrolled out of sight (see updateBarClockMode); while the card is // visible the pill is invisible but keeps its slot so the bar never shifts. function updateBarClockMode() { const pill = document.getElementById("bar-clock"); if (!bigClockState || pill.hidden) return; const card = document.getElementById("big-clock"); const bar = document.querySelector(".day-bar"); // The card lives on the Today tab, so on any other tab it isn't on screen // at all and the pill is the only timer there is. Asked explicitly rather // than left to the rect: a hidden element measures as zeroes, which would // give the right answer here by coincidence rather than by rule. const cardOnThisTab = card.offsetParent !== null; const cardVisible = cardOnThisTab && card.getBoundingClientRect().bottom > bar.getBoundingClientRect().bottom; pill.classList.toggle("standby", cardVisible); } function renderBigClock(events) { const card = document.getElementById("big-clock"); const label = document.getElementById("bc-label"); const time = document.getElementById("bc-time"); const since = document.getElementById("bc-since"); const pill = document.getElementById("bar-clock"); const icon = document.getElementById("bar-clock-icon"); const ptime = document.getElementById("bar-clock-time"); const { state, since: ts } = currentSleepState(events); bigClockState = state; bigClockSince = ts; if (!state) { card.hidden = true; pill.hidden = true; return; } card.hidden = false; pill.hidden = false; card.classList.toggle("asleep", state === "asleep"); card.classList.toggle("awake", state === "awake"); pill.classList.toggle("asleep", state === "asleep"); pill.classList.toggle("awake", state === "awake"); label.textContent = state === "asleep" ? "Asleep for" : "Awake for"; const counter = formatCounter(Date.now() - ts); time.textContent = counter; ptime.textContent = counter; since.textContent = `since ${formatTime(ts)}`; icon.textContent = state === "asleep" ? "😴" : "☀️"; const flip = state === "asleep" ? "Sleep end" : "Sleep start"; pill.title = `${state === "asleep" ? "Asleep" : "Awake"} since ${formatTime(ts)} — tap to log ${flip.toLowerCase()}`; pill.setAttribute("aria-label", `${state === "asleep" ? "Asleep" : "Awake"} since ${formatTime(ts)}. Log ${flip.toLowerCase()}.`); updateBarClockMode(); } function tickBigClock() { if (!bigClockState) return; const counter = formatCounter(Date.now() - bigClockSince); const time = document.getElementById("bc-time"); const ptime = document.getElementById("bar-clock-time"); if (time) time.textContent = counter; if (ptime) ptime.textContent = counter; } function renderWindowList(listId, emptyId, windows, ongoingLabel, extraClass) { const list = document.getElementById(listId); const empty = document.getElementById(emptyId); list.innerHTML = ""; if (windows.length === 0) { empty.hidden = false; return; } empty.hidden = true; for (const w of windows) { // A window may bring its own class and label, which is how the merged // sleep/wake list states every row's kind. A single-kind list passes one // class and one label for the lot and only tags the open row, where // "Walking" is news rather than a repetition of the heading. const cls = w.cls || extraClass; const tag = w.tag || (w.ongoing ? ongoingLabel : ""); const li = document.createElement("li"); li.className = `ww ${cls}` + (w.ongoing ? " ongoing" : ""); const range = document.createElement("span"); range.className = "ww-range"; range.textContent = w.ongoing ? `from ${formatTime(w.start)}` : `${formatTime(w.start)} – ${formatTime(w.end)}`; const dur = document.createElement("span"); dur.className = "ww-dur"; dur.textContent = formatDuration(w.end - w.start); li.appendChild(range); li.appendChild(dur); if (tag) { const tagEl = document.createElement("span"); tagEl.className = "ww-tag"; tagEl.textContent = tag; li.appendChild(tagEl); } list.appendChild(li); } } // Sleep and wake windows are the same boundaries read two ways — a wake // window is precisely the gap between two sleeps — so they alternate by // construction and sorting the two sets by start time interleaves them back // into the day's actual rhythm, "45 minutes asleep after two and a half hours // up" in one glance instead of a comparison between two lists. // // Newest first, like the history and notes logs: the row still running is // then the first one you see, and today's list grows downward from where you // are rather than pushing the current state further away with every window. function sleepWakeWindowsForDay(events, day) { return [ ...sleepWindowsForDay(events, day).map(w => ({ ...w, cls: "sleep-ww", tag: "Asleep" })), ...wakeWindowsForDay(events, day).map(w => ({ ...w, cls: "wake-ww", tag: "Awake" })), ].sort((a, b) => b.start - a.start); } function renderSleepWake(events) { renderWindowList( "sleep-wake-list", "sleep-wake-empty", sleepWakeWindowsForDay(events, selectedDay()), "", "", ); } function renderWalks(events) { const day = selectedDay(); const windows = walkWindowsForDay(events, day); // Newest first, like every other list in the app, so a walk in progress is // the first row. Reversed here rather than in walkWindowsForDay, whose // other callers — the day's total and the history rails — read the windows // as a set and would only be made harder to follow by a display order. renderWindowList( "walk-list", "walk-empty", [...windows].reverse(), "Walking", "walk-ww", ); // Total next to the heading, so a collapsed panel still answers "how much // did we walk?". const from = startOfDay(day).getTime(); const to = ymd(new Date()) === ymd(day) ? Date.now() : endOfDay(day).getTime(); const totalMs = walkMsInRange(events, from, to); document.getElementById("walk-total").textContent = windows.length ? `(${formatDuration(totalMs)})` : ""; } // Which history rows sit inside a sleep or walk window, so the list can run a // dotted rail down the margin from a start row to its end row. It reuses the // windows the Sleep and Walks panels already draw, so a pair that crosses // midnight is treated the same way here as there. Rows logged in between — a // pee taken on a walk — fall inside the bracket, which is the point: the rail // says "this happened during that", not merely "these two are a pair". // // Roles are named for where the row sits in the *rendered* list, which runs // newest-first, so a span's chronologically last event is its top row. A span // whose boundary isn't itself a row here (an ongoing walk, or one that runs // past midnight) leaves that end open, and the rail runs off the list edge // rather than stopping at a row that didn't end anything. function historyRails(events, dayEvents, day) { const rails = new Map(); const spans = [ ...sleepWindowsForDay(events, day).map(w => ({ ...w, kind: "sleep" })), ...walkWindowsForDay(events, day).map(w => ({ ...w, kind: "walk" })), ]; for (const span of spans) { const inside = dayEvents.filter(e => e.at >= span.start && e.at <= span.end); if (inside.length < 2) continue; // nothing to tie to const closedTop = inside[inside.length - 1].at >= span.end; const closedBottom = inside[0].at <= span.start; inside.forEach((e, i) => { const role = i === inside.length - 1 ? (closedTop ? "top" : "mid") : i === 0 ? (closedBottom ? "bottom" : "mid") : "mid"; // Sleep is added first, so the (in practice impossible) overlap of a // walk and a sleep paints as the walk. rails.set(e.id, { kind: span.kind, role }); }); } return rails; } function renderHistory(events) { const day = selectedDay(); // The "not counted" mark is bookkeeping about the day, not something that // happened to the puppy, so it never appears as a row in the log. const chronological = eventsForDay(events, day).filter(e => e.type !== EXCLUDED_TYPE); const rails = historyRails(events, chronological, day); const dayEvents = [...chronological].reverse(); const exNames = exerciseNames(); eventList.innerHTML = ""; if (dayEvents.length === 0) { emptyState.hidden = false; return; } emptyState.hidden = true; for (const ev of dayEvents) { let label = EVENT_LABELS[ev.type] || ev.type; if (ev.type === "training" && exNames.get(ev.exerciseId)) { label = `Training · ${exNames.get(ev.exerciseId)}`; } const li = document.createElement("li"); li.className = "event"; li.dataset.type = ev.type; li.dataset.id = ev.id; const rail = rails.get(ev.id); if (rail) li.classList.add("rail", `rail-${rail.kind}`, `rail-${rail.role}`); li.innerHTML = ` ${formatTime(ev.at)} ${escapeText(label)} `; // Logged through a guest link: say whose, so a row you don't remember // making has an explanation attached to it. if (ev.loggedBy) { const by = document.createElement("span"); by.className = "by"; by.textContent = ev.loggedBy; by.title = `Logged by ${ev.loggedBy} on a guest link`; // the badge is truncated li.querySelector(".label").after(by); } const noteEl = li.querySelector(".note"); if (ev.type === "weight" && Number.isFinite(ev.weight)) { noteEl.textContent = ev.note ? `${formatWeight(ev.weight)} · ${ev.note}` : formatWeight(ev.weight); } else if (ev.type === "eat" && Number.isFinite(ev.grams) && ev.grams > 0) { const g = `${Math.round(ev.grams)} g`; noteEl.textContent = ev.note ? `${g} · ${ev.note}` : g; } else { noteEl.textContent = ev.note || ""; } li.addEventListener("click", () => openEditDialog(ev)); for (const pid of photoIdsOf(ev)) { const img = document.createElement("img"); img.className = "thumb"; img.alt = "photo"; img.loading = "lazy"; img.dataset.photoId = pid; img.addEventListener("click", (e) => { e.stopPropagation(); // don't open the edit dialog openLightbox(pid); }); li.appendChild(img); photoSrc(pid).then(url => { if (url) img.src = url; }); } eventList.appendChild(li); } } // Cross-day log of free-text notes (vaccinations, vet visits, milestones…), // newest first. Unlike History this ignores the day picker so the record is // always visible regardless of which day you're viewing. function renderNotes(events) { const notes = events .filter(e => e.type === "note") .sort((a, b) => b.at - a.at); const list = document.getElementById("notes-list"); const empty = document.getElementById("notes-empty"); list.innerHTML = ""; if (notes.length === 0) { empty.hidden = false; return; } empty.hidden = true; const birthday = loadConfig().birthday; for (const ev of notes) { const li = document.createElement("li"); li.className = "event"; li.dataset.type = "note"; li.dataset.id = ev.id; const dateStr = new Date(ev.at).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); const age = formatAgeWeeks(birthday, ev.at); li.innerHTML = ` ${escapeText(age ? `${dateStr} · ${age}` : dateStr)} `; li.querySelector(".note-text").textContent = ev.note || ""; li.addEventListener("click", () => openEditDialog(ev)); for (const pid of photoIdsOf(ev)) { const img = document.createElement("img"); img.className = "thumb"; img.alt = "photo"; img.loading = "lazy"; img.dataset.photoId = pid; img.addEventListener("click", (e) => { e.stopPropagation(); openLightbox(pid); }); li.appendChild(img); photoSrc(pid).then(url => { if (url) img.src = url; }); } list.appendChild(li); } } // ---------- lightbox ---------- const lightbox = document.getElementById("lightbox"); const lightboxImg = document.getElementById("lightbox-img"); const lightboxClose = document.getElementById("lightbox-close"); async function openLightbox(photoId) { const url = await photoSrc(photoId); if (!url) { alert("Photo not yet available (still uploading?)."); return; } lightboxImg.src = url; lightbox.showModal(); } lightboxClose.addEventListener("click", () => lightbox.close()); // Click on backdrop closes the lightbox too. lightbox.addEventListener("click", (e) => { if (e.target === lightbox) lightbox.close(); }); // ---------- chart window ---------- // How many days the rolling charts cover. Device-global (like the theme), // picked via the 7d/14d/30d buttons in the charts card and applied to every // day-window chart: daily bars, sleep timeline, hour heatmap, training grid. const CHART_DAYS_KEY = "puppy-tracker:chart-days:v1"; const CHART_DAY_CHOICES = [7, 14, 30]; function chartDays() { const v = Number(localStorage.getItem(CHART_DAYS_KEY)); return CHART_DAY_CHOICES.includes(v) ? v : 7; } function setChartDays(n) { try { localStorage.setItem(CHART_DAYS_KEY, String(n)); } catch { /* ignore */ } render(); } // Which metrics the daily-counts chart shows. Device-global, toggled via the // checkboxes under the chart. At least one is always kept on so the chart is // never empty; an invalid/empty stored value falls back to all three. const COUNTS_METRICS_KEY = "puppy-tracker:counts-metrics:v1"; const COUNTS_METRIC_KEYS = ["pees", "poos", "meals"]; function countsMetrics() { let stored; try { stored = JSON.parse(localStorage.getItem(COUNTS_METRICS_KEY)); } catch { /* ignore */ } const on = Array.isArray(stored) ? COUNTS_METRIC_KEYS.filter(k => stored.includes(k)) : []; return on.length ? on : COUNTS_METRIC_KEYS.slice(); } function setCountsMetrics(keys) { // Never let the user hide everything — keep at least one metric visible. const on = COUNTS_METRIC_KEYS.filter(k => keys.includes(k)); if (!on.length) { renderChartWindow(); return; } // restore the checkbox we just rejected try { localStorage.setItem(COUNTS_METRICS_KEY, JSON.stringify(on)); } catch { /* ignore */ } render(); } // Sync every "(last N days)" header and the picker's active button. function renderChartWindow() { const n = chartDays(); document.querySelectorAll("[data-chart-days-label]").forEach(el => { el.textContent = `(last ${n} days)`; }); document.querySelectorAll(".chart-days-picker button").forEach(b => { b.classList.toggle("active", Number(b.dataset.days) === n); }); const on = countsMetrics(); document.querySelectorAll("#counts-metrics input[data-metric]").forEach(cb => { cb.checked = on.includes(cb.dataset.metric); }); } // ---------- daily charts ---------- function weeklyData(events) { const today = startOfDay(new Date()); const now = Date.now(); const days = []; for (let i = chartDays() - 1; i >= 0; i--) { const d = new Date(today); d.setDate(d.getDate() - i); const from = startOfDay(d).getTime(); const to = (i === 0) ? now : endOfDay(d).getTime(); // A day marked "not counted" keeps its slot on the axis — dropping it // would make consecutive bars stop being consecutive days — but carries // no figures. Zeroing them here rather than in each chart means every // axis maximum, total and tooltip downstream is already right, and the // drawers only have to decide what to paint in the empty slot. const excluded = isExcluded(d); const sleepMs = excluded ? 0 : sleepMsInRange(events, from, to); const dayEvents = excluded ? [] : eventsForDay(events, d); days.push({ date: d, ymd: ymd(d), excluded, sleepHours: sleepMs / 3_600_000, pees: dayEvents.filter(e => e.type === "pee").length, poos: dayEvents.filter(e => e.type === "poo").length, meals: dayEvents.filter(e => e.type === "eat").length, grams: dayEvents .filter(e => e.type === "eat" && Number.isFinite(e.grams)) .reduce((s, e) => s + e.grams, 0), walkMinutes: excluded ? 0 : walkMsInRange(events, from, to) / 60_000, }); } return days; } // A day that doesn't count gets a hatched column where its bar would be, so // the gap reads as deliberate rather than as a day the puppy barely did // anything. Shared by all four daily bar charts, which differ only in height. function excludedSlot(d, x, barW, top, innerH) { const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — not counted`; return `${escapeText(title)}`; } function dayLabel(date, isToday) { if (isToday) return "Today"; return date.toLocaleDateString(undefined, { weekday: "short" }); } // Wider windows can't fit a label under every bar: label today and every // 2nd (or 4th) day counting back from it, depending on the window. function showDayLabel(i, len) { const every = len <= 8 ? 1 : len <= 16 ? 2 : 4; return (len - 1 - i) % every === 0; } // Pick a chart Y maximum and tick count so every tick label is a clean // whole number (avoids 0, 0, 1, 1, 2 from rounding fractional steps): // 1-unit gridlines up to 10, 2-unit up to 20, 5-unit beyond. function niceAxis(rawMax) { if (!(rawMax > 0)) return { yMax: 1, steps: 1 }; if (rawMax <= 10) { const m = Math.ceil(rawMax); return { yMax: m, steps: m }; } if (rawMax <= 20) { const m = Math.ceil(rawMax / 2) * 2; return { yMax: m, steps: m / 2 }; } const m = Math.ceil(rawMax / 5) * 5; return { yMax: m, steps: m / 5 }; } // Magnitude-agnostic 0-based axis with a "nice" step, so tick labels stay // round whatever the daily totals are (tens of grams for a tiny puppy, // hundreds+ later; minutes walked likewise). Aims for ~10 segments so small // day-to-day differences still show. function niceAxisLinear(rawMax) { if (!(rawMax > 0)) return { yMax: 100, steps: 4 }; const rawStep = rawMax / 10; const mag = Math.pow(10, Math.floor(Math.log10(rawStep))); const norm = rawStep / mag; const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag; const yMax = Math.ceil(rawMax / step) * step; return { yMax, steps: Math.max(1, Math.round(yMax / step)) }; } // Sleep-specific axis: 1-hour granularity, capped at 24h/day. Shared by the // sleep bars and the trend chart; both thin out labels when steps get dense. function niceAxisSleepHours(rawMax) { if (!(rawMax > 0)) return { yMax: 2, steps: 2 }; const m = Math.min(24, Math.max(2, Math.ceil(rawMax))); return { yMax: m, steps: m }; } function escapeText(s) { return String(s).replace(/[&<>"']/g, c => ( { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c] )); } function setChartSVG(svg, parts) { svg.innerHTML = parts.join(""); svg.querySelectorAll(".bar[data-day]").forEach(b => { b.addEventListener("click", () => { dayPicker.value = b.dataset.day; render(); }); }); } function drawSleepChart(days) { const svg = document.getElementById("chart-sleep"); const W = 320, H = 160; const ML = 26, MR = 6, MT = 10, MB = 26; const innerW = W - ML - MR; const innerH = H - MT - MB; const rawMax = Math.max(...days.map(d => d.sleepHours)); const { yMax, steps: ySteps } = niceAxisSleepHours(rawMax); const gap = days.length > 14 ? 2 : 4; const barW = (innerW - (days.length - 1) * gap) / days.length; // Label every 1h gridline; smaller text once the axis is dense so the // labels stay apart (this chart's plot area is shorter than the trend's). const yLabelCls = ySteps > 12 ? "y-dense" : ""; const parts = []; for (let i = 0; i <= ySteps; i++) { const y = MT + innerH * (1 - i / ySteps); const v = Math.round(yMax * i / ySteps * 10) / 10; const vText = v % 1 === 0 ? v : v.toFixed(1); parts.push(``); 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(``); } if (d.excluded) { parts.push(excludedSlot(d, x, barW, MT, innerH)); } else { 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 series = [ { key: "pees", label: "Pees", cls: "bar-pee" }, { key: "poos", label: "Poos", cls: "bar-poo" }, { key: "meals", label: "Meals", cls: "bar-eat" }, ].filter(s => countsMetrics().includes(s.key)); const rawMax = Math.max(0, ...days.flatMap(d => series.map(s => d[s.key]))); const { yMax, steps: ySteps } = niceAxis(rawMax); const groupGap = days.length > 14 ? 2 : 4; const innerBarGap = days.length > 14 ? 0.5 : 1.5; const groupW = (innerW - (days.length - 1) * groupGap) / days.length; const barW = (groupW - (series.length - 1) * innerBarGap) / series.length; const parts = []; for (let i = 0; i <= ySteps; i++) { const y = MT + innerH * (1 - i / ySteps); const v = Math.round(yMax * i / ySteps); parts.push(``); parts.push(`${v}`); } 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(``); } if (d.excluded) { // One hatch across the whole group rather than three empty bars. parts.push(excludedSlot(d, groupX, groupW, MT, innerH)); } else { series.forEach((s, j) => { const val = d[s.key]; const x = groupX + j * (barW + innerBarGap); const h = (val / yMax) * innerH; const y = MT + innerH - h; const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${s.label}: ${val}`; parts.push( `` + `${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 } = niceAxisLinear(Math.max(...days.map(d => d.grams))); const gap = days.length > 14 ? 2 : 4; const barW = (innerW - (days.length - 1) * gap) / days.length; const parts = []; for (let i = 0; i <= ySteps; i++) { const y = MT + innerH * (1 - i / ySteps); const v = yMax * i / ySteps; const vText = v % 1 === 0 ? v : v.toFixed(1); parts.push(``); 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(``); } if (d.excluded) { parts.push(excludedSlot(d, x, barW, MT, innerH)); } else { parts.push( `` + `${escapeText(title)}` ); } if (showDayLabel(i, days.length) || isSel) { parts.push( `` + `${escapeText(dayLabel(d.date, isToday))}` ); } }); setChartSVG(svg, parts); } // Minutes walked per day. Hidden until there's a walk to show, like the // grams chart — no point in an empty panel for someone who doesn't log walks. function drawWalkChart(days) { const wrap = document.getElementById("walk-chart-wrap"); const svg = document.getElementById("chart-walk"); if (!days.some(d => d.walkMinutes > 0)) { wrap.hidden = true; return; } wrap.hidden = false; const W = 320, H = 160; const ML = 30, MR = 6, MT = 10, MB = 26; const innerW = W - ML - MR; const innerH = H - MT - MB; const { yMax, steps: ySteps } = niceAxisLinear(Math.max(...days.map(d => d.walkMinutes))); const gap = days.length > 14 ? 2 : 4; const barW = (innerW - (days.length - 1) * gap) / days.length; const parts = []; for (let i = 0; i <= ySteps; i++) { const y = MT + innerH * (1 - i / ySteps); const v = yMax * i / ySteps; const vText = v % 1 === 0 ? v : v.toFixed(1); parts.push(``); 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.walkMinutes / yMax) * innerH; const y = MT + innerH - h; const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${formatDuration(d.walkMinutes * 60_000)}`; if (isSel) { parts.push(``); } if (d.excluded) { parts.push(excludedSlot(d, x, barW, MT, innerH)); } else { 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); drawWalkChart(days); drawGramsChart(days); } // ---------- pattern charts ---------- // Where "now" falls in the day, 0..1 — the shared x for the charts that run // midnight to midnight. function nowDayFraction() { const now = new Date(); return (now.getTime() - startOfDay(now).getTime()) / 86_400_000; } // Vertical "now" rule for those charts: ink over a surface-coloured underlay // so it stays legible whatever bar or cell it crosses, with a caret hung // above the track so it doesn't read as one more gridline. Append the parts // last so the rule sits on top; it ignores pointer events, so the rows and // cells underneath stay tappable. function nowRuleParts(x, yTop, yBottom) { const xs = x.toFixed(1); return [ ``, ``, ``, ]; } // Actogram: one row per day (oldest at top), a midnight-to-midnight track // with the given windows shaded. Windows are clipped to each day, so one that // crosses midnight shows correctly split across two rows, and an open one // runs to now (both window builders already clip ongoing to Date.now()). // Sleep and walks are the same picture drawn from different windows, so the // drawing lives here once and each caller brings its windows and their fill. function drawActogram(svgId, windows, barCls) { const svg = document.getElementById(svgId); if (!svg) return; const N = chartDays(); const W = 320; const ML = 44, MR = 8, MT = 16, MB = 4; const innerW = W - ML - MR; const rowGap = 2; // Fixed row height, chart grows with the window (13px × 14 days matches // the original 228-high viewBox; wider windows use thinner rows). const rowH = N > 14 ? 9 : 13; const H = MT + MB + N * rowH + (N - 1) * rowGap; svg.setAttribute("viewBox", `0 0 ${W} ${H}`); const dayMs = 86_400_000; const today = startOfDay(new Date()); const xOf = (frac) => ML + frac * innerW; const parts = []; for (const hr of [0, 6, 12, 18, 24]) { const x = xOf(hr / 24); parts.push(``); 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(``); // A day that doesn't count keeps its row — the rows are a calendar, so // dropping one would misalign every day above it — but is hatched right // across instead of showing the sparse windows that made it untrustworthy. if (isExcluded(day)) { parts.push(``); } else { for (const wdw of windows) { const s = Math.max(wdw.start, dayStart); const e = Math.min(wdw.end, dayEnd); if (e <= s) continue; const x = xOf((s - dayStart) / dayMs); const wpx = ((e - s) / dayMs) * innerW; parts.push(``); } } const label = isToday ? "Today" : `${day.toLocaleDateString(undefined, { weekday: "short" })} ${day.getDate()}`; parts.push(`${escapeText(label)}`); const dateText = day.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" }); const title = isExcluded(day) ? `${dateText} — not counted` : dateText; parts.push(`${escapeText(title)}`); } // The window always ends today, so one rule marks both the live edge of the // bottom row and the same time of day on every row above it. parts.push(...nowRuleParts(xOf(nowDayFraction()), MT, H - MB)); setChartSVG(svg, parts); // wires the .bar[data-day] click → select that day } function renderSleepTimeline(events) { drawActogram("chart-sleep-timeline", sleepWindows(events), "stl-sleep"); } // The walk patterns are only worth a panel once there is a walk to draw, so // both hide themselves rather than showing an empty day grid to someone who // doesn't track walks. function renderWalkPatterns(events) { const windows = walkWindows(events); const any = windows.length > 0; for (const key of ["walk-timeline", "walk-trend"]) { const section = document.querySelector(`section[data-panel="${key}"]`); if (section) section.hidden = !any; } if (!any) return; drawActogram("chart-walk-timeline", windows, "wtl-walk"); renderWalkTrend(events); } // Cumulative minutes walked through the day, as a step per walk: flat while // the puppy is in, climbing only while a walk is on. Today and yesterday are // built from their own walk boundaries so the steps stay square; the average // is sampled hourly instead, since a mean over many days is a smooth // reference with no steps of its own to preserve. // // Unlike the sleep trend there is no projected tail. Sleep is a state the // puppy drifts back into, so "what the average day adds from here" is a fair // guess; walks are decisions, and projecting them would be predicting the // handler, not the dog. function walkTrendCurves(events) { const HOUR = 3_600_000, MIN = 60_000, DAY = 86_400_000; const windows = walkWindows(events); // an ongoing walk is already clipped to now const walkedMs = (from, to) => { let total = 0; for (const w of windows) { const s = Math.max(w.start, from); const e = Math.min(w.end, to); if (e > s) total += e - s; } return total; }; const day = selectedDay(); const isToday = ymd(day) === ymd(new Date()); const dayAgo = (daysAgo) => { const d = startOfDay(day); d.setDate(d.getDate() - daysAgo); return d; }; const dayStartTs = (daysAgo) => dayAgo(daysAgo).getTime(); const pointsAt = (start, stops) => stops.map(t => ({ x: (t - start) / HOUR, y: walkedMs(start, t) / MIN })); // Every boundary falling inside the day, plus the day's two ends, is a // corner of the step line. capTs (today only) ends it at "now". const curveFor = (start, capTs) => { const end = capTs != null ? capTs : start + DAY; const stops = new Set([start, end]); for (const w of windows) { if (w.end <= start || w.start >= end) continue; stops.add(Math.max(w.start, start)); stops.add(Math.min(w.end, end)); } return pointsAt(start, [...stops].sort((a, b) => a - b)); }; const hourlyFor = (start) => { const stops = []; for (let h = 0; h <= 24; h++) stops.push(start + h * HOUR); return pointsAt(start, stops); }; const totalOf = (pts) => pts[pts.length - 1].y; const today = curveFor(dayStartTs(0), isToday ? Date.now() : null); // Same as the sleep trend: a day that doesn't count is dropped as a // comparison rather than drawn flat at zero. const prev = curveFor(dayStartTs(1)); const yesterday = (!isExcluded(dayAgo(1)) && totalOf(prev) > 0) ? prev : null; // Mean of the last N days, skipping days that don't count and days with no // walk at all, so a gap in logging doesn't drag the average toward zero. const avgDays = chartDays(); const dayCurves = []; for (let i = 1; i <= avgDays; i++) { if (isExcluded(dayAgo(i))) continue; const c = hourlyFor(dayStartTs(i)); if (c[24].y > 0) dayCurves.push(c); } let avg = null; if (dayCurves.length > 0) { avg = []; for (let h = 0; h <= 24; h++) { avg.push({ x: h, y: dayCurves.reduce((s, c) => s + c[h].y, 0) / dayCurves.length }); } } const fmtDay = (daysAgo) => new Date(dayStartTs(daysAgo)).toLocaleDateString(undefined, { month: "short", day: "numeric" }); return { today, yesterday, avg, avgDays, dayLabel: isToday ? "Today" : fmtDay(0), prevDayLabel: isToday ? "Yesterday" : fmtDay(1), }; } function drawWalkTrendChart(curves) { const svg = document.getElementById("chart-walk-trend"); if (!svg) return; const W = 320, H = 180; const ML = 30, MR = 8, MT = 10, MB = 22; const innerW = W - ML - MR; const innerH = H - MT - MB; const series = [ // Reference lines first so today draws on top of them. { pts: curves.avg, cls: "wtrend-avg", label: `${curves.avgDays}-day average` }, { pts: curves.yesterday, cls: "wtrend-yesterday", label: curves.prevDayLabel }, { pts: curves.today, cls: "wtrend-today", label: curves.dayLabel }, ].filter(s => s.pts && s.pts.length > 1); const rawMax = Math.max(...series.flatMap(s => s.pts.map(p => p.y))); const { yMax, steps } = niceAxisLinear(rawMax); // Minutes are linear all the way down, so no split axis: the sleep trend // stretches its top because the interesting hours cluster near a 16h goal, // while a walk total is as readable at 10 minutes as at 60. const xOf = (hour) => ML + (hour / 24) * innerW; const yOf = (v) => MT + innerH * (1 - v / yMax); const parts = []; for (let i = 0; i <= steps; i++) { const v = yMax * i / steps; const y = yOf(v); parts.push(``); parts.push(`${Math.round(v)}m`); } 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]; parts.push( `` + `${escapeText(`${s.label} — ${Math.round(last.y)} min walked`)}` ); } svg.innerHTML = parts.join(""); } function renderWalkTrend(events) { const curves = walkTrendCurves(events); drawWalkTrendChart(curves); const chip = (id) => document.getElementById(id); const mins = (pts) => `${Math.round(pts[pts.length - 1].y)} min`; chip("legend-wtrend-today-text").textContent = `${curves.dayLabel} ${mins(curves.today)}`; const yLegend = chip("legend-wtrend-yesterday"); yLegend.hidden = !curves.yesterday; if (curves.yesterday) { chip("legend-wtrend-yesterday-text").textContent = `${curves.prevDayLabel} ${mins(curves.yesterday)}`; } const aLegend = chip("legend-wtrend-avg"); aLegend.hidden = !curves.avg; if (curves.avg) { chip("legend-wtrend-avg-text").textContent = `${curves.avgDays}-day avg ${mins(curves.avg)}`; } } // Which heatmap block is focused, remembered across re-renders so a background // sync doesn't wipe the block the user just tapped. The counts behind it are // recomputed every render, so the readout stays current. let hourCellSel = null; // `${type}-${hour}`, or null for nothing focused const HOUR_CELL_HINT = "Tap a block to see how many it counts."; // Hour-of-day heatmap: one row per event type, 24 cells shaded by how often // that event lands in each hour across the window. Reveals daily rhythm that // the median gap can't show (e.g. "always poos ~7am and ~6pm"). function renderHourHeatmap(events) { const svg = document.getElementById("chart-hour-heatmap"); if (!svg) return; const info = document.getElementById("hour-heatmap-info"); const from = startOfDay(new Date(Date.now() - (chartDays() - 1) * 86_400_000)).getTime(); const series = [ { type: "pee", label: "Pees", cls: "hm-pee", one: "pee", many: "pees" }, { type: "poo", label: "Poos", cls: "hm-poo", one: "poo", many: "poos" }, { type: "eat", label: "Meals", cls: "hm-eat", one: "meal", many: "meals" }, ]; const counts = {}; for (const s of series) counts[s.type] = new Array(24).fill(0); for (const e of events) { if (e.at < from) continue; if (counts[e.type]) counts[e.type][new Date(e.at).getHours()]++; } const W = 320, H = 120; const ML = 40, MR = 8, MT = 6, MB = 18; const innerW = W - ML - MR; const rowGap = 6; const rowH = (H - MT - MB - (series.length - 1) * rowGap) / series.length; const cellW = innerW / 24; // "3 meals between 07:00 and 08:00" — self-describing, so the same string // serves as the pointer tooltip and as the tap readout under the chart. const cellText = (s, h, c) => `${c === 0 ? "No" : c} ${c === 1 ? s.one : s.many} between ` + `${pad2(h)}:00 and ${pad2((h + 1) % 24)}:00`; const parts = []; const hits = []; // appended last so they sit above every row const details = {}; // cell key → readout text series.forEach((s, r) => { const y = MT + r * (rowH + rowGap); const max = Math.max(1, ...counts[s.type]); for (let h = 0; h < 24; h++) { const c = counts[s.type][h]; const op = c === 0 ? 0.06 : 0.2 + 0.8 * (c / max); const x = ML + h * cellW; const key = `${s.type}-${h}`; details[key] = cellText(s, h, c); parts.push( `` ); // A cell is only ~12px wide on a phone, so the tap target claims the // spacing between cells and half the gap to the neighbouring rows. hits.push( `` + `${escapeText(details[key])}` ); } 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.concat(hits, nowRuleParts(ML + nowDayFraction() * innerW, MT, H - MB)).join(""); // tooltips only ever show on a pointer, which left phones with no // way to read a block's count. Tapping (or hovering) a block names it in // the caption under the chart. const cells = svg.querySelectorAll(".hm-cell[data-cell]"); const focusCell = (key) => { hourCellSel = details[key] ? key : null; cells.forEach(c => c.classList.toggle("hm-active", c.dataset.cell === hourCellSel)); if (info) info.textContent = hourCellSel ? details[hourCellSel] : HOUR_CELL_HINT; }; svg.querySelectorAll(".hm-hit").forEach(hit => { const key = hit.dataset.cell; // Select, never toggle. A tap fires the emulated mouseenter and then the // click on the same block, so a toggle here selected on the first of the // pair and cleared on the second — the count only appeared on a later tap // that arrived without a fresh mouseenter. Selecting twice is a no-op. hit.addEventListener("click", () => focusCell(key)); hit.addEventListener("mouseenter", () => focusCell(key)); }); focusCell(hourCellSel); } // ---------- sleep trend ---------- // Cumulative hours slept as the selected day progresses: that day's running // total (up to now when it's today, else the full 24h) against the previous // day's full curve and the mean of the N full days before it, where N // follows the 7/14/30 chart-days picker. // Each curve is a list of { x: hour-of-day 0..24, y: cumulative hours }. function sleepTrendCurves(events) { const HOUR = 3_600_000; const windows = sleepWindows(events); // ongoing sleep already clipped to now const sleptMs = (from, to) => { let total = 0; for (const w of windows) { const s = Math.max(w.start, from); const e = Math.min(w.end, to); if (e > s) total += e - s; } return total; }; const day = selectedDay(); const isToday = ymd(day) === ymd(new Date()); const dayAgo = (daysAgo) => { const d = startOfDay(day); d.setDate(d.getDate() - daysAgo); return d; }; const dayStartTs = (daysAgo) => dayAgo(daysAgo).getTime(); // capTs (today only) truncates the curve at "now" with a final fractional // point, so the line visibly ends where the day currently stands. const curveFor = (start, capTs) => { const pts = []; for (let h = 0; h <= 24; h++) { const to = start + h * HOUR; if (capTs != null && to >= capTs) { pts.push({ x: (capTs - start) / HOUR, y: sleptMs(start, capTs) / HOUR }); break; } pts.push({ x: h, y: sleptMs(start, to) / HOUR }); } return pts; }; // A past day is complete, so its curve runs the full 24h uncapped. const today = curveFor(dayStartTs(0), isToday ? Date.now() : null); // A day that doesn't count is no comparison at all, so it is dropped // outright rather than drawn as a flat line at zero. Checked explicitly // rather than leaning on the "any sleep at all" guard below: a nap running // in from the previous, counted day would give an excluded day a non-zero // total and sneak it back in. const yesterdayCurve = curveFor(dayStartTs(1)); const yesterday = (!isExcluded(dayAgo(1)) && yesterdayCurve[24].y > 0) ? yesterdayCurve : null; // Mean of the last N full days, skipping days that don't count and days // with no sleep at all, so a young log (or a tracking gap) doesn't drag the // average toward zero. const avgDays = chartDays(); const dayCurves = []; for (let i = 1; i <= avgDays; i++) { if (isExcluded(dayAgo(i))) continue; const c = curveFor(dayStartTs(i)); if (c[24].y > 0) dayCurves.push(c); } let avg = null; if (dayCurves.length > 0) { avg = []; for (let h = 0; h <= 24; h++) { avg.push({ x: h, y: dayCurves.reduce((s, c) => s + c[h].y, 0) / dayCurves.length }); } } // Where today is likely to end up: continue today's line by adding what // the average day typically adds between now and midnight. That respects // the time-of-day rhythm (night sleep, nap clusters), unlike a linear // rate extrapolation, which overshoots wildly just after a long night. // No history → no average → no projection. Past days are already complete, // so there is nothing to project. let projected = null; if (avg && isToday) { const nowPt = today[today.length - 1]; const avgAt = (x) => { const lo = Math.floor(x); const hi = Math.min(24, lo + 1); return avg[lo].y + (avg[hi].y - avg[lo].y) * (x - lo); }; projected = [{ x: nowPt.x, y: nowPt.y }]; for (let h = Math.ceil(nowPt.x); h <= 24; h++) { if (h <= nowPt.x) continue; // now landing exactly on an hour boundary projected.push({ x: h, y: nowPt.y + avgAt(h) - avgAt(nowPt.x) }); } } // Labels follow the selection so a past day reads as its date, not "Today". const fmtDay = (daysAgo) => new Date(dayStartTs(daysAgo)).toLocaleDateString(undefined, { month: "short", day: "numeric" }); const dayLabel = isToday ? "Today" : fmtDay(0); const prevDayLabel = isToday ? "Yesterday" : fmtDay(1); return { today, yesterday, avg, avgDays, projected, dayLabel, prevDayLabel }; } function drawSleepTrendChart(curves, target) { const svg = document.getElementById("chart-sleep-trend"); if (!svg) return; const W = 320, H = 220; const ML = 26, MR = 8, MT = 10, MB = 22; const innerW = W - ML - MR; const innerH = H - MT - MB; const series = [ // Reference lines first so today (and its projected tail) draw on top. { pts: curves.avg, cls: "trend-avg", label: `${curves.avgDays}-day average` }, { pts: curves.yesterday, cls: "trend-yesterday", label: curves.prevDayLabel }, { pts: curves.projected, cls: "trend-projected", label: "Projected end of day" }, { pts: curves.today, cls: "trend-today", label: curves.dayLabel }, ].filter(s => s.pts && s.pts.length > 1); // The axis must reach the goal band's top even when the data doesn't yet. const rawMax = Math.max( target ? target.hi : 0, ...series.flatMap(s => s.pts.map(p => p.y)), ); const { yMax } = niceAxisSleepHours(rawMax); // The interesting detail sits near the goal, in the upper teens of hours, // so the y scale is piecewise: everything up to BREAK shares a compressed // slice of the height and the hours above it get all the rest. Linear // again while the axis is still too short for a split to mean anything. const BREAK = 10, LOW_FRAC = 0.26; const split = yMax > BREAK + 2; const frac = (v) => !split ? v / yMax : v <= BREAK ? (v / BREAK) * LOW_FRAC : LOW_FRAC + ((v - BREAK) / (yMax - BREAK)) * (1 - LOW_FRAC); const xOf = (hour) => ML + (hour / 24) * innerW; const yOf = (v) => MT + innerH * (1 - frac(v)); // Gridlines: every 1h in the stretched region above the break, every 2h // in the compressed region below it (1h everywhere when linear). const yTicks = []; if (split) { for (let v = 0; v < BREAK; v += 2) yTicks.push(v); for (let v = BREAK; v <= yMax; v++) yTicks.push(v); } else { for (let v = 0; v <= yMax; v++) yTicks.push(v); } // Smaller text once the axis is dense so the labels stay apart. const yLabelCls = yTicks.length > 16 ? "y-dense" : ""; const parts = []; // Age-based goal band, behind everything: today's projection should land // inside it by midnight. if (target) { const yTop = yOf(target.hi); const yBot = yOf(target.lo); parts.push( `<rect class="trend-goal" x="${ML}" y="${yTop.toFixed(1)}" ` + `width="${innerW}" height="${(yBot - yTop).toFixed(1)}">` + `<title>${escapeText(`Goal ${target.lo}–${target.hi}h (${target.label})`)}` ); } 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 d = dayList[c]; const dateText = d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }); // A day that doesn't count would otherwise show as the palest cell — // indistinguishable from "trained nothing that day", which is the one // reading the mark is there to rule out. if (isExcluded(d)) { parts.push( `` + `${escapeText(`${x.name} · ${dateText}: not counted`)}` ); continue; } const op = n === 0 ? 0.06 : 0.35 + 0.65 * (n / max); parts.push( `` + `${escapeText(`${x.name} · ${dateText}: ${n}`)}` ); } 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; // Adding to the library is the owner's, like editing it. document.getElementById("exercise-add").hidden = isGuest(); for (const ex of exercises) { const times = events .filter(e => e.type === "training" && e.exerciseId === ex.id) .map(e => e.at); const li = document.createElement("li"); li.className = "exercise" + (expandedExercises.has(ex.id) ? " expanded" : ""); const row = document.createElement("div"); row.className = "ex-row"; const main = document.createElement("div"); main.className = "ex-main"; const nameEl = document.createElement("span"); nameEl.className = "ex-name"; nameEl.textContent = ex.name; const metaEl = document.createElement("span"); metaEl.className = "ex-meta"; metaEl.textContent = exerciseMeta(times); main.appendChild(nameEl); main.appendChild(metaEl); const logBtn = document.createElement("button"); logBtn.type = "button"; logBtn.className = "ex-log"; logBtn.textContent = "Log"; logBtn.addEventListener("click", (e) => { e.stopPropagation(); // Log onto the selected day (at the current time of day), so a session // that was forgotten yesterday can be back-filled from that day's view. const day = selectedDay(); const onToday = ymd(day) === ymd(new Date()); let at = Date.now(); if (!onToday) { const now = new Date(); at = new Date(day.getFullYear(), day.getMonth(), day.getDate(), now.getHours(), now.getMinutes(), now.getSeconds()).getTime(); } const ev = addEvent("training", "", at, { exerciseId: ex.id }); const dayStr = day.toLocaleDateString(undefined, { month: "short", day: "numeric" }); showSnackbar(onToday ? `${ex.name} logged` : `${ex.name} logged for ${dayStr}`, ev); }); row.appendChild(main); row.appendChild(logBtn); row.addEventListener("click", () => { if (expandedExercises.has(ex.id)) expandedExercises.delete(ex.id); else expandedExercises.add(ex.id); li.classList.toggle("expanded"); }); const detail = document.createElement("div"); detail.className = "ex-detail"; const noteEl = document.createElement("p"); noteEl.className = "ex-note"; noteEl.textContent = ex.note || (isGuest() ? "No instructions for this one yet." : "No instructions yet — tap Edit to add how to train this."); detail.appendChild(noteEl); // The exercise library is the owner's, not a log: a guest trains against // it and reads the instructions, but doesn't get to rename or delete // anything in it. The server drops guest-sent exercises to match. if (!isGuest()) { const editBtn = document.createElement("button"); editBtn.type = "button"; editBtn.className = "ghost ex-edit"; editBtn.textContent = "Edit"; editBtn.addEventListener("click", (e) => { e.stopPropagation(); openExerciseDialog(ex); }); detail.appendChild(editBtn); } li.appendChild(row); li.appendChild(detail); list.appendChild(li); } renderTrainingHeatmap(exercises, events); } function renderDayBar() { const day = selectedDay(); const isToday = ymd(day) === ymd(new Date()); document.getElementById("day-next").disabled = isToday; document.getElementById("day-today").disabled = isToday; // Year-less date on the picker's face — the year is implicit and the // saved width keeps the bar on one row on small phones. document.getElementById("day-date-face").textContent = day.toLocaleDateString(undefined, { month: "short", day: "numeric" }); const title = document.getElementById("overview-title"); if (title) { title.textContent = isToday ? "Today's overview" : day.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" }); } renderExcludeToggle(day); } // The "not counted" switch, and the overview's own dimmed state while it is // on, so the mark is legible from the day itself and not only from the charts. function renderExcludeToggle(day) { const btn = document.getElementById("exclude-day"); const note = document.getElementById("excluded-note"); const panel = document.querySelector('[data-panel="overview"]'); // Deciding a day doesn't count is a judgment about the owner's own record, // so it sits with the rest of what a guest cannot do. btn.hidden = isGuest(); const off = isExcluded(day); btn.setAttribute("aria-pressed", String(off)); btn.classList.toggle("active", off); note.hidden = !off; panel.classList.toggle("day-excluded", off); } // Marking is adding an event; un-marking is deleting it — so both ride the // ordinary sync, offline included, with no special casing anywhere. function toggleExcludedDay(day) { const key = ymd(day); const existing = live().filter( e => e.type === EXCLUDED_TYPE && ymd(new Date(e.at)) === key); if (existing.length > 0) { // Plural in principle: two devices could each mark the same day offline. for (const e of existing) deleteEvent(e.id); } else { // Noon, so the day it lands on survives any clock or timezone wobble. const at = startOfDay(day); at.setHours(12, 0, 0, 0); addEvent(EXCLUDED_TYPE, "", at.getTime()); } } function renderHeader() { const cfg = loadConfig(); const title = document.getElementById("app-title"); const ageEl = document.getElementById("puppy-age"); title.textContent = cfg.name ? `🐶 ${cfg.name}` : "🐶 Puppy Tracker"; document.title = cfg.name ? `${cfg.name} · Puppy Tracker` : "Puppy Tracker"; const ageText = formatAgeShort(cfg.birthday); ageEl.textContent = ageText; ageEl.hidden = !ageText; // Use the puppy's name in the sleep-timeline heading rather than assuming a // gender; fall back to a neutral phrase when no name is configured. const sleepTitle = document.getElementById("sleep-timeline-title"); if (sleepTitle) sleepTitle.textContent = cfg.name ? `When ${cfg.name} sleeps` : "When sleeping"; const walkTitle = document.getElementById("walk-timeline-title"); if (walkTitle) walkTitle.textContent = cfg.name ? `When ${cfg.name} walks` : "When walking"; // The guest banner names the dog too, so it follows the profile in. renderGuestBanner(); } // Dim the quick actions that don't fit the current state — a nudge // toward the likely next tap. A boundary that would just repeat the latest // one of its pair (a second "sleep start" while already asleep, a "walk end" // with no walk running) is disabled outright, since it can only produce a // zero-length window. Everything else stays clickable so // corrections (a mid-nap pee) are never blocked, and a genuinely missed // boundary is still fixable from the event log, which accepts any time. function renderActionHints(events) { const { state } = currentSleepState(events); const { walking } = currentWalkState(events); document.querySelectorAll("button.action").forEach(btn => { const type = btn.dataset.type; const isWalk = type === "walk-start" || type === "walk-end"; // Walks are a start→end pair of their own, so they answer to the walk // state rather than the sleep one: only the boundary that flips it can // produce a window. Mid-walk, ending it is the obvious next tap. const repeat = isWalk ? (walking ? type === "walk-start" : type === "walk-end") : state === "asleep" ? type === "sleep-start" : state === "awake" ? type === "sleep-end" : false; // no sleep history yet — either boundary is a fine first event const unlikely = !repeat && ( isWalk ? (!walking && state === "asleep") : state === "asleep" ? type !== "sleep-end" : state === "awake" ? type === "sleep-end" : false // no sleep history yet — no hints to give ); btn.classList.toggle("unlikely", unlikely); btn.disabled = repeat; btn.title = !repeat ? "" : isWalk ? (walking ? "Already on a walk" : "No walk in progress") : type === "sleep-start" ? "Already asleep" : "Already awake"; }); } function render() { const events = live(); // Which days don't count is read straight off the event list, and settles // before anything draws — the chart drawers consult excludedSet directly. excludedSet = excludedDays(events); const counted = countedEvents(events); renderHeader(); renderDayBar(); renderChartWindow(); // Day-scoped panels get the full list: you navigated to this day, so you // should see what is actually on it, marked or not. renderBigClock(events); renderActionHints(events); renderStats(events); renderLasts(events); renderSleepWake(events); renderWalks(events); renderHistory(events); // Weight and notes are records of fact rather than behaviour a sparse // logger distorts, so they count everywhere regardless. renderWeight(events); renderNotes(events); // Everything that aggregates across days works from the counted list. renderTiming(counted); renderWeekly(counted); renderSleepTimeline(counted); renderWalkPatterns(counted); renderSleepTrend(counted); renderHourHeatmap(counted); renderTraining(counted); } // ---------- sync ---------- let syncTimer = null; let syncing = false; let lastError = null; let lastSynced = 0; function setStatus(state) { statusEl.classList.remove("offline", "syncing", "error", "pending"); if (!navigator.onLine) { statusEl.textContent = "offline"; statusEl.classList.add("offline"); return; } switch (state) { case "syncing": statusEl.textContent = "syncing…"; statusEl.classList.add("syncing"); break; case "error": statusEl.textContent = "sync error"; statusEl.classList.add("error"); statusEl.title = lastError || ""; break; case "pending": statusEl.textContent = "pending"; statusEl.classList.add("pending"); break; default: statusEl.textContent = lastSynced ? `synced ${formatRelative(lastSynced)}` : "synced"; statusEl.title = ""; } } function scheduleSync() { if (!navigator.onLine) { setStatus("pending"); return; } setStatus("pending"); clearTimeout(syncTimer); syncTimer = setTimeout(sync, SYNC_DEBOUNCE_MS); } // Merge a server response back into local storage. Anything local with a // newer updatedAt than the server's copy wins — that covers items the user // added/edited during the in-flight sync request. Shared by the events and // exercises collections, which follow the same LWW contract. // // serverWins overrides that for items we are not allowed to change: the // server will have refused the write, so keeping our newer local copy would // leave the screen showing an edit that never happened and re-posting it // forever. Taking the server's version instead makes the client heal itself. function mergeSynced(serverItems, load, save, serverWins = () => false) { const localById = new Map(load().map(e => [e.id, e])); const merged = new Map(); for (const se of serverItems) { if (se && se.id) merged.set(se.id, se); } for (const [id, le] of localById) { const se = merged.get(id); if (se && serverWins(se)) continue; if (!se || (le.updatedAt || 0) > (se.updatedAt || 0)) { merged.set(id, le); } } save([...merged.values()]); } async function sync() { if (!currentUser) return; if (syncing) return; if (!navigator.onLine) { setStatus("pending"); return; } syncing = true; setStatus("syncing"); try { // Push queued photos first so events that reference them won't return // 404s when other clients try to fetch. await syncPhotos(); const res = await fetch(SYNC_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ events: loadAll() }), }); if (res.status === 401) { handleLoggedOut(); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = await res.json(); const exRes = await fetch("api/exercises/sync", { method: "POST", headers: { "Content-Type": "application/json" }, // A guest receives the exercise library but never writes to it, so it // sends nothing rather than posting a list the server would discard. body: JSON.stringify({ exercises: isGuest() ? [] : loadExercises() }), }); if (exRes.status === 401) { handleLoggedOut(); return; } if (!exRes.ok) throw new Error(`HTTP ${exRes.status}`); const exBody = await exRes.json(); if (Array.isArray(exBody.exercises)) { // Same reasoning as the events below: a guest's local exercise edits // can never land, so the server's copy is always the truth. mergeSynced(exBody.exercises, loadExercises, saveExercises, isGuest); } if (Array.isArray(body.events)) { mergeSynced(body.events, loadAll, saveAll, (ev) => !canEditEvent(ev)); } lastSynced = Date.now(); lastError = null; render(); setStatus("synced"); } catch (err) { lastError = err.message || String(err); console.warn("sync failed:", lastError); setStatus("error"); } finally { syncing = false; } } // ---------- config sync ---------- // Reconcile the local config cache with the host. Whichever side has the // newer updatedAt wins: adopt the server's copy, or push ours if it's ahead // (e.g. edited on this device while another client hadn't changed it). This // self-heals a failed push — the local copy stays newer and re-pushes next tick. async function syncConfig() { if (!currentUser) return; if (!navigator.onLine) return; const local = loadConfig(); try { const res = await fetch("api/config"); if (res.status === 401) { handleLoggedOut(); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = await res.json(); const server = { name: body.name || "", birthday: body.birthday || "", pedigreeId: body.pedigreeId || "", updatedAt: Number.isFinite(body.updatedAt) ? body.updatedAt : 0, }; const merged = reconcileConfig(local, server); if (!sameConfig(merged, local)) { saveConfig(merged); renderHeader(); refreshPedigreeButton(); } if (!sameConfig(merged, server)) { await pushConfig(merged); } } catch (err) { console.warn("config sync failed:", err); } } async function pushConfig(cfg) { if (!navigator.onLine) return; const res = await fetch("api/config", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(cfg), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = await res.json(); // Adopt the server's answer if it turned out to be newer (another client won). if (Number.isFinite(body.updatedAt) && body.updatedAt > cfg.updatedAt) { saveConfig({ name: body.name || "", birthday: body.birthday || "", pedigreeId: body.pedigreeId || "", updatedAt: body.updatedAt }); renderHeader(); } } // ---------- dialogs ---------- const noteDialog = document.getElementById("note-dialog"); const noteForm = document.getElementById("note-form"); const noteInput = document.getElementById("note-input"); const noteDate = document.getElementById("note-date"); const noteTime = document.getElementById("note-time"); const noteTitle = document.getElementById("note-title"); const notePhotoInput = document.getElementById("note-photo-input"); const notePhotoBtn = document.getElementById("note-photo-btn"); const notePhotoPreview = document.getElementById("note-photo-preview"); const noteWeightField = document.getElementById("note-weight-field"); const noteWeight = document.getElementById("note-weight"); const noteGramsField = document.getElementById("note-grams-field"); const noteGrams = document.getElementById("note-grams"); let pendingType = null; let notePhotos = []; // pending photos for this dialog: [{ blob, url }] // Whether the user has manually touched the date/time fields. While false the // dialog logs at the exact current millisecond rather than the minute-floored // input, so a just-logged event doesn't look up to ~59s old. let noteTimeEdited = false; // An event can carry several photos: photoId holds their UUIDs // comma-separated. A legacy single id is just a one-element list, and the // server passes the string through untouched (photos themselves are // uploaded and fetched individually by UUID). function photoIdsOf(ev) { return (ev.photoId || "").split(",").filter(Boolean); } // A preview thumbnail with a remove button; shared by both dialogs. function photoThumb(url, onRemove) { const wrap = document.createElement("div"); wrap.className = "photo-thumb"; const img = document.createElement("img"); img.alt = ""; if (url) img.src = url; const rm = document.createElement("button"); rm.type = "button"; rm.className = "photo-thumb-remove"; rm.setAttribute("aria-label", "Remove photo"); rm.textContent = "×"; rm.addEventListener("click", onRemove); wrap.appendChild(img); wrap.appendChild(rm); return wrap; } function renderNotePhotos() { notePhotoPreview.innerHTML = ""; notePhotoPreview.hidden = notePhotos.length === 0; notePhotos.forEach((p, i) => { notePhotoPreview.appendChild(photoThumb(p.url, () => { URL.revokeObjectURL(p.url); notePhotos.splice(i, 1); renderNotePhotos(); })); }); } function clearNotePhotos() { for (const p of notePhotos) URL.revokeObjectURL(p.url); notePhotos = []; renderNotePhotos(); notePhotoInput.value = ""; } function openNoteDialog(type) { pendingType = type; noteInput.value = ""; const isNote = type === "note"; // A note is about a day, so default it to the day you're viewing (at the // current clock time). The logging types default to "now"; leaving // noteTimeEdited false lets noteDialogAt() stamp the exact instant. let base = Date.now(); if (isNote) { const day = selectedDay(); const now = new Date(); day.setHours(now.getHours(), now.getMinutes(), 0, 0); base = day.getTime(); } noteTimeEdited = isNote; noteDate.value = toDateInput(base); noteTime.value = toTimeInput(base); noteTitle.textContent = isNote ? "Add note" : `Log ${EVENT_LABELS[type]}`; const isWeight = type === "weight"; const isEat = type === "eat"; noteWeightField.hidden = !isWeight; noteWeight.value = ""; noteGramsField.hidden = !isEat; noteGrams.value = ""; clearNotePhotos(); noteDialog.showModal(); setTimeout(() => (isWeight ? noteWeight : isEat ? noteGrams : noteInput).focus(), 50); } function noteDialogAt() { // Untouched time → stamp the exact current instant (sub-minute accurate). // Once the user picks a time, honor the input (minute precision is fine). if (!noteTimeEdited) return Date.now(); const parsed = fromDateTimeInputs(noteDate.value, noteTime.value); return Number.isFinite(parsed) ? parsed : Date.now(); } document.getElementById("note-time-now").addEventListener("click", () => { const now = Date.now(); noteDate.value = toDateInput(now); noteTime.value = toTimeInput(now); noteTimeEdited = false; // "Now" means log at the current instant again }); [noteDate, noteTime].forEach(el => { const markEdited = () => { noteTimeEdited = true; }; el.addEventListener("change", markEdited); el.addEventListener("input", markEdited); }); notePhotoBtn.addEventListener("click", () => notePhotoInput.click()); notePhotoInput.addEventListener("change", async (e) => { for (const file of Array.from(e.target.files || [])) { try { const blob = await resizeImage(file); notePhotos.push({ blob, url: URL.createObjectURL(blob) }); } catch (err) { alert("Couldn't process that photo: " + err.message); } } notePhotoInput.value = ""; renderNotePhotos(); }); document.getElementById("note-save").addEventListener("click", async (e) => { e.preventDefault(); if (!pendingType) { noteDialog.close(); return; } if (pendingType === "note" && noteInput.value.trim() === "" && notePhotos.length === 0) { alert("Write something for the note, or add a photo."); return; } let weight; if (pendingType === "weight") { weight = parseFloat(noteWeight.value); if (!(weight > 0)) { alert("Enter a weight in kilograms."); return; } weight = Math.round(weight * 100) / 100; } let grams; if (pendingType === "eat" && noteGrams.value.trim() !== "") { const g = parseFloat(noteGrams.value); if (!(g > 0)) { alert("Enter the amount in grams, or leave it empty."); return; } grams = Math.round(g); } const photoIds = []; for (const p of notePhotos) { const id = uuid(); try { await putPhoto(id, p.blob, false); } catch (err) { alert("Couldn't store photo locally: " + err.message); return; } photoIds.push(id); } addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), { photoId: photoIds.join(","), weight, grams }); pendingType = null; clearNotePhotos(); noteDialog.close(); }); noteForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => { e.preventDefault(); pendingType = null; clearNotePhotos(); noteDialog.close(); }); // Edit dialog const editDialog = document.getElementById("edit-dialog"); const editForm = document.getElementById("edit-form"); const editDate = document.getElementById("edit-date"); const editTime = document.getElementById("edit-time"); const editNote = document.getElementById("edit-note"); const editLoggedBy = document.getElementById("edit-logged-by"); const editReadOnly = document.getElementById("edit-readonly"); const editTitle = document.getElementById("edit-title"); const editDelete = document.getElementById("edit-delete"); const editSave = document.querySelector('#edit-form button[value="save"]'); const editCancel = document.querySelector('#edit-form button[value="cancel"]'); const editPhotoInput = document.getElementById("edit-photo-input"); const editPhotoBtn = document.getElementById("edit-photo-btn"); const editPhotoPreview = document.getElementById("edit-photo-preview"); const editWeightField = document.getElementById("edit-weight-field"); const editWeight = document.getElementById("edit-weight"); const editGramsField = document.getElementById("edit-grams-field"); const editGrams = document.getElementById("edit-grams"); let editingId = null; // The dialog's working set of photos, in display order. Existing photos are // { id, url } (url from photoSrc's page-lifetime cache — never revoked here); // newly picked ones are { blob, url } with a fresh object URL we own. let editPhotos = []; function renderEditPhotos() { editPhotoPreview.innerHTML = ""; editPhotoPreview.hidden = editPhotos.length === 0; editPhotos.forEach((p, i) => { editPhotoPreview.appendChild(photoThumb(p.url, () => { if (p.blob) URL.revokeObjectURL(p.url); editPhotos.splice(i, 1); renderEditPhotos(); })); }); } function resetEditPhotos() { for (const p of editPhotos) if (p.blob) URL.revokeObjectURL(p.url); editPhotos = []; editPhotoInput.value = ""; } // Turns the edit dialog into a viewer: every field disabled, Save and Delete // gone, only Cancel left. Used when a guest opens an entry that isn't theirs // — the server would refuse the change anyway (Store.sync), and a form that // silently discards what you typed is worse than one that says it is closed. function setEditReadOnly(on) { editReadOnly.hidden = !on; editTitle.textContent = on ? "Event" : "Edit event"; for (const el of [editDate, editTime, editNote, editWeight, editGrams, editPhotoBtn]) { el.disabled = on; } editDelete.hidden = on; editSave.hidden = on; editCancel.textContent = on ? "Close" : "Cancel"; } async function openEditDialog(ev) { editingId = ev.id; editLoggedBy.hidden = !ev.loggedBy; if (ev.loggedBy) editLoggedBy.textContent = `Logged by ${ev.loggedBy} on a guest link.`; setEditReadOnly(!canEditEvent(ev)); editDate.value = toDateInput(ev.at); editTime.value = toTimeInput(ev.at); editNote.value = ev.note || ""; editWeightField.hidden = ev.type !== "weight"; editWeight.value = (ev.type === "weight" && Number.isFinite(ev.weight)) ? ev.weight : ""; editGramsField.hidden = ev.type !== "eat"; editGrams.value = (ev.type === "eat" && Number.isFinite(ev.grams) && ev.grams > 0) ? ev.grams : ""; resetEditPhotos(); for (const id of photoIdsOf(ev)) { editPhotos.push({ id, url: await photoSrc(id) }); } renderEditPhotos(); editDialog.showModal(); // Without an explicit focus the browser autofocuses the first control — // the date input — and iOS immediately pops the calendar over the form. // Land on the first relevant text field instead (same trick and delay as // openNoteDialog: synchronous focus can be lost while the dialog settles). const focusTarget = !editWeightField.hidden ? editWeight : !editGramsField.hidden ? editGrams : editNote; setTimeout(() => focusTarget.focus(), 50); } editPhotoBtn.addEventListener("click", () => editPhotoInput.click()); editPhotoInput.addEventListener("change", async (e) => { for (const file of Array.from(e.target.files || [])) { try { const blob = await resizeImage(file); editPhotos.push({ blob, url: URL.createObjectURL(blob) }); } catch (err) { alert("Couldn't process that photo: " + err.message); } } editPhotoInput.value = ""; renderEditPhotos(); }); editForm.querySelector('button[value="save"]').addEventListener("click", async (e) => { e.preventDefault(); if (!editingId) { editDialog.close(); return; } const newAt = fromDateTimeInputs(editDate.value, editTime.value); const patch = { at: Number.isFinite(newAt) ? newAt : undefined, note: editNote.value.trim(), }; if (!editWeightField.hidden) { const kg = parseFloat(editWeight.value); if (!(kg > 0)) { alert("Enter a weight in kilograms."); return; } patch.weight = Math.round(kg * 100) / 100; } if (!editGramsField.hidden) { if (editGrams.value.trim() === "") { patch.grams = undefined; // cleared → drop the amount } else { const g = parseFloat(editGrams.value); if (!(g > 0)) { alert("Enter the amount in grams, or leave it empty."); return; } patch.grams = Math.round(g); } } const photoIds = []; for (const p of editPhotos) { if (p.id) { photoIds.push(p.id); continue; } const id = uuid(); try { await putPhoto(id, p.blob, false); } catch (err) { alert("Couldn't store photo locally: " + err.message); return; } photoIds.push(id); } patch.photoId = photoIds.join(","); updateEvent(editingId, patch); editingId = null; resetEditPhotos(); editDialog.close(); }); editForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => { e.preventDefault(); editingId = null; resetEditPhotos(); editDialog.close(); }); editDelete.addEventListener("click", (e) => { e.preventDefault(); if (editingId && confirm("Delete this event?")) { deleteEvent(editingId); } editingId = null; resetEditPhotos(); editDialog.close(); }); // Settings dialog (puppy name + birthday) // ---------- theme ---------- // Preference is device-global (not per user). No stored choice → follow the // OS via the prefers-color-scheme media query; a choice sets data-theme on // , 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"); const settingsProfile = document.getElementById("settings-profile"); const settingsDanger = document.getElementById("settings-danger"); // Apply live so the toggle previews immediately (independent of Save/Cancel). settingsTheme.addEventListener("change", () => { setTheme(settingsTheme.checked ? "dark" : "light"); }); function openSettingsDialog() { const cfg = loadConfig(); settingsName.value = cfg.name; settingsBirthday.value = cfg.birthday; settingsPedigree.value = cfg.pedigreeId; settingsTheme.checked = effectiveTheme() === "dark"; settingsConfetti.checked = confettiEnabled(); // A guest keeps the two device-local preferences and loses everything that // belongs to the account — profile, reminders, guest links, deletion. const guest = isGuest(); settingsProfile.hidden = guest; settingsDanger.hidden = guest; guestAccess.hidden = guest; settingsDialog.showModal(); refreshRemindersUI(); if (!guest) { // Re-defaulted per open: the app can sit on screen for days, and a date // that was "tomorrow" when it launched may be in the past by now. resetGuestExpiry(); refreshGuestLinks(); } setTimeout(() => (guest ? settingsTheme : settingsName).focus(), 50); } document.getElementById("settings-btn").addEventListener("click", openSettingsDialog); settingsForm.querySelector('button[value="save"]').addEventListener("click", async (e) => { e.preventDefault(); // A guest only ever had the two device-local toggles on screen. Saving the // profile from here would push the blanked-out fields over the owner's. if (isGuest()) { setConfettiEnabled(settingsConfetti.checked); settingsDialog.close(); return; } const cfg = { name: settingsName.value.trim(), birthday: settingsBirthday.value, pedigreeId: settingsPedigree.value.trim(), updatedAt: Date.now(), }; saveConfig(cfg); // cache locally for instant + offline paint setConfettiEnabled(settingsConfetti.checked); // device-local, not synced renderHeader(); refreshPedigreeButton(); settingsDialog.close(); // Rules are saved with the rest of Settings; the notification toggle itself // already acted when it was flipped, since permission needs a user gesture. if (!remindersSection.hidden && !remindersRules.hidden) { try { await saveReminderRules(collectReminderRules()); } catch (err) { console.warn("saving reminders failed:", err); } } try { await pushConfig(cfg); } catch (err) { console.warn("config save failed:", err); // Kept locally; syncConfig retries automatically once the host is reachable. } }); settingsForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => { e.preventDefault(); settingsDialog.close(); }); // ---------- guest links ---------- // Hand someone a URL that logs events on this account without giving them the // password. The server holds only a hash of the token (server/auth.go), so the // URL is shown once, right after it is minted, and never again. const guestAccess = document.getElementById("guest-access"); const guestLabelIn = document.getElementById("guest-label"); const guestCreate = document.getElementById("guest-create"); const guestError = document.getElementById("guest-error"); const guestListEl = document.getElementById("guest-list"); const guestEmpty = document.getElementById("guest-empty"); const guestExpires = document.getElementById("guest-expires"); const guestExpHint = document.getElementById("guest-expires-hint"); // The owner picks a date; the link dies at the end of it. Working in whole // days is what people actually mean ("she has him until Sunday"), and doing // the conversion here is the only place the guest's timezone is known — the // server just stores the instant it is handed. function guestExpiryMs() { const [y, m, d] = (guestExpires.value || "").split("-").map(Number); if (!y || !m || !d) return 0; return endOfDay(new Date(y, m - 1, d)).getTime(); } function renderGuestExpiryHint() { const ms = guestExpiryMs(); if (!ms) { guestExpHint.textContent = "Pick the last day the link should work."; return; } if (ms <= Date.now()) { guestExpHint.textContent = "That date has already passed."; return; } guestExpHint.textContent = `Stops working ${formatWhen(ms)}.`; } // Default to tomorrow: the common case is a sitter for the day, and a link // that dies at midnight tonight is rarely what anyone wants. function resetGuestExpiry() { guestExpires.min = toDateInput(Date.now()); guestExpires.value = toDateInput(Date.now() + 86_400_000); renderGuestExpiryHint(); } guestExpires.addEventListener("change", renderGuestExpiryHint); resetGuestExpiry(); // The server never sees its own public origin (it may sit behind any proxy), // so the URL is composed here, against wherever this page is actually served. function guestURL(token) { return new URL(`guest/${token}`, location.href).href; } // Day and month as well as the weekday: a 7-day link expires on the same // weekday it was made, which "Sun 09:00" alone wouldn't tell apart. function formatWhen(ms) { return new Date(ms).toLocaleString(undefined, { weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit", }); } function renderGuestLinks(links) { guestListEl.innerHTML = ""; guestEmpty.textContent = "No active links."; // may hold a stale error guestEmpty.hidden = links.length > 0; for (const l of links) { const li = document.createElement("li"); li.className = "guest-item"; const used = l.lastUsed ? `last used ${formatRelative(l.lastUsed)}` : "never used"; li.innerHTML = `
`; li.querySelector(".guest-item-label").textContent = l.label; li.querySelector(".guest-item-sub").textContent = `expires ${formatWhen(l.expires)} · ${used}`; const revoke = document.createElement("button"); revoke.type = "button"; revoke.className = "linklike guest-revoke"; revoke.textContent = "Revoke"; revoke.addEventListener("click", () => revokeGuestLink(l.id, l.label)); li.querySelector(".guest-item-row").appendChild(revoke); // The URL stays available for as long as the link does, so it can be // re-sent without minting a new one and stranding whoever holds the old. if (l.token) { const url = guestURL(l.token); const box = document.createElement("div"); box.className = "guest-item-url"; const code = document.createElement("code"); code.className = "guest-url"; code.textContent = url; code.title = url; // the line is truncated; the tooltip isn't // Tapping the URL selects the whole of it, so it can be copied by hand // where the clipboard API isn't available (it needs a secure context). code.addEventListener("click", () => selectContents(code)); const copy = document.createElement("button"); copy.type = "button"; copy.className = "ghost guest-copy"; copy.textContent = "Copy"; copy.addEventListener("click", () => copyGuestURL(copy, code)); box.appendChild(code); box.appendChild(copy); li.appendChild(box); } else { // Links minted before the URL was kept: only their hash was stored, so // there is nothing to show. Revoking and making a new one is the fix. const note = document.createElement("p"); note.className = "settings-hint guest-item-url"; note.textContent = "Made before links could be re-shown — revoke it and create a new one to get a copyable URL."; li.appendChild(note); } guestListEl.appendChild(li); } } async function refreshGuestLinks() { guestError.hidden = true; try { const res = await fetch("api/shares"); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = await res.json(); renderGuestLinks(Array.isArray(body.links) ? body.links : []); } catch (err) { // Offline is the common case here and not worth an error: the list is // server state, so it simply isn't knowable right now. guestListEl.innerHTML = ""; guestEmpty.hidden = false; guestEmpty.textContent = navigator.onLine ? "Couldn't load your links." : "Offline — guest links need a connection."; } } guestCreate.addEventListener("click", async () => { guestError.hidden = true; const expires = guestExpiryMs(); if (!expires || expires <= Date.now()) { guestError.textContent = "Pick a date in the future for the link to stop working."; guestError.hidden = false; return; } guestCreate.disabled = true; try { const res = await fetch("api/shares", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ label: guestLabelIn.value.trim(), expires }), }); if (!res.ok) throw new Error((await res.text()).trim() || `HTTP ${res.status}`); await res.json(); guestLabelIn.value = ""; resetGuestExpiry(); // Nothing special to do with the new link: the list shows every link's // URL, so it simply appears there like the rest. await refreshGuestLinks(); } catch (err) { guestError.textContent = err.message || "Couldn't create a link"; guestError.hidden = false; } finally { guestCreate.disabled = false; } }); function selectContents(el) { const range = document.createRange(); range.selectNodeContents(el); const sel = getSelection(); sel.removeAllRanges(); sel.addRange(range); } async function copyGuestURL(btn, code) { try { await navigator.clipboard.writeText(code.textContent); btn.textContent = "Copied"; setTimeout(() => { btn.textContent = "Copy"; }, 1500); } catch { // The clipboard API needs a secure context and can be refused outright. // The URL is on screen either way, so select it and let them copy by hand. selectContents(code); } } async function revokeGuestLink(id, label) { if (!confirm(`Turn off the link for “${label}”? Whoever has it loses access straight away.`)) return; try { const res = await fetch(`api/shares/${encodeURIComponent(id)}`, { method: "DELETE" }); if (!res.ok && res.status !== 404) throw new Error(`HTTP ${res.status}`); await refreshGuestLinks(); } catch (err) { guestError.textContent = err.message || "Couldn't revoke that link"; guestError.hidden = false; } } // ---------- reminders ---------- // The server decides when a reminder is due and pushes it (server/reminders.go); // this side only manages the browser's push subscription and the rule settings. // Nothing here can fire a notification on its own — a closed PWA has no timers, // which is the whole reason the evaluation lives on the host. const REMINDER_LABELS = { sleep: "Time to sleep, awake for", pee: "Time for pee, none for", poo: "Time for poo, none for", eat: "Time for a meal, none for", }; const remindersSection = document.getElementById("reminders-section"); const remindersToggle = document.getElementById("reminders-enabled"); const remindersHint = document.getElementById("reminders-hint"); const remindersRules = document.getElementById("reminders-rules"); const remindersTest = document.getElementById("reminders-test"); let pushKey = null; // VAPID public key, once the server has given us one let reminderRules = []; // last-known rule set, re-rendered into the dialog // iOS only exposes push to a PWA that was added to the Home Screen; in a plain // Safari tab PushManager doesn't exist at all, so the toggle would be dead. const isStandalone = () => window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true; const isIOS = () => /iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); const pushSupported = () => "serviceWorker" in navigator && "PushManager" in window && "Notification" in window; // applicationServerKey wants raw bytes, not the base64url the server sends. function b64UrlToBytes(s) { const pad = "=".repeat((4 - (s.length % 4)) % 4); const bin = atob((s + pad).replace(/-/g, "+").replace(/_/g, "/")); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } function bytesToB64Url(bytes) { let bin = ""; for (const b of bytes) bin += String.fromCharCode(b); return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } // subscribeForPush returns a live subscription, registering it with the host. // It runs on every launch, not just when the toggle is flipped: iOS silently // drops subscriptions, and a stale endpoint fails invisibly until re-posted. async function subscribeForPush() { const reg = await navigator.serviceWorker.ready; let sub = await reg.pushManager.getSubscription(); // A subscription made under a different VAPID key can't be reused — the // browser rejects re-subscribing with a new key — so drop it first. Only // when we can positively see a mismatch, though: if a browser doesn't // expose options, re-subscribing blindly would mint a fresh endpoint on // every launch and strand the old row on the server. if (sub) { const existing = sub.options && sub.options.applicationServerKey; if (existing && bytesToB64Url(new Uint8Array(existing)) !== pushKey) { try { await sub.unsubscribe(); } catch { /* ignore */ } sub = null; } } if (!sub) { sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64UrlToBytes(pushKey), }); } const res = await fetch("api/push/subscribe", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(sub), }); if (!res.ok) throw new Error(`subscribe failed: ${res.status}`); return sub; } async function unsubscribeFromPush() { const reg = await navigator.serviceWorker.ready; const sub = await reg.pushManager.getSubscription(); if (!sub) return; // Tell the host first: if the local unsubscribe succeeds but the POST never // lands, the server would keep pushing to an endpoint nobody listens on. try { await fetch("api/push/unsubscribe", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ endpoint: sub.endpoint }), }); } catch { /* the endpoint dies on its own once the browser drops it */ } try { await sub.unsubscribe(); } catch { /* ignore */ } } async function loadReminderRules() { const res = await fetch("api/reminders"); if (!res.ok) throw new Error(`reminders: ${res.status}`); const body = await res.json(); reminderRules = body.reminders || []; return reminderRules; } async function saveReminderRules(rules) { const res = await fetch("api/reminders", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reminders: rules }), }); if (!res.ok) throw new Error(`reminders: ${res.status}`); // The server clamps intervals, so adopt what it actually stored. reminderRules = (await res.json()).reminders || rules; } function renderReminderRules() { remindersRules.replaceChildren(); for (const rule of reminderRules) { const row = document.createElement("div"); row.className = "reminder-row"; row.dataset.kind = rule.kind; const name = document.createElement("label"); name.className = "reminder-name"; const on = document.createElement("input"); on.type = "checkbox"; on.className = "switch reminder-on"; on.setAttribute("role", "switch"); on.checked = rule.enabled; const text = document.createElement("span"); text.textContent = REMINDER_LABELS[rule.kind] || rule.kind; name.append(on, text); const after = document.createElement("span"); after.className = "reminder-after"; const mins = document.createElement("input"); mins.type = "number"; mins.className = "reminder-mins"; mins.min = "5"; mins.max = "1440"; mins.step = "5"; mins.value = String(rule.intervalMin); const unit = document.createElement("span"); unit.textContent = "min"; after.append(mins, unit); row.append(name, after); remindersRules.append(row); } } // collectReminderRules reads the dialog back into the rule shape the API takes. function collectReminderRules() { return [...remindersRules.querySelectorAll(".reminder-row")].map((row) => ({ kind: row.dataset.kind, enabled: row.querySelector(".reminder-on").checked, intervalMin: Number(row.querySelector(".reminder-mins").value) || 60, })); } function setReminderHint(text) { remindersHint.textContent = text || ""; remindersHint.hidden = !text; } // Reflects permission + subscription state: the rules only matter once there // is somewhere to deliver them. function showReminderControls(subscribed) { remindersToggle.checked = subscribed; remindersRules.hidden = !subscribed; remindersTest.hidden = !subscribed; } // initReminders runs once at startup. It establishes whether reminders are // available at all (the server may have no VAPID key, the browser may have no // push support) and refreshes an existing subscription. async function initReminders() { // Reminders belong to the account, not the device: a guest subscribing here // would route the owner's reminders to the sitter's lock screen. The server // refuses these routes for a guest anyway; bailing early keeps the section // from flashing up on iOS, where it appears before any request is made. if (isGuest()) return; if (!pushSupported()) { // On iOS this is the Home Screen requirement rather than a missing feature, // and it's worth saying so — the toggle is otherwise just absent. if (isIOS() && !isStandalone()) { remindersSection.hidden = false; remindersToggle.disabled = true; setReminderHint("Add Puppy Tracker to your Home Screen to enable reminders."); } return; } try { const res = await fetch("api/push/key"); if (!res.ok) return; // host has push disabled; leave the section hidden pushKey = (await res.json()).key; } catch { return; // offline at boot: try again next launch } if (!pushKey) return; remindersSection.hidden = false; if (Notification.permission !== "granted") { showReminderControls(false); return; } try { await subscribeForPush(); showReminderControls(true); } catch (err) { console.warn("push re-subscribe failed:", err); showReminderControls(false); } } // The toggle acts immediately rather than on Save: requesting notification // permission has to happen inside a user gesture, and on iOS a deferred // request is simply ignored. remindersToggle.addEventListener("change", async () => { if (!remindersToggle.checked) { setReminderHint(""); showReminderControls(false); await unsubscribeFromPush(); return; } remindersToggle.disabled = true; try { const permission = await Notification.requestPermission(); if (permission !== "granted") { showReminderControls(false); setReminderHint( permission === "denied" ? "Notifications are blocked for this app in your browser settings." : "Notifications need permission to work." ); return; } await subscribeForPush(); await loadReminderRules(); renderReminderRules(); showReminderControls(true); setReminderHint(""); } catch (err) { console.warn("enabling reminders failed:", err); showReminderControls(false); setReminderHint("Couldn't enable reminders. Try again once you're online."); } finally { remindersToggle.disabled = false; } }); remindersTest.addEventListener("click", async () => { remindersTest.disabled = true; try { const res = await fetch("api/push/test", { method: "POST" }); setReminderHint(res.ok ? "Test sent." : "Couldn't send a test notification."); } catch { setReminderHint("Couldn't send a test notification."); } finally { remindersTest.disabled = false; } }); // Called when the settings dialog opens, so the rows show what the host has. async function refreshRemindersUI() { if (remindersSection.hidden || !pushKey) return; try { await loadReminderRules(); renderReminderRules(); } catch (err) { console.warn("loading reminders failed:", err); } } // ---------- pedigree lookup ---------- // A separate full-screen view that resolves a dog against SKK Hunddata by // chip / registration number / name and renders its ancestry as a tree. The // server returns the first generations immediately and crawls deeper in the // background; we poll for that and re-render as ancestors arrive. Online-only. const pedScreen = document.getElementById("pedigree-screen"); const pedStatus = document.getElementById("pedigree-status"); const pedChoose = document.getElementById("pedigree-choose"); const pedSubject = document.getElementById("pedigree-subject"); const pedTree = document.getElementById("pedigree-tree"); const pedBtn = document.getElementById("pedigree-btn"); const pedRefresh = document.getElementById("pedigree-refresh"); const pedRepeatNote = document.getElementById("pedigree-repeat-note"); const PED_OPEN_DEPTH = 3; // show 3 generations expanded by default; deeper collapses let pedPollTimer = null; let pedNodes = {}; // latest ancestry map, for the progress count // ---- zoom ---- // The tree gets very wide when expanded, so it's zoomable: buttons, ctrl/⌘ + // wheel, and pinch. We scale via the CSS `zoom` property (not transform) so the // scroll container reflows and every part stays reachable. The level persists. const pedZoomOut = document.getElementById("ped-zoom-out"); const pedZoomIn = document.getElementById("ped-zoom-in"); const pedZoomReset = document.getElementById("ped-zoom-reset"); const PED_ZOOM_MIN = 0.4, PED_ZOOM_MAX = 1.6; const pedZoomKey = () => `puppy-tracker:${currentUser.id}:pedigree-zoom:v1`; let pedZoom = 1; function applyPedZoom() { pedZoom = Math.min(PED_ZOOM_MAX, Math.max(PED_ZOOM_MIN, Math.round(pedZoom * 100) / 100)); pedTree.style.zoom = pedZoom; pedZoomReset.textContent = Math.round(pedZoom * 100) + "%"; try { localStorage.setItem(pedZoomKey(), String(pedZoom)); } catch { /* ignore */ } } function pedZoomBy(delta) { pedZoom += delta; applyPedZoom(); } pedZoomIn.addEventListener("click", () => pedZoomBy(0.2)); pedZoomOut.addEventListener("click", () => pedZoomBy(-0.2)); pedZoomReset.addEventListener("click", () => { pedZoom = 1; applyPedZoom(); }); // ---- collapse / expand all ---- const pedFoldAll = document.getElementById("ped-foldall"); function pedSetAll(collapsed) { pedTree.querySelectorAll("li").forEach((li) => { if (!li.querySelector(":scope > ul")) return; // no ancestors to fold li.classList.toggle("collapsed", collapsed); const t = li.querySelector(":scope > .ped-card > .ped-toggle"); if (t) t.textContent = collapsed ? "+" : "−"; }); pedFoldAll.textContent = collapsed ? "Expand all" : "Collapse all"; } pedFoldAll.addEventListener("click", () => pedSetAll(pedFoldAll.textContent[0] === "C")); // ---- view toggle: top-down tree vs radial fan ---- const pedViewBtn = document.getElementById("ped-view"); const pedCaption = document.getElementById("pedigree-caption"); const pedViewKey = () => `puppy-tracker:${currentUser.id}:pedigree-view:v1`; let pedView = "tree"; try { const v = localStorage.getItem(pedViewKey()); if (v === "fan" || v === "tree") pedView = v; } catch { /* ignore */ } // The fan has no per-branch folding, so hide that control in fan mode; the // toggle always offers the *other* view. function updatePedViewControls() { pedViewBtn.textContent = pedView === "fan" ? "Tree view" : "Fan view"; pedFoldAll.hidden = pedView === "fan"; if (pedView !== "fan" && pedCaption) { pedCaption.hidden = true; } } pedViewBtn.addEventListener("click", () => { pedView = pedView === "fan" ? "tree" : "fan"; try { localStorage.setItem(pedViewKey(), pedView); } catch { /* ignore */ } renderPedigree(pedNodes); }); // Trackpad/desktop: ctrl or ⌘ + wheel zooms instead of scrolling the page. pedTree.addEventListener("wheel", (e) => { if (!e.ctrlKey && !e.metaKey) return; e.preventDefault(); pedZoomBy(e.deltaY < 0 ? 0.1 : -0.1); }, { passive: false }); // Touch: two-finger pinch. let pinchDist = 0, pinchZoom = 1; const touchDist = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY); pedTree.addEventListener("touchstart", (e) => { if (e.touches.length === 2) { pinchDist = touchDist(e.touches); pinchZoom = pedZoom; } }, { passive: true }); pedTree.addEventListener("touchmove", (e) => { if (e.touches.length === 2 && pinchDist > 0) { e.preventDefault(); pedZoom = pinchZoom * (touchDist(e.touches) / pinchDist); applyPedZoom(); } }, { passive: false }); pedTree.addEventListener("touchend", (e) => { if (e.touches.length < 2) pinchDist = 0; }); // The looked-up tree is cached locally per dog id, so reopening the page paints // instantly and still shows the last-known tree offline. The server caches it // too (per dog, permanently); this is just the client-side mirror. const pedCacheKey = (id) => `puppy-tracker:${currentUser.id}:pedigree:${id}:v1`; function loadPedCache(id) { try { return JSON.parse(localStorage.getItem(pedCacheKey(id))) || null; } catch { return null; } } function savePedCache(id, subject, nodes) { try { localStorage.setItem(pedCacheKey(id), JSON.stringify({ subject, nodes })); } catch { /* ignore */ } } // Show the 🌳 button only once a pedigree id is set in Settings. function refreshPedigreeButton() { pedBtn.hidden = !loadConfig().pedigreeId; } function openPedigree() { const id = loadConfig().pedigreeId; appEl.hidden = true; pedScreen.hidden = false; const saved = parseFloat(localStorage.getItem(pedZoomKey())); pedZoom = Number.isFinite(saved) ? saved : 1; applyPedZoom(); if (!id) { setPedStatus("Set your dog's SKK id in Settings to see its pedigree.", ""); return; } lookupPedigree(id); } function closePedigree() { stopPedPoll(); pedScreen.hidden = true; appEl.hidden = false; } function stopPedPoll() { if (pedPollTimer) { clearTimeout(pedPollTimer); pedPollTimer = null; } } pedBtn.addEventListener("click", openPedigree); document.getElementById("pedigree-back").addEventListener("click", closePedigree); pedRefresh.addEventListener("click", () => { const id = loadConfig().pedigreeId; if (id) lookupPedigree(id); }); async function lookupPedigree(q) { stopPedPoll(); pedChoose.hidden = true; pedChoose.textContent = ""; pedSubject.hidden = true; pedSubject.textContent = ""; pedTree.textContent = ""; pedNodes = {}; // Paint the cached tree first so the page is instant (and works offline). const cached = loadPedCache(q); if (cached && cached.nodes) { renderSubject(cached.subject); renderPedigree(cached.nodes); } if (!navigator.onLine) { setPedStatus(cached ? "Offline — showing the last saved pedigree." : "Pedigree needs an internet connection.", cached ? "" : "error"); return; } setPedStatus(cached ? "Refreshing…" : "Looking up…", "busy"); let res; try { res = await fetch("api/pedigree", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ q }), }); } catch { setPedStatus(cached ? "Offline — showing the last saved pedigree." : "Couldn't reach the server. Try again.", cached ? "" : "error"); return; } if (res.status === 401) { handleLoggedOut(); return; } if (res.status === 404) { setPedStatus("Couldn't find that dog in SKK — check the ID in Settings.", "error"); return; } if (!res.ok) { const msg = (await res.text().catch(() => "")).trim(); setPedStatus(msg || `Lookup failed (HTTP ${res.status}).`, "error"); return; } const data = await res.json(); if (data.status === "choose") { renderChoose(data.matches || []); return; } renderSubject(data.subject); renderPedigree(data.nodes || {}); if (data.status === "done") { savePedCache(q, data.subject, data.nodes || {}); setPedDone(); } else { setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy"); pollPedigree(data.jobId, q, data.subject); } } function pollPedigree(jobId, q, subject) { stopPedPoll(); const tick = async () => { let res; try { res = await fetch(`api/pedigree/status?job=${encodeURIComponent(jobId)}`); } catch { pedPollTimer = setTimeout(tick, 3000); return; } if (res.status === 401) { handleLoggedOut(); return; } if (!res.ok) { setPedStatus("Lost track of the pedigree crawl.", "error"); return; } const data = await res.json(); renderPedigree(data.nodes || {}); if (data.status === "done") { savePedCache(q, subject, data.nodes || {}); setPedDone(); return; } if (data.status === "error") { setPedStatus(data.error || "Pedigree crawl failed.", "error"); return; } setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy"); pedPollTimer = setTimeout(tick, 1500); }; pedPollTimer = setTimeout(tick, 1500); } // Counts derived from the ancestry map we already hold, so the summary is // right whether it came from a fresh crawl, a poll, or a cache hit. Distinct // ancestors are keyed by registration number (pedigree collapse means one dog // fills many positions); "generations back" is the depth of the deepest // position (floor(log2(pos)), since sire = 2·pos and dam = 2·pos+1). function pedCounts() { const counts = new Map(); let maxPos = 1; for (const k in pedNodes) { const n = pedNodes[k]; const key = n.reg || n.name; if (key) counts.set(key, (counts.get(key) || 0) + 1); const p = Number(k); if (p > maxPos) maxPos = p; } let repeated = 0; counts.forEach((c) => { if (c > 1) repeated++; }); return { distinct: counts.size, repeated, gens: Math.floor(Math.log2(maxPos)) }; } function pedCountText() { const { distinct: a, repeated: r, gens: g } = pedCounts(); let s = `${a} ancestor${a === 1 ? "" : "s"} back ${g} generation${g === 1 ? "" : "s"}`; if (r > 0) s += `, ${r} appearing more than once`; return s; } function setPedDone() { setPedStatus(`Traced ${pedCountText()}.`, "done"); } function setPedStatus(text, kind) { pedStatus.hidden = false; pedStatus.textContent = text; pedStatus.className = "pedigree-status" + (kind ? " " + kind : ""); } function renderSubject(s) { if (!s) { pedSubject.hidden = true; return; } pedSubject.hidden = false; pedSubject.textContent = ""; const name = document.createElement("div"); name.className = "ped-subject-name"; name.textContent = s.name || "(unnamed)"; const meta = document.createElement("div"); meta.className = "ped-subject-meta"; const bits = []; if (s.breed) bits.push(s.breed); if (s.reg) bits.push(s.reg); if (s.sex) bits.push(s.sex === "H" ? "♂" : s.sex === "T" ? "♀" : s.sex); meta.textContent = bits.join(" · "); pedSubject.append(name, meta); } function renderChoose(matches) { pedTree.textContent = ""; pedSubject.hidden = true; setPedStatus(`${matches.length} matches — pick one:`, ""); pedChoose.hidden = false; pedChoose.textContent = ""; matches.slice(0, 50).forEach((m) => { const b = document.createElement("button"); b.type = "button"; b.className = "ped-match"; const nm = (m.hundnamn || "").trim() || "(unnamed)"; const nameEl = document.createElement("span"); nameEl.className = "ped-match-name"; nameEl.textContent = nm; const metaEl = document.createElement("span"); metaEl.className = "ped-match-meta"; metaEl.textContent = [m.Regnr, m.rastext].filter(Boolean).join(" · "); b.append(nameEl, metaEl); b.addEventListener("click", () => { pedChoose.hidden = true; lookupPedigree((m.Regnr || "").trim() || nm); }); pedChoose.append(b); }); } // The tree is ahnentafel-indexed: the dog is position 1, its sire 2n and dam // 2n+1. We render it top-down like a family tree — the dog on top, parents // branching below — as a nested
    /
  • 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. clearLocalCache(); 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) { const ev = addEvent(type, "", Date.now()); showSnackbar(`${EVENT_LABELS[type]} logged`, ev); if (type === "pee" || type === "poo") pottyConfetti(type); } // 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) { if (!confettiEnabled()) return; if (window.matchMedia && matchMedia("(prefers-reduced-motion: reduce)").matches) return; const emoji = type === "poo" ? "💩" : "💧"; const layer = confettiLayer(); const W = window.innerWidth, H = window.innerHeight; for (let i = 0; i < 30; i++) { const piece = document.createElement("span"); piece.className = "confetti-piece"; piece.textContent = emoji; // Launch from a point across the bottom, shoot up to a random peak while // drifting sideways, slowing and fading out as it reaches the top. const peakY = -Math.round(H * (0.45 + Math.random() * 0.45)); piece.style.left = `${Math.round(W * (0.1 + Math.random() * 0.8))}px`; piece.style.top = `${H + 20}px`; piece.style.setProperty("--peakY", `${peakY}px`); piece.style.setProperty("--dx", `${Math.round((Math.random() * 2 - 1) * W * 0.22)}px`); piece.style.setProperty("--rot", `${Math.round(Math.random() * 720 - 360)}deg`); piece.style.fontSize = `${Math.round(16 + Math.random() * 18)}px`; piece.style.animationDuration = `${Math.round(1100 + Math.random() * 700)}ms`; piece.style.animationDelay = `${Math.round(Math.random() * 280)}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 ---------- // Anything in main 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. // // A whole section heads with an h2, a foldable block inside one with an h3. // The two nest without special handling: each has its own key, and a folded // section hides the block along with everything else it holds, which leaves // the block's own state to reappear as it was when the section reopens. 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 [data-panel]").forEach(panel => { const key = panel.dataset.panel; const head = panel.querySelector(":scope > h2, :scope > h3"); if (!head) return; panel.classList.add("collapsible"); const collapsed = !!state[key]; panel.classList.toggle("collapsed", collapsed); head.setAttribute("role", "button"); head.setAttribute("tabindex", "0"); head.setAttribute("aria-expanded", String(!collapsed)); const toggle = () => { const nowCollapsed = panel.classList.toggle("collapsed"); head.setAttribute("aria-expanded", String(!nowCollapsed)); const s = loadPanelState(); if (nowCollapsed) s[key] = true; else delete s[key]; savePanelState(s); }; head.addEventListener("click", toggle); head.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); } }); }); } initPanels(); // ---------- tabs ---------- // Fourteen panels in one column meant scrolling past sleep, timing and walks // to reach the weight curve. They are grouped into five tabs by subject; the // day bar and the log buttons stay above them, so logging is one tap from // wherever you are. Folding still works inside a tab — the two compose. // // The wrapper is what gets hidden, never the sections inside it: walk-timeline // and walk-trend carry their own `hidden` (renderWalkPatterns), and hiding // them directly would clobber that. const TAB_KEY = "puppy-tracker:tab:v1"; // device-global, like the fold state const DEFAULT_TAB = "today"; const tabButtons = [...document.querySelectorAll(".tabs .tab")]; const tabPanels = [...document.querySelectorAll("main .tab-panel")]; // Today is the one tab with no charts, so the window picker doesn't belong // over it. const TABS_WITHOUT_CHARTS = new Set(["today"]); let activeTab = DEFAULT_TAB; function loadTab() { try { const t = localStorage.getItem(TAB_KEY); return tabButtons.some(b => b.dataset.tab === t) ? t : DEFAULT_TAB; } catch { return DEFAULT_TAB; } } // Back returns to Today from any tab in one press, and a second press leaves // the app — two presses to get out from anywhere, whatever route you took. // // The alternative, a history entry per switch, is what a browser would do by // itself and is the reason tabbed apps get a reputation for trapping you: // flick between tabs fifteen times and it takes fifteen presses to escape. // So exactly one entry is ever live. It is armed on leaving Today and // consumed on returning, whether that return came from the back button or // from tapping the tab. let backArmed = false; function armBack() { if (backArmed) return; history.pushState({ puppyTab: true }, ""); backArmed = true; } addEventListener("popstate", () => { // Not ours: some other navigation, so stay out of its way. if (!backArmed) return; backArmed = false; showTab(DEFAULT_TAB, { manageBack: false }); }); function showTab(id, { manageBack = true } = {}) { if (!tabButtons.some(b => b.dataset.tab === id)) id = DEFAULT_TAB; activeTab = id; for (const b of tabButtons) { const on = b.dataset.tab === id; b.classList.toggle("active", on); b.setAttribute("aria-selected", String(on)); // Roving tabindex: the tablist is one stop, arrows move within it. b.tabIndex = on ? 0 : -1; } for (const p of tabPanels) p.hidden = p.dataset.tab !== id; document.querySelector(".chart-window").hidden = TABS_WITHOUT_CHARTS.has(id); // Deliberately no scrolling. Switching used to jump to the top, on the // theory that arriving halfway down a tab is disorienting — but the tab bar // is sticky, so you switch tabs *from* wherever you have scrolled to, and // being thrown back past the log buttons you had just scrolled off is worse. // If the new tab is shorter than the old scroll position the browser clamps // on its own, which needs no help from us. try { localStorage.setItem(TAB_KEY, id); } catch { /* ignore */ } if (manageBack) { if (id !== DEFAULT_TAB) { armBack(); } else if (backArmed) { // Tapping Today gets to the same place the back button would, so spend // the entry rather than leaving a stale one that makes the next press // appear to do nothing. Clearing the flag first stops the popstate // this triggers from running the handler above a second time. backArmed = false; history.back(); } } // The pill's visibility depends on whether the big card is on screen, and // the card only exists on one tab. A shorter tab can also leave the page // too short to stay scrolled, unsticking the bars, so re-check that too. updateBarClockMode(); updateTabsMerged(); } for (const [i, btn] of tabButtons.entries()) { btn.addEventListener("click", () => showTab(btn.dataset.tab)); btn.addEventListener("keydown", (e) => { const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0; if (!step) return; e.preventDefault(); const next = tabButtons[(i + step + tabButtons.length) % tabButtons.length]; showTab(next.dataset.tab); next.focus(); }); } // The tab bar sticks directly under the day bar, which means its `top` has to // be that bar's height — measured, because the bar's contents (and so its // height) differ between phones and orientations. // // A resize listener alone is not enough: the bar also grows the first time a // sleep is logged, when the timer pill appears inside it, and that fires no // resize. Watching the element covers both, and every other cause besides. const dayBarEl = document.querySelector(".day-bar"); function measureDayBar() { if (!dayBarEl) return; document.documentElement.style.setProperty("--day-bar-h", `${dayBarEl.offsetHeight}px`); } const onDayBarResize = () => { measureDayBar(); updateTabsMerged(); }; if (dayBarEl && typeof ResizeObserver === "function") { new ResizeObserver(onDayBarResize).observe(dayBarEl); } else { addEventListener("resize", onDayBarResize); } measureDayBar(); // Once the log buttons have scrolled away the two frozen bars meet, and they // should look like one card rather than two stacked ones — see .merged in the // stylesheet. CSS cannot ask whether a sticky element is currently stuck, so // the test is simply whether they are touching. const tabsEl = document.querySelector(".tabs"); function updateTabsMerged() { if (!dayBarEl || !tabsEl) return; const gap = tabsEl.getBoundingClientRect().top - dayBarEl.getBoundingClientRect().bottom; const merged = gap <= 1; // they overlap by a pixel by design dayBarEl.classList.toggle("merged", merged); tabsEl.classList.toggle("merged", merged); } updateTabsMerged(); showTab(loadTab()); // ---------- wiring ---------- document.querySelectorAll("button.action").forEach(btn => { btn.addEventListener("click", () => { const type = btn.dataset.type; // Weigh-ins need a typed value, meals ask for grams, and a note is all // free text — so these open the full dialog instead of one-tap logging. if (type === "weight" || type === "eat" || type === "note") { openNoteDialog(type); return; } quickLog(type); }); }); // The timer pill doubles as a one-tap sleep toggle: tapping it logs the // boundary that ends the state it shows, exactly like the matching quick // action. Only reachable once the big card has scrolled away — in standby // the pill is visibility:hidden, so clicks can never land on it. document.getElementById("bar-clock").addEventListener("click", () => { const { state } = currentSleepState(live()); if (!state) return; // no sleep history yet; the pill is hidden anyway quickLog(state === "asleep" ? "sleep-end" : "sleep-start"); }); document.querySelectorAll(".chart-days-picker button").forEach(b => { b.addEventListener("click", () => setChartDays(Number(b.dataset.days))); }); document.querySelectorAll("#counts-metrics input[data-metric]").forEach(cb => { cb.addEventListener("change", () => { const on = [...document.querySelectorAll("#counts-metrics input[data-metric]:checked")] .map(el => el.dataset.metric); setCountsMetrics(on); }); }); // Swap the timer pill in/out of the frozen bar as the big card scrolls past, // and join the two frozen bars up once they meet. 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(); updateTabsMerged(); }); }; 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(); }); document.getElementById("exclude-day").addEventListener("click", () => { toggleExcludedDay(selectedDay()); // addEvent/deleteEvent re-render for us }); // 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"); const authCard = document.getElementById("auth-card"); const guestEnded = document.getElementById("guest-ended"); const guestBanner = document.getElementById("guest-banner"); 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); renderSleepWake(evs); renderWalks(evs); renderWeekly(evs); renderSleepTimeline(evs); renderWalkPatterns(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(); initReminders(); } // ended: show the "guest link has run out" card instead of the sign-in form, // which a guest has no credentials to fill in anyway. function showAuth({ ended = false } = {}) { appEl.hidden = true; authCard.hidden = ended; guestEnded.hidden = !ended; authScreen.hidden = false; } function showApp() { authScreen.hidden = true; appEl.hidden = false; renderGuestBanner(); } // Says whose account this is, under which name the guest's entries will // appear, and when the link runs out. Owners never see it. function renderGuestBanner() { if (!isGuest()) { guestBanner.hidden = true; return; } const name = loadConfig().name; const until = currentUser.expires ? ` · access ends ${formatWhen(currentUser.expires)}` : ""; guestBanner.textContent = `Guest access${name ? ` to ${name}` : ""} as ${currentUser.label}` + `${until} — anything you log is tagged with your name.`; guestBanner.hidden = false; } // Called when the server reports we're no longer authenticated (expired or // revoked session). An owner just drops back to the login screen with their // cache intact — signing back in picks it straight back up. A guest's link // has ended for good, so their copy of someone else's history should not stay // sitting in their browser: wipe it, and say what happened. function handleLoggedOut() { const wasGuest = isGuest(); if (wasGuest) clearLocalCache(); clearUser(); setStatus("offline"); showAuth({ ended: wasGuest }); } // Drops this account's cached data from localStorage. currentUser must still // be set, since the keys are namespaced by its id. function clearLocalCache() { try { localStorage.removeItem(eventsKey()); localStorage.removeItem(configKey()); localStorage.removeItem(exercisesKey()); } catch { /* ignore */ } } 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 () => { // Leaving as a guest ends this session but not the link — they can tap it // again. Their cache goes either way; it is someone else's record. if (isGuest()) clearLocalCache(); 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() { // /guest/ bounces here with this marker when the link was already // expired or revoked, so we can say so rather than show a sign-in form. const params = new URLSearchParams(location.search); const deadLink = params.get("guest") === "expired"; if (params.has("guest")) { history.replaceState(null, "", location.pathname); } try { const res = await fetch("api/me"); if (res.ok) { setUser(await res.json()); applyRole(); showApp(); startApp(); return; } clearUser(); // explicit 401/403: session is gone } catch { const cached = cachedUser(); if (cached) { currentUser = cached; applyRole(); showApp(); startApp(); return; } } renderAuthMode(); showAuth({ ended: deadLink }); })(); // One-time, role-dependent chrome. Everything else a guest sees or doesn't is // decided when the relevant dialog opens. function applyRole() { if (!isGuest()) return; const leave = document.getElementById("logout-btn"); leave.setAttribute("aria-label", "Leave"); leave.title = "Leave"; } })();