Compare commits

...

3 Commits

Author SHA1 Message Date
Alexander Heldt 01ad078b2a Stamp "now" events to the second, not the minute
The time input has no seconds and fromDateTimeInputs floors to :00, so an
event logged at "now" was stored up to ~59s in the past — the big-clock
counter would jump to e.g. "0:40" the instant a sleep boundary was logged.

Track whether the user has actually touched the date/time fields (reset in
openNoteDialog and the "Now" button, set on manual change/input). When
untouched, noteDialogAt() returns the exact Date.now(); an explicitly
picked time is still parsed from the inputs (minute precision is fine there).
2026-07-10 12:36:30 +00:00
Alexander Heldt 8f4034ef47 Fix "Currently" row disagreeing with the big clock about sleep state
The "Currently" row derived its state from isCurrentlyAsleep() (ascending
stable sort, then fold) while the big clock used currentSleepState()
(max-by-`at` with a strict >). When two sleep boundary events shared the
same `at`, the two broke the tie differently, so the row could show
"Asleep" during a wake window while the clock said "Awake".

Make both read from the single currentSleepState() source, and give it a
deterministic tie-breaker: for equal `at`, the later updatedAt (most
recently logged boundary) wins. Remove the now-unused isCurrentlyAsleep().
2026-07-10 12:33:00 +00:00
Alexander Heldt dbcac0653e Prompt to reload when a new version is available
The service worker used to skipWaiting() on install and claim clients on
activate, so a new build's assets swapped in silently and a long-open tab
kept running stale JS. Switch to the standard update flow: the worker now
waits until the page sends it a SKIP_WAITING message, and the page shows a
"A new version is available — Reload / Later" banner when a new worker
reaches "installed" while one is already controlling the tab (that
controller check suppresses the first-install prompt).

