mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
1 parent
0163f159d5
commit
604aec631f
11 files changed
+666
-10
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user