mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Merge branch 'feat/cap-s11-pulse' into release/v1.29.0
# Conflicts: # messages/de.json # messages/en.json # messages/es.json # messages/fr.json # messages/it.json # messages/pl.json # src/lib/daily/__tests__/digest.test.ts # src/lib/daily/digest.ts # src/lib/daily/load-digest.ts
This commit is contained in:
commit
2f1423fa09
17 files changed
+1289
-6
No files matched your search
+23
-1
@@ -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": {
|
||||
@@ -8558,7 +8579,8 @@
|
||||
"checkinKeep": "Behalten",
|
||||
"checkinAdjust": "Anpassen",
|
||||
"checkinLetGo": "Loslassen",
|
||||
"viewMilestone": "Ansehen"
|
||||
"viewMilestone": "Ansehen",
|
||||
"viewPulse": "Puls ansehen"
|
||||
},
|
||||
"today": {
|
||||
"scoreLabel": "Gesundheitsscore",
|
||||
|
||||
+23
-1
@@ -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": {
|
||||
@@ -8558,7 +8579,8 @@
|
||||
"checkinKeep": "Keep it",
|
||||
"checkinAdjust": "Adjust",
|
||||
"checkinLetGo": "Let it go",
|
||||
"viewMilestone": "View"
|
||||
"viewMilestone": "View",
|
||||
"viewPulse": "View pulse"
|
||||
},
|
||||
"today": {
|
||||
"scoreLabel": "Health score",
|
||||
|
||||
+23
-1
@@ -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": {
|
||||
@@ -8558,7 +8579,8 @@
|
||||
"checkinKeep": "Conservar",
|
||||
"checkinAdjust": "Ajustar",
|
||||
"checkinLetGo": "Dejarlo ir",
|
||||
"viewMilestone": "Ver"
|
||||
"viewMilestone": "Ver",
|
||||
"viewPulse": "Ver pulso"
|
||||
},
|
||||
"today": {
|
||||
"scoreLabel": "Puntuación de salud",
|
||||
|
||||
+23
-1
@@ -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": {
|
||||
@@ -8558,7 +8579,8 @@
|
||||
"checkinKeep": "Garder",
|
||||
"checkinAdjust": "Ajuster",
|
||||
"checkinLetGo": "Laisser tomber",
|
||||
"viewMilestone": "Voir"
|
||||
"viewMilestone": "Voir",
|
||||
"viewPulse": "Voir le pouls"
|
||||
},
|
||||
"today": {
|
||||
"scoreLabel": "Score de santé",
|
||||
|
||||
+23
-1
@@ -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": {
|
||||
@@ -8558,7 +8579,8 @@
|
||||
"checkinKeep": "Mantieni",
|
||||
"checkinAdjust": "Modifica",
|
||||
"checkinLetGo": "Lascia andare",
|
||||
"viewMilestone": "Vedi"
|
||||
"viewMilestone": "Vedi",
|
||||
"viewPulse": "Vedi battito"
|
||||
},
|
||||
"today": {
|
||||
"scoreLabel": "Punteggio di salute",
|
||||
|
||||
+23
-1
@@ -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": {
|
||||
@@ -8558,7 +8579,8 @@
|
||||
"checkinKeep": "Zachowaj",
|
||||
"checkinAdjust": "Dostosuj",
|
||||
"checkinLetGo": "Odpuść",
|
||||
"viewMilestone": "Zobacz"
|
||||
"viewMilestone": "Zobacz",
|
||||
"viewPulse": "Zobacz tętno"
|
||||
},
|
||||
"today": {
|
||||
"scoreLabel": "Wynik zdrowia",
|
||||
|
||||
@@ -112,6 +112,12 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray<string> = [
|
||||
"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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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. */}
|
||||
<IntradayPulseChart userTimezone={user?.timezone} />
|
||||
|
||||
<MetricTargetSummary slug="pulse" />
|
||||
|
||||
{/* v1.12.0 — Pulse owns the mood × pulse correlation (relocated off
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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<number, number> {
|
||||
const m = new Map<number, number>();
|
||||
// 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> = {},
|
||||
): 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);
|
||||
});
|
||||
});
|
||||
@@ -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<IntradayPulseResult> {
|
||||
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<number, number>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<IntradaySample>,
|
||||
dayKey: string,
|
||||
localOf: LocalResolver,
|
||||
): IntradayHrBucket[] {
|
||||
const acc = new Map<number, { sum: number; count: number }>();
|
||||
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<WorkoutInterval>,
|
||||
): 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<IntradayHrBucket>;
|
||||
/** 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<number, number>;
|
||||
/** Workout spans (local minutes) — buckets overlapping one never qualify. */
|
||||
workouts: ReadonlyArray<WorkoutInterval>;
|
||||
/** Bucket start-minutes where intraday HRV independently confirms (WHOOP). */
|
||||
hrvConfirmMinutes?: ReadonlyArray<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -59,6 +59,7 @@ function input(over: Partial<DailyDigestInput> = {}): DailyDigestInput {
|
||||
syncIssues: [],
|
||||
preventiveDue: [],
|
||||
coachPlans: [],
|
||||
tensionWindow: null,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -481,3 +482,55 @@ describe("S12 — the milestone reward card", () => {
|
||||
expect(item?.body ?? "").not.toMatch(forbidden);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDailyDigest — S11 tension_window item", () => {
|
||||
function tension(d: ReturnType<typeof buildDailyDigest>) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -97,6 +97,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
|
||||
@@ -147,6 +158,8 @@ export interface DailyDigestInput {
|
||||
* builder only ever sees a milestone worth celebrating today.
|
||||
*/
|
||||
milestone?: Milestone | null;
|
||||
/** S11 — the day's detected elevated-at-rest window, or null (honest-absent). */
|
||||
tensionWindow: DailyDigestTensionWindow | null;
|
||||
}
|
||||
|
||||
export interface DailyDigest {
|
||||
@@ -300,6 +313,37 @@ function buildMilestoneItem(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -443,6 +487,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) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type DailyDigestPreventiveDue,
|
||||
type DailyDigestScore,
|
||||
type DailyDigestSyncIssue,
|
||||
type DailyDigestTensionWindow,
|
||||
} from "@/lib/daily/digest";
|
||||
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
|
||||
import { readDayMeanSeries } from "@/lib/insights/derived/baseline";
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
selectFreshMilestone,
|
||||
type Milestone,
|
||||
} from "@/lib/daily/milestones";
|
||||
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;
|
||||
@@ -269,6 +271,16 @@ export async function loadDailyDigest(
|
||||
? await gatherFreshMilestone(user.id, now)
|
||||
: null;
|
||||
|
||||
// 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 = insightsEnabled
|
||||
? 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(
|
||||
@@ -284,6 +296,7 @@ export async function loadDailyDigest(
|
||||
preventiveDue,
|
||||
coachPlans,
|
||||
milestone,
|
||||
tensionWindow,
|
||||
},
|
||||
t,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user