From b9b38d182cbb6878656c1b31b18c1cc55251371b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 10:21:53 +0200 Subject: [PATCH 1/8] feat(notifications): coalesce the intake sync push per user and strip its payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MEDICATION_INTAKE_SYNC fan-out used to fire one silent APNs per distinct dose slot, so a bulk intake marking five doses woke every other device five times. Replace the per-slot dispatch with a per-user coalescer: the first mutation in a burst dispatches immediately, every further mutation inside the 2 s window folds into one trailing fan-out. A bulk request now costs exactly one queue call, and a rapid burst of N single-dose mutations costs at most two fan-outs. The window state is in-process; multiple app instances throttle independently, which is accepted because the iOS reconcile is idempotent and the deployment model is a single container. The silent payload shrinks to a pure sync trigger — eventType + syncedAt. The per-slot medicationId / scheduledFor fields are gone: the iOS handler runs a full reconcile regardless, a coalesced burst has no single slot to name, and a push payload travels through Apple's infrastructure, so it now carries no health data by construction. The Live Activity end push likewise sends only the ActivityKit envelope. The trailing flush keeps the origin skip only when every coalesced mutation came from the same device; a mixed-origin burst wakes everyone. Each flush still records one push_attempts ledger row, reaps hard-rejected device tokens, and annotates the wide event with the recipient count and coalesced-mutation count. --- .../intake/__tests__/route.test.ts | 14 +- .../intake/bulk/__tests__/route.test.ts | 63 +++ src/app/api/medications/intake/bulk/route.ts | 14 +- src/app/api/medications/intake/route.ts | 8 +- .../__tests__/medication-intake-sync.test.ts | 362 +++++++++++------- .../notifications/medication-intake-sync.ts | 332 ++++++++++------ src/lib/notifications/web-push-clear.ts | 2 +- 7 files changed, 512 insertions(+), 283 deletions(-) diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index b26b14a46..2bef4d0f4 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -54,10 +54,10 @@ vi.mock("@/lib/medications/inventory/consumption", () => ({ })); // v1.17.1 (#22) — silent cross-device intake sync. Mocked so the route -// test asserts the wiring (called with the right slot + origin token) -// without reaching the APNs senders. +// test asserts the wiring (queued with the right origin token) without +// reaching the APNs senders or the coalescing timers. vi.mock("@/lib/notifications/medication-intake-sync", () => ({ - dispatchMedicationIntakeSync: vi.fn().mockResolvedValue(undefined), + queueMedicationIntakeSync: vi.fn(), })); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); @@ -82,7 +82,7 @@ import { consumeForIntake, restoreForIntake, } from "@/lib/medications/inventory/consumption"; -import { dispatchMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; const SESSION_OK = { @@ -425,11 +425,9 @@ describe("POST /api/medications/intake", () => { const res = await POST(r); expect(res.status).toBe(200); - expect(dispatchMedicationIntakeSync).toHaveBeenCalledTimes(1); - expect(dispatchMedicationIntakeSync).toHaveBeenCalledWith({ + expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1); + expect(queueMedicationIntakeSync).toHaveBeenCalledWith({ userId: "user-1", - medicationId: "m1", - scheduledFor: "2026-05-18T10:00:00.000Z", originDeviceToken: "origin-device-token", }); }); 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 f35456c20..afc975db1 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -63,11 +63,19 @@ vi.mock("next/headers", () => ({ })), })); +// (#22) — silent cross-device intake sync. Mocked so the route test can +// assert the batch queues exactly ONE fan-out without reaching the APNs +// senders or the coalescing timers. +vi.mock("@/lib/notifications/medication-intake-sync", () => ({ + queueMedicationIntakeSync: vi.fn(), +})); + import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { consumeForIntake, restoreForIntake, @@ -313,6 +321,61 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => { }); }); + 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([ + { + id: "row-am", + takenAt: null, + skipped: false, + idempotencyKey: null, + scheduledFor: new Date("2026-06-15T05:00:00Z"), + source: "REMINDER", + createdAt: new Date("2026-06-15T00:00:00Z"), + }, + { + id: "row-pm", + takenAt: null, + skipped: false, + idempotencyKey: null, + scheduledFor: new Date("2026-06-15T17:00:00Z"), + source: "REMINDER", + createdAt: new Date("2026-06-15T00:00:00Z"), + }, + ] as never); + vi.mocked(prisma.medicationIntakeEvent.update) + .mockResolvedValueOnce({ id: "row-am" } as never) + .mockResolvedValueOnce({ id: "row-pm" } 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", + }, + { + medicationId: "med-1", + scheduledFor: "2026-06-15T17:00:30.000Z", + takenAt: "2026-06-15T17:02:00.000Z", + }, + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { updated: number } }; + expect(body.data.updated).toBe(2); + + // The whole batch produces ONE queued fan-out, not one per dose; the + // web caller carries no X-Device-Id, so no origin is excluded. + expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1); + expect(queueMedicationIntakeSync).toHaveBeenCalledWith({ + userId: "user-1", + originDeviceToken: null, + }); + }); + it("C1 — a same-slot P2002 on create converges via re-find+update, not dropped as duplicate", async () => { // First slot find empty → create races a P2002 → re-find returns the // racing pending row → update applies the incoming takenAt. diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 316ae38d5..54b33dc8b 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -68,7 +68,7 @@ import { type InjectionSiteValue, } from "@/lib/validations/medication"; import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; -import { dispatchMedicationIntakeSyncBulk } from "@/lib/notifications/medication-intake-sync"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear"; import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; @@ -702,15 +702,15 @@ async function postBulk(request: NextRequest): Promise { } } - // v1.17.1 (#22) — silent cross-device intake sync. One push per device - // per distinct affected slot (the map de-dupes), excluding the - // originating device (`X-Device-Id` = registered `Device.token`). - // APNs-only, best-effort, fire-and-forget: the canonical rows are already + // v1.17.1 (#22) — silent cross-device intake sync. ONE queued fan-out + // for the whole batch regardless of how many slots it touched (the + // per-user coalescer folds bursts further), excluding the originating + // 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. if (syncSlots.size > 0) { - void dispatchMedicationIntakeSyncBulk({ + queueMedicationIntakeSync({ userId: user.id, - slots: Array.from(syncSlots.values()), originDeviceToken: request.headers.get("x-device-id"), }); diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index fb7f2bd4c..336ee1a19 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -49,7 +49,7 @@ import { injectionSiteEnum, type InjectionSiteValue, } from "@/lib/validations/medication"; -import { dispatchMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; @@ -522,14 +522,12 @@ export const POST = apiHandler(async (request: NextRequest) => { // v1.17.1 (#22) — silent cross-device intake sync. Wake the user's OTHER // iOS devices so a running Live Activity / Home-Screen widget reconciles - // the dose state changed here. APNs-only, best-effort, fire-and-forget: + // the dose state changed here. APNs-only, best-effort, coalesced per user: // the canonical row is already persisted, so a sync-push miss never // affects the response. The originating device (the one that POSTed) is // excluded via its `X-Device-Id` (= registered `Device.token`). - void dispatchMedicationIntakeSync({ + queueMedicationIntakeSync({ userId: user.id, - medicationId: existing.medicationId, - scheduledFor: (movedTo ?? existing.scheduledFor).toISOString(), originDeviceToken: request.headers.get("x-device-id"), }); diff --git a/src/lib/notifications/__tests__/medication-intake-sync.test.ts b/src/lib/notifications/__tests__/medication-intake-sync.test.ts index ad6485b2c..043a4bf4a 100644 --- a/src/lib/notifications/__tests__/medication-intake-sync.test.ts +++ b/src/lib/notifications/__tests__/medication-intake-sync.test.ts @@ -5,15 +5,21 @@ * 1. Silent background push to the user's OTHER devices (origin skip). * 2. APNs-only: the dispatch never reaches the dispatcher cascade, so * Telegram / ntfy / Web Push senders are never imported here. - * 3. Live Activity end push only when a `liveActivityPushToken` is stored. - * 4. Bulk de-dup: one silent push per device per distinct slot. - * 5. Explicit APNS-channel opt-out suppresses the fan-out. + * 3. Payload hygiene: the silent push is a pure sync trigger — event + * type + timestamp, no medication id / name / dose instant. + * 4. Coalescing: the first mutation in a burst dispatches immediately, + * everything else folds into ONE trailing dispatch per user. + * 5. Live Activity end push only when a `liveActivityPushToken` is stored. + * 6. Explicit APNS-channel opt-out suppresses the fan-out. + * 7. Failure isolation: a broken flush never throws into the caller. */ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const rawSendMock = vi.fn(); const loadConfigMock = vi.fn(); const recordPushAttemptMock = vi.fn(); +const annotateMock = vi.fn(); +const addWarningMock = vi.fn(); vi.mock("@/lib/notifications/senders/apns", () => ({ loadApnsConfig: () => loadConfigMock(), @@ -25,7 +31,8 @@ vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ })); vi.mock("@/lib/logging/context", () => ({ - getEvent: () => ({ addWarning: vi.fn(), addMeta: vi.fn() }), + annotate: (...args: unknown[]) => annotateMock(...args), + getEvent: () => ({ addWarning: addWarningMock, addMeta: vi.fn() }), })); const deviceFindMany = vi.fn(); @@ -44,46 +51,61 @@ vi.mock("@/lib/db", () => ({ }, })); -import { - dispatchMedicationIntakeSync, - dispatchMedicationIntakeSyncBulk, -} from "@/lib/notifications/medication-intake-sync"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; const APNS_CONFIG = { bundleId: "test.healthlog.ios" }; +/** + * The queue's fan-out is fire-and-forget; drain the microtask queue so + * the leading / trailing flush promise chains settle before asserting. + * (Fake timers do not fake microtasks, so plain awaits drain them.) + */ +async function drainFlush(): Promise { + for (let i = 0; i < 20; i += 1) await Promise.resolve(); +} + +/** Unique per-test user id so per-user coalescing windows never leak + * across tests (the window map is module state by design). */ +let userSeq = 0; +function nextUserId(): string { + userSeq += 1; + return `u-${userSeq}`; +} + +function device(overrides: Record = {}) { + return { + id: "d-other", + token: "other-token", + apnsToken: "bbbb2222", + apnsEnvironment: "sandbox", + liveActivityPushToken: null, + ...overrides, + }; +} + beforeEach(() => { vi.clearAllMocks(); + vi.useFakeTimers(); loadConfigMock.mockReturnValue(APNS_CONFIG); channelFindUnique.mockResolvedValue({ enabled: true, preferences: [] }); rawSendMock.mockResolvedValue({ ok: true, status: 200 }); deviceDeleteMany.mockResolvedValue({ count: 0 }); }); -describe("dispatchMedicationIntakeSync — origin skip + other-devices-only", () => { - it("sends a silent background push to every device EXCEPT the origin", async () => { - deviceFindMany.mockResolvedValue([ - { - id: "d-origin", - token: "origin-token", - apnsToken: "aaaa1111", - apnsEnvironment: "production", - liveActivityPushToken: null, - }, - { - id: "d-other", - token: "other-token", - apnsToken: "bbbb2222", - apnsEnvironment: "sandbox", - liveActivityPushToken: null, - }, - ]); +afterEach(() => { + vi.useRealTimers(); +}); - await dispatchMedicationIntakeSync({ - userId: "u1", - medicationId: "m1", - scheduledFor: "2026-06-14T07:00:00.000Z", - originDeviceToken: "origin-token", - }); +describe("queueMedicationIntakeSync — origin skip + other-devices-only", () => { + it("dispatches immediately to every device EXCEPT the origin", async () => { + deviceFindMany.mockResolvedValue([ + device({ id: "d-origin", token: "origin-token", apnsToken: "aaaa1111" }), + device(), + ]); + const userId = nextUserId(); + + queueMedicationIntakeSync({ userId, originDeviceToken: "origin-token" }); + await drainFlush(); // Exactly one push — to the non-origin device, silent background. expect(rawSendMock).toHaveBeenCalledTimes(1); @@ -94,31 +116,25 @@ describe("dispatchMedicationIntakeSync — origin skip + other-devices-only", () expect(call.payload).toMatchObject({ aps: { "content-available": 1 }, eventType: "MEDICATION_INTAKE_SYNC", - medicationId: "m1", - scheduledFor: "2026-06-14T07:00:00.000Z", }); - // No alert/sound/badge keys in the silent payload. + expect(typeof call.payload.syncedAt).toBe("string"); + // Pure sync trigger — no health data, no alert/sound/badge keys. + expect(call.payload.medicationId).toBeUndefined(); + expect(call.payload.scheduledFor).toBeUndefined(); expect(call.payload.aps.alert).toBeUndefined(); expect(call.payload.aps.sound).toBeUndefined(); }); it("skips nothing when the origin token has no matching device (web caller)", async () => { deviceFindMany.mockResolvedValue([ - { - id: "d1", - token: "tok-1", - apnsToken: "aaaa1111", - apnsEnvironment: "sandbox", - liveActivityPushToken: null, - }, + device({ id: "d1", token: "tok-1", apnsToken: "aaaa1111" }), ]); - await dispatchMedicationIntakeSync({ - userId: "u1", - medicationId: "m1", - scheduledFor: "2026-06-14T07:00:00.000Z", + queueMedicationIntakeSync({ + userId: nextUserId(), originDeviceToken: "no-such-token", }); + await drainFlush(); expect(rawSendMock).toHaveBeenCalledTimes(1); expect(rawSendMock.mock.calls[0][0].deviceToken).toBe("aaaa1111"); @@ -126,21 +142,12 @@ describe("dispatchMedicationIntakeSync — origin skip + other-devices-only", () it("records a skipped attempt when only the origin device exists", async () => { deviceFindMany.mockResolvedValue([ - { - id: "d-origin", - token: "origin-token", - apnsToken: "aaaa1111", - apnsEnvironment: "sandbox", - liveActivityPushToken: null, - }, + device({ id: "d-origin", token: "origin-token", apnsToken: "aaaa1111" }), ]); + const userId = nextUserId(); - await dispatchMedicationIntakeSync({ - userId: "u1", - medicationId: "m1", - scheduledFor: "2026-06-14T07:00:00.000Z", - originDeviceToken: "origin-token", - }); + queueMedicationIntakeSync({ userId, originDeviceToken: "origin-token" }); + await drainFlush(); expect(rawSendMock).not.toHaveBeenCalled(); expect(recordPushAttemptMock).toHaveBeenCalledWith( @@ -154,23 +161,142 @@ describe("dispatchMedicationIntakeSync — origin skip + other-devices-only", () }); }); -describe("dispatchMedicationIntakeSync — Live Activity push", () => { +describe("queueMedicationIntakeSync — coalescing", () => { + it("folds a rapid burst into ONE leading + ONE trailing fan-out", async () => { + deviceFindMany.mockResolvedValue([device()]); + const userId = nextUserId(); + + queueMedicationIntakeSync({ userId, originDeviceToken: null }); + queueMedicationIntakeSync({ userId, originDeviceToken: null }); + queueMedicationIntakeSync({ userId, originDeviceToken: null }); + await drainFlush(); + + // Leading fan-out only — the burst is still inside the window. + expect(rawSendMock).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(2_000); + await drainFlush(); + + // Trailing fan-out covering the two coalesced mutations. + expect(rawSendMock).toHaveBeenCalledTimes(2); + expect(recordPushAttemptMock).toHaveBeenCalledTimes(2); + expect(annotateMock).toHaveBeenLastCalledWith({ + meta: { + medication_intake_sync_devices: 1, + medication_intake_sync_coalesced: 2, + }, + }); + }); + + it("a single queue call (bulk route) produces exactly ONE fan-out", async () => { + deviceFindMany.mockResolvedValue([device()]); + const userId = nextUserId(); + + queueMedicationIntakeSync({ userId, originDeviceToken: null }); + await drainFlush(); + vi.advanceTimersByTime(5_000); + await drainFlush(); + + expect(rawSendMock).toHaveBeenCalledTimes(1); + expect(annotateMock).toHaveBeenCalledWith({ + meta: { + medication_intake_sync_devices: 1, + medication_intake_sync_coalesced: 0, + }, + }); + }); + + it("does not coalesce across users", async () => { + deviceFindMany.mockResolvedValue([device()]); + + queueMedicationIntakeSync({ + userId: nextUserId(), + originDeviceToken: null, + }); + queueMedicationIntakeSync({ + userId: nextUserId(), + originDeviceToken: null, + }); + await drainFlush(); + + expect(rawSendMock).toHaveBeenCalledTimes(2); + }); + + it("trailing flush keeps the origin skip when the burst has ONE origin", async () => { + deviceFindMany.mockResolvedValue([ + device({ id: "d-origin", token: "origin-token", apnsToken: "aaaa1111" }), + device(), + ]); + const userId = nextUserId(); + + queueMedicationIntakeSync({ userId, originDeviceToken: "origin-token" }); + queueMedicationIntakeSync({ userId, originDeviceToken: "origin-token" }); + await drainFlush(); + vi.advanceTimersByTime(2_000); + await drainFlush(); + + // Leading + trailing, and neither ever pushed to the origin device. + expect(rawSendMock).toHaveBeenCalledTimes(2); + const targets = rawSendMock.mock.calls.map((c) => c[0].deviceToken); + expect(targets).toEqual(["bbbb2222", "bbbb2222"]); + }); + + it("trailing flush wakes EVERY device when the coalesced mutations mix origins", async () => { + deviceFindMany.mockResolvedValue([ + device({ id: "d-a", token: "origin-a", apnsToken: "aaaa1111" }), + device({ id: "d-b", token: "origin-b", apnsToken: "bbbb2222" }), + ]); + const userId = nextUserId(); + + queueMedicationIntakeSync({ userId, originDeviceToken: "origin-a" }); + queueMedicationIntakeSync({ userId, originDeviceToken: "origin-b" }); + queueMedicationIntakeSync({ userId, originDeviceToken: "origin-a" }); + await drainFlush(); + vi.advanceTimersByTime(2_000); + await drainFlush(); + + // Leading: excludes origin-a. Trailing covers mutations from BOTH + // devices, so neither is skipped — each still has to learn about the + // other's change. + const targets = rawSendMock.mock.calls.map((c) => c[0].deviceToken); + expect(targets).toEqual(["bbbb2222", "aaaa1111", "bbbb2222"]); + }); + + it("trailing flush excludes the origin when ONLY that device queued mutations", async () => { + deviceFindMany.mockResolvedValue([ + device({ id: "d-a", token: "origin-a", apnsToken: "aaaa1111" }), + device({ id: "d-b", token: "origin-b", apnsToken: "bbbb2222" }), + ]); + const userId = nextUserId(); + + queueMedicationIntakeSync({ userId, originDeviceToken: "origin-a" }); + queueMedicationIntakeSync({ userId, originDeviceToken: "origin-b" }); + await drainFlush(); + vi.advanceTimersByTime(2_000); + await drainFlush(); + + // Leading (origin-a) → d-b. Trailing covers ONLY origin-b's + // mutation, so it wakes just d-a: origin-b already holds its own + // state and the leading flush already synced d-b. + const targets = rawSendMock.mock.calls.map((c) => c[0].deviceToken); + expect(targets).toEqual(["bbbb2222", "aaaa1111"]); + }); +}); + +describe("queueMedicationIntakeSync — Live Activity push", () => { it("sends a liveactivity end push only for devices with a stored token", async () => { deviceFindMany.mockResolvedValue([ - { + device({ id: "d1", token: "tok-1", apnsToken: "aaaa1111", apnsEnvironment: "production", liveActivityPushToken: "la-token-1", - }, + }), ]); - await dispatchMedicationIntakeSync({ - userId: "u1", - medicationId: "m1", - scheduledFor: "2026-06-14T07:00:00.000Z", - }); + queueMedicationIntakeSync({ userId: nextUserId() }); + await drainFlush(); // One silent + one liveactivity push. expect(rawSendMock).toHaveBeenCalledTimes(2); @@ -183,39 +309,30 @@ describe("dispatchMedicationIntakeSync — Live Activity push", () => { expect(la.deviceToken).toBe("la-token-1"); expect(la.topic).toBe("test.healthlog.ios.push-type.liveactivity"); expect(la.payload.aps.event).toBe("end"); + // ActivityKit envelope only — no health data rides along. + expect(la.payload.medicationId).toBeUndefined(); + expect(la.payload.scheduledFor).toBeUndefined(); }); it("sends no liveactivity push when no token is stored", async () => { deviceFindMany.mockResolvedValue([ - { - id: "d1", - token: "tok-1", - apnsToken: "aaaa1111", - apnsEnvironment: "sandbox", - liveActivityPushToken: null, - }, + device({ id: "d1", token: "tok-1", apnsToken: "aaaa1111" }), ]); - await dispatchMedicationIntakeSync({ - userId: "u1", - medicationId: "m1", - scheduledFor: "2026-06-14T07:00:00.000Z", - }); + queueMedicationIntakeSync({ userId: nextUserId() }); + await drainFlush(); expect(rawSendMock).toHaveBeenCalledTimes(1); expect(rawSendMock.mock.calls[0][0].pushType).toBe("background"); }); }); -describe("dispatchMedicationIntakeSync — gating", () => { +describe("queueMedicationIntakeSync — gating + failure isolation", () => { it("no-ops silently when APNs is not configured", async () => { loadConfigMock.mockReturnValue(null); - await dispatchMedicationIntakeSync({ - userId: "u1", - medicationId: "m1", - scheduledFor: "2026-06-14T07:00:00.000Z", - }); + queueMedicationIntakeSync({ userId: nextUserId() }); + await drainFlush(); expect(deviceFindMany).not.toHaveBeenCalled(); expect(rawSendMock).not.toHaveBeenCalled(); @@ -228,11 +345,8 @@ describe("dispatchMedicationIntakeSync — gating", () => { preferences: [{ enabled: false }], }); - await dispatchMedicationIntakeSync({ - userId: "u1", - medicationId: "m1", - scheduledFor: "2026-06-14T07:00:00.000Z", - }); + queueMedicationIntakeSync({ userId: nextUserId() }); + await drainFlush(); expect(deviceFindMany).not.toHaveBeenCalled(); expect(rawSendMock).not.toHaveBeenCalled(); @@ -247,24 +361,15 @@ describe("dispatchMedicationIntakeSync — gating", () => { it("suppresses when the APNS channel row itself is disabled", async () => { channelFindUnique.mockResolvedValue({ enabled: false, preferences: [] }); - await dispatchMedicationIntakeSync({ - userId: "u1", - medicationId: "m1", - scheduledFor: "2026-06-14T07:00:00.000Z", - }); + queueMedicationIntakeSync({ userId: nextUserId() }); + await drainFlush(); expect(rawSendMock).not.toHaveBeenCalled(); }); it("reaps devices APNs reports as permanently dead", async () => { deviceFindMany.mockResolvedValue([ - { - id: "d-dead", - token: "tok-1", - apnsToken: "aaaa1111", - apnsEnvironment: "sandbox", - liveActivityPushToken: null, - }, + device({ id: "d-dead", token: "tok-1", apnsToken: "aaaa1111" }), ]); rawSendMock.mockResolvedValue({ ok: false, @@ -272,11 +377,8 @@ describe("dispatchMedicationIntakeSync — gating", () => { shouldDisable: true, }); - await dispatchMedicationIntakeSync({ - userId: "u1", - medicationId: "m1", - scheduledFor: "2026-06-14T07:00:00.000Z", - }); + queueMedicationIntakeSync({ userId: nextUserId() }); + await drainFlush(); expect(deviceDeleteMany).toHaveBeenCalledWith({ where: { id: { in: ["d-dead"] } }, @@ -285,42 +387,18 @@ describe("dispatchMedicationIntakeSync — gating", () => { expect.objectContaining({ result: "error" }), ); }); -}); -describe("dispatchMedicationIntakeSyncBulk — slot de-dup", () => { - it("fires one silent push per device per DISTINCT slot, not per row", async () => { - deviceFindMany.mockResolvedValue([ - { - id: "d-other", - token: "other-token", - apnsToken: "bbbb2222", - apnsEnvironment: "sandbox", - liveActivityPushToken: null, - }, - ]); + it("never throws into the caller when the flush blows up", async () => { + channelFindUnique.mockRejectedValue(new Error("db down")); - await dispatchMedicationIntakeSyncBulk({ - userId: "u1", - originDeviceToken: "origin-token", - slots: [ - { medicationId: "m1", scheduledFor: "2026-06-14T07:00:00.000Z" }, - // duplicate of the first — must collapse - { medicationId: "m1", scheduledFor: "2026-06-14T07:00:00.000Z" }, - { medicationId: "m1", scheduledFor: "2026-06-14T19:00:00.000Z" }, - { medicationId: "m2", scheduledFor: "2026-06-14T07:00:00.000Z" }, - ], - }); + expect(() => + queueMedicationIntakeSync({ userId: nextUserId() }), + ).not.toThrow(); + await drainFlush(); - // 3 distinct slots × 1 recipient device = 3 silent pushes. - expect(rawSendMock).toHaveBeenCalledTimes(3); - const slots = rawSendMock.mock.calls.map((c) => [ - c[0].payload.medicationId, - c[0].payload.scheduledFor, - ]); - expect(slots).toEqual([ - ["m1", "2026-06-14T07:00:00.000Z"], - ["m1", "2026-06-14T19:00:00.000Z"], - ["m2", "2026-06-14T07:00:00.000Z"], - ]); + expect(rawSendMock).not.toHaveBeenCalled(); + expect(addWarningMock).toHaveBeenCalledWith( + expect.stringContaining("medication_intake_sync_dispatch_failed"), + ); }); }); diff --git a/src/lib/notifications/medication-intake-sync.ts b/src/lib/notifications/medication-intake-sync.ts index 88b879eaa..0950fea7d 100644 --- a/src/lib/notifications/medication-intake-sync.ts +++ b/src/lib/notifications/medication-intake-sync.ts @@ -1,40 +1,64 @@ /** * v1.17.1 (#22) — server-authoritative medication-intake cross-device sync. * - * When a dose is logged / skipped / snoozed on ONE device, the user's - * OTHER iOS devices need to reconcile: end or update the running Live - * Activity, refresh the Home-Screen widget, drop the local reminder. The - * server is the single source of truth for the intake state, so it owns - * the fan-out. This module dispatches two APNs pushes per affected device: + * When intake state changes on ONE device — a dose logged, edited, + * undone, imported or purged — the user's OTHER iOS devices need to + * reconcile: end or update the running Live Activity, refresh the + * Home-Screen widget, drop the local reminder. The server is the single + * source of truth for the intake state, so it owns the fan-out. Per + * flush it dispatches two APNs pushes per affected device: * * 1. A SILENT background push (`apns-push-type: background`, * `content-available: 1`, no alert) carrying - * `{ eventType: "MEDICATION_INTAKE_SYNC", medicationId, scheduledFor }`. - * The iOS `NotificationService` consumes it and runs - * `BackgroundSyncCoordinator.runMedicationReconcile`. + * `{ eventType: "MEDICATION_INTAKE_SYNC", syncedAt }`. The iOS + * `NotificationService` consumes it and runs + * `BackgroundSyncCoordinator.runMedicationReconcile`, which + * re-reads the full intake state from the API. The payload is a + * pure sync trigger — it carries NO health data (no medication + * ids, names or dose instants); the reconcile fetches everything + * it needs over the authenticated channel. * - * 2. A Live Activity end/update push (`apns-push-type: liveactivity`, - * topic `.push-type.liveactivity`) to any device that has a + * 2. A Live Activity end push (`apns-push-type: liveactivity`, topic + * `.push-type.liveactivity`) to any device that has a * stored `liveActivityPushToken`, so the lock-screen Activity ends * immediately rather than waiting for the next background wake. * + * COALESCING. Mutations arrive in bursts — a bulk intake marking five + * doses, or a user resolving several slots back-to-back. The fan-out is + * throttled per user: the first mutation dispatches immediately (the + * felt requirement is "the other phone clears within seconds"), and + * every further mutation inside the coalescing window folds into a + * single trailing dispatch when the window closes. A burst of N + * mutations therefore costs at most two fan-outs, and a single bulk + * request costs exactly one. The window state is in-process + * (per-Node-process `Map`); with multiple app instances each instance + * throttles independently, so a burst split across instances can emit + * one fan-out per instance. That is accepted: the iOS reconcile is + * idempotent and the deployment model is a single container. + * * APNs-ONLY by design. This event has no Telegram / ntfy / Web Push * semantics (those channels have no silent-sync transport), so it never - * touches the dispatcher cascade — it talks straight to the APNs senders. + * touches the dispatcher cascade — the flush talks straight to the APNs + * senders via `fanOutIntakeSyncApns`. A future channel with a real + * silent-sync transport slots in as a sibling fan-out inside + * `flushIntakeSync`. * * Origin skip: the device that POSTed the mutation already knows the new - * state and must not be woken. The caller passes the originating device's - * registered `token` (the value the iOS client sends as `X-Device-Id`); - * the matching `Device` row is excluded from both fan-outs. A web caller — - * or a token with no matching row — skips nothing, which is harmless - * (the iOS reconcile is idempotent). + * state and must not be woken. The caller passes the originating + * device's registered `token` (the value the iOS client sends as + * `X-Device-Id`); the matching `Device` row is excluded from the + * fan-out. A web caller — or a token with no matching row — skips + * nothing, which is harmless (the iOS reconcile is idempotent). When a + * trailing flush covers mutations from MORE than one origin device, no + * device is excluded: each origin still has to learn about the other's + * change. * * Best-effort: every failure is swallowed + annotated. A sync-push miss * must never fail the intake mutation that triggered it; the canonical * row is already persisted. */ import { prisma } from "@/lib/db"; -import { getEvent } from "@/lib/logging/context"; +import { annotate, getEvent } from "@/lib/logging/context"; import { loadApnsConfig, sendApnsRawPush, @@ -44,11 +68,15 @@ import { EVENT_DEFAULT_ENABLED } from "@/lib/notifications/types"; const EVENT_TYPE = "MEDICATION_INTAKE_SYNC"; -export interface IntakeSyncInput { +/** + * Trailing-coalesce window. Long enough to fold a rapid-fire burst of + * single-slot mutations into one trailing fan-out, short enough that + * the second device still reconciles "within seconds". + */ +const COALESCE_WINDOW_MS = 2_000; + +export interface IntakeSyncRequest { userId: string; - medicationId: string; - /** ISO-8601 scheduled-for instant of the affected dose slot. */ - scheduledFor: string; /** * The originating device's registered `Device.token` (sent by the iOS * client as the `X-Device-Id` header). The matching device is excluded @@ -57,6 +85,70 @@ export interface IntakeSyncInput { originDeviceToken?: string | null; } +interface CoalesceWindow { + /** Mutations that arrived AFTER the leading dispatch. */ + queued: number; + /** Distinct non-null origin tokens among the queued mutations. */ + originTokens: Set; + /** Whether any queued mutation had no origin token (web caller). */ + sawNullOrigin: boolean; +} + +const openWindows = new Map(); + +/** + * Queue a cross-device intake-sync fan-out for the user. Synchronous and + * non-throwing — the actual dispatch is fire-and-forget, so a push + * failure can never fail the intake write that triggered it. + * + * First call per user opens a coalescing window and dispatches + * immediately; further calls inside the window fold into one trailing + * dispatch when it closes. + */ +export function queueMedicationIntakeSync(input: IntakeSyncRequest): void { + const existing = openWindows.get(input.userId); + if (existing) { + existing.queued += 1; + if (input.originDeviceToken) { + existing.originTokens.add(input.originDeviceToken); + } else { + existing.sawNullOrigin = true; + } + return; + } + + const window: CoalesceWindow = { + queued: 0, + originTokens: new Set(), + sawNullOrigin: false, + }; + openWindows.set(input.userId, window); + + const timer = setTimeout(() => { + openWindows.delete(input.userId); + if (window.queued === 0) return; + // Exclude an origin only when every queued mutation came from the + // SAME device; a mixed-origin burst wakes everyone. + const origin = + !window.sawNullOrigin && window.originTokens.size === 1 + ? [...window.originTokens][0] + : null; + void flushIntakeSync(input.userId, { + coalesced: window.queued, + originDeviceToken: origin, + }); + }, COALESCE_WINDOW_MS); + // Never keep the process alive for a pending trailing flush. + timer.unref?.(); + + // Leading dispatch — immediate, so the common single-mutation case + // clears the other device at APNs latency, not after the window. + void flushIntakeSync(input.userId, { + coalesced: 0, + originDeviceToken: input.originDeviceToken ?? null, + }); +} + /** * Whether the user has explicitly opted the APNS channel out of * MEDICATION_INTAKE_SYNC. There is no second user-facing toggle — this is @@ -83,15 +175,15 @@ async function apnsSyncEnabled(userId: string): Promise { } /** - * Dispatch the silent intake-sync push (+ Live Activity push) to the - * user's other devices. De-duplicated to ONE silent push per device - * regardless of how many intake rows the triggering mutation touched — - * the caller passes a single `(medicationId, scheduledFor)` pair per - * affected slot, but a bulk call should call this once with the affected - * slot, not once per row (see `dispatchIntakeSyncBulk`). + * One fan-out: silent sync push (+ Live Activity end) to every APNs + * device except the origin. Records ONE `push_attempts` ledger row per + * flush and annotates the wide event (best-effort — the trailing flush + * runs after the triggering request closed, where the annotation is a + * no-op by construction). */ -export async function dispatchMedicationIntakeSync( - input: IntakeSyncInput, +async function flushIntakeSync( + userId: string, + opts: { coalesced: number; originDeviceToken: string | null }, ): Promise { try { const config = loadApnsConfig(); @@ -101,9 +193,9 @@ export async function dispatchMedicationIntakeSync( return; } - if (!(await apnsSyncEnabled(input.userId))) { + if (!(await apnsSyncEnabled(userId))) { recordPushAttempt({ - userId: input.userId, + userId, channel: "APNS", eventType: EVENT_TYPE, result: "skipped", @@ -113,7 +205,7 @@ export async function dispatchMedicationIntakeSync( } const devices = await prisma.device.findMany({ - where: { userId: input.userId, apnsToken: { not: null } }, + where: { userId, apnsToken: { not: null } }, select: { id: true, token: true, @@ -125,12 +217,19 @@ export async function dispatchMedicationIntakeSync( // Exclude the originating device — it already holds the new state. const recipients = devices.filter( - (d) => !input.originDeviceToken || d.token !== input.originDeviceToken, + (d) => !opts.originDeviceToken || d.token !== opts.originDeviceToken, ); + annotate({ + meta: { + medication_intake_sync_devices: recipients.length, + medication_intake_sync_coalesced: opts.coalesced, + }, + }); + if (recipients.length === 0) { recordPushAttempt({ - userId: input.userId, + userId, channel: "APNS", eventType: EVENT_TYPE, result: "skipped", @@ -139,64 +238,10 @@ export async function dispatchMedicationIntakeSync( return; } - const silentPayload = { - aps: { "content-available": 1 }, - eventType: EVENT_TYPE, - medicationId: input.medicationId, - scheduledFor: input.scheduledFor, - }; - - let anySuccess = false; - const deadDeviceIds: string[] = []; - let lastFailureReason: string | undefined; - - for (const device of recipients) { - if (!device.apnsToken) continue; - const environment: "sandbox" | "production" = - device.apnsEnvironment === "production" ? "production" : "sandbox"; - - // 1) Silent background reconcile push — one per device. - const silent = await sendApnsRawPush({ - deviceToken: device.apnsToken, - environment, - pushType: "background", - payload: silentPayload, - priority: 5, - }); - if (silent.ok) { - anySuccess = true; - } else { - lastFailureReason = silent.reason; - if (silent.shouldDisable) deadDeviceIds.push(device.id); - } - - // 2) Live Activity end/update push — only when a token is stored. - if (device.liveActivityPushToken) { - await sendLiveActivityEnd({ - token: device.liveActivityPushToken, - environment, - bundleId: config.bundleId, - medicationId: input.medicationId, - scheduledFor: input.scheduledFor, - }); - } - } - - // Reap devices APNs reported as permanently dead on the silent send. - if (deadDeviceIds.length > 0) { - await prisma.device - .deleteMany({ where: { id: { in: deadDeviceIds } } }) - .catch(() => { - /* best-effort cleanup */ - }); - } - - recordPushAttempt({ - userId: input.userId, - channel: "APNS", - eventType: EVENT_TYPE, - result: anySuccess ? "ok" : "error", - reason: anySuccess ? null : (lastFailureReason ?? "intake_sync_failed"), + await fanOutIntakeSyncApns({ + userId, + recipients, + bundleId: config.bundleId, }); } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -206,49 +251,98 @@ export async function dispatchMedicationIntakeSync( } } +interface SyncRecipient { + id: string; + token: string; + apnsToken: string | null; + apnsEnvironment: string | null; + liveActivityPushToken: string | null; +} + /** - * Bulk-call entry point: de-duplicates the affected slots so the fan-out - * fires ONE silent push per device per distinct `(medicationId, - * scheduledFor)` slot the batch touched — not one per intake row. iOS - * coalesces multiple background wakes anyway, but de-duping here keeps the - * APNs quota proportional to slots, not to row count. + * The APNs leg of the fan-out. Kept as its own unit so a future channel + * with a silent-sync transport can be added beside it without touching + * the coalescing or gating layers. */ -export async function dispatchMedicationIntakeSyncBulk(args: { +async function fanOutIntakeSyncApns(args: { userId: string; - slots: Array<{ medicationId: string; scheduledFor: string }>; - originDeviceToken?: string | null; + recipients: SyncRecipient[]; + bundleId: string; }): Promise { - const seen = new Set(); - const distinct: Array<{ medicationId: string; scheduledFor: string }> = []; - for (const slot of args.slots) { - const key = `${slot.medicationId}|${slot.scheduledFor}`; - if (seen.has(key)) continue; - seen.add(key); - distinct.push(slot); - } - for (const slot of distinct) { - await dispatchMedicationIntakeSync({ - userId: args.userId, - medicationId: slot.medicationId, - scheduledFor: slot.scheduledFor, - originDeviceToken: args.originDeviceToken, + // Pure sync trigger — deliberately NO medication id / name / dose + // instant. The iOS reconcile re-reads the full state itself, and a + // push payload travels through Apple's infrastructure, so it carries + // no health data by construction. + const silentPayload = { + aps: { "content-available": 1 }, + eventType: EVENT_TYPE, + syncedAt: new Date().toISOString(), + }; + + let anySuccess = false; + const deadDeviceIds: string[] = []; + let lastFailureReason: string | undefined; + + for (const device of args.recipients) { + if (!device.apnsToken) continue; + const environment: "sandbox" | "production" = + device.apnsEnvironment === "production" ? "production" : "sandbox"; + + // 1) Silent background reconcile push — one per device. + const silent = await sendApnsRawPush({ + deviceToken: device.apnsToken, + environment, + pushType: "background", + payload: silentPayload, + priority: 5, }); + if (silent.ok) { + anySuccess = true; + } else { + lastFailureReason = silent.reason; + if (silent.shouldDisable) deadDeviceIds.push(device.id); + } + + // 2) Live Activity end push — only when a token is stored. + if (device.liveActivityPushToken) { + await sendLiveActivityEnd({ + token: device.liveActivityPushToken, + environment, + bundleId: args.bundleId, + }); + } } + + // Reap devices APNs reported as permanently dead on the silent send. + if (deadDeviceIds.length > 0) { + await prisma.device + .deleteMany({ where: { id: { in: deadDeviceIds } } }) + .catch(() => { + /* best-effort cleanup */ + }); + } + + recordPushAttempt({ + userId: args.userId, + channel: "APNS", + eventType: EVENT_TYPE, + result: anySuccess ? "ok" : "error", + reason: anySuccess ? null : (lastFailureReason ?? "intake_sync_failed"), + }); } /** * Push an ActivityKit `end` event to a running Live Activity. The Activity * push topic is the app bundle id suffixed with `.push-type.liveactivity`; * the body carries the ActivityKit envelope (`event: "end"`, a timestamp, - * an empty content-state). Best-effort: a failure is annotated, never - * thrown. + * an empty content-state) and nothing else — the push token itself + * addresses the Activity, so no health data rides along. Best-effort: a + * failure is annotated, never thrown. */ async function sendLiveActivityEnd(args: { token: string; environment: "sandbox" | "production"; bundleId: string; - medicationId: string; - scheduledFor: string; }): Promise { try { const result = await sendApnsRawPush({ @@ -265,8 +359,6 @@ async function sendLiveActivityEnd(args: { // Dismiss the ended Activity from the lock screen promptly. "dismissal-date": Math.floor(Date.now() / 1000), }, - medicationId: args.medicationId, - scheduledFor: args.scheduledFor, }, }); if (!result.ok) { diff --git a/src/lib/notifications/web-push-clear.ts b/src/lib/notifications/web-push-clear.ts index b5c2d8534..7a3d4539d 100644 --- a/src/lib/notifications/web-push-clear.ts +++ b/src/lib/notifications/web-push-clear.ts @@ -3,7 +3,7 @@ * * A self-hoster on the installed PWA with no Apple Developer account has no * Live Activity / Home-Screen widget to reconcile when a dose is logged on - * another tab/device. The native path (`dispatchMedicationIntakeSync`) ends + * another tab/device. The native path (`queueMedicationIntakeSync`) ends * the Live Activity over APNs; this module is the PWA-only equivalent: it * pushes a `{ type: "clear", tag }` payload to the user's Web Push * subscriptions so the service worker closes the still-pending dose-due From dca4ca544e0c417849ed149c609d6852c23c9401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 10:22:06 +0200 Subject: [PATCH 2/8] feat(medications): dispatch the intake sync push from every intake mutation route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-device sync push fired only from POST /api/medications/intake and /intake/bulk. The five other routes that mutate intake state — the per-medication dose log, the intake-event edit and delete, the bulk delete, the history import and the purge — persisted the change but never woke the user's other devices, so a dose undone or edited on the web left a stale Live Activity / widget until the next foreground or background wake. Queue the same coalesced fan-out from all five after the canonical write, excluding the originating device via X-Device-Id as on the existing paths. Best-effort as everywhere else: a push miss never affects the mutation response. The import skips the queue when zero rows imported, and the bulk delete returns before the hook on a no-op match. --- .../intake/[eventId]/__tests__/route.test.ts | 16 ++++++++++++++++ .../medications/[id]/intake/[eventId]/route.ts | 18 ++++++++++++++++++ .../[id]/intake/__tests__/route.test.ts | 16 ++++++++++++++++ .../intake/bulk-delete/__tests__/route.test.ts | 14 ++++++++++++++ .../[id]/intake/bulk-delete/route.ts | 10 ++++++++++ .../[id]/intake/import/__tests__/route.test.ts | 12 ++++++++++++ .../medications/[id]/intake/import/route.ts | 11 +++++++++++ .../[id]/intake/purge/__tests__/route.test.ts | 11 +++++++++++ .../api/medications/[id]/intake/purge/route.ts | 9 +++++++++ src/app/api/medications/[id]/intake/route.ts | 9 +++++++++ 10 files changed, 126 insertions(+) diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts index 45e17af7f..d355bf403 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts @@ -53,7 +53,15 @@ vi.mock("next/headers", () => ({ })), })); +// (#22) — silent cross-device intake sync. Mocked so the route test can +// assert the hook fires on edit + undo without reaching the APNs senders +// or the coalescing timers. +vi.mock("@/lib/notifications/medication-intake-sync", () => ({ + queueMedicationIntakeSync: vi.fn(), +})); + import { PUT, DELETE } from "../route"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { auditLog } from "@/lib/auth/audit"; @@ -209,6 +217,14 @@ describe("DELETE /api/medications/[id]/intake/[eventId] — one-shot reconcile", }); const calls = vi.mocked(auditLog).mock.calls.map((c) => c[0]); expect(calls).toContain("medication.oneShot.reconciled"); + + // (#22) — the undo queues exactly one cross-device sync fan-out so + // the user's other iOS devices reconcile the restored dose state. + expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1); + expect(queueMedicationIntakeSync).toHaveBeenCalledWith({ + userId: "user-1", + originDeviceToken: null, + }); }); it("is idempotent on a non-one-shot medication", async () => { diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index c3c2a857e..c4843c102 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -17,6 +17,7 @@ import { } from "@/lib/medications/inventory/consumption"; import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { dayKeyForUserTz } from "@/lib/measurements/consolidation-tz"; import { @@ -379,6 +380,14 @@ export const PUT = apiHandler( // deactivate again. No-op for non-one-shot medications. await reconcileOneShotState(prisma, id, user.id); + // (#22) — silent cross-device intake sync: an edited dose must + // reconcile on the user's other iOS devices too. Coalesced per user, + // best-effort — never affects the response. + queueMedicationIntakeSync({ + userId: user.id, + originDeviceToken: request.headers.get("x-device-id"), + }); + return apiSuccess(updated); }, ); @@ -457,6 +466,15 @@ export const DELETE = apiHandler( // non-one-shot medications. await reconcileOneShotState(prisma, id, user.id); + // (#22) — silent cross-device intake sync: an undone dose must + // reconcile on the user's other iOS devices (the widget count and a + // pending Live Activity both change). Coalesced per user, + // best-effort — never affects the response. + queueMedicationIntakeSync({ + userId: user.id, + originDeviceToken: request.headers.get("x-device-id"), + }); + return apiSuccess({ deleted: true }); }, ); diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index b678fe833..f47647000 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -91,7 +91,15 @@ vi.mock("next/headers", () => ({ })), })); +// (#22) — silent cross-device intake sync. Mocked so the route test can +// assert the hook fires without reaching the APNs senders or the +// coalescing timers. +vi.mock("@/lib/notifications/medication-intake-sync", () => ({ + queueMedicationIntakeSync: vi.fn(), +})); + import { GET, POST } from "../route"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; @@ -363,6 +371,14 @@ describe("POST /api/medications/[id]/intake — one-shot lifecycle", () => { where: { id: "med-1", userId: "user-1", oneShot: true }, data: { active: false }, }); + + // (#22) — the logged dose queues exactly one cross-device sync + // fan-out; the web caller carries no X-Device-Id. + expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1); + expect(queueMedicationIntakeSync).toHaveBeenCalledWith({ + userId: "user-1", + originDeviceToken: null, + }); }); }); diff --git a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts index bcbf33582..381421305 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/__tests__/route.test.ts @@ -82,7 +82,15 @@ vi.mock("next/headers", () => ({ })), })); +// (#22) — silent cross-device intake sync. Mocked so the route test can +// assert the hook without reaching the APNs senders or the coalescing +// timers. +vi.mock("@/lib/notifications/medication-intake-sync", () => ({ + queueMedicationIntakeSync: vi.fn(), +})); + import { POST } from "../route"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; @@ -165,6 +173,8 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => { expect(json.data).toEqual({ deleted: 0 }); expect(prisma.medicationIntakeEvent.updateMany).not.toHaveBeenCalled(); expect(recomputeMedicationComplianceForDay).not.toHaveBeenCalled(); + // (#22) — a no-op delete queues no cross-device sync fan-out. + expect(queueMedicationIntakeSync).not.toHaveBeenCalled(); }); it("recomputes the rollup once per unique dayKey", async () => { @@ -190,6 +200,10 @@ describe("POST /api/medications/[id]/intake/bulk-delete", () => { expect(dayKeyForScheduledFor).toHaveBeenCalledTimes(3); // Three rows, two unique dayKeys → two recompute calls. expect(recomputeMedicationComplianceForDay).toHaveBeenCalledTimes(2); + + // (#22) — the whole bulk delete queues exactly ONE cross-device + // sync fan-out, not one per removed row. + expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1); }); it("soft-deletes scoped by userId + medicationId + deletedAt:null", async () => { diff --git a/src/app/api/medications/[id]/intake/bulk-delete/route.ts b/src/app/api/medications/[id]/intake/bulk-delete/route.ts index d6daf54ad..0ea7d91be 100644 --- a/src/app/api/medications/[id]/intake/bulk-delete/route.ts +++ b/src/app/api/medications/[id]/intake/bulk-delete/route.ts @@ -18,6 +18,7 @@ import { recomputeMedicationComplianceForDay, } from "@/lib/rollups/medication-compliance-rollups"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; @@ -196,6 +197,15 @@ export const POST = apiHandler( invalidateUserMedications(user.id, { evict: true }); + // (#22) — silent cross-device intake sync: removed doses change the + // widget tally and any pending Live Activity on the user's other iOS + // devices. Coalesced per user, best-effort — never affects the + // response. + queueMedicationIntakeSync({ + userId: user.id, + originDeviceToken: request.headers.get("x-device-id"), + }); + return apiSuccess({ deleted: count }); }, ); diff --git a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts index 780a4aa49..e5e3a00a2 100644 --- a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts @@ -47,7 +47,15 @@ vi.mock("next/headers", () => ({ })), })); +// (#22) — silent cross-device intake sync. Mocked so the route test can +// assert the hook without reaching the APNs senders or the coalescing +// timers. +vi.mock("@/lib/notifications/medication-intake-sync", () => ({ + queueMedicationIntakeSync: vi.fn(), +})); + import { POST } from "../route"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { consumeForIntake } from "@/lib/medications/inventory/consumption"; @@ -191,6 +199,10 @@ describe("POST /api/medications/[id]/intake/import — inventory consumption", ( expect(call?.medicationId).toBe("m1"); expect(call?.userId).toBe("user-1"); expect(call?.eventId).toBe("evt-1"); + + // (#22) — the whole import queues exactly ONE cross-device sync + // fan-out, not one per imported row. + expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1); }); it("does not consume for duplicate (already-imported) rows", async () => { diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index be0ab8373..75fa97360 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -12,6 +12,7 @@ import { import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { consumeForIntake } from "@/lib/medications/inventory/consumption"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { recomputeMedicationComplianceForDay, dayKeyForScheduledFor, @@ -206,6 +207,16 @@ export const POST = apiHandler( }, }); + // (#22) — silent cross-device intake sync: imported rows change the + // intake state the user's other iOS devices render. Coalesced per + // user, best-effort — never affects the response. + if (imported > 0) { + queueMedicationIntakeSync({ + userId: user.id, + originDeviceToken: request.headers.get("x-device-id"), + }); + } + return apiSuccess({ imported, skippedDuplicates, skippedInvalid }, 201); }, ); diff --git a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts index 3123799c9..9a4a76a03 100644 --- a/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/purge/__tests__/route.test.ts @@ -45,7 +45,15 @@ vi.mock("next/headers", () => ({ })), })); +// (#22) — silent cross-device intake sync. Mocked so the route test can +// assert the hook without reaching the APNs senders or the coalescing +// timers. +vi.mock("@/lib/notifications/medication-intake-sync", () => ({ + queueMedicationIntakeSync: vi.fn(), +})); + import { DELETE } from "../route"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; @@ -88,6 +96,9 @@ describe("DELETE /api/medications/[id]/intake/purge", () => { expect(res.status).toBe(200); const json = await res.json(); expect(json.data).toEqual({ purged: true, count: 7 }); + + // (#22) — the purge queues exactly ONE cross-device sync fan-out. + expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1); }); it("invalidates the user medication caches on success (F-1 C-3)", async () => { diff --git a/src/app/api/medications/[id]/intake/purge/route.ts b/src/app/api/medications/[id]/intake/purge/route.ts index 1988e879c..d7f342851 100644 --- a/src/app/api/medications/[id]/intake/purge/route.ts +++ b/src/app/api/medications/[id]/intake/purge/route.ts @@ -5,6 +5,7 @@ import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, getClientIp } from "@/lib/api-response"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; +import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; @@ -66,6 +67,14 @@ export const DELETE = apiHandler( // PUT medication, bulk-delete) that all fire this bundle. invalidateUserMedications(user.id, { evict: true }); + // (#22) — silent cross-device intake sync: a purged history changes + // the intake state the user's other iOS devices render. Coalesced + // per user, best-effort — never affects the response. + queueMedicationIntakeSync({ + userId: user.id, + originDeviceToken: request.headers.get("x-device-id"), + }); + return apiSuccess({ purged: true, count }); }, ); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 31bad637b..48e44bb9d 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -24,6 +24,7 @@ import { 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 { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { applyCanonicalSlotWrite, @@ -489,6 +490,14 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { invalidateUserMedications(user.id, { evict: true }); } + // (#22) — silent cross-device intake sync: wake the user's OTHER iOS + // devices so a running Live Activity / widget reconciles this dose. + // Coalesced per user, best-effort — never affects the response. + queueMedicationIntakeSync({ + userId: user.id, + originDeviceToken: request.headers.get("x-device-id"), + }); + return apiSuccess(event, 201); } From 043698a4d3cac9ddcb8fdcdf73bbb9254c0c87b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 10:22:17 +0200 Subject: [PATCH 3/8] docs(env): document IP_GEO_ALLOW_INSECURE in .env.example The opt-in shipped in v1.25.6 and is documented in .env.production.example, but the dev-facing .env.example never mentioned it. Add the commented entry with the plaintext-egress caveat. The check-env gate reads scripts/env-manifest.json, not the example files, so this is documentation only. --- .env.example | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.env.example b/.env.example index 78698a34f..2f39edac0 100644 --- a/.env.example +++ b/.env.example @@ -220,3 +220,10 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32" # Generate with `openssl rand -hex 32`. Configure the SAME value in Coolify's # Notifications → Webhook custom-headers panel. See docs/audit/v1415-auto-deploy.md. # DEPLOY_WEBHOOK_SECRET="" + + +# ----------------------------------------------------------------------------- +# IP geolocation (optional) +# ----------------------------------------------------------------------------- +# Opt-in for a plain-HTTP IP_GEO_LOOKUP_URL provider (e.g. free ip-api.com); off by default — looked-up IPs then travel unencrypted over your server's egress. +# IP_GEO_ALLOW_INSECURE="true" From 28749ec6e70464df690444ae121be22110ba9854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 10:22:17 +0200 Subject: [PATCH 4/8] docs(dashboard): describe the MED_COMPLIANCE ring as today's dose tally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scoreRings doc comment still called the ring the pooled 7-day adherence — that was the v1.27.7 first cut. Since v1.27.8 the ring is today's taken/scheduled dose progress from the snapshot's own medsToday block (see src/lib/dashboard/score-rings.ts). Comment-only change. --- src/lib/dashboard/snapshot.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 424177dc9..5840676fa 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -408,8 +408,9 @@ export interface DashboardSnapshot { /** * v1.27.7 — the user-selected hero score rings (max 3), resolved * server-side: READINESS / RECOVERY_SCORE / SLEEP_SCORE through the - * same engines the derived batch route calls, MED_COMPLIANCE as the - * pooled 7-day adherence from the canonical compliance engine. Only + * same engines the derived batch route calls, MED_COMPLIANCE as + * TODAY's dose tally (taken / scheduled, from the snapshot's own + * medsToday block — v1.27.8; not a trailing 7-day rate). Only * rings with data appear (selection order preserved); module-disabled * and data-less selections drop out, so the hero row self-gates. * Optional on the type (additive contract) so older cached snapshots From 39034afcdceccca8255d1e0cd33b2d6aa97e9d75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 10:36:33 +0200 Subject: [PATCH 5/8] fix(dashboard): stack briefing-row headline over delta on phone widths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A long signal headline beside a wide delta string squeezed the headline into a one-word-per-line column on the hero spotlight rows; at 360 px the delta pushed past the tile edge and got cut off by the band's overflow clip. Below sm the headline/delta pair now stacks — headline wraps at full row width, the delta sits beneath as a left-aligned tone-coloured line — and the side-by-side row returns at sm+. Same treatment on the /insights briefing card's shared finding/signal rows, which speak the identical row language. --- .../__tests__/briefing-spotlight.test.tsx | 30 +++++++++++++++++++ .../dashboard/hero/briefing-spotlight.tsx | 11 +++++-- .../__tests__/daily-briefing.test.tsx | 14 +++++++++ src/components/insights/daily-briefing.tsx | 10 +++++-- 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx b/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx index 2d6a6eb34..ed04df2dd 100644 --- a/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx +++ b/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx @@ -180,4 +180,34 @@ describe("", () => { const rows = html.match(/data-slot="dashboard-briefing-spotlight-row"/g); expect(rows).toHaveLength(3); }); + + it("stacks headline over delta below sm and keeps the row at sm+", () => { + const html = render({ + briefing: briefing({ + signalsOfDay: [ + { + sourceMetric: "bp", + tone: "watch", + headline: + "Systolischer Blutdruck deutlich unter deinem Monatsmittel gemessen", + nudge: "n", + delta: "−12 mmHg vs. 30-Tage-Mittel", + }, + ], + }), + briefingState: "ready", + briefingStale: false, + }); + // Phone-first column, row restored at sm+ — a wide delta must never + // squeeze the headline into a narrow multi-line column (or push + // itself past the tile edge). + expect(html).toMatch( + /class="[^"]*flex min-w-0 flex-1 flex-col gap-1 sm:flex-row[^"]*"/, + ); + // The delta is left-aligned content-width in the stacked state and + // only refuses to shrink in the sm+ row. + expect(html).toMatch( + /data-slot="dashboard-briefing-spotlight-delta"[^>]*class="[^"]*self-start[^"]*sm:shrink-0[^"]*"/, + ); + }); }); diff --git a/src/components/dashboard/hero/briefing-spotlight.tsx b/src/components/dashboard/hero/briefing-spotlight.tsx index 5954030a8..f18dfa928 100644 --- a/src/components/dashboard/hero/briefing-spotlight.tsx +++ b/src/components/dashboard/hero/briefing-spotlight.tsx @@ -196,7 +196,14 @@ export function BriefingSpotlight({ )} aria-hidden="true" /> -
+ {/* Phone widths stack headline over delta — a long + signal headline beside a long delta squeezed the + headline into a one-word-per-line column and pushed + the delta past the tile edge. At `sm+` the + side-by-side row returns. The headline always wraps + (content is never truncated); only the delta is meta + and may stay on one line. */} +

