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