mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +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,
|
||||
});
|
||||
});
|
||||
@@ -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