diff --git a/src/app/insights/blood-glucose/page.tsx b/src/app/insights/blood-glucose/page.tsx index c71a5e9fa..d87b52c80 100644 --- a/src/app/insights/blood-glucose/page.tsx +++ b/src/app/insights/blood-glucose/page.tsx @@ -25,6 +25,7 @@ export default function InsightsBlutzuckerPage() { yAxisUnit="mg/dL" emptyStateIcon={} emptyStateCtaType={null} + targetSummarySlug="blood-glucose" coachPrefill="I haven't logged any blood glucose yet — what does this metric tell me about my health, and how do I improve it?" /> ); diff --git a/src/app/targets/page.tsx b/src/app/targets/page.tsx index 2775fd27b..9d6a9e49a 100644 --- a/src/app/targets/page.tsx +++ b/src/app/targets/page.tsx @@ -14,6 +14,7 @@ import { type TargetCardData, } from "@/components/targets/target-card"; import { TargetsSummaryHeader } from "@/components/targets/targets-summary-header"; +import { getTargetSourceLink } from "@/lib/targets/source-link"; import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer"; import { useCoachHandoff } from "@/hooks/use-coach-handoff"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; @@ -92,41 +93,6 @@ function sortKey(target: TargetData): number { return FIXED_TARGET_ORDER[target.type] ?? 100; } -/** - * Source-citation URL for each target type. Kept on the page (rather - * than the card) so the card stays presentational and the routing - * stays declarative. - */ -function getTargetSourceLink(target: TargetData): string | null { - if (target.type === "WEIGHT" || target.type === "BMI") { - return "https://www.who.int/news-room/fact-sheets/detail/obesity-and-overweight"; - } - if ( - target.type === "BLOOD_PRESSURE" || - target.type === "BLOOD_PRESSURE_IN_TARGET" - ) { - return "https://academic.oup.com/eurheartj/article/39/33/3021/5079119"; - } - if (target.type === "PULSE") { - if (target.source.includes("CDC/NCHS")) { - return "https://www.cdc.gov/nchs/data/nhsr/nhsr041.pdf"; - } - if (target.source.includes("AHA")) { - return "https://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse"; - } - } - if (target.type === "SLEEP_DURATION") { - return "https://aasm.org/seven-or-more-hours-of-sleep-per-night-a-health-necessity-for-adults/"; - } - if (target.type === "BODY_FAT") { - return "https://www.acefitness.org/resources/everyone/blog/6596/what-are-the-guidelines-for-percentage-of-body-fat-loss/"; - } - if (target.type === "ACTIVITY_STEPS") { - return "https://www.who.int/publications/i/item/9789240015128"; - } - return null; -} - export default function TargetsPage() { const { t } = useTranslations(); const { isAuthenticated, user } = useAuth(); diff --git a/src/components/insights/__tests__/metric-target-summary.test.tsx b/src/components/insights/__tests__/metric-target-summary.test.tsx index 0d3ed5aa4..0ff5bbaf9 100644 --- a/src/components/insights/__tests__/metric-target-summary.test.tsx +++ b/src/components/insights/__tests__/metric-target-summary.test.tsx @@ -4,15 +4,18 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nProvider } from "@/lib/i18n/context"; /** - * v1.8.0 — `` unit tests. + * v1.8.0 → v1.8.5 — `` unit tests. * - * The card surfaces the numeric target range + the in-target share 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 rendering + the slug→type mapping, not - * the live fetch. + * 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. */ vi.mock("@/hooks/use-auth", () => ({ @@ -26,17 +29,31 @@ vi.mock("@tanstack/react-query", () => ({ const { MetricTargetSummary } = await import("../metric-target-summary"); +interface TargetFixtureItem { + type: string; + label: string; + current: number | null; + average30: number | null; + unit: string; + range: { min: number; max: number } | null; + classification: { category: string; color: string } | null; + source: string; + daysInRange7d: number; + daysLogged7d: number; + daysInRange30d: number; + daysLogged30d: number; + insufficientData: boolean; + consistency7d: ReadonlyArray<"in" | "near" | "out" | null>; +} + interface TargetsFixture { - targets: Array<{ - type: string; + targets: TargetFixtureItem[]; + bpDiastolic: { current: number | null; - unit: string; + average30: number | null; range: { min: number; max: number } | null; - daysInRange30d: number; - daysLogged30d: number; - insufficientData: boolean; - }>; - bpDiastolic: { current: number | null; range: { min: number; max: number } | null }; + }; + profile?: { glucoseUnit?: string | null }; } function renderWith(slug: string, data: TargetsFixture | undefined) { @@ -48,34 +65,87 @@ function renderWith(slug: string, data: TargetsFixture | undefined) { ); } +const WEIGHT_ITEM: TargetFixtureItem = { + type: "WEIGHT", + label: "Weight", + current: 72, + average30: 71.4, + unit: "kg", + range: { min: 60.1, max: 80.9 }, + classification: { category: "Normal", color: "#50fa7b" }, + source: "WHO BMI", + daysInRange7d: 5, + daysLogged7d: 7, + daysInRange30d: 18, + daysLogged30d: 24, + insufficientData: false, + consistency7d: ["in", "in", "near", "in", "out", "in", "in"], +}; + const WEIGHT_DATA: TargetsFixture = { - targets: [ - { - type: "WEIGHT", - current: 72, - unit: "kg", - range: { min: 60.1, max: 80.9 }, - daysInRange30d: 18, - daysLogged30d: 24, - insufficientData: false, - }, - ], - bpDiastolic: { current: null, range: null }, + targets: [WEIGHT_ITEM], + bpDiastolic: { current: null, average30: null, range: null }, }; const BP_DATA: TargetsFixture = { targets: [ { type: "BLOOD_PRESSURE", + label: "Blood pressure", current: 124, + average30: 122, unit: "mmHg", range: { min: 120, max: 129 }, + classification: { category: "Optimal", color: "#50fa7b" }, + source: "ESH 2023", + daysInRange7d: 3, + daysLogged7d: 6, daysInRange30d: 10, daysLogged30d: 20, insufficientData: false, + consistency7d: ["in", "near", "in", "out", "in", "near", "in"], }, ], - bpDiastolic: { current: 78, range: { min: 70, max: 79 } }, + bpDiastolic: { current: 78, average30: 76, range: { min: 70, max: 79 } }, +}; + +const GLUCOSE_DATA: TargetsFixture = { + targets: [ + { + type: "BLOOD_GLUCOSE_FASTING", + label: "targets.glucoseFasting", + current: 95, + average30: 92, + unit: "mg/dL", + range: { min: 70, max: 100 }, + classification: { category: "Optimal", color: "#50fa7b" }, + source: "ADA 2024 / DDG", + daysInRange7d: 4, + daysLogged7d: 5, + daysInRange30d: 12, + daysLogged30d: 15, + insufficientData: false, + consistency7d: ["in", "in", "near", "in", "in", "in", "in"], + }, + { + type: "BLOOD_GLUCOSE_POSTPRANDIAL", + label: "targets.glucosePostprandial", + current: 140, + average30: 138, + unit: "mg/dL", + range: { min: 70, max: 140 }, + classification: { category: "Optimal", color: "#50fa7b" }, + source: "ADA 2024 / DDG", + daysInRange7d: 3, + daysLogged7d: 4, + daysInRange30d: 9, + daysLogged30d: 12, + insufficientData: false, + consistency7d: ["in", "near", "in", "in", null, "in", "in"], + }, + ], + bpDiastolic: { current: null, average30: null, range: null }, + profile: { glucoseUnit: "mg/dL" }, }; describe("", () => { @@ -87,10 +157,35 @@ describe("", () => { expect(html).toContain("75% of logged days within target"); }); + it("renders the verbal status pill + the guideline source", () => { + const html = renderWith("weight", WEIGHT_DATA); + expect(html).toContain('data-slot="target-status-pill"'); + // EN label for the "Normal" classification category. + expect(html).toContain("Normal"); + // Source citation + external-link. + expect(html).toContain("Source: WHO BMI"); + // The WHO obesity fact-sheet URL resolves for WEIGHT. + expect(html).toContain( + "who.int/news-room/fact-sheets/detail/obesity-and-overweight", + ); + }); + + it("renders the range bar + the 30-day average + the consistency strip", () => { + const html = renderWith("weight", WEIGHT_DATA); + expect(html).toContain('data-slot="target-range-bar"'); + expect(html).toContain('data-slot="consistency-strip"'); + expect(html).toContain("30-day average: 71.4 kg"); + }); + it("stitches the systolic + diastolic bands for blood pressure", () => { const html = renderWith("blood-pressure", BP_DATA); expect(html).toContain("Target: 120–129 / 70–79 mmHg"); expect(html).toContain("50% of logged days within target"); + // The diastolic range bar renders alongside the systolic one. + const barCount = (html.match(/data-slot="target-range-bar"/g) ?? []).length; + expect(barCount).toBe(2); + // 30-day average stitches the diastolic pair. + expect(html).toContain("30-day average: 122/76 mmHg"); }); it("renders the adjust-target link to /targets", () => { @@ -99,13 +194,14 @@ describe("", () => { expect(html).toContain("Adjust target range"); }); - it("suppresses the share when the route flags insufficient data", () => { + it("suppresses the share + strip when the route flags insufficient data", () => { const html = renderWith("weight", { ...WEIGHT_DATA, - targets: [{ ...WEIGHT_DATA.targets[0], insufficientData: true }], + targets: [{ ...WEIGHT_ITEM, insufficientData: true }], }); expect(html).toContain("Target: 60.1–80.9 kg"); expect(html).not.toContain("of logged days within target"); + expect(html).not.toContain('data-slot="consistency-strip"'); }); it("renders nothing for a slug without a numeric target", () => { @@ -121,8 +217,48 @@ describe("", () => { it("renders nothing when the matching target has no range", () => { const html = renderWith("weight", { ...WEIGHT_DATA, - targets: [{ ...WEIGHT_DATA.targets[0], range: null }], + targets: [{ ...WEIGHT_ITEM, range: null }], }); expect(html).toBe(""); }); + + describe("blood-glucose per-context split", () => { + it("renders one panel per logged glucose context in mg/dL", () => { + const html = renderWith("blood-glucose", GLUCOSE_DATA); + const panelCount = ( + html.match(/data-slot="metric-target-summary"/g) ?? [] + ).length; + expect(panelCount).toBe(2); + // 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"); + // ADA / DDG source surfaces for each panel. + expect(html).toContain("Source: ADA 2024 / DDG"); + // The i18n label keys resolve to EN context headings (rendered + // upper-cased by the heading style but kept as text content). + expect(html).toContain("Glucose — fasting"); + expect(html).toContain("Glucose — post-meal"); + }); + + it("converts the glucose bands to mmol/L when the profile prefers it", () => { + const html = renderWith("blood-glucose", { + ...GLUCOSE_DATA, + 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"); + }); + + it("renders nothing when no glucose context has a band", () => { + const html = renderWith("blood-glucose", { + targets: [], + bpDiastolic: { current: null, average30: null, range: null }, + profile: { glucoseUnit: "mg/dL" }, + }); + expect(html).toBe(""); + }); + }); }); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 2b0f0ad0d..6394144d5 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -15,6 +15,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; import { MetricEmptyState } from "@/components/insights/metric-empty-state"; import { MetricStatStrip } from "@/components/insights/metric-stat-strip"; import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge"; +import { MetricTargetSummary } from "@/components/insights/metric-target-summary"; import { SubPageShell } from "@/components/insights/sub-page-shell"; /** @@ -88,6 +89,13 @@ export interface HealthKitMetricPageProps { * Resolves `insights.subPage.explainer.{Title,Body}`. */ explainerMetric?: string; + /** + * v1.8.5 W5 — when set, renders `` beneath + * the chart. Used by blood glucose, whose per-context ADA / DDG bands + * live on the targets wire but whose page rides this generic scaffold + * rather than a bespoke module. Omit it for metrics without a target. + */ + targetSummarySlug?: string; } export function HealthKitMetricPage({ @@ -104,6 +112,7 @@ export function HealthKitMetricPage({ coachPrefill, valueScale, explainerMetric, + targetSummarySlug, }: HealthKitMetricPageProps) { const { user, isAuthenticated } = useAuth(); const { t } = useTranslations(); @@ -186,6 +195,9 @@ export function HealthKitMetricPage({ userTimezone={user?.timezone} valueScale={valueScale} /> + {targetSummarySlug ? ( + + ) : null} ); diff --git a/src/components/insights/metric-target-summary.tsx b/src/components/insights/metric-target-summary.tsx index 9844d67e9..9508ff34a 100644 --- a/src/components/insights/metric-target-summary.tsx +++ b/src/components/insights/metric-target-summary.tsx @@ -2,14 +2,19 @@ import Link from "next/link"; import { useQuery } from "@tanstack/react-query"; -import { Target } from "lucide-react"; +import { ExternalLink, Target } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; import { useTranslations } from "@/lib/i18n/context"; +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 { getTargetSourceLink } from "@/lib/targets/source-link"; /** - * v1.8.0 — surface the numeric target range + in-target share on each + * v1.8.0 → v1.8.5 — surface the per-metric target reference panel on each * insights category page, directly beneath the chart / assessment. * * The data is the same `/api/insights/targets` payload that powers the @@ -19,22 +24,24 @@ import { useTranslations } from "@/lib/i18n/context"; * cache the `/targets` page then reuses. 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, and the medication / mood / step targets. + * band, the AASM sleep band, the ADA / DDG glucose bands, and the + * medication / mood targets. * - * Each insights slug maps to one target `type`. Blood pressure is the one - * composite case: the route emits the systolic band on the - * `BLOOD_PRESSURE` target and the diastolic band on the top-level - * `bpDiastolic` slot, so we stitch them back into a `S/D` range string. + * v1.8.5 W5 — the panel grew from a one-line range card into a compact + * reference panel that ports the context the `/targets` card carries: * - * The "in target" share reads `daysInRange30d / daysLogged30d` — the - * fraction of logged days over the last 30 whose mean reading landed in - * the green band. We hide the share alone when the route flags - * `insufficientData`; the range stays visible so the band is always - * legible. This matches the `/targets` consistency strip so the two - * surfaces never disagree. + * • Row 1: target range string + `` + source link + * (the "what is the band / what is it based on" answer). + * • Row 2: `` — where today's value sits inside the band + * (the spatial answer). BP renders the second diastolic bar. + * • Row 3: in-target share % + the 30-day average. + * • Row 4: `` — the last seven days at a glance. + * • Footer: the "Adjust target range" link to `/targets`. * - * A discreet "Adjust target range" link routes to `/targets`, which - * stays the canonical place to view / tune the bands. + * Each insights slug maps to one target `type`, except blood glucose + * which maps to up to four per-context cards (fasting / postprandial / + * random / bedtime) — those carry mg/dL canonical values that we convert + * to the user's display unit exactly like the `/targets` page does. */ interface TargetRange { @@ -42,22 +49,38 @@ interface TargetRange { max: number; } +interface TargetClassification { + category: string; + color: string; +} + interface TargetItem { type: string; + label: string; current: number | null; + average30: number | null; unit: string; range: TargetRange | null; + classification: TargetClassification | null; + source: string; + daysInRange7d: number; + daysLogged7d: number; daysInRange30d: number; daysLogged30d: number; insufficientData: boolean; + consistency7d: ReadonlyArray<"in" | "near" | "out" | null>; } interface TargetsResponse { targets: TargetItem[]; bpDiastolic: { current: number | null; + average30: number | null; range: TargetRange | null; }; + profile?: { + glucoseUnit?: string | null; + }; } /** @@ -65,6 +88,10 @@ interface TargetsResponse { * Slugs without a numeric target (HRV, oxygen saturation, body * temperature, active energy, workouts, resting HR) are absent — the * component renders nothing for them, so the sub-page collapses cleanly. + * + * Blood glucose is the one slug that fans out to several target types: + * the route emits one card per logged context, so the summary renders a + * reference panel for each. */ const SLUG_TO_TARGET_TYPE: Record = { "blood-pressure": "BLOOD_PRESSURE", @@ -76,6 +103,14 @@ const SLUG_TO_TARGET_TYPE: Record = { medications: "MEDICATION_COMPLIANCE", }; +/** Glucose context target types, in the route's emission order. */ +const GLUCOSE_TARGET_TYPES = [ + "BLOOD_GLUCOSE_FASTING", + "BLOOD_GLUCOSE_POSTPRANDIAL", + "BLOOD_GLUCOSE_RANDOM", + "BLOOD_GLUCOSE_BEDTIME", +]; + interface MetricTargetSummaryProps { /** Insights category slug, e.g. `"blood-pressure"`. */ slug: string; @@ -90,6 +125,7 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); + const isGlucose = slug === "blood-glucose"; const targetType = SLUG_TO_TARGET_TYPE[slug]; const { data } = useQuery({ @@ -100,23 +136,98 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) { const json = await res.json(); return json.data as TargetsResponse; }, - enabled: isAuthenticated && targetType != null, + enabled: isAuthenticated && (targetType != null || isGlucose), }); // No numeric target for this slug, or the payload hasn't resolved yet. - if (!targetType || !data) return null; + if ((!targetType && !isGlucose) || !data) return null; + + // Glucose: one panel per logged context, converting the canonical + // mg/dL values to the user's display unit exactly like the /targets + // page. The route's per-context labels are i18n keys, so resolve them. + if (isGlucose) { + const displayUnit = resolveGlucoseUnit(data.profile?.glucoseUnit ?? null); + const convert = (v: number | null) => + v == null ? null : convertGlucose(v, displayUnit); + const panels = GLUCOSE_TARGET_TYPES.map((type) => + data.targets.find((entry) => entry.type === type), + ) + .filter((entry): entry is TargetItem => entry != null && entry.range != null) + .map((entry) => ({ + ...entry, + label: t(entry.label), + unit: displayUnit, + current: convert(entry.current), + average30: convert(entry.average30), + range: entry.range + ? { + min: convertGlucose(entry.range.min, displayUnit), + max: convertGlucose(entry.range.max, displayUnit), + } + : null, + })); + + if (panels.length === 0) return null; + + return ( +
+ {panels.map((panel) => ( + + ))} +
+ ); + } const target = data.targets.find((entry) => entry.type === targetType); if (!target || !target.range) return null; + return ( + + ); +} + +interface TargetReferencePanelProps { + target: TargetItem; + bpDiastolic?: TargetsResponse["bpDiastolic"]; + /** Optional sub-heading shown above the panel (used for glucose contexts). */ + heading?: string; +} + +/** + * One compact reference panel: range + status pill + source, a range + * bar (plus a diastolic bar for BP), the in-target share + 30-day + * average, the 7-day consistency strip, and the adjust-target link. + * All fields come from the `/api/insights/targets` payload — nothing is + * recomputed here. + */ +function TargetReferencePanel({ + target, + bpDiastolic, + heading, +}: TargetReferencePanelProps) { + const { t } = useTranslations(); + const { range, unit } = target; + if (!range) return null; + + 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 (targetType === "BLOOD_PRESSURE" && data.bpDiastolic.range) { - const dia = data.bpDiastolic.range; + if (isBp && bpDiastolic?.range) { + const dia = bpDiastolic.range; rangeLabel = t("insights.subPage.target.bpRange", { sysMin: fmt(range.min), sysMax: fmt(range.max), @@ -132,49 +243,138 @@ export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) { }); } + const sourceLink = getTargetSourceLink(target); + // In-target share: fraction of logged days in the green band over the // last 30. Suppressed when the route flagged insufficient data so the // sub-page mirrors the `/targets` strip exactly. - const showShare = - !target.insufficientData && target.daysLogged30d > 0; + const showShare = !target.insufficientData && target.daysLogged30d > 0; const sharePct = showShare ? Math.round((target.daysInRange30d / target.daysLogged30d) * 100) : null; + // 30-day average. BP stitches the diastolic average into the familiar + // `S/D` pair; everything else rounds to one decimal. + let averageLabel: string | null = null; + if (target.average30 != null) { + const avgValue = + isBp && bpDiastolic?.average30 != null + ? `${Math.round(target.average30)}/${Math.round(bpDiastolic.average30)}` + : String(Math.round(target.average30 * 10) / 10); + averageLabel = `${t("targets.average30d")} ${avgValue} ${unit}`; + } + + const showConsistency = + !target.insufficientData && + target.consistency7d != null && + target.consistency7d.length > 0; + return ( - // v1.8.5 W4a — demote the target summary from a full bordered `Card` - // (which carried the primitive's `py-4 md:py-6` shell padding around a - // `py-3` content block — far too much vertical reserve for one line of - // range text) to a compact inline band. The green target glyph, the - // range string, the in-target share, and the "adjust" link all stay; - // only the wrapper shell changes so the block reads as a strip, not a - // card, in the tightened sub-page rhythm.
-
-
- {statusLabel ? ( - - - - - {statusLabel} - - - - {target.range && ( -

- {t("targets.targetRangeValue", { - min: String(target.range.min), - max: String(target.range.max), - unit: target.unit, - })} -

- )} -

- {t("targets.sourceLabel", { source: target.source })} -

-
-
-
+ {target.classification ? ( + ) : null}