From 6f2f43bb490e23ea698723ff9aa397b8da01a17e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 22 Aug 2026 16:05:21 +0200 Subject: [PATCH] Say what the AI surfaces actually compute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three places where the copy promised more than the handler delivers. The hero's personal-baseline line rendered unconditionally while both its siblings in the same meta row gate on their own data. It claims the subtitle above it was drawn from the reader's own last 90 days, but with no briefing the subtitle is fixed fallback copy computed from nothing — so an account with no briefing at all was told its generic sentence rested on a personal baseline. Gate it on the briefing, like the rest of the row. The pulse sub-page promised a personalised Karvonen target band. Karvonen is not computed anywhere: the band comes from CDC/NCHS population percentiles by age and sex, and falls back to a fixed 60-100 for a reader with no age on file. The copy now says that in all six locales, and the fallback's own source label says it is a general adult range rather than a personal one, because that arm is the same band for everyone. Karvonen itself stays unimplemented; whether to build it is a product decision, not a copy fix. The Coach's `allTime` window returns 365 days, identical to `lastYear`, and it is the default. "year so far" was wrong too — a rolling 365 days is not year-to-date. Both labels now say what the reads do. The two options remain functionally identical and one of them should probably go, but removing a default is a product decision. --- messages/de.json | 6 ++--- messages/en.json | 6 ++--- messages/es.json | 6 ++--- messages/fr.json | 6 ++--- messages/it.json | 6 ++--- messages/pl.json | 6 ++--- .../insights/__tests__/hero-strip.test.tsx | 27 +++++++++++++++++-- src/components/insights/hero-strip.tsx | 22 ++++++++++----- .../insights/metric-target-summary.tsx | 4 +-- src/lib/analytics/pulse-targets.ts | 9 +++++-- 10 files changed, 68 insertions(+), 30 deletions(-) diff --git a/messages/de.json b/messages/de.json index 67e3f9651..a2d2e6d9e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2748,8 +2748,8 @@ "last7days": "letzte 7 Tage", "last30days": "letzte 30 Tage", "last90days": "letzte 90 Tage", - "lastYear": "Jahresrückblick", - "allTime": "gesamter Zeitraum" + "lastYear": "letzte 12 Monate", + "allTime": "gesamter Zeitraum (12 Monate im Detail)" }, "windowLabel": "Zeitraum", "settingsAriaLabel": "Coach-Einstellungen", @@ -3047,7 +3047,7 @@ "loadError": "Diese Messgröße konnte gerade nicht geladen werden.", "blutdruckDescription": "Dein systolisch-/diastolisch-Verlauf mit altersangepassten Zielbereichen und einer textbasierten Einschätzung.", "gewichtDescription": "Gewichtsverlauf gegen das aus deiner Größe abgeleitete gesunde Band, mit Einschätzung.", - "pulsDescription": "Pulsverlauf gegenüber dem persönlichen Karvonen-Zielband, mit Einschätzung.", + "pulsDescription": "Ruhepuls im Vergleich zu einem alters- und geschlechtsabhängigen Referenzband aus CDC/NCHS-Populationsperzentilen, mit Einschätzung.", "stimmungDescription": "Tägliche Stimmungswerte im Zeitverlauf mit einer textbasierten Einschätzung.", "medikamenteDescription": "Einnahme-Kalender pro Medikament plus eine textbasierte Einschätzung.", "bmiDescription": "BMI aus deinem Gewicht und deiner Größe abgeleitet, mit Einschätzung.", diff --git a/messages/en.json b/messages/en.json index 4d689f6bf..ba0a5ac87 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2748,8 +2748,8 @@ "last7days": "last 7 days", "last30days": "last 30 days", "last90days": "last 90 days", - "lastYear": "year so far", - "allTime": "all time" + "lastYear": "last 12 months", + "allTime": "all time (12 months of detail)" }, "windowLabel": "Window", "settingsAriaLabel": "Coach settings", @@ -3047,7 +3047,7 @@ "loadError": "Could not load this metric right now.", "blutdruckDescription": "Your systolic / diastolic trend with age-personalised target zones and a written assessment.", "gewichtDescription": "Weight trend mapped against your height-derived healthy band, plus a written assessment.", - "pulsDescription": "Pulse trend against the personalised Karvonen target band, plus a written assessment.", + "pulsDescription": "Resting pulse against an age- and sex-based reference band from CDC/NCHS population percentiles, plus a written assessment.", "stimmungDescription": "Daily mood scores over time with a written assessment.", "medikamenteDescription": "Per-medication adherence calendars and a written assessment.", "bmiDescription": "BMI derived from your weight and height, with a written assessment.", diff --git a/messages/es.json b/messages/es.json index da064100b..632a9f9ed 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2748,8 +2748,8 @@ "last7days": "Últimos 7 días", "last30days": "Últimos 30 días", "last90days": "Últimos 90 días", - "lastYear": "lo que va de año", - "allTime": "Todo el tiempo" + "lastYear": "últimos 12 meses", + "allTime": "todo el tiempo (12 meses en detalle)" }, "windowLabel": "Periodo", "settingsAriaLabel": "Ajustes del coach", @@ -3047,7 +3047,7 @@ "loadError": "No se pudo cargar esta métrica ahora mismo.", "blutdruckDescription": "Tu evolución sistólica/diastólica con rangos objetivo ajustados a la edad y una valoración en texto.", "gewichtDescription": "Evolución del peso frente a la banda saludable derivada de tu altura, con valoración.", - "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", + "pulsDescription": "Pulso en reposo frente a una banda de referencia por edad y sexo basada en percentiles poblacionales de CDC/NCHS, con una valoración escrita.", "stimmungDescription": "Valores diarios de estado de ánimo en el tiempo, con una valoración en texto.", "medikamenteDescription": "Calendario de tomas por medicamento, más una valoración en texto.", "bmiDescription": "IMC derivado de tu peso y altura, con valoración.", diff --git a/messages/fr.json b/messages/fr.json index 19ff3c528..5e03401f1 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2748,8 +2748,8 @@ "last7days": "7 derniers jours", "last30days": "30 derniers jours", "last90days": "90 derniers jours", - "lastYear": "depuis le début de l'année", - "allTime": "Toute la période" + "lastYear": "12 derniers mois", + "allTime": "toute la période (12 mois en détail)" }, "windowLabel": "Période", "settingsAriaLabel": "Paramètres du coach", @@ -3047,7 +3047,7 @@ "loadError": "Impossible de charger cette mesure pour le moment.", "blutdruckDescription": "Ton évolution systolique/diastolique avec des plages cibles adaptées à l’âge et une évaluation textuelle.", "gewichtDescription": "Évolution du poids face à la bande saine dérivée de ta taille, avec évaluation.", - "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", + "pulsDescription": "Pouls au repos comparé à une plage de référence selon l'âge et le sexe, issue des percentiles de population CDC/NCHS, avec une évaluation rédigée.", "stimmungDescription": "Valeurs d’humeur quotidiennes dans le temps, avec une évaluation textuelle.", "medikamenteDescription": "Calendrier des prises par médicament, ainsi qu’une évaluation textuelle.", "bmiDescription": "IMC dérivé de ton poids et de ta taille, avec évaluation.", diff --git a/messages/it.json b/messages/it.json index eb36f3640..eb02d4511 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2748,8 +2748,8 @@ "last7days": "Ultimi 7 giorni", "last30days": "Ultimi 30 giorni", "last90days": "Ultimi 90 giorni", - "lastYear": "anno in corso", - "allTime": "Tutto il tempo" + "lastYear": "ultimi 12 mesi", + "allTime": "tutto il tempo (12 mesi in dettaglio)" }, "windowLabel": "Periodo", "settingsAriaLabel": "Impostazioni del coach", @@ -3047,7 +3047,7 @@ "loadError": "Impossibile caricare questa metrica al momento.", "blutdruckDescription": "Il tuo andamento sistolico/diastolico con range obiettivo adattati all’età e una valutazione testuale.", "gewichtDescription": "Andamento del peso rispetto alla fascia sana derivata dalla tua altezza, con valutazione.", - "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", + "pulsDescription": "Battito a riposo confrontato con una fascia di riferimento per età e sesso, basata sui percentili di popolazione CDC/NCHS, con una valutazione scritta.", "stimmungDescription": "Valori giornalieri di umore nel tempo, con una valutazione testuale.", "medikamenteDescription": "Calendario delle assunzioni per farmaco e una valutazione testuale.", "bmiDescription": "BMI derivato dal tuo peso e dalla tua altezza, con valutazione.", diff --git a/messages/pl.json b/messages/pl.json index 8976add7f..18514d304 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2748,8 +2748,8 @@ "last7days": "Ostatnie 7 dni", "last30days": "Ostatnie 30 dni", "last90days": "Ostatnie 90 dni", - "lastYear": "od początku roku", - "allTime": "Cały okres" + "lastYear": "ostatnie 12 miesięcy", + "allTime": "cały okres (12 miesięcy szczegółowo)" }, "windowLabel": "Okres", "settingsAriaLabel": "Ustawienia coacha", @@ -3047,7 +3047,7 @@ "loadError": "Nie udało się teraz wczytać tej metryki.", "blutdruckDescription": "Twój przebieg skurczowego/rozkurczowego z zakresami docelowymi dopasowanymi do wieku i oceną tekstową.", "gewichtDescription": "Przebieg wagi na tle zdrowego pasma wyliczonego z twojego wzrostu, z oceną.", - "pulsDescription": "Resting pulse vs. the personalised Karvonen target band, plus a written assessment.", + "pulsDescription": "Tętno spoczynkowe na tle zakresu referencyjnego według wieku i płci, opartego na percentylach populacyjnych CDC/NCHS, wraz z oceną opisową.", "stimmungDescription": "Dzienne wartości nastroju w czasie wraz z oceną tekstową.", "medikamenteDescription": "Kalendarz przyjęć dla każdego leku plus ocena tekstowa.", "bmiDescription": "BMI obliczone z twojej wagi i wzrostu, z oceną.", diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index 7f59ad7b3..c13e4e984 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -241,12 +241,35 @@ describe("", () => { ); }); - it("renders the personal-baseline meta line", () => { - const html = render(); + it("renders the personal-baseline meta line under a real briefing", () => { + const html = render( + , + ); expect(html).toMatch(/data-slot="insights-hero-strip-baseline"/); expect(html).toContain("Based on your last 90 days"); }); + /** + * Inverted from the previous assertion, which rendered with + * `briefing={null}` and still expected the baseline line. + * + * That pinned the defect. The line claims the subtitle above it was drawn + * from the reader's own last 90 days, but with no briefing the subtitle is + * `heroFallbackSubtitle` — fixed copy computed from nothing. Both siblings in + * the same meta row (the freshness caption, the no-provider hint) already + * gate on their own data; this one did not, so an account with no briefing at + * all was told its generic sentence rested on a personal baseline. + */ + it("withholds the personal-baseline meta line when there is no briefing", () => { + const html = render(); + expect(html).not.toMatch(/data-slot="insights-hero-strip-baseline"/); + expect(html).not.toContain("Based on your last 90 days"); + // The fallback subtitle still renders — only the provenance claim goes. + expect(html).toContain( + "A daily read of your trends, drawn straight from the numbers you've logged.", + ); + }); + it("renders the freshness caption when updatedAt is supplied", () => { // v1.22 — the hero freshness line now uses `formatUpdatedLabel` for parity // with the briefing + per-metric cards: a same-day timestamp reads diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 3ba16e578..ef92e9184 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -199,14 +199,24 @@ export function HeroStrip({
- - {t("insights.heroPersonalBaseline")} - + {/* The baseline line describes where the SUBTITLE came from, so it + may only show when the subtitle IS a briefing. Without one the + subtitle falls back to generic copy derived from nothing, and + this line went on claiming a personal baseline underneath it — + the one part of the row that was ungated while both its + siblings already checked for their own data first. */} + {briefing && ( + + {t("insights.heroPersonalBaseline")} + + )} {generatedLine && ( <> - + {briefing && ( + + )} {generatedLine} diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index 199711035..81fdc0277 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -28,8 +28,8 @@ import { apiGet } from "@/lib/api/api-fetch"; * nothing and a cold sub-page warms the cache its siblings reuse. We * never recompute the ranges here: the route is the single source of * truth for the age-based ESH blood-pressure band, the WHO weight / BMI - * band, the Karvonen pulse band, the AASM sleep band, the ADA / DDG - * glucose bands, and the medication / mood targets. + * band, the CDC/NCHS percentile pulse band, the AASM sleep band, the + * ADA / DDG glucose bands, and the medication / mood targets. * * The panel is a compact reference panel carrying the full target * context: diff --git a/src/lib/analytics/pulse-targets.ts b/src/lib/analytics/pulse-targets.ts index 4acc0ad56..fb3420941 100644 --- a/src/lib/analytics/pulse-targets.ts +++ b/src/lib/analytics/pulse-targets.ts @@ -68,14 +68,19 @@ export function getPersonalizedPulseTarget( age: number | null, gender: PulseGender, ): PersonalizedPulseTarget { - // AHA fallback for adults when profile context is missing. + // No age to personalise against (missing profile, or under 20 where the + // percentile table does not apply), so this arm returns a fixed band that is + // the same for everyone. The source label says so in as many words: the + // surrounding UI describes the pulse band as age- and sex-based, and a reader + // who falls into this arm needs to know theirs is not. if (age == null || age < 20) { return { greenMin: 60, greenMax: 100, orangeMin: 55, orangeMax: 105, - source: "AHA (adults, resting pulse 60-100 bpm)", + source: + "AHA general adult range (resting pulse 60-100 bpm) — not personalised", }; }