Merge pull request #424 from MBombeck/release/v1.27.14

Release v1.27.14 — cross-device dose sync and phone-width polish
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-07 10:52:02 +02:00
commit 4b466be1d2
31 files changed
+1000 -321

No files matched your search

+7
View File
@@ -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"
+7
View File
@@ -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
+1 -1
View File
@@ -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.
+155
View File
@@ -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([]);
});
}
}
});
+66 -5
View File
@@ -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<string, DataSummary>;
/** 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,
};
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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 () => {
@@ -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 });
},
);
@@ -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,
});
});
});
@@ -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 () => {
@@ -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 });
},
);
@@ -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 () => {
@@ -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);
},
);
@@ -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 () => {
@@ -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 });
},
);
@@ -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);
}
@@ -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",
});
});
@@ -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.
+7 -7
View File
@@ -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<Response> {
}
}
// 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"),
});
+3 -5
View File
@@ -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"),
});
@@ -180,4 +180,34 @@ describe("<BriefingSpotlight>", () => {
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[^"]*"/,
);
});
});
@@ -196,7 +196,14 @@ export function BriefingSpotlight({
)}
aria-hidden="true"
/>
<div className="flex min-w-0 flex-1 items-start justify-between gap-3">
{/* 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. */}
<div className="flex min-w-0 flex-1 flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<p className="text-foreground text-sm leading-snug font-medium">
{stripChartTokens(row.headline)}
</p>
@@ -204,7 +211,7 @@ export function BriefingSpotlight({
<span
data-slot="dashboard-briefing-spotlight-delta"
className={cn(
"shrink-0 text-xs font-semibold tabular-nums",
"self-start text-xs font-semibold tabular-nums sm:shrink-0",
TONE_TEXT_CLASSNAME[row.tone],
)}
>
@@ -102,6 +102,20 @@ describe("<DailyBriefing>", () => {
expect(html).toContain("+6 mmHg");
});
it("stacks headline over delta below sm and keeps the row at sm+", () => {
const html = render(<DailyBriefing briefing={baseBriefing} />);
// 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(
<DailyBriefing
@@ -157,6 +157,31 @@ describe("<HealthScoreCard>", () => {
);
});
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(
<HealthScoreCard
score={64}
band="yellow"
components={baseComponents}
delta={-3}
/>,
);
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 <p> closes before the explainer body opens —
// the caption is a sibling below, never a flex neighbour.
expect(html).toMatch(
/vs last week[^<]*<\/span><\/p><span[^>]*data-slot="health-score-delta-explainer-body"/,
);
});
it("renders four component rows with their values", () => {
const html = ssr(
<HealthScoreCard
+8 -2
View File
@@ -197,7 +197,9 @@ function DeltaBadge({
<span
data-slot="daily-briefing-delta"
className={cn(
"text-xs font-semibold tabular-nums",
// `self-start` keeps the badge left-aligned + content-width when
// the parent row stacks into a column below `sm`.
"self-start text-xs font-semibold tabular-nums sm:shrink-0",
TONE_TEXT_CLASSNAME[tone],
)}
>
@@ -243,7 +245,11 @@ function BriefingRow({
aria-hidden="true"
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-start justify-between gap-3">
{/* 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. */}
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<p className="text-sm leading-snug font-medium">
{stripChartTokens(headline)}
</p>
+35 -24
View File
@@ -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. */}
<p
{/* The delta digit and the explainer caption stack — the caption
is a multi-sentence read, and rendering it INSIDE the delta's
inline-flex row squeezed the short delta line into a narrow
multi-line column beside it on every viewport. The delta line
keeps its icon row; the caption sits below at full width. */}
<div
data-slot="health-score-card-delta"
className="text-muted-foreground inline-flex items-center gap-1 text-xs"
className="text-muted-foreground space-y-1 text-xs"
>
{delta === null ? (
<span>{t("insights.healthScore.deltaUnavailable")}</span>
<p>{t("insights.healthScore.deltaUnavailable")}</p>
) : (
<>
{delta > 0 && (
<ArrowUp className="text-success h-3 w-3" aria-hidden="true" />
)}
{delta < 0 && (
<ArrowDown
className="text-destructive h-3 w-3"
aria-hidden="true"
/>
)}
{delta === 0 && (
<Minus
className="text-muted-foreground h-3 w-3"
aria-hidden="true"
/>
)}
<span aria-describedby={deltaExplainerId}>
{t("insights.healthScore.deltaVsLastWeek", {
delta: delta > 0 ? `+${delta}` : `${delta}`,
})}
</span>
<p className="flex items-center gap-1">
{delta > 0 && (
<ArrowUp
className="text-success h-3 w-3"
aria-hidden="true"
/>
)}
{delta < 0 && (
<ArrowDown
className="text-destructive h-3 w-3"
aria-hidden="true"
/>
)}
{delta === 0 && (
<Minus
className="text-muted-foreground h-3 w-3"
aria-hidden="true"
/>
)}
<span aria-describedby={deltaExplainerId}>
{t("insights.healthScore.deltaVsLastWeek", {
delta: delta > 0 ? `+${delta}` : `${delta}`,
})}
</span>
</p>
<HealthScoreDeltaExplainer
className="block"
delta={delta}
bodyId={deltaExplainerId}
/>
</>
)}
</p>
</div>
{/* v1.18.6 — Rest Mode legibility. While an illness episode is
active the server suppresses (never penalises) the score, so
+3 -2
View File
@@ -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
@@ -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<void> {
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<string, unknown> = {}) {
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"),
);
});
});
+212 -120
View File
@@ -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 `<bundle>.push-type.liveactivity`) to any device that has a
* 2. A Live Activity end push (`apns-push-type: liveactivity`, topic
* `<bundle>.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<string>;
/** Whether any queued mutation had no origin token (web caller). */
sawNullOrigin: boolean;
}
const openWindows = new Map<string, CoalesceWindow>();
/**
* 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<boolean> {
}
/**
* 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<void> {
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<void> {
const seen = new Set<string>();
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<void> {
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) {
+1 -1
View File
@@ -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