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:
Marc-André Bombeck committed 2026-07-17 08:25:44 +02:00
commit 2f1423fa09
17 files changed
+1289 -6

No files matched your search

+23 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
});
+6
View File
@@ -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);
});
});
+218
View File
@@ -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,
};
}
+290
View File
@@ -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,
};
}
+53
View File
@@ -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();
});
});
+49
View File
@@ -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) {
+13
View File
@@ -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,
);
+9
View File
@@ -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