From 059ba571e3ab17d8ee042cda4a43ba1c4e06bbf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 19:26:11 +0200 Subject: [PATCH] feat(insights): derived framing for additive HealthKit metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Register four new derived metrics for the additive HealthKit signals: - WRIST_TEMPERATURE_BASELINE, STAIR_ASCENT_SPEED_BASELINE, STAIR_DESCENT_SPEED_BASELINE — any-user-baseline personal bands reusing the type-generic median +/- k*MAD engine via a new BASELINE_CAPABLE_TYPES allowlist. Wrist temperature is a personal-deviation band only (no illness or cycle inference); stair speeds are personal trend bands (no population cutoff, geometry-confounded). - SIX_MINUTE_WALK_BAND — passthrough re-frame placing the device-estimated distance against the Enright & Sherrill 1998 predicted 6MWD (ATS 2002 test standard), as percent-of-predicted with a green/yellow/red band; band null when age/height/weight/sex are incomplete. Fall count and the breathing-disturbance index stay trend-only by design, documented in the registry: fall count is a zero-inflated discrete safety event, the breathing index is a regulated screening signal where a derived band would imply a diagnosis. Note wrist temperature as a deferred correlation outcome channel held pending a privacy review (cycle-phase inference risk), mirroring the medication-compliance omission note. Add provenance + standards entries, method/caveat copy across all six locales, and tests for the engine routing, the Enright equation, and the percent-of-predicted band. --- messages/de.json | 16 ++ messages/en.json | 16 ++ messages/es.json | 16 ++ messages/fr.json | 16 ++ messages/it.json | 16 ++ messages/pl.json | 16 ++ src/components/insights/derived/standards.ts | 36 ++++ src/lib/insights/correlation-discovery.ts | 8 + .../derived/__tests__/dispatch.test.ts | 53 ++++- .../insights/derived/__tests__/norms.test.ts | 42 +++- .../derived/__tests__/six-minute-walk.test.ts | 117 ++++++++++ src/lib/insights/derived/dispatch.ts | 31 +++ src/lib/insights/derived/index.ts | 17 +- src/lib/insights/derived/norms.ts | 54 +++++ src/lib/insights/derived/registry.ts | 82 +++++++- src/lib/insights/derived/six-minute-walk.ts | 199 ++++++++++++++++++ 16 files changed, 731 insertions(+), 4 deletions(-) create mode 100644 src/lib/insights/derived/__tests__/six-minute-walk.test.ts create mode 100644 src/lib/insights/derived/six-minute-walk.ts diff --git a/messages/de.json b/messages/de.json index bc196e448..6820a15d3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2621,6 +2621,22 @@ "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.", "caveat": "Ein beschreibender Aktivitätslast-Proxy, kein klinisches oder trainingsgerechtes Lastmaß.", "description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung." + }, + "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.", + "caveat": "Nur ein persönliches Abweichungsband — beschreibend, kein Anzeichen für Krankheit, Fieber oder Zyklusphase. Du interpretierst es selbst." + }, + "STAIR_ASCENT_SPEED_BASELINE": { + "method": "Dein typisches Treppensteig-Tempo ist der Median deiner Tageswerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Das Band wird aus deinen Daten gebildet — es gibt keinen Bevölkerungs-Grenzwert für Treppentempo, da es von Treppengeometrie und Beinlänge abhängt.", + "caveat": "Ein persönliches Mobilitäts-Trendband — beschreibend, keine Gebrechlichkeits- oder Sturzrisiko-Bewertung." + }, + "STAIR_DESCENT_SPEED_BASELINE": { + "method": "Dein typisches Treppen-Abstiegstempo ist der Median deiner Tageswerte im Zeitraum, ± einer robusten Streuung (skalierte mittlere absolute Abweichung). Das Band wird aus deinen Daten gebildet — es gibt keinen Bevölkerungs-Grenzwert für Treppentempo, da es von Treppengeometrie und Beinlänge abhängt.", + "caveat": "Ein persönliches Mobilitäts-Trendband — beschreibend, keine Gebrechlichkeits- oder Sturzrisiko-Bewertung." + }, + "SIX_MINUTE_WALK_BAND": { + "method": "Die von deinem Gerät GESCHÄTZTE 6-Minuten-Gehstrecke, eingeordnet gegen die Referenz von Enright & Sherrill für Alter, Größe, Gewicht und Geschlecht, als Prozent des Vorhersagewerts. Die Strecke ist die Schätzung des Geräts — hier nie neu berechnet. Ohne Alter, Größe, Gewicht und Geschlecht wird der Prozentwert ausgeblendet und nur Strecke und Trend gezeigt.", + "caveat": "Eine gegen eine veröffentlichte Referenz neu eingeordnete Schätzung — ein Hinweis zur funktionellen Kapazität, kein klinischer 6-Minuten-Gehtest und keine Diagnose." } }, "scores": { diff --git a/messages/en.json b/messages/en.json index 441fb7fd6..249114c6c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2621,6 +2621,22 @@ "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.", "caveat": "A descriptive activity-load proxy, not a clinical or training-grade load measure.", "description": "A nightly activity-load proxy from your heart-rate-weighted effort." + }, + "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.", + "caveat": "A personal-deviation band only — descriptive, not an illness, fever or cycle-phase signal. Interpret it yourself." + }, + "STAIR_ASCENT_SPEED_BASELINE": { + "method": "Your typical stair-climbing pace is the median of your daily values over the window, ± a robust spread (median absolute deviation, scaled). The band is built from your data — there is no population stair-speed cut-off, because pace depends on stair geometry and leg length.", + "caveat": "A personal mobility-trend band — descriptive, not a frailty or fall-risk assessment." + }, + "STAIR_DESCENT_SPEED_BASELINE": { + "method": "Your typical stair-descent pace is the median of your daily values over the window, ± a robust spread (median absolute deviation, scaled). The band is built from your data — there is no population stair-speed cut-off, because pace depends on stair geometry and leg length.", + "caveat": "A personal mobility-trend band — descriptive, not a frailty or fall-risk assessment." + }, + "SIX_MINUTE_WALK_BAND": { + "method": "Your device's ESTIMATED six-minute-walk distance placed against the Enright & Sherrill reference for your age, height, weight and sex, as a percent of predicted. The distance is the device's estimate — never recomputed here. Without your age, height, weight and sex the percent is hidden and only the distance and trend are shown.", + "caveat": "An estimate re-framed against a published reference — a functional-capacity awareness signal, not a clinical 6-minute-walk test or a diagnosis." } }, "scores": { diff --git a/messages/es.json b/messages/es.json index a8a352afa..3110aaf0b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2621,6 +2621,22 @@ "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.", "caveat": "Un indicador descriptivo de carga de actividad, no una medida de carga clínica ni de nivel de entrenamiento.", "description": "Un indicador nocturno de carga de actividad a partir de tu esfuerzo ponderado por frecuencia cardíaca." + }, + "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.", + "caveat": "Solo una banda de desviación personal: descriptiva, no una señal de enfermedad, fiebre ni fase del ciclo. Tú la interpretas." + }, + "STAIR_ASCENT_SPEED_BASELINE": { + "method": "Tu ritmo típico al subir escaleras es la mediana de tus valores diarios en el periodo, ± una dispersión robusta (desviación absoluta de la mediana, escalada). La banda se construye con tus datos: no hay un umbral poblacional de velocidad en escaleras, porque el ritmo depende de la geometría de la escalera y la longitud de las piernas.", + "caveat": "Una banda personal de tendencia de movilidad: descriptiva, no una evaluación de fragilidad ni de riesgo de caídas." + }, + "STAIR_DESCENT_SPEED_BASELINE": { + "method": "Tu ritmo típico al bajar escaleras es la mediana de tus valores diarios en el periodo, ± una dispersión robusta (desviación absoluta de la mediana, escalada). La banda se construye con tus datos: no hay un umbral poblacional de velocidad en escaleras, porque el ritmo depende de la geometría de la escalera y la longitud de las piernas.", + "caveat": "Una banda personal de tendencia de movilidad: descriptiva, no una evaluación de fragilidad ni de riesgo de caídas." + }, + "SIX_MINUTE_WALK_BAND": { + "method": "La distancia ESTIMADA de marcha de seis minutos de tu dispositivo situada frente a la referencia de Enright y Sherrill para tu edad, estatura, peso y sexo, como porcentaje de lo previsto. La distancia es la estimación del dispositivo: nunca se recalcula aquí. Sin tu edad, estatura, peso y sexo se oculta el porcentaje y solo se muestran la distancia y la tendencia.", + "caveat": "Una estimación reformulada frente a una referencia publicada: una señal de capacidad funcional, no una prueba clínica de marcha de seis minutos ni un diagnóstico." } }, "scores": { diff --git a/messages/fr.json b/messages/fr.json index 87906de30..d0c5b0155 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2621,6 +2621,22 @@ "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.", "caveat": "Un indicateur descriptif de charge d’activité, pas une mesure de charge clinique ou de niveau d’entraînement.", "description": "Un indice de charge d’activité nocturne à partir de votre effort pondéré par la fréquence cardiaque." + }, + "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.", + "caveat": "Une simple bande d’écart personnel — descriptive, ni un signe de maladie, de fièvre ou de phase du cycle. À vous de l’interpréter." + }, + "STAIR_ASCENT_SPEED_BASELINE": { + "method": "Votre allure typique en montant les escaliers est la médiane de vos valeurs quotidiennes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). La bande est construite à partir de vos données — il n’existe pas de seuil de population pour l’allure dans les escaliers, car elle dépend de la géométrie des marches et de la longueur des jambes.", + "caveat": "Une bande personnelle de tendance de mobilité — descriptive, ni une évaluation de fragilité ni de risque de chute." + }, + "STAIR_DESCENT_SPEED_BASELINE": { + "method": "Votre allure typique en descendant les escaliers est la médiane de vos valeurs quotidiennes sur la période, ± une dispersion robuste (écart absolu médian, mis à l’échelle). La bande est construite à partir de vos données — il n’existe pas de seuil de population pour l’allure dans les escaliers, car elle dépend de la géométrie des marches et de la longueur des jambes.", + "caveat": "Une bande personnelle de tendance de mobilité — descriptive, ni une évaluation de fragilité ni de risque de chute." + }, + "SIX_MINUTE_WALK_BAND": { + "method": "La distance de marche de six minutes ESTIMÉE par votre appareil, située par rapport à la référence d’Enright et Sherrill pour votre âge, votre taille, votre poids et votre sexe, en pourcentage de la valeur prédite. La distance est l’estimation de l’appareil — jamais recalculée ici. Sans votre âge, taille, poids et sexe, le pourcentage est masqué et seules la distance et la tendance sont affichées.", + "caveat": "Une estimation reformulée par rapport à une référence publiée — un indicateur de capacité fonctionnelle, ni un test de marche de six minutes clinique ni un diagnostic." } }, "scores": { diff --git a/messages/it.json b/messages/it.json index 5ca6bd9d5..ec5df0654 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2621,6 +2621,22 @@ "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.", "caveat": "Un indicatore descrittivo di carico di attività, non una misura di carico clinica o da allenamento.", "description": "Un indicatore notturno di carico di attività dal tuo sforzo pesato sulla frequenza cardiaca." + }, + "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.", + "caveat": "Solo una banda di scostamento personale — descrittiva, non un segnale di malattia, febbre o fase del ciclo. Sei tu a interpretarla." + }, + "STAIR_ASCENT_SPEED_BASELINE": { + "method": "Il tuo passo tipico in salita sulle scale è la mediana dei tuoi valori giornalieri nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). La banda è costruita sui tuoi dati — non esiste una soglia di popolazione per la velocità sulle scale, perché il passo dipende dalla geometria dei gradini e dalla lunghezza delle gambe.", + "caveat": "Una banda personale di tendenza della mobilità — descrittiva, non una valutazione di fragilità o di rischio di caduta." + }, + "STAIR_DESCENT_SPEED_BASELINE": { + "method": "Il tuo passo tipico in discesa sulle scale è la mediana dei tuoi valori giornalieri nel periodo, ± una dispersione robusta (deviazione assoluta mediana, scalata). La banda è costruita sui tuoi dati — non esiste una soglia di popolazione per la velocità sulle scale, perché il passo dipende dalla geometria dei gradini e dalla lunghezza delle gambe.", + "caveat": "Una banda personale di tendenza della mobilità — descrittiva, non una valutazione di fragilità o di rischio di caduta." + }, + "SIX_MINUTE_WALK_BAND": { + "method": "La distanza di cammino di sei minuti STIMATA dal tuo dispositivo, collocata rispetto al riferimento di Enright e Sherrill per la tua età, altezza, peso e sesso, come percentuale del previsto. La distanza è la stima del dispositivo — mai ricalcolata qui. Senza età, altezza, peso e sesso la percentuale è nascosta e vengono mostrate solo distanza e tendenza.", + "caveat": "Una stima riformulata rispetto a un riferimento pubblicato — un segnale di capacità funzionale, non un test del cammino di sei minuti clinico né una diagnosi." } }, "scores": { diff --git a/messages/pl.json b/messages/pl.json index f42013120..10be7cc4f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2621,6 +2621,22 @@ "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ń.", "caveat": "Opisowy wskaźnik obciążenia aktywnością, nie kliniczna ani treningowa miara obciążenia.", "description": "Nocny wskaźnik obciążenia aktywnością z Twojego wysiłku ważonego tętnem." + }, + "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.", + "caveat": "Tylko osobiste pasmo odchylenia — opisowe, nie sygnał choroby, gorączki ani fazy cyklu. Interpretujesz je samodzielnie." + }, + "STAIR_ASCENT_SPEED_BASELINE": { + "method": "Twoje typowe tempo wchodzenia po schodach to mediana Twoich dziennych wartości w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Pasmo jest budowane z Twoich danych — nie ma populacyjnego progu prędkości na schodach, bo tempo zależy od geometrii schodów i długości nóg.", + "caveat": "Osobiste pasmo trendu mobilności — opisowe, nie ocena kruchości ani ryzyka upadku." + }, + "STAIR_DESCENT_SPEED_BASELINE": { + "method": "Twoje typowe tempo schodzenia po schodach to mediana Twoich dziennych wartości w danym okresie, ± solidny rozrzut (przeskalowane medianowe odchylenie bezwzględne). Pasmo jest budowane z Twoich danych — nie ma populacyjnego progu prędkości na schodach, bo tempo zależy od geometrii schodów i długości nóg.", + "caveat": "Osobiste pasmo trendu mobilności — opisowe, nie ocena kruchości ani ryzyka upadku." + }, + "SIX_MINUTE_WALK_BAND": { + "method": "SZACOWANY przez Twoje urządzenie dystans sześciominutowego marszu odniesiony do wzorca Enrighta i Sherrilla dla Twojego wieku, wzrostu, wagi i płci, jako procent wartości przewidywanej. Dystans to szacunek urządzenia — nigdy nie jest tu przeliczany. Bez Twojego wieku, wzrostu, wagi i płci procent jest ukryty i pokazywane są tylko dystans i trend.", + "caveat": "Szacunek odniesiony do opublikowanego wzorca — sygnał wydolności funkcjonalnej, nie kliniczny test sześciominutowego marszu ani diagnoza." } }, "scores": { diff --git a/src/components/insights/derived/standards.ts b/src/components/insights/derived/standards.ts index f701fca1b..1649f82ce 100644 --- a/src/components/insights/derived/standards.ts +++ b/src/components/insights/derived/standards.ts @@ -128,4 +128,40 @@ export const METRIC_PROVENANCE: Record = }, caveatKey: "insights.derived.composite.STRAIN_SCORE.caveat", }, + WRIST_TEMPERATURE_BASELINE: { + methodKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.method", + standard: { + // Robust personal-deviation band via the median ± k·MAD. + name: "Leys et al. 2013, J. Exp. Soc. Psychol.", + url: "https://doi.org/10.1016/j.jesp.2013.03.013", + }, + caveatKey: "insights.derived.composite.WRIST_TEMPERATURE_BASELINE.caveat", + }, + STAIR_ASCENT_SPEED_BASELINE: { + methodKey: "insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.method", + standard: { + // Robust personal-trend band via the median ± k·MAD. + name: "Leys et al. 2013, J. Exp. Soc. Psychol.", + url: "https://doi.org/10.1016/j.jesp.2013.03.013", + }, + caveatKey: "insights.derived.composite.STAIR_ASCENT_SPEED_BASELINE.caveat", + }, + STAIR_DESCENT_SPEED_BASELINE: { + methodKey: "insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.method", + standard: { + // Robust personal-trend band via the median ± k·MAD. + name: "Leys et al. 2013, J. Exp. Soc. Psychol.", + url: "https://doi.org/10.1016/j.jesp.2013.03.013", + }, + caveatKey: "insights.derived.composite.STAIR_DESCENT_SPEED_BASELINE.caveat", + }, + SIX_MINUTE_WALK_BAND: { + methodKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.method", + standard: { + // Enright & Sherrill 1998 reference equations (ATS 2002 test standard). + name: "Enright & Sherrill 1998, Am. J. Respir. Crit. Care Med.", + url: "https://doi.org/10.1164/ajrccm.158.5.9710086", + }, + caveatKey: "insights.derived.composite.SIX_MINUTE_WALK_BAND.caveat", + }, }; diff --git a/src/lib/insights/correlation-discovery.ts b/src/lib/insights/correlation-discovery.ts index 8a86f6a4d..da9881ff6 100644 --- a/src/lib/insights/correlation-discovery.ts +++ b/src/lib/insights/correlation-discovery.ts @@ -249,4 +249,12 @@ export const DISCOVERY_OUTCOMES = [ "HEART_RATE_VARIABILITY", "RESTING_HEART_RATE", "WEIGHT", + // Wrist temperature is a credible future OUTCOME channel (near-daily, so + // n ≥ 20 is reachable; "did a hard workout / late alcohol raise next-night + // temperature?"), but it is deliberately NOT a channel yet: it is + // privacy-sensitive and deviation-framed, and folding it into the FDR + // matrix risks surfacing a cycle-phase correlation that strays into + // reproductive inference. Held pending a deliberate privacy review — + // documented here so the omission is intentional, not a gap (same posture + // as the medication-compliance omission above). ] as const; diff --git a/src/lib/insights/derived/__tests__/dispatch.test.ts b/src/lib/insights/derived/__tests__/dispatch.test.ts index 8e0c1b987..729404ea8 100644 --- a/src/lib/insights/derived/__tests__/dispatch.test.ts +++ b/src/lib/insights/derived/__tests__/dispatch.test.ts @@ -39,6 +39,16 @@ describe("registry", () => { expect(getDerivedMetricMeta("RECOVERY_SCORE")?.implemented).toBe(true); expect(getDerivedMetricMeta("STRESS_SCORE")?.implemented).toBe(true); expect(getDerivedMetricMeta("STRAIN_SCORE")?.implemented).toBe(true); + expect( + getDerivedMetricMeta("WRIST_TEMPERATURE_BASELINE")?.implemented, + ).toBe(true); + expect( + getDerivedMetricMeta("STAIR_ASCENT_SPEED_BASELINE")?.implemented, + ).toBe(true); + expect( + getDerivedMetricMeta("STAIR_DESCENT_SPEED_BASELINE")?.implemented, + ).toBe(true); + expect(getDerivedMetricMeta("SIX_MINUTE_WALK_BAND")?.implemented).toBe(true); }); it("isDerivedMetricId rejects unknown ids", () => { @@ -59,7 +69,11 @@ describe("registry", () => { expect(DERIVED_METRIC_IDS).toContain("RECOVERY_SCORE"); expect(DERIVED_METRIC_IDS).toContain("STRESS_SCORE"); expect(DERIVED_METRIC_IDS).toContain("STRAIN_SCORE"); - expect(DERIVED_METRIC_IDS.length).toBe(11); + expect(DERIVED_METRIC_IDS).toContain("WRIST_TEMPERATURE_BASELINE"); + expect(DERIVED_METRIC_IDS).toContain("STAIR_ASCENT_SPEED_BASELINE"); + expect(DERIVED_METRIC_IDS).toContain("STAIR_DESCENT_SPEED_BASELINE"); + expect(DERIVED_METRIC_IDS).toContain("SIX_MINUTE_WALK_BAND"); + expect(DERIVED_METRIC_IDS.length).toBe(15); }); }); @@ -127,6 +141,43 @@ describe("computeDerivedMetric dispatch", () => { } }); + it.each([ + "WRIST_TEMPERATURE_BASELINE", + "STAIR_ASCENT_SPEED_BASELINE", + "STAIR_DESCENT_SPEED_BASELINE", + ] as const)( + "routes %s through the baseline engine (no data → insufficient, not not_implemented)", + async (metric) => { + const result = await computeDerivedMetric({ + metric, + userId: "u1", + profile: PROFILE, + now: NOW, + }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).not.toBe("not_implemented"); + // The baseline engine names its fixed type as the missing input. + expect(result.provenance.inputs).toContain( + metric.replace("_BASELINE", ""), + ); + } + }, + ); + + it("routes SIX_MINUTE_WALK_BAND to its engine (no data → insufficient, not not_implemented)", async () => { + const result = await computeDerivedMetric({ + metric: "SIX_MINUTE_WALK_BAND", + userId: "u1", + profile: PROFILE, + now: NOW, + }); + expect(result.status).toBe("insufficient"); + if (result.status === "insufficient") { + expect(result.reason).toBe("no_readings_in_window"); + } + }); + it("returns unsupported_baseline_type for a bad VITALS_BASELINE type", async () => { const result = await computeDerivedMetric({ metric: "VITALS_BASELINE", diff --git a/src/lib/insights/derived/__tests__/norms.test.ts b/src/lib/insights/derived/__tests__/norms.test.ts index ed08b0ea1..6f539385f 100644 --- a/src/lib/insights/derived/__tests__/norms.test.ts +++ b/src/lib/insights/derived/__tests__/norms.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "vitest"; -import { lookupNormalRange, hasSharpenedNorm } from "../norms"; +import { + lookupNormalRange, + hasSharpenedNorm, + predictSixMinuteWalkDistance, +} from "../norms"; describe("lookupNormalRange — the age/sex reference-range enabler", () => { it("returns a sex-specific VO2max band for a registered metric", () => { @@ -59,3 +63,39 @@ describe("hasSharpenedNorm", () => { expect(hasSharpenedNorm("STEPS", 35, "MALE")).toBe(false); }); }); + +describe("predictSixMinuteWalkDistance — Enright & Sherrill 1998", () => { + it("matches the published male equation", () => { + // 7.57·180 − 5.02·40 − 1.76·80 − 309 = 712 m + expect(predictSixMinuteWalkDistance(40, 180, 80, "MALE")).toBeCloseTo( + 712, + 6, + ); + }); + + it("matches the published female equation", () => { + // 2.11·165 − 2.29·65 − 5.78·40 + 667 = 635.1 m + expect(predictSixMinuteWalkDistance(40, 165, 65, "FEMALE")).toBeCloseTo( + 635.1, + 6, + ); + }); + + it("returns null without a usable sex (the equations differ by sex)", () => { + expect(predictSixMinuteWalkDistance(40, 180, 80, null)).toBeNull(); + }); + + it("returns null when weight is absent (no silently-dropped term)", () => { + expect(predictSixMinuteWalkDistance(40, 180, null, "MALE")).toBeNull(); + }); + + it("returns null without height", () => { + expect(predictSixMinuteWalkDistance(40, null, 80, "MALE")).toBeNull(); + }); + + it("returns null for non-adult / non-finite ages", () => { + expect(predictSixMinuteWalkDistance(12, 150, 45, "MALE")).toBeNull(); + expect(predictSixMinuteWalkDistance(Number.NaN, 180, 80, "MALE")).toBeNull(); + expect(predictSixMinuteWalkDistance(null, 180, 80, "MALE")).toBeNull(); + }); +}); diff --git a/src/lib/insights/derived/__tests__/six-minute-walk.test.ts b/src/lib/insights/derived/__tests__/six-minute-walk.test.ts new file mode 100644 index 000000000..a77305381 --- /dev/null +++ b/src/lib/insights/derived/__tests__/six-minute-walk.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const findMany = vi.fn(); +const findFirst = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: (...a: unknown[]) => findMany(...a), + findFirst: (...a: unknown[]) => findFirst(...a), + }, + }, +})); + +import { + computeSixMinuteWalkBand, + placeSixMinuteWalkBand, +} from "../six-minute-walk"; + +const NOW = new Date("2026-06-02T07:00:00Z"); +// Enright male, 40 yr, 180 cm, 80 kg → +// 7.57·180 − 5.02·40 − 1.76·80 − 309 = 712 m predicted. +const MALE_40 = { ageYears: 40, sex: "MALE" as const, heightCm: 180 }; +const NO_DEMO = { ageYears: null, sex: null, heightCm: null }; +const NO_WEIGHT_PROFILE = MALE_40; + +beforeEach(() => vi.clearAllMocks()); + +describe("placeSixMinuteWalkBand", () => { + it("green at/above 80% of predicted", () => { + expect(placeSixMinuteWalkBand(80)).toBe("green"); + expect(placeSixMinuteWalkBand(100)).toBe("green"); + }); + it("yellow between 60 and 80%", () => { + expect(placeSixMinuteWalkBand(60)).toBe("yellow"); + expect(placeSixMinuteWalkBand(79)).toBe("yellow"); + }); + it("red below 60%", () => { + expect(placeSixMinuteWalkBand(59)).toBe("red"); + }); + it("null without a percent", () => { + expect(placeSixMinuteWalkBand(null)).toBeNull(); + }); +}); + +describe("computeSixMinuteWalkBand", () => { + it("insufficient when no 6MWT reading exists", async () => { + findMany.mockResolvedValueOnce([]); + const r = await computeSixMinuteWalkBand("u1", MALE_40, { now: NOW }); + expect(r.status).toBe("insufficient"); + if (r.status === "insufficient") { + expect(r.reason).toBe("no_readings_in_window"); + } + // No reading → never reach the weight read. + expect(findFirst).not.toHaveBeenCalled(); + }); + + it("ok with a band + percent-of-predicted when full demographics + weight exist", async () => { + // 3 readings → trend = 712 - 700 = 12; distance 712 vs predicted 712 → 100%. + findMany.mockResolvedValueOnce([ + { value: 712 }, + { value: 700 }, + { value: 690 }, + ]); + findFirst.mockResolvedValueOnce({ value: 80 }); + const r = await computeSixMinuteWalkBand("u1", MALE_40, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.distanceM).toBe(712); + expect(r.value.predictedM).toBe(712); + expect(r.value.percentOfPredicted).toBe(100); + expect(r.value.band).toBe("green"); + expect(r.value.trendDelta).toBe(12); + } + }); + + it("bands red on a low percent of predicted", async () => { + // 400 / 712 = 56% → red. + findMany.mockResolvedValueOnce([{ value: 400 }]); + findFirst.mockResolvedValueOnce({ value: 80 }); + const r = await computeSixMinuteWalkBand("u1", MALE_40, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.percentOfPredicted).toBe(56); + expect(r.value.band).toBe("red"); + } + }); + + it("ok but band/percent null without demographics", async () => { + findMany.mockResolvedValueOnce([{ value: 500 }]); + findFirst.mockResolvedValueOnce({ value: 80 }); + const r = await computeSixMinuteWalkBand("u1", NO_DEMO, { now: NOW }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.distanceM).toBe(500); + expect(r.value.predictedM).toBeNull(); + expect(r.value.percentOfPredicted).toBeNull(); + expect(r.value.band).toBeNull(); + // Single reading → trend suppressed. + expect(r.value.trendDelta).toBeNull(); + } + }); + + it("ok but band null when weight is absent (no fabricated placement)", async () => { + findMany.mockResolvedValueOnce([{ value: 600 }]); + findFirst.mockResolvedValueOnce(null); // no recent weight + const r = await computeSixMinuteWalkBand("u1", NO_WEIGHT_PROFILE, { + now: NOW, + }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect(r.value.distanceM).toBe(600); + expect(r.value.predictedM).toBeNull(); + expect(r.value.percentOfPredicted).toBeNull(); + expect(r.value.band).toBeNull(); + } + }); +}); diff --git a/src/lib/insights/derived/dispatch.ts b/src/lib/insights/derived/dispatch.ts index a5080a56a..1e2f226f0 100644 --- a/src/lib/insights/derived/dispatch.ts +++ b/src/lib/insights/derived/dispatch.ts @@ -23,6 +23,7 @@ import { type BaselineProfile, } from "./baseline"; import { computeFitnessAge } from "./fitness-age"; +import { computeSixMinuteWalkBand } from "./six-minute-walk"; import { computeVascularAgeDelta } from "./vascular-age"; import { computeHrvBalance } from "./hrv-balance"; import { computeBmi } from "./bmi"; @@ -146,6 +147,36 @@ export async function computeDerivedMetric( windowDays: args.windowDays, now, }) as Promise>; + case "WRIST_TEMPERATURE_BASELINE": + case "STAIR_ASCENT_SPEED_BASELINE": + case "STAIR_DESCENT_SPEED_BASELINE": { + // v1.10.3 additive HealthKit baselines — each pins ONE fixed type and + // reuses the type-generic baseline engine unchanged (median ± k·MAD). + // Wrist temperature is a personal-DEVIATION band (no illness/cycle + // inference); stair ascent/descent are personal trend bands (no + // population cutoff — stair pace is geometry-confounded). The fixed + // type is the metric's single registered input. + const baselineType: Record< + | "WRIST_TEMPERATURE_BASELINE" + | "STAIR_ASCENT_SPEED_BASELINE" + | "STAIR_DESCENT_SPEED_BASELINE", + MeasurementType + > = { + WRIST_TEMPERATURE_BASELINE: "WRIST_TEMPERATURE", + STAIR_ASCENT_SPEED_BASELINE: "STAIR_ASCENT_SPEED", + STAIR_DESCENT_SPEED_BASELINE: "STAIR_DESCENT_SPEED", + }; + return computeVitalsBaseline(args.userId, args.profile, { + type: baselineType[args.metric], + windowDays: args.windowDays, + now, + }) as Promise>; + } + case "SIX_MINUTE_WALK_BAND": + return computeSixMinuteWalkBand(args.userId, args.profile, { + windowDays: args.windowDays, + now, + }) as Promise>; case "RECOVERY_SCORE": case "STRESS_SCORE": case "STRAIN_SCORE": diff --git a/src/lib/insights/derived/index.ts b/src/lib/insights/derived/index.ts index 658f7c972..3a27a2445 100644 --- a/src/lib/insights/derived/index.ts +++ b/src/lib/insights/derived/index.ts @@ -39,9 +39,11 @@ export type { DeriveCoverageArgs } from "./coverage"; export { DERIVED_METRIC_IDS, VITALS_BASELINE_TYPES, + BASELINE_CAPABLE_TYPES, isDerivedMetricId, getDerivedMetricMeta, isVitalsBaselineType, + isBaselineCapableType, } from "./registry"; export type { DerivedMetricId, @@ -49,7 +51,11 @@ export type { DerivedArchetype, } from "./registry"; -export { lookupNormalRange, hasSharpenedNorm } from "./norms"; +export { + lookupNormalRange, + hasSharpenedNorm, + predictSixMinuteWalkDistance, +} from "./norms"; export type { NormRange, NormSex } from "./norms"; // ── server-only compute engines (do NOT value-import from a client component) ── @@ -77,6 +83,15 @@ export { } from "./fitness-age"; export type { FitnessAgeValue, FitnessBand } from "./fitness-age"; +export { + computeSixMinuteWalkBand, + placeSixMinuteWalkBand, +} from "./six-minute-walk"; +export type { + SixMinuteWalkValue, + SixMinuteWalkBand, +} from "./six-minute-walk"; + export { computeVascularAgeDelta, placeVascularBand } from "./vascular-age"; export type { VascularAgeDeltaValue, VascularBand } from "./vascular-age"; diff --git a/src/lib/insights/derived/norms.ts b/src/lib/insights/derived/norms.ts index 358023f7b..20090fdff 100644 --- a/src/lib/insights/derived/norms.ts +++ b/src/lib/insights/derived/norms.ts @@ -24,6 +24,13 @@ * 377(9770):1011–1018, normal-range HR centiles by age). * - Respiratory rate by age — clinical reference (WHO/PALS adult vs * paediatric ranges). + * - Six-minute-walk distance — a published REGRESSION (not a bracket + * table): Enright & Sherrill 1998, "Reference Equations for the + * Six-Minute Walk in Healthy Adults", Am J Respir Crit Care Med + * 158(5):1384–1387; the test itself standardised by ATS 2002, + * "ATS Statement: Guidelines for the Six-Minute Walk Test", + * Am J Respir Crit Care Med 166(1):111–117. Surfaced as a + * percent-of-predicted re-frame, never a HealthLog-derived equation. * * Client-safe — pure data + a pure lookup, no server imports. */ @@ -145,6 +152,53 @@ export function lookupNormalRange( return null; } +/** + * Enright & Sherrill 1998 predicted six-minute-walk distance (metres) for a + * healthy adult. A published linear regression on age, height, weight, sex — + * NOT a HealthLog-derived model: + * - Men: 6MWD = 7.57·height_cm − 5.02·age − 1.76·weight_kg − 309 + * - Women: 6MWD = 2.11·height_cm − 2.29·weight_kg − 5.78·age + 667 + * + * Returns `null` when the inputs the equation needs are absent, so the caller + * surfaces the raw distance + trend without a fabricated placement (the + * `fitness-age.ts` `band: null` discipline). Sex is required (the two + * coefficient sets differ); height is required (the dominant term). Weight is + * required for the published full equation — when it is missing we return + * `null` rather than silently dropping the weight term, since the omission + * would inflate the predicted distance. + * + * Pure. The equation is for adults; for ages below 18 the reference does not + * apply and we return `null`. + */ +export function predictSixMinuteWalkDistance( + ageYears: number | null | undefined, + heightCm: number | null | undefined, + weightKg: number | null | undefined, + sex: NormSex, +): number | null { + if (sex !== "MALE" && sex !== "FEMALE") return null; + if ( + ageYears == null || + !Number.isFinite(ageYears) || + ageYears < 18 || + ageYears > 120 + ) { + return null; + } + if (heightCm == null || !Number.isFinite(heightCm) || heightCm <= 0) { + return null; + } + if (weightKg == null || !Number.isFinite(weightKg) || weightKg <= 0) { + return null; + } + const predicted = + sex === "MALE" + ? 7.57 * heightCm - 5.02 * ageYears - 1.76 * weightKg - 309 + : 2.11 * heightCm - 2.29 * weightKg - 5.78 * ageYears + 667; + // A non-positive prediction is physically meaningless — treat as no band. + return predicted > 0 ? predicted : null; +} + /** `true` when an age/sex-sharpened band exists for the metric+profile. */ export function hasSharpenedNorm( metricId: MetricStatusMetricId, diff --git a/src/lib/insights/derived/registry.ts b/src/lib/insights/derived/registry.ts index 73620d5ef..493b9fa80 100644 --- a/src/lib/insights/derived/registry.ts +++ b/src/lib/insights/derived/registry.ts @@ -46,7 +46,27 @@ export type DerivedMetricId = /** Persisted nightly stress composite (passthrough read of COMPUTED rows). */ | "STRESS_SCORE" /** Persisted nightly strain composite (passthrough read of COMPUTED rows). */ - | "STRAIN_SCORE"; + | "STRAIN_SCORE" + /** v1.10.3: overnight wrist-temperature personal-deviation band (baseline engine). */ + | "WRIST_TEMPERATURE_BASELINE" + /** v1.10.3: stair-ascent-speed personal trend band (baseline engine). */ + | "STAIR_ASCENT_SPEED_BASELINE" + /** v1.10.3: stair-descent-speed personal trend band (baseline engine). */ + | "STAIR_DESCENT_SPEED_BASELINE" + /** v1.10.3: estimated 6-minute-walk distance vs Enright-predicted (passthrough re-frame). */ + | "SIX_MINUTE_WALK_BAND"; + +// Documented-as-omitted (v1.10.3): two additive HealthKit signals stay +// trend-only with NO derived band, on purpose — +// - FALL_COUNT is a zero-inflated discrete safety EVENT (target = zero); a +// median ± k·MAD band on a near-constant-zero series is meaningless and a +// band would imply a smooth metric where there is none. +// - BREATHING_DISTURBANCES is a regulated sleep-apnea SCREENING signal (Apple +// publishes only NotElevated/Elevated, no numeric cutoff); a self-derived +// band would read as a HealthLog verdict on a screening signal and imply a +// diagnosis. The fired BREATHING_DISTURBANCE_EVENT carries the device's own +// classification in the awareness card; the continuous index stays a plain +// trend with the generic assessment. /** Archetype of a derived metric — drives shaping + the QA inventory. */ export type DerivedArchetype = @@ -104,6 +124,25 @@ export const VITALS_BASELINE_TYPES: MeasurementType[] = [ "WEIGHT", ]; +/** + * The full set of measurement types the type-generic baseline engine + * (`computeVitalsBaseline`, median ± k·MAD) can produce a personal band + * for. A superset of `VITALS_BASELINE_TYPES`: it adds the v1.10.3 + * additive HealthKit signals that get an `any-user-baseline` derived + * metric of their own (wrist temperature as a personal-deviation band; + * stair ascent/descent speed as a personal trend band — NO population + * cutoff, since stair pace is geometry-confounded). The engine is + * type-generic; this allowlist is the gate, kept distinct from + * `VITALS_BASELINE_TYPES` so a stair-speed baseline is never mislabelled a + * "vital". + */ +export const BASELINE_CAPABLE_TYPES: MeasurementType[] = [ + ...VITALS_BASELINE_TYPES, + "WRIST_TEMPERATURE", + "STAIR_ASCENT_SPEED", + "STAIR_DESCENT_SPEED", +]; + const REGISTRY: Record = { VITALS_BASELINE: { id: "VITALS_BASELINE", @@ -210,6 +249,42 @@ const REGISTRY: Record = { minInputs: 1, implemented: true, }, + WRIST_TEMPERATURE_BASELINE: { + id: "WRIST_TEMPERATURE_BASELINE", + displayName: "Wrist-temperature baseline", + archetype: "any-user-baseline", + inputs: ["WRIST_TEMPERATURE"], + minHistoryDays: 7, + minInputs: 1, + implemented: true, + }, + STAIR_ASCENT_SPEED_BASELINE: { + id: "STAIR_ASCENT_SPEED_BASELINE", + displayName: "Stair-ascent-speed baseline", + archetype: "any-user-baseline", + inputs: ["STAIR_ASCENT_SPEED"], + minHistoryDays: 7, + minInputs: 1, + implemented: true, + }, + STAIR_DESCENT_SPEED_BASELINE: { + id: "STAIR_DESCENT_SPEED_BASELINE", + displayName: "Stair-descent-speed baseline", + archetype: "any-user-baseline", + inputs: ["STAIR_DESCENT_SPEED"], + minHistoryDays: 7, + minInputs: 1, + implemented: true, + }, + SIX_MINUTE_WALK_BAND: { + id: "SIX_MINUTE_WALK_BAND", + displayName: "Estimated 6-minute-walk band", + archetype: "passthrough-reframe", + inputs: ["SIX_MINUTE_WALK_DISTANCE"], + minHistoryDays: 1, + minInputs: 1, + implemented: true, + }, }; /** Closed set of ids the generic route accepts (Zod enum source). */ @@ -231,3 +306,8 @@ export function getDerivedMetricMeta( export function isVitalsBaselineType(type: string): boolean { return (VITALS_BASELINE_TYPES as string[]).includes(type); } + +/** `true` when the type-generic baseline engine can band this type. */ +export function isBaselineCapableType(type: string): boolean { + return (BASELINE_CAPABLE_TYPES as string[]).includes(type); +} diff --git a/src/lib/insights/derived/six-minute-walk.ts b/src/lib/insights/derived/six-minute-walk.ts new file mode 100644 index 000000000..b8f023377 --- /dev/null +++ b/src/lib/insights/derived/six-minute-walk.ts @@ -0,0 +1,199 @@ +/** + * v1.10.3 — additive HealthKit signal: estimated six-minute-walk distance, + * re-framed against a published reference equation. + * + * `computeSixMinuteWalkBand(userId, profile, opts)` takes the device's + * ESTIMATED 6-minute-walk distance (HealthKit `sixMinuteWalkTestDistance` — + * an estimate, NOT a supervised in-clinic 6MWT) and re-frames it as a + * percent-of-predicted against the Enright & Sherrill 1998 reference + * equation for healthy adults. It NEVER recomputes the distance and NEVER + * derives its own equation — this is the `passthrough-reframe` archetype, + * the same posture `fitness-age.ts` takes against VO₂max. + * + * - **percentOfPredicted** — distance ÷ Enright-predicted × 100, placed in + * a green/yellow/red band (≥ 80% green; 60–80% yellow; < 60% red), + * `null` when the demographics the equation needs are absent (sex, + * adult age, height, AND weight — the published full equation). With no + * band the metric still surfaces the raw distance + trend, never a + * fabricated placement. + * - **trend** — signed latest-vs-prior delta, surfaced only once ≥ 3 + * readings exist (the catalogue's trend gate). + * + * Standard: Enright & Sherrill 1998 (reference equations) + ATS 2002 (the + * test standard) — cited in `norms.ts`. Apple's value is framed as + * "estimated", surfaced + trended, never re-derived. No diagnosis: a low + * percent is a functional-capacity awareness signal, not a verdict. + * + * Server-only — reads the latest `SIX_MINUTE_WALK_DISTANCE` + `WEIGHT` rows + * via Prisma; age/height/sex come from the caller's profile. The placement + * helper is exported pure for the unit tests. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + buildInsufficient, + buildOk, + deriveCoverage, + nowProvenanceTimestamp, +} from "./coverage"; +import { predictSixMinuteWalkDistance } from "./norms"; +import type { BaselineProfile } from "./baseline"; +import type { Derived, DerivedProvenanceSource } from "./types"; + +/** Band placement, same green/yellow/red vocabulary the tokens speak. */ +export type SixMinuteWalkBand = "green" | "yellow" | "red"; + +const SIX_MINUTE_WALK_TYPE: MeasurementType = "SIX_MINUTE_WALK_DISTANCE"; +const WEIGHT_TYPE: MeasurementType = "WEIGHT"; +/** Readings needed before the latest-vs-prior trend delta is surfaced. */ +const MIN_TREND_READINGS = 3; +/** + * The estimate refreshes at most every few days; widen the read window so the + * latest value + trend survive sparse cadence (matches `fitness-age.ts`). + */ +const DEFAULT_WINDOW_DAYS = 180; +/** A weight reading older than this no longer reflects current capacity. */ +const WEIGHT_WINDOW_DAYS = 90; + +/** Percent-of-predicted band thresholds (% of Enright-predicted). */ +const GREEN_PCT = 80; +const YELLOW_PCT = 60; + +/** The successful `value` payload for the 6-minute-walk band. */ +export interface SixMinuteWalkValue { + /** The latest device-estimated 6-minute-walk distance (m). */ + distanceM: number; + /** Enright-predicted distance for the profile (m), or `null` without demographics. */ + predictedM: number | null; + /** Distance ÷ predicted × 100, rounded; `null` without a prediction. */ + percentOfPredicted: number | null; + /** Green/yellow/red placement, or `null` without a prediction. */ + band: SixMinuteWalkBand | null; + /** Signed latest-vs-prior delta (m); `null` until ≥ 3 readings. */ + trendDelta: number | null; + /** Distinct readings in the window. */ + readingCount: number; +} + +/** + * Place a percent-of-predicted in a band. Pure. ≥ 80% green; 60–80% yellow; + * < 60% red; `null` when the percent is absent. + */ +export function placeSixMinuteWalkBand( + percentOfPredicted: number | null, +): SixMinuteWalkBand | null { + if (percentOfPredicted == null) return null; + if (percentOfPredicted >= GREEN_PCT) return "green"; + if (percentOfPredicted >= YELLOW_PCT) return "yellow"; + return "red"; +} + +/** + * Estimated six-minute-walk band — passthrough re-frame of + * `SIX_MINUTE_WALK_DISTANCE`. Returns `insufficient` when no reading exists; + * otherwise an `ok` value carrying the latest distance, its + * percent-of-predicted placement (when demographics allow), and a trend once + * ≥ 3 readings exist. With incomplete demographics the band is `null` and the + * value + trend still surface — never a fabricated placement. + */ +export async function computeSixMinuteWalkBand( + userId: string, + profile: BaselineProfile, + opts?: { windowDays?: number; now?: Date }, +): Promise> { + const now = opts?.now ?? new Date(); + const windowDays = opts?.windowDays ?? DEFAULT_WINDOW_DAYS; + const computedAt = nowProvenanceTimestamp(now); + const since = new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000); + + const rows = await prisma.measurement.findMany({ + where: { + userId, + type: SIX_MINUTE_WALK_TYPE, + deletedAt: null, + measuredAt: { gte: since }, + }, + orderBy: { measuredAt: "desc" }, + select: { value: true }, + }); + + if (rows.length === 0) { + const { coverage } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 0, + historyDays: 0, + missing: [SIX_MINUTE_WALK_TYPE], + fullHistoryDays: windowDays, + }); + return buildInsufficient({ + coverage, + provenance: { + inputs: [SIX_MINUTE_WALK_TYPE], + source: "none", + windowDays, + computedAt, + }, + reason: "no_readings_in_window", + }); + } + + const distanceM = rows[0].value; + + // The Enright equation needs a recent weight; read the latest in a tighter + // window. Absent → predicted is null and the band is suppressed. + const weightSince = new Date( + now.getTime() - WEIGHT_WINDOW_DAYS * 24 * 60 * 60 * 1000, + ); + const weightRow = await prisma.measurement.findFirst({ + where: { + userId, + type: WEIGHT_TYPE, + deletedAt: null, + measuredAt: { gte: weightSince }, + }, + orderBy: { measuredAt: "desc" }, + select: { value: true }, + }); + + const predictedM = predictSixMinuteWalkDistance( + profile.ageYears, + profile.heightCm ?? null, + weightRow?.value ?? null, + profile.sex, + ); + const percentOfPredicted = + predictedM != null && predictedM > 0 + ? Math.round((distanceM / predictedM) * 100) + : null; + const band = placeSixMinuteWalkBand(percentOfPredicted); + const trendDelta = + rows.length >= MIN_TREND_READINGS ? rows[0].value - rows[1].value : null; + + const source: DerivedProvenanceSource = "live"; + const { coverage, confidence } = deriveCoverage({ + requiredInputs: 1, + presentInputs: 1, + historyDays: rows.length, + missing: [], + fullHistoryDays: MIN_TREND_READINGS, + }); + + return buildOk({ + value: { + distanceM, + predictedM: predictedM != null ? Math.round(predictedM) : null, + percentOfPredicted, + band, + trendDelta, + readingCount: rows.length, + }, + coverage, + confidence, + provenance: { + inputs: [SIX_MINUTE_WALK_TYPE], + source, + windowDays, + computedAt, + }, + }); +}