From aafc1a3c1a084265bf3cd74d3d4f501583255b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 18:34:11 +0200 Subject: [PATCH 1/3] Quiet the snooze and clear the web reminder on every intake path The two halves of a resolved dose were split across routes: the single intake route reset the medication's snooze but never closed the pending dose-due Web Push reminder, while the bulk route closed the reminder but left the snooze standing. Each path was missing exactly what the other had. A dose drained from a device's offline queue lands on these routes, so a synced take could keep ringing as snoozed or leave a stale web badge behind. The bulk route now nulls snoozedUntil once per affected medication when a non-skipped entry actually lands, and the by-id route dispatches the same fire-and-forget web clear the other intake paths have carried since v1.18.4. --- .../__tests__/source-provenance.test.ts | 37 ++++++++++ src/app/api/medications/[id]/intake/route.ts | 18 +++++ .../intake/bulk/__tests__/route.test.ts | 70 +++++++++++++++++++ src/app/api/medications/intake/bulk/route.ts | 15 ++++ 4 files changed, 140 insertions(+) diff --git a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts index 3b4ebe141..2d1b52967 100644 --- a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts @@ -82,6 +82,15 @@ vi.mock("@/lib/notifications/medication-intake-sync", () => ({ queueMedicationIntakeSync: vi.fn(), })); +// The route dispatches the PWA clear fire-and-forget after a recorded dose; +// both halves are mocked so the test asserts the dispatch, not web-push. +vi.mock("@/lib/notifications/web-push-clear", () => ({ + dispatchMedicationIntakeWebClear: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/medications/outstanding-doses", () => ({ + countOutstandingDosesToday: vi.fn(), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/bearer", () => ({ resolveBearerToken: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -110,6 +119,8 @@ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { resolveBearerToken } from "@/lib/auth/bearer"; +import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; +import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; const USER = { id: "user-1", @@ -191,6 +202,32 @@ describe("POST intake — transport-derived source (iOS #64)", () => { expect(createdSource()).toBe("API"); }); + it("closes the pending dose-due web reminder after a recorded intake", async () => { + // Before this route dispatched the clear, an iOS offline drain (which + // replays free intakes through exactly this endpoint) left the web + // dose-due reminder and the app badge standing after the dose landed. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(countOutstandingDosesToday).mockResolvedValue(3); + + const res = await POST(postReq({}), ROUTE_PARAMS); + expect(res.status).toBe(201); + + // Fire-and-forget: the dispatch runs detached from the response. + await expect + .poll( + () => vi.mocked(dispatchMedicationIntakeWebClear).mock.calls.length, + { timeout: 2_000, interval: 25 }, + ) + .toBe(1); + expect( + vi.mocked(dispatchMedicationIntakeWebClear).mock.calls[0][0], + ).toMatchObject({ + userId: "user-1", + medicationId: "med-1", + badgeCount: 3, + }); + }); + it("ignores a client-supplied `source` in the body (no mass assignment)", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 0164660c2..6d684b667 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -29,6 +29,8 @@ import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; +import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; +import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { @@ -616,6 +618,22 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { originDeviceToken: request.headers.get("x-device-id"), }); + // PWA counterpart of the sync wake above: a dose resolved here (taken or + // skipped) closes the still-pending dose-due Web Push reminder for the + // slot and refreshes the app badge. The other intake routes have carried + // this since v1.18.4; this route is the replay target of the iOS offline + // queue, so a drained dose must clear the web reminder the same way. + // Best-effort, fire-and-forget — the canonical row is already persisted. + void (async () => { + const badgeCount = await countOutstandingDosesToday(user.id, user.timezone); + await dispatchMedicationIntakeWebClear({ + userId: user.id, + medicationId: id, + scheduledFor: event.scheduledFor.toISOString(), + badgeCount, + }); + })(); + // v1.36.x — "somebody else marked your dose". This route has no snooze arm, // so the state is whichever of the two markings the payload carried. The // helper refuses on self, so a person marking their own dose is unaffected. diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index b8b61c992..25765da23 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -11,6 +11,8 @@ vi.mock("@/lib/db", () => ({ findMany: vi.fn(), // v1.8.2 — the slot resolver loads the med via findFirst. findFirst: vi.fn(), + // A landed non-skipped dose clears the medication's snooze. + updateMany: vi.fn(), }, medicationIntakeEvent: { create: vi.fn(), @@ -324,6 +326,74 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => { }); }); + it("a landed taken write clears the medication's active snooze", async () => { + // The single-intake routes null `snoozedUntil` on every recorded take; + // before this parity fix a dose drained through the bulk route (the iOS + // offline-queue path) left the snooze standing and the reminder ringing. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + { + id: "row-pending", + takenAt: null, + skipped: false, + idempotencyKey: null, + scheduledFor: new Date("2026-06-15T05:00:00Z"), + source: "REMINDER", + createdAt: new Date("2026-06-15T00:00:00Z"), + }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({ + id: "row-pending", + } as never); + + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-1", + scheduledFor: "2026-06-15T05:00:30.000Z", + takenAt: "2026-06-15T05:02:00.000Z", + }, + ], + }), + ); + expect(res.status).toBe(200); + expect(prisma.medication.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ["med-1"] }, userId: "user-1" }, + data: { snoozedUntil: null }, + }); + }); + + it("a landed skip leaves the snooze untouched", async () => { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + { + id: "row-pending", + takenAt: null, + skipped: false, + idempotencyKey: null, + scheduledFor: new Date("2026-06-15T05:00:00Z"), + source: "REMINDER", + createdAt: new Date("2026-06-15T00:00:00Z"), + }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValueOnce({ + id: "row-pending", + } as never); + + const res = await POST( + postReq({ + entries: [ + { + medicationId: "med-1", + scheduledFor: "2026-06-15T05:00:30.000Z", + skipped: true, + }, + ], + }), + ); + expect(res.status).toBe(200); + expect(prisma.medication.updateMany).not.toHaveBeenCalled(); + }); + it("queues exactly ONE intake-sync fan-out for a multi-entry batch", async () => { // Two pending rows at the day's two slots; the batch resolves both. vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 9feded721..5064c0699 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -351,6 +351,10 @@ async function postBulk(request: NextRequest): Promise { string, { medicationId: string; scheduledFor: string } >(); + // A landed non-skipped dose quiets an active snooze, exactly like the + // single-intake routes (they null `snoozedUntil` on every recorded take). + // Collected per medication here, cleared once after the loop. + const snoozeClearIds = new Set(); for (let i = 0; i < entries.length; i++) { const entry = entries[i]; @@ -786,6 +790,7 @@ async function postBulk(request: NextRequest): Promise { medicationId: entry.medicationId, scheduledFor: scheduledForIso, }); + if (!entry.skipped) snoozeClearIds.add(entry.medicationId); } } catch (err: unknown) { // P2002 = unique-constraint violation. Two shapes reach here: @@ -909,6 +914,16 @@ async function postBulk(request: NextRequest): Promise { // device (`X-Device-Id` = registered `Device.token`). APNs-only, // best-effort, fire-and-forget: the canonical rows are already // persisted, so a sync-push miss never affects the batch response. + // The single-intake routes reset a medication's snooze on every recorded + // take; a drained offline batch must quiet the snooze the same way, or a + // dose synced in later keeps counting as snoozed although it was taken. + if (snoozeClearIds.size > 0) { + await prisma.medication.updateMany({ + where: { id: { in: [...snoozeClearIds] }, userId: user.id }, + data: { snoozedUntil: null }, + }); + } + if (syncSlots.size > 0) { queueMedicationIntakeSync({ userId: user.id, From 548e94cc75cb958863133b6d8a34b759951b962a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 18:42:50 +0200 Subject: [PATCH 2/3] =?UTF-8?q?chore(release):=20v1.37.21=20=E2=80=94=20ev?= =?UTF-8?q?ery=20intake=20path=20resolves=20a=20dose=20the=20same=20way?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ package.json | 2 +- public/sw.js | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3679ccca1..88109be85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.37.21] — 2026-08-14 + +### Fixed + +- A dose that reaches the server through the batch route, the path a phone's offline queue drains through, now quiets an active snooze the same way a directly logged dose always has. Before, a take synced in later kept counting as snoozed and the reminder kept ringing. +- Marking a dose through the single-medication intake route now also closes the still-pending dose-due reminder in the web app and refreshes its badge. The other intake paths have done this since v1.18.4; this one, which is exactly the route a phone's offline replay uses, left the web reminder standing. + ## [1.37.20] — 2026-08-14 ### Added diff --git a/package.json b/package.json index ba01c99ae..d7cff0ed2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.37.20", + "version": "1.37.21", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index b008e89bd..82e5879f3 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.37.20"; + /* @sw-version-fallback */ "v1.37.21"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` From 5ddf00420e2369f9eb5f5dc51b4e1b500c666b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 19:00:24 +0200 Subject: [PATCH 3/3] Carry the version bump into the API description --- docs/api/openapi.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 78f641f45..33df1201c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.37.20 + version: 1.37.21 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.