Push notifications: period soon/late reminders, test button

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-10-18 09:43:41 +01:00
parent ce66b857db
commit bb19bc0aeb
12 changed files with 872 additions and 21 deletions

View file

@ -14,7 +14,10 @@
days: {},
view: "today",
month: null,
sheetDay: null
sheetDay: null,
push: null,
// This device's PushSubscription: undefined until checked, then null or set.
device: undefined
};
const $ = id => document.getElementById(id);
@ -79,10 +82,12 @@
role: payload.role,
shareDetails: payload.shareDetails,
symptoms: payload.symptoms,
days: payload.days
days: payload.days,
push: payload.push
});
if (!$("banner").classList.contains("error")) showBanner("");
render();
if (state.device === undefined) await checkDevice();
} catch (error) {
showBanner(error.message === "Not allowed" ? "This account does not have access to Flowtrack." : error.message, true);
}
@ -507,7 +512,151 @@
// ---- Settings ------------------------------------------------------------
// ---- Notifications -------------------------------------------------------
const isIos = () => /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
const installed = () => window.navigator.standalone === true || window.matchMedia("(display-mode: standalone)").matches;
const pushSupported = () => "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
const keyBytes = key => Uint8Array.from(atob(key.replace(/-/g, "+").replace(/_/g, "/")), char => char.charCodeAt(0));
const keyText = buffer => btoa(String.fromCharCode(...new Uint8Array(buffer))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
// serviceWorker.ready never settles if registration failed, so give up.
const workerReady = () => Promise.race([
navigator.serviceWorker.ready,
new Promise((_, reject) => setTimeout(() => reject(new Error("the notification worker did not start")), 8000))
]);
async function checkDevice() {
if (!editable() || !state.push || !state.push.available || !pushSupported()) {
state.device = null;
return;
}
try {
const registration = await workerReady();
let subscription = await registration.pushManager.getSubscription();
// A subscription made for a different server key can never be
// delivered to, so drop it rather than show notifications as on.
if (subscription && subscription.options.applicationServerKey &&
keyText(subscription.options.applicationServerKey) !== state.push.publicKey) {
await subscription.unsubscribe();
subscription = null;
}
// Re-register on every launch so the server copy survives a restore.
if (subscription) await sendJson("POST", "/api/push/subscribe", subscription.toJSON());
state.device = subscription;
} catch {
state.device = null;
}
render();
}
async function enablePush() {
try {
// iOS only shows the permission prompt straight from a tap, so this
// must be the first thing awaited in the click handler.
const permission = await Notification.requestPermission();
if (permission !== "granted") {
showBanner("Notifications were not allowed.", true);
render();
return;
}
const registration = await workerReady();
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: keyBytes(state.push.publicKey)
});
const { devices } = await sendJson("POST", "/api/push/subscribe", subscription.toJSON());
state.device = subscription;
state.push.devices = devices;
showBanner("Notifications are on. Send a test to check.");
} catch (error) {
showBanner(`Couldn't turn on notifications: ${error.message}`, true);
}
render();
}
async function disablePush() {
try {
const { devices } = await sendJson("POST", "/api/push/unsubscribe", { endpoint: state.device.endpoint });
await state.device.unsubscribe();
state.device = null;
state.push.devices = devices;
showBanner("Notifications are off on this device.");
} catch (error) {
showBanner(`Couldn't turn off notifications: ${error.message}`, true);
}
render();
}
async function testPush(button) {
button.disabled = true;
try {
const result = await api("/api/push/test", { method: "POST" });
if (result.sent) showBanner(`Test sent to ${plural(result.sent, "device")}. It should arrive within a few seconds.`);
else showBanner(result.errors[0] ? `Test failed: ${result.errors[0]}` : "No devices are signed up for notifications.", true);
} catch (error) {
showBanner(`Test failed: ${error.message}`, true);
} finally {
button.disabled = false;
}
}
async function setRemindDays(remindDays) {
try {
await sendJson("PUT", "/api/settings", { remindDays });
state.push.remindDays = remindDays;
showBanner(`You'll be reminded ${plural(remindDays, "day")} before.`);
render();
} catch (error) {
showBanner(`Couldn't save: ${error.message}`, true);
}
}
function renderNotifications() {
const body = $("notify-body");
const note = text => h("p", { class: "notify-note" }, text);
if (!state.push || !state.push.available) {
body.replaceChildren(note("Notifications are not set up on the server yet."));
return;
}
if (!pushSupported()) {
body.replaceChildren(note(isIos() && !installed()
? "On iPhone, notifications only work from the Home Screen app. In Safari tap Share, then Add to Home Screen, and open Flowtrack from there."
: "This browser does not support notifications."));
return;
}
if (Notification.permission === "denied") {
body.replaceChildren(note(isIos()
? "Notifications are blocked. Turn them on in the iPhone Settings app under Notifications, Flowtrack."
: "Notifications are blocked for this site in the browser settings."));
return;
}
if (state.device === undefined) {
body.replaceChildren(note("Checking…"));
return;
}
const days = h("select", { "aria-label": "Days before" }, range(1, 7).map(count =>
h("option", { value: String(count), selected: count === state.push.remindDays }, String(count))
));
days.addEventListener("change", () => setRemindDays(Number(days.value)));
const remind = h("label", { class: "remind-row" }, "Remind me", days, state.push.remindDays === 1 ? "day before" : "days before");
if (!state.device) {
body.replaceChildren(
h("button", { type: "button", class: "button", onclick: enablePush }, "Turn on notifications"),
remind
);
return;
}
const test = h("button", { type: "button", class: "button" }, "Send test notification");
test.addEventListener("click", () => testPush(test));
body.replaceChildren(
h("p", { class: "notify-status" }, h("span", { class: "status-dot" }), "On for this device"),
remind,
h("div", { class: "button-row" }, test, h("button", { type: "button", class: "button secondary", onclick: disablePush }, "Turn off"))
);
}
function renderSettings(summary) {
renderNotifications();
$("share-toggle").checked = state.shareDetails;
const hint = summary.periodSamples
? `Leave "Ended" empty to use the average length so far, ${plural(summary.periodLength, "day")}.`
@ -572,6 +721,7 @@
}
});
seedForm($("seed-form"));
if ("serviceWorker" in navigator) navigator.serviceWorker.register("/sw.js").catch(() => {});
// The home-screen app is often resumed rather than reopened, so refresh
// whenever it comes back to the foreground (this also rolls "today" over).
document.addEventListener("visibilitychange", () => {