diff --git a/messages/de.json b/messages/de.json index da0e2d9ae..0e7bda121 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3397,6 +3397,7 @@ "sectionSubtitle": "Ruhewerte von Herz-Kreislauf und Atmung im Vergleich zu deiner eigenen Baseline", "loadingLabel": "Vitalwerte werden geladen", "loadError": "Deine Vitalwerte konnten gerade nicht geladen werden.", + "infoLabel": "Wie wird das berechnet?", "building": "Typischer Bereich entsteht — {count} von {target} Tagen", "typicalRange": "Typisch für dich: {low}–{high}", "fitnessYounger": "Ausdauer wie ~{years} J. jünger", diff --git a/messages/en.json b/messages/en.json index 6d7047b16..c2195f3dc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3397,6 +3397,7 @@ "sectionSubtitle": "Resting cardiovascular and respiratory signals against your own baseline", "loadingLabel": "Loading your vitals", "loadError": "Could not load your vitals right now.", + "infoLabel": "How is this calculated?", "building": "Building your typical range — {count} of {target} days", "typicalRange": "Typical for you: {low}–{high}", "fitnessYounger": "Cardio fitness of someone ~{years} yr younger", diff --git a/messages/es.json b/messages/es.json index 79e7d5692..a29101256 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3397,6 +3397,7 @@ "sectionSubtitle": "Señales cardiovasculares y respiratorias en reposo frente a tu propia línea base", "loadingLabel": "Cargando tus constantes", "loadError": "No se pudieron cargar tus constantes en este momento.", + "infoLabel": "¿Cómo se calcula esto?", "building": "Creando tu rango habitual — {count} de {target} días", "typicalRange": "Habitual para ti: {low}–{high}", "fitnessYounger": "Forma cardiovascular de alguien ~{years} años más joven", diff --git a/messages/fr.json b/messages/fr.json index 5d723b8ee..d3726d425 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3397,6 +3397,7 @@ "sectionSubtitle": "Signaux cardiovasculaires et respiratoires au repos par rapport à ta propre référence", "loadingLabel": "Chargement de vos constantes", "loadError": "Impossible de charger vos constantes pour le moment.", + "infoLabel": "Comment ce résultat est-il calculé ?", "building": "Construction de votre plage habituelle — {count} sur {target} jours", "typicalRange": "Habituel pour vous : {low}–{high}", "fitnessYounger": "Forme cardio d'une personne ~{years} ans plus jeune", diff --git a/messages/it.json b/messages/it.json index 02336401e..b07a3cf88 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3397,6 +3397,7 @@ "sectionSubtitle": "Segnali cardiovascolari e respiratori a riposo rispetto alla tua baseline personale", "loadingLabel": "Caricamento dei tuoi parametri vitali", "loadError": "Al momento non è stato possibile caricare i tuoi parametri vitali.", + "infoLabel": "Come viene calcolato questo dato?", "building": "Creazione del tuo intervallo abituale — {count} di {target} giorni", "typicalRange": "Abituale per te: {low}–{high}", "fitnessYounger": "Forma cardio di chi ha ~{years} anni in meno", diff --git a/messages/pl.json b/messages/pl.json index eddaff186..592f41484 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3397,6 +3397,7 @@ "sectionSubtitle": "Spoczynkowe sygnały sercowo-naczyniowe i oddechowe na tle Twojej własnej linii bazowej", "loadingLabel": "Wczytywanie parametrów życiowych", "loadError": "Nie udało się teraz wczytać Twoich parametrów życiowych.", + "infoLabel": "Jak to jest obliczane?", "building": "Budowanie Twojego typowego zakresu — {count} z {target} dni", "typicalRange": "Typowe dla Ciebie: {low}–{high}", "fitnessYounger": "Kondycja jak u osoby ~{years} lat młodszej", diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index 02c8dfe51..57aa57ddf 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -16,7 +16,7 @@ import { SectionHeading } from "@/components/insights/section-heading"; import { SparklineDeltaTile } from "./sparkline-delta-tile"; import { LearnMoreLink } from "@/components/ui/learn-more-link"; import { CoverageMeter } from "./coverage-meter"; -import { ProvenanceExplainer } from "./provenance-explainer"; +import { VitalsInfoTip } from "./vitals-info-tip"; import { METRIC_PROVENANCE } from "./standards"; import { type DerivedBatchRead } from "./use-derived-metric"; import { @@ -169,10 +169,16 @@ function tileOrder(layout: InsightsLayout | undefined, tileId: string): number { return resolveTileLayout(layout, tileId).order; } -/** The provenance ⓘ explainer for one derived metric, wired from the map. */ +/** + * The (i) info-tip trigger for one derived metric, wired from the map. + * `provenance` (inputs / source / window / asOf) isn't rendered here — + * `VitalsInfoTip` carries only the method/caveat + cited standard, mirroring + * what `ProvenanceExplainer` itself renders today — but the param is kept so + * every call site (which already reads `data.provenance` off the derived + * batch) doesn't need touching if a future pass restores the detail. + */ function MetricProvenance({ metric, - provenance, }: { metric: keyof typeof METRIC_PROVENANCE; provenance: DerivedProvenance; @@ -189,13 +195,7 @@ function MetricProvenance({ {t(meta.methodKey)} ); - return ( - - ); + return ; } interface TileProps { @@ -288,14 +288,14 @@ function BaselineTile({ framing={framing} directionSentiment={vitalSentiment(type)} provenance={ - // v1.29.1 — the VITALS_BASELINE "typical range = median ± MAD" method - // caption is suppressed on its own tiles: it repeated across every - // vital baseline tile and read as out-of-place clutter at the top of - // the vitals surface. The dedicated bands (wrist-temp, stair speed, - // 6-min walk) keep their method copy — only VITALS_BASELINE is muted. - metric === "VITALS_BASELINE" ? undefined : ( - - ) + // v1.29.1 muted this on VITALS_BASELINE tiles because the always-on + // "typical range = median ± MAD" caption repeated across every vital + // baseline tile and read as clutter. v1.29.2 restores the context — + // now behind the compact (i) trigger (`VitalsInfoTip`) instead of a + // full-width caption, so every baseline tile carries it again + // without the repetition or the header-row squeeze that clipped the + // cardio-fitness heading. + } footer={} /> diff --git a/src/components/insights/derived/vitals-info-tip.tsx b/src/components/insights/derived/vitals-info-tip.tsx new file mode 100644 index 000000000..753ab3fbc --- /dev/null +++ b/src/components/insights/derived/vitals-info-tip.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useState } from "react"; +import { Info } from "lucide-react"; + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; +import type { ProvenanceStandard } from "./provenance-explainer"; + +/** + * v1.29.2 QoL — the vitals-dashboard tile's (i) info affordance. + * + * v1.29.1 dropped the always-on "your personal normal range is the + * median…" method caption from every `VITALS_BASELINE` tile because it + * repeated verbatim across up to six tiles and read as clutter at the top + * of the vitals surface — and, on the narrower tiles, squeezed the heading + * itself (the VO₂max/cardio-fitness clip). Rather than reintroduce the + * caption at full length, the SAME method/caveat/standard content moves + * behind a small (i) trigger: a click-opened `Popover` (not a hover + * tooltip — the vitals grid sees most of its use on touch), so the context + * is one tap away instead of permanently occupying header space. + * + * This is a DELIBERATE, narrowly-scoped exception to the v1.22 "no + * icon-only disclosure" direction (`ProvenanceExplainer`'s own docstring): + * that decision targeted the always-visible-caption pattern on OTHER + * surfaces (coincident-deviation, the score-anatomy pages), which stay + * unchanged. The vitals grid is dense enough, and the caption repetitive + * enough, that the same content is better one tap away here. + */ +export interface VitalsInfoTipProps { + /** Plain-language method (+ optional caveat) description — text children only. */ + method: React.ReactNode; + /** Optional cited standard, rendered as a plain external link. */ + standard?: ProvenanceStandard; + className?: string; +} + +export function VitalsInfoTip({ + method, + standard, + className, +}: VitalsInfoTipProps) { + const { t } = useTranslations(); + const [open, setOpen] = useState(false); + + return ( + + + + + +

{method}

+ {standard ? ( + + {standard.name} + + ) : null} +
+
+ ); +}