From 097af3b84a2a2055ea58ae12d8a7bd7e445d8068 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 21:32:18 +0200 Subject: [PATCH 1/3] feat(insights): surface the active STRAIN anchor per score MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carry the per-day STRAIN anchor (personal vs population) from the strain_trimp_cache row through to the STRAIN WellnessScoreValue read, and pick the framing line by it on the score-anatomy surface: relative to your own typical effort once anchored personally, a general reference during cold start. The method copy was describing both regimes generically and could not tell the user which one produced this score. Additive read field — iOS non-breaking. --- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/pl.json | 4 +- .../derived/composite-score-anatomy.tsx | 24 ++++++++ .../derived/__tests__/wellness-scores.test.ts | 56 ++++++++++++++++++- src/lib/insights/derived/wellness-scores.ts | 30 ++++++++++ 9 files changed, 127 insertions(+), 7 deletions(-) diff --git a/messages/de.json b/messages/de.json index f790fc6f9..d2f3a034a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2625,7 +2625,9 @@ "description": "Ein nächtlicher Stress-Proxy aus dem Tagesverlauf deiner HRV." }, "STRAIN_SCORE": { - "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. Die Skala ist an deine eigene jüngste Trainingstagslast angelehnt — relativ zu deiner typischen Anstrengung —, sodass ein für dich wirklich harter Tag hoch ausfällt. Solange du noch Verlauf aufbaust, wird stattdessen ein allgemeiner Referenzwert verwendet.", + "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.", "caveat": "Ein beschreibender Aktivitätslast-Proxy, kein klinisches oder trainingsgerechtes Lastmaß.", "description": "Ein nächtlicher Aktivitätslast-Proxy aus deiner herzfrequenzgewichteten Anstrengung, relativ zu deiner typischen Anstrengung." }, diff --git a/messages/en.json b/messages/en.json index f9c78cba6..f403b2a34 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2625,7 +2625,9 @@ "description": "A nightly stress proxy from the shape of your intra-day HRV." }, "STRAIN_SCORE": { - "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. The scale is anchored to your own recent training-day load — relative to your typical effort — so a genuinely hard day for you reads high. While you are still building history it uses a general reference instead.", + "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.", "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, relative to your typical effort." }, diff --git a/messages/es.json b/messages/es.json index 4d7b1d04f..86177a82b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2625,7 +2625,9 @@ "description": "Un indicador nocturno de estrés a partir de la forma de tu VFC durante el día." }, "STRAIN_SCORE": { - "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. La escala se ancla a tu propia carga reciente de días de entrenamiento —relativa a tu esfuerzo habitual—, de modo que un día realmente duro para ti aparece alto. Mientras aún estás acumulando historial usa una referencia general en su lugar.", + "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.", "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, relativo a tu esfuerzo habitual." }, diff --git a/messages/fr.json b/messages/fr.json index ba70c8a37..891517d37 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2625,7 +2625,9 @@ "description": "Un indice de stress nocturne à partir de la forme de votre VFC sur la journée." }, "STRAIN_SCORE": { - "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. L’échelle est calée sur votre propre charge récente des jours d’entraînement — relative à votre effort habituel —, de sorte qu’une journée vraiment difficile pour vous ressort élevée. Tant que vous constituez encore votre historique, elle utilise plutôt une référence générale.", + "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.", "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, relatif à votre effort habituel." }, diff --git a/messages/it.json b/messages/it.json index 883ac29a3..507a4a8fc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2625,7 +2625,9 @@ "description": "Un indicatore notturno di stress dalla forma della tua VFC nel corso della giornata." }, "STRAIN_SCORE": { - "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. La scala è ancorata al tuo carico recente nei giorni di allenamento — relativo al tuo sforzo abituale —, così che una giornata davvero dura per te risulti alta. Finché stai ancora costruendo lo storico usa invece un riferimento generale.", + "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.", "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, relativo al tuo sforzo abituale." }, diff --git a/messages/pl.json b/messages/pl.json index a1da6cf7a..a9c068f1f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2625,7 +2625,9 @@ "description": "Nocny wskaźnik stresu na podstawie kształtu Twojego HRV w ciągu dnia." }, "STRAIN_SCORE": { - "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ń. Skala jest zakotwiczona w Twoim własnym niedawnym obciążeniu z dni treningowych — względem Twojego typowego wysiłku — więc naprawdę ciężki dzień dla Ciebie wypada wysoko. Dopóki dopiero budujesz historię, używa zamiast tego ogólnego punktu odniesienia.", + "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.", "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, względem Twojego typowego wysiłku." }, diff --git a/src/components/insights/derived/composite-score-anatomy.tsx b/src/components/insights/derived/composite-score-anatomy.tsx index f253b9f0c..dd7bd263f 100644 --- a/src/components/insights/derived/composite-score-anatomy.tsx +++ b/src/components/insights/derived/composite-score-anatomy.tsx @@ -73,6 +73,29 @@ export function CompositeScoreAnatomy({ const meta = METRIC_PROVENANCE[metric]; const standard = meta.standard; const title = titleFor(metric, t); + + // STRAIN frames itself by the ACTUAL anchor that produced this score: once + // the user has enough history it is judged against their own typical effort + // (`personal`); during cold start it falls back to a general reference + // (`population`). The base method copy stays anchor-neutral; this line makes + // 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 + ) { + const anchor = (data.value as WellnessScoreValue).anchor; + if (anchor === "personal") { + anchorLine = t("insights.derived.composite.STRAIN_SCORE.anchorPersonal"); + } else if (anchor === "population") { + anchorLine = t( + "insights.derived.composite.STRAIN_SCORE.anchorPopulation", + ); + } + } + // Method copy carries an optional honesty caveat above it (STRESS proxy, // descriptive-not-clinical, …) so the caveat reaches the user, not just // the engine header. @@ -84,6 +107,7 @@ export function CompositeScoreAnatomy({ ) : null} {t(meta.methodKey)} + {anchorLine ? {anchorLine} : null} ); diff --git a/src/lib/insights/derived/__tests__/wellness-scores.test.ts b/src/lib/insights/derived/__tests__/wellness-scores.test.ts index ffc105b4e..0928c979a 100644 --- a/src/lib/insights/derived/__tests__/wellness-scores.test.ts +++ b/src/lib/insights/derived/__tests__/wellness-scores.test.ts @@ -1,7 +1,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/db", () => ({ - prisma: { measurement: { findMany: vi.fn() } }, + prisma: { + measurement: { findMany: vi.fn() }, + strainTrimpCache: { findUnique: vi.fn() }, + }, })); import { prisma } from "@/lib/db"; @@ -14,9 +17,13 @@ import { const PROFILE = { ageYears: 40, sex: "MALE" as const }; const NOW = new Date("2026-06-02T08:00:00Z"); const findMany = prisma.measurement.findMany as ReturnType; +const cacheFindUnique = prisma.strainTrimpCache + .findUnique as ReturnType; beforeEach(() => { findMany.mockReset(); + cacheFindUnique.mockReset(); + cacheFindUnique.mockResolvedValue(null); }); describe("bandWellnessScore", () => { @@ -79,4 +86,51 @@ describe("computeWellnessScore", () => { expect((r.value as WellnessScoreValue).trendDelta).toBeNull(); } }); + + it("STRAIN carries the active anchor from the day's cache row", async () => { + findMany.mockResolvedValue([ + { value: 55, measuredAt: new Date("2026-06-01T12:00:00Z") }, + ]); + cacheFindUnique.mockResolvedValue({ anchor: "personal" }); + const r = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, { + now: NOW, + }); + // The cache is keyed by the scored day (the latest score's day key). + expect(cacheFindUnique).toHaveBeenCalledWith({ + where: { userId_day: { userId: "u1", day: "2026-06-01" } }, + select: { anchor: true }, + }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect((r.value as WellnessScoreValue).anchor).toBe("personal"); + } + }); + + it("STRAIN anchor is null when no cache row exists", async () => { + findMany.mockResolvedValue([ + { value: 55, measuredAt: new Date("2026-06-01T12:00:00Z") }, + ]); + cacheFindUnique.mockResolvedValue(null); + const r = await computeWellnessScore("STRAIN_SCORE", "u1", PROFILE, { + now: NOW, + }); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect((r.value as WellnessScoreValue).anchor).toBeNull(); + } + }); + + it("RECOVERY does not read the strain cache and carries a null anchor", async () => { + findMany.mockResolvedValue([ + { value: 72, measuredAt: new Date("2026-06-01T12:00:00Z") }, + ]); + const r = await computeWellnessScore("RECOVERY_SCORE", "u1", PROFILE, { + now: NOW, + }); + expect(cacheFindUnique).not.toHaveBeenCalled(); + expect(r.status).toBe("ok"); + if (r.status === "ok") { + expect((r.value as WellnessScoreValue).anchor).toBeNull(); + } + }); }); diff --git a/src/lib/insights/derived/wellness-scores.ts b/src/lib/insights/derived/wellness-scores.ts index 3dca29a72..f52f739f8 100644 --- a/src/lib/insights/derived/wellness-scores.ts +++ b/src/lib/insights/derived/wellness-scores.ts @@ -16,6 +16,7 @@ import { prisma } from "@/lib/db"; import type { MeasurementType } from "@/generated/prisma/client"; import { buildInsufficient, buildOk, nowProvenanceTimestamp } from "./coverage"; import type { BaselineProfile } from "./baseline"; +import type { StrainAnchor } from "@/lib/insights/strain-score"; import { SPARKLINE_MAX_POINTS, type Derived } from "./types"; /** A 0–100 wellness score band. Higher is better for recovery; for stress a @@ -38,6 +39,16 @@ export interface WellnessScoreValue { * query. */ series: number[]; + /** + * STRAIN only — which anchor produced THIS score: `personal` once the user + * has enough training history to be judged against their own typical effort, + * `population` during cold start. Read from the latest `strain_trimp_cache` + * row for the scored day so the UI can show the framing line that actually + * applies, not a generic both-regimes blurb. `null` for RECOVERY / STRESS + * (no anchor concept) or when no cache row exists yet (additive — iOS + * non-breaking). + */ + anchor?: StrainAnchor | null; } /** The three persisted score types this engine serves. */ @@ -131,6 +142,24 @@ export async function computeWellnessScore( ) : null; + // STRAIN carries the active anchor for the scored day so the UI shows the + // framing line that actually produced THIS score (personal-relative vs the + // cold-start population reference). The `strain_trimp_cache` row is keyed by + // the same day stamp the score row carries (`scoreDayKey` → noon-UTC + // `measuredAt`), so the latest score's day key is the cache key. Source of + // truth is the cache row, not a re-derivation here. + let anchor: StrainAnchor | null = null; + if (type === "STRAIN_SCORE") { + const day = latest.measuredAt.toISOString().slice(0, 10); + const cache = await prisma.strainTrimpCache.findUnique({ + where: { userId_day: { userId, day } }, + select: { anchor: true }, + }); + if (cache?.anchor === "personal" || cache?.anchor === "population") { + anchor = cache.anchor; + } + } + return buildOk({ value: { score, @@ -143,6 +172,7 @@ export async function computeWellnessScore( .slice(0, SPARKLINE_MAX_POINTS) .map((r) => r.value) .reverse(), + anchor, }, coverage: { requiredInputs: 1, From 73041b10cd3a9bb3a17736d240b641a26d3878ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 21:32:28 +0200 Subject: [PATCH 2/3] docs(insights): note the Enright 40-80 cohort in the six-minute-walk caveat The Enright & Sherrill 1998 predicted-distance equation was established on a 40-80 reference cohort; the metric accepts adult ages 18-120, so the prediction extrapolates outside that range. State it in the user-facing caveat (all locales) and the norms doc so a younger or older user reads their percent-of-predicted with that honesty. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- src/lib/insights/derived/norms.ts | 4 +++- 7 files changed, 9 insertions(+), 7 deletions(-) diff --git a/messages/de.json b/messages/de.json index d2f3a034a..4f310d326 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2645,7 +2645,7 @@ }, "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." + "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. Die Referenz wurde für Alter 40–80 erstellt; außerhalb dieses Bereichs ist der Vorhersagewert eine Extrapolation." } }, "scores": { diff --git a/messages/en.json b/messages/en.json index f403b2a34..09c7691fa 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2645,7 +2645,7 @@ }, "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." + "caveat": "An estimate re-framed against a published reference — a functional-capacity awareness signal, not a clinical 6-minute-walk test or a diagnosis. The reference was established for ages 40–80; outside that range the predicted value is an extrapolation." } }, "scores": { diff --git a/messages/es.json b/messages/es.json index 86177a82b..1959f9ad1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2645,7 +2645,7 @@ }, "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." + "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. La referencia se estableció para edades de 40 a 80 años; fuera de ese rango el valor previsto es una extrapolación." } }, "scores": { diff --git a/messages/fr.json b/messages/fr.json index 891517d37..8ea6667b8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2645,7 +2645,7 @@ }, "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." + "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. La référence a été établie pour les âges de 40 à 80 ans ; en dehors de cette plage, la valeur prédite est une extrapolation." } }, "scores": { diff --git a/messages/it.json b/messages/it.json index 507a4a8fc..ef0d2914b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2645,7 +2645,7 @@ }, "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." + "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. Il riferimento è stato stabilito per le età 40–80; al di fuori di questo intervallo il valore previsto è un’estrapolazione." } }, "scores": { diff --git a/messages/pl.json b/messages/pl.json index a9c068f1f..fd2bf24f1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2645,7 +2645,7 @@ }, "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." + "caveat": "Szacunek odniesiony do opublikowanego wzorca — sygnał wydolności funkcjonalnej, nie kliniczny test sześciominutowego marszu ani diagnoza. Wzorzec opracowano dla wieku 40–80 lat; poza tym zakresem wartość przewidywana jest ekstrapolacją." } }, "scores": { diff --git a/src/lib/insights/derived/norms.ts b/src/lib/insights/derived/norms.ts index 717102021..a606d95bc 100644 --- a/src/lib/insights/derived/norms.ts +++ b/src/lib/insights/derived/norms.ts @@ -221,7 +221,9 @@ export function lookupNormalRange( * would inflate the predicted distance. * * Pure. The equation is for adults; for ages below 18 the reference does not - * apply and we return `null`. + * apply and we return `null`. The Enright & Sherrill cohort was ages 40–80; + * outside that range the linear equation extrapolates, so the caveat copy + * flags the predicted value as an extrapolation for younger / older users. */ export function predictSixMinuteWalkDistance( ageYears: number | null | undefined, From b56f712d4e999347c42b948d90c4dfa462ff1557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 3 Jun 2026 21:33:30 +0200 Subject: [PATCH 3/3] =?UTF-8?q?chore(release):=20v1.10.4=20=E2=80=94=20str?= =?UTF-8?q?ain=20honesty=20label=20+=20six-minute-walk=20reference=20cavea?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 +++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e642c523..8d121d422 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.10.4] — 2026-06-03 — Strain honesty + six-minute-walk caveat + +### Changed + +- **The Strain reading now says which scale it used.** When the score is anchored to your own recent training load it reads "relative to your typical effort"; while there is too little history and it falls back to a general reference, it says so — so the framing always matches the number actually shown. +- **The six-minute-walk band notes its reference range.** The reference equation was established for ages 40–80; outside that range the percent-of-predicted is now flagged as an extrapolation. + ## [1.10.3] — 2026-06-03 — Personalised strain, a daily signal card, deeper derived metrics ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 07014f7e8..172f6d070 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.10.3 + version: 1.10.4 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 86a4a9423..4cbea3e6a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.10.3", + "version": "1.10.4", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev",