From 135141a750b3b1cadd4871bf07dacccb65dfbed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:00:14 +0200 Subject: [PATCH] feat(insights): surface device-flagged events as an awareness timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a timeline of the health notifications the user's device produced on its own, reachable from the Insights overview. The surface reflects the device's own decision: each row reads "Your device flagged …" and a permanent disclaimer states this is not a medical assessment by HealthLog and that HealthLog does not diagnose any condition. The surface is data-availability-gated — it un-mounts entirely when the user has no such events, so an account that never triggered one never sees an empty or alarming card. Copy renders as plain React text children; no markdown library is involved. --- messages/de.json | 30 ++- messages/en.json | 30 ++- messages/es.json | 30 ++- messages/fr.json | 30 ++- messages/it.json | 30 ++- messages/pl.json | 30 ++- .../rhythm-events/__tests__/route.test.ts | 129 +++++++++++++ src/app/api/insights/rhythm-events/route.ts | 83 +++++++++ src/app/insights/page.tsx | 14 ++ .../insights/rhythm-events-card.tsx | 176 ++++++++++++++++++ src/lib/query-keys.ts | 9 + 11 files changed, 579 insertions(+), 12 deletions(-) create mode 100644 src/app/api/insights/rhythm-events/__tests__/route.test.ts create mode 100644 src/app/api/insights/rhythm-events/route.ts create mode 100644 src/components/insights/rhythm-events-card.tsx diff --git a/messages/de.json b/messages/de.json index 76ca8c928..c06e57647 100644 --- a/messages/de.json +++ b/messages/de.json @@ -432,7 +432,12 @@ "typeSixMinuteWalkDistance": "6-Minuten-Gehstrecke", "typeStairAscentSpeed": "Treppensteig-Tempo", "typeStairDescentSpeed": "Treppab-Tempo", - "typeBreathingDisturbances": "Atemaussetzer im Schlaf" + "typeBreathingDisturbances": "Atemaussetzer im Schlaf", + "typeIrregularRhythmNotification": "Hinweis auf unregelmäßigen Rhythmus", + "typeHighHeartRateEvent": "Hinweis auf hohe Herzfrequenz", + "typeLowHeartRateEvent": "Hinweis auf niedrige Herzfrequenz", + "typeWalkingSteadinessEvent": "Hinweis zur Gehstabilität", + "typeBreathingDisturbanceEvent": "Atemstörung" }, "mood": { "title": "Stimmung", @@ -2649,7 +2654,28 @@ "description": "Werte erscheinen hier, sobald Apple Health sie von einer unterstützten Apple Watch synchronisiert." } }, - "navBreathingDisturbances": "Atmung" + "navBreathingDisturbances": "Atmung", + "rhythmEvents": { + "sectionTitle": "Gesundheitshinweise des Geräts", + "sectionIntro": "Hinweise, die deine Uhr eigenständig gesendet hat. HealthLog zeigt diese nur an — es erkennt, bestätigt oder interpretiert sie nicht.", + "loadError": "Die Gerätehinweise konnten nicht geladen werden.", + "disclaimer": "Dies sind Hinweise, die dein Gerät mit seiner eigenen integrierten Erkennung erzeugt hat. Sie werden hier nur zu deiner Information angezeigt. Dies ist keine medizinische Bewertung durch HealthLog, und HealthLog stellt keine Diagnose. Wenn dich ein Hinweis beunruhigt, sprich mit deiner Ärztin oder deinem Arzt.", + "event": { + "irregularRhythm": "Hinweis auf unregelmäßigen Rhythmus", + "highHeartRate": "Hinweis auf hohe Herzfrequenz", + "lowHeartRate": "Hinweis auf niedrige Herzfrequenz", + "walkingSteadiness": "Hinweis zur Gehstabilität", + "breathingDisturbance": "Atemstörung während des Schlafs" + }, + "verdict": { + "irregular": "Dein Gerät hat einen möglichen unregelmäßigen Rhythmus erkannt.", + "notDetected": "Dein Gerät hat keinen unregelmäßigen Rhythmus erkannt.", + "inconclusive": "Dein Gerät konnte keine Messung vornehmen.", + "low": "Dein Gerät hat eine niedrige Gehstabilität erkannt.", + "veryLow": "Dein Gerät hat eine sehr niedrige Gehstabilität erkannt.", + "fired": "Dein Gerät hat dieses Ereignis erkannt." + } + } }, "thresholds": { "defaultLabel": "Default", diff --git a/messages/en.json b/messages/en.json index 813bd8806..3f7169300 100644 --- a/messages/en.json +++ b/messages/en.json @@ -432,7 +432,12 @@ "typeSixMinuteWalkDistance": "Six-minute walk distance", "typeStairAscentSpeed": "Stair ascent speed", "typeStairDescentSpeed": "Stair descent speed", - "typeBreathingDisturbances": "Sleep breathing disturbances" + "typeBreathingDisturbances": "Sleep breathing disturbances", + "typeIrregularRhythmNotification": "Irregular rhythm notification", + "typeHighHeartRateEvent": "High heart rate notification", + "typeLowHeartRateEvent": "Low heart rate notification", + "typeWalkingSteadinessEvent": "Walking steadiness alert", + "typeBreathingDisturbanceEvent": "Breathing disturbance" }, "mood": { "title": "Mood", @@ -2649,7 +2654,28 @@ "description": "Sleep-breathing readings appear here once Apple Health syncs from a supported Apple Watch." } }, - "navBreathingDisturbances": "Breathing" + "navBreathingDisturbances": "Breathing", + "rhythmEvents": { + "sectionTitle": "Device health notifications", + "sectionIntro": "Notifications your watch sent on its own. HealthLog only shows these — it does not detect, confirm, or interpret them.", + "loadError": "Could not load your device notifications.", + "disclaimer": "These are alerts your device produced using its own built-in detection. They are shown here for your awareness only. This is not a medical assessment by HealthLog, and HealthLog does not diagnose any condition. If a notification concerns you, talk to your doctor.", + "event": { + "irregularRhythm": "Irregular rhythm notification", + "highHeartRate": "High heart rate notification", + "lowHeartRate": "Low heart rate notification", + "walkingSteadiness": "Walking steadiness alert", + "breathingDisturbance": "Breathing disturbance during sleep" + }, + "verdict": { + "irregular": "Your device flagged a possible irregular rhythm.", + "notDetected": "Your device did not flag an irregular rhythm.", + "inconclusive": "Your device could not make a reading.", + "low": "Your device flagged low walking steadiness.", + "veryLow": "Your device flagged very low walking steadiness.", + "fired": "Your device flagged this event." + } + } }, "thresholds": { "defaultLabel": "Default", diff --git a/messages/es.json b/messages/es.json index 97b5ae0d6..4fbcaa19d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -432,7 +432,12 @@ "typeSixMinuteWalkDistance": "Distancia en marcha de 6 minutos", "typeStairAscentSpeed": "Velocidad subiendo escaleras", "typeStairDescentSpeed": "Velocidad bajando escaleras", - "typeBreathingDisturbances": "Alteraciones respiratorias del sueño" + "typeBreathingDisturbances": "Alteraciones respiratorias del sueño", + "typeIrregularRhythmNotification": "Irregular rhythm notification", + "typeHighHeartRateEvent": "High heart rate notification", + "typeLowHeartRateEvent": "Low heart rate notification", + "typeWalkingSteadinessEvent": "Walking steadiness alert", + "typeBreathingDisturbanceEvent": "Breathing disturbance" }, "mood": { "title": "Estado de ánimo", @@ -2649,7 +2654,28 @@ "description": "Los valores aparecen aquí cuando Apple Health los sincroniza desde un Apple Watch compatible." } }, - "navBreathingDisturbances": "Respiración" + "navBreathingDisturbances": "Respiración", + "rhythmEvents": { + "sectionTitle": "Device health notifications", + "sectionIntro": "Notifications your watch sent on its own. HealthLog only shows these — it does not detect, confirm, or interpret them.", + "loadError": "Could not load your device notifications.", + "disclaimer": "These are alerts your device produced using its own built-in detection. They are shown here for your awareness only. This is not a medical assessment by HealthLog, and HealthLog does not diagnose any condition. If a notification concerns you, talk to your doctor.", + "event": { + "irregularRhythm": "Irregular rhythm notification", + "highHeartRate": "High heart rate notification", + "lowHeartRate": "Low heart rate notification", + "walkingSteadiness": "Walking steadiness alert", + "breathingDisturbance": "Breathing disturbance during sleep" + }, + "verdict": { + "irregular": "Your device flagged a possible irregular rhythm.", + "notDetected": "Your device did not flag an irregular rhythm.", + "inconclusive": "Your device could not make a reading.", + "low": "Your device flagged low walking steadiness.", + "veryLow": "Your device flagged very low walking steadiness.", + "fired": "Your device flagged this event." + } + } }, "thresholds": { "defaultLabel": "Por defecto", diff --git a/messages/fr.json b/messages/fr.json index 4cfede66c..9a8ca4b09 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -432,7 +432,12 @@ "typeSixMinuteWalkDistance": "Distance de marche de 6 minutes", "typeStairAscentSpeed": "Vitesse en montée d'escaliers", "typeStairDescentSpeed": "Vitesse en descente d'escaliers", - "typeBreathingDisturbances": "Troubles respiratoires du sommeil" + "typeBreathingDisturbances": "Troubles respiratoires du sommeil", + "typeIrregularRhythmNotification": "Irregular rhythm notification", + "typeHighHeartRateEvent": "High heart rate notification", + "typeLowHeartRateEvent": "Low heart rate notification", + "typeWalkingSteadinessEvent": "Walking steadiness alert", + "typeBreathingDisturbanceEvent": "Breathing disturbance" }, "mood": { "title": "Humeur", @@ -2649,7 +2654,28 @@ "description": "Les valeurs apparaissent ici dès qu'Apple Santé les synchronise depuis une Apple Watch compatible." } }, - "navBreathingDisturbances": "Respiration" + "navBreathingDisturbances": "Respiration", + "rhythmEvents": { + "sectionTitle": "Device health notifications", + "sectionIntro": "Notifications your watch sent on its own. HealthLog only shows these — it does not detect, confirm, or interpret them.", + "loadError": "Could not load your device notifications.", + "disclaimer": "These are alerts your device produced using its own built-in detection. They are shown here for your awareness only. This is not a medical assessment by HealthLog, and HealthLog does not diagnose any condition. If a notification concerns you, talk to your doctor.", + "event": { + "irregularRhythm": "Irregular rhythm notification", + "highHeartRate": "High heart rate notification", + "lowHeartRate": "Low heart rate notification", + "walkingSteadiness": "Walking steadiness alert", + "breathingDisturbance": "Breathing disturbance during sleep" + }, + "verdict": { + "irregular": "Your device flagged a possible irregular rhythm.", + "notDetected": "Your device did not flag an irregular rhythm.", + "inconclusive": "Your device could not make a reading.", + "low": "Your device flagged low walking steadiness.", + "veryLow": "Your device flagged very low walking steadiness.", + "fired": "Your device flagged this event." + } + } }, "thresholds": { "defaultLabel": "Par défaut", diff --git a/messages/it.json b/messages/it.json index 95d1fba57..68b53abe9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -432,7 +432,12 @@ "typeSixMinuteWalkDistance": "Distanza nel cammino di 6 minuti", "typeStairAscentSpeed": "Velocità in salita scale", "typeStairDescentSpeed": "Velocità in discesa scale", - "typeBreathingDisturbances": "Disturbi respiratori nel sonno" + "typeBreathingDisturbances": "Disturbi respiratori nel sonno", + "typeIrregularRhythmNotification": "Irregular rhythm notification", + "typeHighHeartRateEvent": "High heart rate notification", + "typeLowHeartRateEvent": "Low heart rate notification", + "typeWalkingSteadinessEvent": "Walking steadiness alert", + "typeBreathingDisturbanceEvent": "Breathing disturbance" }, "mood": { "title": "Umore", @@ -2649,7 +2654,28 @@ "description": "I valori compaiono qui quando Apple Health li sincronizza da un Apple Watch compatibile." } }, - "navBreathingDisturbances": "Respirazione" + "navBreathingDisturbances": "Respirazione", + "rhythmEvents": { + "sectionTitle": "Device health notifications", + "sectionIntro": "Notifications your watch sent on its own. HealthLog only shows these — it does not detect, confirm, or interpret them.", + "loadError": "Could not load your device notifications.", + "disclaimer": "These are alerts your device produced using its own built-in detection. They are shown here for your awareness only. This is not a medical assessment by HealthLog, and HealthLog does not diagnose any condition. If a notification concerns you, talk to your doctor.", + "event": { + "irregularRhythm": "Irregular rhythm notification", + "highHeartRate": "High heart rate notification", + "lowHeartRate": "Low heart rate notification", + "walkingSteadiness": "Walking steadiness alert", + "breathingDisturbance": "Breathing disturbance during sleep" + }, + "verdict": { + "irregular": "Your device flagged a possible irregular rhythm.", + "notDetected": "Your device did not flag an irregular rhythm.", + "inconclusive": "Your device could not make a reading.", + "low": "Your device flagged low walking steadiness.", + "veryLow": "Your device flagged very low walking steadiness.", + "fired": "Your device flagged this event." + } + } }, "thresholds": { "defaultLabel": "Predefinito", diff --git a/messages/pl.json b/messages/pl.json index db5d8c5a8..917d7ecb9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -432,7 +432,12 @@ "typeSixMinuteWalkDistance": "Dystans marszu 6-minutowego", "typeStairAscentSpeed": "Prędkość wchodzenia po schodach", "typeStairDescentSpeed": "Prędkość schodzenia po schodach", - "typeBreathingDisturbances": "Zaburzenia oddychania we śnie" + "typeBreathingDisturbances": "Zaburzenia oddychania we śnie", + "typeIrregularRhythmNotification": "Irregular rhythm notification", + "typeHighHeartRateEvent": "High heart rate notification", + "typeLowHeartRateEvent": "Low heart rate notification", + "typeWalkingSteadinessEvent": "Walking steadiness alert", + "typeBreathingDisturbanceEvent": "Breathing disturbance" }, "mood": { "title": "Nastrój", @@ -2649,7 +2654,28 @@ "description": "Wartości pojawią się tutaj, gdy Apple Health zsynchronizuje je z obsługiwanego Apple Watch." } }, - "navBreathingDisturbances": "Oddychanie" + "navBreathingDisturbances": "Oddychanie", + "rhythmEvents": { + "sectionTitle": "Device health notifications", + "sectionIntro": "Notifications your watch sent on its own. HealthLog only shows these — it does not detect, confirm, or interpret them.", + "loadError": "Could not load your device notifications.", + "disclaimer": "These are alerts your device produced using its own built-in detection. They are shown here for your awareness only. This is not a medical assessment by HealthLog, and HealthLog does not diagnose any condition. If a notification concerns you, talk to your doctor.", + "event": { + "irregularRhythm": "Irregular rhythm notification", + "highHeartRate": "High heart rate notification", + "lowHeartRate": "Low heart rate notification", + "walkingSteadiness": "Walking steadiness alert", + "breathingDisturbance": "Breathing disturbance during sleep" + }, + "verdict": { + "irregular": "Your device flagged a possible irregular rhythm.", + "notDetected": "Your device did not flag an irregular rhythm.", + "inconclusive": "Your device could not make a reading.", + "low": "Your device flagged low walking steadiness.", + "veryLow": "Your device flagged very low walking steadiness.", + "fired": "Your device flagged this event." + } + } }, "thresholds": { "defaultLabel": "Domyślne", diff --git a/src/app/api/insights/rhythm-events/__tests__/route.test.ts b/src/app/api/insights/rhythm-events/__tests__/route.test.ts new file mode 100644 index 000000000..a94647a50 --- /dev/null +++ b/src/app/api/insights/rhythm-events/__tests__/route.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.10.0 — categorical events (WX-B). `GET /api/insights/rhythm-events`. + * + * The route serves the device-flagged event awareness timeline. The + * load-bearing behaviour under test: it narrows the query to the EVENT + * MeasurementTypes for the authenticated user, returns the device's own + * verdict per row, and reports `hasEvents: false` (the data-availability + * gate the UI keys off) when the user has none. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + measurement: { findMany: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { prisma } from "@/lib/db"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "marc", role: "USER" as const, locale: "en" }, +}; + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(): NextRequest { + return new NextRequest(new URL("http://localhost/api/insights/rhythm-events")); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +describe("GET /api/insights/rhythm-events", () => { + it("returns hasEvents:false with an empty list when the user has no events", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { events: unknown[]; hasEvents: boolean }; + }; + expect(body.data.hasEvents).toBe(false); + expect(body.data.events).toEqual([]); + }); + + it("narrows the query to the EVENT MeasurementTypes for the session user only", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + await callGet(makeReq()); + const where = vi.mocked(prisma.measurement.findMany).mock.calls[0][0] + ?.where as { + userId: string; + type: { in: string[] }; + deletedAt: null; + }; + expect(where.userId).toBe("user-1"); + expect(where.deletedAt).toBeNull(); + expect(where.type.in).toEqual( + expect.arrayContaining([ + "IRREGULAR_RHYTHM_NOTIFICATION", + "HIGH_HEART_RATE_EVENT", + "LOW_HEART_RATE_EVENT", + "WALKING_STEADINESS_EVENT", + "BREATHING_DISTURBANCE_EVENT", + ]), + ); + // Never a continuous metric — only the five event classes. + expect(where.type.in).toHaveLength(5); + }); + + it("maps each row to the device verdict + occurrence timestamp", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { + id: "evt_1", + type: "IRREGULAR_RHYTHM_NOTIFICATION", + rhythmClassification: "IRREGULAR", + measuredAt: new Date("2026-06-01T09:15:00.000Z"), + source: "APPLE_HEALTH", + deviceType: "watch", + }, + ] as never); + const res = await callGet(makeReq()); + const body = (await res.json()) as { + data: { + hasEvents: boolean; + events: Array<{ + id: string; + type: string; + classification: string | null; + occurredAt: string; + }>; + }; + }; + expect(body.data.hasEvents).toBe(true); + expect(body.data.events).toEqual([ + { + id: "evt_1", + type: "IRREGULAR_RHYTHM_NOTIFICATION", + classification: "IRREGULAR", + occurredAt: "2026-06-01T09:15:00.000Z", + source: "APPLE_HEALTH", + deviceType: "watch", + }, + ]); + }); +}); diff --git a/src/app/api/insights/rhythm-events/route.ts b/src/app/api/insights/rhythm-events/route.ts new file mode 100644 index 000000000..9d45cb7d0 --- /dev/null +++ b/src/app/api/insights/rhythm-events/route.ts @@ -0,0 +1,83 @@ +/** + * v1.10.0 — device-flagged event awareness route (categorical events, + * WX-B). + * + * `GET /api/insights/rhythm-events` returns the authenticated user's + * timeline of device-flagged EVENT rows — irregular-rhythm / + * high-HR / low-HR / walking-steadiness / breathing-disturbance + * notifications the user's wearable (Apple Watch / Withings ScanWatch) + * already produced and synced. + * + * Regulatory framing (load-bearing): this surface is AWARENESS / SCREENING + * of the DEVICE's own decision. HealthLog stores and reflects ONLY the + * classification RESULT the device's FDA-cleared / CE-marked on-device + * algorithm emitted. It never ingests a raw ECG waveform, never + * re-classifies, and never produces a HealthLog diagnosis. The verdict the + * client renders (`rhythmClassification`) is the device's, verbatim. + * + * Data-availability-gated by construction: an account with no event rows + * gets `{ events: [], hasEvents: false }`, and the client un-mounts the + * whole surface rather than painting an empty / alarming card. + * + * Follows the `metric-status` / `derived` route precedent: `apiHandler` + * wrapper, cookie OR Bearer auth, `userId` narrowed from the session + * (never a query field), the `insightStatus` assistant-surface gate (no AI + * provider call — this is a pure DB read). + */ +import { apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { requireAssistantSurface } from "@/lib/feature-flags"; +import { prisma } from "@/lib/db"; +import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement"; +import type { MeasurementType } from "@/generated/prisma/client"; + +export const dynamic = "force-dynamic"; + +// Cap the returned timeline. A healthy account produces a handful of +// these a year; the cap is a defensive bound, not an expected ceiling. +const MAX_EVENTS = 200; + +const EVENT_TYPE_LIST = Array.from(EVENT_MEASUREMENT_TYPES) as MeasurementType[]; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + await requireAssistantSurface("insightStatus"); + + const rows = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: { in: EVENT_TYPE_LIST }, + deletedAt: null, + }, + select: { + id: true, + type: true, + rhythmClassification: true, + measuredAt: true, + source: true, + deviceType: true, + }, + orderBy: { measuredAt: "desc" }, + take: MAX_EVENTS, + }); + + const events = rows.map((r) => ({ + id: r.id, + type: r.type, + classification: r.rhythmClassification, + occurredAt: r.measuredAt.toISOString(), + source: r.source, + deviceType: r.deviceType, + })); + + annotate({ + action: { name: "insights.rhythm-events" }, + meta: { count: events.length }, + }); + + return apiSuccess({ + events, + hasEvents: events.length > 0, + }); +}); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index ab5d7812e..1302c484c 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -85,6 +85,18 @@ const VitalsDashboard = dynamic( ), }, ); +// v1.10.0 — categorical events (WX-B). The device-flagged event awareness +// timeline. Deferred like the other below-the-fold blocks; the card +// un-mounts itself when the user has no such events (no skeleton-then-empty +// flash — `ssr: false` + the card's own data gate), so it carries no +// loading placeholder of its own. +const RhythmEventsCard = dynamic( + () => + import("@/components/insights/rhythm-events-card").then((mod) => ({ + default: mod.RhythmEventsCard, + })), + { ssr: false }, +); /** * v1.4.25 W4d — Insights mother page. @@ -248,6 +260,8 @@ export default function InsightsPage() { + + {flags.briefing && ( = { + IRREGULAR_RHYTHM_NOTIFICATION: Activity, + HIGH_HEART_RATE_EVENT: HeartPulse, + LOW_HEART_RATE_EVENT: HeartPulse, + WALKING_STEADINESS_EVENT: Footprints, + BREATHING_DISTURBANCE_EVENT: Wind, +}; + +const EVENT_LABEL_KEYS: Record = { + IRREGULAR_RHYTHM_NOTIFICATION: "insights.rhythmEvents.event.irregularRhythm", + HIGH_HEART_RATE_EVENT: "insights.rhythmEvents.event.highHeartRate", + LOW_HEART_RATE_EVENT: "insights.rhythmEvents.event.lowHeartRate", + WALKING_STEADINESS_EVENT: "insights.rhythmEvents.event.walkingSteadiness", + BREATHING_DISTURBANCE_EVENT: "insights.rhythmEvents.event.breathingDisturbance", +}; + +/** + * The device's verdict, surfaced verbatim. Each line is framed as the + * device's decision ("Your device flagged …"), never HealthLog's. + */ +const CLASSIFICATION_LABEL_KEYS: Record = { + IRREGULAR: "insights.rhythmEvents.verdict.irregular", + NOT_DETECTED: "insights.rhythmEvents.verdict.notDetected", + INCONCLUSIVE: "insights.rhythmEvents.verdict.inconclusive", + LOW: "insights.rhythmEvents.verdict.low", + VERY_LOW: "insights.rhythmEvents.verdict.veryLow", + FIRED: "insights.rhythmEvents.verdict.fired", +}; + +interface RhythmEventsCardProps { + enabled?: boolean; + className?: string; +} + +export function RhythmEventsCard({ + enabled = true, + className, +}: RhythmEventsCardProps) { + const { isAuthenticated } = useAuth(); + const { t } = useTranslations(); + const fmt = useFormatters(); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.insightsRhythmEvents(), + queryFn: async () => { + const res = await fetch("/api/insights/rhythm-events"); + if (!res.ok) throw new Error(t("insights.rhythmEvents.loadError")); + const json = await res.json(); + return json.data as RhythmEventsResponse; + }, + enabled: enabled && isAuthenticated, + }); + + // Data-availability gate — never paint an empty / alarming card. + if (isLoading || !data || !data.hasEvents) return null; + + return ( +
+
+

+ {t("insights.rhythmEvents.sectionTitle")} +

+

+ {t("insights.rhythmEvents.sectionIntro")} +

+
+ +
    + {data.events.map((event) => { + const Icon = EVENT_ICONS[event.type] ?? Activity; + const labelKey = EVENT_LABEL_KEYS[event.type]; + const label = labelKey ? t(labelKey) : event.type; + const verdictKey = event.classification + ? CLASSIFICATION_LABEL_KEYS[event.classification] + : undefined; + const verdict = verdictKey ? t(verdictKey) : null; + return ( +
  1. + + + +
    +

    {label}

    + {verdict && ( +

    + {verdict} +

    + )} +

    + {fmt.dateTime(new Date(event.occurredAt))} +

    +
    +
  2. + ); + })} +
+ + {/* Load-bearing regulatory disclaimer — permanent, non-dismissible. + Plain React text children (no markdown library — XSS rule). */} +
+
+
+ ); +} diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index f9101c8c7..8504391d0 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -132,6 +132,15 @@ export const queryKeys = { * own stale-while-revalidate. */ insightsPregenerate: () => ["insights", "pregenerate"] as const, + /** + * v1.10.0 — categorical events (WX-B). The device-flagged event + * awareness timeline (`/api/insights/rhythm-events`). No locale segment + * — the payload is verdicts + timestamps; the surface localises its own + * prose. The `["insights"]` prefix keeps it in the standard insights + * invalidation fan-out (an Apple Health batch write that lands an event + * row busts it via the prefix). + */ + insightsRhythmEvents: () => ["insights", "rhythm-events"] as const, /** * v1.10.0 — derived-wellness metrics. One generic route * (`/api/insights/derived?metric=…[&type=…]`) backs the vitals