From 38299f7c6e38b2d119cdb3dc2ca47c122c91664b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 12 Nov 2026 13:56:33 +0100 Subject: [PATCH] fix(pwa): clear the dose count earlier releases left on the app icon The server set the Home Screen badge only through the clear push sent for each logged dose, and that push is gone. A device whose last clear left a count (one forgotten dose, an update in the middle of the day) kept that number on the icon for good, since iOS never clears a web app's badge by itself. The worker now clears the badge once on activate. The badge handling in the push handler, the unused outstanding-dose counter and the test of a payload no server sends are removed, and the self-hosting guide no longer promises the clear push or the badge. --- docs/self-hosting/notifications.md | 40 +++++++--------- public/sw.js | 44 +++++++----------- .../baselines/version-stamp-comments.json | 5 +- .../medication-intake-tracking-guard.test.ts | 1 - src/__tests__/service-worker.test.ts | 28 +++++------ src/lib/medications/outstanding-doses.ts | 46 ------------------- 6 files changed, 50 insertions(+), 114 deletions(-) delete mode 100644 src/lib/medications/outstanding-doses.ts diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index 08eed4b7f..1a300a785 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -111,17 +111,9 @@ v1.18.4: - **Reminders.** Medication doses, mood check-ins, and any other event the user has enabled, delivered to the installed app even when it is closed. -- **Taken → clears the reminder.** When you log a dose, the server sends - a silent `type:"clear"` push that closes the still-pending dose-due - reminder for that slot (matched on its stable tag) without showing a - new notification (`public/sw.js`). This is the PWA equivalent of ending - an iOS Live Activity. -- **An app badge for outstanding doses.** The icon on the Home Screen - carries a server-authoritative count of doses still due; logging a dose - decrements it, and a count of zero clears the badge. The count is - computed on the server and reflected by the service worker via the - Badging API (`navigator.setAppBadge`), feature-detected — engines - without it (most desktop Firefox, older Safari) silently no-op. +- **A reminder stays until you act on it.** It stays on screen until it + is tapped or dismissed, or until the next reminder for the same dose + replaces it. ### iPhone — Add to Home Screen first @@ -141,11 +133,11 @@ permission, and push works. ### The inherent limit -The installed PWA gets reminders, the taken-clears behaviour, and the app -badge — but it **cannot** show a native iOS lock-screen Live Activity -widget. That single feature rides Apple Push Notification service and is -only available through a native iOS build with APNs. Reminders themselves -arrive on both paths; only the Live Activity is APNs-exclusive. +The installed PWA gets reminders, but it **cannot** show a native iOS +lock-screen Live Activity widget. That single feature rides Apple Push +Notification service and is only available through a native iOS build with +APNs. Reminders themselves arrive on both paths; only the Live Activity is +APNs-exclusive. ## Telegram @@ -586,14 +578,14 @@ it registers for the mood reminder. ## Which channel should I pick? -| Your setup | Recommended channel | -| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. | -| PWA on an iPhone | **Web Push**, but install the PWA via _Add to Home Screen_ first (iOS 16.4+). Reminders, taken-clears, and the app badge all work; the lock-screen Live Activity needs a native build. | -| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with Web Push as a fallback for any non-iOS device. | -| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. | -| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. | -| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. | +| Your setup | Recommended channel | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. | +| PWA on an iPhone | **Web Push**, but install the PWA via _Add to Home Screen_ first (iOS 16.4+). Reminders work; the lock-screen Live Activity needs a native build. | +| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with Web Push as a fallback for any non-iOS device. | +| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. | +| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. | +| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. | Mixing channels is fine and common — e.g. Web Push on the laptop and ntfy on the phone. Each user configures their own set from diff --git a/public/sw.js b/public/sw.js index f3b5c226f..f778a5239 100644 --- a/public/sw.js +++ b/public/sw.js @@ -68,6 +68,7 @@ self.addEventListener("activate", (event) => { self.registration.navigationPreload ? self.registration.navigationPreload.enable() : Promise.resolve(), + clearLeftoverAppBadge(), ]).then(() => self.clients.claim()), ); }); @@ -352,20 +353,13 @@ async function trimCache(cacheName, maxEntries) { } } -// The outstanding-dose count on the app icon, where the Badging API exists. -// 0 clears it; anything that is not a number leaves it alone. -function applyAppBadge(count) { - if (typeof count !== "number" || !isFinite(count)) return; +// Earlier releases set a dose count on the app icon; nothing sends one now, +// so clear what they left. Never fails the activation. +async function clearLeftoverAppBadge() { try { - if (count > 0) { - if (typeof self.navigator?.setAppBadge === "function") { - self.navigator.setAppBadge(count); - } - } else if (typeof self.navigator?.clearAppBadge === "function") { - self.navigator.clearAppBadge(); - } + await self.navigator?.clearAppBadge?.(); } catch { - // Never let the badge break the push handler. + // Optional. } } @@ -384,25 +378,21 @@ self.addEventListener("push", (event) => { body = "", tag = "default", url = "/", - badge, requireInteraction = false, } = payload; event.waitUntil( - Promise.all([ - self.registration.showNotification(title, { - body, - tag, - // A re-sent reminder with the same tag alerts again. - renotify: true, - // Set by the sender for urgent events, where the browser honours it. - requireInteraction: requireInteraction === true, - icon: "/logo-192.png", - badge: "/logo-192.png", - data: { url }, - }), - Promise.resolve(applyAppBadge(badge)), - ]), + self.registration.showNotification(title, { + body, + tag, + // A re-sent reminder with the same tag alerts again. + renotify: true, + // Set by the sender for urgent events, where the browser honours it. + requireInteraction: requireInteraction === true, + icon: "/logo-192.png", + badge: "/logo-192.png", + data: { url }, + }), ); }); diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index f4c5e40e0..7bbae47ac 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2877, - "total": 9165, + "count": 2876, + "total": 9163, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -2502,7 +2502,6 @@ "lib/medications/list-read.ts": 23, "lib/medications/med-target-map.ts": 1, "lib/medications/medication-order.ts": 1, - "lib/medications/outstanding-doses.ts": 2, "lib/medications/route-guards.ts": 1, "lib/medications/schedule-units-dto.ts": 1, "lib/medications/scheduled-doses.ts": 1, diff --git a/src/__tests__/medication-intake-tracking-guard.test.ts b/src/__tests__/medication-intake-tracking-guard.test.ts index cd58f79bd..f5718f2f5 100644 --- a/src/__tests__/medication-intake-tracking-guard.test.ts +++ b/src/__tests__/medication-intake-tracking-guard.test.ts @@ -245,7 +245,6 @@ describe("medication intake tracking — intake-row readers honour the switch", "lib/jobs/coach-nudge.ts", "lib/insights/general-status.ts", "lib/analytics/correlations-fast-path.ts", - "lib/medications/outstanding-doses.ts", ]) { expect(intakeReaders).toContain(known); } diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index a3290e9da..6fc4a074e 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -158,10 +158,9 @@ function bootServiceWorker(): SwHarness { navPreload.enabled = true; }, }; - // Push-handler fakes: the notifications `showNotification` shows, and a - // Badging-API spy. + // The notifications `showNotification` shows, and a Badging-API spy. const shown: Array<{ title: string; tag?: string; closed: boolean }> = []; - const badge = { value: undefined as number | undefined, cleared: false }; + const badge = { cleared: false }; const selfObj = { addEventListener: (type: string, fn: (event: unknown) => void) => { listeners.set(type, fn); @@ -182,12 +181,8 @@ function bootServiceWorker(): SwHarness { }, }, navigator: { - setAppBadge: (n: number) => { - badge.value = n; - }, - clearAppBadge: () => { + clearAppBadge: async () => { badge.cleared = true; - badge.value = undefined; }, }, location: { origin: ORIGIN }, @@ -486,6 +481,15 @@ describe("sw.js — activate", () => { ); expect(harness.navPreload.enabled).toBe(true); }); + + it("clears the dose count an earlier release left on the app icon", async () => { + const harness = bootServiceWorker(); + const { badge } = swFakes(harness); + + await dispatchActivate(harness); + + expect(badge.cleared).toBe(true); + }); }); describe("sw.js — authenticated install cache isolation", () => { @@ -1094,27 +1098,25 @@ async function dispatchPush(harness: SwHarness, data: unknown): Promise { function swFakes(harness: SwHarness) { const selfObj = harness.context.self as unknown as { __shown: Array<{ title: string; tag?: string; closed: boolean }>; - __badge: { value: number | undefined; cleared: boolean }; + __badge: { cleared: boolean }; }; return { shown: selfObj.__shown, badge: selfObj.__badge }; } describe("sw.js — push handler", () => { - it("shows a reminder with its stable tag and sets the app badge", async () => { + it("shows a reminder with its stable tag", async () => { const harness = bootServiceWorker(); - const { shown, badge } = swFakes(harness); + const { shown } = swFakes(harness); const tag = "med:med-1:2026-06-18T07:00:00.000Z"; await dispatchPush(harness, { title: "Time for your dose", body: "Ramipril 5mg", tag, - badge: 3, }); expect(shown).toHaveLength(1); expect(shown[0].tag).toBe(tag); - expect(badge.value).toBe(3); }); }); diff --git a/src/lib/medications/outstanding-doses.ts b/src/lib/medications/outstanding-doses.ts deleted file mode 100644 index d94109953..000000000 --- a/src/lib/medications/outstanding-doses.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * v1.18.4 — server-authoritative count of a user's still-outstanding doses - * for the current local day. Drives the PWA app badge (`navigator.setAppBadge`) - * so the installed icon mirrors how many doses still need logging — the - * free-tier equivalent of the iOS widget's pending count. - * - * Outstanding = a today-scheduled `MedicationIntakeEvent` that is neither - * taken (`takenAt == null`) nor skipped (`skipped == false`) nor swept by the - * miss cutoff (`autoMissed == false`). Pending rows are minted by - * `projectTodayIntakesAndRecompute`; this is a read-only count over the same - * window, so callers that have just projected today's rows get an accurate - * total. - */ -import { prisma } from "@/lib/db"; -import { getUserTodayBounds } from "@/lib/tz/local-day"; -import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking"; - -/** - * Count the user's outstanding (pending) doses for today in their timezone. - * Best-effort: any failure returns 0 so a badge update never blocks a caller. - */ -export async function countOutstandingDosesToday( - userId: string, - tz: string, -): Promise { - try { - const { start, end } = getUserTodayBounds(new Date(), tz); - return await prisma.medicationIntakeEvent.count({ - where: { - userId, - scheduledFor: { gte: start, lte: end }, - takenAt: null, - skipped: false, - autoMissed: false, - // A tombstoned placeholder is gone for every read surface; it must - // not keep a badge count alive. - deletedAt: null, - // v1.39.1 (#1033) — nothing of a medication with intake tracking - // off is outstanding. - ...TRACKED_INTAKE_EVENT_WHERE, - }, - }); - } catch { - return 0; - } -}