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:
+282
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user