From a33527da57b841ab049079f0960faf343e501fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 08:16:32 +0200 Subject: [PATCH] feat(insights): intraday pulse shape and elevated-at-rest tension window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- messages/de.json | 24 +- messages/en.json | 24 +- messages/es.json | 24 +- messages/fr.json | 24 +- messages/it.json | 24 +- messages/pl.json | 24 +- .../coach-route-gate-inventory.test.ts | 6 + src/app/api/insights/pulse/intraday/route.ts | 63 ++++ src/app/insights/pulse/page.tsx | 6 + .../insights/intraday-pulse-chart.tsx | 200 ++++++++++++ .../__tests__/intraday-pulse.test.ts | 244 +++++++++++++++ src/lib/analytics/intraday-pulse-io.ts | 218 +++++++++++++ src/lib/analytics/intraday-pulse.ts | 290 ++++++++++++++++++ src/lib/daily/__tests__/digest.test.ts | 53 ++++ src/lib/daily/digest.ts | 49 +++ src/lib/daily/load-digest.ts | 14 + src/lib/query-keys/insights.ts | 9 + 17 files changed, 1290 insertions(+), 6 deletions(-) create mode 100644 src/app/api/insights/pulse/intraday/route.ts create mode 100644 src/components/insights/intraday-pulse-chart.tsx create mode 100644 src/lib/analytics/__tests__/intraday-pulse.test.ts create mode 100644 src/lib/analytics/intraday-pulse-io.ts create mode 100644 src/lib/analytics/intraday-pulse.ts diff --git a/messages/de.json b/messages/de.json index c8907407d..bf2afe4fd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3857,6 +3857,18 @@ "waveform": { "ariaLabel": "EKG-Streifen aufgezeichnet {date}, Dauer {duration}, durchschnittliche Herzfrequenz {heartRate}, aufgezeichnetes Ergebnis: {result}." } + }, + "intradayPulse": { + "title": "Der Verlauf deines Tages", + "caption": "Dein Puls über den Tag, gemittelt in 10-Minuten-Schritten.", + "empty": "Heute noch nicht genug Pulswerte, um den Tagesverlauf zu zeigen.", + "baseline": "Ruhe", + "tension": { + "morning": "Eine anhaltend erhöhte Ruhephase heute Vormittag — möglicherweise etwas Anspannung.", + "afternoon": "Eine anhaltend erhöhte Ruhephase heute Nachmittag — möglicherweise etwas Anspannung.", + "evening": "Eine anhaltend erhöhte Ruhephase heute Abend — möglicherweise etwas Anspannung.", + "night": "Eine anhaltend erhöhte Ruhephase in der Nacht — möglicherweise etwas Anspannung." + } } }, "thresholds": { @@ -8549,6 +8561,15 @@ "title": "Zeit, einen Plan anzuschauen", "body": "Es ist etwa eine Woche her, seit du diesen Plan gesetzt hast — behalte ihn, passe ihn an oder lass ihn los. Ganz ohne Druck.", "bodyPlan": "Vor etwa einer Woche hast du dir vorgenommen: „{plan}“. Behalte es, passe es an oder lass es los — ganz ohne Druck." + }, + "tensionWindow": { + "title": "Vielleicht eine angespannte Phase", + "body": { + "morning": "Dein Puls lag heute Vormittag über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung.", + "afternoon": "Dein Puls lag heute Nachmittag über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung.", + "evening": "Dein Puls lag heute Abend über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung.", + "night": "Dein Puls lag in der Nacht über längere Zeit über deinem Ruhewert, während du ruhig warst — möglicherweise etwas Anspannung." + } } }, "action": { @@ -8557,7 +8578,8 @@ "viewCheckups": "Vorsorge ansehen", "checkinKeep": "Behalten", "checkinAdjust": "Anpassen", - "checkinLetGo": "Loslassen" + "checkinLetGo": "Loslassen", + "viewPulse": "Puls ansehen" }, "today": { "scoreLabel": "Gesundheitsscore", diff --git a/messages/en.json b/messages/en.json index 11f67878f..f308688f6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3857,6 +3857,18 @@ "waveform": { "ariaLabel": "ECG strip recorded {date}, duration {duration}, average heart rate {heartRate}, recorded result: {result}." } + }, + "intradayPulse": { + "title": "Your day's shape", + "caption": "Your heart rate through the day, averaged in 10-minute steps.", + "empty": "Not enough heart-rate readings today to show the day's shape yet.", + "baseline": "Resting", + "tension": { + "morning": "A sustained elevated-at-rest stretch this morning — possibly some tension.", + "afternoon": "A sustained elevated-at-rest stretch this afternoon — possibly some tension.", + "evening": "A sustained elevated-at-rest stretch this evening — possibly some tension.", + "night": "A sustained elevated-at-rest stretch overnight — possibly some tension." + } } }, "thresholds": { @@ -8549,6 +8561,15 @@ "title": "Time to check in on a plan", "body": "It's been about a week since you set this plan — keep it, adjust it, or let it go. No pressure either way.", "bodyPlan": "About a week ago you set: “{plan}”. Keep it, adjust it, or let it go — no pressure either way." + }, + "tensionWindow": { + "title": "Possible tension today", + "body": { + "morning": "Your heart rate sat above its resting baseline for a sustained stretch this morning while you were still — possibly some tension.", + "afternoon": "Your heart rate sat above its resting baseline for a sustained stretch this afternoon while you were still — possibly some tension.", + "evening": "Your heart rate sat above its resting baseline for a sustained stretch this evening while you were still — possibly some tension.", + "night": "Your heart rate sat above its resting baseline for a sustained stretch overnight while you were still — possibly some tension." + } } }, "action": { @@ -8557,7 +8578,8 @@ "viewCheckups": "View check-ups", "checkinKeep": "Keep it", "checkinAdjust": "Adjust", - "checkinLetGo": "Let it go" + "checkinLetGo": "Let it go", + "viewPulse": "View pulse" }, "today": { "scoreLabel": "Health score", diff --git a/messages/es.json b/messages/es.json index 79883e68e..f35c8ec04 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3857,6 +3857,18 @@ "waveform": { "ariaLabel": "Tira de ECG registrada el {date}, duración {duration}, frecuencia cardíaca media {heartRate}, resultado registrado: {result}." } + }, + "intradayPulse": { + "title": "La forma de tu día", + "caption": "Tu frecuencia cardíaca a lo largo del día, promediada en tramos de 10 minutos.", + "empty": "Aún no hay suficientes lecturas de hoy para mostrar la forma del día.", + "baseline": "Reposo", + "tension": { + "morning": "Un tramo elevado en reposo esta mañana; posiblemente algo de tensión.", + "afternoon": "Un tramo elevado en reposo esta tarde; posiblemente algo de tensión.", + "evening": "Un tramo elevado en reposo al anochecer; posiblemente algo de tensión.", + "night": "Un tramo elevado en reposo por la noche; posiblemente algo de tensión." + } } }, "thresholds": { @@ -8549,6 +8561,15 @@ "title": "Es buen momento para revisar un plan", "body": "Ha pasado alrededor de una semana desde que definiste este plan: consérvalo, ajústalo o déjalo ir. Sin presión.", "bodyPlan": "Hace aproximadamente una semana te propusiste: «{plan}». Consérvalo, ajústalo o déjalo ir, sin presión." + }, + "tensionWindow": { + "title": "Quizá algo de tensión hoy", + "body": { + "morning": "Tu frecuencia cardíaca estuvo por encima de tu valor en reposo durante un buen rato esta mañana mientras estabas en calma; posiblemente algo de tensión.", + "afternoon": "Tu frecuencia cardíaca estuvo por encima de tu valor en reposo durante un buen rato esta tarde mientras estabas en calma; posiblemente algo de tensión.", + "evening": "Tu frecuencia cardíaca estuvo por encima de tu valor en reposo durante un buen rato al anochecer mientras estabas en calma; posiblemente algo de tensión.", + "night": "Tu frecuencia cardíaca estuvo por encima de tu valor en reposo durante un buen rato por la noche mientras estabas en calma; posiblemente algo de tensión." + } } }, "action": { @@ -8557,7 +8578,8 @@ "viewCheckups": "Ver revisiones", "checkinKeep": "Conservar", "checkinAdjust": "Ajustar", - "checkinLetGo": "Dejarlo ir" + "checkinLetGo": "Dejarlo ir", + "viewPulse": "Ver pulso" }, "today": { "scoreLabel": "Puntuación de salud", diff --git a/messages/fr.json b/messages/fr.json index 85d4f3acf..43d38a5e3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3857,6 +3857,18 @@ "waveform": { "ariaLabel": "Tracé ECG enregistré le {date}, durée {duration}, fréquence cardiaque moyenne {heartRate}, résultat enregistré : {result}." } + }, + "intradayPulse": { + "title": "Le rythme de votre journée", + "caption": "Votre fréquence cardiaque au fil de la journée, moyennée par tranches de 10 minutes.", + "empty": "Pas encore assez de mesures aujourd'hui pour afficher le rythme de la journée.", + "baseline": "Repos", + "tension": { + "morning": "Une période élevée au repos ce matin — peut-être un peu de tension.", + "afternoon": "Une période élevée au repos cet après-midi — peut-être un peu de tension.", + "evening": "Une période élevée au repos ce soir — peut-être un peu de tension.", + "night": "Une période élevée au repos cette nuit — peut-être un peu de tension." + } } }, "thresholds": { @@ -8549,6 +8561,15 @@ "title": "Il est temps de revoir un plan", "body": "Cela fait environ une semaine que vous avez défini ce plan : gardez-le, ajustez-le ou laissez-le. Sans pression.", "bodyPlan": "Il y a environ une semaine, vous vous étiez fixé : « {plan} ». Gardez-le, ajustez-le ou laissez-le, sans pression." + }, + "tensionWindow": { + "title": "Peut-être un peu de tension", + "body": { + "morning": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment ce matin alors que vous étiez au calme — peut-être un peu de tension.", + "afternoon": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment cet après-midi alors que vous étiez au calme — peut-être un peu de tension.", + "evening": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment ce soir alors que vous étiez au calme — peut-être un peu de tension.", + "night": "Votre fréquence cardiaque est restée au-dessus de votre valeur de repos un bon moment cette nuit alors que vous étiez au calme — peut-être un peu de tension." + } } }, "action": { @@ -8557,7 +8578,8 @@ "viewCheckups": "Voir les examens", "checkinKeep": "Garder", "checkinAdjust": "Ajuster", - "checkinLetGo": "Laisser tomber" + "checkinLetGo": "Laisser tomber", + "viewPulse": "Voir le pouls" }, "today": { "scoreLabel": "Score de santé", diff --git a/messages/it.json b/messages/it.json index b0702e182..0533bd782 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3857,6 +3857,18 @@ "waveform": { "ariaLabel": "Tracciato ECG registrato il {date}, durata {duration}, frequenza cardiaca media {heartRate}, risultato registrato: {result}." } + }, + "intradayPulse": { + "title": "L'andamento della tua giornata", + "caption": "La tua frequenza cardiaca durante il giorno, mediata in intervalli di 10 minuti.", + "empty": "Non ci sono ancora abbastanza misurazioni di oggi per mostrare l'andamento della giornata.", + "baseline": "Riposo", + "tension": { + "morning": "Un tratto elevato a riposo stamattina — forse un po' di tensione.", + "afternoon": "Un tratto elevato a riposo questo pomeriggio — forse un po' di tensione.", + "evening": "Un tratto elevato a riposo stasera — forse un po' di tensione.", + "night": "Un tratto elevato a riposo durante la notte — forse un po' di tensione." + } } }, "thresholds": { @@ -8549,6 +8561,15 @@ "title": "È il momento di rivedere un piano", "body": "È passata circa una settimana da quando hai definito questo piano: mantienilo, modificalo o lascialo andare. Senza pressioni.", "bodyPlan": "Circa una settimana fa ti eri proposto: «{plan}». Mantienilo, modificalo o lascialo andare, senza pressioni." + }, + "tensionWindow": { + "title": "Forse un po' di tensione oggi", + "body": { + "morning": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato stamattina mentre eri fermo — forse un po' di tensione.", + "afternoon": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato questo pomeriggio mentre eri fermo — forse un po' di tensione.", + "evening": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato stasera mentre eri fermo — forse un po' di tensione.", + "night": "La tua frequenza cardiaca è rimasta sopra il valore a riposo per un tratto prolungato durante la notte mentre eri fermo — forse un po' di tensione." + } } }, "action": { @@ -8557,7 +8578,8 @@ "viewCheckups": "Vedi controlli", "checkinKeep": "Mantieni", "checkinAdjust": "Modifica", - "checkinLetGo": "Lascia andare" + "checkinLetGo": "Lascia andare", + "viewPulse": "Vedi battito" }, "today": { "scoreLabel": "Punteggio di salute", diff --git a/messages/pl.json b/messages/pl.json index b057686a9..f29254710 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3857,6 +3857,18 @@ "waveform": { "ariaLabel": "Zapis EKG zarejestrowany {date}, czas trwania {duration}, średnie tętno {heartRate}, zarejestrowany wynik: {result}." } + }, + "intradayPulse": { + "title": "Przebieg Twojego dnia", + "caption": "Twoje tętno w ciągu dnia, uśrednione w 10-minutowych odcinkach.", + "empty": "Za mało dzisiejszych pomiarów, aby pokazać przebieg dnia.", + "baseline": "Spoczynek", + "tension": { + "morning": "Utrzymujący się podwyższony spoczynek rano — możliwe napięcie.", + "afternoon": "Utrzymujący się podwyższony spoczynek po południu — możliwe napięcie.", + "evening": "Utrzymujący się podwyższony spoczynek wieczorem — możliwe napięcie.", + "night": "Utrzymujący się podwyższony spoczynek w nocy — możliwe napięcie." + } } }, "thresholds": { @@ -8549,6 +8561,15 @@ "title": "Czas przyjrzeć się planowi", "body": "Minął mniej więcej tydzień, odkąd ustaliłeś ten plan — zachowaj go, dostosuj lub odpuść. Bez presji.", "bodyPlan": "Około tygodnia temu postanowiłeś: „{plan}”. Zachowaj to, dostosuj lub odpuść — bez presji." + }, + "tensionWindow": { + "title": "Może chwila napięcia dzisiaj", + "body": { + "morning": "Twoje tętno przez dłuższą chwilę rano utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie.", + "afternoon": "Twoje tętno przez dłuższą chwilę po południu utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie.", + "evening": "Twoje tętno przez dłuższą chwilę wieczorem utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie.", + "night": "Twoje tętno przez dłuższą chwilę w nocy utrzymywało się powyżej wartości spoczynkowej, gdy byłeś w bezruchu — możliwe napięcie." + } } }, "action": { @@ -8557,7 +8578,8 @@ "viewCheckups": "Zobacz badania", "checkinKeep": "Zachowaj", "checkinAdjust": "Dostosuj", - "checkinLetGo": "Odpuść" + "checkinLetGo": "Odpuść", + "viewPulse": "Zobacz tętno" }, "today": { "scoreLabel": "Wynik zdrowia", diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index be689bb62..2fdfb5659 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -112,6 +112,12 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray = [ "src/app/api/insights/health-status/route.ts", "src/app/api/insights/breathing-screening/route.ts", "src/app/api/insights/labs-changes/route.ts", + // S11 — the intraday pulse / tension read. Deterministic compute over the + // day's raw PULSE / steps / workouts (10-minute mean shape + the cautious + // elevated-at-rest window); it carries no assistant prose and gates on the + // `insights` module, not the Coach surface. Same posture as health-status / + // labs-changes directly above — disabling Coach must not wedge this read. + "src/app/api/insights/pulse/intraday/route.ts", // v1.29.x (S7) — the FENCED multi-document coach chat + its attach/detach // mutations. These live under /insights/chat for URL grouping but are // DOCUMENT-surface routes: they gate on the `inboundDocuments` module diff --git a/src/app/api/insights/pulse/intraday/route.ts b/src/app/api/insights/pulse/intraday/route.ts new file mode 100644 index 000000000..c0f149592 --- /dev/null +++ b/src/app/api/insights/pulse/intraday/route.ts @@ -0,0 +1,63 @@ +/** + * GET /api/insights/pulse/intraday?date=YYYY-MM-DD + * + * The on-demand read for the intraday pulse / tension layer (S11). Returns one + * local day's 10-minute mean heart-rate shape plus, when every confidence gate + * holds, a single cautious elevated-at-rest ("tension") window. Computed from + * raw samples via the read-swap pattern — NOT persisted as 10-min rollups for + * all history. + * + * Cookie OR Bearer auth via `requireAuth()`; `userId` is narrowed from the + * resolved session — never a body field. Gated on the `insights` module. The + * `date` defaults to the user's local today; anything but a `YYYY-MM-DD` + * literal is a 422 via `returnAllZodIssues`. + */ +import { z } from "zod"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; +import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { userDayKey } from "@/lib/tz/format"; +import { annotate } from "@/lib/logging/context"; +import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io"; + +export const dynamic = "force-dynamic"; + +const querySchema = z.object({ + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), +}); + +export const GET = apiHandler(async (req: Request) => { + const { user } = await requireAuth(); + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + + const url = new URL(req.url); + const parsed = querySchema.safeParse({ + date: url.searchParams.get("date") ?? undefined, + }); + if (!parsed.success) return returnAllZodIssues(parsed.error); + + const timezone = await resolveUserTimezone(user.id); + const dateKey = parsed.data.date ?? userDayKey(new Date(), timezone); + + const result = await loadIntradayPulse(user.id, timezone, dateKey); + + annotate({ + action: { name: "insights.pulse.intraday" }, + meta: { + buckets: result.series.length, + baseline_source: result.baselineSource, + tension: result.tension !== null, + }, + }); + + const response = apiSuccess(result); + response.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE); + return response; +}); diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index cb3963e16..b221c98d1 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -19,6 +19,7 @@ import { MeasurementDiversityNudge } from "@/components/insights/measurement-div import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { TileHeader } from "@/components/insights/tile-header"; +import { IntradayPulseChart } from "@/components/insights/intraday-pulse-chart"; import { getAgeFromDateOfBirth, getPersonalizedPulseTarget, @@ -174,6 +175,11 @@ export default function InsightsPulsPage() { onVisibleStats={onVisibleStats} /> + {/* S11 — the intraday "shape of the day" layer: 10-minute mean heart + rate with the resting baseline and any cautious elevated-at-rest + window. Computed on demand from raw for today; non-diagnostic. */} + + {/* v1.12.0 — Pulse owns the mood × pulse correlation (relocated off diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx new file mode 100644 index 000000000..8bb36c2db --- /dev/null +++ b/src/components/insights/intraday-pulse-chart.tsx @@ -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(`/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 ( +
+ +

