Merge v1.8.0 Insights release into main

# Conflicts:
#	CHANGELOG.md
#	docs/api/openapi.yaml
#	package.json
This commit is contained in:
Marc-André Bombeck committed 2026-05-31 23:00:08 +02:00
commit 869f13612a
115 files changed
+5736 -2737

No files matched your search

@@ -0,0 +1,151 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider, useTranslations } from "@/lib/i18n/context";
import { MetricExplainer } from "../metric-explainer";
/**
* v1.8.0 — `<MetricExplainer>` unit tests.
*
* The explainer is the `?` glyph next to a metric sub-page heading that
* opens a static "What is X?" read on tap / Enter / Space. Tests run
* through SSR so they don't depend on a browser surface: the trigger
* button is always present, the popover / sheet body mounts only after
* the user activates it. The load-bearing constraints pinned here are
* the a11y contract (real button, labelled, disclosure semantics, 44 px
* hit surface) and that every locale ships the title + body copy.
*/
function ssr(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
/**
* Probe that resolves the explainer keys through the same `t()` calls
* the component uses, so a dropped locale key fails here at the call
* site instead of silently rendering the literal key path on the page.
*/
function KeyProbe({ metric }: { metric: string }) {
const { t } = useTranslations();
return (
<div>
<span data-slot="probe-title">
{t(`insights.subPage.explainer.${metric}Title`)}
</span>
<span data-slot="probe-body">
{t(`insights.subPage.explainer.${metric}Body`)}
</span>
</div>
);
}
// v1.8.0 — English key segments (ADR-0001). Every routed insights
// category page now passes one of these; the explainer copy ships in
// all six locales.
const METRICS = [
"bloodPressure",
"pulse",
"weight",
"bmi",
"sleep",
"mood",
"medications",
"restingHr",
"hrv",
"oxygenSaturation",
"bodyTemperature",
"activeEnergy",
"workouts",
"respiratoryRate",
"bodyWater",
"boneMass",
"fatFreeMass",
"fatMass",
"muscleMass",
"visceralFat",
"leanBodyMass",
"flightsClimbed",
"walkingDistance",
"walkingSteadiness",
"walkingHeartRate",
"walkingAsymmetry",
"doubleSupportTime",
"stepLength",
"walkingSpeed",
"pulseWaveVelocity",
"vascularAge",
"environmentalAudio",
"headphoneAudio",
"audioEvents",
"daylight",
"bloodGlucose",
"skinTemperature",
] as const;
describe("<MetricExplainer>", () => {
it("renders an icon-only trigger button with a metric-specific accessible label (EN)", () => {
const html = ssr(<MetricExplainer metric="bloodPressure" />);
const trigger = html.match(
/<button[^>]*data-slot="metric-explainer-trigger"[^>]*>/,
);
expect(trigger).not.toBeNull();
expect(trigger?.[0]).toContain('aria-label="What is Blood pressure?"');
});
it("lifts the tap target to the 44 px WCAG 2.5.5 floor", () => {
// The glyph stays 14 px but the click surface inflates to 44 px via
// `min-h-11 min-w-11`; the surrounding heading row keeps its stride
// because `-my-3 -mx-2` swallows the extra reach. Pinned so a future
// refactor can't collapse the hit surface back to the visual chip.
const html = ssr(<MetricExplainer metric="pulse" />);
const trigger = html.match(
/<button[^>]*data-slot="metric-explainer-trigger"[^>]*>/,
);
expect(trigger?.[0]).toContain("min-h-11");
expect(trigger?.[0]).toContain("min-w-11");
});
it("threads disclosure semantics onto the trigger button", () => {
const html = ssr(<MetricExplainer metric="hrv" />);
const trigger = html.match(
/<button[^>]*data-slot="metric-explainer-trigger"[^>]*>/,
);
expect(trigger?.[0]).toContain("aria-expanded");
expect(trigger?.[0]).toContain("aria-controls");
});
it("does not paint the popover/sheet body on the initial SSR snapshot", () => {
// Closed-by-default: the body lives in a portal that mounts after
// the user activates the trigger. Pinning the negative keeps a
// future "open on render" refactor from leaking copy into the
// static markup.
const html = ssr(<MetricExplainer metric="weight" />);
expect(html).not.toContain('data-slot="metric-explainer-body"');
expect(html).not.toContain('data-slot="metric-explainer-title"');
});
it("localises the trigger label in German", () => {
const html = ssr(<MetricExplainer metric="sleep" />, "de");
const trigger = html.match(
/<button[^>]*data-slot="metric-explainer-trigger"[^>]*>/,
);
expect(trigger?.[0]).toContain('aria-label="Was ist Schlaf?"');
});
it("ships the title + body copy for every category metric in every locale", () => {
for (const locale of ["en", "de", "fr", "es", "it", "pl"] as const) {
for (const metric of METRICS) {
const html = renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<KeyProbe metric={metric} />
</I18nProvider>,
);
// A missing key surfaces as the literal `insights.subPage…`
// path, which never contains a space.
expect(html).not.toContain(`insights.subPage.explainer.${metric}`);
}
}
});
});
@@ -0,0 +1,128 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.8.0 — `<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.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: vi.fn(() => ({ isAuthenticated: true, user: null })),
}));
const useQueryMock = vi.fn();
vi.mock("@tanstack/react-query", () => ({
useQuery: (opts: unknown) => useQueryMock(opts),
}));
const { MetricTargetSummary } = await import("../metric-target-summary");
interface TargetsFixture {
targets: Array<{
type: string;
current: number | null;
unit: string;
range: { min: number; max: number } | null;
daysInRange30d: number;
daysLogged30d: number;
insufficientData: boolean;
}>;
bpDiastolic: { current: number | null; range: { min: number; max: number } | null };
}
function renderWith(slug: string, data: TargetsFixture | undefined) {
useQueryMock.mockReturnValue({ data });
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MetricTargetSummary slug={slug} />
</I18nProvider>,
);
}
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 },
};
const BP_DATA: TargetsFixture = {
targets: [
{
type: "BLOOD_PRESSURE",
current: 124,
unit: "mmHg",
range: { min: 120, max: 129 },
daysInRange30d: 10,
daysLogged30d: 20,
insufficientData: false,
},
],
bpDiastolic: { current: 78, range: { min: 70, max: 79 } },
};
describe("<MetricTargetSummary>", () => {
it("renders the numeric range + in-target share for a simple metric", () => {
const html = renderWith("weight", WEIGHT_DATA);
// Trailing `.0` trimmed, single-decimal kept.
expect(html).toContain("Target: 60.1–80.9 kg");
// round(18/24*100) = 75
expect(html).toContain("75% of logged days within target");
});
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");
});
it("renders the adjust-target link to /targets", () => {
const html = renderWith("weight", WEIGHT_DATA);
expect(html).toContain('href="/targets"');
expect(html).toContain("Adjust target range");
});
it("suppresses the share when the route flags insufficient data", () => {
const html = renderWith("weight", {
...WEIGHT_DATA,
targets: [{ ...WEIGHT_DATA.targets[0], insufficientData: true }],
});
expect(html).toContain("Target: 60.1–80.9 kg");
expect(html).not.toContain("of logged days within target");
});
it("renders nothing for a slug without a numeric target", () => {
const html = renderWith("hrv", undefined);
expect(html).toBe("");
});
it("renders nothing before the payload resolves", () => {
const html = renderWith("weight", undefined);
expect(html).toBe("");
});
it("renders nothing when the matching target has no range", () => {
const html = renderWith("weight", {
...WEIGHT_DATA,
targets: [{ ...WEIGHT_DATA.targets[0], range: null }],
});
expect(html).toBe("");
});
});
+8 -8
View File
@@ -39,23 +39,23 @@ import type {
* through to `null` and render as a plain row (no link wrap).
*/
const METRIC_HREF: Record<DailyBriefingKeyFinding["sourceMetric"], string | null> = {
bp: "/insights/blutdruck",
weight: "/insights/gewicht",
pulse: "/insights/puls",
mood: "/insights/stimmung",
compliance: "/insights/medikamente",
bp: "/insights/blood-pressure",
weight: "/insights/weight",
pulse: "/insights/pulse",
mood: "/insights/mood",
compliance: "/insights/medications",
// Apple Health / GLP-1 additive metrics — no dedicated sub-page yet.
// They render as static rows; v1.4.28 can wire each as they ship.
hrv: null,
sleep: "/insights/schlaf",
sleep: "/insights/sleep",
resting_hr: null,
steps: null,
active_energy: null,
flights: null,
distance: null,
vo2_max: "/insights/puls",
vo2_max: "/insights/pulse",
body_temp: null,
glp1_plateau: "/insights/medikamente",
glp1_plateau: "/insights/medications",
};
/**
@@ -80,6 +80,12 @@ export interface HealthKitMetricPageProps {
* (identity) so every existing page renders unchanged.
*/
valueScale?: number;
/**
* v1.8.0 — metric key threaded into `<SubPageShell explainerMetric>`
* so the `?` heading glyph opens the static "What is X?" explainer.
* Resolves `insights.subPage.explainer.<explainerMetric>{Title,Body}`.
*/
explainerMetric?: string;
}
export function HealthKitMetricPage({
@@ -95,6 +101,7 @@ export function HealthKitMetricPage({
emptyStateIcon,
coachPrefill,
valueScale,
explainerMetric,
}: HealthKitMetricPageProps) {
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
@@ -115,7 +122,11 @@ export function HealthKitMetricPage({
</Button>
) : null;
return (
<SubPageShell title={title} description={description}>
<SubPageShell
title={title}
description={description}
explainerMetric={explainerMetric}
>
<MetricEmptyState
icon={emptyStateIcon}
title={t(`${i18nPrefix}.emptyState.title`)}
@@ -128,7 +139,11 @@ export function HealthKitMetricPage({
}
return (
<SubPageShell title={title} description={description}>
<SubPageShell
title={title}
description={description}
explainerMetric={explainerMetric}
>
<HealthChartDynamic
chartKey={chartKey}
types={[measurementType]}
+36 -36
View File
@@ -31,7 +31,7 @@ import {
* v1.4.25 W4 — routed tab strip for `/insights`.
*
* Behaviour evolved from the v1.4.25 W3 scroll-anchor version: each pill
* is now a `<Link>` to a routed sub-page (`/insights/blutdruck` …),
* is now a `<Link>` to a routed sub-page (`/insights/blood-pressure` …),
* `usePathname()` decides the active pill, and the strip is mounted in
* `src/app/insights/layout.tsx` so it persists across navigation
* without re-rendering. The CoachDrawer is NOT mounted in this layout —
@@ -116,47 +116,47 @@ const SUB_PAGE_TABS: Record<
{ labelKey: string; metric: InsightMetric }
> = {
// ── vitals ──
blutdruck: {
"blood-pressure": {
labelKey: "insights.navBloodPressure",
metric: "BLOOD_PRESSURE_SYS",
},
puls: { labelKey: "insights.navPulse", metric: "PULSE" },
sauerstoff: {
pulse: { labelKey: "insights.navPulse", metric: "PULSE" },
oxygen: {
labelKey: "insights.navOxygenSaturation",
metric: "OXYGEN_SATURATION",
},
koerpertemperatur: {
"body-temperature": {
labelKey: "insights.navBodyTemperature",
metric: "BODY_TEMPERATURE",
},
atemfrequenz: {
"respiratory-rate": {
labelKey: "insights.navRespiratoryRate",
metric: "RESPIRATORY_RATE",
},
// ── body composition ──
gewicht: { labelKey: "insights.navWeight", metric: "WEIGHT" },
weight: { labelKey: "insights.navWeight", metric: "WEIGHT" },
bmi: { labelKey: "insights.navBmi", metric: "BMI" },
koerperwasser: {
"body-water": {
labelKey: "insights.navTotalBodyWater",
metric: "TOTAL_BODY_WATER",
},
knochenmasse: { labelKey: "insights.navBoneMass", metric: "BONE_MASS" },
"fettfreie-masse": {
"bone-mass": { labelKey: "insights.navBoneMass", metric: "BONE_MASS" },
"fat-free-mass": {
labelKey: "insights.navFatFreeMass",
metric: "FAT_FREE_MASS",
},
fettmasse: { labelKey: "insights.navFatMass", metric: "FAT_MASS" },
muskelmasse: { labelKey: "insights.navMuscleMass", metric: "MUSCLE_MASS" },
viszeralfett: {
"fat-mass": { labelKey: "insights.navFatMass", metric: "FAT_MASS" },
"muscle-mass": { labelKey: "insights.navMuscleMass", metric: "MUSCLE_MASS" },
"visceral-fat": {
labelKey: "insights.navVisceralFat",
metric: "VISCERAL_FAT",
},
magermasse: {
"lean-body-mass": {
labelKey: "insights.navLeanBodyMass",
metric: "LEAN_BODY_MASS",
},
// ── activity ──
"aktive-energie": {
"active-energy": {
labelKey: "insights.navActiveEnergy",
metric: "ACTIVE_ENERGY_BURNED",
},
@@ -164,85 +164,85 @@ const SUB_PAGE_TABS: Record<
// availability helper reads `inputs.hasWorkouts` rather than a
// `summaries[…].count`.
workouts: { labelKey: "insights.navWorkouts", metric: "WORKOUTS" },
stockwerke: {
"flights-climbed": {
labelKey: "insights.navFlightsClimbed",
metric: "FLIGHTS_CLIMBED",
},
gehstrecke: {
"walking-distance": {
labelKey: "insights.navWalkingRunningDistance",
metric: "WALKING_RUNNING_DISTANCE",
},
gangstabilitaet: {
"walking-steadiness": {
labelKey: "insights.navWalkingSteadiness",
metric: "WALKING_STEADINESS",
},
gehpuls: {
"walking-heart-rate": {
labelKey: "insights.navWalkingHeartRateAverage",
metric: "WALKING_HEART_RATE_AVERAGE",
},
gangasymmetrie: {
"walking-asymmetry": {
labelKey: "insights.navWalkingAsymmetry",
metric: "WALKING_ASYMMETRY",
},
doppelstandphase: {
"double-support-time": {
labelKey: "insights.navWalkingDoubleSupport",
metric: "WALKING_DOUBLE_SUPPORT",
},
schrittlaenge: {
"step-length": {
labelKey: "insights.navWalkingStepLength",
metric: "WALKING_STEP_LENGTH",
},
gehgeschwindigkeit: {
"walking-speed": {
labelKey: "insights.navWalkingSpeed",
metric: "WALKING_SPEED",
},
// ── sleep ──
schlaf: { labelKey: "insights.navSleep", metric: "SLEEP_DURATION" },
sleep: { labelKey: "insights.navSleep", metric: "SLEEP_DURATION" },
// ── cardiovascular ──
ruhepuls: {
"resting-pulse": {
labelKey: "insights.navRestingHr",
metric: "RESTING_HEART_RATE",
},
hrv: { labelKey: "insights.navHrv", metric: "HEART_RATE_VARIABILITY" },
pulswellengeschwindigkeit: {
"pulse-wave-velocity": {
labelKey: "insights.navPulseWaveVelocity",
metric: "PULSE_WAVE_VELOCITY",
},
gefaessalter: {
"vascular-age": {
labelKey: "insights.navVascularAge",
metric: "VASCULAR_AGE",
},
// ── hearing ──
laermbelastung: {
"environmental-audio": {
labelKey: "insights.navAudioExposureEnv",
metric: "AUDIO_EXPOSURE_ENV",
},
kopfhoererpegel: {
"headphone-audio": {
labelKey: "insights.navAudioExposureHeadphone",
metric: "AUDIO_EXPOSURE_HEADPHONE",
},
laermereignisse: {
"audio-events": {
labelKey: "insights.navAudioExposureEvent",
metric: "AUDIO_EXPOSURE_EVENT",
},
// ── environment ──
tageslicht: {
daylight: {
labelKey: "insights.navTimeInDaylight",
metric: "TIME_IN_DAYLIGHT",
},
// ── metabolic ──
blutzucker: {
"blood-glucose": {
labelKey: "insights.navBloodGlucose",
metric: "BLOOD_GLUCOSE",
},
hauttemperatur: {
"skin-temperature": {
labelKey: "insights.navSkinTemperature",
metric: "SKIN_TEMPERATURE",
},
// ── mood ──
stimmung: { labelKey: "insights.navMood", metric: "MOOD" },
mood: { labelKey: "insights.navMood", metric: "MOOD" },
// ── events ──
medikamente: { labelKey: "insights.navMedication", metric: "MEDICATION" },
medications: { labelKey: "insights.navMedication", metric: "MEDICATION" },
};
/**
@@ -409,7 +409,7 @@ function InsightsTabStripImpl({
if (tab.kind === "link") {
// The overview pill matches the mother page exactly; the
// sub-page pills match a prefix so future nested routes
// (e.g. `/insights/schlaf/2026-05-11`) still highlight the
// (e.g. `/insights/sleep/2026-05-11`) still highlight the
// parent tab.
const isActive =
tab.href === INSIGHTS_OVERVIEW_PATH
@@ -0,0 +1,123 @@
"use client";
import { useId, useState } from "react";
import { HelpCircle } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* v1.8.0 — per-category "What is X?" explainer.
*
* A question-mark glyph that sits next to a metric sub-page heading and,
* on tap / Enter / Space, surfaces one to two layperson sentences that
* define the metric. The copy is static — there is no network call — so
* the explainer paints instantly and works offline. Each metric resolves
* its title + body off a stable i18n key pair:
*
* insights.subPage.explainer.<metric>Title
* insights.subPage.explainer.<metric>Body
*
* The surface choice mirrors `<HealthScoreDeltaExplainer>`: a popover on
* `md+`, a bottom-sheet on phone-class viewports. Both branches share the
* same body copy so the read is identical regardless of viewport.
*
* a11y: the trigger is a real `<button>` (Enter / Space activate it
* natively) carrying a descriptive `aria-label`, `aria-expanded`, and
* `aria-controls` pointing at the body. The optical glyph stays 14 px so
* it reads at the heading's height, but the hit surface lifts to the
* 44 px WCAG 2.5.5 floor via `min-h-11 min-w-11`; negative margins
* collapse the surrounding row back to its natural stride so the chip
* stays visually small while the reach is full-size.
*/
interface MetricExplainerProps {
/**
* The metric key. Drives the title + body lookup under
* `insights.subPage.explainer.<metric>{Title,Body}`.
*/
metric: string;
/** Optional className forwarded to the trigger button. */
className?: string;
}
export function MetricExplainer({ metric, className }: MetricExplainerProps) {
const { t } = useTranslations();
const isMobile = useIsMobile();
const [open, setOpen] = useState(false);
const bodyId = useId();
const title = t(`insights.subPage.explainer.${metric}Title`);
const body = t(`insights.subPage.explainer.${metric}Body`);
const triggerLabel = t("insights.subPage.explainer.trigger", { title });
const trigger = (
<button
type="button"
data-slot="metric-explainer-trigger"
aria-label={triggerLabel}
aria-expanded={open}
aria-controls={bodyId}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
"-my-3 -mx-2",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
className,
)}
onClick={() => setOpen(true)}
>
<HelpCircle className="h-3.5 w-3.5" aria-hidden="true" />
</button>
);
if (isMobile) {
return (
<>
{trigger}
<ResponsiveSheet open={open} onOpenChange={setOpen} title={title}>
<p
id={bodyId}
data-slot="metric-explainer-body"
className="text-muted-foreground text-sm leading-relaxed"
>
{body}
</p>
</ResponsiveSheet>
</>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
data-slot="metric-explainer-popover"
align="start"
sideOffset={6}
className="max-w-xs space-y-1.5"
>
<p
data-slot="metric-explainer-title"
className="text-foreground text-xs font-semibold"
>
{title}
</p>
<p
id={bodyId}
data-slot="metric-explainer-body"
className="text-muted-foreground text-xs leading-snug"
>
{body}
</p>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,173 @@
"use client";
import Link from "next/link";
import { useQuery } from "@tanstack/react-query";
import { Target } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent } from "@/components/ui/card";
/**
* v1.8.0 — surface the numeric target range + in-target share on each
* insights category page, directly beneath the chart / assessment.
*
* The data is the same `/api/insights/targets` payload that powers the
* `/targets` editing surface — this component reads the shared
* `queryKeys.insightsTargets()` cache, so on a session that has already
* visited `/targets` the read is free, and on a cold sub-page it warms a
* 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.
*
* 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.
*
* 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.
*
* A discreet "Adjust target range" link routes to `/targets`, which
* stays the canonical place to view / tune the bands.
*/
interface TargetRange {
min: number;
max: number;
}
interface TargetItem {
type: string;
current: number | null;
unit: string;
range: TargetRange | null;
daysInRange30d: number;
daysLogged30d: number;
insufficientData: boolean;
}
interface TargetsResponse {
targets: TargetItem[];
bpDiastolic: {
current: number | null;
range: TargetRange | null;
};
}
/**
* Map each insights category slug to the target `type` the route emits.
* 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.
*/
const SLUG_TO_TARGET_TYPE: Record<string, string> = {
"blood-pressure": "BLOOD_PRESSURE",
weight: "WEIGHT",
bmi: "BMI",
pulse: "PULSE",
sleep: "SLEEP_DURATION",
mood: "MOOD_SCORE",
medications: "MEDICATION_COMPLIANCE",
};
interface MetricTargetSummaryProps {
/** Insights category slug, e.g. `"blood-pressure"`. */
slug: string;
}
/** Trim a trailing `.0` so whole-number bands read `120` not `120.0`. */
function fmt(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1);
}
export function MetricTargetSummary({ slug }: MetricTargetSummaryProps) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const targetType = SLUG_TO_TARGET_TYPE[slug];
const { data } = useQuery({
queryKey: queryKeys.insightsTargets(),
queryFn: async () => {
const res = await fetch("/api/insights/targets");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as TargetsResponse;
},
enabled: isAuthenticated && targetType != null,
});
// No numeric target for this slug, or the payload hasn't resolved yet.
if (!targetType || !data) return null;
const target = data.targets.find((entry) => entry.type === targetType);
if (!target || !target.range) return null;
const { range, unit } = target;
// 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;
rangeLabel = t("insights.subPage.target.bpRange", {
sysMin: fmt(range.min),
sysMax: fmt(range.max),
diaMin: fmt(dia.min),
diaMax: fmt(dia.max),
unit,
});
} else {
rangeLabel = t("insights.subPage.target.range", {
min: fmt(range.min),
max: fmt(range.max),
unit,
});
}
// 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 sharePct = showShare
? Math.round((target.daysInRange30d / target.daysLogged30d) * 100)
: null;
return (
<Card data-slot="metric-target-summary">
<CardContent className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-3">
<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>
) : 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>
</CardContent>
</Card>
);
}
@@ -3,6 +3,7 @@
import { useEffect, useRef, type ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { MetricExplainer } from "@/components/insights/metric-explainer";
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
import { cn } from "@/lib/utils";
@@ -34,6 +35,14 @@ export interface SubPageShellProps {
* page does not pass it; sub-pages use it to anchor the surface.
*/
description?: string;
/**
* v1.8.0 — when set, a `?` glyph next to the heading opens a static
* "What is X?" explainer (popover on desktop, bottom-sheet on phones).
* The value is the metric key feeding
* `insights.subPage.explainer.<metric>{Title,Body}`. Omitted on the
* mother page; every routed metric sub-page passes its key.
*/
explainerMetric?: string;
/**
* v1.4.27 MB7 / CF-35 — opt-in programmatic focus on mount.
*
@@ -56,6 +65,7 @@ export function SubPageShell({
title,
badge,
description,
explainerMetric,
focusOnMount = false,
children,
}: SubPageShellProps) {
@@ -99,6 +109,9 @@ export function SubPageShell({
>
{title}
</h1>
{explainerMetric ? (
<MetricExplainer metric={explainerMetric} />
) : null}
{badge ? (
<Badge variant="outline" className="border text-xs">
{badge}
+1 -1
View File
@@ -11,7 +11,7 @@ import {
} from "@/lib/medications/injection-sites";
/**
* v1.4.25 W4d — therapy-timeline view for /insights/medikamente.
* v1.4.25 W4d — therapy-timeline view for /insights/medications.
*
* Renders a chronological list of GLP-1 therapy events: dose changes,
* injections (with site if recorded), inventory events, side-effect tag
@@ -9,7 +9,7 @@ import { cn } from "@/lib/utils";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
/**
* v1.4.25 W16a — VO2 max chart-row card for `/insights/puls`.
* v1.4.25 W16a — VO2 max chart-row card for `/insights/pulse`.
*
* The dashboard already carries an opt-in VO2 max trend tile (W8d.5);
* this is the deeper Insights surface. Renders one HealthChart for the