From ac482c508d16b079a86a0623b8f1e8e80c0d1660 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 18:17:20 +0200 Subject: [PATCH] Serve the device's day strain on the strain page when no proxy exists The recovery page charts the band's own DAY_STRAIN while the strain score page read only the computed proxy, so one account showed weeks of strain on one page and 'not enough data' on the other. Without a proxy row in the window the derived strain read now falls back to DAY_STRAIN: the score is the reading as a share of the device scale, an additive 'device' block carries the native value, and the course below the card charts that same series. --- docs/api/openapi.yaml | 18 ++++ messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/ko.json | 4 +- messages/pl.json | 4 +- .../derived/composite-score-anatomy.tsx | 28 +++++- .../insights/derived/score-history.tsx | 5 +- .../derived/__tests__/wellness-scores.test.ts | 73 +++++++++++++++ src/lib/insights/derived/wellness-scores.ts | 88 +++++++++++++++++++ src/lib/openapi/routes/insights/schemas.ts | 14 +++ 13 files changed, 241 insertions(+), 13 deletions(-) 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" });