mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
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:
1 parent
605d9b1fb4
commit
ac482c508d
13 files changed
+241
-13
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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" });
|
||||
|
||||
|
||||
Reference in new issue
Block a user