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,
});
});