mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge v1.8.0 Insights release into main
# Conflicts: # CHANGELOG.md # docs/api/openapi.yaml # package.json
This commit is contained in:
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("");
|
||||
});
|
||||
});
|
||||
@@ -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]}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user