{stripChartTokens(row.headline)}

@@ -204,7 +211,7 @@ export function BriefingSpotlight({ diff --git a/src/components/insights/__tests__/daily-briefing.test.tsx b/src/components/insights/__tests__/daily-briefing.test.tsx index 3bd9bdb85..8db6630e4 100644 --- a/src/components/insights/__tests__/daily-briefing.test.tsx +++ b/src/components/insights/__tests__/daily-briefing.test.tsx @@ -102,6 +102,20 @@ describe("", () => { expect(html).toContain("+6 mmHg"); }); + it("stacks headline over delta below sm and keeps the row at sm+", () => { + const html = render(); + // Phone-first column (headline above, delta beneath), side-by-side + // restored at sm+ — a wide delta must never squeeze the headline + // into a narrow multi-line column. + expect(html).toMatch( + /class="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-3"/, + ); + // Delta badge stays content-width + left-aligned in the stacked state. + expect(html).toMatch( + /data-slot="daily-briefing-delta"[^>]*class="[^"]*self-start[^"]*sm:shrink-0[^"]*"/, + ); + }); + it("does NOT render a delta badge when delta is null", () => { const html = render( @@ -243,7 +245,11 @@ function BriefingRow({ aria-hidden="true" />
-
+ {/* Phone widths stack headline over delta (same treatment as the + dashboard spotlight rows): side-by-side squeezed a long + headline into a narrow multi-line column beside a long delta. + At `sm+` the row layout returns. */} +

{stripChartTokens(headline)}

From 29e2b44310ab71f30f915b1dcbfb2113c1659e41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 10:36:45 +0200 Subject: [PATCH 6/8] fix(insights): stack the health-score delta caption below the delta line The v1.22 inline explainer caption rendered INSIDE the delta line's inline-flex row, so the short delta text ("-2 vs last week") wrapped into a narrow four-line column beside the multi-sentence read on every viewport. The wrapper now stacks: delta line with its trend icon on top, the caption below at full width. The aria-describedby thread from the delta digit to the caption id is unchanged. --- .../__tests__/health-score-card.test.tsx | 25 ++++++++ src/components/insights/health-score-card.tsx | 59 +++++++++++-------- 2 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/components/insights/__tests__/health-score-card.test.tsx b/src/components/insights/__tests__/health-score-card.test.tsx index ef0ee82e5..0353d7719 100644 --- a/src/components/insights/__tests__/health-score-card.test.tsx +++ b/src/components/insights/__tests__/health-score-card.test.tsx @@ -157,6 +157,31 @@ describe("", () => { ); }); + it("stacks the explainer caption BELOW the delta line, not beside it", () => { + // The caption is a multi-sentence read; rendering it inside the + // delta's inline-flex row squeezed the short delta into a narrow + // multi-line column on every viewport. The wrapper stacks instead. + const html = ssr( + , + ); + const wrapper = html.match( + /data-slot="health-score-card-delta"[^>]*class="([^"]*)"/, + ); + expect(wrapper).not.toBeNull(); + expect(wrapper?.[1]).toContain("space-y-1"); + expect(wrapper?.[1]).not.toContain("inline-flex"); + // The delta line's

closes before the explainer body opens — + // the caption is a sibling below, never a flex neighbour. + expect(html).toMatch( + /vs last week[^<]*<\/span><\/p>]*data-slot="health-score-delta-explainer-body"/, + ); + }); + it("renders four component rows with their values", () => { const html = ssr( {delta === null ? ( - {t("insights.healthScore.deltaUnavailable")} +

{t("insights.healthScore.deltaUnavailable")}

) : ( <> - {delta > 0 && ( -
{/* v1.18.6 — Rest Mode legibility. While an illness episode is active the server suppresses (never penalises) the score, so From 714aa0f2c080cd9dd097c9659ea0ac1b2c7073f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 10:36:57 +0200 Subject: [PATCH 7/8] test(e2e): guard phone widths against overflow and tile escapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New density guard at 390x844 and 360x780 on the dashboard and /insights: page scrollWidth never exceeds the viewport, and no descendant's visible box crosses its card / hero / list-row edge (clip-aware below the tile; content cut off at the tile's own edge counts as an escape). The snapshot mock gains hero fixtures — layout heroVisible, three score rings, and a briefing whose signal rows pair long wrapping headlines with wide delta strings — so the guard exercises the exact shape that regressed, and asserts the spotlight rows actually render rather than passing vacuously. --- e2e/mobile-density.spec.ts | 155 +++++++++++++++++++++++++++ e2e/utils/mock-dashboard-snapshot.ts | 71 +++++++++++- 2 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 e2e/mobile-density.spec.ts diff --git a/e2e/mobile-density.spec.ts b/e2e/mobile-density.spec.ts new file mode 100644 index 000000000..5478a0919 --- /dev/null +++ b/e2e/mobile-density.spec.ts @@ -0,0 +1,155 @@ +import { expect, test } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + LONG_HEADLINE_BRIEFING, + MOCK_SCORE_RINGS, + mockDashboardSnapshot, + POPULATED_SUMMARIES, +} from "./utils/mock-dashboard-snapshot"; + +/** + * Phone-width density guard (390×844 iPhone class, 360×780 small + * Android class). + * + * Two invariants, per the report against the dashboard hero (a long + * briefing-signal headline squeezed into a one-word-per-line column + * beside a wide delta, the delta spilling past the tile edge at 360 px): + * + * 1. No horizontal page overflow — `scrollWidth` never exceeds the + * viewport width (1 px sub-pixel tolerance). + * 2. Nothing escapes its tile — for every card / hero band / list + * row, no descendant's visible box crosses the tile's left or + * right edge (clip-aware: content genuinely hidden by an inner + * `overflow-hidden` wrapper below the tile is fine; content cut + * off at the tile's own edge is the bug). + * + * The snapshot mock pins the worst case: hero band on, three score + * rings, and a fresh briefing whose signal rows pair wrapping German + * headlines with wide delta strings. + * + * Desktop project only — the assertions are viewport-driven via + * `setViewportSize` (the `ipad-viewport.spec.ts` pattern). + */ +const VIEWPORTS = [ + { width: 390, height: 844 }, + { width: 360, height: 780 }, +] as const; + +const ROUTES = ["/", "/insights"] as const; + +interface TileEscape { + tile: string; + child: string; + overRight: number; + overLeft: number; + text: string; +} + +/** Serialised into the page: measure children escaping their tile. */ +function collectTileEscapes(): TileEscape[] { + const escapes: TileEscape[] = []; + const tiles = document.querySelectorAll( + '[data-slot="card"], [data-slot="dashboard-hero"], [data-slot="list-row"]', + ); + const TOLERANCE = 1; + for (const tile of tiles) { + const tileRect = tile.getBoundingClientRect(); + if (tileRect.width === 0) continue; + for (const child of tile.querySelectorAll("*")) { + const rect = child.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) continue; + if (getComputedStyle(child).position === "fixed") continue; + // Clamp by clipping ancestors BELOW the tile only — the tile's + // own overflow-hidden cutting content off is exactly the bug. + let left = rect.left; + let right = rect.right; + let ancestor = child.parentElement; + while (ancestor && ancestor !== tile) { + if (getComputedStyle(ancestor).overflowX !== "visible") { + const ancestorRect = ancestor.getBoundingClientRect(); + left = Math.max(left, ancestorRect.left); + right = Math.min(right, ancestorRect.right); + } + ancestor = ancestor.parentElement; + } + if (right <= left) continue; + const overRight = right - tileRect.right; + const overLeft = tileRect.left - left; + if (overRight > TOLERANCE || overLeft > TOLERANCE) { + escapes.push({ + tile: tile.getAttribute("data-slot") ?? tile.tagName, + child: + child.tagName.toLowerCase() + + (child.getAttribute("data-slot") + ? `[${child.getAttribute("data-slot")}]` + : ""), + overRight: Math.round(overRight * 10) / 10, + overLeft: Math.round(overLeft * 10) / 10, + text: (child.textContent ?? "").trim().slice(0, 60), + }); + } + } + } + return escapes; +} + +test.describe("phone-width density guard", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(({}, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "viewport-driven spec; desktop project only", + ); + }); + + for (const viewport of VIEWPORTS) { + for (const route of ROUTES) { + test(`no overflow and no tile escape on ${route} at ${viewport.width}x${viewport.height}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + await mockDashboardSnapshot(page, { + summaries: POPULATED_SUMMARIES, + heroVisible: true, + briefing: LONG_HEADLINE_BRIEFING, + scoreRings: MOCK_SCORE_RINGS, + }); + await page.goto(route, { waitUntil: "domcontentloaded" }); + await page.waitForLoadState("networkidle"); + + if (route === "/") { + // The guard must not pass vacuously — the hero band and its + // long-headline spotlight rows have to actually render. + await expect( + page.locator('[data-slot="dashboard-hero"]'), + ).toBeVisible(); + await expect( + page.locator('[data-slot="dashboard-briefing-spotlight-row"]'), + ).toHaveCount(3); + } + + const dims = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + bodyScrollWidth: document.body.scrollWidth, + innerWidth: window.innerWidth, + })); + expect( + dims.scrollWidth, + `page scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`, + ).toBeLessThanOrEqual(dims.innerWidth + 1); + expect( + dims.bodyScrollWidth, + `body scrollWidth=${dims.bodyScrollWidth}`, + ).toBeLessThanOrEqual(dims.innerWidth + 1); + + const escapes = await page.evaluate(collectTileEscapes); + expect( + escapes, + `tile escapes: ${JSON.stringify(escapes, null, 2)}`, + ).toEqual([]); + }); + } + } +}); diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index e1f626569..08a58c947 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -4,6 +4,8 @@ import { DASHBOARD_WIDGET_CATALOGUE_IDS, DEFAULT_DASHBOARD_LAYOUT, } from "@/lib/dashboard-layout"; +import type { DailyBriefing } from "@/lib/ai/schema"; +import type { DashboardScoreRing } from "@/lib/dashboard/score-rings"; import type { DataSummary } from "@/lib/analytics/trends"; import type { DashboardSnapshot, @@ -172,6 +174,54 @@ function buildLayoutCatalogue(): DashboardLayoutCatalogueEntry[] { })); } +/** + * Long-headline briefing fixture for the hero density guard: every + * signal pairs a wrapping German headline with a wide delta string — + * the exact shape that squeezed the spotlight rows on phone widths. + */ +export const LONG_HEADLINE_BRIEFING: DailyBriefing = { + paragraph: + "Dein systolischer Blutdruck liegt heute deutlich unter deinem Monatsmittel, während die Schlafdauer stabil bleibt.", + signalsOfDay: [ + { + sourceMetric: "bp", + tone: "watch", + headline: + "Systolischer Blutdruck deutlich unter deinem Monatsmittel gemessen", + nudge: "Miss heute Abend erneut, um den Wert einzuordnen.", + delta: "−12 mmHg vs. 30-Tage-Mittel", + }, + { + sourceMetric: "sleep", + tone: "info", + headline: + "Schlafdauer in den letzten sieben Nächten etwa eine Stunde kürzer als üblich", + nudge: "Plane heute eine frühere Nachtruhe ein.", + delta: "−58 min", + }, + { + sourceMetric: "compliance", + tone: "good", + headline: "Alle geplanten Dosen der Woche bisher vollständig eingenommen", + nudge: "Weiter so — die Abenddosis steht um 20:00 an.", + delta: "7/7 Tage", + }, + ], + keyFindings: [], +}; + +/** Three-ring hero row fixture (max the hero renders beside the score). */ +export const MOCK_SCORE_RINGS: DashboardScoreRing[] = [ + { id: "READINESS", score: 82, band: "green" }, + { id: "RECOVERY_SCORE", score: 74, band: "yellow" }, + { + id: "MED_COMPLIANCE", + score: 33, + band: "yellow", + doses: { taken: 1, scheduled: 3 }, + }, +]; + export interface MockSnapshotOptions { /** * Tile summaries keyed by `MeasurementType`. Defaults to the @@ -181,6 +231,12 @@ export interface MockSnapshotOptions { summaries?: Record; /** Artificial latency in ms before the route fulfils (race specs). */ delayMs?: number; + /** Render the dashboard hero band (`layout.heroVisible` opt-in). */ + heroVisible?: boolean; + /** Fresh (`ready`, non-stale) briefing for the hero spotlight rows. */ + briefing?: DailyBriefing; + /** Selected hero score rings (renders beside the health-score ring). */ + scoreRings?: DashboardScoreRing[]; } /** Assemble a fully-typed snapshot body from the requested tile data. */ @@ -207,7 +263,9 @@ export function buildMockSnapshot( onboardingTourCompleted: true, greetingHour: 9, }, - layout: DEFAULT_DASHBOARD_LAYOUT, + layout: options.heroVisible + ? { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true } + : DEFAULT_DASHBOARD_LAYOUT, layoutCatalogue: buildLayoutCatalogue(), metricStates: {}, targetBands: mockTargetBands(), @@ -226,10 +284,13 @@ export function buildMockSnapshot( nextDueOverdue: false, nextDueMedicationName: null, }, - healthScore: null, - briefing: null, - briefingState: "preparing", - briefingUpdatedAt: null, + healthScore: options.briefing + ? { score: 84, band: "green", delta: -2, restMode: null } + : null, + scoreRings: options.scoreRings, + briefing: options.briefing ?? null, + briefingState: options.briefing ? "ready" : "preparing", + briefingUpdatedAt: options.briefing ? now : null, briefingStale: false, generatedAt: now, }; From 456a547ada47000246202d12e49a4b0bc89c11c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 10:41:52 +0200 Subject: [PATCH 8/8] =?UTF-8?q?chore(release):=20v1.27.14=20=E2=80=94=20cr?= =?UTF-8?q?oss-device=20dose=20sync=20and=20phone-width=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba9bb892..041f1163b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +## [1.27.14] — 2026-07-07 — Cross-device dose sync and phone-width polish + +### Fixed + +- Marking a dose on one device now wakes the others within seconds: every intake mutation — logging, editing, undoing, bulk actions, imports — dispatches a silent sync push to the user's other iPhones, so a lock-screen Live Activity no longer keeps counting down after the dose was taken on the web. Rapid mutations coalesce into one ping (a five-dose bulk action sends one push, not five), the originating device is skipped, and the payload carries no health data — previously it leaked the medication id, and five of the seven mutation routes sent nothing at all. +- On phone widths, the dashboard's briefing-signal rows and the health-score delta stack vertically instead of squeezing a wrapping headline beside its value — a signal headline measured seven lines tall at a third of the tile width before, and on narrow phones the delta spilled past the tile edge. A permanent guard test now pins zero horizontal overflow and no element escaping its tile on the dashboard and insights at 390 px and 360 px widths. + ## [1.27.13] — 2026-07-07 — Assessments that interpret ### Changed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 138d1ae88..64aabfd54 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.27.13 + version: 1.27.14 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 4b0145bdc..b656f4123 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.27.13", + "version": "1.27.14", "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 43d6d908b..a0331e915 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.27.13"; + /* @sw-version-fallback */ "v1.27.14"; 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/*`