mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
feat(insights): intraday pulse shape and elevated-at-rest tension window
Add an on-demand intraday layer to the pulse insight: 10-minute mean heart rate for the viewed day, folded from raw samples via the read-swap pattern (never persisted as 10-minute rollups for all history). Draw the personal resting baseline and softly shade any sustained elevated-at-rest stretch. The tension signal is not raw heart rate — it is heart rate held above the personal resting baseline while movement is low (the elevated-at-rest window), so a workout or a walk never reads as tension. Detection is deliberately conservative: baseline maturity, per-bucket sample density, a mandatory step-based low-movement gate, and a minimum sustained duration must all hold before anything is reported. Silence over speculation. Surface the same signal as a calm, non-diagnostic tension_window item in the daily digest — at most once per day, honest-absent otherwise, gated on the insights module and yielding the rail to time-sensitive actions. Cautious copy across all six locales; framed as "possible tension", never a clinical stress verdict.
This commit is contained in:
1 parent
1427125b7e
commit
a33527da57
17 files changed
+1290
-6
No files matched your search
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Activity } from "lucide-react";
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ReferenceLine,
|
||||
ReferenceArea,
|
||||
} from "recharts";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import type {
|
||||
IntradayHrBucket,
|
||||
TensionWindow,
|
||||
} from "@/lib/analytics/intraday-pulse";
|
||||
|
||||
/** Slim DTO the intraday route returns (type-only — no server code bundled). */
|
||||
interface IntradayPulseDto {
|
||||
dateKey: string;
|
||||
bucketMinutes: number;
|
||||
series: IntradayHrBucket[];
|
||||
baseline: number | null;
|
||||
baselineSource: "resting" | "proxy" | "none";
|
||||
tension: TensionWindow | null;
|
||||
}
|
||||
|
||||
/** Minutes-since-midnight → "HH:mm" wall-clock label. */
|
||||
function minuteLabel(minute: number): string {
|
||||
const h = Math.floor(minute / 60);
|
||||
const m = minute % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const AXIS_TICKS = [0, 360, 720, 1080, 1440];
|
||||
|
||||
/**
|
||||
* S11 — the intraday pulse "shape of the day" layer inside the pulse insight.
|
||||
*
|
||||
* Charts one local day's 10-minute mean heart rate (computed on demand from
|
||||
* raw), with the personal resting baseline drawn as a reference line and any
|
||||
* detected elevated-at-rest ("tension") window softly shaded. Copy is cautious
|
||||
* and non-diagnostic — "possible tension", never a stress score. Empty / no-
|
||||
* baseline days render an honest one-liner rather than an empty grid.
|
||||
*/
|
||||
export function IntradayPulseChart({
|
||||
userTimezone,
|
||||
}: {
|
||||
userTimezone?: string;
|
||||
}) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const tz = userTimezone ?? "Europe/Berlin";
|
||||
const dateKey = userDayKey(new Date(), tz);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.insightsPulseIntraday(dateKey),
|
||||
queryFn: () =>
|
||||
apiGet<IntradayPulseDto>(`/api/insights/pulse/intraday?date=${dateKey}`),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
(data?.series ?? []).map((b) => ({
|
||||
minute: b.startMinute,
|
||||
label: minuteLabel(b.startMinute),
|
||||
bpm: b.mean,
|
||||
})),
|
||||
[data?.series],
|
||||
);
|
||||
|
||||
const caption = data?.tension
|
||||
? t(`insights.intradayPulse.tension.${data.tension.partOfDay}`)
|
||||
: t("insights.intradayPulse.caption");
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="intraday-pulse-chart"
|
||||
className="bg-card space-y-1.5 rounded-xl border p-4"
|
||||
>
|
||||
<TileHeader icon={Activity} title={t("insights.intradayPulse.title")} />
|
||||
<p className="text-muted-foreground text-xs leading-snug">{caption}</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-muted/40 h-44 animate-pulse rounded-lg motion-reduce:animate-none" />
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="text-muted-foreground flex h-44 items-center justify-center rounded-lg border border-dashed text-sm">
|
||||
{t("insights.intradayPulse.empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="touch-pan-y">
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="intradayPulseFill"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0.3}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor="var(--chart-1)"
|
||||
stopOpacity={0}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="var(--border)"
|
||||
opacity={0.5}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="minute"
|
||||
type="number"
|
||||
domain={[0, 1440]}
|
||||
ticks={AXIS_TICKS}
|
||||
tickFormatter={minuteLabel}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
domain={["dataMin - 10", "dataMax + 10"]}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
unit=" bpm"
|
||||
width={52}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
labelFormatter={(minute) => minuteLabel(Number(minute))}
|
||||
formatter={(value) => [`${value} bpm`, t("charts.pulse")]}
|
||||
/>
|
||||
{data?.tension ? (
|
||||
<ReferenceArea
|
||||
x1={data.tension.startMinute}
|
||||
x2={data.tension.endMinute}
|
||||
fill="var(--warning)"
|
||||
fillOpacity={0.14}
|
||||
ifOverflow="extendDomain"
|
||||
/>
|
||||
) : null}
|
||||
{data?.baseline != null ? (
|
||||
<ReferenceLine
|
||||
y={data.baseline}
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeDasharray="5 5"
|
||||
strokeOpacity={0.7}
|
||||
label={{
|
||||
value: t("insights.intradayPulse.baseline"),
|
||||
position: "insideTopLeft",
|
||||
fill: "var(--muted-foreground)",
|
||||
fontSize: 10,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="bpm"
|
||||
stroke="var(--chart-1)"
|
||||
strokeWidth={2}
|
||||
fill="url(#intradayPulseFill)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user