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() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[
|
||||
{ "date": "2026-08-20", "text": "Added reminders: turn them on in Settings and your phone gets a notification when it's time to sleep (\"Awake for 45 min\") or when there's been no pee, poo or meal for a while. Each one has its own interval, they arrive even with the app closed, and they stay quiet while the puppy is logged as asleep so you're not nagged all night. On iPhone, add Puppy Tracker to your Home Screen first — iOS only allows notifications for installed apps" },
|
||||
{ "date": "2026-08-18", "text": "The sleep button that would just repeat the last one is now disabled: while asleep you can only tap ⏰ Sleep end, and while awake only 😴 Sleep start — no more accidental double taps creating zero-length sleep windows. If you did miss a boundary, you can still add it at the right time from the event log" },
|
||||
{ "date": "2026-08-02", "text": "Added free-text notes: tap 📝 Note to jot down things that happened on a day — vaccinations, vet visits, milestones — with a date, optional photo, and any text. All your notes are collected in a new Notes section that stays visible whatever day you're viewing, so you can see at a glance when things like a tick vaccination were done" },
|
||||
{ "date": "2026-08-02", "text": "The Daily counts chart now has Pees / Poos / Meals checkboxes so you can focus on just the metrics you care about — untick the rest to see, say, only poos; your choice is remembered" },
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
+12
-1
@@ -7,7 +7,7 @@
|
||||
<title>Puppy Tracker</title>
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<link rel="icon" href="icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="icon.svg" />
|
||||
<link rel="apple-touch-icon" href="icon-180.png" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<script>
|
||||
// Apply a saved theme before first paint so there's no light/dark flash.
|
||||
@@ -346,6 +346,17 @@
|
||||
<span>Pee/poo confetti 💩</span>
|
||||
<input type="checkbox" id="settings-confetti" role="switch" class="switch" />
|
||||
</label>
|
||||
<hr class="settings-sep" />
|
||||
<div id="reminders-section" hidden>
|
||||
<h4 class="settings-subhead">Reminders</h4>
|
||||
<label class="toggle-row">
|
||||
<span>Push notifications</span>
|
||||
<input type="checkbox" id="reminders-enabled" role="switch" class="switch" />
|
||||
</label>
|
||||
<p class="settings-hint" id="reminders-hint" hidden></p>
|
||||
<div id="reminders-rules" hidden></div>
|
||||
<button type="button" id="reminders-test" class="ghost" hidden>Send a test notification</button>
|
||||
</div>
|
||||
<menu>
|
||||
<button value="cancel" class="ghost">Cancel</button>
|
||||
<button value="save" id="settings-save">Save</button>
|
||||
|
||||
@@ -13,6 +13,16 @@
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1406,3 +1406,62 @@ section.collapsed > :not(h2) { display: none; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.confetti-piece { display: none; }
|
||||
}
|
||||
|
||||
/* ---------- reminders ---------- */
|
||||
.settings-subhead {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text);
|
||||
}
|
||||
#reminders-rules {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.reminder-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
/* Overrides dialog's block labels: each rule is a switch and its name on one
|
||||
line, with the interval pushed to the far end. */
|
||||
.reminder-row .reminder-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.reminder-row .reminder-name input { margin-top: 0; }
|
||||
.reminder-after {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
input.reminder-mins {
|
||||
width: 4.75rem;
|
||||
padding: 6px 8px;
|
||||
text-align: right;
|
||||
}
|
||||
/* The full-size switch crowds a row that also carries a number field. */
|
||||
.reminder-row input.switch {
|
||||
width: 36px;
|
||||
height: 22px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
.reminder-row input.switch::after {
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.reminder-row input.switch:checked::after { transform: translateX(14px); }
|
||||
#reminders-test { width: 100%; }
|
||||
|
||||
@@ -13,6 +13,9 @@ const ASSETS = [
|
||||
"./app.js",
|
||||
"./manifest.json",
|
||||
"./icon.svg",
|
||||
"./icon-180.png",
|
||||
"./icon-192.png",
|
||||
"./icon-512.png",
|
||||
"./changelog.json",
|
||||
];
|
||||
|
||||
@@ -92,3 +95,46 @@ self.addEventListener("fetch", (event) => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// ---------- 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);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user