(() => {
"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();
})();