mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
merge: assemble capstone signal slices (S10 ECG narrative, S11 intraday pulse, S12 milestones)
This commit is contained in:
commit
8ac38926c4
20 files changed
+806
-10
No files matched your search
+19
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user