mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(insights): surface device-flagged events as an awareness timeline
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.
This commit is contained in:
1 parent
a45325af52
commit
135141a750
11 files changed
+579
-12
No files matched your search
+28
-2
@@ -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",
|
||||
|
||||
+28
-2
@@ -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",
|
||||
|
||||
+28
-2
@@ -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",
|
||||
|
||||
+28
-2
@@ -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",
|
||||
|
||||
+28
-2
@@ -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",
|
||||
|
||||
+28
-2
@@ -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",
|
||||
|
||||
@@ -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<Response>;
|
||||
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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
<VitalsDashboard enabled={isAuthenticated} />
|
||||
|
||||
<RhythmEventsCard enabled={isAuthenticated} />
|
||||
|
||||
{flags.briefing && (
|
||||
<DailyBriefing
|
||||
briefing={briefingPayload}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
HeartPulse,
|
||||
Footprints,
|
||||
Wind,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.10.0 — device-flagged event awareness surface (categorical events,
|
||||
* WX-B).
|
||||
*
|
||||
* A timeline of the on-device notifications the user's wearable (Apple
|
||||
* Watch / Withings ScanWatch) already produced and synced —
|
||||
* irregular-rhythm / high-HR / low-HR / walking-steadiness /
|
||||
* breathing-disturbance.
|
||||
*
|
||||
* Regulatory framing (load-bearing, do not soften): this surface is
|
||||
* AWARENESS / SCREENING of the DEVICE's own decision. It reflects ONLY the
|
||||
* classification RESULT the device's FDA-cleared / CE-marked on-device
|
||||
* algorithm produced. HealthLog never re-classifies and never emits a
|
||||
* diagnosis of its own. The disclaimer below states this verbatim; it is a
|
||||
* permanent, non-dismissible part of the surface.
|
||||
*
|
||||
* Data-availability-gated: the card un-mounts entirely (`return null`) when
|
||||
* the user has no such events — never an empty / alarming card.
|
||||
*/
|
||||
|
||||
interface RhythmEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
classification: string | null;
|
||||
occurredAt: string;
|
||||
source: string;
|
||||
deviceType: string | null;
|
||||
}
|
||||
|
||||
interface RhythmEventsResponse {
|
||||
events: RhythmEvent[];
|
||||
hasEvents: boolean;
|
||||
}
|
||||
|
||||
const EVENT_ICONS: Record<string, LucideIcon> = {
|
||||
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<string, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<section
|
||||
data-slot="rhythm-events-card"
|
||||
aria-label={t("insights.rhythmEvents.sectionTitle")}
|
||||
className={cn(
|
||||
"bg-card border-border space-y-4 rounded-xl border p-4 md:p-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-foreground text-sm font-semibold tracking-tight">
|
||||
{t("insights.rhythmEvents.sectionTitle")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.rhythmEvents.sectionIntro")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ol data-slot="rhythm-events-timeline" className="space-y-3">
|
||||
{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 (
|
||||
<li
|
||||
key={event.id}
|
||||
data-slot="rhythm-event-row"
|
||||
data-event-type={event.type}
|
||||
className="border-border/60 flex items-start gap-3 border-b pb-3 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<span className="bg-muted text-muted-foreground mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-foreground text-sm font-medium">{label}</p>
|
||||
{verdict && (
|
||||
<p
|
||||
data-slot="rhythm-event-verdict"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{verdict}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{fmt.dateTime(new Date(event.occurredAt))}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* Load-bearing regulatory disclaimer — permanent, non-dismissible.
|
||||
Plain React text children (no markdown library — XSS rule). */}
|
||||
<div
|
||||
data-slot="rhythm-events-disclaimer"
|
||||
role="note"
|
||||
className="bg-muted/50 text-muted-foreground flex items-start gap-2 rounded-lg p-3 text-xs"
|
||||
>
|
||||
<Info className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
|
||||
<p>{t("insights.rhythmEvents.disclaimer")}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user