Reload posts SKIP_WAITING and reloads on controllerchange (guarded against
a reload loop and against the initial clients.claim on a fresh install);
Later dismisses until the next update. Since browsers only auto-check on
navigation, also poll registration.update() hourly and on visibilitychange.
Bump the cache to v9 so the old cache is cleaned up on activate.
2026-07-10 12:30:30 +00:00
4 changed files with 141 additions and 18 deletions
+89 -16
View File
@@ -394,23 +394,20 @@
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.
// Current state derived from the *latest* sleep event. The single source of
// truth for both the big clock and the "Currently" row. For two boundary
// events sharing the same `at` (common once "now" events are minute-floored),
// the one logged later (higher updatedAt) wins, so the tie resolves the same
// way everywhere it's read.
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 ||
e.at > latest.at ||
(e.at === latest.at && (e.updatedAt || 0) > (latest.updatedAt || 0))) {
latest = e;
}
}
if (!latest) return { state: null, since: 0 };
return {
@@ -603,7 +600,7 @@
const row = document.getElementById("currently-row");
const currently = document.getElementById("currently");
if (isCurrentlyAsleep(events)) {
if (currentSleepState(events).state === "asleep") {
row.hidden = false;
currently.textContent = "😴 Asleep";
} else {
@@ -1277,6 +1274,10 @@
let pendingType = null;
let notePhotoBlob = null; // pending blob for the dialog (not yet committed)
let notePhotoURL = null; // current preview object URL
// Whether the user has manually touched the date/time fields. While false the
// dialog logs at the exact current millisecond rather than the minute-floored
// input, so a just-logged event doesn't look up to ~59s old.
let noteTimeEdited = false;
function clearNotePhoto() {
notePhotoBlob = null;
@@ -1291,6 +1292,7 @@
function openNoteDialog(type) {
pendingType = type;
noteInput.value = "";
noteTimeEdited = false;
const now = Date.now();
noteDate.value = toDateInput(now);
noteTime.value = toTimeInput(now);
@@ -1304,6 +1306,9 @@
}
function noteDialogAt() {
// Untouched time → stamp the exact current instant (sub-minute accurate).
// Once the user picks a time, honor the input (minute precision is fine).
if (!noteTimeEdited) return Date.now();
const parsed = fromDateTimeInputs(noteDate.value, noteTime.value);
return Number.isFinite(parsed) ? parsed : Date.now();
}
@@ -1312,6 +1317,13 @@
const now = Date.now();
noteDate.value = toDateInput(now);
noteTime.value = toTimeInput(now);
noteTimeEdited = false; // "Now" means log at the current instant again
});
[noteDate, noteTime].forEach(el => {
const markEdited = () => { noteTimeEdited = true; };
el.addEventListener("change", markEdited);
el.addEventListener("input", markEdited);
});
notePhotoBtn.addEventListener("click", () => notePhotoInput.click());
@@ -1619,10 +1631,71 @@
window.addEventListener("online", () => { setStatus(); sync(); syncConfig(); });
window.addEventListener("offline", () => setStatus());
// Service worker (independent of auth).
// Service worker (independent of auth). The worker no longer auto-activates a
// new build; instead we detect the waiting worker and let the user choose when
// to swap onto fresh assets, so a long-open tab isn't left running stale JS.
if ("serviceWorker" in navigator) {
const updateBanner = document.getElementById("update-banner");
const updateReload = document.getElementById("update-reload");
const updateLater = document.getElementById("update-later");
let waitingWorker = null;
// Whether there was already a controlling worker when the page loaded. On a
// brand-new install there isn't, and clients.claim() fires an initial
// controllerchange we must NOT reload on (there's nothing to refresh to).
const hadController = !!navigator.serviceWorker.controller;
let reloadRequested = false; // user pressed Reload → controllerchange reloads
let refreshing = false; // guard against a reload loop
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (refreshing) return;
if (!hadController && !reloadRequested) return; // first-install claim
refreshing = true;
location.reload();
});
function showUpdateBanner(worker) {
waitingWorker = worker;
updateBanner.hidden = false;
}
updateReload.addEventListener("click", () => {
reloadRequested = true;
updateBanner.hidden = true;
// Tell the waiting worker to activate; controllerchange then reloads us.
if (waitingWorker) waitingWorker.postMessage({ type: "SKIP_WAITING" });
});
// "Later" just dismisses; the next update (or reload) surfaces it again.
updateLater.addEventListener("click", () => { updateBanner.hidden = true; });
// Only prompt when a *previous* worker was already in control — that check
// is what suppresses the banner on the very first install.
function trackInstalling(worker) {
worker.addEventListener("statechange", () => {
if (worker.state === "installed" && navigator.serviceWorker.controller) {
showUpdateBanner(worker);
}
});
}
function watchForUpdate(reg) {
// A worker may already be waiting from a previous session's update.
if (reg.waiting && navigator.serviceWorker.controller) showUpdateBanner(reg.waiting);
reg.addEventListener("updatefound", () => {
if (reg.installing) trackInstalling(reg.installing);
});
// Browsers only auto-check for a new worker on navigation, so also poll
// hourly and whenever the tab becomes visible again.
setInterval(() => reg.update().catch(() => {}), 60 * 60 * 1000);
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") reg.update().catch(() => {});
});
}
window.addEventListener("load", () => {
navigator.serviceWorker.register("sw.js").catch(err => console.error("SW", err));
navigator.serviceWorker.register("sw.js")
.then(watchForUpdate)
.catch(err => console.error("SW", err));
});
}
+11
View File
@@ -21,6 +21,17 @@
</script>
</head>
<body>
<!-- Shown when a newer build's service worker is waiting. "Reload" activates
it and refreshes onto the new assets; "Later" dismisses until next time.
Suppressed on the very first install (see app.js). -->
<div id="update-banner" class="update-banner" hidden role="status" aria-live="polite">
<span class="update-banner-msg">A new version is available</span>
<span class="update-banner-actions">
<button type="button" id="update-reload" class="update-banner-btn">Reload</button>
<button type="button" id="update-later" class="update-banner-btn ghostish">Later</button>
</span>
</div>
<!-- Login / register gate. Shown until the session check succeeds; the app
(#app) stays hidden behind it so no puppy data paints while logged out. -->
<div id="auth-screen" class="auth-screen" hidden>
+33
View File
@@ -663,3 +663,36 @@ input.switch::after {
}
input.switch:checked { background: var(--accent); }
input.switch:checked::after { transform: translateX(18px); }
/* ---------- update banner ---------- */
.update-banner {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 70;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
padding: calc(10px + env(safe-area-inset-top, 0)) 16px 10px;
background: var(--accent);
color: #fff;
box-shadow: var(--shadow);
}
.update-banner[hidden] { display: none; }
.update-banner-msg { font-size: 0.9rem; font-weight: 600; }
.update-banner-actions { display: flex; gap: 8px; }
.update-banner-btn {
background: #fff;
color: var(--accent);
font-weight: 700;
padding: 6px 14px;
}
.update-banner-btn.ghostish {
background: transparent;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.6);
}
.update-banner-btn:hover { filter: brightness(0.97); }
+8 -2
View File
@@ -1,4 +1,4 @@
const CACHE = "puppy-tracker-v8";
const CACHE = "puppy-tracker-v9";
const PHOTO_CACHE = "puppy-tracker-photos-v1";
const ASSETS = [
"./",
@@ -13,7 +13,13 @@ self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(ASSETS))
);
self.skipWaiting();
// No skipWaiting() here: a new worker stays in "waiting" while an old one is
// controlling a tab, so the page can prompt before swapping assets out from
// under it. The page tells us to activate via a SKIP_WAITING message.
});
self.addEventListener("message", (event) => {
if (event.data && event.data.type === "SKIP_WAITING") self.skipWaiting();
});
self.addEventListener("activate", (event) => {