From af86a14824a53fc0d6befff4844e5eaf76775ab0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 00:54:09 +0200 Subject: [PATCH 1/3] feat(dashboard): surface the daily briefing on the hero The briefing only reached the dashboard as the hero's lowest-priority verdict, which the severity ladder almost always pre-empted, so a fresh briefing was invisible above the fold. Lift its own signals (falling back to key findings) onto the hero as a self-gating strip beneath the verdict row, reusing the insights briefing's tone-bar row language. Placement only: the verdict ladder and the briefing content are unchanged. --- messages/de.json | 4 + messages/en.json | 4 + messages/es.json | 4 + messages/fr.json | 4 + messages/it.json | 4 + messages/pl.json | 4 + .../__tests__/briefing-spotlight.test.tsx | 180 ++++++++++++++ .../dashboard/hero/briefing-spotlight.tsx | 219 ++++++++++++++++++ .../dashboard/hero/dashboard-hero.tsx | 13 ++ 9 files changed, 436 insertions(+) create mode 100644 src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx create mode 100644 src/components/dashboard/hero/briefing-spotlight.tsx diff --git a/messages/de.json b/messages/de.json index 9bf4e25e8..31bd8ee23 100644 --- a/messages/de.json +++ b/messages/de.json @@ -301,6 +301,10 @@ "viewSleep": "Schlaf ansehen", "logMeasurement": "Messung erfassen", "viewInsights": "Insights öffnen" + }, + "briefing": { + "title": "Tagesbriefing", + "viewAll": "Insights öffnen" } }, "greetingSalutation": "Hallo, {name}", diff --git a/messages/en.json b/messages/en.json index c95da55f1..ac76e0b75 100644 --- a/messages/en.json +++ b/messages/en.json @@ -301,6 +301,10 @@ "viewSleep": "View sleep", "logMeasurement": "Log measurement", "viewInsights": "Open Insights" + }, + "briefing": { + "title": "Today’s briefing", + "viewAll": "Open Insights" } }, "greetingSalutation": "Hi, {name}", diff --git a/messages/es.json b/messages/es.json index 92f869149..e325e4055 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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}", diff --git a/messages/fr.json b/messages/fr.json index 8d8d40a7e..fa9ffc99e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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}", diff --git a/messages/it.json b/messages/it.json index 93037e331..08c442904 100644 --- a/messages/it.json +++ b/messages/it.json @@ -301,6 +301,10 @@ "viewSleep": "Vedi sonno", "logMeasurement": "Registra misurazione", "viewInsights": "Apri Insights" + }, + "briefing": { + "title": "Briefing di oggi", + "viewAll": "Apri Insights" } }, "greetingSalutation": "Ciao, {name}", diff --git a/messages/pl.json b/messages/pl.json index 13ec008aa..b97894578 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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}", diff --git a/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx b/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx new file mode 100644 index 000000000..8c72401be --- /dev/null +++ b/src/components/dashboard/hero/__tests__/briefing-spotlight.test.tsx @@ -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"; + +/** + * `` 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, + locale: "en" | "de" = "en", +) { + return renderToStaticMarkup( + + + , + ); +} + +function briefing(overrides: Partial = {}): DailyBriefing { + return { + paragraph: "x", + signalsOfDay: null, + keyFindings: [], + ...overrides, + }; +} + +describe("", () => { + 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); + }); +}); diff --git a/src/components/dashboard/hero/briefing-spotlight.tsx b/src/components/dashboard/hero/briefing-spotlight.tsx new file mode 100644 index 000000000..ae222e0b8 --- /dev/null +++ b/src/components/dashboard/hero/briefing-spotlight.tsx @@ -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 = { + good: "bg-success", + watch: "bg-warning", + info: "bg-info", +}; + +const TONE_TEXT_CLASSNAME: Record = { + 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 ( +
+
+

+ {t("dashboard.hero.briefing.title")} +

+ + {t("dashboard.hero.briefing.viewAll")} + +
+
+ {rows.map((row, index) => { + const Icon = METRIC_ICON[row.sourceMetric]; + return ( + + + + ); + })} +
+
+ ); +} diff --git a/src/components/dashboard/hero/dashboard-hero.tsx b/src/components/dashboard/hero/dashboard-hero.tsx index d13db1963..36863ac82 100644 --- a/src/components/dashboard/hero/dashboard-hero.tsx +++ b/src/components/dashboard/hero/dashboard-hero.tsx @@ -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`. */} + {/* 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. */} + ); From 621b6734d0db440d4f72d95596ee82f7f0aaf4dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 00:54:16 +0200 Subject: [PATCH 2/3] feat(withings): capture ECG / AFib screening results The Heart List endpoint returns a per-recording AFib verdict from the ScanWatch family that no sync fetched. Capture each recording as an IRREGULAR_RHYTHM_NOTIFICATION event row carrying the device's own verdict in rhythm_classification, mirroring the Apple Health categorical-event treatment. Reuses the existing enum, column, and constraint, so no migration. Fires on the measure webhook path, which shares the metrics scope the Heart List endpoint needs. --- src/lib/withings/__tests__/sync-ecg.test.ts | 197 +++++++++++++ src/lib/withings/sync-ecg.ts | 301 ++++++++++++++++++++ src/lib/withings/webhook-handler.ts | 16 ++ 3 files changed, 514 insertions(+) create mode 100644 src/lib/withings/__tests__/sync-ecg.test.ts create mode 100644 src/lib/withings/sync-ecg.ts diff --git a/src/lib/withings/__tests__/sync-ecg.test.ts b/src/lib/withings/__tests__/sync-ecg.test.ts new file mode 100644 index 000000000..57d4ead02 --- /dev/null +++ b/src/lib/withings/__tests__/sync-ecg.test.ts @@ -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("../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" }), + ); + }); +}); diff --git a/src/lib/withings/sync-ecg.ts b/src/lib/withings/sync-ecg.ts new file mode 100644 index 000000000..71dee7d05 --- /dev/null +++ b/src/lib/withings/sync-ecg.ts @@ -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::` (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 { + 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 { + 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; +} diff --git a/src/lib/withings/webhook-handler.ts b/src/lib/withings/webhook-handler.ts index 1bfc4ca77..31770e9d9 100644 --- a/src/lib/withings/webhook-handler.ts +++ b/src/lib/withings/webhook-handler.ts @@ -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 }); From 0f5d67033a94a6f7612a846b9d5b8752ccfaa6e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 00:54:27 +0200 Subject: [PATCH 3/3] feat(oura): ingest the daily cardiovascular-age estimate The daily_cardiovascular_age collection reports a years-unit arterial-age estimate that maps directly onto the existing VASCULAR_AGE metric, so no migration is needed. Resilience stays out: it is a categorical level with no numeric home and would require a new type plus a categorical column. --- src/lib/oura/__tests__/client.test.ts | 46 ++++++++++++++++++++++ src/lib/oura/__tests__/sync.test.ts | 4 ++ src/lib/oura/client.ts | 56 +++++++++++++++++++++++++++ src/lib/oura/sync.ts | 19 +++++++-- 4 files changed, 121 insertions(+), 4 deletions(-) diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index 81b86ab6a..07409fafd 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -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"); diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 6ab30d4ac..8fac99762 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -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); diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 35ca9c241..aebebbbb0 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -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 { + return fetchCollection( + "/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", + }, + ]; +} diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 64726c859..63df960f6 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -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 => { - 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; };