mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
31 files changed
+1000
-321
No files matched your search
@@ -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"
|
||||
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user