mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
refactor(insights): unify the metric subpage template — target tile, order, stat strip, drop the range row
Make every metric subpage identical by construction in the shared scaffold rather than per-page bespoke. - Target tile matches the other cards: dark `bg-card` background and the `rounded-xl border p-4` spacing the assessment and stat-strip cards use, instead of the old translucent `bg-card/20` panel. - Drop the 7T/30T/90T/1J range row and its "+X% vs previous" delta from every subpage. The chart owns its own window via its in-card range tabs, so the chart query is unaffected. Retire the now-dead `MetricRangeControls`, `MetricRangeDelta`, `TimeRangePills`, `useAnalyticsRange`, the `useInsightsRangePref` persisted-range hook, and `sentimentFromMetricDirection` along with their tests. - Fix the spine order to intro -> chart -> target card -> assessment -> Min/Max/Median/Mittelwert stat strip. The stat strip moves to the foot of the spine, below the assessment. Remove the "Letzte Messung" card and the headline primary tile from subpages; retire `MetricLastMeasurementCard` and `MetricPrimaryTile`. - Blood pressure gains the stat strip it was missing: two stacked systolic / diastolic strips, since a single-series strip would drop the diastolic half. `MetricStatStrip` takes an optional series caption. Update the spine guard to assert the target card precedes the assessment and that the retired range row and last-measurement card appear on no subpage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (cherry picked from commit 80b2a810314b4c0951c0d68c04bffc89003005d6)
This commit is contained in:
1 parent
9cf2b3086a
commit
4ace1b7706
22 files changed
+120
-960
No files matched your search
@@ -3,21 +3,25 @@ import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* v1.12.2 — canonical metric-detail spine guard.
|
||||
* v1.12.4 — canonical metric-detail spine guard.
|
||||
*
|
||||
* v1.12.0 made the AI assessment the LAST content block on every metric
|
||||
* sub-page (primary → stat strip → chart → range → target → forecast →
|
||||
* assessment). The generic `HealthKitMetricPage` scaffold enforces this for
|
||||
* the ~29 HealthKit pages structurally; the six bespoke pages each hand-write
|
||||
* their body, so a future edit can silently slot a card after the assessment
|
||||
* (as `mood` and `medications` did before this release).
|
||||
* The unified subpage spine is: intro → chart → target card → assessment →
|
||||
* stat strip. The assessment is the LAST content block the page hands to
|
||||
* `<SubPageShell>` as `children`; the shell appends the Min/Max/Median/Mean
|
||||
* stat strip below it. The generic `HealthKitMetricPage` scaffold enforces
|
||||
* this for the ~29 HealthKit pages structurally; the bespoke pages each
|
||||
* hand-write their body, so a future edit can silently slot a card after the
|
||||
* assessment (as `mood` and `medications` did before).
|
||||
*
|
||||
* This is a source-level structural guard: the assessment card element must be
|
||||
* the last JSX child of the page's `<SubPageShell>`. Asserting on the source
|
||||
* (rather than rendered markup) keeps the test deterministic — the preceding
|
||||
* blocks (`MetricTargetSummary`, `MoodInsightsSections`, `TherapyTimeline`)
|
||||
* all self-suppress on a data miss, so a render-order assertion would depend
|
||||
* on mocking the whole data layer.
|
||||
* This is a source-level structural guard:
|
||||
* 1. the assessment card element is the last JSX child of `<SubPageShell>`;
|
||||
* 2. the target card (`MetricTargetSummary`) precedes the assessment;
|
||||
* 3. the retired range row (`MetricRangeControls`) and the retired
|
||||
* "Letzte Messung" card (`MetricLastMeasurementCard`) appear nowhere.
|
||||
*
|
||||
* Asserting on the source (rather than rendered markup) keeps the test
|
||||
* deterministic — the preceding blocks all self-suppress on a data miss, so a
|
||||
* render-order assertion would depend on mocking the whole data layer.
|
||||
*/
|
||||
|
||||
const PAGES_DIR = join(process.cwd(), "src", "app", "insights");
|
||||
@@ -80,6 +84,36 @@ describe("bespoke metric-detail spine — assessment is the last block", () => {
|
||||
`${slug}: "${trailing.slice(0, 40)}" renders AFTER the assessment card — ` +
|
||||
`the assessment must be the last block on the canonical spine`,
|
||||
).toBe("");
|
||||
|
||||
// v1.12.4 — the target card precedes the assessment when present.
|
||||
const targetIndex = body.indexOf("<MetricTargetSummary");
|
||||
if (targetIndex > -1) {
|
||||
expect(
|
||||
targetIndex,
|
||||
`${slug}: target card must render before the assessment`,
|
||||
).toBeLessThan(assessmentIndex);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("metric subpages — retired range row + Letzte-Messung are gone", () => {
|
||||
// The whole insights subpage tree (bespoke + the values/scores detail pages).
|
||||
for (const { slug } of BESPOKE_PAGES) {
|
||||
it(`/insights/${slug} carries no range row and no last-measurement card`, () => {
|
||||
const source = readFileSync(join(PAGES_DIR, slug, "page.tsx"), "utf8");
|
||||
// Match a real JSX element start (`<Name ` or `<Name\n` or `<Name/>`),
|
||||
// not the back-ticked references inside explanatory comments
|
||||
// (`<MetricRangeControls>`) that mood / medications keep to document
|
||||
// why the row is intentionally absent.
|
||||
expect(
|
||||
/<MetricRangeControls[\s/]/.test(source),
|
||||
`${slug}: the 7T/30T/90T/1J range row was removed in v1.12.4`,
|
||||
).toBe(false);
|
||||
expect(
|
||||
/<MetricLastMeasurementCard[\s/]/.test(source),
|
||||
`${slug}: the "Letzte Messung" card was removed from subpages in v1.12.4`,
|
||||
).toBe(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -12,8 +12,7 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
@@ -45,8 +44,11 @@ export default function InsightsBlutdruckPage() {
|
||||
|
||||
const { data: analytics, isEmpty } =
|
||||
useInsightsAnalytics("BLOOD_PRESSURE_SYS");
|
||||
const bpLastSeenAt =
|
||||
analytics?.lastSeenByType?.BLOOD_PRESSURE_SYS?.lastSeenAt ?? null;
|
||||
// v1.12.4 — blood pressure is two series, so it stacks two stat strips
|
||||
// (systolic / diastolic) rather than the single-series strip the other
|
||||
// metrics carry. Both summaries ride the same `summaries` slice.
|
||||
const sysSummary = analytics?.summaries?.BLOOD_PRESSURE_SYS ?? null;
|
||||
const diaSummary = analytics?.summaries?.BLOOD_PRESSURE_DIA ?? null;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
@@ -103,14 +105,21 @@ export default function InsightsBlutdruckPage() {
|
||||
description={t("insights.subPage.blutdruckDescription")}
|
||||
explainerMetric="bloodPressure"
|
||||
coachLaunch
|
||||
primary={
|
||||
<>
|
||||
{/* Blood pressure's primary tile IS its richer target panel
|
||||
(ESH classification + sys/dia band + 30-day S/D average),
|
||||
promoted above the chart per the canonical spine. */}
|
||||
<MetricTargetSummary slug="blood-pressure" />
|
||||
<MetricLastMeasurementCard lastSeenAt={bpLastSeenAt} />
|
||||
</>
|
||||
statStrip={
|
||||
<div className="space-y-3" data-slot="bp-stat-strips">
|
||||
<MetricStatStrip
|
||||
summary={sysSummary}
|
||||
unit="mmHg"
|
||||
fractionDigits={0}
|
||||
seriesLabel={t("charts.systolic")}
|
||||
/>
|
||||
<MetricStatStrip
|
||||
summary={diaSummary}
|
||||
unit="mmHg"
|
||||
fractionDigits={0}
|
||||
seriesLabel={t("charts.diastolic")}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
@@ -120,13 +129,7 @@ export default function InsightsBlutdruckPage() {
|
||||
/>
|
||||
}
|
||||
showAllValuesType="BLOOD_PRESSURE_SYS"
|
||||
/* No `statStrip`: `<MetricStatStrip>` is single-series, but blood
|
||||
pressure is two series (systolic + diastolic). A single min / max /
|
||||
median / mean strip would silently drop the diastolic half, so the
|
||||
page omits it rather than mislead. */
|
||||
>
|
||||
{/* The period-over-period delta keys on the systolic series — the
|
||||
canonical BP figure the targets + status surfaces lead with. */}
|
||||
<HealthChartDynamic
|
||||
chartKey="bp"
|
||||
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
|
||||
@@ -138,11 +141,14 @@ export default function InsightsBlutdruckPage() {
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
|
||||
<MetricRangeControls measurementType="BLOOD_PRESSURE_SYS" enabled={!isEmpty} />
|
||||
|
||||
{/* v1.12.0 — Einschätzung is the last block on the canonical
|
||||
metric-detail spine. */}
|
||||
{/* v1.12.4 — target card sits between the chart and the assessment on
|
||||
the canonical spine (ESH classification + sys/dia band + 30-day S/D
|
||||
average). */}
|
||||
<MetricTargetSummary slug="blood-pressure" />
|
||||
|
||||
{/* v1.12.4 — Einschätzung is the last content block; the stat strip
|
||||
(rendered by the shell) closes the spine below it. */}
|
||||
<SlugInsightStatusCard
|
||||
slug="blood-pressure"
|
||||
icon={<HeartPulse className="h-5 w-5" />}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
|
||||
@@ -115,9 +114,7 @@ export default function InsightsBmiPage() {
|
||||
so it has no first-class summary or raw-reading rows of its own. A
|
||||
stat strip would have nothing to read, and a diversity nudge / "show
|
||||
all values" entry would point at WEIGHT and duplicate the weight
|
||||
page's controls.
|
||||
BMI is derived from WEIGHT, so the period-over-period delta keys on
|
||||
the underlying weight series the chart reuses. */}
|
||||
page's controls. */}
|
||||
<HealthChartDynamic
|
||||
chartKey="bmi"
|
||||
types={["WEIGHT"]}
|
||||
@@ -129,8 +126,6 @@ export default function InsightsBmiPage() {
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
|
||||
<MetricRangeControls measurementType="WEIGHT" enabled={!isEmpty} />
|
||||
|
||||
<MetricTargetSummary slug="bmi" />
|
||||
|
||||
|
||||
@@ -12,11 +12,8 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
import { MetricPrimaryTile } from "@/components/insights/metric-primary-tile";
|
||||
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
|
||||
import { MetricCorrelationCard } from "@/components/insights/metric-correlation-card";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row";
|
||||
@@ -52,7 +49,6 @@ export default function InsightsPulsPage() {
|
||||
const { data: analytics, isEmpty } = useInsightsAnalytics("PULSE");
|
||||
const vo2Summary = analytics?.summaries?.VO2_MAX ?? null;
|
||||
const pulseSummary = analytics?.summaries?.PULSE ?? null;
|
||||
const pulseLastSeenAt = analytics?.lastSeenByType?.PULSE?.lastSeenAt ?? null;
|
||||
|
||||
// v1.4.27 F17 — gate the sub-page on at least one pulse observation.
|
||||
// Brand-new accounts (no manual logs, no Apple-Health upload yet)
|
||||
@@ -117,12 +113,6 @@ export default function InsightsPulsPage() {
|
||||
title={t("insights.pulseSectionTitle")}
|
||||
description={t("insights.subPage.pulsDescription")}
|
||||
explainerMetric="pulse"
|
||||
primary={
|
||||
<>
|
||||
<MetricPrimaryTile summary={pulseSummary} unit="bpm" slug="pulse" />
|
||||
<MetricLastMeasurementCard lastSeenAt={pulseLastSeenAt} />
|
||||
</>
|
||||
}
|
||||
statStrip={<MetricStatStrip summary={pulseSummary} unit="bpm" />}
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
@@ -144,8 +134,6 @@ export default function InsightsPulsPage() {
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
|
||||
<MetricRangeControls measurementType="PULSE" enabled={!isEmpty} />
|
||||
|
||||
<MetricTargetSummary slug="pulse" />
|
||||
|
||||
|
||||
@@ -10,8 +10,6 @@ import { Button } from "@/components/ui/button";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricStatusCard } from "@/components/insights/metric-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SleepOverview } from "@/components/insights/sleep-overview";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
@@ -36,10 +34,7 @@ export default function InsightsSchlafPage() {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data: analytics, isEmpty } =
|
||||
useInsightsAnalytics("SLEEP_DURATION");
|
||||
const sleepLastSeenAt =
|
||||
analytics?.lastSeenByType?.SLEEP_DURATION?.lastSeenAt ?? null;
|
||||
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
@@ -71,7 +66,6 @@ export default function InsightsSchlafPage() {
|
||||
description={t("insights.sleep.description")}
|
||||
explainerMetric="sleep"
|
||||
coachLaunch
|
||||
primary={<MetricLastMeasurementCard lastSeenAt={sleepLastSeenAt} />}
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
measurementType="SLEEP_DURATION"
|
||||
@@ -87,9 +81,6 @@ export default function InsightsSchlafPage() {
|
||||
>
|
||||
<SleepOverview />
|
||||
|
||||
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
|
||||
<MetricRangeControls measurementType="SLEEP_DURATION" enabled={!isEmpty} />
|
||||
|
||||
<MetricTargetSummary slug="sleep" />
|
||||
|
||||
{/*
|
||||
|
||||
@@ -12,11 +12,8 @@ import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
import { MetricPrimaryTile } from "@/components/insights/metric-primary-tile";
|
||||
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
|
||||
import { MetricCorrelationCard } from "@/components/insights/metric-correlation-card";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory-forecast-card";
|
||||
@@ -41,8 +38,6 @@ export default function InsightsGewichtPage() {
|
||||
|
||||
const { data: analytics, isEmpty } = useInsightsAnalytics("WEIGHT");
|
||||
const weightSummary = analytics?.summaries?.WEIGHT ?? null;
|
||||
const weightLastSeenAt =
|
||||
analytics?.lastSeenByType?.WEIGHT?.lastSeenAt ?? null;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
@@ -80,12 +75,6 @@ export default function InsightsGewichtPage() {
|
||||
title={t("insights.weightSectionTitle")}
|
||||
description={t("insights.subPage.gewichtDescription")}
|
||||
explainerMetric="weight"
|
||||
primary={
|
||||
<>
|
||||
<MetricPrimaryTile summary={weightSummary} unit="kg" slug="weight" />
|
||||
<MetricLastMeasurementCard lastSeenAt={weightLastSeenAt} />
|
||||
</>
|
||||
}
|
||||
statStrip={<MetricStatStrip summary={weightSummary} unit="kg" />}
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
@@ -107,8 +96,6 @@ export default function InsightsGewichtPage() {
|
||||
compareBaseline={compareBaseline}
|
||||
userTimezone={user?.timezone}
|
||||
/>
|
||||
{/* v1.12.0 — range pills + period-over-period delta below the chart. */}
|
||||
<MetricRangeControls measurementType="WEIGHT" enabled={!isEmpty} />
|
||||
|
||||
<MetricTargetSummary slug="weight" />
|
||||
|
||||
|
||||
@@ -33,13 +33,6 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
|
||||
vi.mock("@/hooks/use-insights-layout-prefs", () => ({
|
||||
useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }),
|
||||
useInsightsRangePref: () => ({ range: "30d", setRange: () => {} }),
|
||||
}));
|
||||
|
||||
// v1.9.0 — the page reads the period-over-period range delta; mock the hook
|
||||
// so the test stays deterministic and never touches `fetch`.
|
||||
vi.mock("@/hooks/use-analytics-range", () => ({
|
||||
useAnalyticsRange: () => ({ data: undefined, isLoading: false }),
|
||||
}));
|
||||
|
||||
const analyticsMock = vi.fn();
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.10.2 — `<MetricRangeControls>` is the shared range-controls block lifted
|
||||
* out of `<HealthKitMetricPage>` so the bespoke metric sub-pages render the
|
||||
* identical time-range pills + period-over-period delta. The component owns
|
||||
* the range pref + range read + direction-sentiment lookup; the test mocks
|
||||
* those hooks so the render stays deterministic and never touches `fetch`.
|
||||
*/
|
||||
|
||||
const rangeMock = vi.fn();
|
||||
vi.mock("@/hooks/use-insights-layout-prefs", () => ({
|
||||
useInsightsRangePref: () => ({ range: "30d", setRange: () => {} }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-analytics-range", () => ({
|
||||
useAnalyticsRange: (...args: unknown[]) => rangeMock(...args),
|
||||
}));
|
||||
|
||||
import { MetricRangeControls } from "../metric-range-controls";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
rangeMock.mockReset();
|
||||
rangeMock.mockReturnValue({ data: undefined, isLoading: false });
|
||||
});
|
||||
|
||||
describe("<MetricRangeControls>", () => {
|
||||
it("renders the time-range pills and the delta slot", () => {
|
||||
const html = render(<MetricRangeControls measurementType="WEIGHT" />);
|
||||
expect(html).toContain('data-slot="metric-range-controls"');
|
||||
expect(html).toContain('data-slot="time-range-pills"');
|
||||
});
|
||||
|
||||
it("passes the measurement type and enabled flag through to the range read", () => {
|
||||
render(<MetricRangeControls measurementType="PULSE" enabled={false} />);
|
||||
expect(rangeMock).toHaveBeenCalledWith("PULSE", "30d", false);
|
||||
});
|
||||
|
||||
it("defaults the range read to enabled", () => {
|
||||
render(<MetricRangeControls measurementType="SLEEP_DURATION" />);
|
||||
expect(rangeMock).toHaveBeenCalledWith("SLEEP_DURATION", "30d", true);
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { MetricRangeDelta } from "../metric-range-delta";
|
||||
import type { AnalyticsRangeData } from "@/hooks/use-analytics-range";
|
||||
|
||||
/**
|
||||
* v1.9.0 QA (Design M1 / Correctness L-1) — a perfectly stable metric
|
||||
* (delta === 0 / rounded 0 %) must read neutral: a flat dash and "no
|
||||
* change", never a down-arrow with "−0%".
|
||||
*/
|
||||
function render(data: AnalyticsRangeData | undefined) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<MetricRangeDelta
|
||||
data={data}
|
||||
range="30d"
|
||||
directionSentiment="up-good"
|
||||
isLoading={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const base: AnalyticsRangeData = {
|
||||
range: "30d",
|
||||
windowDays: 30,
|
||||
granularity: "DAY",
|
||||
current: { count: 1, min: 1, max: 1, mean: 1, sum: 1 },
|
||||
previous: { count: 1, min: 1, max: 1, mean: 1, sum: 1 },
|
||||
delta: 0,
|
||||
deltaPct: 0,
|
||||
};
|
||||
|
||||
describe("MetricRangeDelta — zero-delta neutral", () => {
|
||||
it("renders neutral copy + no directional arrow for a zero delta", () => {
|
||||
const html = render(base);
|
||||
expect(html).toContain('data-direction="neutral"');
|
||||
expect(html).toContain("no change");
|
||||
// No misleading "−0%".
|
||||
expect(html).not.toContain("0%");
|
||||
expect(html).not.toContain("−");
|
||||
});
|
||||
|
||||
it("still renders a directional +pct for a real increase", () => {
|
||||
const html = render({ ...base, delta: 2, deltaPct: 0.1 });
|
||||
expect(html).toContain('data-direction=');
|
||||
expect(html).not.toContain('data-direction="neutral"');
|
||||
expect(html).toContain("+10%");
|
||||
});
|
||||
|
||||
it("shows the no-prior-data line when the comparison is null", () => {
|
||||
const html = render({ ...base, delta: null, deltaPct: null });
|
||||
expect(html).toContain("metric-range-delta-empty");
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { TimeRangePills } from "../time-range-pills";
|
||||
import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta";
|
||||
|
||||
/**
|
||||
* v1.9.0 QA (Design H1) — the range pills are a WAI-ARIA APG radiogroup, so
|
||||
* the group must be a SINGLE tab stop: only the checked radio carries
|
||||
* `tabIndex={0}`, every other radio is `-1` (roving tabindex). Before the
|
||||
* fix all four were native buttons (four tab stops) with no arrow-key
|
||||
* handler. Project convention is SSR-only tests, so we assert the rendered
|
||||
* roving-tabindex contract + radio semantics rather than driving live key
|
||||
* events.
|
||||
*/
|
||||
function render(value: (typeof ANALYTICS_RANGES)[number]) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<TimeRangePills value={value} onChange={() => {}} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TimeRangePills — roving tabindex (single tab stop)", () => {
|
||||
it("renders a radiogroup with one radio per range", () => {
|
||||
const html = render("30d");
|
||||
expect(html).toContain('role="radiogroup"');
|
||||
const radios = html.match(/role="radio"/g) ?? [];
|
||||
expect(radios).toHaveLength(ANALYTICS_RANGES.length);
|
||||
});
|
||||
|
||||
it("makes only the checked radio tabbable (tabindex 0); the rest are -1", () => {
|
||||
const html = render("30d");
|
||||
// Exactly one tabindex=0 (the checked radio is the single tab stop).
|
||||
const tabbable = html.match(/tabindex="0"/g) ?? [];
|
||||
expect(tabbable).toHaveLength(1);
|
||||
// The remaining three are removed from the tab order.
|
||||
const untabbable = html.match(/tabindex="-1"/g) ?? [];
|
||||
expect(untabbable).toHaveLength(ANALYTICS_RANGES.length - 1);
|
||||
});
|
||||
|
||||
it("marks the chosen value selected (aria-checked + data-selected)", () => {
|
||||
const html = render("90d");
|
||||
// Exactly one radio is checked / selected.
|
||||
expect(html.match(/aria-checked="true"/g) ?? []).toHaveLength(1);
|
||||
expect(html.match(/data-selected="true"/g) ?? []).toHaveLength(1);
|
||||
// The chosen pill is present and selected.
|
||||
expect(html).toMatch(/data-range="90d"/);
|
||||
});
|
||||
});
|
||||
@@ -19,11 +19,8 @@ import { TrajectoryForecastCard } from "@/components/insights/derived/trajectory
|
||||
import { isTrajectoryType } from "@/lib/insights/derived/registry";
|
||||
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
|
||||
import { MetricPrimaryTile } from "@/components/insights/metric-primary-tile";
|
||||
import { MetricLastMeasurementCard } from "@/components/insights/metric-last-measurement-card";
|
||||
import { MeasurementDiversityNudge } from "@/components/insights/measurement-diversity-nudge";
|
||||
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
|
||||
import { MetricRangeControls } from "@/components/insights/metric-range-controls";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
|
||||
/**
|
||||
@@ -179,9 +176,6 @@ export function HealthKitMetricPage({
|
||||
}
|
||||
: rawSummary;
|
||||
|
||||
const lastSeenAt =
|
||||
analytics?.lastSeenByType?.[measurementType]?.lastSeenAt ?? null;
|
||||
|
||||
const title = t(`${i18nPrefix}.title`);
|
||||
const description = t(`${i18nPrefix}.description`);
|
||||
|
||||
@@ -216,16 +210,6 @@ export function HealthKitMetricPage({
|
||||
title={title}
|
||||
description={description}
|
||||
explainerMetric={explainerMetric}
|
||||
primary={
|
||||
<>
|
||||
<MetricPrimaryTile
|
||||
summary={summary}
|
||||
unit={yAxisUnit ?? unit}
|
||||
slug={targetSummarySlug}
|
||||
/>
|
||||
<MetricLastMeasurementCard lastSeenAt={lastSeenAt} />
|
||||
</>
|
||||
}
|
||||
statStrip={<MetricStatStrip summary={summary} unit={yAxisUnit ?? unit} />}
|
||||
diversityNudge={
|
||||
<MeasurementDiversityNudge
|
||||
@@ -249,13 +233,6 @@ export function HealthKitMetricPage({
|
||||
userTimezone={user?.timezone}
|
||||
valueScale={valueScale}
|
||||
/>
|
||||
{/* v1.12.0 — time-range pills + period-over-period delta, relocated
|
||||
BELOW the chart (iOS-parity). The pills drive the period-over-period
|
||||
delta read and persist across metrics via `useInsightsRangePref`;
|
||||
the chart owns its own window via its in-card range tabs, so this
|
||||
block sits as a clean bottom-of-card row rather than between the
|
||||
stat strip and the chart. */}
|
||||
<MetricRangeControls measurementType={measurementType} enabled={!isEmpty} />
|
||||
{targetSummarySlug ? (
|
||||
<MetricTargetSummary slug={targetSummarySlug} />
|
||||
) : null}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Clock } from "lucide-react";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
|
||||
/**
|
||||
* v1.12.0 — canonical "Letzte Messung" card.
|
||||
*
|
||||
* The third block on the per-metric detail page (after the primary
|
||||
* tile), mirroring the iOS `InsightsLastMeasurementCard`. It captions
|
||||
* WHEN the most recent reading landed — the date plus a "vor X Tagen"
|
||||
* recency line.
|
||||
*
|
||||
* It deliberately does NOT repeat the latest value: the headline value
|
||||
* lives in `<MetricPrimaryTile>` only (the no-duplicate-info rule). This
|
||||
* card owns the timing dimension; the primary tile owns the value.
|
||||
*
|
||||
* Self-suppressing: renders nothing when no `lastSeenAt` is available
|
||||
* (a brand-new metric, or a metric whose freshness map has no entry),
|
||||
* so a sparse page never carries an empty timing card.
|
||||
*/
|
||||
|
||||
interface MetricLastMeasurementCardProps {
|
||||
/**
|
||||
* ISO timestamp of the latest reading for this metric, read from the
|
||||
* analytics `lastSeenByType[type]?.lastSeenAt` slot. Null / undefined
|
||||
* suppresses the card.
|
||||
*/
|
||||
lastSeenAt: string | null | undefined;
|
||||
}
|
||||
|
||||
export function MetricLastMeasurementCard({
|
||||
lastSeenAt,
|
||||
}: MetricLastMeasurementCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
if (!lastSeenAt) return null;
|
||||
const parsed = new Date(lastSeenAt);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="metric-last-measurement"
|
||||
className="bg-card border-border flex items-center gap-3 rounded-xl border px-4 py-3"
|
||||
>
|
||||
<div className="bg-muted text-muted-foreground flex size-9 shrink-0 items-center justify-center rounded-full">
|
||||
<Clock className="size-4" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p className="text-muted-foreground text-[0.6875rem] font-medium tracking-[0.06em] uppercase">
|
||||
{t("insights.subPage.lastMeasurement.label")}
|
||||
</p>
|
||||
<p className="text-sm font-medium">
|
||||
{fmt.dateWithWeekday(parsed)}
|
||||
<span className="text-muted-foreground ml-2 font-normal">
|
||||
{formatRelativeTime(lastSeenAt, t)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.12.0 — canonical metric primary tile.
|
||||
*
|
||||
* The first data block on every per-metric detail page, mirroring the
|
||||
* iOS `InsightsPrimaryTile`:
|
||||
*
|
||||
* • the headline value — the latest reading (the single place on the
|
||||
* page the headline number appears; the stat strip below carries
|
||||
* min / max / median / mean, not the latest),
|
||||
* • the 30-Tage-Durchschnitt (`summary.avg30`) as a secondary line,
|
||||
* • an "Im Zielbereich" bar — the share of logged days in target over
|
||||
* the last 30, shown ONLY when the metric carries a per-metric pct
|
||||
* (a registered target band feeds `/api/insights/targets`).
|
||||
*
|
||||
* Self-suppressing: renders nothing without a summary or with a
|
||||
* zero-count series (the page empty-state owns that surface). The
|
||||
* in-target bar omits itself when the slug has no numeric target or the
|
||||
* route flagged insufficient data, so a target-less metric (HRV, SpO₂,
|
||||
* temperature, …) reads as a clean headline + 30-day average.
|
||||
*
|
||||
* Blood pressure does NOT use this tile — its richer BP target panel is
|
||||
* its primary tile (the page omits this component and leads with
|
||||
* `<MetricTargetSummary slug="blood-pressure" />`).
|
||||
*/
|
||||
|
||||
interface TargetRange {
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
interface TargetItem {
|
||||
type: string;
|
||||
daysInRange30d: number;
|
||||
daysLogged30d: number;
|
||||
insufficientData: boolean;
|
||||
range: TargetRange | null;
|
||||
}
|
||||
|
||||
interface TargetsResponse {
|
||||
targets: TargetItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Insights slug → target `type`. Mirrors `metric-target-summary.tsx`'s
|
||||
* map for the single-target metrics. Slugs absent from the map (HRV,
|
||||
* SpO₂, temperature, …) carry no in-target pct, so the bar suppresses.
|
||||
*/
|
||||
const SLUG_TO_TARGET_TYPE: Record<string, string> = {
|
||||
weight: "WEIGHT",
|
||||
bmi: "BMI",
|
||||
pulse: "PULSE",
|
||||
sleep: "SLEEP_DURATION",
|
||||
};
|
||||
|
||||
interface MetricPrimaryTileProps {
|
||||
/**
|
||||
* Analytics summary for the page's `MeasurementType`. Null is allowed
|
||||
* so the parent doesn't gate on the network read finishing.
|
||||
*/
|
||||
summary: DataSummary | null;
|
||||
/** Unit suffix rendered next to the value (e.g. `bpm`, `kg`). */
|
||||
unit: string;
|
||||
/**
|
||||
* Insights category slug (e.g. `"weight"`). When the slug maps to a
|
||||
* registered target, the tile reads the in-target pct from
|
||||
* `/api/insights/targets` and paints the "Im Zielbereich" bar. Omit
|
||||
* for metrics with no numeric target — the tile then shows headline +
|
||||
* 30-day average only.
|
||||
*/
|
||||
slug?: string;
|
||||
/** Decimal precision for the formatted values. Defaults to 1. */
|
||||
fractionDigits?: number;
|
||||
}
|
||||
|
||||
export function MetricPrimaryTile({
|
||||
summary,
|
||||
unit,
|
||||
slug,
|
||||
fractionDigits = 1,
|
||||
}: MetricPrimaryTileProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const targetType = slug ? SLUG_TO_TARGET_TYPE[slug] : undefined;
|
||||
|
||||
const { data: targets } = 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,
|
||||
});
|
||||
|
||||
// Nothing to show until the summary lands or for a metric with no
|
||||
// readings — the page's empty-state covers the zero-data case.
|
||||
if (!summary || summary.count <= 0) return null;
|
||||
|
||||
const headline =
|
||||
summary.latest === null
|
||||
? "—"
|
||||
: `${fmt.number(summary.latest, fractionDigits)} ${unit}`;
|
||||
const avg30 =
|
||||
summary.avg30 === null
|
||||
? null
|
||||
: `${fmt.number(summary.avg30, fractionDigits)} ${unit}`;
|
||||
|
||||
// In-target share: fraction of logged days in the green band over the
|
||||
// last 30, read verbatim from the targets route (never recomputed).
|
||||
const target = targetType
|
||||
? targets?.targets.find((entry) => entry.type === targetType)
|
||||
: undefined;
|
||||
const showShare =
|
||||
target != null &&
|
||||
target.range != null &&
|
||||
!target.insufficientData &&
|
||||
target.daysLogged30d > 0;
|
||||
const sharePct = showShare
|
||||
? Math.round((target.daysInRange30d / target.daysLogged30d) * 100)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="metric-primary-tile"
|
||||
className="bg-card border-border space-y-3 rounded-xl border p-4"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<p
|
||||
data-slot="metric-primary-value"
|
||||
className="text-3xl font-semibold tabular-nums"
|
||||
>
|
||||
{headline}
|
||||
</p>
|
||||
{avg30 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.subPage.primary.avg30Label")}: {avg30}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* "Im Zielbereich" bar — share of the last 30 logged days inside
|
||||
the target band. Self-suppresses for target-less metrics and on
|
||||
insufficient data. */}
|
||||
{sharePct != null ? (
|
||||
<div data-slot="metric-primary-in-range" className="space-y-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.subPage.primary.inRangeLabel")}
|
||||
</p>
|
||||
<p className="text-sm font-medium tabular-nums">{sharePct}%</p>
|
||||
</div>
|
||||
<div
|
||||
className="bg-muted h-2 w-full overflow-hidden rounded-full"
|
||||
role="img"
|
||||
aria-label={t("insights.subPage.primary.inRangeAria", {
|
||||
pct: sharePct,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-[width]",
|
||||
sharePct >= 70 ? "bg-dracula-green" : "bg-dracula-orange",
|
||||
)}
|
||||
style={{ width: `${Math.min(100, Math.max(0, sharePct))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useInsightsRangePref } from "@/hooks/use-insights-layout-prefs";
|
||||
import { useAnalyticsRange } from "@/hooks/use-analytics-range";
|
||||
import {
|
||||
getMetricStatusMeta,
|
||||
metricIdForMeasurementType,
|
||||
} from "@/lib/insights/metric-status-registry";
|
||||
import { sentimentFromMetricDirection } from "@/lib/insights/trend-sentiment";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { TimeRangePills } from "@/components/insights/time-range-pills";
|
||||
import { MetricRangeDelta } from "@/components/insights/metric-range-delta";
|
||||
|
||||
/**
|
||||
* v1.10.2 — shared range-controls block (time-range pills +
|
||||
* period-over-period delta) lifted out of `<HealthKitMetricPage>` so the
|
||||
* bespoke metric sub-pages (`weight`, `blood-pressure`, `pulse`, `mood`,
|
||||
* `sleep`, `bmi`, …) render the identical control.
|
||||
*
|
||||
* Before this, the 37 HealthKit pages carried the selector and the 8
|
||||
* hand-rolled pages carried nothing, so navigating from an HK metric to
|
||||
* `weight` made the control vanish. This component is the single source the
|
||||
* HK scaffold and the bespoke pages both consume, wired to the same
|
||||
* persisted range pref (`useInsightsRangePref`) so the choice sticks across
|
||||
* every metric.
|
||||
*
|
||||
* The delta's sentiment colour follows the metric's "good direction" from
|
||||
* the metric-status registry (higher-better → up-good, lower-better →
|
||||
* up-bad, target-band → neutral); a metric absent from the registry falls
|
||||
* back to neutral.
|
||||
*/
|
||||
export function MetricRangeControls({
|
||||
measurementType,
|
||||
enabled = true,
|
||||
}: {
|
||||
/** The MeasurementType the period-over-period read is keyed on. */
|
||||
measurementType: string;
|
||||
/**
|
||||
* Gate the range read. Pages pass `!isEmpty` so a brand-new metric with
|
||||
* no observations never fires the round-trip. Defaults to `true`.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const { range, setRange } = useInsightsRangePref();
|
||||
const { data: rangeData, isLoading: isRangeLoading } = useAnalyticsRange(
|
||||
measurementType,
|
||||
range,
|
||||
enabled,
|
||||
);
|
||||
|
||||
const registryMeta = (() => {
|
||||
const id = metricIdForMeasurementType(measurementType as MeasurementType);
|
||||
return id ? getMetricStatusMeta(id) : null;
|
||||
})();
|
||||
const directionSentiment = registryMeta
|
||||
? sentimentFromMetricDirection(registryMeta.direction)
|
||||
: "neutral";
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="metric-range-controls"
|
||||
className="flex flex-wrap items-center justify-between gap-2"
|
||||
>
|
||||
<TimeRangePills value={range} onChange={setRange} />
|
||||
<MetricRangeDelta
|
||||
data={rangeData}
|
||||
range={range}
|
||||
directionSentiment={directionSentiment}
|
||||
isLoading={isRangeLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowDown, ArrowUp, Minus } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getTrendSentiment,
|
||||
sentimentColorClass,
|
||||
type TrendDirectionSentiment,
|
||||
} from "@/lib/insights/trend-sentiment";
|
||||
import type { AnalyticsRangeData } from "@/hooks/use-analytics-range";
|
||||
import type { AnalyticsRange } from "@/lib/analytics/range-shared";
|
||||
|
||||
/**
|
||||
* v1.9.0 — period-over-period delta caption beside the time-range pills.
|
||||
*
|
||||
* Renders "+3 % vs prior 30d" with the metric-aware sentiment colour the
|
||||
* dashboard tiles use (up-good vs up-bad). When the prior window has no data
|
||||
* it shows a neutral "no prior-period data" line instead of a misleading 0 %
|
||||
* (the `deltaPct` is null in that case). For `target-band` metrics the
|
||||
* sentiment is neutral, so the delta still shows magnitude + direction but
|
||||
* carries no good/bad colour — matching Oura's deviation framing for metrics
|
||||
* with no single good direction.
|
||||
*/
|
||||
export function MetricRangeDelta({
|
||||
data,
|
||||
range,
|
||||
directionSentiment,
|
||||
isLoading,
|
||||
}: {
|
||||
data: AnalyticsRangeData | undefined;
|
||||
range: AnalyticsRange;
|
||||
directionSentiment: TrendDirectionSentiment;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<span
|
||||
data-slot="metric-range-delta-pending"
|
||||
className="bg-muted/60 inline-block h-3.5 w-28 animate-pulse rounded motion-reduce:animate-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const priorLabel = t(`insights.range.priorLabel.${range}`);
|
||||
|
||||
// No comparison available — prior window empty, or current window empty.
|
||||
if (!data || data.delta === null || data.deltaPct === null) {
|
||||
return (
|
||||
<span
|
||||
data-slot="metric-range-delta-empty"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("insights.range.noPriorData")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const direction = getTrendSentiment(data.delta, directionSentiment);
|
||||
const colorClass = sentimentColorClass(direction);
|
||||
const pct = Math.round(Math.abs(data.deltaPct) * 100);
|
||||
|
||||
// A perfectly stable metric (current mean == prior mean, or a rounded
|
||||
// delta of 0 %) reads neutral — a flat dash and "no change", never a
|
||||
// down-arrow with "−0%". The direction arrow is decoupled from the raw
|
||||
// sign so a zero delta never paints a misleading decrease.
|
||||
if (data.delta === 0 || pct === 0) {
|
||||
return (
|
||||
<span
|
||||
data-slot="metric-range-delta"
|
||||
data-direction="neutral"
|
||||
className="text-muted-foreground inline-flex items-center gap-1 text-xs"
|
||||
>
|
||||
<Minus className="h-3 w-3" aria-hidden="true" />
|
||||
<span>
|
||||
{t("insights.range.noChange")} {priorLabel}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const isUp = data.delta > 0;
|
||||
const sign = isUp ? "+" : "−";
|
||||
|
||||
return (
|
||||
<span
|
||||
data-slot="metric-range-delta"
|
||||
data-direction={direction}
|
||||
className={cn("inline-flex items-center gap-1 text-xs", colorClass)}
|
||||
>
|
||||
{isUp ? (
|
||||
<ArrowUp className="h-3 w-3" aria-hidden="true" />
|
||||
) : (
|
||||
<ArrowDown className="h-3 w-3" aria-hidden="true" />
|
||||
)}
|
||||
<span>
|
||||
{sign}
|
||||
{pct}% {priorLabel}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -40,12 +40,20 @@ interface MetricStatStripProps {
|
||||
* metrics, which `Intl.NumberFormat` drops anyway.
|
||||
*/
|
||||
fractionDigits?: number;
|
||||
/**
|
||||
* v1.12.4 — optional series caption rendered above the grid. Single-series
|
||||
* metrics omit it; blood pressure stacks two strips (systolic / diastolic)
|
||||
* and labels each so the unified strip covers both halves rather than
|
||||
* silently dropping one. Also feeds the `aria-label` for the section.
|
||||
*/
|
||||
seriesLabel?: string;
|
||||
}
|
||||
|
||||
export function MetricStatStrip({
|
||||
summary,
|
||||
unit,
|
||||
fractionDigits = 1,
|
||||
seriesLabel,
|
||||
}: MetricStatStripProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -75,10 +83,23 @@ export function MetricStatStrip({
|
||||
return (
|
||||
<section
|
||||
data-slot="metric-stat-strip"
|
||||
aria-label={t("insights.subPage.stats.label")}
|
||||
className="bg-card border-border grid grid-cols-2 gap-3 rounded-xl border p-4 sm:grid-cols-4 [&>div]:min-h-[56px]"
|
||||
aria-label={
|
||||
seriesLabel
|
||||
? `${t("insights.subPage.stats.label")} — ${seriesLabel}`
|
||||
: t("insights.subPage.stats.label")
|
||||
}
|
||||
className="bg-card border-border space-y-2 rounded-xl border p-4"
|
||||
>
|
||||
{cells.map((cell) => (
|
||||
{seriesLabel ? (
|
||||
<p
|
||||
data-slot="metric-stat-strip-series"
|
||||
className="text-muted-foreground text-[10px] font-medium tracking-wide uppercase"
|
||||
>
|
||||
{seriesLabel}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 [&>div]:min-h-[56px]">
|
||||
{cells.map((cell) => (
|
||||
<div
|
||||
key={cell.key}
|
||||
data-slot="metric-stat"
|
||||
@@ -92,7 +113,8 @@ export function MetricStatStrip({
|
||||
{format(cell.value)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -325,7 +325,7 @@ function TargetReferencePanel({
|
||||
<div
|
||||
data-slot="metric-target-summary"
|
||||
data-target-type={target.type}
|
||||
className="border-border/40 bg-card/20 space-y-3 rounded-lg border px-3 py-2.5"
|
||||
className="bg-card border-border space-y-3 rounded-xl border p-4"
|
||||
>
|
||||
{/* 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">
|
||||
|
||||
@@ -80,21 +80,14 @@ export interface SubPageShellProps {
|
||||
*/
|
||||
focusOnMount?: boolean;
|
||||
/**
|
||||
* v1.8.5 W4b — numbers-first stat strip rendered directly beneath the
|
||||
* header, above the chart. Sub-pages pass `<MetricStatStrip metric=… />`
|
||||
* so every metric (including the thin HealthKit pages) leads with
|
||||
* min / max / median / mean. Omitted on the empty-state branch.
|
||||
* v1.12.4 — the Min / Max / Median / Mittelwert stat strip. It now sits
|
||||
* at the FOOT of the canonical spine, below the assessment, so every
|
||||
* metric subpage reads the same: intro → chart → target → assessment →
|
||||
* stat strip. Sub-pages pass `<MetricStatStrip metric=… />`; the strip
|
||||
* self-gates (renders nothing without data), so the empty-state and
|
||||
* brand-new-metric paths stay clean. Omitted on the empty-state branch.
|
||||
*/
|
||||
statStrip?: ReactNode;
|
||||
/**
|
||||
* v1.12.0 — canonical "primary" zone rendered between the header and
|
||||
* the stat strip, the top of the metric-detail spine: the primary tile
|
||||
* (headline value + 30-Tage-Durchschnitt + "Im Zielbereich" bar) and
|
||||
* the "Letzte Messung" card. Each child self-suppresses when its data
|
||||
* is absent, so a sparse metric reads as a clean heading + chart. The
|
||||
* mother page and empty-state branches omit it.
|
||||
*/
|
||||
primary?: ReactNode;
|
||||
/**
|
||||
* v1.8.5 W4b — measurement-diversity nudge.
|
||||
*
|
||||
@@ -135,7 +128,6 @@ export function SubPageShell({
|
||||
explainerMetric,
|
||||
focusOnMount = false,
|
||||
statStrip,
|
||||
primary,
|
||||
diversityNudge,
|
||||
coachLaunch = false,
|
||||
showAllValuesType,
|
||||
@@ -263,19 +255,17 @@ export function SubPageShell({
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
) : null}
|
||||
</header>
|
||||
{/* v1.12.0 — the canonical "primary" zone leads the spine: the
|
||||
primary tile (headline + 30-Tage-Durchschnitt + Im-Zielbereich
|
||||
bar) and the "Letzte Messung" card, both above the stat strip.
|
||||
Each child self-suppresses when its data is absent. */}
|
||||
{primary}
|
||||
{/* v1.8.5 W4b — numbers-first stat strip sits between the header
|
||||
and the chart so the page leads with data, mirroring the Apple
|
||||
Health / Withings detail layout. Self-gating (renders nothing
|
||||
without data), so the spacing rhythm holds on the empty-state
|
||||
and brand-new-metric paths.
|
||||
v1.8.6 — the diversity nudge moved up to the heading row. */}
|
||||
{statStrip}
|
||||
{/* v1.12.4 — the canonical spine body: intro (header, above) →
|
||||
chart → target card → assessment, all rendered by the page as
|
||||
`children`. */}
|
||||
{children}
|
||||
{/* v1.12.4 — the Min / Max / Median / Mittelwert stat strip closes
|
||||
the spine, below the assessment. Self-gating (renders nothing
|
||||
without data), so the spacing rhythm holds on the empty-state and
|
||||
brand-new-metric paths. The diversity nudge rides the heading row;
|
||||
the headline tile and the "Letzte Messung" card are retired so the
|
||||
subpage is identical by construction across every metric. */}
|
||||
{statStrip}
|
||||
{/* v1.8.5 W4b — "show all readings" entry at the foot, linking to
|
||||
the dedicated per-metric values subpage.
|
||||
v1.8.6 — normalised to the `h-10` secondary-button height so it
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, type KeyboardEvent } from "react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ANALYTICS_RANGES,
|
||||
type AnalyticsRange,
|
||||
} from "@/lib/analytics/range-shared";
|
||||
|
||||
/**
|
||||
* v1.9.0 — `7d / 30d / 90d / 1y` segmented control for the Insights metric
|
||||
* pages. The interaction model Apple Health / Oura users already know (a
|
||||
* range selector across the top of a metric chart). Built as an accessible
|
||||
* `radiogroup` of pills over the existing zinc/shadcn primitives rather than
|
||||
* pulling a new Radix dependency — the project's tab strip uses the same
|
||||
* custom-pill approach. The selected range drives the period-over-period
|
||||
* delta caption and (where wired) the chart window.
|
||||
*
|
||||
* Follows the WAI-ARIA APG radiogroup pattern: the group is a single tab
|
||||
* stop (only the checked radio is `tabIndex={0}`, the rest `-1`) and the
|
||||
* arrow keys / Home / End move selection with a roving focus, so a keyboard
|
||||
* user does not tab through all four and the arrow keys are not dead.
|
||||
*/
|
||||
export function TimeRangePills({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
value: AnalyticsRange;
|
||||
onChange: (range: AnalyticsRange) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const radiosRef = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
|
||||
const selectAt = (index: number) => {
|
||||
const next = ANALYTICS_RANGES[index];
|
||||
onChange(next);
|
||||
// Roving focus follows selection so the newly checked radio is focused.
|
||||
radiosRef.current[index]?.focus();
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
const current = ANALYTICS_RANGES.indexOf(value);
|
||||
const last = ANALYTICS_RANGES.length - 1;
|
||||
switch (event.key) {
|
||||
case "ArrowRight":
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
selectAt(current >= last ? 0 : current + 1);
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
selectAt(current <= 0 ? last : current - 1);
|
||||
break;
|
||||
case "Home":
|
||||
event.preventDefault();
|
||||
selectAt(0);
|
||||
break;
|
||||
case "End":
|
||||
event.preventDefault();
|
||||
selectAt(last);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t("insights.range.label")}
|
||||
data-slot="time-range-pills"
|
||||
onKeyDown={onKeyDown}
|
||||
className={cn(
|
||||
"bg-muted/50 inline-flex items-center gap-0.5 rounded-lg p-0.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{ANALYTICS_RANGES.map((range, index) => {
|
||||
const selected = range === value;
|
||||
return (
|
||||
<button
|
||||
key={range}
|
||||
ref={(el) => {
|
||||
radiosRef.current[index] = el;
|
||||
}}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
data-range={range}
|
||||
data-selected={selected}
|
||||
onClick={() => onChange(range)}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring rounded-md px-2.5 py-1 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
selected
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(`insights.range.option.${range}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { AnalyticsRange } from "@/lib/analytics/range-shared";
|
||||
|
||||
/**
|
||||
* v1.9.0 — client reader for the single-metric period-over-period range
|
||||
* read (`GET /api/analytics/range`). One cache slot per `(type, range)` via
|
||||
* the centralised query-key factory, so switching the time-range pills is a
|
||||
* cheap cache hit after first fetch and never collides with the shared
|
||||
* `["analytics", "summaries"]` slot.
|
||||
*/
|
||||
export interface AnalyticsWindowAggregate {
|
||||
count: number;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
mean: number | null;
|
||||
sum: number | null;
|
||||
}
|
||||
|
||||
export interface AnalyticsRangeData {
|
||||
range: AnalyticsRange;
|
||||
windowDays: number;
|
||||
granularity: string;
|
||||
current: AnalyticsWindowAggregate;
|
||||
previous: AnalyticsWindowAggregate;
|
||||
delta: number | null;
|
||||
deltaPct: number | null;
|
||||
}
|
||||
|
||||
export function useAnalyticsRange(
|
||||
type: string,
|
||||
range: AnalyticsRange,
|
||||
enabled = true,
|
||||
) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
return useQuery({
|
||||
queryKey: queryKeys.analyticsRange(type, range),
|
||||
queryFn: async (): Promise<AnalyticsRangeData> => {
|
||||
const res = await fetch(
|
||||
`/api/analytics/range?type=${encodeURIComponent(
|
||||
type,
|
||||
)}&range=${encodeURIComponent(range)}`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to load range");
|
||||
const json = (await res.json()) as { data: AnalyticsRangeData };
|
||||
return json.data;
|
||||
},
|
||||
enabled: isAuthenticated && enabled && type.length > 0,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -9,59 +7,6 @@ import {
|
||||
resolveDashboardLayout,
|
||||
type DashboardLayout,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import {
|
||||
ANALYTICS_RANGES,
|
||||
type AnalyticsRange,
|
||||
} from "@/lib/analytics/range-shared";
|
||||
|
||||
const RANGE_STORAGE_KEY = "healthlog.insights.range";
|
||||
const DEFAULT_RANGE: AnalyticsRange = "30d";
|
||||
|
||||
function isAnalyticsRange(value: string | null): value is AnalyticsRange {
|
||||
return (
|
||||
value !== null && (ANALYTICS_RANGES as readonly string[]).includes(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted range once, lazily. Guarded by `typeof window` so the
|
||||
* server render (where `localStorage` is absent) returns the default rather
|
||||
* than throwing; the first client render then hydrates with the stored value
|
||||
* via the lazy `useState` initializer — no `setState`-in-effect needed.
|
||||
*/
|
||||
function readStoredRange(): AnalyticsRange {
|
||||
if (typeof window === "undefined") return DEFAULT_RANGE;
|
||||
try {
|
||||
const stored = window.localStorage.getItem(RANGE_STORAGE_KEY);
|
||||
return isAnalyticsRange(stored) ? stored : DEFAULT_RANGE;
|
||||
} catch {
|
||||
return DEFAULT_RANGE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.9.0 — persisted time-range choice for the Insights metric pages.
|
||||
*
|
||||
* The choice sticks across metrics (a user who picks `90d` on weight sees
|
||||
* `90d` on HRV) via `localStorage` — the same lightweight client-pref seam
|
||||
* the comparison toggle conceptually lives alongside, without a server
|
||||
* round-trip or a dashboard-layout schema change. Defaults to `30d` so the
|
||||
* existing fixed-window behaviour is the default and nothing regresses.
|
||||
*/
|
||||
export function useInsightsRangePref() {
|
||||
const [range, setRangeState] = useState<AnalyticsRange>(readStoredRange);
|
||||
|
||||
const setRange = useCallback((next: AnalyticsRange) => {
|
||||
setRangeState(next);
|
||||
try {
|
||||
window.localStorage.setItem(RANGE_STORAGE_KEY, next);
|
||||
} catch {
|
||||
// Best-effort persistence; the in-memory state still drives the UI.
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { range, setRange } as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.25 W4 — shared reader for the dashboard-layout payload across
|
||||
|
||||
@@ -53,18 +53,3 @@ export function sentimentColorClass(
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.9.0 — map a `MetricDirection` (the metric-status registry's
|
||||
* `higher-better` / `lower-better` / `target-band`) onto a
|
||||
* `TrendDirectionSentiment` for the range-delta caption. `target-band`
|
||||
* metrics (body temperature, respiratory rate, …) have no single good
|
||||
* direction, so a period-over-period delta carries no sentiment colour —
|
||||
* `neutral`, matching Oura's deviation framing for baseline metrics.
|
||||
*/
|
||||
export function sentimentFromMetricDirection(
|
||||
direction: "higher-better" | "lower-better" | "target-band",
|
||||
): TrendDirectionSentiment {
|
||||
if (direction === "higher-better") return "up-good";
|
||||
if (direction === "lower-better") return "up-bad";
|
||||
return "neutral";
|
||||
}
|
||||
Reference in new issue
Block a user