Push notifications: period soon/late reminders, test button
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ce66b857db
commit
bb19bc0aeb
12 changed files with 872 additions and 21 deletions
154
static/app.js
154
static/app.js
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials">
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/styles.css?v=1.1.0">
|
||||
<link rel="stylesheet" href="/styles.css?v=1.2.0">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
|
|
@ -74,6 +74,11 @@
|
|||
</section>
|
||||
|
||||
<section class="view" id="view-settings" aria-labelledby="tab-settings" hidden>
|
||||
<div class="card" id="notify-card">
|
||||
<h2>Notifications</h2>
|
||||
<p class="subtitle">A reminder before your period is due, and a note if it is late.</p>
|
||||
<div id="notify-body" class="notify-body"></div>
|
||||
</div>
|
||||
<div class="card" id="share-card">
|
||||
<h2>Sharing</h2>
|
||||
<label class="switch-row">
|
||||
|
|
@ -125,7 +130,7 @@
|
|||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="/cycle.js?v=1.1.0"></script>
|
||||
<script src="/app.js?v=1.1.0"></script>
|
||||
<script src="/cycle.js?v=1.2.0"></script>
|
||||
<script src="/app.js?v=1.2.0"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -307,6 +307,19 @@ main {
|
|||
.switch-row small { color: var(--muted); font-size: 0.85rem; }
|
||||
.switch-row input { flex: none; width: 24px; height: 24px; accent-color: var(--period); }
|
||||
|
||||
.notify-body { display: grid; gap: 14px; justify-items: start; }
|
||||
.notify-note { margin: 0; color: var(--ink-2); font-size: 0.92rem; }
|
||||
.notify-status { display: flex; align-items: center; gap: 8px; margin: 0; font-weight: 600; }
|
||||
.status-dot { width: 10px; height: 10px; border-radius: 999px; background: var(--fertile); }
|
||||
.remind-row { display: flex; align-items: center; gap: 8px; color: var(--ink-2); font-size: 0.92rem; }
|
||||
.remind-row select {
|
||||
min-height: 40px;
|
||||
padding: 6px 10px;
|
||||
border: 1.5px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.seed-form { display: grid; gap: 12px; }
|
||||
.seed-form label { display: grid; gap: 4px; color: var(--ink-2); font-size: 0.88rem; font-weight: 600; }
|
||||
.seed-form input {
|
||||
|
|
|
|||
29
static/sw.js
Normal file
29
static/sw.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Service worker: only shows push notifications. It deliberately caches
|
||||
// nothing, so the app always loads fresh from the server.
|
||||
self.addEventListener("install", () => self.skipWaiting());
|
||||
self.addEventListener("activate", event => event.waitUntil(self.clients.claim()));
|
||||
|
||||
self.addEventListener("push", event => {
|
||||
let message = { title: "Flowtrack", body: "" };
|
||||
try {
|
||||
message = { ...message, ...event.data.json() };
|
||||
} catch {
|
||||
// An empty or unreadable push still has to show something on iOS.
|
||||
}
|
||||
event.waitUntil(self.registration.showNotification(message.title, {
|
||||
body: message.body,
|
||||
icon: "/icon-192.png",
|
||||
badge: "/icon-192.png",
|
||||
tag: message.tag,
|
||||
data: { url: message.url || "/" }
|
||||
}));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", event => {
|
||||
event.notification.close();
|
||||
event.waitUntil((async () => {
|
||||
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
if (windows.length) return windows[0].focus();
|
||||
return self.clients.openWindow(event.notification.data.url);
|
||||
})());
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue