Files
puppy-tracker/src/app.js
T
2026-06-21 17:51:33 +00:00

1196 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
"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 = `
<span class="dot"></span>
<span class="time">${formatTime(ev.at)}</span>
<span class="label">${EVENT_LABELS[ev.type] || ev.type}</span>
<span class="note"></span>
`;
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 1018 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 => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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(`<line class="grid" x1="${ML}" y1="${y}" x2="${W - MR}" y2="${y}"/>`);
parts.push(`<text x="${ML - 4}" y="${y + 3}" text-anchor="end">${vText}h</text>`);
}
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(
`<rect class="bar bar-sleep ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="3">` +
`<title>${escapeText(title)}</title></rect>`
);
parts.push(
`<text x="${x + barW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
`${escapeText(dayLabel(d.date, isToday))}</text>`
);
});
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(`<line class="grid" x1="${ML}" y1="${y}" x2="${W - MR}" y2="${y}"/>`);
parts.push(`<text x="${ML - 4}" y="${y + 3}" text-anchor="end">${v}</text>`);
}
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(
`<rect class="bar ${s.cls} ${isToday ? "" : "bar-faded"}" data-day="${d.ymd}" ` +
`x="${x}" y="${y}" width="${barW}" height="${Math.max(0, h)}" rx="2">` +
`<title>${escapeText(title)}</title></rect>`
);
});
parts.push(
`<text x="${groupX + groupW / 2}" y="${H - MB + 14}" text-anchor="middle">` +
`${escapeText(dayLabel(d.date, isToday))}</text>`
);
});
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 = `<img alt="" src="${notePhotoURL}">`;
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 = `<img alt="" src="${url}">`;
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();
})();