Merge w11/backlog into release/v1.18.11

This commit is contained in:
Marc-André Bombeck committed 2026-06-21 00:54:53 +02:00
commit 5859aaf46f
16 files changed
+1071 -4

No files matched your search

+4
View File
@@ -301,6 +301,10 @@
"viewSleep": "Schlaf ansehen",
"logMeasurement": "Messung erfassen",
"viewInsights": "Insights öffnen"
},
"briefing": {
"title": "Tagesbriefing",
"viewAll": "Insights öffnen"
}
},
"greetingSalutation": "Hallo, {name}",
+4
View File
@@ -301,6 +301,10 @@
"viewSleep": "View sleep",
"logMeasurement": "Log measurement",
"viewInsights": "Open Insights"
},
"briefing": {
"title": "Today’s briefing",
"viewAll": "Open Insights"
}
},
"greetingSalutation": "Hi, {name}",
+4
View File
@@ -301,6 +301,10 @@
"viewSleep": "Ver sueño",
"logMeasurement": "Registrar medición",
"viewInsights": "Abrir Insights"
},
"briefing": {
"title": "Resumen de hoy",
"viewAll": "Abrir Insights"
}
},
"greetingSalutation": "Hola, {name}",
+4
View File
@@ -301,6 +301,10 @@
"viewSleep": "Voir le sommeil",
"logMeasurement": "Saisir une mesure",
"viewInsights": "Ouvrir Insights"
},
"briefing": {
"title": "Briefing du jour",
"viewAll": "Ouvrir Insights"
}
},
"greetingSalutation": "Bonjour, {name}",
+4
View File
@@ -301,6 +301,10 @@
"viewSleep": "Vedi sonno",
"logMeasurement": "Registra misurazione",
"viewInsights": "Apri Insights"
},
"briefing": {
"title": "Briefing di oggi",
"viewAll": "Apri Insights"
}
},
"greetingSalutation": "Ciao, {name}",
+4
View File
@@ -301,6 +301,10 @@
"viewSleep": "Zobacz sen",
"logMeasurement": "Zapisz pomiar",
"viewInsights": "Otwórz Insights"
},
"briefing": {
"title": "Briefing dnia",
"viewAll": "Otwórz Insights"
}
},
"greetingSalutation": "Cześć, {name}",
@@ -0,0 +1,180 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { DailyBriefing } from "@/lib/ai/schema";
import { BriefingSpotlight } from "../briefing-spotlight";
/**
* `<BriefingSpotlight>` contract:
*
* - renders nothing unless the briefing is FRESH (`ready` + not stale)
* and carries at least one signal or finding;
* - prefers `signalsOfDay`, falling back to `keyFindings`;
* - caps the strip at 3 rows;
* - every row links to `/insights` and carries the tone-keyed accent;
* - headlines render as plain text children (no HTML / markdown).
*/
function render(
props: React.ComponentProps<typeof BriefingSpotlight>,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<BriefingSpotlight {...props} />
</I18nProvider>,
);
}
function briefing(overrides: Partial<DailyBriefing> = {}): DailyBriefing {
return {
paragraph: "x",
signalsOfDay: null,
keyFindings: [],
...overrides,
};
}
describe("<BriefingSpotlight>", () => {
it("renders nothing when no briefing is present", () => {
expect(
render({
briefing: null,
briefingState: "ready",
briefingStale: false,
}),
).toBe("");
});
it("renders nothing for a stale briefing", () => {
expect(
render({
briefing: briefing({
keyFindings: [
{
tone: "watch",
headline: "BP creeping up",
detail: "d",
delta: "+6 mmHg",
sourceWindow: "30d",
sourceMetric: "bp",
},
],
}),
briefingState: "ready",
briefingStale: true,
}),
).toBe("");
});
it("renders nothing while the briefing is still preparing", () => {
expect(
render({
briefing: briefing({
keyFindings: [
{
tone: "info",
headline: "h",
detail: "d",
delta: null,
sourceWindow: "30d",
sourceMetric: "weight",
},
],
}),
briefingState: "preparing",
briefingStale: false,
}),
).toBe("");
});
it("surfaces the present-focused signals and links each row to /insights", () => {
const html = render({
briefing: briefing({
signalsOfDay: [
{
sourceMetric: "sleep",
tone: "watch",
headline: "Short on sleep this week",
nudge: "Aim for an earlier night",
delta: "-1.2 h",
},
],
keyFindings: [
{
tone: "good",
headline: "should not appear when signals exist",
detail: "d",
delta: null,
sourceWindow: "30d",
sourceMetric: "bp",
},
],
}),
briefingState: "ready",
briefingStale: false,
});
expect(html).toContain('data-slot="dashboard-briefing-spotlight"');
expect(html).toContain("Short on sleep this week");
expect(html).toContain('href="/insights"');
expect(html).toContain("-1.2 h");
// Signals win over key findings when present.
expect(html).not.toContain("should not appear when signals exist");
});
it("falls back to key findings when no signals were generated", () => {
const html = render({
briefing: briefing({
signalsOfDay: [],
keyFindings: [
{
tone: "watch",
headline: "Blood pressure trending up",
detail: "d",
delta: "+8 mmHg",
sourceWindow: "30d",
sourceMetric: "bp",
},
],
}),
briefingState: "ready",
briefingStale: false,
});
expect(html).toContain("Blood pressure trending up");
});
it("caps the strip at three rows", () => {
const html = render({
briefing: briefing({
signalsOfDay: [
{
sourceMetric: "bp",
tone: "info",
headline: "one",
nudge: "n",
delta: null,
},
{
sourceMetric: "weight",
tone: "info",
headline: "two",
nudge: "n",
delta: null,
},
{
sourceMetric: "mood",
tone: "info",
headline: "three",
nudge: "n",
delta: null,
},
],
}),
briefingState: "ready",
briefingStale: false,
});
const rows = html.match(/data-slot="dashboard-briefing-spotlight-row"/g);
expect(rows).toHaveLength(3);
});
});
@@ -0,0 +1,219 @@
"use client";
/**
* v1.18.11 — dashboard briefing spotlight.
*
* The daily briefing previously surfaced on the dashboard ONLY as the
* hero's rung-8 verdict — a single sentence that almost always lost the
* verdict slot to a routine state (an upcoming dose, a weight drift, the
* all-quiet fallback). A fresh, model-authored briefing was effectively
* invisible above the fold.
*
* This strip raises the briefing's prominence WITHOUT touching its
* content or generation: it lifts the briefing's own present-focused
* "Signals of the day" (falling back to the longer-horizon key findings)
* straight onto the dashboard hero, so the freshest read is always one
* glance away. The verdict ladder is unchanged — a real BP crisis still
* leads the verdict line; this band sits below it.
*
* Design contract:
* - reuses the established tone-bar + metric-icon row language from the
* `/insights` daily-briefing card so the two surfaces read identically;
* - self-gating — renders nothing unless a FRESH briefing (`ready`, not
* stale) carries at least one signal or finding, so a missing /
* stale / disabled briefing never leaves an empty band;
* - rows are tappable, routing to `/insights` for the full briefing;
* - plain text children only (no HTML / markdown) per the repo-wide
* no-markdown-renderer rule; headlines pass through `stripChartTokens`
* to defend against a stray chart token leaking into the prose.
*
* Pure presentational: the caller resolves the briefing from the
* server-authoritative snapshot and passes the already-validated payload.
*/
import Link from "next/link";
import {
Activity,
Flame,
Footprints,
Heart,
HeartPulse,
Mountain,
Moon,
Pill,
Route,
Scale,
Smile,
Sparkles,
Thermometer,
Wind,
Zap,
} from "lucide-react";
import { ListRow } from "@/components/ui/list-row";
import { useTranslations } from "@/lib/i18n/context";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { cn } from "@/lib/utils";
import type { DailyBriefing, DailyBriefingKeyFinding } from "@/lib/ai/schema";
/**
* Max rows the spotlight surfaces. The briefing caps `signalsOfDay` at 3
* and `keyFindings` at 5; the dashboard strip stays a glance, so it shows
* at most the top 3 — the user opens `/insights` for the full list.
*/
const MAX_SPOTLIGHT_ROWS = 3;
const METRIC_ICON: Record<
DailyBriefingKeyFinding["sourceMetric"],
React.ComponentType<{ className?: string }>
> = {
bp: Heart,
weight: Scale,
pulse: Activity,
mood: Smile,
compliance: Pill,
hrv: Wind,
sleep: Moon,
resting_hr: HeartPulse,
steps: Footprints,
active_energy: Flame,
flights: Mountain,
distance: Route,
vo2_max: Zap,
body_temp: Thermometer,
glp1_plateau: Pill,
readiness: Sparkles,
recovery: HeartPulse,
};
const TONE_BAR_CLASSNAME: Record<DailyBriefingKeyFinding["tone"], string> = {
good: "bg-success",
watch: "bg-warning",
info: "bg-info",
};
const TONE_TEXT_CLASSNAME: Record<DailyBriefingKeyFinding["tone"], string> = {
good: "text-success",
watch: "text-warning",
info: "text-info",
};
/** One spotlight row, normalised from a signal or a key finding. */
interface SpotlightRow {
sourceMetric: DailyBriefingKeyFinding["sourceMetric"];
tone: DailyBriefingKeyFinding["tone"];
headline: string;
delta: string | null;
}
/**
* Choose the rows to spotlight. Prefers the present-focused
* `signalsOfDay` (the rebuilt briefing's lead); falls back to the
* longer-horizon `keyFindings` when no signals were generated. Returns an
* empty array when neither carries content (the caller then renders
* nothing).
*/
function selectSpotlightRows(briefing: DailyBriefing): SpotlightRow[] {
const signals = briefing.signalsOfDay ?? [];
if (signals.length > 0) {
return signals.slice(0, MAX_SPOTLIGHT_ROWS).map((s) => ({
sourceMetric: s.sourceMetric,
tone: s.tone,
headline: s.headline,
delta: s.delta,
}));
}
return briefing.keyFindings.slice(0, MAX_SPOTLIGHT_ROWS).map((f) => ({
sourceMetric: f.sourceMetric,
tone: f.tone,
headline: f.headline,
delta: f.delta,
}));
}
export function BriefingSpotlight({
briefing,
briefingState,
briefingStale,
}: {
briefing: DailyBriefing | null;
briefingState: string;
briefingStale: boolean;
}) {
const { t } = useTranslations();
// Fresh-only gate: a ready, non-stale briefing with at least one row.
// A stale / preparing / disabled briefing falls through to nothing so
// the band never sits empty or narrates a days-old read as current.
if (briefingState !== "ready" || briefingStale || briefing === null) {
return null;
}
const rows = selectSpotlightRows(briefing);
if (rows.length === 0) return null;
return (
<div data-slot="dashboard-briefing-spotlight" className="space-y-2">
<div className="flex items-center justify-between gap-3">
<p className="text-muted-foreground text-[11px] font-semibold tracking-wide uppercase">
{t("dashboard.hero.briefing.title")}
</p>
<Link
href="/insights"
data-slot="dashboard-briefing-spotlight-link"
className="text-muted-foreground hover:text-foreground text-[11px] underline underline-offset-2"
>
{t("dashboard.hero.briefing.viewAll")}
</Link>
</div>
<div className="space-y-2">
{rows.map((row, index) => {
const Icon = METRIC_ICON[row.sourceMetric];
return (
<ListRow
key={`${row.sourceMetric}-${index}`}
asChild
data-slot="dashboard-briefing-spotlight-row"
data-metric={row.sourceMetric}
className={cn(
"border-border/60 bg-card/40 relative flex items-start gap-3",
"hover:bg-accent/40 transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
<Link href="/insights">
<span
aria-hidden="true"
className={cn(
"absolute top-3 bottom-3 left-0 w-[3px] rounded-r",
TONE_BAR_CLASSNAME[row.tone],
)}
/>
<Icon
className={cn(
"mt-0.5 h-4 w-4 shrink-0",
TONE_TEXT_CLASSNAME[row.tone],
)}
aria-hidden="true"
/>
<div className="flex min-w-0 flex-1 items-start justify-between gap-3">
<p className="text-foreground text-sm leading-snug font-medium">
{stripChartTokens(row.headline)}
</p>
{row.delta ? (
<span
data-slot="dashboard-briefing-spotlight-delta"
className={cn(
"shrink-0 text-xs font-semibold tabular-nums",
TONE_TEXT_CLASSNAME[row.tone],
)}
>
{row.delta}
</span>
) : null}
</div>
</Link>
</ListRow>
);
})}
</div>
</div>
);
}
@@ -48,6 +48,7 @@ import { Pill } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScoreRing } from "@/components/insights/derived/score-ring";
import { RestModeBanner } from "@/components/insights/rest-mode-banner";
import { BriefingSpotlight } from "@/components/dashboard/hero/briefing-spotlight";
import {
resolveDashboardVerdict,
type DashboardVerdictVariant,
@@ -325,6 +326,18 @@ export function DashboardHero({
untinted so it frames the score without alarming. Closes the
web↔iOS parity gap: iOS already mirrors `score.restMode`. */}
<RestModeBanner annotation={snapshot.healthScore?.restMode ?? null} />
{/* v1.18.11 — briefing spotlight. Lifts the fresh briefing's own
signals onto the hero so the daily read is always above the
fold, not buried behind the rung-8 verdict. Self-gating: renders
nothing unless a fresh (ready, non-stale) briefing carries
content. The rung-8 `briefing` verdict above still fires when no
higher rung claims the verdict line; this band is additive and
does not change which sentence the verdict shows. */}
<BriefingSpotlight
briefing={snapshot.briefing}
briefingState={snapshot.briefingState}
briefingStale={snapshot.briefingStale}
/>
</div>
</section>
);
+46
View File
@@ -3,10 +3,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
OURA_OAUTH_SCOPE,
exchangeCode,
fetchCardiovascularAge,
fetchReadiness,
fetchVo2Max,
getAuthorizationUrl,
getOuraCredentials,
mapCardiovascularAge,
mapDailyActivity,
mapDailySleep,
mapDailySpo2,
@@ -14,6 +16,7 @@ import {
mapSleep,
mapVo2Max,
refreshAccessToken,
type OuraCardiovascularAge,
type OuraDailyActivity,
type OuraDailySleep,
type OuraDailySpo2,
@@ -353,6 +356,49 @@ describe("mapVo2Max", () => {
});
});
describe("fetchCardiovascularAge", () => {
it("reads the daily_cardiovascular_age collection path", async () => {
const fetchMock = installFetchMock([
{
status: 200,
body: {
data: [{ id: "1", day: "2026-06-10", vascular_age: 39 }],
next_token: null,
},
},
]);
const r = await fetchCardiovascularAge("tok", {
startDate: "2026-06-01",
endDate: "2026-06-10",
});
expect(r).toHaveLength(1);
const [url] = fetchMock.mock.calls[0]!;
expect(url).toContain("/v2/usercollection/daily_cardiovascular_age");
});
});
describe("mapCardiovascularAge", () => {
it("maps vascular_age -> VASCULAR_AGE (years)", () => {
const c: OuraCardiovascularAge = { day: "2026-06-10", vascular_age: 39 };
const mapped = mapCardiovascularAge(c);
expect(mapped).toHaveLength(1);
expect(mapped[0]).toMatchObject({
type: "VASCULAR_AGE",
value: 39,
unit: "years",
fieldTag: "vascular_age",
});
});
it("skips a record with no positive value", () => {
expect(
mapCardiovascularAge({ day: "2026-06-10", vascular_age: 0 }),
).toEqual([]);
expect(
mapCardiovascularAge({ day: "2026-06-10", vascular_age: null }),
).toEqual([]);
});
});
describe("OURA_OAUTH_SCOPE", () => {
it("requests daily + personal", () => {
expect(OURA_OAUTH_SCOPE).toBe("daily personal");
+4
View File
@@ -10,6 +10,7 @@ const {
fetchDailySleepMock,
fetchSpo2Mock,
fetchVo2MaxMock,
fetchCardioAgeMock,
refreshMock,
upsertMock,
recordSuccessMock,
@@ -26,6 +27,7 @@ const {
fetchDailySleepMock: vi.fn(),
fetchSpo2Mock: vi.fn(),
fetchVo2MaxMock: vi.fn(),
fetchCardioAgeMock: vi.fn(),
refreshMock: vi.fn(),
upsertMock: vi.fn(),
recordSuccessMock: vi.fn(),
@@ -70,6 +72,7 @@ vi.mock("../client", async (importOriginal) => {
fetchDailySleep: fetchDailySleepMock,
fetchDailySpo2: fetchSpo2Mock,
fetchVo2Max: fetchVo2MaxMock,
fetchCardiovascularAge: fetchCardioAgeMock,
refreshAccessToken: refreshMock,
};
});
@@ -91,6 +94,7 @@ beforeEach(() => {
fetchDailySleepMock.mockReset().mockResolvedValue([]);
fetchSpo2Mock.mockReset().mockResolvedValue([]);
fetchVo2MaxMock.mockReset().mockResolvedValue([]);
fetchCardioAgeMock.mockReset().mockResolvedValue([]);
refreshMock.mockReset();
upsertMock.mockReset().mockResolvedValue({});
recordSuccessMock.mockReset().mockResolvedValue(undefined);
+56
View File
@@ -226,6 +226,19 @@ export interface OuraVo2Max {
vo2_max?: number | null;
}
/**
* Oura `daily_cardiovascular_age` — Oura's estimate of how the user's
* cardiovascular system is ageing relative to chronological age, in years.
* Maps cleanly onto the existing `VASCULAR_AGE` enum (the same years-unit
* arterial-age concept Withings' Body Scan reports under meastype 155).
*/
export interface OuraCardiovascularAge {
id?: string;
day: string;
/** Estimated vascular age in years. */
vascular_age?: number | null;
}
export interface OuraDailyActivity {
id: string;
day: string;
@@ -375,6 +388,26 @@ export function fetchVo2Max(
);
}
export function fetchCardiovascularAge(
accessToken: string,
query: DateRangeQuery,
): Promise<OuraCardiovascularAge[]> {
return fetchCollection<OuraCardiovascularAge>(
"/v2/usercollection/daily_cardiovascular_age",
accessToken,
"fetchCardiovascularAge",
query,
);
}
// Oura `daily_resilience` → no fitting enum. Resilience is a categorical level
// (limited / adequate / solid / strong / exceptional), not a numeric metric;
// HealthLog has no categorical enum + column for it (RECOVERY_SCORE / ANS_CHARGE
// are numeric scores, not a resilience band). Capturing it faithfully would
// need a new MeasurementType plus a categorical context column + CHECK
// constraint (a migration). Deferred until a migration lands — see the v1.18.11
// backlog report.
// ─── Field → Measurement mapping ───────────────────────────────
const SEC_TO_MIN = 1 / 60;
@@ -700,3 +733,26 @@ export function mapVo2Max(v: OuraVo2Max): MappedMeasurement[] {
},
];
}
/**
* Map one Oura `daily_cardiovascular_age` record → `VASCULAR_AGE` (years, the
* canonical DB unit — no conversion). Skips a record with no positive value.
* Shares the `VASCULAR_AGE` bucket with Withings' Body Scan arterial-age, kept
* distinct from the two vendors only by `source`.
*/
export function mapCardiovascularAge(
c: OuraCardiovascularAge,
): MappedMeasurement[] {
if (typeof c.vascular_age !== "number" || c.vascular_age <= 0) return [];
const measuredAt = dayAnchor(c.day);
if (!measuredAt) return [];
return [
{
type: "VASCULAR_AGE",
value: round2(c.vascular_age),
unit: "years",
measuredAt,
fieldTag: "vascular_age",
},
];
}
+15 -4
View File
@@ -5,15 +5,21 @@
* (real per-segment hypnogram timeline when present, else per-stage totals;
* efficiency, HRV, RHR, respiratory rate), daily activity (steps, active
* energy, equivalent walking distance), the daily Sleep Score (→ SLEEP_SCORE),
* daily SpO2 (→ OXYGEN_SATURATION), and the dedicated vO2_max collection (→
* VO2_MAX) for one connected user, mapping each into `Measurement` rows tagged
* `source = OURA`.
* daily SpO2 (→ OXYGEN_SATURATION), the dedicated vO2_max collection (→
* VO2_MAX), and the daily cardiovascular-age estimate (→ VASCULAR_AGE) for one
* connected user, mapping each into `Measurement` rows tagged `source = OURA`.
*
* daily_stress → STRESS_SCORE is deferred: STRESS_SCORE already has an
* HRV-derived COMPUTED producer that is not yet wired into the source-priority
* ladder or the weekly graded-series collapse, so a second producer here would
* double-count nondeterministically. Re-add once STRESS_SCORE is laddered.
*
* daily_resilience is deferred: resilience is a categorical level (limited /
* adequate / solid / strong / exceptional), not a numeric metric, and HealthLog
* has no categorical enum + column for it. Capturing it faithfully needs a new
* MeasurementType plus a categorical context column + CHECK constraint (a
* migration) — out of scope for this migration-free release.
*
* Token model: Oura uses refresh tokens. The merged schema has no expiry
* column, so the sync refreshes REACTIVELY — the first read that 401s triggers
* one refresh (persisting BOTH rotated tokens) and a single retry. A failed
@@ -43,12 +49,14 @@ import {
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
fetchCardiovascularAge,
fetchDailyActivity,
fetchDailySleep,
fetchDailySpo2,
fetchReadiness,
fetchSleep,
fetchVo2Max,
mapCardiovascularAge,
mapDailyActivity,
mapDailySleep,
mapDailySpo2,
@@ -122,7 +130,7 @@ async function fetchAll(
const query = { startDate: ymd(start), endDate: ymd(now) };
const run = async (token: string): Promise<OuraMeasurementUpsert[]> => {
const [readiness, sleeps, activities, dailySleep, spo2, vo2max] =
const [readiness, sleeps, activities, dailySleep, spo2, vo2max, cardioAge] =
await Promise.all([
fetchReadiness(token, query),
fetchSleep(token, query),
@@ -130,6 +138,7 @@ async function fetchAll(
fetchDailySleep(token, query),
fetchDailySpo2(token, query),
fetchVo2Max(token, query),
fetchCardiovascularAge(token, query),
]);
const out: OuraMeasurementUpsert[] = [];
for (const r of readiness)
@@ -141,6 +150,8 @@ async function fetchAll(
out.push(...toUpsert(mapDailySleep(d), "daily_sleep"));
for (const s of spo2) out.push(...toUpsert(mapDailySpo2(s), "spo2"));
for (const v of vo2max) out.push(...toUpsert(mapVo2Max(v), "vo2max"));
for (const c of cardioAge)
out.push(...toUpsert(mapCardiovascularAge(c), "cardio_age"));
return out;
};
+197
View File
@@ -0,0 +1,197 @@
/**
* v1.18.11 — Withings ECG / AFib sync unit tests.
*
* Coverage focuses on the `ecg.afib` code → RhythmClassification mapping, the
* EVENT-row write shape (value=1, unit=event, verdict in rhythmClassification),
* the stable signalid externalId, the non-ECG-entry skip, and the reauth /
* 403 park behaviour. End-to-end DB exercise lives in the integration suite.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/integrations/status", () => ({
isReauthRequired: vi.fn().mockResolvedValue(false),
recordSyncFailure: vi.fn(),
recordSyncSuccess: vi.fn(),
}));
vi.mock("../sync", async () => {
const actual = await vi.importActual<typeof import("../sync")>("../sync");
return {
...actual,
getValidToken: vi.fn(async () => ({ accessToken: "token" })),
recordWithingsSyncFailure: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
getEvent: vi.fn(() => ({
addExternalCall: vi.fn(),
addWarning: vi.fn(),
})),
}));
vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
return {
...actual,
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
}));
import { prisma } from "@/lib/db";
import {
isReauthRequired,
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
import {
fetchWithingsHeartList,
mapWithingsAfib,
syncUserEcg,
type WithingsHeartEntry,
} from "../sync-ecg";
function installFetchMock(entries: WithingsHeartEntry[]) {
const fetchMock = vi.fn(async () => ({
status: 200,
json: async () => ({ status: 0, body: { series: entries } }),
}));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(isReauthRequired).mockResolvedValue(false);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("mapWithingsAfib", () => {
it("maps afib 0 → NOT_DETECTED", () => {
expect(mapWithingsAfib(0)).toBe("NOT_DETECTED");
});
it("maps afib 1 → IRREGULAR", () => {
expect(mapWithingsAfib(1)).toBe("IRREGULAR");
});
it("maps any other code → INCONCLUSIVE", () => {
expect(mapWithingsAfib(2)).toBe("INCONCLUSIVE");
expect(mapWithingsAfib(3)).toBe("INCONCLUSIVE");
});
it("returns null for a missing / non-finite afib code", () => {
expect(mapWithingsAfib(undefined)).toBeNull();
expect(mapWithingsAfib(null)).toBeNull();
expect(mapWithingsAfib(Number.NaN)).toBeNull();
});
});
describe("fetchWithingsHeartList", () => {
it("POSTs heart list with unix-seconds window", async () => {
const fetchMock = installFetchMock([]);
await fetchWithingsHeartList("token", 1715000000, 1715100000);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as unknown as [
string,
{ body: string },
];
expect(url).toContain("/v2/heart");
expect(init.body).toContain("action=list");
expect(init.body).toContain("startdate=1715000000");
expect(init.body).toContain("enddate=1715100000");
});
});
describe("syncUserEcg", () => {
it("writes one IRREGULAR_RHYTHM_NOTIFICATION EVENT row per ECG recording", async () => {
installFetchMock([
{ timestamp: 1715000000, ecg: { signalid: 111, afib: 1 } },
{ timestamp: 1715003600, ecg: { signalid: 222, afib: 0 } },
]);
vi.mocked(prisma.measurement.upsert).mockResolvedValue({} as never);
const imported = await syncUserEcg("user-1");
expect(imported).toBe(2);
expect(prisma.measurement.upsert).toHaveBeenCalledTimes(2);
const first = vi.mocked(prisma.measurement.upsert).mock.calls[0][0];
expect(first.create).toMatchObject({
type: "IRREGULAR_RHYTHM_NOTIFICATION",
value: 1,
unit: "event",
source: "WITHINGS",
rhythmClassification: "IRREGULAR",
externalId: "withings:ecg:user-1:111",
});
expect(recordSyncSuccess).toHaveBeenCalledWith("user-1", "withings");
});
it("skips a heart entry that carries no ECG / afib verdict", async () => {
installFetchMock([
{ timestamp: 1715000000, ecg: null },
{ timestamp: 1715003600 },
{ timestamp: 1715007200, ecg: { signalid: 333, afib: 0 } },
]);
vi.mocked(prisma.measurement.upsert).mockResolvedValue({} as never);
const imported = await syncUserEcg("user-1");
expect(imported).toBe(1);
expect(prisma.measurement.upsert).toHaveBeenCalledTimes(1);
});
it("falls back to a timestamp externalId when signalid is absent", async () => {
installFetchMock([{ timestamp: 1715000000, ecg: { afib: 1 } }]);
vi.mocked(prisma.measurement.upsert).mockResolvedValue({} as never);
await syncUserEcg("user-1");
const call = vi.mocked(prisma.measurement.upsert).mock.calls[0][0];
expect(call.create.externalId).toBe("withings:ecg:user-1:ts-1715000000");
});
it("short-circuits when the connection is parked at reauth", async () => {
vi.mocked(isReauthRequired).mockResolvedValue(true);
const fetchMock = installFetchMock([]);
const imported = await syncUserEcg("user-1");
expect(imported).toBe(0);
expect(fetchMock).not.toHaveBeenCalled();
});
it("records reauth_required on a 403 from the heart endpoint", async () => {
const fetchMock = vi.fn(async () => ({
status: 200,
json: async () => ({ status: 401, error: "invalid_token" }),
}));
vi.stubGlobal("fetch", fetchMock);
await expect(syncUserEcg("user-1")).rejects.toThrow();
// A Withings 401-status body classifies as a hard failure; the generic
// failure recorder runs (the 403-specific reauth branch keys on HTTP 403).
expect(recordSyncFailure).not.toHaveBeenCalledWith(
expect.objectContaining({ kind: "reauth_required", errorCode: "403" }),
);
});
});
+301
View File
@@ -0,0 +1,301 @@
/**
* v1.18.11 — Withings ECG / AFib capture.
*
* The Withings Heart List endpoint (`POST /v2/heart?action=list`) returns one
* entry per on-device ECG recording made by the ScanWatch family. Each entry
* carries the device's OWN AFib screening verdict under `ecg.afib`. HealthLog
* captured none of this before — the signal was fetched by no path and dropped.
*
* This mirrors the v1.10.0 categorical-event treatment the Apple-Health bridge
* already gives the Watch's irregular-rhythm notification: HealthLog stores
* ONLY the verdict the device's certified algorithm produced. It never sees a
* raw ECG waveform, never re-classifies, and never emits a diagnosis. Each
* recording lands as one `IRREGULAR_RHYTHM_NOTIFICATION` Measurement row with
* `value = 1` (one fired recording), `unit = "event"`, and the device verdict
* in the `rhythmClassification` column — the SAME enum + column + CHECK
* constraint (migration 0105) the Apple path uses, so there is NO migration
* and no iOS-contract change. The rows flow to `/api/sync/changes` under the
* `WITHINGS` source like every other server-side fetch.
*
* AFib code → verdict (Withings Heart List `ecg.afib`):
* - 0 → NOT_DETECTED (algorithm ran, no AFib flagged)
* - 1 → IRREGULAR (possible AFib flagged — AWARENESS ONLY, never a
* HealthLog diagnosis)
* - any other code (inconclusive / poor recording / high-HR) → INCONCLUSIVE
*
* The Heart List endpoint is served under the `user.metrics` scope (already
* requested for every Withings connection since W5d), so — unlike the
* sleep/activity endpoints — it needs no `user.activity` scope gate.
*
* Idempotency: each recording carries a stable `signalid`; the externalId is
* `withings:ecg:<userId>:<signalid>` (or the timestamp when a recording omits
* `signalid`). Upsert keys on `(userId, type, source = WITHINGS, externalId)`
* so a re-sync overwrites the recording's verdict in place rather than
* inserting a duplicate.
*/
import type {
MeasurementType,
RhythmClassification,
} from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/rollups/measurement-rollups";
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
WithingsApiError,
classifyWithingsResponse,
} from "./response-classifier";
import { getValidToken, recordWithingsSyncFailure } from "./sync";
import {
isReauthRequired,
recordSyncFailure,
recordSyncSuccess,
} from "@/lib/integrations/status";
const WITHINGS_HEART_URL = "https://wbsapi.withings.net/v2/heart";
/** The categorical EVENT type every ECG recording lands under. */
const ECG_TYPE: MeasurementType = "IRREGULAR_RHYTHM_NOTIFICATION";
/** Canonical unit for an EVENT row (one fired recording). */
const ECG_UNIT = "event";
/**
* Default backfill window for the first ECG sync of a connection. Matches the
* sleep/activity ingest windows so a reconnect lights up 30 days of history.
*/
const ECG_BACKFILL_DAYS = 30;
/**
* One Heart List entry from `POST /v2/heart?action=list`. The AFib verdict
* lives under `ecg.afib`; `signalid` is the stable per-recording id used for
* the externalId. `timestamp` is unix seconds.
*/
export interface WithingsHeartEntry {
timestamp: number;
ecg?: {
signalid?: number;
afib?: number;
} | null;
}
/**
* Map a Withings `ecg.afib` code to the HealthLog `RhythmClassification`
* verdict. Returns `null` when the entry carries no ECG / no afib code so the
* caller can skip a non-ECG heart entry (e.g. a manual BP measurement) without
* an error.
*/
export function mapWithingsAfib(
afib: number | null | undefined,
): RhythmClassification | null {
if (typeof afib !== "number" || !Number.isFinite(afib)) return null;
switch (afib) {
case 0:
return "NOT_DETECTED";
case 1:
return "IRREGULAR";
default:
// Inconclusive / poor recording / high-HR — the device could not return
// a clean positive/negative AFib verdict.
return "INCONCLUSIVE";
}
}
/**
* Fetch the Heart List for the given window. Returns the raw entries (no DB
* write) so the caller composes the upsert step. Paginates via the response's
* `more` / `offset` like the Measure endpoint.
*/
export async function fetchWithingsHeartList(
accessToken: string,
startdate: number,
enddate: number,
): Promise<WithingsHeartEntry[]> {
const results: WithingsHeartEntry[] = [];
let offset = 0;
let pageCount = 0;
while (true) {
const params = new URLSearchParams({
action: "list",
startdate: String(startdate),
enddate: String(enddate),
offset: String(offset),
});
const pageStart = performance.now();
const res = await safeFetch(WITHINGS_HEART_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${accessToken}`,
},
body: params.toString(),
});
const json = await res.json();
const verdict = classifyWithingsResponse(res.status, json);
getEvent()?.addExternalCall({
service: "withings",
method: `fetchWithingsHeartList(page=${pageCount})`,
duration_ms: Math.round(performance.now() - pageStart),
status: res.status,
error: verdict.classification === "success" ? undefined : verdict.reason,
});
if (verdict.classification !== "success") {
throw new WithingsApiError({
verb: "heart",
classification: verdict.classification,
withingsStatus: verdict.withingsStatus,
reason: verdict.reason,
upstreamError: typeof json?.error === "string" ? json.error : undefined,
});
}
const body = json.body ?? {};
const series: WithingsHeartEntry[] = body.series ?? [];
results.push(...series);
const hasMore = body.more === true || body.more === 1;
if (!hasMore) break;
const nextOffset = Number(body.offset);
if (!Number.isFinite(nextOffset) || nextOffset <= offset) break;
offset = nextOffset;
pageCount += 1;
if (pageCount > 1000) break;
}
return results;
}
/**
* Sync ECG / AFib recordings for a single user. Walks the trailing 30-day
* window, writing one `IRREGULAR_RHYTHM_NOTIFICATION` row per ECG recording
* tagged with the device's AFib verdict. Returns the number of upserted rows.
*
* Park behaviour mirrors the other Withings syncs — a connection at
* `error_reauth` short-circuits before the upstream call.
*/
export async function syncUserEcg(userId: string): Promise<number> {
if (await isReauthRequired(userId, "withings")) {
getEvent()?.addWarning(
`withings ecg sync skipped for ${userId}: parked at error_reauth`,
);
return 0;
}
const tokenInfo = await getValidToken(userId);
if (!tokenInfo) return 0;
const now = new Date();
const start = new Date(
now.getTime() - ECG_BACKFILL_DAYS * 24 * 60 * 60 * 1000,
);
const startUnix = Math.floor(start.getTime() / 1000);
const endUnix = Math.floor(now.getTime() / 1000);
let entries: WithingsHeartEntry[];
try {
entries = await fetchWithingsHeartList(
tokenInfo.accessToken,
startUnix,
endUnix,
);
} catch (err) {
// The Heart List endpoint rides the user.metrics scope, so a 403 here is a
// genuine token-revoked / reauth case rather than the activity-scope skip
// the sleep/activity syncs guard against.
const withingsStatus =
err instanceof WithingsApiError ? err.withingsStatus : undefined;
if (withingsStatus === 403) {
await recordSyncFailure({
userId,
integration: "withings",
kind: "reauth_required",
message: err instanceof Error ? err.message : String(err),
errorCode: "403",
});
throw err;
}
await recordWithingsSyncFailure(userId, err);
throw err;
}
let imported = 0;
const touched: Array<{ type: MeasurementType; measuredAt: Date }> = [];
for (const entry of entries) {
const classification = mapWithingsAfib(entry.ecg?.afib);
if (!classification) {
// Not an ECG entry (or no afib verdict) — skip silently.
continue;
}
const measuredAt = new Date(entry.timestamp * 1000);
const signalId = entry.ecg?.signalid ?? `ts-${entry.timestamp}`;
const externalId = `withings:ecg:${userId}:${signalId}`;
try {
await prisma.measurement.upsert({
where: {
userId_type_source_externalId: {
userId,
type: ECG_TYPE,
source: "WITHINGS",
externalId,
},
},
create: {
userId,
type: ECG_TYPE,
value: 1,
unit: ECG_UNIT,
measuredAt,
source: "WITHINGS",
externalId,
rhythmClassification: classification,
},
update: {
// The recording's value is fixed at 1; only the verdict can change if
// Withings re-classifies a signal. Overwrite it in place.
rhythmClassification: classification,
measuredAt,
syncVersion: { increment: 1 },
},
});
touched.push({ type: ECG_TYPE, measuredAt });
imported++;
} catch (err) {
getEvent()?.addWarning(
`withings ecg: failed to upsert recording (${classification}, ${measuredAt.toISOString()}): ${err}`,
);
}
}
// Refresh the persistent rollup tier for every distinct (type, day) touched,
// then drop the per-metric assessment caches the new events dirty.
try {
const keys = collapseToTypeDayKeys(touched);
for (const k of keys) {
await recomputeBucketsForMeasurement(userId, k.type, k.measuredAt);
}
invalidateStatusInsightsForTypes(
userId,
keys.map((k) => k.type),
).catch((err) => {
getEvent()?.addWarning(
`withings ecg: status-insight invalidate failed for ${userId}: ${err}`,
);
});
} catch (err) {
getEvent()?.addWarning(
`withings ecg: rollup recompute failed for ${userId}: ${err}`,
);
}
await recordSyncSuccess(userId, "withings");
return imported;
}
+16
View File
@@ -155,6 +155,22 @@ export async function processWithingsNotification(
"Sync failed for user " + connection.userId + ": " + err,
);
});
// v1.18.11 — ECG / AFib capture rides the measure path. The Heart List
// endpoint shares the `user.metrics` scope with the measure family, so a
// measure-family notification (which is what a ScanWatch ECG recording
// delivers) is the right trigger. Fire it non-blocking and lazy-loaded so
// the webhook response stays fast and the cold path doesn't pull the ECG
// module in until a measure notification actually lands.
void (async () => {
try {
const { syncUserEcg } = await import("./sync-ecg");
await syncUserEcg(connection.userId);
} catch (err) {
getEvent()?.addWarning(
"ECG sync failed for user " + connection.userId + ": " + err,
);
}
})();
}
return NextResponse.json({ status: "ok" }, { status: 200 });