diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index b66a81c6c..e9dfaa516 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -83586,6 +83586,24 @@ components:
- weight
additionalProperties: false
- type: "null"
+ device:
+ description: STRAIN only — present when no computed strain score exists in the window and the score is served from the
+ device's own DAY_STRAIN instead. `score` is then `value` as a 0–100 share of `scaleMax`. Absent when the
+ computed proxy is the source.
+ anyOf:
+ - type: object
+ properties:
+ value:
+ type: number
+ description: The device's latest day strain on its own scale.
+ scaleMax:
+ type: number
+ description: Top of the device scale (21 for WHOOP day strain).
+ required:
+ - value
+ - scaleMax
+ additionalProperties: false
+ - type: "null"
required:
- score
- band
diff --git a/messages/de.json b/messages/de.json
index 4f5a17b6b..3f66e1bbd 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4340,7 +4340,9 @@
"method": "Ein nächtlicher 0–100-Belastungs-Proxy aus deiner herzfrequenzgewichteten Aktivitätslast (ein TRIMP-ähnliches Trainingsimpuls-Modell). Höher heißt ein anstrengenderer Tag.",
"anchorPersonal": "An deine eigene jüngste Trainingstagslast angelehnt — bewertet relativ zu deiner typischen Anstrengung, sodass ein für dich wirklich harter Tag hoch ausfällt.",
"anchorPopulation": "Gegen einen allgemeinen Referenzwert bewertet, während du noch Trainingsverlauf aufbaust; sobald genug vorhanden ist, wechselt die Bewertung auf deine eigene typische Anstrengung.",
- "description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung, relativ zu deiner typischen Anstrengung."
+ "description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung, relativ zu deiner typischen Anstrengung.",
+ "deviceCaption": "Tagesbelastung deines Geräts: {value} von {max}",
+ "deviceSource": "Bis HealthLog nachts einen eigenen Wert berechnet hat, zeigt die Karte die Tagesbelastung deines Geräts, umgerechnet auf 0–100."
},
"WRIST_TEMPERATURE_BASELINE": {
"method": "Deine typische nächtliche Handgelenkstemperatur ist der Median deiner nächtlichen Messwerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Sie folgt DEINEN Werten — letzte Nacht wird als Abweichung von deiner eigenen Baseline gezeigt, nicht von einer Bevölkerungsnorm.",
diff --git a/messages/en.json b/messages/en.json
index 6603bcbc7..a391ae472 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4340,7 +4340,9 @@
"method": "A nightly 0–100 strain proxy from your heart-rate-weighted activity load (a TRIMP-style training-impulse model). Higher means a harder day.",
"anchorPersonal": "Anchored to your own recent training-day load — scored relative to your typical effort, so a genuinely hard day for you reads high.",
"anchorPopulation": "Scored against a general reference while you build training history; once you have enough it switches to your own typical effort.",
- "description": "A nightly activity-load proxy from your heart-rate-weighted effort, relative to your typical effort."
+ "description": "A nightly activity-load proxy from your heart-rate-weighted effort, relative to your typical effort.",
+ "deviceCaption": "Your device's day strain: {value} of {max}",
+ "deviceSource": "Until HealthLog has computed its own value overnight, the card shows your device's day strain, rescaled to 0–100."
},
"WRIST_TEMPERATURE_BASELINE": {
"method": "Your typical overnight wrist temperature is the median of your nightly readings over the window, ± a robust spread (median absolute deviation, scaled). It tracks YOUR readings — last night is shown as a deviation from your own baseline, not a population norm.",
diff --git a/messages/es.json b/messages/es.json
index 0c3c15219..17a5b9d4f 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4340,7 +4340,9 @@
"method": "Un indicador nocturno de esfuerzo de 0 a 100 a partir de tu carga de actividad ponderada por frecuencia cardíaca (un modelo de impulso de entrenamiento tipo TRIMP). Más alto significa un día más exigente.",
"anchorPersonal": "Anclado a tu propia carga reciente de días de entrenamiento: se evalúa en relación con tu esfuerzo habitual, de modo que un día realmente duro para ti aparece alto.",
"anchorPopulation": "Se evalúa frente a una referencia general mientras acumulas historial de entrenamiento; cuando tengas suficiente, pasa a tu propio esfuerzo habitual.",
- "description": "Un indicador nocturno de carga de actividad a partir de tu esfuerzo ponderado por frecuencia cardíaca, relativo a tu esfuerzo habitual."
+ "description": "Un indicador nocturno de carga de actividad a partir de tu esfuerzo ponderado por frecuencia cardíaca, relativo a tu esfuerzo habitual.",
+ "deviceCaption": "Esfuerzo diario de tu dispositivo: {value} de {max}",
+ "deviceSource": "Hasta que HealthLog calcule su propio valor durante la noche, la tarjeta muestra el esfuerzo diario de tu dispositivo, convertido a 0–100."
},
"WRIST_TEMPERATURE_BASELINE": {
"method": "Tu temperatura típica de muñeca durante la noche es la mediana de tus lecturas nocturnas en el periodo, ± una dispersión robusta (desviación absoluta de la mediana, escalada). Sigue TUS lecturas: la noche pasada se muestra como una desviación respecto a tu propia base, no a una norma poblacional.",
diff --git a/messages/fr.json b/messages/fr.json
index eeeeb1e25..04eda7899 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4340,7 +4340,9 @@
"method": "Un indice d’effort nocturne de 0 à 100 à partir de votre charge d’activité pondérée par la fréquence cardiaque (un modèle d’impulsion d’entraînement de type TRIMP). Plus élevé signifie une journée plus exigeante.",
"anchorPersonal": "Calé sur votre propre charge récente des jours d’entraînement — évalué par rapport à votre effort habituel, de sorte qu’une journée vraiment difficile pour vous ressort élevée.",
"anchorPopulation": "Évalué par rapport à une référence générale pendant que vous constituez votre historique d’entraînement ; dès que vous en avez assez, le calcul passe à votre propre effort habituel.",
- "description": "Un indice de charge d’activité nocturne à partir de votre effort pondéré par la fréquence cardiaque, relatif à votre effort habituel."
+ "description": "Un indice de charge d’activité nocturne à partir de votre effort pondéré par la fréquence cardiaque, relatif à votre effort habituel.",
+ "deviceCaption": "Effort du jour de votre appareil : {value} sur {max}",
+ "deviceSource": "Tant que HealthLog n’a pas calculé sa propre valeur pendant la nuit, la carte affiche l’effort du jour de votre appareil, ramené sur 0–100."
},
"WRIST_TEMPERATURE_BASELINE": {
"method": "Votre température nocturne typique au poignet est la médiane de vos relevés nocturnes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). Elle suit VOS relevés — la nuit dernière est affichée comme un écart par rapport à votre propre référence, pas à une norme de population.",
diff --git a/messages/it.json b/messages/it.json
index 162c4ed1f..0919825b9 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4340,7 +4340,9 @@
"method": "Un indicatore notturno di sforzo da 0 a 100 dal tuo carico di attività pesato sulla frequenza cardiaca (un modello di impulso d’allenamento tipo TRIMP). Più alto significa una giornata più impegnativa.",
"anchorPersonal": "Ancorato al tuo carico recente nei giorni di allenamento — valutato rispetto al tuo sforzo abituale, così che una giornata davvero dura per te risulti alta.",
"anchorPopulation": "Valutato rispetto a un riferimento generale mentre costruisci lo storico di allenamento; quando ne hai abbastanza passa al tuo sforzo abituale.",
- "description": "Un indicatore notturno di carico di attività dal tuo sforzo pesato sulla frequenza cardiaca, relativo al tuo sforzo abituale."
+ "description": "Un indicatore notturno di carico di attività dal tuo sforzo pesato sulla frequenza cardiaca, relativo al tuo sforzo abituale.",
+ "deviceCaption": "Sforzo giornaliero del tuo dispositivo: {value} su {max}",
+ "deviceSource": "Finché HealthLog non ha calcolato un proprio valore durante la notte, la scheda mostra lo sforzo giornaliero del tuo dispositivo, riportato su 0–100."
},
"WRIST_TEMPERATURE_BASELINE": {
"method": "La tua tipica temperatura notturna al polso è la mediana delle tue letture notturne nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). Segue le TUE letture — la notte scorsa è mostrata come scostamento dalla tua baseline, non da una norma di popolazione.",
diff --git a/messages/ko.json b/messages/ko.json
index 8aa72d743..7dc463063 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -4340,7 +4340,9 @@
"method": "심박수로 가중한 활동 부하(TRIMP 방식 훈련 자극 모델)로 매일 밤 내는 0–100 부하 지표예요. 높을수록 힘든 하루였다는 뜻이에요.",
"anchorPersonal": "최근 운동한 날의 부하를 기준으로 삼아요 — 평소 강도와 비교해 점수를 매기니 나에게 정말 힘든 날이 높게 나와요.",
"anchorPopulation": "운동 기록이 쌓이는 동안에는 일반 기준으로 점수를 매기고, 충분히 모이면 내 평소 강도 기준으로 바뀌어요.",
- "description": "심박수로 가중한 운동 강도를 평소 강도와 비교해 매일 밤 계산하는 활동 부하 지표예요."
+ "description": "심박수로 가중한 운동 강도를 평소 강도와 비교해 매일 밤 계산하는 활동 부하 지표예요.",
+ "deviceCaption": "기기의 일일 부하: {max} 중 {value}",
+ "deviceSource": "HealthLog가 밤사이 자체 값을 계산하기 전까지는 기기의 일일 부하를 0–100으로 환산해 보여 줘요."
},
"WRIST_TEMPERATURE_BASELINE": {
"method": "밤사이 손목 온도의 일반 범위는 해당 기간 야간 측정값의 중앙값에 견고한 산포(중앙절대편차를 보정한 값)를 더하고 뺀 구간이에요. 내 측정값을 따라가기 때문에, 지난밤은 인구 평균이 아니라 내 기준값에서 얼마나 벗어났는지로 보여줘요.",
diff --git a/messages/pl.json b/messages/pl.json
index 6f50fcf3a..c3c2b4f76 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4340,7 +4340,9 @@
"method": "Nocny wskaźnik obciążenia 0–100 na podstawie Twojego obciążenia aktywnością ważonego tętnem (model impulsu treningowego typu TRIMP). Wyższy oznacza cięższy dzień.",
"anchorPersonal": "Zakotwiczony w Twoim własnym niedawnym obciążeniu z dni treningowych — oceniany względem Twojego typowego wysiłku, więc naprawdę ciężki dzień dla Ciebie wypada wysoko.",
"anchorPopulation": "Oceniany względem ogólnego punktu odniesienia, gdy dopiero budujesz historię treningów; gdy zbierzesz jej wystarczająco, przełącza się na Twój własny typowy wysiłek.",
- "description": "Nocny wskaźnik obciążenia aktywnością z Twojego wysiłku ważonego tętnem, względem Twojego typowego wysiłku."
+ "description": "Nocny wskaźnik obciążenia aktywnością z Twojego wysiłku ważonego tętnem, względem Twojego typowego wysiłku.",
+ "deviceCaption": "Dzienne obciążenie z Twojego urządzenia: {value} z {max}",
+ "deviceSource": "Dopóki HealthLog nie obliczy w nocy własnej wartości, karta pokazuje dzienne obciążenie z Twojego urządzenia, przeliczone na 0–100."
},
"WRIST_TEMPERATURE_BASELINE": {
"method": "Twoja typowa nocna temperatura nadgarstka to mediana Twoich nocnych odczytów w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Śledzi TWOJE odczyty — ostatnia noc jest pokazana jako odchylenie od Twojej własnej bazy, a nie od normy populacyjnej.",
diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx
index 7f6bee80d..17d6637ce 100644
--- a/src/components/insights/derived/composite-score-anatomy.tsx
+++ b/src/components/insights/derived/composite-score-anatomy.tsx
@@ -3,6 +3,7 @@
import { Sparkles } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
+import { resolveIntlLocale } from "@/lib/format-locale";
import { useDerivedMetric } from "./use-derived-metric";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorRow } from "@/components/ui/query-error-row";
@@ -109,7 +110,7 @@ export function CompositeScoreAnatomy({
metric,
className,
}: CompositeScoreAnatomyProps) {
- const { t } = useTranslations();
+ const { t, locale } = useTranslations();
const query = useDerivedMetric<
SleepScoreValue | ReadinessValue | WellnessScoreValue
@@ -135,7 +136,15 @@ export function CompositeScoreAnatomy({
// the displayed framing honest per-score. `null` (RECOVERY / STRESS, or no
// cache row yet) renders nothing.
let anchorLine: string | null = null;
- if (metric === "STRAIN_SCORE" && data?.status === "ok" && data.value) {
+ // Strain served from the device's own day strain (no computed proxy in the
+ // window): the card says so, and the course below charts that same series.
+ const deviceStrain =
+ metric === "STRAIN_SCORE" && data?.status === "ok" && data.value
+ ? ((data.value as WellnessScoreValue).device ?? null)
+ : null;
+ if (deviceStrain) {
+ anchorLine = t("insights.derived.composite.STRAIN_SCORE.deviceSource");
+ } else if (metric === "STRAIN_SCORE" && data?.status === "ok" && data.value) {
const anchor = (data.value as WellnessScoreValue).anchor;
if (anchor === "personal") {
anchorLine = t("insights.derived.composite.STRAIN_SCORE.anchorPersonal");
@@ -238,7 +247,15 @@ export function CompositeScoreAnatomy({
// READINESS component keys verbatim. STRESS / STRAIN stay row-less.
const v = data.value as WellnessScoreValue;
score = v.score;
- caption = t(`insights.derived.scoreRing.band.${v.band}`);
+ caption = v.device
+ ? t("insights.derived.composite.STRAIN_SCORE.deviceCaption", {
+ value: v.device.value.toLocaleString(resolveIntlLocale(locale), {
+ minimumFractionDigits: 1,
+ maximumFractionDigits: 1,
+ }),
+ max: v.device.scaleMax,
+ })
+ : t(`insights.derived.scoreRing.band.${v.band}`);
if (metric === "RECOVERY_SCORE" && v.components) {
contributors = v.components
.map((c) => ({
@@ -275,7 +292,10 @@ export function CompositeScoreAnatomy({
data.status !== "ok" ? null : metric === "RECOVERY_SCORE" ||
metric === "STRESS_SCORE" ||
metric === "STRAIN_SCORE" ? (
-
+
) : series && series.length >= 2 ? (
{
expect(where.source).toBe("COMPUTED");
});
});
+
+describe("computeWellnessScore — strain reads the device's day strain", () => {
+ // `/insights/recovery` charts the band's own DAY_STRAIN. The strain page
+ // must not call the same account "not enough data": with no computed proxy
+ // in the window it serves the device's day strain, on the device's scale.
+ function byType(rows: Record) {
+ findMany.mockImplementation(
+ async (args: { where: { type: string } }) => rows[args.where.type] ?? [],
+ );
+ }
+
+ it("falls back to DAY_STRAIN when no computed strain score exists", async () => {
+ byType({
+ STRAIN_SCORE: [],
+ DAY_STRAIN: [
+ {
+ value: 10.5,
+ measuredAt: new Date("2026-06-02T04:00:00Z"),
+ source: "WHOOP",
+ },
+ {
+ value: 14.7,
+ measuredAt: new Date("2026-06-01T04:00:00Z"),
+ source: "WHOOP",
+ },
+ ],
+ });
+ const r = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, {
+ now: NOW,
+ });
+ expect(r.status).toBe("ok");
+ if (r.status !== "ok") return;
+ const v = r.value as WellnessScoreValue;
+ expect(v.device).toEqual({ value: 10.5, scaleMax: 21 });
+ expect(v.score).toBe(50);
+ expect(v.daysInWindow).toBe(2);
+ expect(r.provenance.inputs).toEqual(["DAY_STRAIN"]);
+ });
+
+ it("keeps the computed proxy when one exists", async () => {
+ byType({
+ STRAIN_SCORE: [
+ {
+ value: 64,
+ measuredAt: new Date("2026-06-01T12:00:00Z"),
+ source: "COMPUTED",
+ },
+ ],
+ DAY_STRAIN: [
+ {
+ value: 10.5,
+ measuredAt: new Date("2026-06-02T04:00:00Z"),
+ source: "WHOOP",
+ },
+ ],
+ });
+ const r = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, {
+ now: NOW,
+ });
+ expect(r.status).toBe("ok");
+ if (r.status !== "ok") return;
+ expect(r.value.score).toBe(64);
+ expect(r.value.device ?? null).toBeNull();
+ });
+
+ it("stays insufficient with neither", async () => {
+ byType({});
+ const r = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, {
+ now: NOW,
+ });
+ expect(r.status).toBe("insufficient");
+ });
+});
diff --git a/src/lib/insights/derived/wellness-scores.ts b/src/lib/insights/derived/wellness-scores.ts
index 4e0830d4b..c60148976 100644
--- a/src/lib/insights/derived/wellness-scores.ts
+++ b/src/lib/insights/derived/wellness-scores.ts
@@ -68,8 +68,24 @@ export interface WellnessScoreValue {
* non-breaking).
*/
components?: ReadinessComponent[] | null;
+ /**
+ * STRAIN only — set when the score is the device's own day strain rather
+ * than the server's computed proxy: no proxy row exists in the window, but
+ * the band delivered `DAY_STRAIN` (WHOOP's 0–21 scale). `value` is the
+ * device's latest reading on its own scale; `score` is that reading as a
+ * share of `scaleMax`, so the ring and the band keep their 0–100 contract.
+ * Absent whenever the computed proxy is the source.
+ */
+ device?: { value: number; scaleMax: number } | null;
}
+/**
+ * The device-native day-strain scale. WHOOP reports cycle strain on 0–21;
+ * the ingest validation (`DAY_STRAIN: { min: 0, max: 21 }`) pins the same
+ * bound, so a stored reading can never exceed it.
+ */
+export const DEVICE_STRAIN_SCALE_MAX = 21;
+
/** The three persisted score types this engine serves. */
export const WELLNESS_SCORE_TYPES = {
RECOVERY_SCORE: "RECOVERY_SCORE",
@@ -176,6 +192,26 @@ export async function computeWellnessScore(
)
: rawRows;
+ // STRAIN has a second, device-native source: the band's own DAY_STRAIN,
+ // which `/insights/recovery` charts. Without a computed proxy in the window
+ // the strain page reads that same series instead of reporting "not enough
+ // data" for an account the recovery page shows weeks of strain for.
+ if (rows.length === 0 && type === "STRAIN_SCORE") {
+ const deviceRows = await prisma.measurement.findMany({
+ where: {
+ userId,
+ type: "DAY_STRAIN",
+ deletedAt: null,
+ measuredAt: { gte: cutoff, lte: now },
+ },
+ select: { value: true, measuredAt: true, source: true },
+ orderBy: { measuredAt: "desc" },
+ });
+ if (deviceRows.length > 0) {
+ return buildDeviceStrain(deviceRows, windowDays, computedAt);
+ }
+ }
+
if (rows.length === 0) {
return buildInsufficient({
coverage: {
@@ -270,3 +306,55 @@ export async function computeWellnessScore(
},
});
}
+
+/** A device day-strain reading as a 0–100 share of the device scale. */
+function deviceStrainShare(value: number): number {
+ const clamped = Math.min(Math.max(value, 0), DEVICE_STRAIN_SCALE_MAX);
+ return Math.round((clamped / DEVICE_STRAIN_SCALE_MAX) * 100);
+}
+
+function buildDeviceStrain(
+ rows: { value: number; measuredAt: Date }[],
+ windowDays: number,
+ computedAt: string,
+): Derived {
+ const latest = rows[0];
+ const score = deviceStrainShare(latest.value);
+ const prior = rows.slice(1).map((r) => deviceStrainShare(r.value));
+ const trendDelta =
+ prior.length > 0
+ ? Math.round(score - prior.reduce((s, v) => s + v, 0) / prior.length)
+ : null;
+ return buildOk({
+ value: {
+ score,
+ band: bandWellnessScore("STRAIN_SCORE", score),
+ trendDelta,
+ daysInWindow: rows.length,
+ asOf: latest.measuredAt.toISOString(),
+ series: rows
+ .slice(0, SPARKLINE_MAX_POINTS)
+ .map((r) => deviceStrainShare(r.value))
+ .reverse(),
+ anchor: null,
+ components: null,
+ device: {
+ value: Math.round(latest.value * 10) / 10,
+ scaleMax: DEVICE_STRAIN_SCALE_MAX,
+ },
+ },
+ coverage: {
+ requiredInputs: 1,
+ presentInputs: 1,
+ historyDays: rows.length,
+ missing: [],
+ },
+ confidence: { score: 90, band: "high" },
+ provenance: {
+ inputs: ["DAY_STRAIN"],
+ source: "DAY",
+ windowDays,
+ computedAt,
+ },
+ });
+}
diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts
index 3ba65922d..b35e7febb 100644
--- a/src/lib/openapi/routes/insights/schemas.ts
+++ b/src/lib/openapi/routes/insights/schemas.ts
@@ -254,6 +254,20 @@ export const wellnessScoreValue = z
.describe(
"RECOVERY only, and only when the canonical value is the server's computed proxy — a device-native recovery percentage is not our blend and carries no decomposition.",
),
+ device: z
+ .object({
+ value: z
+ .number()
+ .describe("The device's latest day strain on its own scale."),
+ scaleMax: z
+ .number()
+ .describe("Top of the device scale (21 for WHOOP day strain)."),
+ })
+ .nullable()
+ .optional()
+ .describe(
+ "STRAIN only — present when no computed strain score exists in the window and the score is served from the device's own DAY_STRAIN instead. `score` is then `value` as a 0–100 share of `scaleMax`. Absent when the computed proxy is the source.",
+ ),
})
.meta({ id: "WellnessScoreValue" });