feat(notifications): strengthen the Web Push path for PWA-only self-hosters

Self-hosters without an Apple Developer account rely on Web Push to the
installed PWA — there is no Live Activity or lock-screen widget to drive.
Make that path carry the dose lifecycle on its own:

- Stable per-slot tag. The medication dose-due reminder now sets a
  `med:<id>:<iso-slot>` Web Push tag (medicationDoseTag), so a re-fired
  reminder for the same slot coalesces instead of stacking, and a later
  clear can target it.
- Clear-on-taken. When a dose is marked taken or skipped, push a
  `type:"clear"` notification reusing that slot tag. The service worker
  closes the matching pending reminder and refreshes the badge — the PWA
  equivalent of ending a Live Activity. A snooze leaves the dose
  outstanding and clears nothing. Wired into the single + bulk intake
  routes alongside the existing APNs cross-device sync.
- App badge. The dispatcher carries a server-authoritative outstanding-
  dose count; the SW reflects it via the Badging API
  (setAppBadge/clearAppBadge), feature-detected and a no-op where
  unsupported.
- The SW push handler honours requireInteraction for urgent events and
  renotify so a re-fired reminder re-alerts.

Dose-due, preventive-care, and low-stock reminders already cascade to
Web Push when no APNs channel exists; tests pin that an APNs-less
instance still delivers.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 11:39:55 +02:00
1 parent 0163f159d5
commit 604aec631f
11 files changed
+666 -10

No files matched your search

+107 -1
View File
@@ -114,15 +114,47 @@ function bootServiceWorker(): SwHarness {
navPreload.enabled = true;
},
};
// v1.18.4 — push-handler fakes: a notification store the `getNotifications`
// / `showNotification` calls read/write, and a Badging-API spy.
const shown: Array<{ title: string; tag?: string; closed: boolean }> = [];
const badge = { value: undefined as number | undefined, cleared: false };
const selfObj = {
addEventListener: (type: string, fn: (event: unknown) => void) => {
listeners.set(type, fn);
},
skipWaiting: async () => {},
clients: { claim: async () => {} },
registration: { navigationPreload: navPreload },
registration: {
navigationPreload: navPreload,
showNotification: async (title: string, opts: { tag?: string }) => {
shown.push({ title, tag: opts?.tag, closed: false });
},
getNotifications: async ({ tag }: { tag?: string }) =>
shown
.filter((n) => !n.closed && (tag === undefined || n.tag === tag))
.map((n) => ({
close: () => {
n.closed = true;
},
})),
},
navigator: {
setAppBadge: (n: number) => {
badge.value = n;
},
clearAppBadge: () => {
badge.cleared = true;
badge.value = undefined;
},
},
location: { origin: ORIGIN },
};
(
selfObj as unknown as { __shown: typeof shown; __badge: typeof badge }
).__shown = shown;
(
selfObj as unknown as { __shown: typeof shown; __badge: typeof badge }
).__badge = badge;
const context: Record<string, unknown> = {
self: selfObj,
caches: cacheStorage,
@@ -306,3 +338,77 @@ describe("sw.js — trimCache", () => {
expect(store.map.has(`${ORIGIN}/page-7`)).toBe(true);
});
});
// ── push handler (v1.18.4: clear-on-taken tag close + app badge) ─────────────
async function dispatchPush(
harness: SwHarness,
data: unknown,
): Promise<void> {
let settled: Promise<unknown> = Promise.resolve();
harness.listeners.get("push")!({
data: { json: () => data, text: () => JSON.stringify(data) },
waitUntil: (p: Promise<unknown>) => {
settled = p;
},
});
await settled;
}
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 };
};
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 () => {
const harness = bootServiceWorker();
const { shown, badge } = 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);
});
it("a type:clear push closes the matching-tag notification (no new one) and updates the badge", async () => {
const harness = bootServiceWorker();
const { shown, badge } = swFakes(harness);
const tag = "med:med-1:2026-06-18T07:00:00.000Z";
await dispatchPush(harness, { title: "dose", tag, badge: 1 });
expect(shown.filter((n) => !n.closed)).toHaveLength(1);
await dispatchPush(harness, { type: "clear", tag, badge: 0 });
// The pending reminder for that slot is closed; no new notification shown.
expect(shown).toHaveLength(1);
expect(shown[0].closed).toBe(true);
// badge: 0 clears the app badge.
expect(badge.cleared).toBe(true);
});
it("a type:clear push only closes its own slot's tag", async () => {
const harness = bootServiceWorker();
const { shown } = swFakes(harness);
await dispatchPush(harness, { title: "a", tag: "med:a:t1" });
await dispatchPush(harness, { title: "b", tag: "med:b:t2" });
await dispatchPush(harness, { type: "clear", tag: "med:a:t1" });
const a = shown.find((n) => n.tag === "med:a:t1")!;
const b = shown.find((n) => n.tag === "med:b:t2")!;
expect(a.closed).toBe(true);
expect(b.closed).toBe(false);
});
});