merge: assemble capstone signal slices (S10 ECG narrative, S11 intraday pulse, S12 milestones)

This commit is contained in:
Marc-André Bombeck committed 2026-07-17 08:38:44 +02:00
commit 8ac38926c4
20 files changed
+806 -10

No files matched your search

+19 -1
View File
@@ -3856,6 +3856,13 @@
},
"waveform": {
"ariaLabel": "EKG-Streifen aufgezeichnet {date}, Dauer {duration}, durchschnittliche Herzfrequenz {heartRate}, aufgezeichnetes Ergebnis: {result}."
},
"crossLink": {
"title": "EKG-Aufzeichnungen",
"recordingsOne": "1 Aufzeichnung vorhanden.",
"recordingsMany": "{count} Aufzeichnungen vorhanden.",
"latestResult": "Letztes Geräte-Ergebnis: {result}.",
"cta": "Aufzeichnungen ansehen"
}
},
"intradayPulse": {
@@ -8570,6 +8577,16 @@
"evening": "Dein Puls lag heute Abend über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung.",
"night": "Dein Puls lag in der Nacht über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung."
}
},
"ecgNewRecording": {
"title": "Neue EKG-Aufzeichnung",
"body": "Dein Gerät hat ein neues EKG aufgezeichnet — bereit zum Ansehen.",
"bodyVerdict": "Dein Gerät hat ein neues EKG aufgezeichnet und meldet: {verdict}.",
"verdict": {
"irregular": "einen möglichen unregelmäßigen Rhythmus",
"notDetected": "keinen unregelmäßigen Rhythmus",
"inconclusive": "ein nicht eindeutiges Ergebnis"
}
}
},
"action": {
@@ -8580,7 +8597,8 @@
"checkinAdjust": "Anpassen",
"checkinLetGo": "Loslassen",
"viewMilestone": "Ansehen",
"viewPulse": "Puls ansehen"
"viewPulse": "Puls ansehen",
"viewEcg": "Aufzeichnung ansehen"
},
"today": {
"scoreLabel": "Gesundheitsscore",
+19 -1
View File
@@ -3856,6 +3856,13 @@
},
"waveform": {
"ariaLabel": "ECG strip recorded {date}, duration {duration}, average heart rate {heartRate}, recorded result: {result}."
},
"crossLink": {
"title": "ECG recordings",
"recordingsOne": "1 recording on file.",
"recordingsMany": "{count} recordings on file.",
"latestResult": "Latest device result: {result}.",
"cta": "View recordings"
}
},
"intradayPulse": {
@@ -8570,6 +8577,16 @@
"evening": "Your heart rate sat above its resting baseline for a sustained stretch this evening while you were still — possibly some tension.",
"night": "Your heart rate sat above its resting baseline for a sustained stretch overnight while you were still — possibly some tension."
}
},
"ecgNewRecording": {
"title": "New ECG recording",
"body": "Your device recorded a new ECG — it's ready to view.",
"bodyVerdict": "Your device recorded a new ECG and reported: {verdict}.",
"verdict": {
"irregular": "a possible irregular rhythm",
"notDetected": "no irregular rhythm",
"inconclusive": "an inconclusive result"
}
}
},
"action": {
@@ -8580,7 +8597,8 @@
"checkinAdjust": "Adjust",
"checkinLetGo": "Let it go",
"viewMilestone": "View",
"viewPulse": "View pulse"
"viewPulse": "View pulse",
"viewEcg": "View recording"
},
"today": {
"scoreLabel": "Health score",
+19 -1
View File
@@ -3856,6 +3856,13 @@
},
"waveform": {
"ariaLabel": "Tira de ECG registrada el {date}, duración {duration}, frecuencia cardíaca media {heartRate}, resultado registrado: {result}."
},
"crossLink": {
"title": "Registros de ECG",
"recordingsOne": "1 registro guardado.",
"recordingsMany": "{count} registros guardados.",
"latestResult": "Último resultado del dispositivo: {result}.",
"cta": "Ver registros"
}
},
"intradayPulse": {
@@ -8570,6 +8577,16 @@
"evening": "Tu frecuencia cardíaca estuvo por encima de tu valor en reposo durante un buen rato al anochecer mientras estabas en calma; posiblemente algo de tensión.",
"night": "Tu frecuencia cardíaca estuvo por encima de tu valor en reposo durante un buen rato por la noche mientras estabas en calma; posiblemente algo de tensión."
}
},
"ecgNewRecording": {
"title": "Nuevo registro de ECG",
"body": "Tu dispositivo registró un nuevo ECG: está listo para verlo.",
"bodyVerdict": "Tu dispositivo registró un nuevo ECG e informó: {verdict}.",
"verdict": {
"irregular": "un posible ritmo irregular",
"notDetected": "ningún ritmo irregular",
"inconclusive": "un resultado no concluyente"
}
}
},
"action": {
@@ -8580,7 +8597,8 @@
"checkinAdjust": "Ajustar",
"checkinLetGo": "Dejarlo ir",
"viewMilestone": "Ver",
"viewPulse": "Ver pulso"
"viewPulse": "Ver pulso",
"viewEcg": "Ver registro"
},
"today": {
"scoreLabel": "Puntuación de salud",
+19 -1
View File
@@ -3856,6 +3856,13 @@
},
"waveform": {
"ariaLabel": "Tracé ECG enregistré le {date}, durée {duration}, fréquence cardiaque moyenne {heartRate}, résultat enregistré : {result}."
},
"crossLink": {
"title": "Enregistrements ECG",
"recordingsOne": "1 enregistrement disponible.",
"recordingsMany": "{count} enregistrements disponibles.",
"latestResult": "Dernier résultat de l’appareil : {result}.",
"cta": "Voir les enregistrements"
}
},
"intradayPulse": {
@@ -8570,6 +8577,16 @@
"evening": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment ce soir alors que vous étiez au calme — peut-être un peu de tension.",
"night": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment cette nuit alors que vous étiez au calme — peut-être un peu de tension."
}
},
"ecgNewRecording": {
"title": "Nouvel enregistrement ECG",
"body": "Votre appareil a enregistré un nouvel ECG : prêt à être consulté.",
"bodyVerdict": "Votre appareil a enregistré un nouvel ECG et a signalé : {verdict}.",
"verdict": {
"irregular": "un possible rythme irrégulier",
"notDetected": "aucun rythme irrégulier",
"inconclusive": "un résultat non concluant"
}
}
},
"action": {
@@ -8580,7 +8597,8 @@
"checkinAdjust": "Ajuster",
"checkinLetGo": "Laisser tomber",
"viewMilestone": "Voir",
"viewPulse": "Voir le pouls"
"viewPulse": "Voir le pouls",
"viewEcg": "Voir l’enregistrement"
},
"today": {
"scoreLabel": "Score de santé",
+19 -1
View File
@@ -3856,6 +3856,13 @@
},
"waveform": {
"ariaLabel": "Tracciato ECG registrato il {date}, durata {duration}, frequenza cardiaca media {heartRate}, risultato registrato: {result}."
},
"crossLink": {
"title": "Registrazioni ECG",
"recordingsOne": "1 registrazione disponibile.",
"recordingsMany": "{count} registrazioni disponibili.",
"latestResult": "Ultimo risultato del dispositivo: {result}.",
"cta": "Vedi registrazioni"
}
},
"intradayPulse": {
@@ -8570,6 +8577,16 @@
"evening": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato stasera mentre eri fermo — forse un po' di tensione.",
"night": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato durante la notte mentre eri fermo — forse un po' di tensione."
}
},
"ecgNewRecording": {
"title": "Nuova registrazione ECG",
"body": "Il tuo dispositivo ha registrato un nuovo ECG: è pronto da vedere.",
"bodyVerdict": "Il tuo dispositivo ha registrato un nuovo ECG e ha segnalato: {verdict}.",
"verdict": {
"irregular": "un possibile ritmo irregolare",
"notDetected": "nessun ritmo irregolare",
"inconclusive": "un risultato non conclusivo"
}
}
},
"action": {
@@ -8580,7 +8597,8 @@
"checkinAdjust": "Modifica",
"checkinLetGo": "Lascia andare",
"viewMilestone": "Vedi",
"viewPulse": "Vedi battito"
"viewPulse": "Vedi battito",
"viewEcg": "Vedi registrazione"
},
"today": {
"scoreLabel": "Punteggio di salute",
+19 -1
View File
@@ -3856,6 +3856,13 @@
},
"waveform": {
"ariaLabel": "Zapis EKG zarejestrowany {date}, czas trwania {duration}, średnie tętno {heartRate}, zarejestrowany wynik: {result}."
},
"crossLink": {
"title": "Zapisy EKG",
"recordingsOne": "1 zapis w kartotece.",
"recordingsMany": "Zapisy w kartotece: {count}.",
"latestResult": "Ostatni wynik urządzenia: {result}.",
"cta": "Zobacz zapisy"
}
},
"intradayPulse": {
@@ -8570,6 +8577,16 @@
"evening": "Twoje tętno przez dłuższą chwilę wieczorem utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie.",
"night": "Twoje tętno przez dłuższą chwilę w nocy utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie."
}
},
"ecgNewRecording": {
"title": "Nowy zapis EKG",
"body": "Twoje urządzenie zarejestrowało nowe EKG — gotowe do podglądu.",
"bodyVerdict": "Twoje urządzenie zarejestrowało nowe EKG i zgłosiło: {verdict}.",
"verdict": {
"irregular": "możliwy nieregularny rytm",
"notDetected": "brak nieregularnego rytmu",
"inconclusive": "wynik niejednoznaczny"
}
}
},
"action": {
@@ -8580,7 +8597,8 @@
"checkinAdjust": "Dostosuj",
"checkinLetGo": "Odpuść",
"viewMilestone": "Zobacz",
"viewPulse": "Zobacz tętno"
"viewPulse": "Zobacz tętno",
"viewEcg": "Zobacz zapis"
},
"today": {
"scoreLabel": "Wynik zdrowia",
+7
View File
@@ -17,6 +17,7 @@ import { CoachReadStrip } from "@/components/insights/derived/coach-read-strip";
import { MetricCorrelationCard } from "@/components/insights/metric-correlation-card";
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { EcgCrossLink } from "@/components/insights/ecg-cross-link";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { TileHeader } from "@/components/insights/tile-header";
import { IntradayPulseChart } from "@/components/insights/intraday-pulse-chart";
@@ -194,6 +195,12 @@ export default function InsightsPulsPage() {
icon={<Heart className="h-5 w-5" />}
/>
{/* S10 — device-attributed pointer into the ECG viewer, in the
resting-HR / pulse context. Un-mounts when the user has no ECG
recordings; surfaces only that recordings exist + the device's own
latest result, never a HealthLog interpretation. */}
<EcgCrossLink enabled={isAuthenticated} />
{/* VO₂ max is a cardio-fitness metric (Apple's Health app surfaces it
under "Heart"), so the pulse page links across to its dedicated
`/insights/cardio-fitness` page — the single surface that carries
@@ -0,0 +1,81 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* S10 — `<EcgCrossLink>` unit tests.
*
* The load-bearing behaviour under test:
* - data-availability gating: the pointer un-mounts entirely when the user
* has no ECG recordings;
* - the NON-DIAGNOSTIC framing: it surfaces only that recordings exist and
* the RECORDING DEVICE's own latest result, attributed to the device — no
* HealthLog interpretation, no waveform.
*
* `useAuth` + TanStack Query are mocked and assertions run through SSR, exactly
* like the sibling `ecg-section` test.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: vi.fn(() => ({ isAuthenticated: true, user: null })),
}));
const useQueryMock = vi.fn();
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: unknown) => useQueryMock(opts),
}));
const { EcgCrossLink } = await import("../ecg-cross-link");
interface Item {
id: string;
classification: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
}
function render(
data: { recordings: Item[]; hasRecordings: boolean } | undefined,
) {
useQueryMock.mockReturnValue({ data });
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<EcgCrossLink />
</I18nProvider>,
);
}
describe("<EcgCrossLink>", () => {
it("renders nothing before the payload resolves", () => {
expect(render(undefined)).toBe("");
});
it("renders nothing when the user has no recordings (gate)", () => {
expect(render({ recordings: [], hasRecordings: false })).toBe("");
});
it("links into the ECG viewer and attributes the latest result to the device", () => {
const html = render({
recordings: [
{ id: "a", classification: "IRREGULAR" },
{ id: "b", classification: "NOT_DETECTED" },
],
hasRecordings: true,
});
expect(html).toContain('data-slot="ecg-cross-link"');
expect(html).toContain('href="/insights#ecg"');
expect(html).toContain("2 recordings on file.");
// The latest (first) recording's DEVICE result, attributed to the device.
expect(html).toContain("Latest device result:");
// Never a HealthLog interpretation.
expect(html).not.toMatch(/we (detected|found)|our (reading|analysis)/i);
});
it("omits the result line when the latest recording has no device verdict", () => {
const html = render({
recordings: [{ id: "a", classification: null }],
hasRecordings: true,
});
expect(html).toContain("1 recording on file.");
expect(html).not.toContain("Latest device result:");
});
});
+106
View File
@@ -0,0 +1,106 @@
"use client";
import Link from "next/link";
import { ArrowRight, HeartPulse } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { TileHeader } from "@/components/insights/tile-header";
/**
* S10 — ECG cross-link, the pointer from the resting-HR / pulse context into
* the ECG viewer (`/insights#ecg`).
*
* NON-DIAGNOSTIC (load-bearing, mirrors `EcgSection` / `RhythmEventsCard`): the
* card surfaces ONLY that recordings exist + the RECORDING DEVICE's OWN latest
* result, attributed to the device. HealthLog never reads or interprets the
* waveform, and this pointer never touches it — the list route returns metadata
* only. All copy renders as plain React text children (no markdown library).
*
* Data-availability-gated: the card un-mounts entirely (`return null`) when the
* user has no recordings, so a pulse page without any ECG never paints it. It
* reuses the SAME query cell as `EcgSection` (`insightsEcgList`), so the two
* surfaces share one cache entry with an identical response shape.
*/
type EcgClassification = "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
interface EcgRecordingListItem {
id: string;
classification: EcgClassification;
}
interface EcgListResponse {
recordings: EcgRecordingListItem[];
hasRecordings: boolean;
}
/** The device verdict, surfaced verbatim and attributed to the device. */
const RESULT_LABEL_KEYS: Record<string, string> = {
IRREGULAR: "insights.ecg.result.irregular",
NOT_DETECTED: "insights.ecg.result.notDetected",
INCONCLUSIVE: "insights.ecg.result.inconclusive",
};
interface EcgCrossLinkProps {
enabled?: boolean;
className?: string;
}
export function EcgCrossLink({ enabled = true, className }: EcgCrossLinkProps) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data } = useQuery({
queryKey: queryKeys.insightsEcgList(),
queryFn: async () => {
try {
return await apiGet<EcgListResponse>("/api/insights/ecg");
} catch {
throw new Error(t("insights.ecg.loadError"));
}
},
enabled: enabled && isAuthenticated,
});
// Data-availability gate — never paint an empty pointer.
if (!data || !data.hasRecordings || data.recordings.length === 0) return null;
const count = data.recordings.length;
const latestClassification = data.recordings[0]?.classification ?? null;
const resultKey = latestClassification
? RESULT_LABEL_KEYS[latestClassification]
: null;
const countText =
count === 1
? t("insights.ecg.crossLink.recordingsOne")
: t("insights.ecg.crossLink.recordingsMany", { count });
const resultText = resultKey
? t("insights.ecg.crossLink.latestResult", { result: t(resultKey) })
: null;
return (
<Link
href="/insights#ecg"
data-slot="ecg-cross-link"
className={cn(
"bg-card hover:bg-accent/40 focus-visible:ring-ring/50 block space-y-1.5 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
>
<TileHeader icon={HeartPulse} title={t("insights.ecg.crossLink.title")} />
<span className="text-muted-foreground block text-xs leading-snug">
{resultText ? `${countText} ${resultText}` : countText}
</span>
<span className="text-primary inline-flex shrink-0 items-center gap-1 text-xs font-medium">
{t("insights.ecg.crossLink.cta")}
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</span>
</Link>
);
}
+2 -1
View File
@@ -118,9 +118,10 @@ export function EcgSection({ enabled = true, className }: EcgSectionProps) {
return (
<section
id="ecg"
data-slot="ecg-section"
aria-label={t("insights.ecg.sectionTitle")}
className={cn("space-y-3", className)}
className={cn("scroll-mt-24 space-y-3", className)}
>
<SectionHeading icon={Activity} title={t("insights.ecg.sectionTitle")} />
<div
@@ -261,4 +261,39 @@ describe("core-vitals grounding extension", () => {
);
expect(found.map((f) => f.value)).toContain(195);
});
// S10 — the ECG device-verdict descriptor feeds the narrative; its
// server-computed counts must be admitted so a device-attributed sentence
// ("your device logged 5 ECG recordings") is not stripped by the gate, while
// a count the block never produced still trips it.
const ecgFeatures = {
ecg: {
recordingCount: 5,
deviceVerdicts: { irregular: 2, notDetected: 3, inconclusive: 0 },
latestDeviceVerdict: "IRREGULAR",
latestRecordedDaysAgo: 4,
latestAverageHeartRate: 61,
},
} as never;
it("grounds a restated ECG recording count + latest device figures", () => {
const found = findUngroundedBriefingNumbers(
{
paragraph:
"Your device logged 5 ECG recordings this month; the latest, 4 days ago, averaged 61 bpm.",
},
signals,
ecgFeatures,
);
expect(found).toEqual([]);
});
it("flags an ECG count the descriptor never produced", () => {
const found = findUngroundedBriefingNumbers(
{ paragraph: "Your device logged 12 ECG recordings this month." },
signals,
ecgFeatures,
);
expect(found.map((f) => f.value)).toContain(12);
});
});
@@ -52,6 +52,22 @@ describe("scope-hardened system prompt", () => {
expect(prompt).toMatch(/verschreibst NICHT/);
});
it("English prompt keeps ECG device-verdict-only and forbids interpretation", () => {
const prompt = getStrictInsightsSystemPrompt("en");
// Attribution + existence allowed; interpretation of the trace forbidden.
expect(prompt).toMatch(/ECG recordings are DEVICE-VERDICT ONLY/);
expect(prompt).toMatch(/never read morphology/i);
expect(prompt).toMatch(/never measure intervals/i);
expect(prompt).toMatch(/attribute its result to the device/i);
});
it("German prompt keeps ECG device-verdict-only and forbids interpretation", () => {
const prompt = getStrictInsightsSystemPrompt("de");
expect(prompt).toMatch(/EKG-Aufzeichnungen sind NUR das GERÄTE-URTEIL/);
expect(prompt).toMatch(/keine Morphologie/i);
expect(prompt).toMatch(/dem Gerät zuschreiben/i);
});
it("English prompt instructs the doctor-consult call-to-action", () => {
const prompt = getStrictInsightsSystemPrompt("en");
expect(prompt).toMatch(/consult your doctor/i);
+14
View File
@@ -153,6 +153,20 @@ function authoritativeValues(
push(w.latest.daysAgo);
}
}
// S10 — ECG device-verdict descriptor. Admit the pre-computed counts + the
// latest recording's device-reported HR / recency so a briefing that
// references the recordings ("your device logged 2 ECG recordings this
// month") stays grounded. Only server-computed figures are added; the
// waveform never reaches the payload, so no trace-derived number exists.
const ecg = features.ecg;
if (ecg) {
push(ecg.recordingCount);
push(ecg.deviceVerdicts.irregular);
push(ecg.deviceVerdicts.notDetected);
push(ecg.deviceVerdicts.inconclusive);
push(ecg.latestRecordedDaysAgo);
push(ecg.latestAverageHeartRate);
}
// v1.25.1 — clinical-depth aggregate blocks (grip / waist / pain) wired
// into the briefing. Admit their pre-computed figures so the prose may cite
// them, same posture as the glucose / labs / workout blocks above.
+33 -2
View File
@@ -397,7 +397,22 @@ gib die obige Verweigerung zurück.`,
typical mid-titration. Worth mentioning at the next visit if
it persists." This is a SAFETY contract; treat any
dose-prescriptive instinct as a sign the response is
out-of-bounds.`,
out-of-bounds.
14. ECG recordings are DEVICE-VERDICT ONLY. When the snapshot
carries an "ecg" block, it holds ONLY counts, the RECORDING
DEVICE's own verdicts (deviceVerdicts / latestDeviceVerdict:
irregular / notDetected / inconclusive), and the latest
recording's recency + average heart rate. You NEVER see the
waveform and you NEVER interpret one. You may note that a
recording exists and attribute its result to the device —
"your device logged an ECG on <date> and flagged a possible
irregular result" — and, for a non-normal device verdict,
suggest discussing that recording with a clinician. You must
NEVER present the verdict as your own, never read morphology,
never measure intervals or beats, never assign a rhythm
diagnosis, and never state or imply that HealthLog analysed the
trace. If the "ecg" block is absent, stay silent about ECG —
do not mention its absence or suggest recording one.`,
de: `GRUNDREGELN — NULL HALLUZINATIONEN
1. Jede Aussage in "summary" muss auf einer Zahl beruhen, die im
übergebenen Datenpaket sichtbar ist. Lässt sich die Aussage nicht
@@ -601,7 +616,23 @@ gib die obige Verweigerung zurück.`,
mid-titration Phase. Lohnt sich beim nächsten Termin
anzusprechen, falls es darüber hinaus persistiert." Das ist
ein SICHERHEITS-Vertrag; behandle jeden dosis-präskriptiven
Impuls als Signal, dass die Antwort außerhalb des Skopus liegt.`,
Impuls als Signal, dass die Antwort außerhalb des Skopus liegt.
14. EKG-Aufzeichnungen sind NUR das GERÄTE-URTEIL. Enthält der
Snapshot einen "ecg"-Block, trägt er AUSSCHLIESSLICH Zählungen,
die EIGENEN Urteile des AUFZEICHNUNGSGERÄTS (deviceVerdicts /
latestDeviceVerdict: irregular / notDetected / inconclusive)
sowie Aktualität und durchschnittliche Herzfrequenz der letzten
Aufzeichnung. Du siehst NIE die Kurve und interpretierst sie
NIE. Du darfst erwähnen, dass eine Aufzeichnung existiert, und
das Ergebnis dem Gerät zuschreiben — "dein Gerät hat am <Datum>
ein EKG aufgezeichnet und ein mögliches unregelmäßiges Ergebnis
markiert" — und bei einem nicht-normalen Geräte-Urteil anregen,
diese Aufzeichnung mit einer Ärztin zu besprechen. Du darfst das
Urteil NIEMALS als dein eigenes ausgeben, keine Morphologie
lesen, keine Intervalle oder Schläge vermessen, keine
Rhythmus-Diagnose stellen und niemals andeuten, dass HealthLog
die Kurve analysiert hat. Fehlt der "ecg"-Block, schweige zum
Thema EKG — erwähne weder sein Fehlen noch rege eine Aufzeichnung an.`,
},
{
id: "guidelineTargets",
+119
View File
@@ -534,3 +534,122 @@ describe("buildDailyDigest — S11 tension_window item", () => {
expect(tension(d)).toBeUndefined();
});
});
describe("buildDailyDigest — ecg_new_recording (S10)", () => {
const ecgItem = (d: ReturnType<typeof buildDailyDigest>) =>
d.worthALook.find((i) => i.kind === "ecg_new_recording");
it("emits ONE calm item for a recording within the last day", () => {
const d = buildDailyDigest(
input({
latestEcg: {
recordedAt: new Date(NOW.getTime() - 3 * 60 * 60 * 1000),
deviceVerdict: "NOT_DETECTED",
},
}),
t,
);
const item = ecgItem(d);
expect(item).toBeDefined();
expect(item?.status).toBe("info");
expect(item?.moduleKey).toBe("insights");
// Single action, deep-linking the ECG viewer.
expect(item?.actions).toHaveLength(1);
expect(item?.actions[0].intent).toBe("ecg.view");
expect(item?.actions[0].href).toBe("/insights#ecg");
});
it("attributes the verdict to the DEVICE (never a HealthLog reading)", () => {
const d = buildDailyDigest(
input({
latestEcg: {
recordedAt: new Date(NOW.getTime() - 60 * 60 * 1000),
deviceVerdict: "IRREGULAR",
},
}),
t,
);
const item = ecgItem(d);
// Copy leads with the device as the actor, echoes only its verdict, and
// never claims HealthLog interpreted the trace.
expect(item?.body).toContain("Your device recorded");
expect(item?.body).toContain("possible irregular rhythm");
expect(item?.body).not.toMatch(/we (detected|found|think)|HealthLog/i);
});
it("does not emit for an OLD recording (outside the last-day window)", () => {
const d = buildDailyDigest(
input({
latestEcg: {
recordedAt: new Date(NOW.getTime() - 2 * DAY),
deviceVerdict: "IRREGULAR",
},
}),
t,
);
expect(ecgItem(d)).toBeUndefined();
});
it("does not emit a future-dated recording (clock-skew guard)", () => {
const d = buildDailyDigest(
input({
latestEcg: {
recordedAt: new Date(NOW.getTime() + 60 * 60 * 1000),
deviceVerdict: "NOT_DETECTED",
},
}),
t,
);
expect(ecgItem(d)).toBeUndefined();
});
it("does not emit when the insights module is off", () => {
const d = buildDailyDigest(
input({
modules: { insights: false },
latestEcg: {
recordedAt: new Date(NOW.getTime() - 60 * 60 * 1000),
deviceVerdict: "IRREGULAR",
},
}),
t,
);
expect(ecgItem(d)).toBeUndefined();
});
it("emits nothing when there is no recent recording", () => {
const d = buildDailyDigest(input({ latestEcg: null }), t);
expect(ecgItem(d)).toBeUndefined();
});
it("uses the calm, verdict-less body when the device gave no verdict", () => {
const d = buildDailyDigest(
input({
latestEcg: {
recordedAt: new Date(NOW.getTime() - 60 * 60 * 1000),
deviceVerdict: null,
},
}),
t,
);
const item = ecgItem(d);
expect(item?.body).toBe(
"Your device recorded a new ECG — it's ready to view.",
);
});
it("carries no waveform / sample data on the item or its input DTO", () => {
const latestEcg = {
recordedAt: new Date(NOW.getTime() - 60 * 60 * 1000),
deviceVerdict: "NOT_DETECTED" as const,
};
// The input DTO is verdict + recordedAt only — no waveform channel exists.
expect(Object.keys(latestEcg).sort()).toEqual([
"deviceVerdict",
"recordedAt",
]);
const d = buildDailyDigest(input({ latestEcg }), t);
const serialised = JSON.stringify(ecgItem(d));
expect(serialised).not.toMatch(/waveform|sample|signal|voltage|microvolt/i);
});
});
+88
View File
@@ -108,6 +108,18 @@ export interface DailyDigestTensionWindow {
partOfDay: "morning" | "afternoon" | "evening" | "night";
}
/**
* S10 — the latest ECG recording, for the `ecg_new_recording` rail item. Only
* ever the DEVICE's verdict + when it was recorded — NEVER the waveform (the
* DTO carries no samples). The builder decides "new" from `recordedAt`; the
* card attributes any verdict to the recording device.
*/
export interface DailyDigestEcg {
recordedAt: Date;
/** The recording device's OWN verdict, or null when unclassified. */
deviceVerdict: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
}
/**
* A standing coach plan the IO seam offers as a check-in candidate (§2.3). The
* builder computes due-ness deterministically from the plain columns — it never
@@ -160,6 +172,12 @@ export interface DailyDigestInput {
milestone?: Milestone | null;
/** S11 — the day's detected elevated-at-rest window, or null (honest-absent). */
tensionWindow: DailyDigestTensionWindow | null;
/**
* S10 — the freshest ECG recording (device verdict + recordedAt only), or
* null. Optional so consumers that predate the ECG weave stay valid; the
* builder treats a missing value as "no recent recording".
*/
latestEcg?: DailyDigestEcg | null;
}
export interface DailyDigest {
@@ -424,6 +442,69 @@ function buildCoachCheckinItem(
};
}
/**
* S10 — how recently an ECG recording must have landed to count as "new" for
* the rail (§3.5.3). A calendar-day window: a recording synced within the last
* day surfaces once, then retires on its own (no persisted "seen" marker, no
* migration). The 24h window is itself the one/day cap — at most one recording
* is the freshest, and it drops off the rail after a day.
*/
const ECG_NEW_WINDOW_MS = MS_PER_DAY;
/** Device verdict → the calm, device-attributed verdict key for the card body. */
const ECG_VERDICT_KEYS: Record<
NonNullable<DailyDigestEcg["deviceVerdict"]>,
string
> = {
IRREGULAR: "daily.item.ecgNewRecording.verdict.irregular",
NOT_DETECTED: "daily.item.ecgNewRecording.verdict.notDetected",
INCONCLUSIVE: "daily.item.ecgNewRecording.verdict.inconclusive",
};
/**
* The one ECG "new recording" item (§3.5.3). Gated on the `insights` module
* (the ECG viewer's own gate). Fires ONLY when the freshest recording landed
* within the last day — a calm "a new ECG recording is ready to view" pointer
* into the viewer. NON-DIAGNOSTIC: the body echoes ONLY the RECORDING DEVICE's
* verdict, attributed to the device; HealthLog never interprets the trace (the
* DTO carries no waveform). The 24h window caps it at one/day and retires it on
* its own without a persisted seen-marker.
*/
function buildEcgNewRecordingItem(
ecg: DailyDigestEcg | null | undefined,
modules: DigestModuleMap,
now: Date,
t: Translate,
): PriorityItem | null {
if (!moduleEnabled(modules, "insights")) return null;
if (!ecg) return null;
const age = now.getTime() - ecg.recordedAt.getTime();
// Skip a future-dated row (clock skew) and anything older than the window.
if (age < 0 || age > ECG_NEW_WINDOW_MS) return null;
const verdictKey = ecg.deviceVerdict
? ECG_VERDICT_KEYS[ecg.deviceVerdict]
: null;
const body = verdictKey
? t("daily.item.ecgNewRecording.bodyVerdict", { verdict: t(verdictKey) })
: t("daily.item.ecgNewRecording.body");
return {
kind: "ecg_new_recording",
title: t("daily.item.ecgNewRecording.title"),
body,
status: "info",
actions: [
{
labelKey: "daily.action.viewEcg",
intent: "ecg.view",
href: "/insights#ecg",
},
],
moduleKey: "insights",
};
}
/**
* The push / lock-screen line: prefer the warmer cached briefing lead, fall
* back to the top signal's headline, then a deterministic score floor, then
@@ -485,6 +566,13 @@ export function buildDailyDigest(
t,
);
if (checkin) worthALook.push(checkin);
const ecg = buildEcgNewRecordingItem(
input.latestEcg,
input.modules,
input.now,
t,
);
if (ecg) worthALook.push(ecg);
const preventive = buildPreventiveCareItem(input.preventiveDue, t);
if (preventive) worthALook.push(preventive);
// S11 — the calm, informational tension marker sits last: it is context, not
+36
View File
@@ -26,6 +26,7 @@ import {
buildDailyDigest,
type DailyDigest,
type DailyDigestCoachPlan,
type DailyDigestEcg,
type DailyDigestPreventiveDue,
type DailyDigestScore,
type DailyDigestSyncIssue,
@@ -134,6 +135,9 @@ async function gatherFreshMilestone(
return selectFreshMilestone(candidates, todayKey);
}
/** S10 — how recently an ECG recording counts as "new" for the rail read. */
const ECG_NEW_WINDOW_MS = 24 * 60 * 60 * 1000;
interface CoachPlanRow {
id: string;
status: string;
@@ -183,6 +187,7 @@ export async function loadDailyDigest(
syncRows,
dueReminders,
planRows,
ecgRow,
] = await Promise.all([
readDashboardSnapshotCached(user),
resolveModuleMap(user.id),
@@ -224,6 +229,18 @@ export async function loadDailyDigest(
orderBy: { updatedAt: "desc" },
take: CHECKIN_PLAN_READ_LIMIT,
}),
// S10 — the freshest ECG recording within the last-day window, for the
// `ecg_new_recording` rail item. Descriptors only (verdict + recordedAt) —
// the encrypted waveform is never selected, so no decrypt and no trace
// crosses into the digest. The builder gates on the `insights` module.
prisma.ecgRecording.findFirst({
where: {
userId: user.id,
recordedAt: { gte: new Date(now.getTime() - ECG_NEW_WINDOW_MS) },
},
orderBy: { recordedAt: "desc" },
select: { recordedAt: true, rhythmClassification: true },
}),
]);
const score: DailyDigestScore | null = snapshot.healthScore
@@ -256,6 +273,24 @@ export async function loadDailyDigest(
label: row.label,
}));
// S10 — the freshest ECG recording (device verdict + recordedAt only). The
// builder decides "new" and gates on the `insights` module. An ECG row only
// ever carries an AFib-screening verdict; the shared enum's other members
// (walking-steadiness / event codes) never apply, so anything else maps to
// null rather than leaking into the device-verdict copy.
const verdict = ecgRow?.rhythmClassification;
const latestEcg: DailyDigestEcg | null = ecgRow
? {
recordedAt: ecgRow.recordedAt,
deviceVerdict:
verdict === "IRREGULAR" ||
verdict === "NOT_DETECTED" ||
verdict === "INCONCLUSIVE"
? verdict
: null,
}
: null;
// Only decrypt plan prose for a coach-enabled account; the builder gates on
// the module too, so a disabled coach never surfaces a check-in either way.
const coachEnabled = modules.coach !== false;
@@ -297,6 +332,7 @@ export async function loadDailyDigest(
coachPlans,
milestone,
tensionWindow,
latestEcg,
},
t,
);
@@ -13,6 +13,7 @@ vi.mock("@/lib/db", () => ({
labResult: { findMany: vi.fn() },
measurementReminder: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
ecgRecording: { findMany: vi.fn() },
$queryRawUnsafe: vi.fn(),
},
}));
@@ -63,6 +64,7 @@ const prismaMock = prisma as unknown as {
labResult: { findMany: ReturnType<typeof vi.fn> };
measurementReminder: { findMany: ReturnType<typeof vi.fn> };
workout: { findMany: ReturnType<typeof vi.fn> };
ecgRecording: { findMany: ReturnType<typeof vi.fn> };
};
const dayMs = 24 * 60 * 60 * 1000;
@@ -100,6 +102,7 @@ beforeEach(() => {
prismaMock.labResult.findMany.mockResolvedValue([]);
prismaMock.measurementReminder.findMany.mockResolvedValue([]);
prismaMock.workout.findMany.mockResolvedValue([]);
prismaMock.ecgRecording.findMany.mockResolvedValue([]);
});
describe("extractFeatures — v1.4.36 W3 bucketed payload", () => {
@@ -433,6 +436,8 @@ describe("extractFeatures — v1.22 integration blocks are briefing-scoped", ()
expect(prismaMock.labResult.findMany).not.toHaveBeenCalled();
expect(prismaMock.measurementReminder.findMany).not.toHaveBeenCalled();
expect(prismaMock.workout.findMany).not.toHaveBeenCalled();
// S10 — the ECG descriptor rides the same briefing-scoped gate.
expect(prismaMock.ecgRecording.findMany).not.toHaveBeenCalled();
});
it("runs the extra reads on the wide briefing window (sinceDays: 400)", async () => {
@@ -440,5 +445,53 @@ describe("extractFeatures — v1.22 integration blocks are briefing-scoped", ()
expect(prismaMock.labResult.findMany).toHaveBeenCalled();
expect(prismaMock.measurementReminder.findMany).toHaveBeenCalled();
expect(prismaMock.workout.findMany).toHaveBeenCalled();
expect(prismaMock.ecgRecording.findMany).toHaveBeenCalled();
});
});
describe("extractFeatures — S10 ECG device-verdict descriptor", () => {
const DAY = 86_400_000;
it("emits a device-verdict descriptor and NEVER a waveform", async () => {
const now = Date.now();
prismaMock.ecgRecording.findMany.mockResolvedValue([
{
recordedAt: new Date(now - 2 * DAY),
rhythmClassification: "IRREGULAR",
averageHeartRate: 72,
},
{
recordedAt: new Date(now - 10 * DAY),
rhythmClassification: "NOT_DETECTED",
averageHeartRate: 61,
},
]);
const f = await extractFeatures("user-1", false);
expect(f.ecg?.recordingCount).toBe(2);
expect(f.ecg?.deviceVerdicts).toEqual({
irregular: 1,
notDetected: 1,
inconclusive: 0,
});
// The LATEST recording's device verdict + HR (newest-first ordering).
expect(f.ecg?.latestDeviceVerdict).toBe("IRREGULAR");
expect(f.ecg?.latestAverageHeartRate).toBe(72);
// The waveform never reaches the payload — only descriptors.
expect(JSON.stringify(f.ecg)).not.toMatch(
/waveform|sample|signal|voltage|microvolt/i,
);
});
it("omits the ECG block entirely when the user has no recordings", async () => {
const f = await extractFeatures("user-1", false);
expect(f.ecg).toBeUndefined();
});
it("only ever reads descriptor columns, never the encrypted waveform", async () => {
await extractFeatures("user-1", false);
const call = prismaMock.ecgRecording.findMany.mock.calls[0]?.[0];
expect(call?.select?.waveformEncrypted).toBeUndefined();
expect(call?.select?.recordedAt).toBe(true);
expect(call?.select?.rhythmClassification).toBe(true);
});
});
+68
View File
@@ -114,6 +114,12 @@ const LABS_LOOKBACK_MONTHS = 12;
/** Trailing window (days) for the workout aggregate. */
const WORKOUT_WINDOW_DAYS = 90;
/** Trailing window (days) for the ECG recording descriptor. */
const ECG_WINDOW_DAYS = 90;
/** Defensive cap on ECG rows read for the descriptor (a bound, not a ceiling). */
const ECG_MAX_ROWS = 500;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/**
@@ -347,6 +353,68 @@ export async function readWorkoutsBlock(
return { last7: tally(7), last30: tally(30), latest };
}
/**
* S10 — ECG recording descriptor for the briefing narrative (device-verdict
* only). A bounded, non-diagnostic snapshot of the user's on-device ECG
* recordings over the trailing window: how many exist, the distribution of the
* RECORDING DEVICE's OWN verdicts (never HealthLog's), and the latest
* recording's device verdict + average heart rate. The waveform is NEVER read
* (no decrypt on this path) and NEVER enters the payload — the trace does not
* cross into the prompt, so the model cannot interpret morphology. The prompt
* (insight-generator rule) constrains any mention to the device's attribution;
* the grounding gate constrains any restated count. Returns `undefined` when no
* recordings fall in the window, so the block is omitted rather than emitting an
* empty shape.
*/
export async function readEcgBriefingBlock(
userId: string,
now: number,
): Promise<AggregatedFeatures["ecg"] | undefined> {
const since = new Date(now - ECG_WINDOW_DAYS * MS_PER_DAY);
const rows = await prisma.ecgRecording.findMany({
where: { userId, recordedAt: { gte: since } },
orderBy: { recordedAt: "desc" },
take: ECG_MAX_ROWS,
// Descriptors only — the encrypted waveform blob is never selected, so no
// decrypt happens and no trace can reach the narrative.
select: {
recordedAt: true,
rhythmClassification: true,
averageHeartRate: true,
},
});
if (rows.length === 0) return undefined;
const deviceVerdicts = { irregular: 0, notDetected: 0, inconclusive: 0 };
for (const r of rows) {
if (r.rhythmClassification === "IRREGULAR") deviceVerdicts.irregular += 1;
else if (r.rhythmClassification === "NOT_DETECTED")
deviceVerdicts.notDetected += 1;
else if (r.rhythmClassification === "INCONCLUSIVE")
deviceVerdicts.inconclusive += 1;
}
// An ECG row only ever carries an AFib-screening verdict; the shared enum's
// other members (walking-steadiness / event codes) never apply here, so
// anything else narrows to null.
const latest = rows[0];
const latestVerdict = latest.rhythmClassification;
return {
recordingCount: rows.length,
deviceVerdicts,
latestDeviceVerdict:
latestVerdict === "IRREGULAR" ||
latestVerdict === "NOT_DETECTED" ||
latestVerdict === "INCONCLUSIVE"
? latestVerdict
: null,
latestRecordedDaysAgo: Math.round(
(now - latest.recordedAt.getTime()) / MS_PER_DAY,
),
latestAverageHeartRate: latest.averageHeartRate,
};
}
/**
* Read every BUCKETED_TYPES × BUCKET_WINDOWS combination from the
* persistent rollup table and project to the wire shape. Empty
+34 -1
View File
@@ -33,6 +33,7 @@ import {
import {
readAllTimeExtremes,
readBucketedSeries,
readEcgBriefingBlock,
readLabsBriefingBlock,
readPreventiveCareBlock,
readWorkoutsBlock,
@@ -321,6 +322,33 @@ export interface AggregatedFeatures {
distanceKm: number | null;
} | null;
};
/**
* S10 — ECG recording descriptor (device-verdict only, NON-DIAGNOSTIC). A
* bounded, existence-and-count read of the user's on-device ECG recordings so
* the nightly narrative can reference them FACTUALLY and attributed to the
* device. `deviceVerdicts` / `latestDeviceVerdict` are the RECORDING DEVICE's
* OWN classifications — HealthLog never re-classifies, never reads the
* waveform (it never enters this payload), and never emits a verdict of its
* own. The prompt constrains any mention to the device's attribution; the
* grounding gate constrains any restated count. Omitted when no recordings
* fall in the window.
*/
ecg?: {
/** Number of ECG recordings in the trailing window. */
recordingCount: number;
/** Distribution of the DEVICE's own verdicts across the window. */
deviceVerdicts: {
irregular: number;
notDetected: number;
inconclusive: number;
};
/** The latest recording's DEVICE verdict, or null when unclassified. */
latestDeviceVerdict: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
/** Days since the latest recording. */
latestRecordedDaysAgo: number;
/** The latest recording's device-reported average HR, or null. */
latestAverageHeartRate: number | null;
};
}
/**
@@ -1316,14 +1344,19 @@ export async function extractFeatures(
(typeof sinceDays === "number" &&
sinceDays >= INTEGRATION_BLOCK_MIN_WINDOW_DAYS);
if (includeIntegrations) {
const [labs, preventiveCare, workouts] = await Promise.all([
const [labs, preventiveCare, workouts, ecg] = await Promise.all([
readLabsBriefingBlock(userId, now),
readPreventiveCareBlock(userId, now),
readWorkoutsBlock(userId, now),
// S10 — ECG device-verdict descriptor (never the waveform). Weaves the
// ECG silo into the narrative; the model may reference it, attributed to
// the device, but never interprets the trace (it never sees one).
readEcgBriefingBlock(userId, now),
]);
if (labs) features.labs = labs;
if (preventiveCare) features.preventiveCare = preventiveCare;
if (workouts) features.workouts = workouts;
if (ecg) features.ecg = ecg;
}
// v1.4.36 W3 T1 — "raw" mode no longer dumps every measurement row