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" 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/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, }; 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/*` 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); } 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/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( ", () => { ); }); + 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( @@ -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)}

diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 1dc4a637b..1ecff0abd 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -407,41 +407,52 @@ export function HealthScoreCard({ on phone-class viewports. The explainer only mounts when a numeric delta is available; the "no history yet" branch keeps the existing single-line caption. */} -

{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 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 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