(() => { "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 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", }; // ---------- 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) { const events = loadAll(); const now = Date.now(); events.push({ id: uuid(), type, at: Number.isFinite(at) ? at : now, note: note || "", photoId: photoId || "", weight: Number.isFinite(weight) ? weight : undefined, updatedAt: now, }); saveAll(events); scheduleSync(); render(); } 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(); } // ---------- 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; } function isCurrentlyAsleep(events) { const sorted = [...events].sort((a, b) => a.at - b.at); let asleep = false; for (const e of sorted) { if (e.type === "sleep-start") asleep = true; else if (e.type === "sleep-end") asleep = false; } return asleep; } // Current state derived from the *latest* sleep event. Used by the live // counter at the top of the page. 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) 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"); document.getElementById("stat-pees").textContent = count("pee"); document.getElementById("stat-poos").textContent = count("poo"); } // 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)})` : "—"; const row = document.getElementById("currently-row"); const currently = document.getElementById("currently"); if (isCurrentlyAsleep(events)) { row.hidden = false; currently.textContent = "😴 Asleep"; } else { row.hidden = true; } } // 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(); eventList.innerHTML = ""; if (dayEvents.length === 0) { emptyState.hidden = false; return; } emptyState.hidden = true; for (const ev of dayEvents) { const li = document.createElement("li"); li.className = "event"; li.dataset.type = ev.type; li.dataset.id = ev.id; li.innerHTML = ` ${formatTime(ev.at)} ${EVENT_LABELS[ev.type] || ev.type} `; 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)); if (ev.photoId) { const img = document.createElement("img"); img.className = "thumb"; img.alt = "photo"; img.loading = "lazy"; img.dataset.photoId = ev.photoId; img.addEventListener("click", (e) => { e.stopPropagation(); // don't open the edit dialog openLightbox(ev.photoId); }); li.appendChild(img); photoSrc(ev.photoId).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, }); } 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). function niceAxis(rawMax) { if (!(rawMax > 0)) return { yMax: 1, steps: 1 }; if (rawMax <= 4) { const m = Math.ceil(rawMax); return { yMax: m, steps: m }; } if (rawMax <= 10) { const m = Math.ceil(rawMax / 2) * 2; return { yMax: m, steps: m / 2 }; } const m = Math.ceil(rawMax / 5) * 5; return { yMax: m, steps: 5 }; } // 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); } function renderWeekly(events) { const days = weeklyData(events); drawSleepChart(days); drawCountsChart(days); } // ---------- 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); } 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; } function render() { const events = live(); renderHeader(); renderDayBar(); renderBigClock(events); renderStats(events); renderLasts(events); renderTiming(events); renderSleepWindows(events); renderWakeWindows(events); renderWeekly(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 server response back into local storage. Anything local with a newer // updatedAt than the server's copy wins — that covers events the user added // during the in-flight sync request. function mergeServer(serverEvents) { const localById = new Map(loadAll().map(e => [e.id, e])); const merged = new Map(); for (const se of serverEvents) { 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); } } saveAll([...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(); if (Array.isArray(body.events)) { mergeServer(body.events); 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 notePhotoClear = document.getElementById("note-photo-clear"); const notePhotoPreview = document.getElementById("note-photo-preview"); const noteWeightField = document.getElementById("note-weight-field"); const noteWeight = document.getElementById("note-weight"); let pendingType = null; let notePhotoBlob = null; // pending blob for the dialog (not yet committed) let notePhotoURL = null; // current preview object URL function clearNotePhoto() { notePhotoBlob = null; if (notePhotoURL) { URL.revokeObjectURL(notePhotoURL); notePhotoURL = null; } notePhotoPreview.hidden = true; notePhotoPreview.innerHTML = ""; notePhotoClear.hidden = true; notePhotoBtn.textContent = "📷 Add photo"; notePhotoInput.value = ""; } function openNoteDialog(type) { pendingType = type; noteInput.value = ""; const now = Date.now(); noteDate.value = toDateInput(now); noteTime.value = toTimeInput(now); noteTitle.textContent = `Log ${EVENT_LABELS[type]}`; const isWeight = type === "weight"; noteWeightField.hidden = !isWeight; noteWeight.value = ""; clearNotePhoto(); noteDialog.showModal(); setTimeout(() => (isWeight ? noteWeight : noteInput).focus(), 50); } function noteDialogAt() { 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); }); notePhotoBtn.addEventListener("click", () => notePhotoInput.click()); notePhotoClear.addEventListener("click", () => clearNotePhoto()); notePhotoInput.addEventListener("change", async (e) => { const file = e.target.files?.[0]; if (!file) return; try { notePhotoBlob = await resizeImage(file); if (notePhotoURL) URL.revokeObjectURL(notePhotoURL); notePhotoURL = URL.createObjectURL(notePhotoBlob); notePhotoPreview.innerHTML = ``; notePhotoPreview.hidden = false; notePhotoClear.hidden = false; notePhotoBtn.textContent = "📷 Replace photo"; } catch (err) { alert("Couldn't process that photo: " + err.message); } }); 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 photoId = ""; if (notePhotoBlob) { photoId = uuid(); try { await putPhoto(photoId, notePhotoBlob, false); } catch (err) { alert("Couldn't store photo locally: " + err.message); return; } } addEvent(pendingType, noteInput.value.trim(), noteDialogAt(), photoId, weight); pendingType = null; clearNotePhoto(); noteDialog.close(); }); noteForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => { e.preventDefault(); pendingType = null; clearNotePhoto(); 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 editPhotoClear = document.getElementById("edit-photo-clear"); const editPhotoPreview = document.getElementById("edit-photo-preview"); const editWeightField = document.getElementById("edit-weight-field"); const editWeight = document.getElementById("edit-weight"); let editingId = null; let editPhotoId = ""; // current photoId for this event let editPhotoBlob = null; // new blob chosen in this session let editPhotoURL = null; let editPhotoCleared = false; // user removed an existing photo function setEditPreviewFromURL(url) { if (!url) { editPhotoPreview.hidden = true; editPhotoPreview.innerHTML = ""; return; } editPhotoPreview.innerHTML = ``; editPhotoPreview.hidden = false; } function clearEditPhotoLocalState() { editPhotoBlob = null; if (editPhotoURL) { URL.revokeObjectURL(editPhotoURL); editPhotoURL = null; } 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 : ""; editPhotoId = ev.photoId || ""; editPhotoCleared = false; clearEditPhotoLocalState(); if (editPhotoId) { const url = await photoSrc(editPhotoId); setEditPreviewFromURL(url); editPhotoBtn.textContent = "📷 Replace photo"; editPhotoClear.hidden = false; } else { setEditPreviewFromURL(null); editPhotoBtn.textContent = "📷 Add photo"; editPhotoClear.hidden = true; } editDialog.showModal(); } editPhotoBtn.addEventListener("click", () => editPhotoInput.click()); editPhotoClear.addEventListener("click", () => { editPhotoCleared = true; clearEditPhotoLocalState(); setEditPreviewFromURL(null); editPhotoBtn.textContent = "📷 Add photo"; editPhotoClear.hidden = true; }); editPhotoInput.addEventListener("change", async (e) => { const file = e.target.files?.[0]; if (!file) return; try { editPhotoBlob = await resizeImage(file); if (editPhotoURL) URL.revokeObjectURL(editPhotoURL); editPhotoURL = URL.createObjectURL(editPhotoBlob); setEditPreviewFromURL(editPhotoURL); editPhotoCleared = true; // a new photo supersedes any existing one editPhotoBtn.textContent = "📷 Replace photo"; editPhotoClear.hidden = false; } catch (err) { alert("Couldn't process that photo: " + err.message); } }); 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 (editPhotoBlob) { const newId = uuid(); try { await putPhoto(newId, editPhotoBlob, false); } catch (err) { alert("Couldn't store photo locally: " + err.message); return; } patch.photoId = newId; } else if (editPhotoCleared) { patch.photoId = ""; } updateEvent(editingId, patch); editingId = null; clearEditPhotoLocalState(); editDialog.close(); }); editForm.querySelector('button[value="cancel"]').addEventListener("click", (e) => { e.preventDefault(); editingId = null; clearEditPhotoLocalState(); editDialog.close(); }); editDelete.addEventListener("click", (e) => { e.preventDefault(); if (editingId && confirm("Delete this event?")) { deleteEvent(editingId); } editingId = null; clearEditPhotoLocalState(); 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(); }); // ---------- 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()); } catch { /* ignore */ } clearUser(); deleteAccountDialog.close(); location.reload(); } catch (err) { deleteAccountError.textContent = err.message || "Something went wrong"; deleteAccountError.hidden = false; } finally { deleteAccountConfirm.disabled = false; } }); // ---------- wiring ---------- document.querySelectorAll("button.action").forEach(btn => { btn.addEventListener("click", () => openNoteDialog(btn.dataset.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(); }); function showUpdateBanner(worker) { waitingWorker = worker; updateBanner.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); 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(); })(); })();