mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(insights): move vitals method context into an (i) popover; unclip VO2max
The always-on "typical range = median ± MAD" caption was dropped from the vitals tiles because it repeated across every baseline tile — but that same inline caption, still rendered unconditionally on the four derived re-frame tiles (cardio fitness, vascular age, HRV, BMI), was squeezing the header row hard enough to clip the heading next to it: measured on the cardio-fitness tile, the "VO2 max" title's rendered width collapsed from 64px to 15px with the caption in the row, cutting it down to "V.". Confirmed and re-measured with a Playwright pass over the actual TileHeader/SparklineDeltaTile markup before touching the CSS. Replace the inline caption with a small (i) trigger (VitalsInfoTip) on the tile heading's trailing edge — a click-opened popover carrying the same method/caveat text plus the cited standard, matching the existing click-to-open info-tip pattern used elsewhere (touch-friendly, unlike a hover tooltip). Restore it on the VITALS_BASELINE tiles too, since a compact icon repeated across tiles doesn't clutter the way the full sentence did. The heading now keeps its full width regardless of how long the method copy runs.
This commit is contained in:
1 parent
270f33edb2
commit
7445925d58
8 files changed
+114
-18
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<ProvenanceExplainer
|
||||
provenance={provenance}
|
||||
method={method}
|
||||
standard={meta.standard}
|
||||
/>
|
||||
);
|
||||
return <VitalsInfoTip method={method} standard={meta.standard} />;
|
||||
}
|
||||
|
||||
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 : (
|
||||
<MetricProvenance metric={metric} provenance={data.provenance} />
|
||||
)
|
||||
// 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.
|
||||
<MetricProvenance metric={metric} provenance={data.provenance} />
|
||||
}
|
||||
footer={<LearnMoreLink concept={tileId} />}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="vitals-info-trigger"
|
||||
aria-label={t("insights.derived.vitals.infoLabel")}
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
|
||||
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
|
||||
"-mx-2 -my-3",
|
||||
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-slot="vitals-info-body"
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className="max-w-xs space-y-1.5"
|
||||
>
|
||||
<p className="text-muted-foreground text-xs leading-snug">{method}</p>
|
||||
{standard ? (
|
||||
<a
|
||||
href={standard.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary block text-xs leading-snug hover:underline"
|
||||
>
|
||||
{standard.name}
|
||||
</a>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user