feat: pwa notifications

This commit is contained in:
2026-07-14 20:49:30 +02:00
parent 2cf7b283e0
commit 5198826f83
21 changed files with 1563 additions and 8 deletions
+120
View File
@@ -145,5 +145,125 @@
{% endif %}
</div>
<div class="osiris-card" id="reminders" hidden>
<h2 style="margin-top:0">Reminders</h2>
<p class="dose-detail" id="reminder-status">Checking…</p>
<div style="display:flex; gap:8px; flex-wrap:wrap">
<button type="button" id="reminder-toggle" class="osiris-save" style="flex:1; min-width:140px"></button>
<button type="button" id="reminder-test" class="osiris-save" style="flex:1; min-width:140px; background:transparent; color:inherit; border:1px solid var(--border-color)" hidden>
Send test
</button>
</div>
<p class="dose-detail" style="margin-bottom:0">
A reminder is sent at each dose's reminder time, and only if it isn't already ticked off.
Set those times on the medication in the admin.
</p>
</div>
<p style="text-align:center"><a href="{% url 'admin:index' %}">Manage medication &amp; history &rarr;</a></p>
<script>
(function () {
const VAPID_PUBLIC_KEY = "{{ vapid_public_key|escapejs }}";
const card = document.getElementById("reminders");
const status = document.getElementById("reminder-status");
const toggle = document.getElementById("reminder-toggle");
const test = document.getElementById("reminder-test");
// No keys configured, or a browser without push: leave the card hidden entirely.
if (!VAPID_PUBLIC_KEY || !("serviceWorker" in navigator) || !("PushManager" in window)) {
return;
}
card.hidden = false;
const csrf = document.querySelector("[name=csrfmiddlewaretoken]").value;
function post(url, body) {
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRFToken": csrf },
body: JSON.stringify(body || {}),
}).then(async (response) => {
if (!response.ok) {
const detail = await response.json().catch(() => ({}));
throw new Error(detail.detail || `Request failed (${response.status})`);
}
return response.json();
});
}
// The browser wants the key as a Uint8Array, not the base64url we store it as.
function decodeKey(key) {
const padded = (key + "=".repeat((4 - (key.length % 4)) % 4))
.replace(/-/g, "+")
.replace(/_/g, "/");
const raw = atob(padded);
return Uint8Array.from([...raw].map((c) => c.charCodeAt(0)));
}
let subscription = null;
function render() {
const blocked = Notification.permission === "denied";
if (blocked) {
status.textContent =
"Notifications are blocked for this site. Re-allow them in Chrome's site settings.";
toggle.hidden = true;
test.hidden = true;
return;
}
toggle.hidden = false;
toggle.disabled = false;
toggle.textContent = subscription ? "Turn reminders off" : "Turn reminders on";
test.hidden = !subscription;
status.textContent = subscription
? "Reminders are on for this device."
: "Reminders are off for this device.";
}
navigator.serviceWorker.ready
.then((registration) => registration.pushManager.getSubscription())
.then((existing) => {
subscription = existing;
render();
});
toggle.addEventListener("click", async () => {
toggle.disabled = true;
try {
if (subscription) {
await post("/api/push/unsubscribe", { endpoint: subscription.endpoint });
await subscription.unsubscribe();
subscription = null;
} else {
const permission = await Notification.requestPermission();
if (permission !== "granted") return render();
const registration = await navigator.serviceWorker.ready;
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: decodeKey(VAPID_PUBLIC_KEY),
});
await post("/api/push/subscribe", subscription.toJSON());
}
render();
} catch (error) {
status.textContent = error.message;
toggle.disabled = false;
}
});
test.addEventListener("click", async () => {
test.disabled = true;
status.textContent = "Sending…";
try {
const result = await post("/api/push/test");
status.textContent = result.detail;
} catch (error) {
status.textContent = error.message;
}
test.disabled = false;
});
})();
</script>
{% endblock %}