Say what the AI surfaces actually compute

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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-22 16:14:54 +02:00
1 parent c1811bc85b
commit 6f2f43bb49
10 files changed
+68 -30

No files matched your search

+3 -3
View File
@@ -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.",
+3 -3
View File
@@ -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.",
+3 -3
View File
@@ -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.",
+3 -3
View File
@@ -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.",
+3 -3
View File
@@ -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.",
+3 -3
View File
@@ -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ą.",
@@ -241,12 +241,35 @@ describe("<HeroStrip>", () => {
);
});
it("renders the personal-baseline meta line", () => {
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
it("renders the personal-baseline meta line under a real briefing", () => {
const html = render(
<HeroStrip briefing={sampleBriefing} now={morningLocal} />,
);
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(<HeroStrip briefing={null} now={morningLocal} />);
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&#x27;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
+16 -6
View File
@@ -199,14 +199,24 @@ export function HeroStrip({
<ProseBlocks text={subtitle} />
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span data-slot="insights-hero-strip-baseline">
{t("insights.heroPersonalBaseline")}
</span>
{/* 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 && (
<span data-slot="insights-hero-strip-baseline">
{t("insights.heroPersonalBaseline")}
</span>
)}
{generatedLine && (
<>
<span aria-hidden="true" className="opacity-50">
·
</span>
{briefing && (
<span aria-hidden="true" className="opacity-50">
·
</span>
)}
<span data-slot="insights-hero-strip-generated">
{generatedLine}
</span>
@@ -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:
+7 -2
View File
@@ -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",
};
}