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:
Marc-André Bombeck committed 2026-06-05 10:42:47 +02:00
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);
});
}
});
+28 -22
View File
@@ -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" />}
+1 -6
View File
@@ -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
View File
@@ -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" />
+1 -10
View File
@@ -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" />
{/*
-13
View File
@@ -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>
);
}
+26 -4
View File
@@ -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">
+16 -26
View File
@@ -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>
);
}
-56
View File
@@ -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,
});
}
-55
View File
@@ -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
-15
View File
@@ -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";
}