mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
insights: add canonical TileHeader and route shared tiles through it
Introduce a single TileHeader primitive (foreground icon + CardTitle,
matching the assessment-card header) and route the assessment card, the
target reference panel, and the stat strip through it so every Insights
tile leads with the same header language. The target panel now leads with
a single-word "Ziel"/"Target" heading and drops the redundant range
string (the band numbers already live on the range bar); the stat strip
labels each series via a header ("Systolisch"/"Diastolisch" for blood
pressure, the metric name for single-series metrics).
This commit is contained in:
1 parent
5846ce0916
commit
d434a6e24b
12 files changed
+232
-118
No files matched your search
+6
-3
@@ -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": {
|
||||
|
||||
+6
-3
@@ -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": {
|
||||
|
||||
+6
-3
@@ -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": {
|
||||
|
||||
+6
-3
@@ -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": {
|
||||
|
||||
+6
-3
@@ -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": {
|
||||
|
||||
+6
-3
@@ -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": {
|
||||
|
||||
@@ -6,16 +6,16 @@ import { I18nProvider } from "@/lib/i18n/context";
|
||||
/**
|
||||
* v1.8.0 → v1.8.5 — `<MetricTargetSummary>` 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("<MetricTargetSummary>", () => {
|
||||
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 `<TileHeader>`;
|
||||
// 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 `<MetricPrimaryTile>`,
|
||||
// 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("<MetricTargetSummary>", () => {
|
||||
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("<MetricTargetSummary>", () => {
|
||||
...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("<MetricTargetSummary>", () => {
|
||||
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("<MetricTargetSummary>", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -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 `<TileHeader>` (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 `<CoachLaunchButton>`'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={<MetricStatStrip summary={summary} unit={yAxisUnit ?? unit} />}
|
||||
statStrip={
|
||||
<MetricStatStrip
|
||||
summary={summary}
|
||||
unit={yAxisUnit ?? unit}
|
||||
seriesLabel={title}
|
||||
icon={statIcon}
|
||||
/>
|
||||
}
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
measurementType={measurementType}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
@@ -11,6 +12,22 @@ import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* v1.12.6 — route the icon + title row through the canonical
|
||||
* `<TileHeader>`. Callers still pass a pre-sized icon NODE (e.g.
|
||||
* `<HeartPulse className="h-5 w-5" />`) because the bespoke metric pages
|
||||
* own that prop; `<TileHeader>` 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 `<TileHeader>` 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"
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</div>
|
||||
<TileHeader icon={nodeIcon(icon)} title={title} />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="bg-muted h-3.5 w-full animate-pulse rounded motion-reduce:animate-none" />
|
||||
@@ -115,10 +129,7 @@ export function InsightStatusCard({
|
||||
return (
|
||||
<Card className="gap-2 py-4 opacity-60 md:gap-3 md:py-5">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</div>
|
||||
<TileHeader icon={nodeIcon(icon)} title={title} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -133,10 +144,7 @@ export function InsightStatusCard({
|
||||
return (
|
||||
<Card className="gap-2 py-4 md:gap-3 md:py-5">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</div>
|
||||
<TileHeader icon={nodeIcon(icon)} title={title} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -175,11 +183,9 @@ export function InsightStatusCard({
|
||||
<CardHeader className="pb-2">
|
||||
{/* 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. */}
|
||||
<div className="flex items-center gap-2">
|
||||
{icon}
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</div>
|
||||
still consumes the warm cache; it just no longer announces it.
|
||||
v1.12.6 — the icon + title row is the canonical `<TileHeader>`. */}
|
||||
<TileHeader icon={nodeIcon(icon)} title={title} />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
{/* v1.4.27 — defence-in-depth strip. Cached status text from
|
||||
|
||||
@@ -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
|
||||
* `<TileHeader>` (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 `<TileHeader>`. 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 ? (
|
||||
<p
|
||||
data-slot="metric-stat-strip-series"
|
||||
className="text-muted-foreground text-[10px] font-medium tracking-wide uppercase"
|
||||
>
|
||||
{seriesLabel}
|
||||
</p>
|
||||
<TileHeader icon={icon ?? Sigma} title={seriesLabel} />
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 [&>div]:min-h-[56px]">
|
||||
{cells.map((cell) => (
|
||||
|
||||
@@ -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. */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Target
|
||||
className="text-dracula-green size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
{heading ? (
|
||||
<p className="text-muted-foreground text-[0.6875rem] font-medium tracking-[0.06em] uppercase">
|
||||
{heading}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-sm font-medium">{rangeLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{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. */}
|
||||
<TileHeader
|
||||
icon={Target}
|
||||
title={t("insights.target.heading")}
|
||||
right={
|
||||
target.classification ? (
|
||||
<TargetStatusPill
|
||||
classification={target.classification}
|
||||
range={range}
|
||||
unit={unit}
|
||||
source={target.source}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{heading ? (
|
||||
<p className="text-muted-foreground text-[0.6875rem] font-medium tracking-[0.06em] uppercase">
|
||||
{heading}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Row 2: range bar — where today's value sits inside the band. */}
|
||||
{target.current != null ? (
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
data-slot="tile-header"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
>
|
||||
{/* Icon and heading share the foreground colour — the calm,
|
||||
high-contrast read the "Einschätzung" card established. */}
|
||||
<Icon className="text-foreground h-5 w-5 shrink-0" aria-hidden="true" />
|
||||
<CardTitle id={id} className={cn("text-base", titleClassName)}>
|
||||
{title}
|
||||
</CardTitle>
|
||||
{right ? <div className="ml-auto flex items-center">{right}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user