(() => { "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: "", updatedAt: 0 }; return { name: parsed.name || "", birthday: parsed.birthday || "", updatedAt: Number.isFinite(parsed.updatedAt) ? parsed.updatedAt : 0, }; } catch { return { name: "", birthday: "", updatedAt: 0 }; } } function saveConfig(cfg) { localStorage.setItem(configKey(), JSON.stringify(cfg)); } // 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; return { days, weeks, months }; } 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"}`; return `${wk} · ${mo} old`; } 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; 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 { state, since: ts } = currentSleepState(events); bigClockState = state; bigClockSince = ts; if (!state) { card.hidden = true; return; } card.hidden = false; card.classList.toggle("asleep", state === "asleep"); card.classList.toggle("awake", state === "awake"); label.textContent = state === "asleep" ? "Asleep for" : "Awake for"; time.textContent = formatCounter(Date.now() - ts); since.textContent = `since ${formatTime(ts)}`; } function tickBigClock() { if (!bigClockState) return; const time = document.getElementById("bc-time"); if (time) time.textContent = formatCounter(Date.now() - bigClockSince); } 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 { 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(); }); // ---------- weekly charts ---------- function weeklyData(events) { const today = startOfDay(new Date()); const now = Date.now(); const days = []; for (let i = 6; 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" }); } // 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). function niceAxisGrams(rawMax) { if (!(rawMax > 0)) return { yMax: 100, steps: 2 }; const rawStep = rawMax / 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; const yMax = Math.ceil(rawMax / step) * step; return { yMax, steps: Math.max(1, Math.round(yMax / step)) }; } // Sleep-specific axis: always 2-hour granularity, capped at 24h/day, // for a more readable picture of typical 10–18 h puppy sleep. function niceAxisSleepHours(rawMax) { if (!(rawMax > 0)) return { yMax: 2, steps: 2 }; const m = Math.min(24, Math.max(2, Math.ceil(rawMax / 2) * 2)); return { yMax: m, steps: m / 2 }; } 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(); const hist = document.querySelector(".history"); if (hist) hist.scrollIntoView({ behavior: "smooth", block: "start" }); }); }); } 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 = 6; 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 = Math.round(yMax * i / ySteps * 10) / 10; const vText = v % 1 === 0 ? v : v.toFixed(1); parts.push(``); parts.push(`${vText}h`); } days.forEach((d, i) => { const isToday = i === days.length - 1; 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`; parts.push( `` + `${escapeText(title)}` ); 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 = 6; const innerBarGap = 2; 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" }, ]; days.forEach((d, i) => { const isToday = i === days.length - 1; const groupX = ML + i * (groupW + groupGap); 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)}` ); }); 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 = 6; 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}`); } days.forEach((d, i) => { const isToday = i === days.length - 1; 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`; parts.push( `` + `${escapeText(title)}` ); 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 (last 14 days) ---------- const PATTERN_DAYS = 14; // 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 = PATTERN_DAYS; const W = 320, H = 228; const ML = 44, MR = 8, MT = 16, MB = 4; const innerW = W - ML - MR; const rowGap = 2; const rowH = (H - MT - MB - (N - 1) * rowGap) / N; 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`); } 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 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() - (PATTERN_DAYS - 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(""); } // ---------- 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 = formatAge(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 / last 14 days) rather than scoped to // the day picker — the point is keeping the habit up, not reviewing one day. const TRAINING_DAYS = 14; 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 = TRAINING_DAYS; 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 = []; 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(); const ev = addEvent("training", "", Date.now(), { exerciseId: ex.id }); showSnackbar(`${ex.name} logged`, 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; 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 = formatAge(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"; } function render() { const events = live(); renderHeader(); renderDayBar(); renderBigClock(events); renderStats(events); renderLasts(events); renderTiming(events); renderSleepWindows(events); renderWakeWindows(events); renderWeekly(events); renderSleepTimeline(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 || "", updatedAt: Number.isFinite(body.updatedAt) ? body.updatedAt : 0, }; if (server.updatedAt > local.updatedAt) { saveConfig(server); renderHeader(); } else if (local.updatedAt > server.updatedAt) { await pushConfig(local); } } 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 || "", 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(); } 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 settingsTheme = document.getElementById("settings-theme"); // 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; settingsTheme.checked = effectiveTheme() === "dark"; 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, updatedAt: Date.now(), }; saveConfig(cfg); // cache locally for instant + offline paint renderHeader(); 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(); }); // ---------- 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) { const ev = addEvent(type, "", Date.now()); showSnackbar(`${EVENT_LABELS[type]} logged`, ev); } 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); }); }); dayPicker.value = ymd(new Date()); dayPicker.addEventListener("change", render); 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); renderStats(evs); renderLasts(evs); renderTiming(evs); renderSleepWindows(evs); renderWakeWindows(evs); renderWeekly(evs); renderSleepTimeline(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(); 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(); })(); })();