feat(insights): port the target reference panel onto the category pages

Grow the per-category insights target summary from a one-line range card
into a compact reference panel that surfaces the band range, the verbal
status, the guideline source, the in-target share, the 30-day average,
the range bar and the 7-day consistency strip — all from the Targets
data already on the wire, reading the same shared cache. Extract the
status pill and the source-link resolver into shared primitives so the
Targets card and the insights panel can never disagree, and wire blood
glucose into the summary with its per-context split and unit conversion.
This commit is contained in:
Marc-André Bombeck committed 2026-06-01 20:42:40 +02:00
1 parent 5de9ddb0cc
commit 0f7ca61e2f
10 files changed
+844 -269

No files matched your search

+1
View File
@@ -25,6 +25,7 @@ export default function InsightsBlutzuckerPage() {
yAxisUnit="mg/dL"
emptyStateIcon={<Droplet className="size-6" />}
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?"
/>
);
+1 -35
View File
@@ -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();
@@ -4,15 +4,18 @@ import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.8.0 — `<MetricTargetSummary>` unit tests.
* v1.8.0 → v1.8.5 — `<MetricTargetSummary>` 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("<MetricTargetSummary>", () => {
@@ -87,10 +157,35 @@ describe("<MetricTargetSummary>", () => {
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("<MetricTargetSummary>", () => {
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("<MetricTargetSummary>", () => {
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("");
});
});
});
@@ -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.<explainerMetric>{Title,Body}`.
*/
explainerMetric?: string;
/**
* v1.8.5 W5 — when set, renders `<MetricTargetSummary slug=…>` 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 ? (
<MetricTargetSummary slug={targetSummarySlug} />
) : null}
<CoachLaunchButton />
</SubPageShell>
);
+248 -48
View File
@@ -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 + `<TargetStatusPill>` + source link
* (the "what is the band / what is it based on" answer).
* • Row 2: `<RangeBar>` — 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: `<ConsistencyStrip>` — 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<string, string> = {
"blood-pressure": "BLOOD_PRESSURE",
@@ -76,6 +103,14 @@ const SLUG_TO_TARGET_TYPE: Record<string, string> = {
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 (
<div className="space-y-2" data-slot="metric-target-summary-group">
{panels.map((panel) => (
<TargetReferencePanel
key={panel.type}
target={panel}
heading={panel.label}
/>
))}
</div>
);
}
const target = data.targets.find((entry) => entry.type === targetType);
if (!target || !target.range) return null;
return (
<TargetReferencePanel
target={target}
bpDiastolic={
targetType === "BLOOD_PRESSURE" ? data.bpDiastolic : undefined
}
/>
);
}
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.
<div
data-slot="metric-target-summary"
className="bg-muted/30 flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 rounded-lg px-3 py-2"
data-target-type={target.type}
className="bg-muted/30 space-y-3 rounded-lg px-3 py-2.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">
<p className="text-sm font-medium">{rangeLabel}</p>
{sharePct != null ? (
<p className="text-muted-foreground text-xs">
{t("insights.subPage.target.inTargetShare", {
pct: sharePct,
})}
</p>
{/* 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 ? (
<TargetStatusPill
classification={target.classification}
range={range}
unit={unit}
source={target.source}
/>
) : null}
</div>
</div>
<Link
href="/targets"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center rounded-sm px-1 text-xs font-medium underline-offset-4 transition-colors hover:underline focus-visible:ring-2 focus-visible:outline-none"
>
{t("insights.subPage.target.adjustLink")}
</Link>
{/* Row 2: range bar — where today's value sits inside the band. */}
{target.current != null ? (
<RangeBar
value={target.current}
min={range.min}
max={range.max}
unit={unit}
orangeMin={isMedicationCompliance ? 70 : undefined}
orangeMax={isMedicationCompliance ? 100 : undefined}
/>
) : null}
{/* Row 2b: diastolic range bar for blood pressure. */}
{isBp && bpDiastolic?.range && bpDiastolic.current != null ? (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">
{t("targets.diastolic")}
</p>
<RangeBar
value={bpDiastolic.current}
min={bpDiastolic.range.min}
max={bpDiastolic.range.max}
unit="mmHg"
/>
</div>
) : null}
{/* Row 3: in-target share + 30-day average. */}
{sharePct != null || averageLabel ? (
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
{sharePct != null ? (
<span>
{t("insights.subPage.target.inTargetShare", { pct: sharePct })}
</span>
) : null}
{averageLabel ? <span>{averageLabel}</span> : null}
</div>
) : null}
{/* Row 4: 7-day consistency strip. */}
{showConsistency && target.consistency7d ? (
<ConsistencyStrip
days={target.consistency7d}
daysInRange={target.daysInRange7d ?? 0}
daysLogged={target.daysLogged7d ?? 0}
/>
) : null}
{/* Footer: source link + adjust-target link. */}
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 pt-0.5">
{sourceLink ? (
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
<span>{t("targets.sourceLabel", { source: target.source })}</span>
<ExternalLink className="size-3" aria-hidden="true" />
</a>
) : (
<span className="text-muted-foreground text-xs">
{t("targets.sourceLabel", { source: target.source })}
</span>
)}
<Link
href="/targets"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-11 items-center rounded-sm px-1 text-xs font-medium underline-offset-4 transition-colors hover:underline focus-visible:ring-2 focus-visible:outline-none"
>
{t("insights.subPage.target.adjustLink")}
</Link>
</div>
</div>
);
}
@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import {
TargetStatusPill,
statusGroupForCategory,
} from "../target-status-pill";
/**
* v1.8.5 W5 — `<TargetStatusPill>` was extracted from `target-card.tsx`
* so the Targets card and the Insights reference panel render the
* identical pill from the same server-emitted classification category.
* These tests pin the category → semantic-group mapping (which drives
* the pill colour) and the rendered label + range/source tooltip.
*/
function render(props: Parameters<typeof TargetStatusPill>[0]) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<TargetStatusPill {...props} />
</I18nProvider>,
);
}
describe("statusGroupForCategory", () => {
it("paints in-band categories green", () => {
expect(statusGroupForCategory("Normal")).toBe("in");
expect(statusGroupForCategory("Optimal")).toBe("in");
expect(statusGroupForCategory("On target")).toBe("in");
});
it("paints far-out categories red", () => {
expect(statusGroupForCategory("Hypertension Grade 3")).toBe("out");
expect(statusGroupForCategory("Significantly elevated")).toBe("out");
expect(statusGroupForCategory("Tachycardia")).toBe("out");
});
it("falls back to amber for caution + unmapped categories", () => {
expect(statusGroupForCategory("Slightly elevated")).toBe("near");
expect(statusGroupForCategory("Overweight")).toBe("near");
expect(statusGroupForCategory("Totally Unknown Category")).toBe("near");
});
});
describe("<TargetStatusPill>", () => {
it("renders the translated label + the in/out status attribute", () => {
const html = render({
classification: { category: "Optimal", color: "#50fa7b" },
range: { min: 120, max: 129 },
unit: "mmHg",
source: "ESH 2023",
});
expect(html).toContain('data-slot="target-status-pill"');
expect(html).toContain('data-status="in"');
expect(html).toContain("Optimal");
// The tooltip trigger is the pill itself; the range/source content
// lives behind a closed Radix tooltip and is not in static markup.
expect(html).toContain('data-state="closed"');
});
it("renders the raw category when no translation key matches", () => {
const html = render({
classification: { category: "Totally Unknown Category", color: "#fff" },
range: null,
unit: "kg",
source: "Custom",
});
expect(html).toContain("Totally Unknown Category");
expect(html).toContain('data-status="near"');
});
});
+12 -154
View File
@@ -32,6 +32,11 @@ import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { moodStabilityLabel } from "@/lib/targets/mood-stability-label";
import { ConsistencyStrip } from "./consistency-strip";
import { RangeBar } from "./range-bar";
import {
TargetStatusPill,
statusGroupForCategory,
translateStatus,
} from "./target-status-pill";
import { TargetCoachButton } from "./target-coach-button";
import { TargetEditSheet } from "./target-edit-sheet";
import { buildTargetPrompt } from "@/lib/ai/coach/target-prompts";
@@ -96,128 +101,6 @@ const TYPE_COLORS: Record<string, string> = {
BLOOD_GLUCOSE_BEDTIME: "text-dracula-red",
};
const STATUS_CATEGORY_KEY: Record<string, string> = {
Underweight: "underweight",
Normal: "normal",
Overweight: "overweight",
"Obesity Grade I": "obesityGrade1",
"Obesity Grade II": "obesityGrade2",
"Obesity Grade III": "obesityGrade3",
Optimal: "optimal",
"High-normal": "highNormal",
"Hypertension Grade 1": "hypertensionGrade1",
"Hypertension Grade 2": "hypertensionGrade2",
"Hypertension Grade 3": "hypertensionGrade3",
Bradycardia: "bradycardia",
Elevated: "elevated",
Tachycardia: "tachycardia",
"Significantly low": "significantlyLow",
"Slightly low": "slightlyLow",
"On target": "onTarget",
"Slightly elevated": "slightlyElevated",
"Significantly elevated": "significantlyElevated",
"Far too short": "farTooShort",
"Too short": "tooShort",
"Slightly long": "slightlyLong",
"Far too long": "farTooLong",
"Below essential": "belowEssential",
Essential: "essential",
Athletic: "athletic",
Fitness: "fitness",
Acceptable: "acceptable",
Obese: "obese",
"Very low": "veryLow",
"Low active": "lowActive",
"Moderately active": "moderatelyActive",
Active: "active",
"Very active": "veryActive",
Good: "good",
Moderate: "moderate",
Low: "low",
High: "high",
"Very good": "veryGood",
"Very stable": "veryStable",
Stable: "stable",
Fluctuating: "fluctuating",
};
/** Resolve a server-emitted classification.category to its translated label. */
function translateStatus(
category: string,
t: (key: string, vars?: Record<string, string>) => string,
): string {
const key = STATUS_CATEGORY_KEY[category];
if (!key) return category;
return t(`targets.status.${key}`);
}
/**
* Map an in-band classification category to one of three semantic
* groups. Used to drive the status-pill style without re-classifying
* the value from scratch; the server's category is canonical.
*
* "in" = inside the green band ("On target", "Normal", "Optimal", …).
* "near" = inside the orange band ("Slightly elevated", "Overweight",
* …) or a step inside an obesity / hypertension grade-1 band.
* "out" = outside all bands ("Hypertension Grade 3", "Obesity Grade
* III", "Tachycardia" away from the target band, …).
*
* When the category is unmapped we fall back to "near" — the safe
* middle, which paints the pill in the neutral amber tone rather than
* lighting up a false-positive green/red.
*/
function statusGroupForCategory(category: string): "in" | "near" | "out" {
const greenCategories = new Set([
"Normal",
"Optimal",
"On target",
"Good",
"Very good",
"Very stable",
"Stable",
"Athletic",
"Fitness",
"Acceptable",
"Active",
"Very active",
"Moderately active",
"Essential",
]);
const redCategories = new Set([
"Obesity Grade III",
"Hypertension Grade 3",
"Significantly elevated",
"Significantly low",
"Far too short",
"Far too long",
"Tachycardia",
"Bradycardia",
"Low",
"High",
"Fluctuating",
"Below essential",
"Very low",
]);
if (greenCategories.has(category)) return "in";
if (redCategories.has(category)) return "out";
return "near";
}
// Use the Tailwind v4 parenthesised CSS-variable shorthand
// (bg-VAR-PAREN-FORM-/N) instead of the legacy bracketed VAR-form. The
// bracketed form combined with an opacity modifier emits an escaped
// class selector that Turbopack's CSS parser rejects with
// "Unexpected token Delim('.')" because the scanner picks the literal
// string out of source files (including this comment) and emits a CSS
// rule for it. The parenthesised shorthand produces a clean selector
// that parses cleanly. See the Tailwind v4 upgrade guide for the
// migration from bracket-form to paren-form for CSS-variable values.
const STATUS_PILL_STYLES: Record<"in" | "near" | "out", string> = {
in: "bg-(--dracula-green)/12 text-(--dracula-green) ring-(--dracula-green)/30",
near: "bg-(--dracula-orange)/14 text-(--dracula-orange) ring-(--dracula-orange)/30",
out: "bg-(--dracula-red)/12 text-(--dracula-red) ring-(--dracula-red)/30",
};
interface TargetClassification {
category: string;
color: string;
@@ -373,7 +256,6 @@ export function TargetCard({
const statusGroup = target.classification
? statusGroupForCategory(target.classification.category)
: "near";
const statusPillStyle = STATUS_PILL_STYLES[statusGroup];
const statusLabel = target.classification
? translateStatus(target.classification.category, t)
: null;
@@ -452,37 +334,13 @@ export function TargetCard({
</h3>
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
{statusLabel ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
data-slot="target-status-pill"
data-status={statusGroup}
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1",
statusPillStyle,
)}
>
{statusLabel}
</span>
</TooltipTrigger>
<TooltipContent>
{target.range && (
<p className="text-xs">
{t("targets.targetRangeValue", {
min: String(target.range.min),
max: String(target.range.max),
unit: target.unit,
})}
</p>
)}
<p className="text-xs">
{t("targets.sourceLabel", { source: target.source })}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{target.classification ? (
<TargetStatusPill
classification={target.classification}
range={target.range}
unit={target.unit}
source={target.source}
/>
) : null}
<Button
type="button"
@@ -0,0 +1,220 @@
"use client";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.8.5 W5 — `<TargetStatusPill>` extracted from `target-card.tsx`.
*
* The verbal status pill ("Optimal", "Slightly elevated", localized)
* is now a shared primitive so both the Targets card and the Insights
* per-category reference panel render the identical pill from the same
* server-emitted `classification.category`. Single-sourcing the
* category → label → colour mapping here means the two surfaces can
* never disagree on what a band reads or which tone it paints.
*
* Behaviour is byte-identical to the previous inline version in
* `target-card.tsx`: a coloured pill (green in-band / amber caution /
* red out) with a tooltip carrying the target range + guideline source.
*/
interface TargetClassification {
category: string;
color: string;
}
interface TargetRange {
min: number;
max: number;
}
/**
* Server classification category → i18n key under `targets.status.*`.
* The category is canonical (emitted by `/api/insights/targets`); this
* map only resolves it to the localized label.
*/
const STATUS_CATEGORY_KEY: Record<string, string> = {
Underweight: "underweight",
Normal: "normal",
Overweight: "overweight",
"Obesity Grade I": "obesityGrade1",
"Obesity Grade II": "obesityGrade2",
"Obesity Grade III": "obesityGrade3",
Optimal: "optimal",
"High-normal": "highNormal",
"Hypertension Grade 1": "hypertensionGrade1",
"Hypertension Grade 2": "hypertensionGrade2",
"Hypertension Grade 3": "hypertensionGrade3",
Bradycardia: "bradycardia",
Elevated: "elevated",
Tachycardia: "tachycardia",
"Significantly low": "significantlyLow",
"Slightly low": "slightlyLow",
"On target": "onTarget",
"Slightly elevated": "slightlyElevated",
"Significantly elevated": "significantlyElevated",
"Far too short": "farTooShort",
"Too short": "tooShort",
"Slightly long": "slightlyLong",
"Far too long": "farTooLong",
"Below essential": "belowEssential",
Essential: "essential",
Athletic: "athletic",
Fitness: "fitness",
Acceptable: "acceptable",
Obese: "obese",
"Very low": "veryLow",
"Low active": "lowActive",
"Moderately active": "moderatelyActive",
Active: "active",
"Very active": "veryActive",
Good: "good",
Moderate: "moderate",
Low: "low",
High: "high",
"Very good": "veryGood",
"Very stable": "veryStable",
Stable: "stable",
Fluctuating: "fluctuating",
};
/** Resolve a server-emitted classification.category to its translated label. */
export function translateStatus(
category: string,
t: (key: string, vars?: Record<string, string>) => string,
): string {
const key = STATUS_CATEGORY_KEY[category];
if (!key) return category;
return t(`targets.status.${key}`);
}
/**
* Map an in-band classification category to one of three semantic
* groups. Used to drive the status-pill style without re-classifying
* the value from scratch; the server's category is canonical.
*
* "in" = inside the green band ("On target", "Normal", "Optimal", …).
* "near" = inside the orange band ("Slightly elevated", "Overweight",
* …) or a step inside an obesity / hypertension grade-1 band.
* "out" = outside all bands ("Hypertension Grade 3", "Obesity Grade
* III", "Tachycardia" away from the target band, …).
*
* When the category is unmapped we fall back to "near" — the safe
* middle, which paints the pill in the neutral amber tone rather than
* lighting up a false-positive green/red.
*/
export function statusGroupForCategory(
category: string,
): "in" | "near" | "out" {
const greenCategories = new Set([
"Normal",
"Optimal",
"On target",
"Good",
"Very good",
"Very stable",
"Stable",
"Athletic",
"Fitness",
"Acceptable",
"Active",
"Very active",
"Moderately active",
"Essential",
]);
const redCategories = new Set([
"Obesity Grade III",
"Hypertension Grade 3",
"Significantly elevated",
"Significantly low",
"Far too short",
"Far too long",
"Tachycardia",
"Bradycardia",
"Low",
"High",
"Fluctuating",
"Below essential",
"Very low",
]);
if (greenCategories.has(category)) return "in";
if (redCategories.has(category)) return "out";
return "near";
}
// Use the Tailwind v4 parenthesised CSS-variable shorthand
// (bg-VAR-PAREN-FORM-/N) instead of the legacy bracketed VAR-form. The
// bracketed form combined with an opacity modifier emits an escaped
// class selector that Turbopack's CSS parser rejects with
// "Unexpected token Delim('.')" because the scanner picks the literal
// string out of source files (including this comment) and emits a CSS
// rule for it. The parenthesised shorthand produces a clean selector
// that parses cleanly. See the Tailwind v4 upgrade guide for the
// migration from bracket-form to paren-form for CSS-variable values.
export const STATUS_PILL_STYLES: Record<"in" | "near" | "out", string> = {
in: "bg-(--dracula-green)/12 text-(--dracula-green) ring-(--dracula-green)/30",
near: "bg-(--dracula-orange)/14 text-(--dracula-orange) ring-(--dracula-orange)/30",
out: "bg-(--dracula-red)/12 text-(--dracula-red) ring-(--dracula-red)/30",
};
export interface TargetStatusPillProps {
classification: TargetClassification;
range: TargetRange | null;
unit: string;
source: string;
}
/**
* Verbal status pill with a tooltip carrying the target range + the
* guideline source. The pill colour is derived from the classification
* category's semantic group; unmapped categories paint amber.
*/
export function TargetStatusPill({
classification,
range,
unit,
source,
}: TargetStatusPillProps) {
const { t } = useTranslations();
const statusGroup = statusGroupForCategory(classification.category);
const statusPillStyle = STATUS_PILL_STYLES[statusGroup];
const statusLabel = translateStatus(classification.category, t);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
data-slot="target-status-pill"
data-status={statusGroup}
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1",
statusPillStyle,
)}
>
{statusLabel}
</span>
</TooltipTrigger>
<TooltipContent>
{range && (
<p className="text-xs">
{t("targets.targetRangeValue", {
min: String(range.min),
max: String(range.max),
unit,
})}
</p>
)}
<p className="text-xs">{t("targets.sourceLabel", { source })}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import { getTargetSourceLink } from "../source-link";
/**
* v1.8.5 W5 — `getTargetSourceLink` was extracted from the Targets page
* so both the Targets card and the Insights reference panel resolve the
* identical guideline-citation URL. These tests pin the type → URL map
* and the pulse source-substring branching.
*/
describe("getTargetSourceLink", () => {
it("maps weight + BMI to the WHO obesity fact-sheet", () => {
const url =
"https://www.who.int/news-room/fact-sheets/detail/obesity-and-overweight";
expect(getTargetSourceLink({ type: "WEIGHT", source: "WHO BMI" })).toBe(url);
expect(getTargetSourceLink({ type: "BMI", source: "WHO BMI" })).toBe(url);
});
it("maps blood pressure (and the in-target derivative) to the ESH paper", () => {
const url =
"https://academic.oup.com/eurheartj/article/39/33/3021/5079119";
expect(
getTargetSourceLink({ type: "BLOOD_PRESSURE", source: "ESH 2023" }),
).toBe(url);
expect(
getTargetSourceLink({
type: "BLOOD_PRESSURE_IN_TARGET",
source: "ESH 2023",
}),
).toBe(url);
});
it("branches the pulse link on the source substring", () => {
expect(
getTargetSourceLink({ type: "PULSE", source: "CDC/NCHS 2011" }),
).toBe("https://www.cdc.gov/nchs/data/nhsr/nhsr041.pdf");
expect(getTargetSourceLink({ type: "PULSE", source: "AHA" })).toBe(
"https://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse",
);
// An unrecognised pulse source resolves to no link rather than a wrong one.
expect(getTargetSourceLink({ type: "PULSE", source: "Karvonen" })).toBe(
null,
);
});
it("maps sleep, body fat, and steps to their respective guidelines", () => {
expect(
getTargetSourceLink({ type: "SLEEP_DURATION", source: "AASM" }),
).toContain("aasm.org");
expect(getTargetSourceLink({ type: "BODY_FAT", source: "ACE" })).toContain(
"acefitness.org",
);
expect(
getTargetSourceLink({ type: "ACTIVITY_STEPS", source: "WHO" }),
).toContain("who.int/publications");
});
it("returns null for a type without a guideline citation", () => {
expect(
getTargetSourceLink({ type: "MEDICATION_COMPLIANCE", source: "—" }),
).toBe(null);
expect(
getTargetSourceLink({ type: "BLOOD_GLUCOSE_FASTING", source: "ADA" }),
).toBe(null);
});
});
+44
View File
@@ -0,0 +1,44 @@
/**
* v1.8.5 W5 — `getTargetSourceLink` extracted from
* `src/app/targets/page.tsx`.
*
* Resolves the guideline-source citation URL for a target, keyed off
* the target `type` and (for pulse) substrings of the `source` label.
* Pure function with no React / page coupling so both the Targets page
* and the Insights per-category reference panel resolve the identical
* URL — the citation link can never drift between the two surfaces.
*/
export interface TargetSourceLinkInput {
type: string;
source: string;
}
export function getTargetSourceLink(target: TargetSourceLinkInput): 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;
}