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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-12 13:56:33 +01:00
1 parent 1dde3034c6
commit 38299f7c6e
6 files changed
+50 -114

No files matched your search

+16 -24
View File
@@ -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
+17 -27
View File
@@ -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 },
}),
);
});
@@ -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,
@@ -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);
}
+15 -13
View File
@@ -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<void> {
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);
});
});
-46
View File
@@ -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<number> {
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;
}
}