(() => { "use strict"; // Storage is namespaced per account so two people sharing a browser (or one // person logging out and back in as someone else) never see each other's // cached events/profile. currentUser is set by the auth gate before the app // boots, so these are only ever called once a user is known. let currentUser = null; const eventsKey = () => `puppy-tracker:${currentUser.id}:events:v1`; const configKey = () => `puppy-tracker:${currentUser.id}:config:v1`; const exercisesKey = () => `puppy-tracker:${currentUser.id}:exercises:v1`; const SYNC_URL = "api/events/sync"; const SYNC_DEBOUNCE_MS = 1200; const SYNC_POLL_MS = 60_000; const EVENT_LABELS = { "sleep-start": "Sleep start", "sleep-end": "Sleep end", "eat": "Ate", "pee": "Pee", "poo": "Poo", "weight": "Weigh-in", "training": "Training", }; // ---------- photos: IndexedDB store ---------- // Schema: object store `photos` keyed by `id`, value `{id, blob, uploaded}`. // - Photos taken locally are written with uploaded:false and queued for upload. // - Photos fetched from server are cached with uploaded:true (server is source-of-truth). const PHOTO_DB = "puppy-tracker"; const PHOTO_STORE = "photos"; let photoDB = null; function openPhotoDB() { if (photoDB) return Promise.resolve(photoDB); return new Promise((resolve, reject) => { const req = indexedDB.open(PHOTO_DB, 1); req.onupgradeneeded = (e) => { const db = e.target.result; if (!db.objectStoreNames.contains(PHOTO_STORE)) { db.createObjectStore(PHOTO_STORE, { keyPath: "id" }); } }; req.onsuccess = () => { photoDB = req.result; resolve(photoDB); }; req.onerror = () => reject(req.error); }); } async function putPhoto(id, blob, uploaded) { const db = await openPhotoDB(); return new Promise((resolve, reject) => { const tx = db.transaction(PHOTO_STORE, "readwrite"); tx.objectStore(PHOTO_STORE).put({ id, blob, uploaded }); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } async function getPhoto(id) { const db = await openPhotoDB(); return new Promise((resolve, reject) => { const tx = db.transaction(PHOTO_STORE, "readonly"); const req = tx.objectStore(PHOTO_STORE).get(id); req.onsuccess = () => resolve(req.result || null); req.onerror = () => reject(req.error); }); } async function getAllPhotos() { const db = await openPhotoDB(); return new Promise((resolve, reject) => { const tx = db.transaction(PHOTO_STORE, "readonly"); const req = tx.objectStore(PHOTO_STORE).getAll(); req.onsuccess = () => resolve(req.result || []); req.onerror = () => reject(req.error); }); } async function deletePhoto(id) { const db = await openPhotoDB(); return new Promise((resolve, reject) => { const tx = db.transaction(PHOTO_STORE, "readwrite"); tx.objectStore(PHOTO_STORE).delete(id); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } // Resize an image File to a max dimension and return a JPEG Blob. function resizeImage(file, maxDim = 1600, quality = 0.85) { return new Promise((resolve, reject) => { const img = new Image(); const url = URL.createObjectURL(file); img.onload = () => { URL.revokeObjectURL(url); const scale = Math.min(1, maxDim / Math.max(img.naturalWidth, img.naturalHeight)); const w = Math.max(1, Math.round(img.naturalWidth * scale)); const h = Math.max(1, Math.round(img.naturalHeight * scale)); const canvas = document.createElement("canvas"); canvas.width = w; canvas.height = h; canvas.getContext("2d").drawImage(img, 0, 0, w, h); canvas.toBlob( (blob) => blob ? resolve(blob) : reject(new Error("toBlob returned null")), "image/jpeg", quality, ); }; img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("image load failed")); }; img.src = url; }); } async function uploadPhotoBlob(id, blob) { const form = new FormData(); form.append("id", id); form.append("file", blob, `${id}.jpg`); const res = await fetch("api/photos", { method: "POST", body: form }); if (!res.ok) throw new Error(`upload HTTP ${res.status}`); } async function syncPhotos() { if (!navigator.onLine) return; const all = await getAllPhotos(); for (const p of all) { if (p.uploaded) continue; try { await uploadPhotoBlob(p.id, p.blob); await putPhoto(p.id, p.blob, true); } catch (err) { console.warn("photo upload failed", p.id, err); // leave queued; next sync will retry } } } // Resolve a photoId to a displayable URL. Prefers local cache; falls back // to the server URL (the SW will cache it transparently). Returns null if // we have nothing and the server doesn't either. const photoURLCache = new Map(); // id -> object URL (lifetime = page session) async function photoSrc(id) { if (!id) return null; if (photoURLCache.has(id)) return photoURLCache.get(id); const local = await getPhoto(id); if (local && local.blob) { const url = URL.createObjectURL(local.blob); photoURLCache.set(id, url); return url; } // Fall back to server URL — let the browser/SW cache it. Also opportunistically // pull it into IndexedDB so cold-offline-loads still see it. if (navigator.onLine) { try { const res = await fetch(`api/photos/${encodeURIComponent(id)}`); if (res.ok) { const blob = await res.blob(); await putPhoto(id, blob, true); const url = URL.createObjectURL(blob); photoURLCache.set(id, url); return url; } } catch (_) { /* fall through */ } } return null; } // crypto.randomUUID() is only exposed in secure contexts (HTTPS / localhost), // so over plain HTTP on the LAN we need a fallback. crypto.getRandomValues // is available everywhere; Math.random is the last resort. function uuid() { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { const b = new Uint8Array(16); crypto.getRandomValues(b); b[6] = (b[6] & 0x0f) | 0x40; // version 4 b[8] = (b[8] & 0x3f) | 0x80; // variant 10 const h = [...b].map(x => x.toString(16).padStart(2, "0")); return `${h.slice(0,4).join("")}-${h.slice(4,6).join("")}-${h.slice(6,8).join("")}-${h.slice(8,10).join("")}-${h.slice(10,16).join("")}`; } return `x-${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 10)}`; } // ---------- storage ---------- // Internal "raw" storage includes deleted tombstones; UI uses live(). function loadAll() { try { const raw = localStorage.getItem(eventsKey()); if (!raw) return []; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; // Backfill updatedAt for events written by an older client version. return parsed.map(e => ({ ...e, updatedAt: Number.isFinite(e.updatedAt) ? e.updatedAt : (e.at || Date.now()), })); } catch { return []; } } function saveAll(events) { localStorage.setItem(eventsKey(), JSON.stringify(events)); } function live() { return loadAll().filter(e => !e.deleted); } // ---------- config (puppy name + birthday) ---------- // The shared profile lives on the host so every client sees the same values. // localStorage is just a cache for instant paint + offline; the server is the // source of truth, reconciled by last-write-wins on updatedAt (see syncConfig). function loadConfig() { try { const parsed = JSON.parse(localStorage.getItem(configKey())); if (!parsed || typeof parsed !== "object") return { name: "", birthday: "", pedigreeId: "", updatedAt: 0 }; return { name: parsed.name || "", birthday: parsed.birthday || "", pedigreeId: parsed.pedigreeId || "", updatedAt: Number.isFinite(parsed.updatedAt) ? parsed.updatedAt : 0, }; } catch { return { name: "", birthday: "", pedigreeId: "", updatedAt: 0 }; } } function saveConfig(cfg) { localStorage.setItem(configKey(), JSON.stringify(cfg)); } // Reconcile a local and a server profile. Name/birthday/updatedAt are plain // last-write-wins by timestamp. The pedigree id is sticky: a non-empty value // never loses to an empty one — so it can't be dropped by a clock race between // devices — and when both are set the newer profile's id wins with the rest. // (The server merge mirrors this, so a set id is only changed, never cleared, // by sync.) function reconcileConfig(local, server) { const base = server.updatedAt >= local.updatedAt ? server : local; return { name: base.name, birthday: base.birthday, pedigreeId: (local.pedigreeId && server.pedigreeId) ? base.pedigreeId : (local.pedigreeId || server.pedigreeId), updatedAt: base.updatedAt, }; } function sameConfig(a, b) { return a.name === b.name && a.birthday === b.birthday && a.pedigreeId === b.pedigreeId && a.updatedAt === b.updatedAt; } // Age in whole days / weeks / calendar months from a "YYYY-MM-DD" birthday, // measured at `at` (defaults to now — pass a weigh-in's timestamp for its age // at that point). Returns null for a missing/invalid birthday or a date before it. function ageParts(birthday, at) { if (!birthday) return null; const [y, mo, d] = birthday.split("-").map(Number); if (!y || !mo || !d) return null; const birth = startOfDay(new Date(y, mo - 1, d)); const ref = startOfDay(new Date(Number.isFinite(at) ? at : Date.now())); if (birth > ref) return null; const days = Math.floor((ref - birth) / 86_400_000); const weeks = Math.floor(days / 7); let months = (ref.getFullYear() - birth.getFullYear()) * 12 + (ref.getMonth() - birth.getMonth()); if (ref.getDate() < birth.getDate()) months--; if (months < 0) months = 0; // Whole weeks left over after the calendar months (so "N months and M weeks"). const anchor = startOfDay(new Date(birth.getFullYear(), birth.getMonth() + months, birth.getDate())); const remWeeks = Math.floor((ref - anchor) / 86_400_000 / 7); return { days, weeks, months, remWeeks }; } // e.g. "16 weeks (3 months and 3 weeks) old". Weeks are the headline unit while // the puppy is young; once it's 4+ months the week count is just noise, so we // drop it and lead with the months form ("4 months and 1 week old"). function formatAge(birthday, at) { const a = ageParts(birthday, at); if (!a) return ""; const wk = `${a.weeks} week${a.weeks === 1 ? "" : "s"}`; if (a.months < 1) return `${wk} old`; const mo = `${a.months} month${a.months === 1 ? "" : "s"}`; const rw = a.remWeeks > 0 ? ` and ${a.remWeeks} week${a.remWeeks === 1 ? "" : "s"}` : ""; const monthPhrase = `${mo}${rw}`; if (a.months >= 4) return `${monthPhrase} old`; return `${wk} (${monthPhrase}) old`; } // Compact one-line age for space-tight spots (header): keeps the weeks + months // breakdown but abbreviated — "16 wk · 3 mo 3 wk", "5 mo 2 wk", "3 wk". function formatAgeShort(birthday, at) { const a = ageParts(birthday, at); if (!a) return ""; const wk = `${a.weeks} wk`; if (a.months < 1) return wk; const mo = `${a.months} mo${a.remWeeks > 0 ? ` ${a.remWeeks} wk` : ""}`; if (a.months >= 4) return mo; return `${wk} · ${mo}`; } // Weeks-only age for dense lists (weight rows): "16 wk". function formatAgeWeeks(birthday, at) { const a = ageParts(birthday, at); return a ? `${a.weeks} wk` : ""; } // Rough age-based daily sleep goal (hours), for the trend chart's goal band: // 0–8 weeks 20–22h, 8–16 weeks 18–20h, then 16–18h to 6 months and 14–16h to // 12 months. No birthday (or an adult dog) → no goal. function sleepTargetFor(birthday) { const a = ageParts(birthday); if (!a) return null; if (a.weeks < 8) return { lo: 20, hi: 22, label: "0–8 weeks" }; if (a.weeks < 16) return { lo: 18, hi: 20, label: "8–16 weeks" }; if (a.months < 6) return { lo: 16, hi: 18, label: "4–6 months" }; if (a.months < 12) return { lo: 14, hi: 16, label: "6–12 months" }; return null; } function addEvent(type, note, at, { photoId, weight, grams, exerciseId } = {}) { const events = loadAll(); const now = Date.now(); const ev = { id: uuid(), type, at: Number.isFinite(at) ? at : now, note: note || "", photoId: photoId || "", weight: Number.isFinite(weight) ? weight : undefined, grams: Number.isFinite(grams) ? grams : undefined, exerciseId: exerciseId || "", updatedAt: now, }; events.push(ev); saveAll(events); scheduleSync(); render(); return ev; } function updateEvent(id, patch) { const events = loadAll().map(e => e.id === id ? { ...e, ...patch, updatedAt: Date.now() } : e ); saveAll(events); scheduleSync(); render(); } function deleteEvent(id) { // Soft-delete so the deletion can propagate via sync. const events = loadAll().map(e => e.id === id ? { ...e, deleted: true, updatedAt: Date.now() } : e ); saveAll(events); scheduleSync(); render(); } // ---------- exercises (training definitions) ---------- // User-defined training exercises ("Sit", "Leash walking", …), each with // optional instruction text. They sync like events: UUID ids, last-write-wins // on updatedAt, tombstoned deletes — but as their own collection, since they // are definitions rather than things that happened at a point in time. function loadExercises() { try { const parsed = JSON.parse(localStorage.getItem(exercisesKey())); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } function saveExercises(list) { localStorage.setItem(exercisesKey(), JSON.stringify(list)); } function liveExercises() { return loadExercises() .filter(x => !x.deleted) .sort((a, b) => (a.name || "").localeCompare(b.name || "")); } function addExercise(name, note) { const list = loadExercises(); list.push({ id: uuid(), name, note: note || "", updatedAt: Date.now() }); saveExercises(list); scheduleSync(); render(); } function updateExercise(id, patch) { saveExercises(loadExercises().map(x => x.id === id ? { ...x, ...patch, updatedAt: Date.now() } : x )); scheduleSync(); render(); } function deleteExercise(id) { // Tombstone, like events. Logged training sessions keep referencing the id; // name lookups still resolve through the tombstone (see exerciseNames). saveExercises(loadExercises().map(x => x.id === id ? { ...x, deleted: true, updatedAt: Date.now() } : x )); scheduleSync(); render(); } // id -> name across *all* exercises, tombstones included, so history rows for // a deleted exercise still show its name instead of a generic "Training". function exerciseNames() { return new Map(loadExercises().map(x => [x.id, x.name])); } // ---------- helpers ---------- function ymd(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, "0"); const d = String(date.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; } function startOfDay(date) { const d = new Date(date); d.setHours(0, 0, 0, 0); return d; } function endOfDay(date) { const d = new Date(date); d.setHours(23, 59, 59, 999); return d; } function formatTime(ts) { return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false }); } const pad2 = n => String(n).padStart(2, "0"); function toDateInput(ts) { const d = new Date(ts); return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } function toTimeInput(ts) { const d = new Date(ts); return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; } function fromDateTimeInputs(dateStr, timeStr) { if (!dateStr || !timeStr) return NaN; const [y, mo, d] = dateStr.split("-").map(Number); const [h, mi] = timeStr.split(":").map(Number); const t = new Date(y, mo - 1, d, h, mi).getTime(); return Number.isFinite(t) ? t : NaN; } function formatDuration(ms) { if (ms <= 0) return "0m"; const totalMin = Math.round(ms / 60000); const h = Math.floor(totalMin / 60); const m = totalMin % 60; if (h === 0) return `${m}m`; return `${h}h ${m}m`; } // kg with up to 2 decimals, trailing zeros stripped (5.20 -> "5.2 kg"). function formatWeight(kg) { return `${Math.round(kg * 100) / 100} kg`; } function formatRelative(ts) { if (!ts) return "—"; const diff = Date.now() - ts; if (diff < 60_000) return "just now"; if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; if (diff < 86_400_000) { const h = Math.floor(diff / 3_600_000); const m = Math.floor((diff % 3_600_000) / 60_000); return m ? `${h}h ${m}m ago` : `${h}h ago`; } const d = Math.floor(diff / 86_400_000); return `${d}d ago`; } function eventsForDay(events, day) { const from = startOfDay(day).getTime(); const to = endOfDay(day).getTime(); return events .filter(e => e.at >= from && e.at <= to) .sort((a, b) => a.at - b.at); } // Calculate sleep time within a given day. Sleep intervals are derived from // consecutive sleep-start → sleep-end pairs across the whole event log, and // clipped to the day window. Unmatched sleep-start = ongoing sleep (clipped to now). function sleepMsInRange(events, fromTs, toTs) { const sorted = [...events].sort((a, b) => a.at - b.at); const intervals = []; let openStart = null; for (const e of sorted) { if (e.type === "sleep-start" && openStart === null) { openStart = e.at; } else if (e.type === "sleep-end" && openStart !== null) { intervals.push([openStart, e.at]); openStart = null; } } if (openStart !== null) intervals.push([openStart, Date.now()]); let total = 0; for (const [start, end] of intervals) { const s = Math.max(start, fromTs); const ePt = Math.min(end, toTs); if (ePt > s) total += ePt - s; } return total; } // Current state derived from the *latest* sleep event. The single source of // truth for both the big clock and the "Currently" row. For two boundary // events sharing the same `at` (common once "now" events are minute-floored), // the one logged later (higher updatedAt) wins, so the tie resolves the same // way everywhere it's read. function currentSleepState(events) { let latest = null; for (const e of events) { if (e.type !== "sleep-start" && e.type !== "sleep-end") continue; if (!latest || e.at > latest.at || (e.at === latest.at && (e.updatedAt || 0) > (latest.updatedAt || 0))) { latest = e; } } if (!latest) return { state: null, since: 0 }; return { state: latest.type === "sleep-start" ? "asleep" : "awake", since: latest.at, }; } function formatCounter(ms) { if (ms < 0) ms = 0; const totalSec = Math.floor(ms / 1000); const h = Math.floor(totalSec / 3600); const m = Math.floor((totalSec % 3600) / 60); const s = totalSec % 60; const pp = n => String(n).padStart(2, "0"); return h > 0 ? `${h}:${pp(m)}:${pp(s)}` : `${m}:${pp(s)}`; } function lastEventOfType(events, type) { let latest = null; for (const e of events) { if (e.type === type && (!latest || e.at > latest.at)) latest = e; } return latest; } // Wake windows: time between a sleep-end and the next sleep-start. The final // sleep-end with no following sleep-start = ongoing/open wake window. function wakeWindows(events) { const sorted = events .filter(e => e.type === "sleep-start" || e.type === "sleep-end") .sort((a, b) => a.at - b.at); const out = []; let waking = null; for (const e of sorted) { if (e.type === "sleep-end") { waking = e.at; } else if (e.type === "sleep-start" && waking !== null) { out.push({ start: waking, end: e.at, ongoing: false }); waking = null; } } if (waking !== null) out.push({ start: waking, end: Date.now(), ongoing: true }); return out; } // Sleep windows: each sleep-start → next sleep-end pair. An unmatched // sleep-start = ongoing/open sleep window. function sleepWindows(events) { const sorted = events .filter(e => e.type === "sleep-start" || e.type === "sleep-end") .sort((a, b) => a.at - b.at); const out = []; let sleeping = null; for (const e of sorted) { if (e.type === "sleep-start") { sleeping = e.at; } else if (e.type === "sleep-end" && sleeping !== null) { out.push({ start: sleeping, end: e.at, ongoing: false }); sleeping = null; } } if (sleeping !== null) out.push({ start: sleeping, end: Date.now(), ongoing: true }); return out; } function sleepWindowsForDay(events, day) { const dayStart = startOfDay(day).getTime(); const dayEnd = endOfDay(day).getTime(); const today = ymd(new Date()) === ymd(day); // Keep the overlap filter so windows show on every day they touch, but // display the *actual* start/end — a sleep from 23:00 yesterday to 07:00 // today should show as "23:00 – 07:00 (8h)", not clipped at midnight. return sleepWindows(events) .filter(w => w.start <= dayEnd && w.end >= dayStart) .map(w => ({ start: w.start, end: w.end, ongoing: w.ongoing && today })); } // Wake windows that overlap the given day, clipped to that day for display. // Only mark a window as "ongoing" if the selected day is today. function wakeWindowsForDay(events, day) { const dayStart = startOfDay(day).getTime(); const dayEnd = endOfDay(day).getTime(); const today = ymd(new Date()) === ymd(day); // Same untrimmed-times policy as sleep windows: show the real start/end // even if part of the window falls outside the selected day. return wakeWindows(events) .filter(w => w.start <= dayEnd && w.end >= dayStart) .map(w => ({ start: w.start, end: w.end, ongoing: w.ongoing && today })); } // ---------- rendering ---------- const dayPicker = document.getElementById("day-picker"); const eventList = document.getElementById("event-list"); const emptyState = document.getElementById("empty-state"); const statusEl = document.getElementById("online-status"); function selectedDay() { const v = dayPicker.value; if (v) { const [y, m, d] = v.split("-").map(Number); return new Date(y, m - 1, d); } return new Date(); } function renderStats(events) { const day = selectedDay(); const from = startOfDay(day).getTime(); const today = ymd(new Date()) === ymd(day); const to = today ? Date.now() : endOfDay(day).getTime(); const sleepMs = sleepMsInRange(events, from, to); const awakeMs = Math.max(0, (to - from) - sleepMs); const dayEvents = eventsForDay(events, day); const count = (t) => dayEvents.filter(e => e.type === t).length; document.getElementById("stat-sleep").textContent = formatDuration(sleepMs); document.getElementById("stat-awake").textContent = formatDuration(awakeMs); document.getElementById("stat-meals").textContent = count("eat"); const gramsTotal = dayEvents .filter(e => e.type === "eat" && Number.isFinite(e.grams)) .reduce((s, e) => s + e.grams, 0); const gramsEl = document.getElementById("stat-meals-grams"); gramsEl.textContent = gramsTotal > 0 ? `${Math.round(gramsTotal)} g` : ""; gramsEl.hidden = !(gramsTotal > 0); document.getElementById("stat-pees").textContent = count("pee"); document.getElementById("stat-poos").textContent = count("poo"); document.getElementById("stat-training").textContent = count("training"); } // Gaps (ms) between consecutive events of `type` logged within the last // `days` days, sorted ascending. These intervals are what tell you how // often the puppy needs to go out. function gapsBetween(events, type, days = 7) { const cutoff = startOfDay(new Date()); cutoff.setDate(cutoff.getDate() - (days - 1)); const from = cutoff.getTime(); const times = events .filter(e => e.type === type && e.at >= from) .map(e => e.at) .sort((a, b) => a - b); const gaps = []; for (let i = 1; i < times.length; i++) gaps.push(times[i] - times[i - 1]); return gaps.sort((a, b) => a - b); } // Median is used for the "typical" gap because it ignores the single long // overnight gap each day, so it reflects real daytime frequency. function median(sorted) { if (sorted.length === 0) return null; const mid = Math.floor(sorted.length / 2); return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; } function renderTiming(events) { const peeGaps = gapsBetween(events, "pee"); const pooGaps = gapsBetween(events, "poo"); const show = (id, ms) => { document.getElementById(id).textContent = ms == null ? "—" : formatDuration(ms); }; show("gap-pee", median(peeGaps)); show("gap-pee-min", peeGaps[0] ?? null); show("gap-poo", median(pooGaps)); show("gap-poo-min", pooGaps[0] ?? null); const hint = document.getElementById("timing-hint"); const typicalPee = median(peeGaps); if (typicalPee != null) { hint.textContent = `Based on ${peeGaps.length} pee gap${peeGaps.length === 1 ? "" : "s"}. ` + `Aim to take the puppy out a little before the typical ${formatDuration(typicalPee)} mark.`; } else { hint.textContent = "Log a few more pees and poos to see typical timings."; } } function renderLasts(events) { const setLast = (id, type) => { const ev = lastEventOfType(events, type); document.getElementById(id).textContent = ev ? `${formatTime(ev.at)} (${formatRelative(ev.at)})` : "—"; }; setLast("last-pee", "pee"); setLast("last-poo", "poo"); setLast("last-eat", "eat"); const sortedSleep = events .filter(e => e.type === "sleep-start" || e.type === "sleep-end") .sort((a, b) => b.at - a.at); const lastSleep = sortedSleep[0]; document.getElementById("last-sleep").textContent = lastSleep ? `${EVENT_LABELS[lastSleep.type]} at ${formatTime(lastSleep.at)} (${formatRelative(lastSleep.at)})` : "—"; } // Tracks the latest sleep transition so the 1-second tick can update the // counter without re-deriving from the event log. let bigClockState = null; // "asleep" | "awake" | null let bigClockSince = 0; // The counter renders twice: the big card at the top of the page and the // compact pill in the frozen day bar. The pill only *shows* once the big // card is scrolled out of sight (see updateBarClockMode); while the card is // visible the pill is invisible but keeps its slot so the bar never shifts. function updateBarClockMode() { const pill = document.getElementById("bar-clock"); if (!bigClockState || pill.hidden) return; const card = document.getElementById("big-clock"); const bar = document.querySelector(".day-bar"); const cardVisible = card.getBoundingClientRect().bottom > bar.getBoundingClientRect().bottom; pill.classList.toggle("standby", cardVisible); } function renderBigClock(events) { const card = document.getElementById("big-clock"); const label = document.getElementById("bc-label"); const time = document.getElementById("bc-time"); const since = document.getElementById("bc-since"); const pill = document.getElementById("bar-clock"); const icon = document.getElementById("bar-clock-icon"); const ptime = document.getElementById("bar-clock-time"); const { state, since: ts } = currentSleepState(events); bigClockState = state; bigClockSince = ts; if (!state) { card.hidden = true; pill.hidden = true; return; } card.hidden = false; pill.hidden = false; card.classList.toggle("asleep", state === "asleep"); card.classList.toggle("awake", state === "awake"); pill.classList.toggle("asleep", state === "asleep"); pill.classList.toggle("awake", state === "awake"); label.textContent = state === "asleep" ? "Asleep for" : "Awake for"; const counter = formatCounter(Date.now() - ts); time.textContent = counter; ptime.textContent = counter; since.textContent = `since ${formatTime(ts)}`; icon.textContent = state === "asleep" ? "😴" : "☀️"; pill.title = `${state === "asleep" ? "Asleep" : "Awake"} since ${formatTime(ts)}`; updateBarClockMode(); } function tickBigClock() { if (!bigClockState) return; const counter = formatCounter(Date.now() - bigClockSince); const time = document.getElementById("bc-time"); const ptime = document.getElementById("bar-clock-time"); if (time) time.textContent = counter; if (ptime) ptime.textContent = counter; } function renderWindowList(listId, emptyId, windows, ongoingLabel, extraClass) { const list = document.getElementById(listId); const empty = document.getElementById(emptyId); list.innerHTML = ""; if (windows.length === 0) { empty.hidden = false; return; } empty.hidden = true; for (const w of windows) { const li = document.createElement("li"); li.className = `ww ${extraClass}` + (w.ongoing ? " ongoing" : ""); const range = document.createElement("span"); range.className = "ww-range"; range.textContent = w.ongoing ? `from ${formatTime(w.start)}` : `${formatTime(w.start)} – ${formatTime(w.end)}`; const dur = document.createElement("span"); dur.className = "ww-dur"; dur.textContent = formatDuration(w.end - w.start); li.appendChild(range); li.appendChild(dur); if (w.ongoing) { const tag = document.createElement("span"); tag.className = "ww-tag"; tag.textContent = ongoingLabel; li.appendChild(tag); } list.appendChild(li); } } function renderSleepWindows(events) { renderWindowList( "sleep-list", "sleep-empty", sleepWindowsForDay(events, selectedDay()), "Asleep", "sleep-ww", ); } function renderWakeWindows(events) { renderWindowList( "wake-list", "wake-empty", wakeWindowsForDay(events, selectedDay()), "Awake", "wake-ww", ); } function renderHistory(events) { const dayEvents = eventsForDay(events, selectedDay()).reverse(); const exNames = exerciseNames(); eventList.innerHTML = ""; if (dayEvents.length === 0) { emptyState.hidden = false; return; } emptyState.hidden = true; for (const ev of dayEvents) { let label = EVENT_LABELS[ev.type] || ev.type; if (ev.type === "training" && exNames.get(ev.exerciseId)) { label = `Training · ${exNames.get(ev.exerciseId)}`; } const li = document.createElement("li"); li.className = "event"; li.dataset.type = ev.type; li.dataset.id = ev.id; li.innerHTML = ` ${formatTime(ev.at)} ${escapeText(label)} `; const noteEl = li.querySelector(".note"); if (ev.type === "weight" && Number.isFinite(ev.weight)) { noteEl.textContent = ev.note ? `${formatWeight(ev.weight)} · ${ev.note}` : formatWeight(ev.weight); } else if (ev.type === "eat" && Number.isFinite(ev.grams) && ev.grams > 0) { const g = `${Math.round(ev.grams)} g`; noteEl.textContent = ev.note ? `${g} · ${ev.note}` : g; } else { noteEl.textContent = ev.note || ""; } li.addEventListener("click", () => openEditDialog(ev)); for (const pid of photoIdsOf(ev)) { const img = document.createElement("img"); img.className = "thumb"; img.alt = "photo"; img.loading = "lazy"; img.dataset.photoId = pid; img.addEventListener("click", (e) => { e.stopPropagation(); // don't open the edit dialog openLightbox(pid); }); li.appendChild(img); photoSrc(pid).then(url => { if (url) img.src = url; }); } eventList.appendChild(li); } } // ---------- lightbox ---------- const lightbox = document.getElementById("lightbox"); const lightboxImg = document.getElementById("lightbox-img"); const lightboxClose = document.getElementById("lightbox-close"); async function openLightbox(photoId) { const url = await photoSrc(photoId); if (!url) { alert("Photo not yet available (still uploading?)."); return; } lightboxImg.src = url; lightbox.showModal(); } lightboxClose.addEventListener("click", () => lightbox.close()); // Click on backdrop closes the lightbox too. lightbox.addEventListener("click", (e) => { if (e.target === lightbox) lightbox.close(); }); // ---------- chart window ---------- // How many days the rolling charts cover. Device-global (like the theme), // picked via the 7d/14d/30d buttons in the charts card and applied to every // day-window chart: daily bars, sleep timeline, hour heatmap, training grid. const CHART_DAYS_KEY = "puppy-tracker:chart-days:v1"; const CHART_DAY_CHOICES = [7, 14, 30]; function chartDays() { const v = Number(localStorage.getItem(CHART_DAYS_KEY)); return CHART_DAY_CHOICES.includes(v) ? v : 7; } function setChartDays(n) { try { localStorage.setItem(CHART_DAYS_KEY, String(n)); } catch { /* ignore */ } render(); } // Sync every "(last N days)" header and the picker's active button. function renderChartWindow() { const n = chartDays(); const title = document.getElementById("daily-charts-title"); if (title) title.textContent = `Last ${n} days`; document.querySelectorAll("[data-chart-days-label]").forEach(el => { el.textContent = `(last ${n} days)`; }); document.querySelectorAll(".chart-days-picker button").forEach(b => { b.classList.toggle("active", Number(b.dataset.days) === n); }); } // ---------- daily charts ---------- function weeklyData(events) { const today = startOfDay(new Date()); const now = Date.now(); const days = []; for (let i = chartDays() - 1; i >= 0; i--) { const d = new Date(today); d.setDate(d.getDate() - i); const from = startOfDay(d).getTime(); const to = (i === 0) ? now : endOfDay(d).getTime(); const sleepMs = sleepMsInRange(events, from, to); const dayEvents = eventsForDay(events, d); days.push({ date: d, ymd: ymd(d), sleepHours: sleepMs / 3_600_000, pees: dayEvents.filter(e => e.type === "pee").length, poos: dayEvents.filter(e => e.type === "poo").length, meals: dayEvents.filter(e => e.type === "eat").length, grams: dayEvents .filter(e => e.type === "eat" && Number.isFinite(e.grams)) .reduce((s, e) => s + e.grams, 0), }); } return days; } function dayLabel(date, isToday) { if (isToday) return "Today"; return date.toLocaleDateString(undefined, { weekday: "short" }); } // Wider windows can't fit a label under every bar: label today and every // 2nd (or 4th) day counting back from it, depending on the window. function showDayLabel(i, len) { const every = len <= 8 ? 1 : len <= 16 ? 2 : 4; return (len - 1 - i) % every === 0; } // Pick a chart Y maximum and tick count so every tick label is a clean // whole number (avoids 0, 0, 1, 1, 2 from rounding fractional steps): // 1-unit gridlines up to 10, 2-unit up to 20, 5-unit beyond. function niceAxis(rawMax) { if (!(rawMax > 0)) return { yMax: 1, steps: 1 }; if (rawMax <= 10) { const m = Math.ceil(rawMax); return { yMax: m, steps: m }; } if (rawMax <= 20) { const m = Math.ceil(rawMax / 2) * 2; return { yMax: m, steps: m / 2 }; } const m = Math.ceil(rawMax / 5) * 5; return { yMax: m, steps: m / 5 }; } // Grams axis: 0-based with a "nice" step so tick labels stay round whatever // the daily totals are (tens of grams for a tiny puppy, hundreds+ later). // Aims for ~10 segments so day-to-day differences of a few grams show. function niceAxisGrams(rawMax) { if (!(rawMax > 0)) return { yMax: 100, steps: 4 }; const rawStep = rawMax / 10; const mag = Math.pow(10, Math.floor(Math.log10(rawStep))); const norm = rawStep / mag; const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag; const yMax = Math.ceil(rawMax / step) * step; return { yMax, steps: Math.max(1, Math.round(yMax / step)) }; } // Sleep-specific axis: 1-hour granularity, capped at 24h/day. Shared by the // sleep bars and the trend chart; both thin out labels when steps get dense. function niceAxisSleepHours(rawMax) { if (!(rawMax > 0)) return { yMax: 2, steps: 2 }; const m = Math.min(24, Math.max(2, Math.ceil(rawMax))); return { yMax: m, steps: m }; } function escapeText(s) { return String(s).replace(/[&<>"']/g, c => ( { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c] )); } function setChartSVG(svg, parts) { svg.innerHTML = parts.join(""); svg.querySelectorAll(".bar[data-day]").forEach(b => { b.addEventListener("click", () => { dayPicker.value = b.dataset.day; render(); }); }); } function drawSleepChart(days) { const svg = document.getElementById("chart-sleep"); const W = 320, H = 160; const ML = 26, MR = 6, MT = 10, MB = 26; const innerW = W - ML - MR; const innerH = H - MT - MB; const rawMax = Math.max(...days.map(d => d.sleepHours)); const { yMax, steps: ySteps } = niceAxisSleepHours(rawMax); const gap = days.length > 14 ? 2 : 4; const barW = (innerW - (days.length - 1) * gap) / days.length; // Label every 1h gridline; smaller text once the axis is dense so the // labels stay apart (this chart's plot area is shorter than the trend's). const yLabelCls = ySteps > 12 ? "y-dense" : ""; const parts = []; for (let i = 0; i <= ySteps; i++) { const y = MT + innerH * (1 - i / ySteps); const v = Math.round(yMax * i / ySteps * 10) / 10; const vText = v % 1 === 0 ? v : v.toFixed(1); parts.push(``); parts.push(`${vText}h`); } const selYmd = ymd(selectedDay()); days.forEach((d, i) => { const isToday = i === days.length - 1; const isSel = d.ymd === selYmd; const x = ML + i * (barW + gap); const h = (d.sleepHours / yMax) * innerH; const y = MT + innerH - h; const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${d.sleepHours.toFixed(1)}h`; if (isSel) { parts.push(``); } parts.push( `` + `${escapeText(title)}` ); // The selected day always gets a label (accent-colored), even on wide // windows that would otherwise skip it. if (showDayLabel(i, days.length) || isSel) { parts.push( `` + `${escapeText(dayLabel(d.date, isToday))}` ); } }); setChartSVG(svg, parts); } function drawCountsChart(days) { const svg = document.getElementById("chart-counts"); const W = 320, H = 180; const ML = 22, MR = 6, MT = 10, MB = 26; const innerW = W - ML - MR; const innerH = H - MT - MB; const rawMax = Math.max(...days.flatMap(d => [d.pees, d.poos, d.meals])); const { yMax, steps: ySteps } = niceAxis(rawMax); const groupGap = days.length > 14 ? 2 : 4; const innerBarGap = days.length > 14 ? 0.5 : 1.5; const groupW = (innerW - (days.length - 1) * groupGap) / days.length; const barW = (groupW - 2 * innerBarGap) / 3; const parts = []; for (let i = 0; i <= ySteps; i++) { const y = MT + innerH * (1 - i / ySteps); const v = Math.round(yMax * i / ySteps); parts.push(``); parts.push(`${v}`); } const series = [ { key: "pees", label: "Pees", cls: "bar-pee" }, { key: "poos", label: "Poos", cls: "bar-poo" }, { key: "meals", label: "Meals", cls: "bar-eat" }, ]; const selYmd = ymd(selectedDay()); days.forEach((d, i) => { const isToday = i === days.length - 1; const isSel = d.ymd === selYmd; const groupX = ML + i * (groupW + groupGap); if (isSel) { parts.push(``); } series.forEach((s, j) => { const val = d[s.key]; const x = groupX + j * (barW + innerBarGap); const h = (val / yMax) * innerH; const y = MT + innerH - h; const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${s.label}: ${val}`; parts.push( `` + `${escapeText(title)}` ); }); if (showDayLabel(i, days.length) || isSel) { parts.push( `` + `${escapeText(dayLabel(d.date, isToday))}` ); } }); setChartSVG(svg, parts); } // Grams of food per day. Hidden entirely until any meal in the window has an // amount logged, so the weekly card doesn't grow an empty chart. function drawGramsChart(days) { const wrap = document.getElementById("grams-chart-wrap"); const svg = document.getElementById("chart-grams"); if (!days.some(d => d.grams > 0)) { wrap.hidden = true; return; } wrap.hidden = false; const W = 320, H = 160; const ML = 34, MR = 6, MT = 10, MB = 26; const innerW = W - ML - MR; const innerH = H - MT - MB; const { yMax, steps: ySteps } = niceAxisGrams(Math.max(...days.map(d => d.grams))); const gap = days.length > 14 ? 2 : 4; const barW = (innerW - (days.length - 1) * gap) / days.length; const parts = []; for (let i = 0; i <= ySteps; i++) { const y = MT + innerH * (1 - i / ySteps); const v = yMax * i / ySteps; const vText = v % 1 === 0 ? v : v.toFixed(1); parts.push(``); parts.push(`${vText}`); } const selYmd = ymd(selectedDay()); days.forEach((d, i) => { const isToday = i === days.length - 1; const isSel = d.ymd === selYmd; const x = ML + i * (barW + gap); const h = (d.grams / yMax) * innerH; const y = MT + innerH - h; const title = `${d.date.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" })} — ${Math.round(d.grams)} g`; if (isSel) { parts.push(``); } parts.push( `` + `${escapeText(title)}` ); if (showDayLabel(i, days.length) || isSel) { parts.push( `` + `${escapeText(dayLabel(d.date, isToday))}` ); } }); setChartSVG(svg, parts); } function renderWeekly(events) { const days = weeklyData(events); drawSleepChart(days); drawCountsChart(days); drawGramsChart(days); } // ---------- pattern charts ---------- // Actogram: one row per day (oldest at top), a midnight-to-midnight track with // the puppy's sleep shaded. Sleep windows are clipped to each day, so a night // that crosses midnight shows correctly split across two rows. Today's open // sleep runs to now (sleepWindows already clips ongoing sleep to Date.now()). function renderSleepTimeline(events) { const svg = document.getElementById("chart-sleep-timeline"); if (!svg) return; const N = chartDays(); const W = 320; const ML = 44, MR = 8, MT = 16, MB = 4; const innerW = W - ML - MR; const rowGap = 2; // Fixed row height, chart grows with the window (13px × 14 days matches // the original 228-high viewBox; wider windows use thinner rows). const rowH = N > 14 ? 9 : 13; const H = MT + MB + N * rowH + (N - 1) * rowGap; svg.setAttribute("viewBox", `0 0 ${W} ${H}`); const dayMs = 86_400_000; const today = startOfDay(new Date()); const windows = sleepWindows(events); const xOf = (frac) => ML + frac * innerW; const parts = []; for (const hr of [0, 6, 12, 18, 24]) { const x = xOf(hr / 24); parts.push(``); const anchor = hr === 0 ? "start" : hr === 24 ? "end" : "middle"; parts.push(`${hr}h`); } const selYmd = ymd(selectedDay()); for (let i = 0; i < N; i++) { const day = new Date(today); day.setDate(day.getDate() - (N - 1 - i)); // oldest at top, today at bottom const dayStart = startOfDay(day).getTime(); const dayEnd = dayStart + dayMs; const isToday = ymd(day) === ymd(new Date()); const isSel = ymd(day) === selYmd; const y = MT + i * (rowH + rowGap); parts.push(``); for (const wdw of windows) { const s = Math.max(wdw.start, dayStart); const e = Math.min(wdw.end, dayEnd); if (e <= s) continue; const x = xOf((s - dayStart) / dayMs); const wpx = ((e - s) / dayMs) * innerW; parts.push(``); } const label = isToday ? "Today" : `${day.toLocaleDateString(undefined, { weekday: "short" })} ${day.getDate()}`; parts.push(`${escapeText(label)}`); const title = day.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" }); parts.push(`${escapeText(title)}`); } setChartSVG(svg, parts); // wires the .bar[data-day] click → select that day } // Hour-of-day heatmap: one row per event type, 24 cells shaded by how often // that event lands in each hour across the window. Reveals daily rhythm that // the median gap can't show (e.g. "always poos ~7am and ~6pm"). function renderHourHeatmap(events) { const svg = document.getElementById("chart-hour-heatmap"); if (!svg) return; const from = startOfDay(new Date(Date.now() - (chartDays() - 1) * 86_400_000)).getTime(); const series = [ { type: "pee", label: "Pees", cls: "hm-pee" }, { type: "poo", label: "Poos", cls: "hm-poo" }, { type: "eat", label: "Meals", cls: "hm-eat" }, ]; const counts = {}; for (const s of series) counts[s.type] = new Array(24).fill(0); for (const e of events) { if (e.at < from) continue; if (counts[e.type]) counts[e.type][new Date(e.at).getHours()]++; } const W = 320, H = 120; const ML = 40, MR = 8, MT = 6, MB = 18; const innerW = W - ML - MR; const rowGap = 6; const rowH = (H - MT - MB - (series.length - 1) * rowGap) / series.length; const cellW = innerW / 24; const parts = []; series.forEach((s, r) => { const y = MT + r * (rowH + rowGap); const max = Math.max(1, ...counts[s.type]); for (let h = 0; h < 24; h++) { const c = counts[s.type][h]; const op = c === 0 ? 0.06 : 0.2 + 0.8 * (c / max); const x = ML + h * cellW; const range = `${pad2(h)}:00–${pad2((h + 1) % 24)}:00`; parts.push( `` + `${escapeText(`${s.label} · ${range}: ${c}`)}` ); } parts.push(`${s.label}`); }); const yAxis = H - MB + 12; for (const hr of [0, 6, 12, 18]) { const x = ML + (hr / 24) * innerW; parts.push(`${hr}h`); } parts.push(`24h`); svg.innerHTML = parts.join(""); } // ---------- sleep trend ---------- // Cumulative hours slept as the selected day progresses: that day's running // total (up to now when it's today, else the full 24h) against the previous // day's full curve and the mean of the N full days before it, where N // follows the 7/14/30 chart-days picker. // Each curve is a list of { x: hour-of-day 0..24, y: cumulative hours }. function sleepTrendCurves(events) { const HOUR = 3_600_000; const windows = sleepWindows(events); // ongoing sleep already clipped to now const sleptMs = (from, to) => { let total = 0; for (const w of windows) { const s = Math.max(w.start, from); const e = Math.min(w.end, to); if (e > s) total += e - s; } return total; }; const day = selectedDay(); const isToday = ymd(day) === ymd(new Date()); const dayStartTs = (daysAgo) => { const d = startOfDay(day); d.setDate(d.getDate() - daysAgo); return d.getTime(); }; // capTs (today only) truncates the curve at "now" with a final fractional // point, so the line visibly ends where the day currently stands. const curveFor = (start, capTs) => { const pts = []; for (let h = 0; h <= 24; h++) { const to = start + h * HOUR; if (capTs != null && to >= capTs) { pts.push({ x: (capTs - start) / HOUR, y: sleptMs(start, capTs) / HOUR }); break; } pts.push({ x: h, y: sleptMs(start, to) / HOUR }); } return pts; }; // A past day is complete, so its curve runs the full 24h uncapped. const today = curveFor(dayStartTs(0), isToday ? Date.now() : null); const yesterdayCurve = curveFor(dayStartTs(1)); const yesterday = yesterdayCurve[24].y > 0 ? yesterdayCurve : null; // Mean of the last N full days, skipping days with no sleep at all so a // young log (or a tracking gap) doesn't drag the average toward zero. const avgDays = chartDays(); const dayCurves = []; for (let i = 1; i <= avgDays; i++) { const c = curveFor(dayStartTs(i)); if (c[24].y > 0) dayCurves.push(c); } let avg = null; if (dayCurves.length > 0) { avg = []; for (let h = 0; h <= 24; h++) { avg.push({ x: h, y: dayCurves.reduce((s, c) => s + c[h].y, 0) / dayCurves.length }); } } // Where today is likely to end up: continue today's line by adding what // the average day typically adds between now and midnight. That respects // the time-of-day rhythm (night sleep, nap clusters), unlike a linear // rate extrapolation, which overshoots wildly just after a long night. // No history → no average → no projection. Past days are already complete, // so there is nothing to project. let projected = null; if (avg && isToday) { const nowPt = today[today.length - 1]; const avgAt = (x) => { const lo = Math.floor(x); const hi = Math.min(24, lo + 1); return avg[lo].y + (avg[hi].y - avg[lo].y) * (x - lo); }; projected = [{ x: nowPt.x, y: nowPt.y }]; for (let h = Math.ceil(nowPt.x); h <= 24; h++) { if (h <= nowPt.x) continue; // now landing exactly on an hour boundary projected.push({ x: h, y: nowPt.y + avgAt(h) - avgAt(nowPt.x) }); } } // Labels follow the selection so a past day reads as its date, not "Today". const fmtDay = (daysAgo) => new Date(dayStartTs(daysAgo)).toLocaleDateString(undefined, { month: "short", day: "numeric" }); const dayLabel = isToday ? "Today" : fmtDay(0); const prevDayLabel = isToday ? "Yesterday" : fmtDay(1); return { today, yesterday, avg, avgDays, projected, dayLabel, prevDayLabel }; } function drawSleepTrendChart(curves, target) { const svg = document.getElementById("chart-sleep-trend"); if (!svg) return; const W = 320, H = 220; const ML = 26, MR = 8, MT = 10, MB = 22; const innerW = W - ML - MR; const innerH = H - MT - MB; const series = [ // Reference lines first so today (and its projected tail) draw on top. { pts: curves.avg, cls: "trend-avg", label: `${curves.avgDays}-day average` }, { pts: curves.yesterday, cls: "trend-yesterday", label: curves.prevDayLabel }, { pts: curves.projected, cls: "trend-projected", label: "Projected end of day" }, { pts: curves.today, cls: "trend-today", label: curves.dayLabel }, ].filter(s => s.pts && s.pts.length > 1); // The axis must reach the goal band's top even when the data doesn't yet. const rawMax = Math.max( target ? target.hi : 0, ...series.flatMap(s => s.pts.map(p => p.y)), ); const { yMax } = niceAxisSleepHours(rawMax); // The interesting detail sits near the goal, in the upper teens of hours, // so the y scale is piecewise: everything up to BREAK shares a compressed // slice of the height and the hours above it get all the rest. Linear // again while the axis is still too short for a split to mean anything. const BREAK = 10, LOW_FRAC = 0.26; const split = yMax > BREAK + 2; const frac = (v) => !split ? v / yMax : v <= BREAK ? (v / BREAK) * LOW_FRAC : LOW_FRAC + ((v - BREAK) / (yMax - BREAK)) * (1 - LOW_FRAC); const xOf = (hour) => ML + (hour / 24) * innerW; const yOf = (v) => MT + innerH * (1 - frac(v)); // Gridlines: every 1h in the stretched region above the break, every 2h // in the compressed region below it (1h everywhere when linear). const yTicks = []; if (split) { for (let v = 0; v < BREAK; v += 2) yTicks.push(v); for (let v = BREAK; v <= yMax; v++) yTicks.push(v); } else { for (let v = 0; v <= yMax; v++) yTicks.push(v); } // Smaller text once the axis is dense so the labels stay apart. const yLabelCls = yTicks.length > 16 ? "y-dense" : ""; const parts = []; // Age-based goal band, behind everything: today's projection should land // inside it by midnight. if (target) { const yTop = yOf(target.hi); const yBot = yOf(target.lo); parts.push( `` + `${escapeText(`Goal ${target.lo}–${target.hi}h (${target.label})`)}` ); } for (const v of yTicks) { const y = yOf(v); parts.push(``); parts.push(`${v}h`); } for (const hr of [0, 6, 12, 18, 24]) { const x = xOf(hr); parts.push(``); const anchor = hr === 0 ? "start" : hr === 24 ? "end" : "middle"; parts.push(`${pad2(hr)}`); } for (const s of series) { const d = s.pts .map((p, i) => `${i === 0 ? "M" : "L"}${xOf(p.x).toFixed(1)} ${yOf(p.y).toFixed(1)}`) .join(" "); const last = s.pts[s.pts.length - 1]; const title = `${s.label} — ${last.y.toFixed(1)}h slept`; parts.push(`${escapeText(title)}`); } svg.innerHTML = parts.join(""); } function renderSleepTrend(events) { const curves = sleepTrendCurves(events); const target = sleepTargetFor(loadConfig().birthday); drawSleepTrendChart(curves, target); // Drop legend chips for missing reference lines (no data for them yet), // keep the average chip's label in step with the chart-days picker, and // write each curve's slept-hours total into its chip. const chip = (id) => document.getElementById(id); const hrs = (pts) => `${pts[pts.length - 1].y.toFixed(1)}h`; chip("legend-trend-today-text").textContent = `${curves.dayLabel} ${hrs(curves.today)}`; const yLegend = chip("legend-trend-yesterday"); yLegend.hidden = !curves.yesterday; if (curves.yesterday) { chip("legend-trend-yesterday-text").textContent = `${curves.prevDayLabel} ${hrs(curves.yesterday)}`; } const aLegend = chip("legend-trend-avg"); aLegend.hidden = !curves.avg; if (curves.avg) { chip("legend-trend-avg-text").textContent = `${curves.avgDays}-day avg ${hrs(curves.avg)}`; } const pLegend = chip("legend-trend-projected"); pLegend.hidden = !curves.projected; if (curves.projected) { // ✓ when the projection reaches at least the goal's lower bound. let mark = ""; if (target) { const end = curves.projected[curves.projected.length - 1].y; mark = end >= target.lo ? " ✓" : " ▽"; pLegend.title = end >= target.lo ? "On track for the sleep goal" : "Projected below the sleep goal"; } chip("legend-trend-projected-text").textContent = `Projected ~${hrs(curves.projected)}${mark}`; } const gLegend = chip("legend-trend-goal"); gLegend.hidden = !target; if (target) { chip("legend-trend-goal-text").textContent = `Goal ${target.lo}–${target.hi}h (${target.label})`; } } // ---------- weight ---------- // Pick a "nice" kg axis that frames the data with a little headroom rather // than forcing 0-based (a puppy going 5→8 kg would otherwise look flat). function niceWeightAxis(min, max) { if (!(max > 0)) return { lo: 0, hi: 1, steps: 1 }; if (min === max) { min = Math.max(0, min - 0.5); max = max + 0.5; } const span = max - min; let lo = Math.max(0, min - span * 0.15); let hi = max + span * 0.15; const rawStep = (hi - lo) / 4; const mag = Math.pow(10, Math.floor(Math.log10(rawStep))); const norm = rawStep / mag; const step = (norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 2.5 ? 2.5 : norm <= 5 ? 5 : 10) * mag; lo = Math.floor(lo / step) * step; hi = Math.ceil(hi / step) * step; return { lo, hi, steps: Math.max(1, Math.round((hi - lo) / step)) }; } // Human-readable detail for one weigh-in: date, weight, and the puppy's age // at that date (omitted if no birthday is configured). function weightPointInfo(w, birthday) { const dateStr = new Date(w.at).toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }); const age = formatAge(birthday, w.at); return `${dateStr} — ${formatWeight(w.weight)}${age ? ` · ${age}` : ""}`; } function drawWeightChart(weights, birthday) { const svg = document.getElementById("chart-weight"); const info = document.getElementById("weight-point-info"); const W = 320, H = 180; const ML = 30, MR = 8, MT = 10, MB = 24; const innerW = W - ML - MR; const innerH = H - MT - MB; if (weights.length === 0) { svg.innerHTML = ""; info.textContent = ""; return; } const vals = weights.map(w => w.weight); const { lo, hi, steps } = niceWeightAxis(Math.min(...vals), Math.max(...vals)); const t0 = weights[0].at; const t1 = weights[weights.length - 1].at; const tSpan = t1 - t0; const xOf = (t) => tSpan > 0 ? ML + ((t - t0) / tSpan) * innerW : ML + innerW / 2; const yOf = (v) => MT + innerH * (1 - (v - lo) / (hi - lo)); const parts = []; for (let i = 0; i <= steps; i++) { const v = lo + (hi - lo) * i / steps; const y = yOf(v); parts.push(``); parts.push(`${Math.round(v * 10) / 10}`); } if (weights.length > 1) { const d = weights .map((w, i) => `${i === 0 ? "M" : "L"}${xOf(w.at).toFixed(1)} ${yOf(w.weight).toFixed(1)}`) .join(" "); parts.push(``); } // Visible dots, then larger transparent hit targets on top (easier to tap // on touch, and they carry the tooltip + click detail). weights.forEach(w => { parts.push(``); }); weights.forEach((w, i) => { const detail = weightPointInfo(w, birthday); parts.push( `` + `${escapeText(detail)}` ); }); const fmtX = (t) => new Date(t).toLocaleDateString(undefined, { month: "short", day: "numeric" }); parts.push(`${escapeText(fmtX(t0))}`); if (tSpan > 0) { parts.push(`${escapeText(fmtX(t1))}`); } svg.innerHTML = parts.join(""); // Default the caption to the most recent weigh-in; hover/tap focuses a point. const hits = svg.querySelectorAll(".weight-hit"); const focus = (i) => { info.textContent = weightPointInfo(weights[i], birthday); hits.forEach(h => h.classList.toggle("active", Number(h.dataset.i) === i)); }; hits.forEach(h => { const i = Number(h.dataset.i); h.addEventListener("mouseenter", () => focus(i)); h.addEventListener("click", () => focus(i)); }); info.textContent = weightPointInfo(weights[weights.length - 1], birthday); } function renderWeight(events) { const weights = events .filter(e => e.type === "weight" && Number.isFinite(e.weight)) .sort((a, b) => a.at - b.at); const empty = document.getElementById("weight-empty"); const latestEl = document.getElementById("weight-latest"); const changeEl = document.getElementById("weight-change"); const list = document.getElementById("weight-list"); const birthday = loadConfig().birthday; list.innerHTML = ""; changeEl.classList.remove("up", "down"); if (weights.length === 0) { empty.hidden = false; latestEl.textContent = "—"; changeEl.textContent = "—"; drawWeightChart([], birthday); return; } empty.hidden = true; const latest = weights[weights.length - 1]; latestEl.textContent = formatWeight(latest.weight); if (weights.length >= 2) { const d = latest.weight - weights[weights.length - 2].weight; const rounded = Math.round(d * 100) / 100; const arrow = d > 0 ? "▲" : d < 0 ? "▼" : "▬"; changeEl.textContent = `${arrow} ${d > 0 ? "+" : ""}${rounded} kg`; changeEl.classList.toggle("up", d > 0); changeEl.classList.toggle("down", d < 0); } else { changeEl.textContent = "—"; } // Most-recent-first log; tap a row to edit that weigh-in. for (const w of [...weights].reverse()) { const li = document.createElement("li"); li.className = "ww weight-ww"; const date = document.createElement("span"); date.className = "ww-range"; const age = formatAgeWeeks(birthday, w.at); const dateStr = new Date(w.at).toLocaleDateString(undefined, { month: "short", day: "numeric" }); date.textContent = age ? `${dateStr} · ${age}` : dateStr; const val = document.createElement("span"); val.className = "ww-dur"; val.textContent = formatWeight(w.weight); li.appendChild(date); li.appendChild(val); li.addEventListener("click", () => openEditDialog(w)); list.appendChild(li); } drawWeightChart(weights, birthday); } // ---------- training ---------- // Per-exercise stats plus a consistency heatmap. Everything here is rolling // (last session / last 7 days / streak / chart window) rather than scoped to // the day picker — the point is keeping the habit up, not reviewing one day. const expandedExercises = new Set(); // ids showing their instructions (per page load) function trainingStreakDays(times) { // Consecutive days with ≥1 session, counting back from today — or from // yesterday, so a streak isn't shown as broken before today's session // has had a chance to happen. const days = new Set(times.map(t => ymd(new Date(t)))); const d = new Date(); if (!days.has(ymd(d))) d.setDate(d.getDate() - 1); let streak = 0; while (days.has(ymd(d))) { streak++; d.setDate(d.getDate() - 1); } return streak; } function exerciseMeta(times) { if (times.length === 0) return "not yet trained"; const weekFrom = startOfDay(new Date()); weekFrom.setDate(weekFrom.getDate() - 6); const week = times.filter(t => t >= weekFrom.getTime()).length; const parts = [`last ${formatRelative(Math.max(...times))}`, `${week}× this week`]; const streak = trainingStreakDays(times); if (streak >= 2) parts.push(`🔥 ${streak}-day streak`); return parts.join(" · "); } // Exercise × day grid: one row per exercise, one cell per day, opacity scaled // by that day's session count. Same idea as the hour heatmap, with days for // columns. Cells click through to the day picker via setChartSVG. function renderTrainingHeatmap(exercises, events) { const wrap = document.getElementById("training-chart-wrap"); const svg = document.getElementById("chart-training"); if (exercises.length === 0) { wrap.hidden = true; return; } wrap.hidden = false; const N = chartDays(); const from = startOfDay(new Date()); from.setDate(from.getDate() - (N - 1)); const dayList = []; const dayIndex = new Map(); // ymd -> column for (let i = 0; i < N; i++) { const d = new Date(from); d.setDate(d.getDate() + i); dayList.push(d); dayIndex.set(ymd(d), i); } const exIndex = new Map(exercises.map((x, i) => [x.id, i])); const counts = exercises.map(() => new Array(N).fill(0)); for (const e of events) { if (e.type !== "training") continue; const r = exIndex.get(e.exerciseId); const c = dayIndex.get(ymd(new Date(e.at))); if (r !== undefined && c !== undefined) counts[r][c]++; } const W = 320; const ML = 70, MR = 8, MT = 6, MB = 18; const rowH = 16, rowGap = 4; const H = MT + exercises.length * (rowH + rowGap) - rowGap + MB; svg.setAttribute("viewBox", `0 0 ${W} ${H}`); const innerW = W - ML - MR; const cellW = innerW / N; const parts = []; const selCol = dayIndex.get(ymd(selectedDay())); if (selCol !== undefined) { parts.push( `` ); } exercises.forEach((x, r) => { const y = MT + r * (rowH + rowGap); const max = Math.max(1, ...counts[r]); for (let c = 0; c < N; c++) { const n = counts[r][c]; const op = n === 0 ? 0.06 : 0.35 + 0.65 * (n / max); const d = dayList[c]; const title = `${x.name} · ${d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" })}: ${n}`; parts.push( `` + `${escapeText(title)}` ); } const name = x.name.length > 12 ? x.name.slice(0, 11) + "…" : x.name; parts.push(`${escapeText(name)}`); }); const yAxis = H - MB + 12; parts.push(`${escapeText(dayList[0].toLocaleDateString(undefined, { month: "short", day: "numeric" }))}`); parts.push(`Today`); setChartSVG(svg, parts); } function renderTraining(events) { const list = document.getElementById("training-list"); const empty = document.getElementById("training-empty"); const exercises = liveExercises(); list.innerHTML = ""; empty.hidden = exercises.length > 0; for (const ex of exercises) { const times = events .filter(e => e.type === "training" && e.exerciseId === ex.id) .map(e => e.at); const li = document.createElement("li"); li.className = "exercise" + (expandedExercises.has(ex.id) ? " expanded" : ""); const row = document.createElement("div"); row.className = "ex-row"; const main = document.createElement("div"); main.className = "ex-main"; const nameEl = document.createElement("span"); nameEl.className = "ex-name"; nameEl.textContent = ex.name; const metaEl = document.createElement("span"); metaEl.className = "ex-meta"; metaEl.textContent = exerciseMeta(times); main.appendChild(nameEl); main.appendChild(metaEl); const logBtn = document.createElement("button"); logBtn.type = "button"; logBtn.className = "ex-log"; logBtn.textContent = "Log"; logBtn.addEventListener("click", (e) => { e.stopPropagation(); // Log onto the selected day (at the current time of day), so a session // that was forgotten yesterday can be back-filled from that day's view. const day = selectedDay(); const onToday = ymd(day) === ymd(new Date()); let at = Date.now(); if (!onToday) { const now = new Date(); at = new Date(day.getFullYear(), day.getMonth(), day.getDate(), now.getHours(), now.getMinutes(), now.getSeconds()).getTime(); } const ev = addEvent("training", "", at, { exerciseId: ex.id }); const dayStr = day.toLocaleDateString(undefined, { month: "short", day: "numeric" }); showSnackbar(onToday ? `${ex.name} logged` : `${ex.name} logged for ${dayStr}`, ev); }); row.appendChild(main); row.appendChild(logBtn); row.addEventListener("click", () => { if (expandedExercises.has(ex.id)) expandedExercises.delete(ex.id); else expandedExercises.add(ex.id); li.classList.toggle("expanded"); }); const detail = document.createElement("div"); detail.className = "ex-detail"; const noteEl = document.createElement("p"); noteEl.className = "ex-note"; noteEl.textContent = ex.note || "No instructions yet — tap Edit to add how to train this."; const editBtn = document.createElement("button"); editBtn.type = "button"; editBtn.className = "ghost ex-edit"; editBtn.textContent = "Edit"; editBtn.addEventListener("click", (e) => { e.stopPropagation(); openExerciseDialog(ex); }); detail.appendChild(noteEl); detail.appendChild(editBtn); li.appendChild(row); li.appendChild(detail); list.appendChild(li); } renderTrainingHeatmap(exercises, events); } function renderDayBar() { const day = selectedDay(); const isToday = ymd(day) === ymd(new Date()); document.getElementById("day-next").disabled = isToday; document.getElementById("day-today").disabled = isToday; // Year-less date on the picker's face — the year is implicit and the // saved width keeps the bar on one row on small phones. document.getElementById("day-date-face").textContent = day.toLocaleDateString(undefined, { month: "short", day: "numeric" }); const title = document.getElementById("overview-title"); if (title) { title.textContent = isToday ? "Today's overview" : day.toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric" }); } } function renderHeader() { const cfg = loadConfig(); const title = document.getElementById("app-title"); const ageEl = document.getElementById("puppy-age"); title.textContent = cfg.name ? `🐶 ${cfg.name}` : "🐶 Puppy Tracker"; document.title = cfg.name ? `${cfg.name} · Puppy Tracker` : "Puppy Tracker"; const ageText = formatAgeShort(cfg.birthday); ageEl.textContent = ageText; ageEl.hidden = !ageText; // Use the puppy's name in the sleep-timeline heading rather than assuming a // gender; fall back to a neutral phrase when no name is configured. const sleepTitle = document.getElementById("sleep-timeline-title"); if (sleepTitle) sleepTitle.textContent = cfg.name ? `When ${cfg.name} sleeps` : "When sleeping"; } // Dim the quick actions that don't fit the current sleep state — a nudge // toward the likely next tap, never a block: everything stays clickable so // corrections (a missed sleep-end, a mid-nap pee) are always possible. function renderActionHints(events) { const { state } = currentSleepState(events); document.querySelectorAll("button.action").forEach(btn => { const type = btn.dataset.type; const unlikely = state === "asleep" ? type !== "sleep-end" : state === "awake" ? type === "sleep-end" : false; // no sleep history yet — no hints to give btn.classList.toggle("unlikely", unlikely); }); } function render() { const events = live(); renderHeader(); renderDayBar(); renderChartWindow(); renderBigClock(events); renderActionHints(events); renderStats(events); renderLasts(events); renderTiming(events); renderSleepWindows(events); renderWakeWindows(events); renderWeekly(events); renderSleepTimeline(events); renderSleepTrend(events); renderHourHeatmap(events); renderTraining(events); renderWeight(events); renderHistory(events); } // ---------- sync ---------- let syncTimer = null; let syncing = false; let lastError = null; let lastSynced = 0; function setStatus(state) { statusEl.classList.remove("offline", "syncing", "error", "pending"); if (!navigator.onLine) { statusEl.textContent = "offline"; statusEl.classList.add("offline"); return; } switch (state) { case "syncing": statusEl.textContent = "syncing…"; statusEl.classList.add("syncing"); break; case "error": statusEl.textContent = "sync error"; statusEl.classList.add("error"); statusEl.title = lastError || ""; break; case "pending": statusEl.textContent = "pending"; statusEl.classList.add("pending"); break; default: statusEl.textContent = lastSynced ? `synced ${formatRelative(lastSynced)}` : "synced"; statusEl.title = ""; } } function scheduleSync() { if (!navigator.onLine) { setStatus("pending"); return; } setStatus("pending"); clearTimeout(syncTimer); syncTimer = setTimeout(sync, SYNC_DEBOUNCE_MS); } // Merge a server response back into local storage. Anything local with a // newer updatedAt than the server's copy wins — that covers items the user // added/edited during the in-flight sync request. Shared by the events and // exercises collections, which follow the same LWW contract. function mergeSynced(serverItems, load, save) { const localById = new Map(load().map(e => [e.id, e])); const merged = new Map(); for (const se of serverItems) { if (se && se.id) merged.set(se.id, se); } for (const [id, le] of localById) { const se = merged.get(id); if (!se || (le.updatedAt || 0) > (se.updatedAt || 0)) { merged.set(id, le); } } save([...merged.values()]); } async function sync() { if (!currentUser) return; if (syncing) return; if (!navigator.onLine) { setStatus("pending"); return; } syncing = true; setStatus("syncing"); try { // Push queued photos first so events that reference them won't return // 404s when other clients try to fetch. await syncPhotos(); const res = await fetch(SYNC_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ events: loadAll() }), }); if (res.status === 401) { handleLoggedOut(); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = await res.json(); const exRes = await fetch("api/exercises/sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ exercises: loadExercises() }), }); if (exRes.status === 401) { handleLoggedOut(); return; } if (!exRes.ok) throw new Error(`HTTP ${exRes.status}`); const exBody = await exRes.json(); if (Array.isArray(exBody.exercises)) { mergeSynced(exBody.exercises, loadExercises, saveExercises); } if (Array.isArray(body.events)) { mergeSynced(body.events, loadAll, saveAll); } lastSynced = Date.now(); lastError = null; render(); setStatus("synced"); } catch (err) { lastError = err.message || String(err); console.warn("sync failed:", lastError); setStatus("error"); } finally { syncing = false; } } // ---------- config sync ---------- // Reconcile the local config cache with the host. Whichever side has the // newer updatedAt wins: adopt the server's copy, or push ours if it's ahead // (e.g. edited on this device while another client hadn't changed it). This // self-heals a failed push — the local copy stays newer and re-pushes next tick. async function syncConfig() { if (!currentUser) return; if (!navigator.onLine) return; const local = loadConfig(); try { const res = await fetch("api/config"); if (res.status === 401) { handleLoggedOut(); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = await res.json(); const server = { name: body.name || "", birthday: body.birthday || "", pedigreeId: body.pedigreeId || "", updatedAt: Number.isFinite(body.updatedAt) ? body.updatedAt : 0, }; const merged = reconcileConfig(local, server); if (!sameConfig(merged, local)) { saveConfig(merged); renderHeader(); refreshPedigreeButton(); } if (!sameConfig(merged, server)) { await pushConfig(merged); } } catch (err) { console.warn("config sync failed:", err); } } async function pushConfig(cfg) { if (!navigator.onLine) return; const res = await fetch("api/config", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(cfg), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const body = await res.json(); // Adopt the server's answer if it turned out to be newer (another client won). if (Number.isFinite(body.updatedAt) && body.updatedAt > cfg.updatedAt) { saveConfig({ name: body.name || "", birthday: body.birthday || "", pedigreeId: body.pedigreeId || "", updatedAt: body.updatedAt }); renderHeader(); } } // ---------- dialogs ---------- const noteDialog = document.getElementById("note-dialog"); const noteForm = document.getElementById("note-form"); const noteInput = document.getElementById("note-input"); const noteDate = document.getElementById("note-date"); const noteTime = document.getElementById("note-time"); const noteTitle = document.getElementById("note-title"); const notePhotoInput = document.getElementById("note-photo-input"); const notePhotoBtn = document.getElementById("note-photo-btn"); const notePhotoPreview = document.getElementById("note-photo-preview"); const noteWeightField = document.getElementById("note-weight-field"); const noteWeight = document.getElementById("note-weight"); const noteGramsField = document.getElementById("note-grams-field"); const noteGrams = document.getElementById("note-grams"); let pendingType = null; let notePhotos = []; // pending photos for this dialog: [{ blob, url }] // Whether the user has manually touched the date/time fields. While false the // dialog logs at the exact current millisecond rather than the minute-floored // input, so a just-logged event doesn't look up to ~59s old. let noteTimeEdited = false; // An event can carry several photos: photoId holds their UUIDs // comma-separated. A legacy single id is just a one-element list, and the // server passes the string through untouched (photos themselves are // uploaded and fetched individually by UUID). function photoIdsOf(ev) { return (ev.photoId || "").split(",").filter(Boolean); } // A preview thumbnail with a remove button; shared by both dialogs. function photoThumb(url, onRemove) { const wrap = document.createElement("div"); wrap.className = "photo-thumb"; const img = document.createElement("img"); img.alt = ""; if (url) img.src = url; const rm = document.createElement("button"); rm.type = "button"; rm.className = "photo-thumb-remove"; rm.setAttribute("aria-label", "Remove photo"); rm.textContent = "×"; rm.addEventListener("click", onRemove); wrap.appendChild(img); wrap.appendChild(rm); return wrap; } function renderNotePhotos() { notePhotoPreview.innerHTML = ""; notePhotoPreview.hidden = notePhotos.length === 0; notePhotos.forEach((p, i) => { notePhotoPreview.appendChild(photoThumb(p.url, () => { URL.revokeObjectURL(p.url); notePhotos.splice(i, 1); renderNotePhotos(); })); }); } function clearNotePhotos() { for (const p of notePhotos) URL.revokeObjectURL(p.url); notePhotos = []; renderNotePhotos(); notePhotoInput.value = ""; } function openNoteDialog(type) { pendingType = type; noteInput.value = ""; noteTimeEdited = false; const now = Date.now(); noteDate.value = toDateInput(now); noteTime.value = toTimeInput(now); noteTitle.textContent = `Log ${EVENT_LABELS[type]}`; const isWeight = type === "weight"; const isEat = type === "eat"; noteWeightField.hidden = !isWeight; noteWeight.value = ""; noteGramsField.hidden = !isEat; noteGrams.value = ""; clearNotePhotos(); noteDialog.showModal(); setTimeout(() => (isWeight ? noteWeight : isEat ? noteGrams : noteInput).focus(), 50); } function noteDialogAt() { // Untouched time → stamp the exact current instant (sub-minute accurate). // Once the user picks a time, honor the input (minute precision is fine). if (!noteTimeEdited) return Date.now(); const parsed = fromDateTimeInputs(noteDate.value, noteTime.value); return Number.isFinite(parsed) ? parsed : Date.now(); } document.getElementById("note-time-now").addEventListener("click", () => { const now = Date.now(); noteDate.value = toDateInput(now); noteTime.value = toTimeInput(now); noteTimeEdited = false; // "Now" means log at the current instant again }); [noteDate, noteTime].forEach(el => { const markEdited = () => { noteTimeEdited = true; }; el.addEventListener("change", markEdited); el.addEventListener("input", markEdited); }); notePhotoBtn.addEventListener("click", () => notePhotoInput.click()); notePhotoInput.addEventListener("change", async (e) => { for (const file of Array.from(e.target.files || [])) { try { const blob = await resizeImage(file); notePhotos.push({ blob, url: URL.createObjectURL(blob) }); } catch (err) { alert("Couldn't process that photo: " + err.message); } } notePhotoInput.value = ""; renderNotePhotos(); }); document.getElementById("note-save").addEventListener("click", async (e) => { e.preventDefault(); if (!pendingType) { noteDialog.close(); return; } let weight; if (pendingType === "weight") { weight = parseFloat(noteWeight.value); if (!(weight > 0)) { alert("Enter a weight in kilograms."); return; } weight = Math.round(weight * 100) / 100; } let grams; if (pendingType === "eat" && noteGrams.value.trim() !== "") { const g = parseFloat(noteGrams.value); if (!(g > 0)) { alert("Enter the amount in grams, or leave it empty."); return; } grams = Math.round(g); } const photoIds = []; for (const p of notePhotos) { const id = uuid(); try { await putPhoto(id, p.blob, false); } catch (err) { alert("Couldn't store photo locally: " + err.message); return; } photoIds.push(id); } addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), { photoId: photoIds.join(","), weight, grams }); pendingType = null; clearNotePhotos(); noteDialog.close(); }); noteForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => { e.preventDefault(); pendingType = null; clearNotePhotos(); noteDialog.close(); }); // Edit dialog const editDialog = document.getElementById("edit-dialog"); const editForm = document.getElementById("edit-form"); const editDate = document.getElementById("edit-date"); const editTime = document.getElementById("edit-time"); const editNote = document.getElementById("edit-note"); const editDelete = document.getElementById("edit-delete"); const editPhotoInput = document.getElementById("edit-photo-input"); const editPhotoBtn = document.getElementById("edit-photo-btn"); const editPhotoPreview = document.getElementById("edit-photo-preview"); const editWeightField = document.getElementById("edit-weight-field"); const editWeight = document.getElementById("edit-weight"); const editGramsField = document.getElementById("edit-grams-field"); const editGrams = document.getElementById("edit-grams"); let editingId = null; // The dialog's working set of photos, in display order. Existing photos are // { id, url } (url from photoSrc's page-lifetime cache — never revoked here); // newly picked ones are { blob, url } with a fresh object URL we own. let editPhotos = []; function renderEditPhotos() { editPhotoPreview.innerHTML = ""; editPhotoPreview.hidden = editPhotos.length === 0; editPhotos.forEach((p, i) => { editPhotoPreview.appendChild(photoThumb(p.url, () => { if (p.blob) URL.revokeObjectURL(p.url); editPhotos.splice(i, 1); renderEditPhotos(); })); }); } function resetEditPhotos() { for (const p of editPhotos) if (p.blob) URL.revokeObjectURL(p.url); editPhotos = []; editPhotoInput.value = ""; } async function openEditDialog(ev) { editingId = ev.id; editDate.value = toDateInput(ev.at); editTime.value = toTimeInput(ev.at); editNote.value = ev.note || ""; editWeightField.hidden = ev.type !== "weight"; editWeight.value = (ev.type === "weight" && Number.isFinite(ev.weight)) ? ev.weight : ""; editGramsField.hidden = ev.type !== "eat"; editGrams.value = (ev.type === "eat" && Number.isFinite(ev.grams) && ev.grams > 0) ? ev.grams : ""; resetEditPhotos(); for (const id of photoIdsOf(ev)) { editPhotos.push({ id, url: await photoSrc(id) }); } renderEditPhotos(); editDialog.showModal(); // Without an explicit focus the browser autofocuses the first control — // the date input — and iOS immediately pops the calendar over the form. // Land on the first relevant text field instead (same trick and delay as // openNoteDialog: synchronous focus can be lost while the dialog settles). const focusTarget = !editWeightField.hidden ? editWeight : !editGramsField.hidden ? editGrams : editNote; setTimeout(() => focusTarget.focus(), 50); } editPhotoBtn.addEventListener("click", () => editPhotoInput.click()); editPhotoInput.addEventListener("change", async (e) => { for (const file of Array.from(e.target.files || [])) { try { const blob = await resizeImage(file); editPhotos.push({ blob, url: URL.createObjectURL(blob) }); } catch (err) { alert("Couldn't process that photo: " + err.message); } } editPhotoInput.value = ""; renderEditPhotos(); }); editForm.querySelector('button[value="save"]').addEventListener("click", async (e) => { e.preventDefault(); if (!editingId) { editDialog.close(); return; } const newAt = fromDateTimeInputs(editDate.value, editTime.value); const patch = { at: Number.isFinite(newAt) ? newAt : undefined, note: editNote.value.trim(), }; if (!editWeightField.hidden) { const kg = parseFloat(editWeight.value); if (!(kg > 0)) { alert("Enter a weight in kilograms."); return; } patch.weight = Math.round(kg * 100) / 100; } if (!editGramsField.hidden) { if (editGrams.value.trim() === "") { patch.grams = undefined; // cleared → drop the amount } else { const g = parseFloat(editGrams.value); if (!(g > 0)) { alert("Enter the amount in grams, or leave it empty."); return; } patch.grams = Math.round(g); } } const photoIds = []; for (const p of editPhotos) { if (p.id) { photoIds.push(p.id); continue; } const id = uuid(); try { await putPhoto(id, p.blob, false); } catch (err) { alert("Couldn't store photo locally: " + err.message); return; } photoIds.push(id); } patch.photoId = photoIds.join(","); updateEvent(editingId, patch); editingId = null; resetEditPhotos(); editDialog.close(); }); editForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => { e.preventDefault(); editingId = null; resetEditPhotos(); editDialog.close(); }); editDelete.addEventListener("click", (e) => { e.preventDefault(); if (editingId && confirm("Delete this event?")) { deleteEvent(editingId); } editingId = null; resetEditPhotos(); editDialog.close(); }); // Settings dialog (puppy name + birthday) // ---------- theme ---------- // Preference is device-global (not per user). No stored choice → follow the // OS via the prefers-color-scheme media query; a choice sets data-theme on // , which the CSS treats as an override. The applies any saved // choice before first paint; this just resolves state and reacts to the toggle. const THEME_KEY = "puppy-tracker:theme"; const prefersDark = () => window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; function effectiveTheme() { const s = localStorage.getItem(THEME_KEY); return s === "light" || s === "dark" ? s : (prefersDark() ? "dark" : "light"); } function setTheme(theme) { document.documentElement.dataset.theme = theme; try { localStorage.setItem(THEME_KEY, theme); } catch { /* ignore */ } } const settingsDialog = document.getElementById("settings-dialog"); const settingsForm = document.getElementById("settings-form"); const settingsName = document.getElementById("settings-name"); const settingsBirthday = document.getElementById("settings-birthday"); const settingsPedigree = document.getElementById("settings-pedigree"); const settingsTheme = document.getElementById("settings-theme"); const settingsConfetti = document.getElementById("settings-confetti"); // Apply live so the toggle previews immediately (independent of Save/Cancel). settingsTheme.addEventListener("change", () => { setTheme(settingsTheme.checked ? "dark" : "light"); }); function openSettingsDialog() { const cfg = loadConfig(); settingsName.value = cfg.name; settingsBirthday.value = cfg.birthday; settingsPedigree.value = cfg.pedigreeId; settingsTheme.checked = effectiveTheme() === "dark"; settingsConfetti.checked = confettiEnabled(); settingsDialog.showModal(); setTimeout(() => settingsName.focus(), 50); } document.getElementById("settings-btn").addEventListener("click", openSettingsDialog); settingsForm.querySelector('button[value="save"]').addEventListener("click", async (e) => { e.preventDefault(); const cfg = { name: settingsName.value.trim(), birthday: settingsBirthday.value, pedigreeId: settingsPedigree.value.trim(), updatedAt: Date.now(), }; saveConfig(cfg); // cache locally for instant + offline paint setConfettiEnabled(settingsConfetti.checked); // device-local, not synced renderHeader(); refreshPedigreeButton(); settingsDialog.close(); try { await pushConfig(cfg); } catch (err) { console.warn("config save failed:", err); // Kept locally; syncConfig retries automatically once the host is reachable. } }); settingsForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => { e.preventDefault(); settingsDialog.close(); }); // ---------- pedigree lookup ---------- // A separate full-screen view that resolves a dog against SKK Hunddata by // chip / registration number / name and renders its ancestry as a tree. The // server returns the first generations immediately and crawls deeper in the // background; we poll for that and re-render as ancestors arrive. Online-only. const pedScreen = document.getElementById("pedigree-screen"); const pedStatus = document.getElementById("pedigree-status"); const pedChoose = document.getElementById("pedigree-choose"); const pedSubject = document.getElementById("pedigree-subject"); const pedTree = document.getElementById("pedigree-tree"); const pedBtn = document.getElementById("pedigree-btn"); const pedRefresh = document.getElementById("pedigree-refresh"); const pedRepeatNote = document.getElementById("pedigree-repeat-note"); const PED_OPEN_DEPTH = 3; // show 3 generations expanded by default; deeper collapses let pedPollTimer = null; let pedNodes = {}; // latest ancestry map, for the progress count // ---- zoom ---- // The tree gets very wide when expanded, so it's zoomable: buttons, ctrl/⌘ + // wheel, and pinch. We scale via the CSS `zoom` property (not transform) so the // scroll container reflows and every part stays reachable. The level persists. const pedZoomOut = document.getElementById("ped-zoom-out"); const pedZoomIn = document.getElementById("ped-zoom-in"); const pedZoomReset = document.getElementById("ped-zoom-reset"); const PED_ZOOM_MIN = 0.4, PED_ZOOM_MAX = 1.6; const pedZoomKey = () => `puppy-tracker:${currentUser.id}:pedigree-zoom:v1`; let pedZoom = 1; function applyPedZoom() { pedZoom = Math.min(PED_ZOOM_MAX, Math.max(PED_ZOOM_MIN, Math.round(pedZoom * 100) / 100)); pedTree.style.zoom = pedZoom; pedZoomReset.textContent = Math.round(pedZoom * 100) + "%"; try { localStorage.setItem(pedZoomKey(), String(pedZoom)); } catch { /* ignore */ } } function pedZoomBy(delta) { pedZoom += delta; applyPedZoom(); } pedZoomIn.addEventListener("click", () => pedZoomBy(0.2)); pedZoomOut.addEventListener("click", () => pedZoomBy(-0.2)); pedZoomReset.addEventListener("click", () => { pedZoom = 1; applyPedZoom(); }); // ---- collapse / expand all ---- const pedFoldAll = document.getElementById("ped-foldall"); function pedSetAll(collapsed) { pedTree.querySelectorAll("li").forEach((li) => { if (!li.querySelector(":scope > ul")) return; // no ancestors to fold li.classList.toggle("collapsed", collapsed); const t = li.querySelector(":scope > .ped-card > .ped-toggle"); if (t) t.textContent = collapsed ? "+" : "−"; }); pedFoldAll.textContent = collapsed ? "Expand all" : "Collapse all"; } pedFoldAll.addEventListener("click", () => pedSetAll(pedFoldAll.textContent[0] === "C")); // ---- view toggle: top-down tree vs radial fan ---- const pedViewBtn = document.getElementById("ped-view"); const pedCaption = document.getElementById("pedigree-caption"); const pedViewKey = () => `puppy-tracker:${currentUser.id}:pedigree-view:v1`; let pedView = "tree"; try { const v = localStorage.getItem(pedViewKey()); if (v === "fan" || v === "tree") pedView = v; } catch { /* ignore */ } // The fan has no per-branch folding, so hide that control in fan mode; the // toggle always offers the *other* view. function updatePedViewControls() { pedViewBtn.textContent = pedView === "fan" ? "Tree view" : "Fan view"; pedFoldAll.hidden = pedView === "fan"; if (pedView !== "fan" && pedCaption) { pedCaption.hidden = true; } } pedViewBtn.addEventListener("click", () => { pedView = pedView === "fan" ? "tree" : "fan"; try { localStorage.setItem(pedViewKey(), pedView); } catch { /* ignore */ } renderPedigree(pedNodes); }); // Trackpad/desktop: ctrl or ⌘ + wheel zooms instead of scrolling the page. pedTree.addEventListener("wheel", (e) => { if (!e.ctrlKey && !e.metaKey) return; e.preventDefault(); pedZoomBy(e.deltaY < 0 ? 0.1 : -0.1); }, { passive: false }); // Touch: two-finger pinch. let pinchDist = 0, pinchZoom = 1; const touchDist = (t) => Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY); pedTree.addEventListener("touchstart", (e) => { if (e.touches.length === 2) { pinchDist = touchDist(e.touches); pinchZoom = pedZoom; } }, { passive: true }); pedTree.addEventListener("touchmove", (e) => { if (e.touches.length === 2 && pinchDist > 0) { e.preventDefault(); pedZoom = pinchZoom * (touchDist(e.touches) / pinchDist); applyPedZoom(); } }, { passive: false }); pedTree.addEventListener("touchend", (e) => { if (e.touches.length < 2) pinchDist = 0; }); // The looked-up tree is cached locally per dog id, so reopening the page paints // instantly and still shows the last-known tree offline. The server caches it // too (per dog, permanently); this is just the client-side mirror. const pedCacheKey = (id) => `puppy-tracker:${currentUser.id}:pedigree:${id}:v1`; function loadPedCache(id) { try { return JSON.parse(localStorage.getItem(pedCacheKey(id))) || null; } catch { return null; } } function savePedCache(id, subject, nodes) { try { localStorage.setItem(pedCacheKey(id), JSON.stringify({ subject, nodes })); } catch { /* ignore */ } } // Show the 🌳 button only once a pedigree id is set in Settings. function refreshPedigreeButton() { pedBtn.hidden = !loadConfig().pedigreeId; } function openPedigree() { const id = loadConfig().pedigreeId; appEl.hidden = true; pedScreen.hidden = false; const saved = parseFloat(localStorage.getItem(pedZoomKey())); pedZoom = Number.isFinite(saved) ? saved : 1; applyPedZoom(); if (!id) { setPedStatus("Set your dog's SKK id in Settings to see its pedigree.", ""); return; } lookupPedigree(id); } function closePedigree() { stopPedPoll(); pedScreen.hidden = true; appEl.hidden = false; } function stopPedPoll() { if (pedPollTimer) { clearTimeout(pedPollTimer); pedPollTimer = null; } } pedBtn.addEventListener("click", openPedigree); document.getElementById("pedigree-back").addEventListener("click", closePedigree); pedRefresh.addEventListener("click", () => { const id = loadConfig().pedigreeId; if (id) lookupPedigree(id); }); async function lookupPedigree(q) { stopPedPoll(); pedChoose.hidden = true; pedChoose.textContent = ""; pedSubject.hidden = true; pedSubject.textContent = ""; pedTree.textContent = ""; pedNodes = {}; // Paint the cached tree first so the page is instant (and works offline). const cached = loadPedCache(q); if (cached && cached.nodes) { renderSubject(cached.subject); renderPedigree(cached.nodes); } if (!navigator.onLine) { setPedStatus(cached ? "Offline — showing the last saved pedigree." : "Pedigree needs an internet connection.", cached ? "" : "error"); return; } setPedStatus(cached ? "Refreshing…" : "Looking up…", "busy"); let res; try { res = await fetch("api/pedigree", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ q }), }); } catch { setPedStatus(cached ? "Offline — showing the last saved pedigree." : "Couldn't reach the server. Try again.", cached ? "" : "error"); return; } if (res.status === 401) { handleLoggedOut(); return; } if (res.status === 404) { setPedStatus("Couldn't find that dog in SKK — check the ID in Settings.", "error"); return; } if (!res.ok) { const msg = (await res.text().catch(() => "")).trim(); setPedStatus(msg || `Lookup failed (HTTP ${res.status}).`, "error"); return; } const data = await res.json(); if (data.status === "choose") { renderChoose(data.matches || []); return; } renderSubject(data.subject); renderPedigree(data.nodes || {}); if (data.status === "done") { savePedCache(q, data.subject, data.nodes || {}); setPedDone(); } else { setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy"); pollPedigree(data.jobId, q, data.subject); } } function pollPedigree(jobId, q, subject) { stopPedPoll(); const tick = async () => { let res; try { res = await fetch(`api/pedigree/status?job=${encodeURIComponent(jobId)}`); } catch { pedPollTimer = setTimeout(tick, 3000); return; } if (res.status === 401) { handleLoggedOut(); return; } if (!res.ok) { setPedStatus("Lost track of the pedigree crawl.", "error"); return; } const data = await res.json(); renderPedigree(data.nodes || {}); if (data.status === "done") { savePedCache(q, subject, data.nodes || {}); setPedDone(); return; } if (data.status === "error") { setPedStatus(data.error || "Pedigree crawl failed.", "error"); return; } setPedStatus(`Tracing ancestry… ${pedCountText()}`, "busy"); pedPollTimer = setTimeout(tick, 1500); }; pedPollTimer = setTimeout(tick, 1500); } // Counts derived from the ancestry map we already hold, so the summary is // right whether it came from a fresh crawl, a poll, or a cache hit. Distinct // ancestors are keyed by registration number (pedigree collapse means one dog // fills many positions); "generations back" is the depth of the deepest // position (floor(log2(pos)), since sire = 2·pos and dam = 2·pos+1). function pedCounts() { const counts = new Map(); let maxPos = 1; for (const k in pedNodes) { const n = pedNodes[k]; const key = n.reg || n.name; if (key) counts.set(key, (counts.get(key) || 0) + 1); const p = Number(k); if (p > maxPos) maxPos = p; } let repeated = 0; counts.forEach((c) => { if (c > 1) repeated++; }); return { distinct: counts.size, repeated, gens: Math.floor(Math.log2(maxPos)) }; } function pedCountText() { const { distinct: a, repeated: r, gens: g } = pedCounts(); let s = `${a} ancestor${a === 1 ? "" : "s"} back ${g} generation${g === 1 ? "" : "s"}`; if (r > 0) s += `, ${r} appearing more than once`; return s; } function setPedDone() { setPedStatus(`Traced ${pedCountText()}.`, "done"); } function setPedStatus(text, kind) { pedStatus.hidden = false; pedStatus.textContent = text; pedStatus.className = "pedigree-status" + (kind ? " " + kind : ""); } function renderSubject(s) { if (!s) { pedSubject.hidden = true; return; } pedSubject.hidden = false; pedSubject.textContent = ""; const name = document.createElement("div"); name.className = "ped-subject-name"; name.textContent = s.name || "(unnamed)"; const meta = document.createElement("div"); meta.className = "ped-subject-meta"; const bits = []; if (s.breed) bits.push(s.breed); if (s.reg) bits.push(s.reg); if (s.sex) bits.push(s.sex === "H" ? "♂" : s.sex === "T" ? "♀" : s.sex); meta.textContent = bits.join(" · "); pedSubject.append(name, meta); } function renderChoose(matches) { pedTree.textContent = ""; pedSubject.hidden = true; setPedStatus(`${matches.length} matches — pick one:`, ""); pedChoose.hidden = false; pedChoose.textContent = ""; matches.slice(0, 50).forEach((m) => { const b = document.createElement("button"); b.type = "button"; b.className = "ped-match"; const nm = (m.hundnamn || "").trim() || "(unnamed)"; const nameEl = document.createElement("span"); nameEl.className = "ped-match-name"; nameEl.textContent = nm; const metaEl = document.createElement("span"); metaEl.className = "ped-match-meta"; metaEl.textContent = [m.Regnr, m.rastext].filter(Boolean).join(" · "); b.append(nameEl, metaEl); b.addEventListener("click", () => { pedChoose.hidden = true; lookupPedigree((m.Regnr || "").trim() || nm); }); pedChoose.append(b); }); } // The tree is ahnentafel-indexed: the dog is position 1, its sire 2n and dam // 2n+1. We render it top-down like a family tree — the dog on top, parents // branching below — as a nested
    /
  • so CSS can draw the connectors. // Built recursively (sire left, dam right) and collapsed below PED_OPEN_DEPTH. // A dog's identity for spotting pedigree collapse: its registration number, // or its name when it has none. Ancestors sharing a key are the same dog. function dogKey(n) { return n ? (n.reg || n.name || "") : ""; } // Stable hue per repeated dog so its badge/highlight colour is consistent // everywhere it appears. function hueFor(s) { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h % 360; } let pedRepeat = {}; // dogKey -> occurrence count, for the current tree // Render the ancestry in whichever view is active (top-down tree or radial // fan). Shared prep — cache the nodes and count pedigree collapse — lives here. function renderPedigree(nodes) { pedNodes = nodes; pedRepeat = {}; for (const k in nodes) { const id = dogKey(nodes[k]); if (id) pedRepeat[id] = (pedRepeat[id] || 0) + 1; } if (pedRepeatNote) pedRepeatNote.hidden = !Object.values(pedRepeat).some((c) => c > 1); pedTree.textContent = ""; if (pedView === "fan") renderFan(nodes); else renderTree(nodes); updatePedViewControls(); } function renderTree(nodes) { const root = buildPedNode(nodes, 1, 0); if (!root) return; const ul = document.createElement("ul"); ul.className = "ped-tree-h"; ul.append(root); pedTree.append(ul); if (pedFoldAll) pedFoldAll.textContent = "Collapse all"; // fresh tree starts partly open } // ---- radial fan chart ---- // The dog sits in a centre disc; each generation is a ring fanning outward. // A position p is at generation g = floor(log2 p); within that ring it takes // the wedge (idx=p-2^g) of 2^g equal slices, which nests each dog's parents // directly outside it. Labels only fit on the inner rings; deeper wedges are // colour only, with details on tap. Repeated dogs (pedigree collapse) carry // their stable hue so tapping one lights up every wedge of that dog. const FAN_MAX_GEN = 9; const SVGNS = "http://www.w3.org/2000/svg"; const fanPolar = (r, deg) => { const a = (deg - 90) * Math.PI / 180; return [r * Math.cos(a), r * Math.sin(a)]; }; const fanNum = (n) => Math.round(n * 100) / 100; function renderFan(nodes) { let maxGen = 0; for (const k in nodes) { const g = Math.floor(Math.log2(Number(k))); if (g > maxGen) maxGen = g; } maxGen = Math.min(maxGen, FAN_MAX_GEN); const r0 = 46; const radii = [r0]; for (let g = 1; g <= maxGen; g++) radii[g] = radii[g - 1] + Math.max(24, 50 - g * 3); const R = radii[maxGen] || r0; const pad = 4; const box = (R + pad) * 2; const svg = document.createElementNS(SVGNS, "svg"); svg.setAttribute("class", "ped-fan"); svg.setAttribute("viewBox", `${-R - pad} ${-R - pad} ${box} ${box}`); svg.setAttribute("width", box); svg.setAttribute("height", box); for (let g = 1; g <= maxGen; g++) { const count = 2 ** g, degPer = 360 / count, ri = radii[g - 1], ro = radii[g]; for (let idx = 0; idx < count; idx++) { const n = nodes[String(count + idx)]; if (!n) continue; const a0 = idx * degPer, a1 = a0 + degPer, large = (a1 - a0) > 180 ? 1 : 0; const [x1, y1] = fanPolar(ri, a0), [x2, y2] = fanPolar(ro, a0); const [x3, y3] = fanPolar(ro, a1), [x4, y4] = fanPolar(ri, a1); const path = document.createElementNS(SVGNS, "path"); path.setAttribute("d", `M${fanNum(x2)} ${fanNum(y2)}A${fanNum(ro)} ${fanNum(ro)} 0 ${large} 1 ${fanNum(x3)} ${fanNum(y3)}` + `L${fanNum(x4)} ${fanNum(y4)}A${fanNum(ri)} ${fanNum(ri)} 0 ${large} 0 ${fanNum(x1)} ${fanNum(y1)}Z`); path.setAttribute("class", "ped-wedge"); path.style.setProperty("--gen", g); const key = dogKey(n); if (key && pedRepeat[key] > 1) { path.dataset.dogkey = key; path.style.setProperty("--repeat-hue", hueFor(key)); path.classList.add("ped-wedge-repeat"); } const title = document.createElementNS(SVGNS, "title"); title.textContent = fanTitle(n, key); path.append(title); path.addEventListener("click", () => selectFan(count + idx)); svg.append(path); if (degPer >= 20) fanLabel(svg, n, (a0 + a1) / 2, ri, ro, degPer); } } // centre disc = the dog const c = document.createElementNS(SVGNS, "circle"); c.setAttribute("r", r0); c.setAttribute("class", "ped-fan-center"); c.addEventListener("click", () => selectFan(1)); svg.append(c); fanCenterLabel(svg, nodes["1"], r0); pedTree.append(svg); if (pedCaption) { pedCaption.hidden = false; pedCaption.textContent = "Tap a wedge for its dog. Zoom to read the outer rings."; } } function fanTitle(n, key) { let t = n.name || "(unnamed)"; if (n.reg) t += " — " + n.reg; if (key && pedRepeat[key] > 1) t += " (×" + pedRepeat[key] + ")"; return t; } function fanLabel(svg, n, midA, ri, ro, degPer) { let rot = midA - 90; if (rot > 90 && rot < 270) rot -= 180; // keep upright const [px, py] = fanPolar((ri + ro) / 2, midA); const t = document.createElementNS(SVGNS, "text"); t.setAttribute("class", "ped-wedge-label"); t.setAttribute("transform", `translate(${fanNum(px)} ${fanNum(py)}) rotate(${fanNum(rot)})`); const room = Math.floor((ro - ri) / 6.2); // chars that fit along the ring t.textContent = fanTrunc(n.name || (n.reg || "?"), Math.max(6, room)); svg.append(t); } function fanCenterLabel(svg, n, r0) { if (!n) return; const words = (n.name || "Dog").split(" "); const lines = []; let line = ""; for (const w of words) { if ((line + " " + w).trim().length > 12) { if (line) lines.push(line); line = w; } else line = (line ? line + " " : "") + w; } if (line) lines.push(line); const shown = lines.slice(0, 3); const t = document.createElementNS(SVGNS, "text"); t.setAttribute("class", "ped-fan-center-label"); t.setAttribute("text-anchor", "middle"); const lh = 12, y0 = -(shown.length - 1) * lh / 2; shown.forEach((ln, i) => { const ts = document.createElementNS(SVGNS, "tspan"); ts.setAttribute("x", "0"); ts.setAttribute("y", fanNum(y0 + i * lh)); ts.textContent = ln; t.append(ts); }); svg.append(t); } function fanTrunc(s, max) { return s.length > max ? s.slice(0, max - 1) + "…" : s; } function selectFan(pos) { const n = pedNodes[String(pos)]; if (!n || !pedCaption) return; const key = dogKey(n); const bits = []; if (n.reg) bits.push(n.reg); if (n.titles) bits.push(n.titles); if (key && pedRepeat[key] > 1) bits.push("appears ×" + pedRepeat[key]); pedCaption.hidden = false; pedCaption.textContent = (n.name || "(unnamed)") + (bits.length ? " — " + bits.join(" · ") : ""); togglePedHighlight(key && pedRepeat[key] > 1 ? key : ""); // clears if not a repeat } // Highlight (or unhighlight) every element (tree card or fan wedge) that is the // same dog as `key`. function togglePedHighlight(key) { const els = pedTree.querySelectorAll("[data-dogkey]"); let lit = false; els.forEach((c) => { if (c.dataset.dogkey === key && c.classList.contains("ped-lit")) lit = true; }); els.forEach((c) => c.classList.remove("ped-lit")); if (!lit) els.forEach((c) => { if (c.dataset.dogkey === key) c.classList.add("ped-lit"); }); } function buildPedNode(nodes, pos, depth) { const n = nodes[String(pos)]; const hasSire = !!nodes[String(pos * 2)]; const hasDam = !!nodes[String(pos * 2 + 1)]; if (!n && !hasSire && !hasDam) return null; const li = document.createElement("li"); const card = document.createElement("div"); card.className = "ped-card"; const nameEl = document.createElement("div"); nameEl.className = "ped-name"; nameEl.textContent = n ? (n.name || "(unnamed)") : "Unknown"; if (!n) nameEl.classList.add("ped-unknown"); card.append(nameEl); if (n && n.titles) { const t = document.createElement("div"); t.className = "ped-titles"; t.textContent = n.titles; card.append(t); } if (n && n.reg) { const r = document.createElement("div"); r.className = "ped-reg"; r.textContent = n.reg; card.append(r); } // Mark dogs that fill more than one position (pedigree collapse). A ×N badge // shows how many times, a stable colour ties the copies together, and tapping // the card lights up every place this dog appears. const key = dogKey(n); if (key && pedRepeat[key] > 1) { card.classList.add("ped-repeat"); card.dataset.dogkey = key; card.style.setProperty("--repeat-hue", hueFor(key)); const badge = document.createElement("span"); badge.className = "ped-repeat-badge"; badge.textContent = "×" + pedRepeat[key]; badge.title = "Appears " + pedRepeat[key] + " times in this pedigree — tap to highlight them all"; card.append(badge); card.addEventListener("click", (e) => { if (e.target.closest(".ped-toggle")) return; // let the expander do its job togglePedHighlight(key); }); } li.append(card); if (hasSire || hasDam) { const kids = document.createElement("ul"); const s = buildPedNode(nodes, pos * 2, depth + 1); const d = buildPedNode(nodes, pos * 2 + 1, depth + 1); if (s) { s.classList.add("ped-sire"); kids.append(s); } if (d) { d.classList.add("ped-dam"); kids.append(d); } const collapsed = depth >= PED_OPEN_DEPTH; if (collapsed) li.classList.add("collapsed"); const toggle = document.createElement("button"); toggle.type = "button"; toggle.className = "ped-toggle"; toggle.setAttribute("aria-label", "Toggle ancestors"); toggle.textContent = collapsed ? "+" : "−"; toggle.addEventListener("click", () => { const nowCollapsed = li.classList.toggle("collapsed"); toggle.textContent = nowCollapsed ? "+" : "−"; }); card.prepend(toggle); li.append(kids); } return li; } // ---------- changelog dialog ---------- // Shows the *loaded* build's full changelog: the plain URL is served // cache-first by the controlling service worker, so the list always matches // the version actually running (the update banner handles what's newer). const changelogDialog = document.getElementById("changelog-dialog"); const changelogList = document.getElementById("changelog-list"); const changelogEmpty = document.getElementById("changelog-empty"); function formatChangelogDate(iso) { const [y, m, d] = (iso || "").split("-").map(Number); if (!y || !m || !d) return iso || ""; return new Date(y, m - 1, d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric", }); } document.getElementById("changelog-btn").addEventListener("click", async () => { let entries = []; try { const res = await fetch("changelog.json"); if (res.ok) { const body = await res.json(); if (Array.isArray(body)) entries = body.filter(e => e && e.text); } } catch { /* fall through to empty state */ } changelogList.innerHTML = ""; changelogEmpty.hidden = entries.length > 0; let lastDate = null; for (const e of entries) { if (e.date !== lastDate) { lastDate = e.date; const dt = document.createElement("li"); dt.className = "changelog-date"; dt.textContent = formatChangelogDate(e.date); changelogList.appendChild(dt); } const li = document.createElement("li"); li.className = "changelog-entry"; li.textContent = e.text; changelogList.appendChild(li); } changelogDialog.showModal(); }); // ---------- exercise dialog (add / edit a training exercise) ---------- const exerciseDialog = document.getElementById("exercise-dialog"); const exerciseForm = document.getElementById("exercise-form"); const exerciseTitle = document.getElementById("exercise-title"); const exerciseName = document.getElementById("exercise-name"); const exerciseNote = document.getElementById("exercise-note"); const exerciseDelete = document.getElementById("exercise-delete"); let editingExerciseId = null; function openExerciseDialog(ex) { editingExerciseId = ex ? ex.id : null; exerciseTitle.textContent = ex ? "Edit exercise" : "Add exercise"; exerciseName.value = ex ? ex.name : ""; exerciseNote.value = ex ? (ex.note || "") : ""; exerciseDelete.hidden = !ex; exerciseDialog.showModal(); setTimeout(() => exerciseName.focus(), 50); } document.getElementById("exercise-add").addEventListener("click", () => openExerciseDialog(null)); exerciseForm.querySelector('button[value="save"]').addEventListener("click", (e) => { e.preventDefault(); const name = exerciseName.value.trim(); if (!name) { alert("Give the exercise a name."); return; } const note = exerciseNote.value.trim(); if (editingExerciseId) updateExercise(editingExerciseId, { name, note }); else addExercise(name, note); editingExerciseId = null; exerciseDialog.close(); }); exerciseForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => { e.preventDefault(); editingExerciseId = null; exerciseDialog.close(); }); exerciseDelete.addEventListener("click", (e) => { e.preventDefault(); if (editingExerciseId && confirm("Delete this exercise? Logged sessions stay in history.")) { deleteExercise(editingExerciseId); } editingExerciseId = null; exerciseDialog.close(); }); // ---------- delete account ---------- const deleteAccountDialog = document.getElementById("delete-account-dialog"); const deleteAccountPassword = document.getElementById("delete-account-password"); const deleteAccountError = document.getElementById("delete-account-error"); const deleteAccountConfirm = document.getElementById("delete-account-confirm"); document.getElementById("delete-account-btn").addEventListener("click", () => { settingsDialog.close(); deleteAccountPassword.value = ""; deleteAccountError.hidden = true; deleteAccountDialog.showModal(); setTimeout(() => deleteAccountPassword.focus(), 50); }); deleteAccountConfirm.addEventListener("click", async () => { deleteAccountError.hidden = true; deleteAccountConfirm.disabled = true; try { const res = await fetch("api/me", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: deleteAccountPassword.value }), }); if (!res.ok) { const msg = (await res.text()).trim(); throw new Error(res.status === 401 ? "Incorrect password" : (msg || `HTTP ${res.status}`)); } // Account is gone server-side. Wipe this user's local cache before the // reload drops us back on the login screen. try { localStorage.removeItem(eventsKey()); localStorage.removeItem(configKey()); localStorage.removeItem(exercisesKey()); } catch { /* ignore */ } clearUser(); deleteAccountDialog.close(); location.reload(); } catch (err) { deleteAccountError.textContent = err.message || "Something went wrong"; deleteAccountError.hidden = false; } finally { deleteAccountConfirm.disabled = false; } }); // ---------- quick-log snackbar ---------- // A single tap on a quick-action logs the event at the current instant, then // shows a brief snackbar to undo it or add detail — so the two-tap dialog flow // is never required for a plain pee/poo/meal/sleep boundary. Weigh-ins still // open the dialog since they need a value. const snackbar = document.getElementById("snackbar"); const snackbarMsg = document.getElementById("snackbar-msg"); const snackbarUndo = document.getElementById("snackbar-undo"); const snackbarNote = document.getElementById("snackbar-note"); let snackbarEvent = null; let snackbarTimer = null; function hideSnackbar() { clearTimeout(snackbarTimer); snackbarTimer = null; snackbarEvent = null; snackbar.classList.remove("show"); snackbar.hidden = true; } function showSnackbar(msg, ev) { snackbarEvent = ev; snackbarMsg.textContent = msg; snackbar.hidden = false; void snackbar.offsetWidth; // reflow so the fade-in transition runs snackbar.classList.add("show"); clearTimeout(snackbarTimer); snackbarTimer = setTimeout(hideSnackbar, 5000); } function quickLog(type, originEl) { const ev = addEvent(type, "", Date.now()); showSnackbar(`${EVENT_LABELS[type]} logged`, ev); if (type === "pee" || type === "poo") pottyConfetti(type, originEl); } // A little burst of 💧/💩 from the tapped button when a pee/poo is logged. // Pure DOM + CSS; particles remove themselves when their animation ends. // Skipped when disabled in Settings or the user prefers reduced motion. // Device-local preference (like the theme), on by default. const CONFETTI_KEY = "puppy-tracker:confetti:v1"; function confettiEnabled() { try { return localStorage.getItem(CONFETTI_KEY) !== "off"; } catch { return true; } } function setConfettiEnabled(on) { try { localStorage.setItem(CONFETTI_KEY, on ? "on" : "off"); } catch { /* ignore */ } } let confettiLayerEl = null; function confettiLayer() { if (!confettiLayerEl) { confettiLayerEl = document.createElement("div"); confettiLayerEl.id = "confetti-layer"; document.body.appendChild(confettiLayerEl); } return confettiLayerEl; } function pottyConfetti(type, originEl) { if (!confettiEnabled()) return; if (window.matchMedia && matchMedia("(prefers-reduced-motion: reduce)").matches) return; const emoji = type === "poo" ? "💩" : "💧"; const layer = confettiLayer(); const r = originEl && originEl.getBoundingClientRect ? originEl.getBoundingClientRect() : { left: innerWidth / 2, top: innerHeight / 2, width: 0, height: 0 }; const ox = r.left + r.width / 2, oy = r.top + r.height / 2; for (let i = 0; i < 16; i++) { const piece = document.createElement("span"); piece.className = "confetti-piece"; piece.textContent = emoji; const ang = Math.random() * Math.PI * 2; const dist = 60 + Math.random() * 130; piece.style.left = `${ox}px`; piece.style.top = `${oy}px`; piece.style.setProperty("--dx", `${Math.round(Math.cos(ang) * dist)}px`); piece.style.setProperty("--dy", `${Math.round(Math.sin(ang) * dist - 50)}px`); // bias upward piece.style.setProperty("--rot", `${Math.round(Math.random() * 720 - 360)}deg`); piece.style.fontSize = `${Math.round(14 + Math.random() * 16)}px`; piece.style.animationDuration = `${Math.round(900 + Math.random() * 600)}ms`; piece.addEventListener("animationend", () => piece.remove()); layer.appendChild(piece); } } snackbarUndo.addEventListener("click", () => { if (snackbarEvent) deleteEvent(snackbarEvent.id); hideSnackbar(); }); snackbarNote.addEventListener("click", () => { const ev = snackbarEvent; hideSnackbar(); if (ev) openEditDialog(ev); }); // ---------- collapsible panels ---------- // Each main section carrying a data-panel key can be folded by clicking its // heading; collapsed keys are remembered across reloads. State is device-global // (like the theme), so a single non-namespaced key is fine — it's not per-user // data. Only collapsed panels are stored, so newly added panels default open. const PANELS_KEY = "puppy-tracker:panels:v1"; function loadPanelState() { try { const s = JSON.parse(localStorage.getItem(PANELS_KEY)); return s && typeof s === "object" ? s : {}; } catch { return {}; } } function savePanelState(state) { try { localStorage.setItem(PANELS_KEY, JSON.stringify(state)); } catch { /* ignore */ } } function initPanels() { const state = loadPanelState(); document.querySelectorAll("main section[data-panel]").forEach(section => { const key = section.dataset.panel; const h2 = section.querySelector(":scope > h2"); if (!h2) return; section.classList.add("collapsible"); const collapsed = !!state[key]; section.classList.toggle("collapsed", collapsed); h2.setAttribute("role", "button"); h2.setAttribute("tabindex", "0"); h2.setAttribute("aria-expanded", String(!collapsed)); const toggle = () => { const nowCollapsed = section.classList.toggle("collapsed"); h2.setAttribute("aria-expanded", String(!nowCollapsed)); const s = loadPanelState(); if (nowCollapsed) s[key] = true; else delete s[key]; savePanelState(s); }; h2.addEventListener("click", toggle); h2.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); } }); }); } initPanels(); // ---------- wiring ---------- document.querySelectorAll("button.action").forEach(btn => { btn.addEventListener("click", () => { const type = btn.dataset.type; // Weigh-ins need a typed value and meals ask for grams, so those two // keep the full dialog. if (type === "weight" || type === "eat") { openNoteDialog(type); return; } quickLog(type, btn); }); }); document.querySelectorAll(".chart-days-picker button").forEach(b => { b.addEventListener("click", () => setChartDays(Number(b.dataset.days))); }); // Swap the timer pill in/out of the frozen bar as the big card scrolls // past. rAF-throttled: scroll events fire far more often than we can paint. { let queued = false; const onScroll = () => { if (queued) return; queued = true; requestAnimationFrame(() => { queued = false; updateBarClockMode(); }); }; window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("resize", onScroll, { passive: true }); } dayPicker.value = ymd(new Date()); dayPicker.addEventListener("change", render); // The face opens the hidden input's native picker (iOS 16+ has showPicker; // focus() is the fallback and is what pops the picker on older iOS anyway). document.getElementById("day-date-face").addEventListener("click", () => { try { if (typeof dayPicker.showPicker === "function") dayPicker.showPicker(); else dayPicker.focus(); } catch { dayPicker.focus(); } }); function shiftSelectedDay(days) { const d = selectedDay(); d.setDate(d.getDate() + days); dayPicker.value = ymd(d); render(); } document.getElementById("day-prev").addEventListener("click", () => shiftSelectedDay(-1)); document.getElementById("day-next").addEventListener("click", () => shiftSelectedDay(+1)); document.getElementById("day-today").addEventListener("click", () => { dayPicker.value = ymd(new Date()); render(); }); // Clicking the status pill forces an immediate sync. statusEl.style.cursor = "pointer"; statusEl.title = "Click to sync now"; statusEl.addEventListener("click", () => { clearTimeout(syncTimer); sync(); }); window.addEventListener("online", () => { setStatus(); sync(); syncConfig(); }); window.addEventListener("offline", () => setStatus()); // Service worker (independent of auth). The worker no longer auto-activates a // new build; instead we detect the waiting worker and let the user choose when // to swap onto fresh assets, so a long-open tab isn't left running stale JS. if ("serviceWorker" in navigator) { const updateBanner = document.getElementById("update-banner"); const updateReload = document.getElementById("update-reload"); const updateLater = document.getElementById("update-later"); let waitingWorker = null; // Whether there was already a controlling worker when the page loaded. On a // brand-new install there isn't, and clients.claim() fires an initial // controllerchange we must NOT reload on (there's nothing to refresh to). const hadController = !!navigator.serviceWorker.controller; let reloadRequested = false; // user pressed Reload → controllerchange reloads let refreshing = false; // guard against a reload loop navigator.serviceWorker.addEventListener("controllerchange", () => { if (refreshing) return; if (!hadController && !reloadRequested) return; // first-install claim refreshing = true; location.reload(); }); // What the waiting build changes compared to the running one. The plain // URL is answered by the *old* controlling worker cache-first, i.e. the // loaded build's changelog; the cache-busting query misses every SW cache // and hits the network, i.e. the new build's changelog. Entries in the // fresh copy that the cached one lacks are exactly "new since this build". async function changelogDiff() { const load = async (url) => { try { const res = await fetch(url); if (!res.ok) return null; const body = await res.json(); return Array.isArray(body) ? body : null; } catch { return null; } }; const current = await load("changelog.json"); const fresh = await load(`changelog.json?v=${Date.now()}`); if (!fresh) return []; const seen = new Set((current || []).map(e => e && e.text)); return fresh.filter(e => e && e.text && !seen.has(e.text)); } function showUpdateBanner(worker) { waitingWorker = worker; updateBanner.hidden = false; const list = document.getElementById("update-changelog"); list.hidden = true; list.innerHTML = ""; changelogDiff().then(entries => { if (entries.length === 0) return; for (const e of entries.slice(0, 6)) { const li = document.createElement("li"); li.textContent = e.text; list.appendChild(li); } list.hidden = false; }); } updateReload.addEventListener("click", () => { reloadRequested = true; updateBanner.hidden = true; // Tell the waiting worker to activate; controllerchange then reloads us. if (waitingWorker) waitingWorker.postMessage({ type: "SKIP_WAITING" }); }); // "Later" just dismisses; the next update (or reload) surfaces it again. updateLater.addEventListener("click", () => { updateBanner.hidden = true; }); // Only prompt when a *previous* worker was already in control — that check // is what suppresses the banner on the very first install. function trackInstalling(worker) { worker.addEventListener("statechange", () => { if (worker.state === "installed" && navigator.serviceWorker.controller) { showUpdateBanner(worker); } }); } function watchForUpdate(reg) { // A worker may already be waiting from a previous session's update. if (reg.waiting && navigator.serviceWorker.controller) showUpdateBanner(reg.waiting); reg.addEventListener("updatefound", () => { if (reg.installing) trackInstalling(reg.installing); }); // Browsers only auto-check for a new worker on navigation, so also poll // hourly and whenever the tab becomes visible again. setInterval(() => reg.update().catch(() => {}), 60 * 60 * 1000); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") reg.update().catch(() => {}); }); } window.addEventListener("load", () => { navigator.serviceWorker.register("sw.js") .then(watchForUpdate) .catch(err => console.error("SW", err)); }); } // ---------- auth gate ---------- // The tracker only boots once we know who the user is. startApp() does the // first paint, initial sync, and starts the periodic timers — guarded so it // runs at most once per page load even if login and the session check race. const authScreen = document.getElementById("auth-screen"); const appEl = document.getElementById("app"); const authForm = document.getElementById("auth-form"); const authEmail = document.getElementById("auth-email"); const authPassword = document.getElementById("auth-password"); const authInvite = document.getElementById("auth-invite"); const authInviteFld= document.getElementById("auth-invite-field"); const authError = document.getElementById("auth-error"); const authSubmit = document.getElementById("auth-submit"); const authSub = document.getElementById("auth-sub"); const authToggleBtn= document.getElementById("auth-toggle-btn"); const authToggleTxt= document.getElementById("auth-toggle-text"); let authMode = "login"; // or "register" let appStarted = false; // Remember who was last signed in so an offline reload can still open the // app against the cached data instead of stranding the user on a login screen // it can't verify. Cleared only on an explicit logout or a server 401. const SESSION_KEY = "puppy-tracker:session:v1"; function setUser(u) { currentUser = u; try { localStorage.setItem(SESSION_KEY, JSON.stringify(u)); } catch { /* ignore */ } } function clearUser() { currentUser = null; try { localStorage.removeItem(SESSION_KEY); } catch { /* ignore */ } } function cachedUser() { try { const u = JSON.parse(localStorage.getItem(SESSION_KEY)); return u && u.id ? u : null; } catch { return null; } } function startApp() { if (appStarted) return; appStarted = true; // Live-update relative times and (eventually) sync status text. setInterval(() => { const evs = live(); renderHeader(); renderBigClock(evs); renderActionHints(evs); renderStats(evs); renderLasts(evs); renderTiming(evs); renderSleepWindows(evs); renderWakeWindows(evs); renderWeekly(evs); renderSleepTimeline(evs); renderSleepTrend(evs); renderHourHeatmap(evs); renderTraining(evs); if (navigator.onLine && !syncing) setStatus(); }, 60_000); setInterval(tickBigClock, 1000); setInterval(sync, SYNC_POLL_MS); setInterval(syncConfig, SYNC_POLL_MS); setStatus(); render(); refreshPedigreeButton(); sync(); syncConfig(); } function showAuth() { appEl.hidden = true; authScreen.hidden = false; } function showApp() { authScreen.hidden = true; appEl.hidden = false; } // Called when the server reports we're no longer authenticated (expired or // revoked session). Drop back to the login screen without wiping the local // cache — logging back in as the same user picks it straight back up. function handleLoggedOut() { clearUser(); setStatus("offline"); showAuth(); } function renderAuthMode() { const reg = authMode === "register"; authInviteFld.hidden = !reg; authInvite.required = reg; authSubmit.textContent = reg ? "Create account" : "Sign in"; authSub.textContent = reg ? "Create your account" : "Sign in to continue"; authToggleTxt.textContent = reg ? "Already have an account?" : "No account yet?"; authToggleBtn.textContent = reg ? "Sign in" : "Create one"; authPassword.autocomplete = reg ? "new-password" : "current-password"; authError.hidden = true; } authToggleBtn.addEventListener("click", () => { authMode = authMode === "login" ? "register" : "login"; renderAuthMode(); }); authForm.addEventListener("submit", async (e) => { e.preventDefault(); authError.hidden = true; authSubmit.disabled = true; const body = { email: authEmail.value.trim(), password: authPassword.value }; if (authMode === "register") body.invite = authInvite.value.trim(); try { const res = await fetch(authMode === "register" ? "api/register" : "api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); if (!res.ok) { const msg = (await res.text()).trim(); throw new Error(msg || `HTTP ${res.status}`); } setUser(await res.json()); authForm.reset(); showApp(); startApp(); } catch (err) { authError.textContent = err.message || "Something went wrong"; authError.hidden = false; } finally { authSubmit.disabled = false; } }); document.getElementById("logout-btn").addEventListener("click", async () => { try { await fetch("api/logout", { method: "POST" }); } catch { /* ignore */ } clearUser(); // Full reload is the simplest way to clear in-memory app state and timers. location.reload(); }); // On load, ask the server who we are. A valid session boots straight into the // app. A 401 means log in. A network failure (offline PWA) falls back to the // last cached session so offline data stays reachable — a later sync will // 401 and bounce to login if that session has actually gone stale. (async function bootstrap() { try { const res = await fetch("api/me"); if (res.ok) { setUser(await res.json()); showApp(); startApp(); return; } clearUser(); // explicit 401/403: session is gone } catch { const cached = cachedUser(); if (cached) { currentUser = cached; showApp(); startApp(); return; } } renderAuthMode(); showAuth(); })(); })();