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

@@ -69,6 +69,8 @@ import {
} from "@/lib/validations/medication";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import { dispatchMedicationIntakeSyncBulk } from "@/lib/notifications/medication-intake-sync";
import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
@@ -711,6 +713,23 @@ async function postBulk(request: NextRequest): Promise<Response> {
slots: Array.from(syncSlots.values()),
originDeviceToken: request.headers.get("x-device-id"),
});
// v1.18.4 — PWA-only counterpart: close the still-pending dose-due
// reminders for every affected slot over Web Push and refresh the app
// badge once with the post-batch outstanding-dose count. Best-effort,
// fire-and-forget — a clear miss never affects the batch response.
const affectedSlots = Array.from(syncSlots.values());
void (async () => {
const badgeCount = await countOutstandingDosesToday(
user.id,
user.timezone,
);
await dispatchMedicationIntakeWebClearBulk({
userId: user.id,
slots: affectedSlots,
badgeCount,
});
})();
}
return apiSuccess({
+20
View File
@@ -51,6 +51,8 @@ import {
type InjectionSiteValue,
} from "@/lib/validations/medication";
import { dispatchMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear";
import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses";
const querySchema = z.object({
scope: z.enum(["today", "compliance"]),
@@ -698,5 +700,23 @@ export const POST = apiHandler(async (request: NextRequest) => {
originDeviceToken: request.headers.get("x-device-id"),
});
// v1.18.4 — PWA-only equivalent of the Live Activity end: when a dose is
// resolved (taken / skipped), push a `type:"clear"` to the user's Web Push
// subscriptions so the still-pending dose-due reminder for this slot is
// closed by the service worker (matched on the stable slot tag) and the app
// badge re-reflects the outstanding-dose count. A snooze leaves the dose
// outstanding, so it neither clears the reminder nor changes the badge.
if (status === "taken" || status === "skipped") {
void (async () => {
const badgeCount = await countOutstandingDosesToday(user.id, userTzForHook);
await dispatchMedicationIntakeWebClear({
userId: user.id,
medicationId: existing.medicationId,
scheduledFor: (movedTo ?? existing.scheduledFor).toISOString(),
badgeCount,
});
})();
}
return apiSuccess(updated);
});