Compare commits
3 Commits
4df5b0ec08
...
01ad078b2a
| Author | SHA1 | Date | |
|---|---|---|---|
| 01ad078b2a | |||
| 8f4034ef47 | |||
| dbcac0653e |
+89
-16
@@ -394,23 +394,20 @@
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCurrentlyAsleep(events) {
|
// Current state derived from the *latest* sleep event. The single source of
|
||||||
const sorted = [...events].sort((a, b) => a.at - b.at);
|
// truth for both the big clock and the "Currently" row. For two boundary
|
||||||
let asleep = false;
|
// events sharing the same `at` (common once "now" events are minute-floored),
|
||||||
for (const e of sorted) {
|
// the one logged later (higher updatedAt) wins, so the tie resolves the same
|
||||||
if (e.type === "sleep-start") asleep = true;
|
// way everywhere it's read.
|
||||||
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) {
|
function currentSleepState(events) {
|
||||||
let latest = null;
|
let latest = null;
|
||||||
for (const e of events) {
|
for (const e of events) {
|
||||||
if (e.type !== "sleep-start" && e.type !== "sleep-end") continue;
|
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 };
|
if (!latest) return { state: null, since: 0 };
|
||||||
return {
|
return {
|
||||||
@@ -603,7 +600,7 @@
|
|||||||
|
|
||||||
const row = document.getElementById("currently-row");
|
const row = document.getElementById("currently-row");
|
||||||
const currently = document.getElementById("currently");
|
const currently = document.getElementById("currently");
|
||||||
if (isCurrentlyAsleep(events)) {
|
if (currentSleepState(events).state === "asleep") {
|
||||||
row.hidden = false;
|
row.hidden = false;
|
||||||
currently.textContent = "😴 Asleep";
|
currently.textContent = "😴 Asleep";
|
||||||
} else {
|
} else {
|
||||||
@@ -1277,6 +1274,10 @@
|
|||||||
let pendingType = null;
|
let pendingType = null;
|
||||||
let notePhotoBlob = null; // pending blob for the dialog (not yet committed)
|
let notePhotoBlob = null; // pending blob for the dialog (not yet committed)
|
||||||
let notePhotoURL = null; // current preview object URL
|
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() {
|
function clearNotePhoto() {
|
||||||
notePhotoBlob = null;
|
notePhotoBlob = null;
|
||||||
@@ -1291,6 +1292,7 @@
|
|||||||
function openNoteDialog(type) {
|
function openNoteDialog(type) {
|
||||||
pendingType = type;
|
pendingType = type;
|
||||||
noteInput.value = "";
|
noteInput.value = "";
|
||||||
|
noteTimeEdited = false;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
noteDate.value = toDateInput(now);
|
noteDate.value = toDateInput(now);
|
||||||
noteTime.value = toTimeInput(now);
|
noteTime.value = toTimeInput(now);
|
||||||
@@ -1304,6 +1306,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function noteDialogAt() {
|
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);
|
const parsed = fromDateTimeInputs(noteDate.value, noteTime.value);
|
||||||
return Number.isFinite(parsed) ? parsed : Date.now();
|
return Number.isFinite(parsed) ? parsed : Date.now();
|
||||||
}
|
}
|
||||||
@@ -1312,6 +1317,13 @@
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
noteDate.value = toDateInput(now);
|
noteDate.value = toDateInput(now);
|
||||||
noteTime.value = toTimeInput(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());
|
notePhotoBtn.addEventListener("click", () => notePhotoInput.click());
|
||||||
@@ -1619,10 +1631,71 @@
|
|||||||
window.addEventListener("online", () => { setStatus(); sync(); syncConfig(); });
|
window.addEventListener("online", () => { setStatus(); sync(); syncConfig(); });
|
||||||
window.addEventListener("offline", () => setStatus());
|
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) {
|
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", () => {
|
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));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,17 @@
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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
|
<!-- Login / register gate. Shown until the session check succeeds; the app
|
||||||
(#app) stays hidden behind it so no puppy data paints while logged out. -->
|
(#app) stays hidden behind it so no puppy data paints while logged out. -->
|
||||||
<div id="auth-screen" class="auth-screen" hidden>
|
<div id="auth-screen" class="auth-screen" hidden>
|
||||||
|
|||||||
@@ -663,3 +663,36 @@ input.switch::after {
|
|||||||
}
|
}
|
||||||
input.switch:checked { background: var(--accent); }
|
input.switch:checked { background: var(--accent); }
|
||||||
input.switch:checked::after { transform: translateX(18px); }
|
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); }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = "puppy-tracker-v8";
|
const CACHE = "puppy-tracker-v9";
|
||||||
const PHOTO_CACHE = "puppy-tracker-photos-v1";
|
const PHOTO_CACHE = "puppy-tracker-photos-v1";
|
||||||
const ASSETS = [
|
const ASSETS = [
|
||||||
"./",
|
"./",
|
||||||
@@ -13,7 +13,13 @@ self.addEventListener("install", (event) => {
|
|||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE).then((cache) => cache.addAll(ASSETS))
|
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) => {
|
self.addEventListener("activate", (event) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user