Add push reminders for sleep, pee, poo and meals

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.
This commit is contained in:
Alexander Heldt
2026-08-20 17:19:18 +00:00
parent 93d6ea27a7
commit 51d015c231
17 changed files with 1918 additions and 11 deletions
+282
View File
@@ -2538,6 +2538,7 @@
settingsTheme.checked = effectiveTheme() === "dark";
settingsConfetti.checked = confettiEnabled();
settingsDialog.showModal();
refreshRemindersUI();
setTimeout(() => settingsName.focus(), 50);
}
@@ -2556,6 +2557,15 @@
renderHeader();
refreshPedigreeButton();
settingsDialog.close();
// Rules are saved with the rest of Settings; the notification toggle itself
// already acted when it was flipped, since permission needs a user gesture.
if (!remindersSection.hidden && !remindersRules.hidden) {
try {
await saveReminderRules(collectReminderRules());
} catch (err) {
console.warn("saving reminders failed:", err);
}
}
try {
await pushConfig(cfg);
} catch (err) {
@@ -2568,6 +2578,277 @@
settingsDialog.close();
});
// ---------- reminders ----------
// The server decides when a reminder is due and pushes it (server/reminders.go);
// this side only manages the browser's push subscription and the rule settings.
// Nothing here can fire a notification on its own — a closed PWA has no timers,
// which is the whole reason the evaluation lives on the host.
const REMINDER_LABELS = {
sleep: "Time to sleep, awake for",
pee: "Time for pee, none for",
poo: "Time for poo, none for",
eat: "Time for a meal, none for",
};
const remindersSection = document.getElementById("reminders-section");
const remindersToggle = document.getElementById("reminders-enabled");
const remindersHint = document.getElementById("reminders-hint");
const remindersRules = document.getElementById("reminders-rules");
const remindersTest = document.getElementById("reminders-test");
let pushKey = null; // VAPID public key, once the server has given us one
let reminderRules = []; // last-known rule set, re-rendered into the dialog
// iOS only exposes push to a PWA that was added to the Home Screen; in a plain
// Safari tab PushManager doesn't exist at all, so the toggle would be dead.
const isStandalone = () =>
window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
const isIOS = () =>
/iP(hone|ad|od)/.test(navigator.userAgent) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
const pushSupported = () =>
"serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
// applicationServerKey wants raw bytes, not the base64url the server sends.
function b64UrlToBytes(s) {
const pad = "=".repeat((4 - (s.length % 4)) % 4);
const bin = atob((s + pad).replace(/-/g, "+").replace(/_/g, "/"));
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function bytesToB64Url(bytes) {
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
// subscribeForPush returns a live subscription, registering it with the host.
// It runs on every launch, not just when the toggle is flipped: iOS silently
// drops subscriptions, and a stale endpoint fails invisibly until re-posted.
async function subscribeForPush() {
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
// A subscription made under a different VAPID key can't be reused — the
// browser rejects re-subscribing with a new key — so drop it first. Only
// when we can positively see a mismatch, though: if a browser doesn't
// expose options, re-subscribing blindly would mint a fresh endpoint on
// every launch and strand the old row on the server.
if (sub) {
const existing = sub.options && sub.options.applicationServerKey;
if (existing && bytesToB64Url(new Uint8Array(existing)) !== pushKey) {
try { await sub.unsubscribe(); } catch { /* ignore */ }
sub = null;
}
}
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: b64UrlToBytes(pushKey),
});
}
const res = await fetch("api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(sub),
});
if (!res.ok) throw new Error(`subscribe failed: ${res.status}`);
return sub;
}
async function unsubscribeFromPush() {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (!sub) return;
// Tell the host first: if the local unsubscribe succeeds but the POST never
// lands, the server would keep pushing to an endpoint nobody listens on.
try {
await fetch("api/push/unsubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: sub.endpoint }),
});
} catch { /* the endpoint dies on its own once the browser drops it */ }
try { await sub.unsubscribe(); } catch { /* ignore */ }
}
async function loadReminderRules() {
const res = await fetch("api/reminders");
if (!res.ok) throw new Error(`reminders: ${res.status}`);
const body = await res.json();
reminderRules = body.reminders || [];
return reminderRules;
}
async function saveReminderRules(rules) {
const res = await fetch("api/reminders", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reminders: rules }),
});
if (!res.ok) throw new Error(`reminders: ${res.status}`);
// The server clamps intervals, so adopt what it actually stored.
reminderRules = (await res.json()).reminders || rules;
}
function renderReminderRules() {
remindersRules.replaceChildren();
for (const rule of reminderRules) {
const row = document.createElement("div");
row.className = "reminder-row";
row.dataset.kind = rule.kind;
const name = document.createElement("label");
name.className = "reminder-name";
const on = document.createElement("input");
on.type = "checkbox";
on.className = "switch reminder-on";
on.setAttribute("role", "switch");
on.checked = rule.enabled;
const text = document.createElement("span");
text.textContent = REMINDER_LABELS[rule.kind] || rule.kind;
name.append(on, text);
const after = document.createElement("span");
after.className = "reminder-after";
const mins = document.createElement("input");
mins.type = "number";
mins.className = "reminder-mins";
mins.min = "5";
mins.max = "1440";
mins.step = "5";
mins.value = String(rule.intervalMin);
const unit = document.createElement("span");
unit.textContent = "min";
after.append(mins, unit);
row.append(name, after);
remindersRules.append(row);
}
}
// collectReminderRules reads the dialog back into the rule shape the API takes.
function collectReminderRules() {
return [...remindersRules.querySelectorAll(".reminder-row")].map((row) => ({
kind: row.dataset.kind,
enabled: row.querySelector(".reminder-on").checked,
intervalMin: Number(row.querySelector(".reminder-mins").value) || 60,
}));
}
function setReminderHint(text) {
remindersHint.textContent = text || "";
remindersHint.hidden = !text;
}
// Reflects permission + subscription state: the rules only matter once there
// is somewhere to deliver them.
function showReminderControls(subscribed) {
remindersToggle.checked = subscribed;
remindersRules.hidden = !subscribed;
remindersTest.hidden = !subscribed;
}
// initReminders runs once at startup. It establishes whether reminders are
// available at all (the server may have no VAPID key, the browser may have no
// push support) and refreshes an existing subscription.
async function initReminders() {
if (!pushSupported()) {
// On iOS this is the Home Screen requirement rather than a missing feature,
// and it's worth saying so — the toggle is otherwise just absent.
if (isIOS() && !isStandalone()) {
remindersSection.hidden = false;
remindersToggle.disabled = true;
setReminderHint("Add Puppy Tracker to your Home Screen to enable reminders.");
}
return;
}
try {
const res = await fetch("api/push/key");
if (!res.ok) return; // host has push disabled; leave the section hidden
pushKey = (await res.json()).key;
} catch {
return; // offline at boot: try again next launch
}
if (!pushKey) return;
remindersSection.hidden = false;
if (Notification.permission !== "granted") {
showReminderControls(false);
return;
}
try {
await subscribeForPush();
showReminderControls(true);
} catch (err) {
console.warn("push re-subscribe failed:", err);
showReminderControls(false);
}
}
// The toggle acts immediately rather than on Save: requesting notification
// permission has to happen inside a user gesture, and on iOS a deferred
// request is simply ignored.
remindersToggle.addEventListener("change", async () => {
if (!remindersToggle.checked) {
setReminderHint("");
showReminderControls(false);
await unsubscribeFromPush();
return;
}
remindersToggle.disabled = true;
try {
const permission = await Notification.requestPermission();
if (permission !== "granted") {
showReminderControls(false);
setReminderHint(
permission === "denied"
? "Notifications are blocked for this app in your browser settings."
: "Notifications need permission to work."
);
return;
}
await subscribeForPush();
await loadReminderRules();
renderReminderRules();
showReminderControls(true);
setReminderHint("");
} catch (err) {
console.warn("enabling reminders failed:", err);
showReminderControls(false);
setReminderHint("Couldn't enable reminders. Try again once you're online.");
} finally {
remindersToggle.disabled = false;
}
});
remindersTest.addEventListener("click", async () => {
remindersTest.disabled = true;
try {
const res = await fetch("api/push/test", { method: "POST" });
setReminderHint(res.ok ? "Test sent." : "Couldn't send a test notification.");
} catch {
setReminderHint("Couldn't send a test notification.");
} finally {
remindersTest.disabled = false;
}
});
// Called when the settings dialog opens, so the rows show what the host has.
async function refreshRemindersUI() {
if (remindersSection.hidden || !pushKey) return;
try {
await loadReminderRules();
renderReminderRules();
} catch (err) {
console.warn("loading reminders failed:", err);
}
}
// ---------- pedigree lookup ----------
// A separate full-screen view that resolves a dog against SKK Hunddata by
// chip / registration number / name and renders its ancestry as a tree. The
@@ -3626,6 +3907,7 @@
refreshPedigreeButton();
sync();
syncConfig();
initReminders();
}
function showAuth() {