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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 18:17:20 +02:00
1 parent 605d9b1fb4
commit ac482c508d
13 files changed
+241 -13

No files matched your search

+18
View File
@@ -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
+3 -1
View File
@@ -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.",
+3 -1
View File
@@ -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.",
+3 -1
View File
@@ -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.",
+3 -1
View File
@@ -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.",
+3 -1
View File
@@ -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.",
+3 -1
View File
@@ -4340,7 +4340,9 @@
"method": "심박수로 가중한 활동 부하(TRIMP 방식 훈련 자극 모델)로 매일 밤 내는 0–100 부하 지표예요. 높을수록 힘든 하루였다는 뜻이에요.",
"anchorPersonal": "최근 운동한 날의 부하를 기준으로 삼아요 — 평소 강도와 비교해 점수를 매기니 나에게 정말 힘든 날이 높게 나와요.",
"anchorPopulation": "운동 기록이 쌓이는 동안에는 일반 기준으로 점수를 매기고, 충분히 모이면 내 평소 강도 기준으로 바뀌어요.",
"description": "심박수로 가중한 운동 강도를 평소 강도와 비교해 매일 밤 계산하는 활동 부하 지표예요."
"description": "심박수로 가중한 운동 강도를 평소 강도와 비교해 매일 밤 계산하는 활동 부하 지표예요.",
"deviceCaption": "기기의 일일 부하: {max} 중 {value}",
"deviceSource": "HealthLog가 밤사이 자체 값을 계산하기 전까지는 기기의 일일 부하를 0–100으로 환산해 보여 줘요."
},
"WRIST_TEMPERATURE_BASELINE": {
"method": "밤사이 손목 온도의 일반 범위는 해당 기간 야간 측정값의 중앙값에 견고한 산포(중앙절대편차를 보정한 값)를 더하고 뺀 구간이에요. 내 측정값을 따라가기 때문에, 지난밤은 인구 평균이 아니라 내 기준값에서 얼마나 벗어났는지로 보여줘요.",
+3 -1
View File
@@ -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.",
@@ -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" ? (
<ScoreHistoryChart type={metric} hue={METRIC_HUE[metric]} />
<ScoreHistoryChart
type={deviceStrain ? "DAY_STRAIN" : metric}
hue={METRIC_HUE[metric]}
/>
) : series && series.length >= 2 ? (
<ScoreHistoryCard
series={series}
@@ -33,8 +33,9 @@ export function ScoreHistoryChart({
type,
hue,
}: {
/** The stored measurement type the score is written as. */
type: "RECOVERY_SCORE" | "STRESS_SCORE" | "STRAIN_SCORE";
/** The stored measurement type the score is written as — or, for a strain
* score served from the device, the device's own `DAY_STRAIN`. */
type: "RECOVERY_SCORE" | "STRESS_SCORE" | "STRAIN_SCORE" | "DAY_STRAIN";
hue: RingHue;
}) {
const { t } = useTranslations();
@@ -313,3 +313,76 @@ describe("computeWellnessScore", () => {
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<string, unknown[]>) {
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");
});
});
@@ -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<WellnessScoreValue>({
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<WellnessScoreValue> {
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<WellnessScoreValue>({
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,
},
});
}
@@ -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" });