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