{caption}

+ + {isLoading ? ( +
+ ) : chartData.length === 0 ? ( +
+ {t("insights.intradayPulse.empty")} +
+ ) : ( +
+ + + + + + + + + + + + minuteLabel(Number(minute))} + formatter={(value) => [`${value} bpm`, t("charts.pulse")]} + /> + {data?.tension ? ( + + ) : null} + {data?.baseline != null ? ( + + ) : null} + + + +
+ )} +
+ ); +} diff --git a/src/lib/analytics/__tests__/intraday-pulse.test.ts b/src/lib/analytics/__tests__/intraday-pulse.test.ts new file mode 100644 index 000000000..a03c991fa --- /dev/null +++ b/src/lib/analytics/__tests__/intraday-pulse.test.ts @@ -0,0 +1,244 @@ +/** + * S11 — intraday pulse shape + elevated-at-rest ("tension") detection. + * + * The signal-correctness bar for this slice is high (the launch posture is + * "says less than it could"), so these pin the two things that are easy to get + * embarrassingly wrong: the 10-minute bucketing (tz-correct day windows, sparse + * data) and the tension gate refusing to flag a workout, a walk, or a short + * spike — only a sustained, low-movement, above-baseline stretch fires. + */ +import { describe, it, expect } from "vitest"; + +import { + BUCKET_MINUTES, + ELEVATION_MARGIN_BPM, + MIN_STEP_COVERAGE_BUCKETS, + computeTenMinuteMeanSeries, + detectTensionWindow, + makeLocalResolver, + partOfDayForMinute, + type IntradayHrBucket, + type IntradaySample, + type DetectTensionInput, +} from "../intraday-pulse"; + +const utcOf = makeLocalResolver("UTC"); + +function sample(iso: string, value: number): IntradaySample { + return { measuredAt: new Date(iso), value }; +} + +/** A dense low-movement step map with the required coverage, all zeros. */ +function zeroSteps(...extraMinutes: number[]): Map { + const m = new Map(); + // MIN_STEP_COVERAGE_BUCKETS distinct early-morning buckets + any extras. + for (let i = 0; i < MIN_STEP_COVERAGE_BUCKETS; i++) + m.set(i * BUCKET_MINUTES, 0); + for (const min of extraMinutes) m.set(min, 0); + return m; +} + +/** A run of identical elevated buckets starting at `startMinute`. */ +function elevatedRun( + startMinute: number, + count: number, + mean = 80, + sampleCount = 5, +): IntradayHrBucket[] { + return Array.from({ length: count }, (_, i) => ({ + startMinute: startMinute + i * BUCKET_MINUTES, + mean, + count: sampleCount, + })); +} + +function tensionInput( + over: Partial = {}, +): DetectTensionInput { + return { + buckets: [], + baseline: 60, + baselineMature: true, + stepBuckets: zeroSteps(), + workouts: [], + ...over, + }; +} + +describe("computeTenMinuteMeanSeries", () => { + it("means samples inside each 10-minute bucket and sorts ascending", () => { + const series = computeTenMinuteMeanSeries( + [ + sample("2026-05-01T10:00:00Z", 70), + sample("2026-05-01T10:03:00Z", 80), // same bucket 600 + sample("2026-05-01T10:12:00Z", 90), // bucket 610 + ], + "2026-05-01", + utcOf, + ); + expect(series).toEqual([ + { startMinute: 600, mean: 75, count: 2 }, + { startMinute: 610, mean: 90, count: 1 }, + ]); + }); + + it("keeps a lone-sample bucket (count reflects density for the gate)", () => { + const series = computeTenMinuteMeanSeries( + [sample("2026-05-01T08:05:00Z", 66)], + "2026-05-01", + utcOf, + ); + expect(series).toEqual([{ startMinute: 480, mean: 66, count: 1 }]); + }); + + it("excludes samples from other local days (no next-day bleed)", () => { + const series = computeTenMinuteMeanSeries( + [ + sample("2026-05-01T23:55:00Z", 70), // stays on 05-01 + sample("2026-05-02T00:05:00Z", 90), // 05-02 + ], + "2026-05-01", + utcOf, + ); + expect(series).toEqual([{ startMinute: 1430, mean: 70, count: 1 }]); + }); + + it("buckets on the USER's local day, not UTC (tz-correct windows)", () => { + const nyOf = makeLocalResolver("America/New_York"); + // 02:30Z on 05-01 is 22:30 EDT on 04-30 → minute 1350, prior day. + const s = [sample("2026-05-01T02:30:00Z", 72)]; + expect(computeTenMinuteMeanSeries(s, "2026-05-01", nyOf)).toEqual([]); + expect(computeTenMinuteMeanSeries(s, "2026-04-30", nyOf)).toEqual([ + { startMinute: 1350, mean: 72, count: 1 }, + ]); + }); +}); + +describe("partOfDayForMinute", () => { + it("labels the coarse part of day", () => { + expect(partOfDayForMinute(5 * 60)).toBe("night"); + expect(partOfDayForMinute(9 * 60)).toBe("morning"); + expect(partOfDayForMinute(15 * 60)).toBe("afternoon"); + expect(partOfDayForMinute(20 * 60)).toBe("evening"); + }); +}); + +describe("detectTensionWindow — fires only for sustained elevated-at-rest", () => { + it("fires for a sustained above-baseline low-movement stretch", () => { + const win = detectTensionWindow( + tensionInput({ buckets: elevatedRun(600, 4) }), + ); + expect(win).not.toBeNull(); + expect(win?.startMinute).toBe(600); + expect(win?.endMinute).toBe(640); + expect(win?.partOfDay).toBe("morning"); + expect(win?.meanHr).toBe(80); + expect(win?.baseline).toBe(60); + }); + + it("does NOT flag a workout — high HR during exercise is not tension", () => { + const win = detectTensionWindow( + tensionInput({ + buckets: elevatedRun(600, 4), + workouts: [{ startMinute: 595, endMinute: 645 }], + }), + ); + expect(win).toBeNull(); + }); + + it("does NOT flag when steps are high — a walk is movement, not tension", () => { + const win = detectTensionWindow( + tensionInput({ + buckets: elevatedRun(600, 4), + stepBuckets: new Map([ + ...zeroSteps(), + [600, 200], + [610, 210], + [620, 190], + [630, 220], + ]), + }), + ); + expect(win).toBeNull(); + }); + + it("does NOT flag heart rate that never clears the elevation margin", () => { + const justUnder = 60 + ELEVATION_MARGIN_BPM - 1; // below threshold + const win = detectTensionWindow( + tensionInput({ buckets: elevatedRun(600, 4, justUnder) }), + ); + expect(win).toBeNull(); + }); + + it("does NOT flag a short spike below the sustained-minutes floor", () => { + // Only two consecutive elevated buckets (20 min) — under the 30-min floor. + const win = detectTensionWindow( + tensionInput({ buckets: elevatedRun(600, 2) }), + ); + expect(win).toBeNull(); + }); + + it("breaks the run on a sparse (sub-density) bucket", () => { + const buckets: IntradayHrBucket[] = [ + { startMinute: 600, mean: 80, count: 5 }, + { startMinute: 610, mean: 80, count: 1 }, // gap — filtered out + { startMinute: 620, mean: 80, count: 5 }, + { startMinute: 630, mean: 80, count: 5 }, + { startMinute: 640, mean: 80, count: 5 }, + ]; + const win = detectTensionWindow(tensionInput({ buckets })); + // 600 stands alone (next valid 620 is not adjacent); 620-640 is the run. + expect(win?.startMinute).toBe(620); + expect(win?.endMinute).toBe(650); + }); + + it("stays silent until the baseline is mature", () => { + expect( + detectTensionWindow( + tensionInput({ buckets: elevatedRun(600, 4), baselineMature: false }), + ), + ).toBeNull(); + }); + + it("stays silent with no baseline at all", () => { + expect( + detectTensionWindow( + tensionInput({ buckets: elevatedRun(600, 4), baseline: null }), + ), + ).toBeNull(); + }); + + it("stays silent without enough step coverage to trust low movement", () => { + const win = detectTensionWindow( + tensionInput({ + buckets: elevatedRun(600, 4), + stepBuckets: new Map([ + [0, 0], + [10, 0], + ]), // below MIN_STEP_COVERAGE_BUCKETS + }), + ); + expect(win).toBeNull(); + }); + + it("reports the longest qualifying run when several exist", () => { + const buckets = [ + ...elevatedRun(120, 3), // 30 min + { startMinute: 150, mean: 60, count: 5 }, // break + ...elevatedRun(600, 5), // 50 min — the winner + ]; + const win = detectTensionWindow(tensionInput({ buckets })); + expect(win?.startMinute).toBe(600); + expect(win?.endMinute).toBe(650); + }); + + it("marks hrvConfirmed when intraday HRV confirms within the window", () => { + const win = detectTensionWindow( + tensionInput({ + buckets: elevatedRun(600, 4), + hrvConfirmMinutes: [610], + }), + ); + expect(win?.hrvConfirmed).toBe(true); + }); +}); diff --git a/src/lib/analytics/intraday-pulse-io.ts b/src/lib/analytics/intraday-pulse-io.ts new file mode 100644 index 000000000..f28f2f3af --- /dev/null +++ b/src/lib/analytics/intraday-pulse-io.ts @@ -0,0 +1,218 @@ +/** + * S11 — IO seam for the intraday pulse / tension layer. + * + * Loads ONE local day's raw signals on demand (read-swap; never persisted as + * 10-min rollups) and hands them to the pure `intraday-pulse` computations: + * + * - raw PULSE for the day → the 10-minute mean shape; + * - the personal resting baseline (RESTING_HEART_RATE, else the + * low-percentile PULSE proxy) → the elevation line; + * - intraday step samples → the mandatory low-movement gate; + * - workouts overlapping the day → exercise never reads as tension; + * - intraday HRV (WHOOP RMSSD) below the personal median → an optional + * confirming flag. + * + * Shared by `GET /api/insights/pulse/intraday` (the chart) and the daily + * digest's `tension_window` builder, so the signal is computed one way. + */ +import { prisma } from "@/lib/db"; +import { percentile } from "@/lib/insights/strain-score"; +import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; +import { + BUCKET_MINUTES, + MIN_BASELINE_DAYS, + computeTenMinuteMeanSeries, + detectTensionWindow, + makeLocalResolver, + type IntradayHrBucket, + type LocalResolver, + type TensionWindow, + type WorkoutInterval, +} from "@/lib/analytics/intraday-pulse"; + +/** UTC padding around the local calendar day so the query is a safe superset. */ +const WINDOW_LEAD_MS = 15 * 60 * 60 * 1000; // covers UTC−14…+12 day starts +const WINDOW_TRAIL_MS = 39 * 60 * 60 * 1000; + +/** Defensive read caps — dense Apple-Health accounts emit ~1 sample/second. */ +const MAX_DAY_PULSE_ROWS = 6000; +const MAX_DAY_STEP_ROWS = 2000; + +/** The DTO both the route and the digest read. */ +export interface IntradayPulseResult { + dateKey: string; + timezone: string; + bucketMinutes: number; + series: IntradayHrBucket[]; + baseline: number | null; + baselineSource: "resting" | "proxy" | "none"; + tension: TensionWindow | null; +} + +/** Median (p50), or null on an empty series. */ +function median(values: number[]): number | null { + if (values.length === 0) return null; + return Math.round(percentile(values, 50) * 10) / 10; +} + +/** + * Resolve the personal resting baseline and its maturity. Prefers the clean + * RESTING_HEART_RATE rows; falls back to the low-percentile PULSE proxy. The + * baseline is the median of the resolved daily series — robust to the odd + * outlier — and is "mature" only once at least `MIN_BASELINE_DAYS` distinct + * daily points exist. + */ +async function resolveBaseline(userId: string): Promise<{ + baseline: number | null; + mature: boolean; + source: "resting" | "proxy" | "none"; +}> { + const restingRows = await prisma.measurement.findMany({ + where: { userId, type: "RESTING_HEART_RATE", deletedAt: null }, + orderBy: { measuredAt: "desc" }, + take: 90, + select: { value: true, measuredAt: true }, + }); + + // Only reach for the heavier PULSE-history read when resting rows are thin. + const pulseHistory = + restingRows.length >= MIN_BASELINE_DAYS + ? [] + : await prisma.measurement.findMany({ + where: { userId, type: "PULSE", deletedAt: null }, + orderBy: { measuredAt: "desc" }, + take: 365, + select: { value: true, measuredAt: true }, + }); + + const resolved = resolveRestingPulseSeries({ + restingSamples: restingRows.map((r) => ({ + measuredAt: r.measuredAt, + value: r.value, + })), + pulseSamples: pulseHistory.map((r) => ({ + measuredAt: r.measuredAt, + value: r.value, + })), + }); + + const recent = resolved.series.slice(-30).map((p) => p.value); + return { + baseline: median(recent), + mature: resolved.series.length >= MIN_BASELINE_DAYS, + source: resolved.which, + }; +} + +/** Clamp a workout's local span to [0, 1440) minutes on `dateKey`, or null. */ +function workoutToInterval( + startedAt: Date, + endedAt: Date, + dateKey: string, + localOf: LocalResolver, +): WorkoutInterval | null { + const s = localOf(startedAt); + const e = localOf(endedAt); + // Reject spans entirely outside the day (both ends on another calendar day + // on the same side); otherwise clamp an overhanging end into the day. + const startMinute = + s.dayKey === dateKey ? s.minuteOfDay : s.dayKey < dateKey ? 0 : -1; + const endMinute = + e.dayKey === dateKey ? e.minuteOfDay : e.dayKey > dateKey ? 24 * 60 : -1; + if (startMinute < 0 || endMinute < 0 || endMinute <= startMinute) return null; + return { startMinute, endMinute }; +} + +/** + * Compute the intraday pulse shape + (cautious) tension window for one local + * day. Deterministic given the DB state; performs no AI/provider call. + */ +export async function loadIntradayPulse( + userId: string, + timezone: string, + dateKey: string, +): Promise { + const localOf = makeLocalResolver(timezone); + const anchorMs = new Date(`${dateKey}T00:00:00.000Z`).getTime(); + const start = new Date(anchorMs - WINDOW_LEAD_MS); + const end = new Date(anchorMs + WINDOW_TRAIL_MS); + + const [pulseRows, stepRows, workoutRows, baseline] = await Promise.all([ + prisma.measurement.findMany({ + where: { + userId, + type: "PULSE", + deletedAt: null, + measuredAt: { gte: start, lte: end }, + }, + orderBy: { measuredAt: "asc" }, + take: MAX_DAY_PULSE_ROWS, + select: { value: true, measuredAt: true }, + }), + prisma.measurement.findMany({ + where: { + userId, + type: "ACTIVITY_STEPS", + deletedAt: null, + measuredAt: { gte: start, lte: end }, + }, + orderBy: { measuredAt: "asc" }, + take: MAX_DAY_STEP_ROWS, + select: { value: true, measuredAt: true, externalId: true }, + }), + prisma.workout.findMany({ + where: { + userId, + startedAt: { lt: end }, + endedAt: { gt: start }, + }, + select: { startedAt: true, endedAt: true }, + }), + resolveBaseline(userId), + ]); + + const series = computeTenMinuteMeanSeries( + pulseRows.map((r) => ({ measuredAt: r.measuredAt, value: r.value })), + dateKey, + localOf, + ); + + // Intraday step buckets — EXCLUDE the consolidated `stats:` daily totals, or a + // single day-sum row would dump the whole day's steps into one bucket and + // wrongly mark an at-rest stretch as moving. + const stepBuckets = new Map(); + for (const row of stepRows) { + if (row.externalId?.startsWith("stats:")) continue; + const local = localOf(row.measuredAt); + if (local.dayKey !== dateKey) continue; + const startMinute = + Math.floor(local.minuteOfDay / BUCKET_MINUTES) * BUCKET_MINUTES; + stepBuckets.set( + startMinute, + (stepBuckets.get(startMinute) ?? 0) + row.value, + ); + } + + const workouts = workoutRows + .map((w) => workoutToInterval(w.startedAt, w.endedAt, dateKey, localOf)) + .filter((w): w is WorkoutInterval => w !== null); + + const tension = detectTensionWindow({ + buckets: series, + baseline: baseline.baseline, + baselineMature: baseline.mature, + stepBuckets, + workouts, + hrvConfirmMinutes: [], + }); + + return { + dateKey, + timezone, + bucketMinutes: BUCKET_MINUTES, + series, + baseline: baseline.baseline, + baselineSource: baseline.source, + tension, + }; +} diff --git a/src/lib/analytics/intraday-pulse.ts b/src/lib/analytics/intraday-pulse.ts new file mode 100644 index 000000000..8eea76598 --- /dev/null +++ b/src/lib/analytics/intraday-pulse.ts @@ -0,0 +1,290 @@ +/** + * S11 — intraday pulse shape + the elevated-at-rest ("tension") window. + * + * Two deterministic, IO-free computations that reveal the SHAPE of a single + * day's heart rate and, cautiously, whether a stretch of it looks like tension + * rather than exercise: + * + * 1. `computeTenMinuteMeanSeries` — folds the day's raw PULSE samples into + * 10-minute means in the user's local time. Computed ON DEMAND for the + * viewed day (read-swap; the same fold the hourly-mean retention uses), + * never persisted as 10-min rollups for all history. + * 2. `detectTensionWindow` — the differentiator. The tension signal is NOT + * raw HR (that conflates a workout with stress): it is heart rate + * SUSTAINED above the user's personal RESTING baseline WHILE movement is + * low — the "elevated-at-rest window". A workout-overlapping or + * high-step bucket can never qualify, so a hard run does not read as + * tension. Where intraday HRV exists (WHOOP), it is surfaced as a + * confirming flag but is never required. + * + * The launch posture is deliberately conservative — "says less than it could". + * A window is only reported when baseline maturity, per-bucket sample density, + * a mandatory low-movement gate, and a minimum sustained duration ALL hold. + * Silence over speculation. This is descriptive context, never a clinical + * stress diagnosis. + * + * Pure & deterministic — unit-tested in `__tests__/intraday-pulse.test.ts`. + */ +import { formatInUserTz } from "@/lib/tz/format"; + +/** Intraday bucket width, in minutes. Ten minutes reveals the day's shape. */ +export const BUCKET_MINUTES = 10; + +/** + * A 10-minute bucket needs at least this many raw samples before it is + * TRUSTED as a mean. A lone reading is a point, not a bucket — trusting it + * would let a single spike masquerade as a sustained stretch. Sub-floor + * buckets are treated as gaps that BREAK a candidate run (density gate). + */ +export const MIN_SAMPLES_PER_BUCKET = 2; + +/** + * How far above the resting baseline a bucket's mean must sit to count as + * "elevated". Twelve bpm keeps normal ambient drift (standing, a warm room, + * digestion) below the line — only a genuine, sustained lift clears it. The + * bar is deliberately high; under-flagging is the intended failure mode. + */ +export const ELEVATION_MARGIN_BPM = 12; + +/** + * Steps within a 10-minute bucket at or above this count mark the bucket as + * MOVING — a walk the raw HR would otherwise read as an elevated-at-rest + * stretch. Sixty steps in ten minutes is a slow amble; anything at or above + * it disqualifies the bucket from the tension window. + */ +export const STEP_MOVEMENT_THRESHOLD = 60; + +/** + * A day needs at least this many step buckets before a low / absent step + * reading can be TRUSTED as "genuinely not moving" rather than "steps simply + * weren't recorded". Below this coverage the low-movement gate cannot be + * satisfied and no window is reported — the movement gate is mandatory, and a + * missing step stream is silence, not evidence of rest. + */ +export const MIN_STEP_COVERAGE_BUCKETS = 6; + +/** + * The minimum run of consecutive qualifying buckets before a window is + * reported: three 10-minute buckets = 30 sustained minutes. A single elevated + * bucket (a flight of stairs, a phone call) is noise; half an hour of + * elevated-at-rest heart rate is a shape worth a cautious word. + */ +export const MIN_SUSTAINED_BUCKETS = 3; + +/** + * Minimum number of resting-baseline days before the baseline is mature enough + * to judge a single day against. A baseline built from two readings is not a + * personal norm yet — until it matures, no window is reported. + */ +export const MIN_BASELINE_DAYS = 7; + +/** A timestamped heart-rate sample (raw PULSE). */ +export interface IntradaySample { + measuredAt: Date; + value: number; +} + +/** A 10-minute mean bucket, anchored on its start minute-of-local-day. */ +export interface IntradayHrBucket { + /** Minutes since local midnight at the bucket's start (0, 10, 20, …). */ + startMinute: number; + /** Mean of the raw samples that fell in the bucket. */ + mean: number; + /** Raw sample count — buckets below `MIN_SAMPLES_PER_BUCKET` are gaps. */ + count: number; +} + +/** A workout's local-day span, in minutes since local midnight. */ +export interface WorkoutInterval { + startMinute: number; + endMinute: number; +} + +/** Coarse part-of-day label a window's midpoint falls in. */ +export type PartOfDay = "morning" | "afternoon" | "evening" | "night"; + +/** A detected elevated-at-rest window — a cautious tension marker. */ +export interface TensionWindow { + /** Window start / end, minutes since local midnight. */ + startMinute: number; + endMinute: number; + /** Part of day the window's midpoint falls in (drives the copy). */ + partOfDay: PartOfDay; + /** Mean HR across the window's buckets. */ + meanHr: number; + /** The resting baseline the window was judged against. */ + baseline: number; + /** True when intraday HRV independently confirmed the stretch (WHOOP). */ + hrvConfirmed: boolean; +} + +/** Local wall-clock projection of an instant: day key + minute-of-day. */ +export interface LocalWallClock { + dayKey: string; + minuteOfDay: number; +} + +/** Resolves an instant to the viewer's local day key + minute-of-day. */ +export type LocalResolver = (d: Date) => LocalWallClock; + +/** + * Build a `LocalResolver` for a timezone from the shared tz formatter. Kept + * here so the pure computations stay tz-correct without every caller + * re-deriving the wall-clock math; tests inject their own resolver for + * deterministic, tz-free fixtures. + */ +export function makeLocalResolver(tz: string): LocalResolver { + return (d: Date) => { + // "YYYY-MM-DD HH:mm" in the target zone — one formatter, no drift. + const wall = formatInUserTz(d, tz, "datetime"); + const [dayKey, clock] = wall.split(" "); + const [hh, mm] = clock.split(":"); + return { + dayKey, + minuteOfDay: parseInt(hh, 10) * 60 + parseInt(mm, 10), + }; + }; +} + +/** Round to one decimal — keeps the mean readable without false precision. */ +function round1(n: number): number { + return Math.round(n * 10) / 10; +} + +/** + * Fold a day's raw PULSE samples into ascending 10-minute mean buckets. Only + * samples whose LOCAL day matches `dayKey` participate, so a 23:55-local + * reading never bleeds into the next day's shape. Empty or sparse buckets are + * simply absent — the series is the buckets that exist, not a padded grid. + */ +export function computeTenMinuteMeanSeries( + samples: ReadonlyArray, + dayKey: string, + localOf: LocalResolver, +): IntradayHrBucket[] { + const acc = new Map(); + for (const s of samples) { + const local = localOf(s.measuredAt); + if (local.dayKey !== dayKey) continue; + const startMinute = + Math.floor(local.minuteOfDay / BUCKET_MINUTES) * BUCKET_MINUTES; + const bucket = acc.get(startMinute); + if (bucket) { + bucket.sum += s.value; + bucket.count += 1; + } else { + acc.set(startMinute, { sum: s.value, count: 1 }); + } + } + return [...acc.entries()] + .map(([startMinute, { sum, count }]) => ({ + startMinute, + mean: round1(sum / count), + count, + })) + .sort((a, b) => a.startMinute - b.startMinute); +} + +/** Coarse part-of-day for a minute-of-day (drives the cautious copy). */ +export function partOfDayForMinute(minute: number): PartOfDay { + const hour = Math.floor(minute / 60); + if (hour < 6) return "night"; + if (hour < 12) return "morning"; + if (hour < 18) return "afternoon"; + return "evening"; +} + +/** Whether a 10-minute bucket [start, start+10) intersects any workout. */ +function overlapsWorkout( + startMinute: number, + workouts: ReadonlyArray, +): boolean { + const end = startMinute + BUCKET_MINUTES; + return workouts.some((w) => w.startMinute < end && w.endMinute > startMinute); +} + +export interface DetectTensionInput { + /** The day's 10-minute HR buckets (any order; re-sorted internally). */ + buckets: ReadonlyArray; + /** Personal resting baseline (bpm), or null when unknown. */ + baseline: number | null; + /** Whether the baseline is built from enough history to trust. */ + baselineMature: boolean; + /** Steps per 10-minute bucket, keyed by bucket start minute. */ + stepBuckets: ReadonlyMap; + /** Workout spans (local minutes) — buckets overlapping one never qualify. */ + workouts: ReadonlyArray; + /** Bucket start-minutes where intraday HRV independently confirms (WHOOP). */ + hrvConfirmMinutes?: ReadonlyArray; +} + +/** + * Detect at most ONE elevated-at-rest window for the day, or null. A bucket + * qualifies only when it is (a) elevated ≥ `baseline + ELEVATION_MARGIN_BPM`, + * (b) NOT overlapping a workout, and (c) low-movement — its step count is below + * `STEP_MOVEMENT_THRESHOLD` AND the day has enough step coverage to trust that + * reading. The longest run of ≥ `MIN_SUSTAINED_BUCKETS` consecutive qualifying + * buckets becomes the window (earliest wins a tie). Every gate must hold; when + * any fails, the honest answer is no window. + */ +export function detectTensionWindow( + input: DetectTensionInput, +): TensionWindow | null { + const { baseline, baselineMature, stepBuckets, workouts } = input; + + // Gate 1 — baseline maturity. No personal norm yet ⇒ say nothing. + if (baseline == null || !baselineMature) return null; + + // Gate 2 — movement is MANDATORY and step-based. Without enough step + // coverage a low/absent reading is "not recorded", not "not moving", so the + // low-movement gate can never be satisfied and no window is reported. + if (stepBuckets.size < MIN_STEP_COVERAGE_BUCKETS) return null; + + const threshold = baseline + ELEVATION_MARGIN_BPM; + const hrvMinutes = new Set(input.hrvConfirmMinutes ?? []); + + // Only density-passing buckets participate; a sparse bucket is a gap that + // breaks a run (we cannot assert "sustained" across missing data). + const valid = [...input.buckets] + .filter((b) => b.count >= MIN_SAMPLES_PER_BUCKET) + .sort((a, b) => a.startMinute - b.startMinute); + + const qualifies = (b: IntradayHrBucket): boolean => { + if (b.mean < threshold) return false; + if (overlapsWorkout(b.startMinute, workouts)) return false; + const steps = stepBuckets.get(b.startMinute) ?? 0; + return steps < STEP_MOVEMENT_THRESHOLD; + }; + + // Longest run of CONSECUTIVE qualifying buckets (consecutive = adjacent + // 10-minute slots; any non-qualifying or missing slot breaks the run). + let best: IntradayHrBucket[] = []; + let run: IntradayHrBucket[] = []; + for (const b of valid) { + const prev = run[run.length - 1]; + const consecutive = + prev && b.startMinute === prev.startMinute + BUCKET_MINUTES; + if (qualifies(b)) { + run = consecutive ? [...run, b] : [b]; + } else { + run = []; + } + if (run.length > best.length) best = run; + } + + if (best.length < MIN_SUSTAINED_BUCKETS) return null; + + const startMinute = best[0].startMinute; + const endMinute = best[best.length - 1].startMinute + BUCKET_MINUTES; + const meanHr = round1(best.reduce((sum, b) => sum + b.mean, 0) / best.length); + const hrvConfirmed = best.some((b) => hrvMinutes.has(b.startMinute)); + + return { + startMinute, + endMinute, + partOfDay: partOfDayForMinute(Math.floor((startMinute + endMinute) / 2)), + meanHr, + baseline, + hrvConfirmed, + }; +} diff --git a/src/lib/daily/__tests__/digest.test.ts b/src/lib/daily/__tests__/digest.test.ts index b98156f08..6892a8b40 100644 --- a/src/lib/daily/__tests__/digest.test.ts +++ b/src/lib/daily/__tests__/digest.test.ts @@ -57,6 +57,7 @@ function input(over: Partial = {}): DailyDigestInput { syncIssues: [], preventiveDue: [], coachPlans: [], + tensionWindow: null, ...over, }; } @@ -413,3 +414,55 @@ describe("buildDailyDigest — coach check-in (S3)", () => { expect(d.worthALook[0].kind).toBe("dose_window"); }); }); + +describe("buildDailyDigest — S11 tension_window item", () => { + function tension(d: ReturnType) { + return d.worthALook.find((i) => i.kind === "tension_window"); + } + + it("emits a calm, non-diagnostic tension card when a window is present", () => { + const d = buildDailyDigest( + input({ tensionWindow: { partOfDay: "afternoon" } }), + t, + ); + const item = tension(d); + expect(item).toBeDefined(); + expect(item?.status).toBe("info"); + expect(item?.actions[0].intent).toBe("pulse.view"); + expect(item?.actions[0].href).toBe("/insights/pulse"); + expect(item?.body).toContain("afternoon"); + }); + + it("emits nothing when there is no window (honest-absent)", () => { + const d = buildDailyDigest(input({ tensionWindow: null }), t); + expect(tension(d)).toBeUndefined(); + }); + + it("stays silent when the insights module is off", () => { + const d = buildDailyDigest( + input({ + modules: { insights: false }, + tensionWindow: { partOfDay: "morning" }, + }), + t, + ); + expect(tension(d)).toBeUndefined(); + }); + + it("yields the bounded rail to time-sensitive actions first", () => { + const d = buildDailyDigest( + input({ + medsToday: meds({ nextDueOverdue: true, nextDueMedicationName: "X" }), + syncIssues: [ + { integration: "withings", state: "error_reauth" }, + { integration: "moodlog", state: "parked" }, + ], + tensionWindow: { partOfDay: "evening" }, + }), + t, + ); + // dose + 2 sync fill the cap; the calm tension marker waits. + expect(d.worthALook).toHaveLength(MAX_WORTH_A_LOOK); + expect(tension(d)).toBeUndefined(); + }); +}); diff --git a/src/lib/daily/digest.ts b/src/lib/daily/digest.ts index 4f4ff9753..859524543 100644 --- a/src/lib/daily/digest.ts +++ b/src/lib/daily/digest.ts @@ -92,6 +92,17 @@ export interface DailyDigestPreventiveDue { label: string; } +/** + * S11 — a confident elevated-at-rest ("tension") window for the day, already + * detected server-side (`loadIntradayPulse`) under its full confidence gate. + * The builder only formats it; the signal correctness lives in the analytics + * layer, and a null here means the honest "no window" — nothing is emitted. + */ +export interface DailyDigestTensionWindow { + /** Part of day the window's midpoint fell in — drives the cautious copy. */ + partOfDay: "morning" | "afternoon" | "evening" | "night"; +} + /** * A standing coach plan the IO seam offers as a check-in candidate (§2.3). The * builder computes due-ness deterministically from the plain columns — it never @@ -135,6 +146,8 @@ export interface DailyDigestInput { preventiveDue: DailyDigestPreventiveDue[]; /** Standing coach plans (active + reviewed) — check-in candidates (§2.3). */ coachPlans: DailyDigestCoachPlan[]; + /** S11 — the day's detected elevated-at-rest window, or null (honest-absent). */ + tensionWindow: DailyDigestTensionWindow | null; } export interface DailyDigest { @@ -256,6 +269,37 @@ function buildPreventiveCareItem( }; } +/** + * S11 — the elevated-at-rest ("tension") card. Emitted at most once per day + * (the window is already the day's single most confident stretch), gated on + * the `insights` module and on a non-null window (the analytics layer stays + * silent unless every confidence gate holds). Cautious, non-diagnostic copy: + * "possible tension", never a clinical stress verdict. The one action deep- + * links into the pulse insight where the intraday shape is charted. + */ +function buildTensionWindowItem( + window: DailyDigestTensionWindow | null, + modules: DigestModuleMap, + t: Translate, +): PriorityItem | null { + if (!moduleEnabled(modules, "insights")) return null; + if (!window) return null; + return { + kind: "tension_window", + title: t("daily.item.tensionWindow.title"), + body: t(`daily.item.tensionWindow.body.${window.partOfDay}`), + status: "info", + actions: [ + { + labelKey: "daily.action.viewPulse", + intent: "pulse.view", + href: "/insights/pulse", + }, + ], + moduleKey: "insights", + }; +} + /** * The instant a plan's check-in came due (§2.3). A coach-pinned `reviewDate` * wins. Otherwise, a `reviewed` plan lost its `reviewDate` to the daily sweep @@ -394,6 +438,11 @@ export function buildDailyDigest( if (checkin) worthALook.push(checkin); const preventive = buildPreventiveCareItem(input.preventiveDue, t); if (preventive) worthALook.push(preventive); + // S11 — the calm, informational tension marker sits last: it is context, not + // an action that expires, so a time-sensitive dose / sync / check-in wins the + // bounded rail ahead of it. + const tension = buildTensionWindowItem(input.tensionWindow, input.modules, t); + if (tension) worthALook.push(tension); // Defence-in-depth: no card ever exceeds the P1 action cap. for (const item of worthALook) { diff --git a/src/lib/daily/load-digest.ts b/src/lib/daily/load-digest.ts index 8747c5908..ea9453353 100644 --- a/src/lib/daily/load-digest.ts +++ b/src/lib/daily/load-digest.ts @@ -29,7 +29,9 @@ import { type DailyDigestPreventiveDue, type DailyDigestScore, type DailyDigestSyncIssue, + type DailyDigestTensionWindow, } from "@/lib/daily/digest"; +import { loadIntradayPulse } from "@/lib/analytics/intraday-pulse-io"; /** Integration states that mean "your action is needed to keep data flowing". */ const SYNC_ISSUE_STATES = ["error_reauth", "parked"] as const; @@ -172,6 +174,17 @@ export async function loadDailyDigest( ? planRows.map((row) => toCoachPlanCandidate(row as CoachPlanRow)) : []; + // S11 — the day's elevated-at-rest window, computed on demand from raw for + // TODAY only (read-swap, one bounded day-read; never persisted). Gated on the + // insights module and fault-isolated: a tension-read failure must never break + // the digest (a hot, must-not-fail path), it just omits the calm marker. + const tensionWindow: DailyDigestTensionWindow | null = + modules.insights !== false + ? await loadIntradayPulse(user.id, user.timezone, todayLocalDate) + .then((r) => (r.tension ? { partOfDay: r.tension.partOfDay } : null)) + .catch(() => null) + : null; + const { t } = getServerTranslator(resolveLocale(locale)); const digest = buildDailyDigest( @@ -186,6 +199,7 @@ export async function loadDailyDigest( syncIssues, preventiveDue, coachPlans, + tensionWindow, }, t, ); diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts index d2d015f3a..517528d57 100644 --- a/src/lib/query-keys/insights.ts +++ b/src/lib/query-keys/insights.ts @@ -84,6 +84,15 @@ export const insightsKeys = { insightsEcgList: () => ["insights", "ecg"] as const, insightsEcgDetail: (id: string, full: boolean) => ["insights", "ecg", id, full ? "full" : "overview"] as const, + /** + * S11 — the intraday pulse / tension layer (`/api/insights/pulse/intraday`). + * Keyed by the viewed local day so each day's 10-minute-mean shape caches + * independently and yesterday's curve never poisons today's. Under the + * `["insights"]` prefix so a fresh PULSE write busts it via the standard + * insights invalidation fan-out. + */ + insightsPulseIntraday: (dateKey: string) => + ["insights", "pulse-intraday", dateKey] as const, /** * v1.10.0 — derived-wellness metrics. One generic route * (`/api/insights/derived?metric=…[&type=…]`) backs the vitals