A closed PWA has no timers, so reminders are evaluated on the server: the event log is already there (clients sync on every mutation), and a ticker re-checks each enabled rule once a minute and pushes the ones that are due. Two rule shapes. "sleep" measures from the last sleep-end and fires only while the puppy is awake. "pee"/"poo"/"eat" measure from the newest event of that type and stay quiet while the puppy is asleep — otherwise they nag all night, and suppressing them means an overdue rule instead fires promptly on waking, which is when it actually matters. Sleep state is derived exactly the way currentSleepState() does in app.js, tie-break included, so both sides always agree. Rules read the event's own timestamp rather than when it synced, so a pee logged offline at 03:10 cancels the reminder retroactively. Every push carries a tag, so a repeat replaces the previous notification instead of stacking another one on the lock screen. last_fired is server-owned and not writable by a client, so a stale device can't force a re-fire. Web Push is implemented directly rather than pulled in as a dependency: RFC 8291 encryption in the RFC 8188 aes128gcm coding with an RFC 8292 VAPID token, stdlib only, checked against the RFC 8291 test vector. The key is generated into vapid.json beside the DB or supplied via -vapid-key; without one the server logs a warning, skips registering the routes, and the client hides the UI. Subscriptions a push service reports as 404/410 are dropped. PNG icons are added because iOS gates push on a Home Screen install and rejects SVG for apple-touch-icon, and Android has no notification icon without them.
141 lines
4.6 KiB
JavaScript
141 lines
4.6 KiB
JavaScript
// BUILD is substituted per-deploy by the server with a hash of the static
|
|
// assets (see serveSW in server/main.go), so the cache name — and therefore the
|
|
// bytes of this file — change whenever any asset changes. That byte difference
|
|
// is what makes the browser install a new worker and surface the update prompt.
|
|
// Served unsubstituted (dev / a plain static host) it stays a valid constant.
|
|
const BUILD = "__BUILD_HASH__";
|
|
const CACHE = `puppy-tracker-${BUILD}`;
|
|
const PHOTO_CACHE = "puppy-tracker-photos-v1";
|
|
const ASSETS = [
|
|
"./",
|
|
"./index.html",
|
|
"./style.css",
|
|
"./app.js",
|
|
"./manifest.json",
|
|
"./icon.svg",
|
|
"./icon-180.png",
|
|
"./icon-192.png",
|
|
"./icon-512.png",
|
|
"./changelog.json",
|
|
];
|
|
|
|
self.addEventListener("install", (event) => {
|
|
// cache: "reload" bypasses the browser's HTTP cache, so a new build always
|
|
// caches assets fetched fresh from the server. Without it, addAll could mix
|
|
// a fresh index.html with a heuristically-cached stale app.js and install a
|
|
// build whose markup references listeners the old script never registers.
|
|
event.waitUntil(
|
|
caches.open(CACHE).then((cache) =>
|
|
cache.addAll(ASSETS.map((u) => new Request(u, { cache: "reload" })))
|
|
)
|
|
);
|
|
// 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) => {
|
|
event.waitUntil(
|
|
caches.keys().then((keys) =>
|
|
Promise.all(
|
|
keys
|
|
.filter((k) => k !== CACHE && k !== PHOTO_CACHE)
|
|
.map((k) => caches.delete(k))
|
|
)
|
|
)
|
|
);
|
|
self.clients.claim();
|
|
});
|
|
|
|
self.addEventListener("fetch", (event) => {
|
|
const req = event.request;
|
|
if (req.method !== "GET") return;
|
|
|
|
const url = new URL(req.url);
|
|
|
|
// Photos are immutable per UUID — cache-first forever and stuff every
|
|
// successful fetch into a dedicated cache so they survive between visits
|
|
// and are available offline once seen.
|
|
if (url.pathname.includes("/api/photos/")) {
|
|
event.respondWith(
|
|
caches.open(PHOTO_CACHE).then(async (cache) => {
|
|
const cached = await cache.match(req);
|
|
if (cached) return cached;
|
|
try {
|
|
const res = await fetch(req);
|
|
if (res && res.ok) cache.put(req, res.clone());
|
|
return res;
|
|
} catch (err) {
|
|
return cached || Response.error();
|
|
}
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Other API calls: never cache — sync must reflect live server state.
|
|
if (url.pathname.includes("/api/")) return;
|
|
|
|
event.respondWith(
|
|
caches.match(req).then((cached) => {
|
|
if (cached) return cached;
|
|
return fetch(req)
|
|
.then((res) => {
|
|
if (res && res.ok && url.origin === self.location.origin) {
|
|
const clone = res.clone();
|
|
caches.open(CACHE).then((c) => c.put(req, clone));
|
|
}
|
|
return res;
|
|
})
|
|
.catch(() => caches.match("./index.html"));
|
|
})
|
|
);
|
|
});
|
|
|
|
// ---------- push reminders ----------
|
|
// The server evaluates reminder rules and pushes the ones that come due (see
|
|
// server/reminders.go). Subscriptions are userVisibleOnly, so every push must
|
|
// result in a notification — there is no silent path to fall back on.
|
|
self.addEventListener("push", (event) => {
|
|
let data = {};
|
|
try {
|
|
data = event.data ? event.data.json() : {};
|
|
} catch (err) {
|
|
data = {};
|
|
}
|
|
// The tag is what makes a repeat of the same reminder replace the previous
|
|
// notification instead of stacking another one on the lock screen, and
|
|
// renotify:false lets that replacement happen without re-alerting.
|
|
event.waitUntil(
|
|
self.registration.showNotification(data.title || "Puppy Tracker", {
|
|
body: data.body || "",
|
|
tag: data.tag || "reminder",
|
|
renotify: false,
|
|
icon: "./icon-192.png",
|
|
badge: "./icon-192.png",
|
|
data: { url: data.url || "./" },
|
|
})
|
|
);
|
|
});
|
|
|
|
self.addEventListener("notificationclick", (event) => {
|
|
event.notification.close();
|
|
const url = (event.notification.data && event.notification.data.url) || "./";
|
|
// Prefer focusing a tab the app is already open in — opening a second window
|
|
// onto the same PWA is disorienting and loses whatever was on screen.
|
|
event.waitUntil(
|
|
self.clients
|
|
.matchAll({ type: "window", includeUncontrolled: true })
|
|
.then((clients) => {
|
|
for (const client of clients) {
|
|
if ("focus" in client) return client.focus();
|
|
}
|
|
return self.clients.openWindow(url);
|
|
})
|
|
);
|
|
});
|