diff --git a/messages/de.json b/messages/de.json index 73279398e..73e41890e 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2005,9 +2005,6 @@ "therapyTimelineDescription": "Chronologisches GLP-1-Therapieprotokoll — Dosis-Änderungen, Injektionen, Bestand, Nebenwirkungen.", "bmiDescription": "BMI aus deinem Gewicht und deiner Größe abgeleitet, mit Einschätzung.", "target": { - "range": "Ziel: {min}–{max} {unit}", - "bpRange": "Ziel: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}", - "inTargetShare": "{pct}% der erfassten Tage im Zielbereich", "adjustLink": "Zielbereich anpassen" }, "primary": { @@ -2919,6 +2916,12 @@ "moodImproved": "Stimmung über 30 Tage leicht verbessert.", "moodDeclined": "Stimmung über 30 Tage leicht verschlechtert.", "moodStable": "Stimmung über die letzten 30 Tage stabil." + }, + "target": { + "heading": "Ziel" + }, + "disclaimer": { + "footer": "HealthLog beschreibt deine eigenen erfassten Daten — es ist keine klinische Bewertung und keine Diagnose. Sprich mit deiner Ärztin oder deinem Arzt über alles, was dich beunruhigt." } }, "thresholds": { diff --git a/messages/en.json b/messages/en.json index ff734fe34..140470549 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2005,9 +2005,6 @@ "therapyTimelineDescription": "Chronological GLP-1 therapy log — dose changes, injections, inventory, side effects.", "bmiDescription": "BMI derived from your weight and height, with a written assessment.", "target": { - "range": "Target: {min}–{max} {unit}", - "bpRange": "Target: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}", - "inTargetShare": "{pct}% of logged days within target", "adjustLink": "Adjust target range" }, "primary": { @@ -2919,6 +2916,12 @@ "moodImproved": "Mood trended slightly higher over 30 days.", "moodDeclined": "Mood trended slightly lower over 30 days.", "moodStable": "Mood held steady over the last 30 days." + }, + "target": { + "heading": "Target" + }, + "disclaimer": { + "footer": "HealthLog describes your own tracked data — it is not a clinical assessment or diagnosis. Talk to your doctor about anything that concerns you." } }, "thresholds": { diff --git a/messages/es.json b/messages/es.json index 4d6f06a78..754212920 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2005,9 +2005,6 @@ "therapyTimelineDescription": "Protocolo cronológico de terapia GLP-1 — cambios de dosis, inyecciones, existencias, efectos secundarios.", "bmiDescription": "IMC derivado de tu peso y altura, con valoración.", "target": { - "range": "Objetivo: {min}–{max} {unit}", - "bpRange": "Objetivo: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}", - "inTargetShare": "{pct}% de los días registrados dentro del objetivo", "adjustLink": "Ajustar el rango objetivo" }, "primary": { @@ -2919,6 +2916,12 @@ "moodImproved": "El estado de ánimo mejoró ligeramente en 30 días.", "moodDeclined": "El estado de ánimo bajó ligeramente en 30 días.", "moodStable": "El estado de ánimo se mantuvo estable en los últimos 30 días." + }, + "target": { + "heading": "Objetivo" + }, + "disclaimer": { + "footer": "HealthLog describe tus propios datos registrados — no es una evaluación clínica ni un diagnóstico. Habla con tu médico sobre cualquier cosa que te preocupe." } }, "thresholds": { diff --git a/messages/fr.json b/messages/fr.json index 13c542728..f4579f8b5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2005,9 +2005,6 @@ "therapyTimelineDescription": "Journal chronologique de la thérapie GLP-1 — changements de dose, injections, stock, effets secondaires.", "bmiDescription": "IMC dérivé de ton poids et de ta taille, avec évaluation.", "target": { - "range": "Cible : {min}–{max} {unit}", - "bpRange": "Cible : {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}", - "inTargetShare": "{pct}% des jours enregistrés dans la cible", "adjustLink": "Ajuster la plage cible" }, "primary": { @@ -2919,6 +2916,12 @@ "moodImproved": "Humeur en légère hausse sur 30 jours.", "moodDeclined": "Humeur en légère baisse sur 30 jours.", "moodStable": "Humeur stable sur les 30 derniers jours." + }, + "target": { + "heading": "Objectif" + }, + "disclaimer": { + "footer": "HealthLog décrit vos propres données suivies — il ne s'agit pas d'une évaluation clinique ni d'un diagnostic. Parlez à votre médecin de tout ce qui vous préoccupe." } }, "thresholds": { diff --git a/messages/it.json b/messages/it.json index 5245295ef..11dfd526e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2005,9 +2005,6 @@ "therapyTimelineDescription": "Protocollo cronologico della terapia GLP-1 — cambi di dose, iniezioni, scorte, effetti collaterali.", "bmiDescription": "BMI derivato dal tuo peso e dalla tua altezza, con valutazione.", "target": { - "range": "Obiettivo: {min}–{max} {unit}", - "bpRange": "Obiettivo: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}", - "inTargetShare": "{pct}% dei giorni registrati nell'obiettivo", "adjustLink": "Modifica l'intervallo obiettivo" }, "primary": { @@ -2919,6 +2916,12 @@ "moodImproved": "Umore in lieve miglioramento su 30 giorni.", "moodDeclined": "Umore in lieve calo su 30 giorni.", "moodStable": "Umore stabile negli ultimi 30 giorni." + }, + "target": { + "heading": "Obiettivo" + }, + "disclaimer": { + "footer": "HealthLog descrive i tuoi dati registrati — non è una valutazione clinica né una diagnosi. Parla con il tuo medico di qualsiasi cosa ti preoccupi." } }, "thresholds": { diff --git a/messages/pl.json b/messages/pl.json index d4b5efcff..9cd479969 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2005,9 +2005,6 @@ "therapyTimelineDescription": "Chronologiczny protokół terapii GLP-1 — zmiany dawki, iniekcje, stan, działania niepożądane.", "bmiDescription": "BMI obliczone z twojej wagi i wzrostu, z oceną.", "target": { - "range": "Cel: {min}–{max} {unit}", - "bpRange": "Cel: {sysMin}–{sysMax} / {diaMin}–{diaMax} {unit}", - "inTargetShare": "{pct}% zarejestrowanych dni w zakresie celu", "adjustLink": "Dostosuj zakres docelowy" }, "primary": { @@ -2919,6 +2916,12 @@ "moodImproved": "Nastrój nieco się poprawił w ciągu 30 dni.", "moodDeclined": "Nastrój nieco się pogorszył w ciągu 30 dni.", "moodStable": "Nastrój pozostał stabilny przez ostatnie 30 dni." + }, + "target": { + "heading": "Cel" + }, + "disclaimer": { + "footer": "HealthLog opisuje Twoje własne zarejestrowane dane — nie jest oceną kliniczną ani diagnozą. Porozmawiaj z lekarzem o wszystkim, co Cię niepokoi." } }, "thresholds": { diff --git a/src/components/insights/__tests__/metric-target-summary.test.tsx b/src/components/insights/__tests__/metric-target-summary.test.tsx index 10faa398e..18e781bb6 100644 --- a/src/components/insights/__tests__/metric-target-summary.test.tsx +++ b/src/components/insights/__tests__/metric-target-summary.test.tsx @@ -6,16 +6,16 @@ import { I18nProvider } from "@/lib/i18n/context"; /** * v1.8.0 → v1.8.5 — `` unit tests. * - * The panel surfaces the numeric target range, the verbal status pill, - * the guideline source, the in-target share, the 30-day average, the - * range bar and the 7-day consistency strip on each insights category - * page, reading the same `/api/insights/targets` payload that powers - * `/targets`. The component depends on `useAuth` (gate) and TanStack - * Query (`useQuery`) for the payload, so both are mocked here and the - * assertions run through SSR — the load-bearing behaviour is the - * range/share/source rendering + the slug→type mapping (including the - * blood-glucose per-context split + unit conversion), not the live - * fetch. + * The panel surfaces the canonical "Target" tile header, the verbal + * status pill, the guideline source, the 30-day average (BP only), the + * range bar (whose axis labels carry the band endpoints) and the 7-day + * consistency strip on each insights category page, reading the same + * `/api/insights/targets` payload that powers `/targets`. The component + * depends on `useAuth` (gate) and TanStack Query (`useQuery`) for the + * payload, so both are mocked here and the assertions run through SSR — + * the load-bearing behaviour is the header/bar/source rendering + the + * slug→type mapping (including the blood-glucose per-context split + unit + * conversion), not the live fetch. */ vi.mock("@/hooks/use-auth", () => ({ @@ -154,10 +154,17 @@ const GLUCOSE_DATA: TargetsFixture = { }; describe("", () => { - it("renders the numeric range for a simple metric", () => { + it("renders the canonical Target tile header and the band endpoints on the bar", () => { const html = renderWith("weight", WEIGHT_DATA); - // Trailing `.0` trimmed, single-decimal kept. - expect(html).toContain("Target: 60.1–80.9 kg"); + // v1.12.6 — the heading is the single-word canonical ``; + // the band numbers live on the range bar's axis labels rather than a + // redundant "Target: 60.1–80.9 kg" string above it. + expect(html).toContain('data-slot="tile-header"'); + expect(html).toContain("Target"); + expect(html).not.toContain("Target: 60.1–80.9 kg"); + // The min / max endpoints surface as the range-bar axis labels. + expect(html).toContain("60.1 kg"); + expect(html).toContain("80.9 kg"); // v1.12.0 — the in-target share moved up to ``, // the canonical home for the headline + 30-day average + in-range // bar. The band-reference panel no longer repeats it for non-BP @@ -188,9 +195,13 @@ describe("", () => { expect(html).not.toContain("30-day average: 71.4 kg"); }); - it("stitches the systolic + diastolic bands for blood pressure", () => { + it("renders the systolic + diastolic bands for blood pressure", () => { const html = renderWith("blood-pressure", BP_DATA); - expect(html).toContain("Target: 120–129 / 70–79 mmHg"); + // v1.12.6 — the stitched "Target: 120–129 / 70–79 mmHg" string is gone; + // the canonical "Target" header leads and each band's endpoints surface + // on its own range bar. + expect(html).toContain('data-slot="tile-header"'); + expect(html).not.toContain("Target: 120–129 / 70–79 mmHg"); // v1.12.0 — the in-target share moved up to the primary tile; the BP // panel keeps only the stitched 30-day average inline. expect(html).not.toContain("of logged days within target"); @@ -223,7 +234,9 @@ describe("", () => { ...WEIGHT_DATA, targets: [{ ...WEIGHT_ITEM, insufficientData: true }], }); - expect(html).toContain("Target: 60.1–80.9 kg"); + // v1.12.6 — the canonical Target header still renders; the share + + // consistency strip suppress on insufficient data. + expect(html).toContain('data-slot="tile-header"'); expect(html).not.toContain("of logged days within target"); expect(html).not.toContain('data-slot="consistency-strip"'); }); @@ -253,9 +266,12 @@ describe("", () => { html.match(/data-slot="metric-target-summary"/g) ?? [] ).length; expect(panelCount).toBe(2); + // v1.12.6 — the per-context band numbers live on each panel's range + // bar (the redundant "Target: 70–100 mg/dL" string was removed). The // mg/dL display unit keeps the canonical values unchanged. - expect(html).toContain("Target: 70–100 mg/dL"); - expect(html).toContain("Target: 70–140 mg/dL"); + expect(html).toContain("70 mg/dL"); + expect(html).toContain("100 mg/dL"); + expect(html).toContain("140 mg/dL"); // ADA / DDG source surfaces for each panel. expect(html).toContain("Source: ADA 2024 / DDG"); // The i18n label keys resolve to EN context headings (rendered @@ -270,10 +286,12 @@ describe("", () => { profile: { glucoseUnit: "mmol/L" }, }); // 70 mg/dL → 3.9, 100 mg/dL → 5.5, 140 mg/dL → 7.8 mmol/L - // (the /targets converter rounds to one decimal). - expect(html).toContain("Target: 3.9–5.5 mmol/L"); - expect(html).toContain("Target: 3.9–7.8 mmol/L"); - expect(html).toContain("mmol/L"); + // (the /targets converter rounds to one decimal). v1.12.6 — the + // converted endpoints surface on the range bar, not a "Target: …" + // string. + expect(html).toContain("3.9 mmol/L"); + expect(html).toContain("5.5 mmol/L"); + expect(html).toContain("7.8 mmol/L"); }); it("renders nothing when no glucose context has a band", () => { diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index aa86c5c5c..4732f4e7c 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import type { ComponentProps, ReactNode } from "react"; +import type { ComponentProps, ComponentType, ReactNode } from "react"; import { Sparkles } from "lucide-react"; @@ -77,6 +77,14 @@ export interface HealthKitMetricPageProps { emptyStateCtaType?: string | null; /** Icon node mounted in the empty-state card. */ emptyStateIcon: ReactNode; + /** + * v1.12.6 — leading glyph for the stat strip's `` (the + * numbers-first block above the chart). Pass the metric's icon component + * (e.g. `Activity`, `Droplet`); when omitted the strip falls back to a + * generic stats glyph so every HealthKit page still leads with a complete + * header carrying the metric name. + */ + statIcon?: ComponentType<{ className?: string }>; /** * Optional Coach prefill for the empty-state launch. Falls back to a * generic onboarding prompt threaded through ``'s @@ -148,6 +156,7 @@ export function HealthKitMetricPage({ explainerMetric, targetSummarySlug, statusMetric, + statIcon, forecast = false, }: HealthKitMetricPageProps) { const { user, isAuthenticated } = useAuth(); @@ -210,7 +219,14 @@ export function HealthKitMetricPage({ title={title} description={description} explainerMetric={explainerMetric} - statStrip={} + statStrip={ + + } diversityNudge={ `. Callers still pass a pre-sized icon NODE (e.g. + * ``) because the bespoke metric pages + * own that prop; `` drives its icon off a component, so wrap + * the node in a thin component that renders it verbatim. The node already + * carries the `h-5 w-5` foreground sizing `` would apply, so + * the rendered row is byte-identical to the previous hand-rolled `flex + * items-center gap-2` header. + */ +function nodeIcon(icon: React.ReactNode): React.ComponentType { + return function StatusIcon() { + return <>{icon}; + }; +} + interface InsightStatusCardProps { title: string; icon: React.ReactNode; @@ -59,10 +76,7 @@ export function InsightStatusCard({ className="gap-2 py-4 md:gap-3 md:py-5" > -
- {icon} - {title} -
+
@@ -115,10 +129,7 @@ export function InsightStatusCard({ return ( -
- {icon} - {title} -
+

@@ -133,10 +144,7 @@ export function InsightStatusCard({ return ( -

- {icon} - {title} -
+

@@ -175,11 +183,9 @@ export function InsightStatusCard({ {/* v1.11.5 — the top-right "cached" label was removed: it surfaced an implementation detail and devalued the assessment. The card - still consumes the warm cache; it just no longer announces it. */} -

- {icon} - {title} -
+ still consumes the warm cache; it just no longer announces it. + v1.12.6 — the icon + title row is the canonical ``. */} + {/* v1.4.27 — defence-in-depth strip. Cached status text from diff --git a/src/components/insights/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index 75af6b489..c49964c68 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -1,6 +1,10 @@ "use client"; +import type { ComponentType } from "react"; +import { Sigma } from "lucide-react"; + import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import { TileHeader } from "@/components/insights/tile-header"; import type { DataSummary } from "@/lib/analytics/trends"; /** @@ -41,12 +45,26 @@ interface MetricStatStripProps { */ fractionDigits?: number; /** - * v1.12.4 — optional series caption rendered above the grid. Single-series - * metrics omit it; blood pressure stacks two strips (systolic / diastolic) - * and labels each so the unified strip covers both halves rather than - * silently dropping one. Also feeds the `aria-label` for the section. + * v1.12.4 — series caption rendered above the grid. Single-series metrics + * pass the metric name (e.g. "Gewicht"); blood pressure stacks two strips + * (systolic / diastolic) and labels each so the unified strip covers both + * halves rather than silently dropping one. Also feeds the `aria-label` + * for the section. + * + * v1.12.6 — the caption is now rendered through the canonical + * `` (white heading + white icon, matching the Einschätzung + * card) rather than a small muted uppercase line, so every series block + * leads with the same header language as the rest of the surface. */ seriesLabel?: string; + /** + * v1.12.6 — leading glyph for the series ``. Pass the metric's + * icon component (e.g. `Scale`, `Heart`); blood pressure passes a + * directional glyph per series. Defaults to a generic stats glyph so a + * caller that supplies a `seriesLabel` without an icon still renders a + * complete header. + */ + icon?: ComponentType<{ className?: string }>; } export function MetricStatStrip({ @@ -54,6 +72,7 @@ export function MetricStatStrip({ unit, fractionDigits = 1, seriesLabel, + icon, }: MetricStatStripProps) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -88,15 +107,10 @@ export function MetricStatStrip({ ? `${t("insights.subPage.stats.label")} — ${seriesLabel}` : t("insights.subPage.stats.label") } - className="bg-card border-border space-y-2 rounded-xl border p-4" + className="bg-card border-border space-y-3 rounded-xl border p-4" > {seriesLabel ? ( -

- {seriesLabel} -

+ ) : null}
{cells.map((cell) => ( diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index bda5faf12..fa32de60f 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -11,6 +11,7 @@ import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; import { RangeBar } from "@/components/targets/range-bar"; import { ConsistencyStrip } from "@/components/targets/consistency-strip"; import { TargetStatusPill } from "@/components/targets/target-status-pill"; +import { TileHeader } from "@/components/insights/tile-header"; import { useTargetAdjust } from "@/lib/insights/target-adjust-context"; import { getTargetSourceLink } from "@/lib/targets/source-link"; @@ -121,11 +122,6 @@ interface MetricTargetSummaryProps { slug: string; } -/** Trim a trailing `.0` so whole-number bands read `120` not `120.0`. */ -function fmt(value: number): string { - return Number.isInteger(value) ? String(value) : value.toFixed(1); -} - export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); @@ -275,27 +271,6 @@ function TargetReferencePanel({ const isBp = target.type === "BLOOD_PRESSURE"; const isMedicationCompliance = target.type === "MEDICATION_COMPLIANCE"; - // Compose the range string. Blood pressure stitches the systolic band - // (on this target) with the diastolic band (on `bpDiastolic`) so the - // user reads the familiar `S/D` pair. - let rangeLabel: string; - if (isBp && bpDiastolic?.range) { - const dia = bpDiastolic.range; - rangeLabel = t("insights.subPage.target.bpRange", { - sysMin: fmt(range.min), - sysMax: fmt(range.max), - diaMin: fmt(dia.min), - diaMax: fmt(dia.max), - unit, - }); - } else { - rangeLabel = t("insights.subPage.target.range", { - min: fmt(range.min), - max: fmt(range.max), - unit, - }); - } - const sourceLink = getTargetSourceLink(target); // v1.12.0 — the in-target share (% of logged days in band) and the @@ -327,33 +302,32 @@ function TargetReferencePanel({ data-target-type={target.type} className="bg-card border-border space-y-3 rounded-xl border p-4" > - {/* Row 1: target range string + status pill + source link. */} -
-
-
-
- {target.classification ? ( + {/* Row 1: the canonical tile header — a single-word "Ziel" / "Target" + heading (the range numbers live on the range bar below, so the + old "Target: 120–129 mmHg" string would just duplicate them) with + the status pill pinned to the trailing edge. For glucose the + per-context label (Fasting / Postprandial / …) carries real, + non-duplicative info, so it stays as a sub-caption under the + header. */} + - ) : null} -
-
+ ) : null + } + /> + {heading ? ( +

+ {heading} +

+ ) : null} {/* Row 2: range bar — where today's value sits inside the band. */} {target.current != null ? ( diff --git a/src/components/insights/tile-header.tsx b/src/components/insights/tile-header.tsx new file mode 100644 index 000000000..5d0428e64 --- /dev/null +++ b/src/components/insights/tile-header.tsx @@ -0,0 +1,68 @@ +import type { ComponentType, ReactNode } from "react"; + +import { CardTitle } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; + +/** + * v1.12.6 — the one canonical tile header used by every Insights tile. + * + * Pre-v1.12.6 each tile hand-rolled its own `icon + heading` row at a + * slightly different size, weight, and colour: the "Einschätzung" card + * led with a foreground-colour icon + `CardTitle`, the target panel led + * with a small muted `Target` glyph + a range string, and the stat strip + * led with a tiny uppercase muted caption. Side-by-side on the same + * subpage they read as three different card languages. + * + * `TileHeader` pins the single header contract to the "Einschätzung" + * reference (the calmest, most legible of the three): a `flex + * items-center gap-2` row with the icon and the `CardTitle` BOTH in the + * foreground colour (white in dark mode) — never `text-muted-foreground` + * — at `text-base`, icon `h-5 w-5 shrink-0`. An optional `right` slot + * carries a trailing affordance (a provenance / tooltip glyph, a status + * pill) without breaking the row's alignment. + * + * RSC-safe: no hooks, no browser API, so it renders on the server inside + * any client or server tile. + */ + +interface TileHeaderProps { + /** + * Leading glyph. Accepts a Lucide icon component (or any component that + * takes a `className`) so the caller passes the component itself + * (`icon={Target}`), not a pre-sized node — `TileHeader` owns the size + * and colour so every tile header matches. + */ + icon: ComponentType<{ className?: string }>; + /** Heading text (or node). Rendered inside a `CardTitle` at `text-base`. */ + title: ReactNode; + /** Optional trailing affordance pinned to the right edge of the row. */ + right?: ReactNode; + className?: string; + titleClassName?: string; + /** Optional id on the `CardTitle`, e.g. for an `aria-labelledby` link. */ + id?: string; +} + +export function TileHeader({ + icon: Icon, + title, + right, + className, + titleClassName, + id, +}: TileHeaderProps) { + return ( +
+ {/* Icon and heading share the foreground colour — the calm, + high-contrast read the "Einschätzung" card established. */} +
+ ); +}