mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge w11/backlog into release/v1.18.11
This commit is contained in:
commit
5859aaf46f
16 files changed
+1071
-4
No files matched your search
@@ -301,6 +301,10 @@
|
||||
"viewSleep": "Schlaf ansehen",
|
||||
"logMeasurement": "Messung erfassen",
|
||||
"viewInsights": "Insights öffnen"
|
||||
},
|
||||
"briefing": {
|
||||
"title": "Tagesbriefing",
|
||||
"viewAll": "Insights öffnen"
|
||||
}
|
||||
},
|
||||
"greetingSalutation": "Hallo, {name}",
|
||||
|
||||
@@ -301,6 +301,10 @@
|
||||
"viewSleep": "View sleep",
|
||||
"logMeasurement": "Log measurement",
|
||||
"viewInsights": "Open Insights"
|
||||
},
|
||||
"briefing": {
|
||||
"title": "Today’s briefing",
|
||||
"viewAll": "Open Insights"
|
||||
}
|
||||
},
|
||||
"greetingSalutation": "Hi, {name}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -301,6 +301,10 @@
|
||||
"viewSleep": "Vedi sonno",
|
||||
"logMeasurement": "Registra misurazione",
|
||||
"viewInsights": "Apri Insights"
|
||||
},
|
||||
"briefing": {
|
||||
"title": "Briefing di oggi",
|
||||
"viewAll": "Apri Insights"
|
||||
}
|
||||
},
|
||||
"greetingSalutation": "Ciao, {name}",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user