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:
Marc-André Bombeck committed 2026-06-02 22:08:12 +02:00
1 parent a45325af52
commit 135141a750
11 files changed
+579 -12

No files matched your search

@@ -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,
});
});
+14
View File
@@ -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>
);
}
+9
View File
@@ -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