mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +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
@@ -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,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user