mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
5de9ddb0cc
commit
0f7ca61e2f
10 files changed
+844
-269
No files matched your search
@@ -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?"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user