1196 lines
40 KiB
JavaScript
1196 lines
40 KiB
JavaScript
(() => {
|
||
"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 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(`<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();
|
||
})();
|