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; - } -}