(() => { "use strict"; const STORAGE_KEY = "puppy-tracker:events: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", }; // ---------- 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(STORAGE_KEY); 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(STORAGE_KEY, JSON.stringify(events)); } function live() { return loadAll().filter(e => !e.deleted); } function addEvent(type, note, at, photoId) { const events = loadAll(); const now = Date.now(); events.push({ id: uuid(), type, at: Number.isFinite(at) ? at : now, note: note || "", photoId: photoId || "", 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`; } 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"); } 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} `; li.querySelector(".note").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); } 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 render() { const events = live(); renderDayBar(); renderBigClock(events); renderStats(events); renderLasts(events); renderSleepWindows(events); renderWakeWindows(events); renderWeekly(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 (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.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; } } // ---------- 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"); 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]}`; clearNotePhoto(); noteDialog.showModal(); setTimeout(() => 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 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); 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"); 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 || ""; 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 (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(); }); // ---------- 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(); }); window.addEventListener("offline", () => setStatus()); // Live-update relative times and (eventually) sync status text. setInterval(() => { const evs = live(); renderBigClock(evs); renderStats(evs); renderLasts(evs); renderSleepWindows(evs); renderWakeWindows(evs); renderWeekly(evs); if (navigator.onLine && !syncing) setStatus(); }, 60_000); // Big-clock counter updates once a second without re-deriving state. setInterval(tickBigClock, 1000); // Periodic pull from server so other clients' changes show up. setInterval(sync, SYNC_POLL_MS); // Service worker if ("serviceWorker" in navigator) { window.addEventListener("load", () => { navigator.serviceWorker.register("sw.js").catch(err => console.error("SW", err)); }); } // First paint + initial sync. setStatus(); render(); sync(); })();