diff --git a/src/app/insights/__tests__/assessment-spine-order.test.ts b/src/app/insights/__tests__/assessment-spine-order.test.ts index 49e85c895..d73973512 100644 --- a/src/app/insights/__tests__/assessment-spine-order.test.ts +++ b/src/app/insights/__tests__/assessment-spine-order.test.ts @@ -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 + * `` 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 ``. 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 ``; + * 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(" -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 (``), + // not the back-ticked references inside explanatory comments + // (``) that mood / medications keep to document + // why the row is intentionally absent. + expect( + / - {/* 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. */} - - - + statStrip={ +
+ + +
} diversityNudge={ } showAllValuesType="BLOOD_PRESSURE_SYS" - /* No `statStrip`: `` 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. */} - {/* v1.12.0 — range pills + period-over-period delta below the chart. */} - - {/* 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). */} + + + {/* v1.12.4 — Einschätzung is the last content block; the stat strip + (rendered by the shell) closes the spine below it. */} } diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index 7d3788a3d..7bd339b4f 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -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. */} - {/* v1.12.0 — range pills + period-over-period delta below the chart. */} - diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index 50834520c..5ee3b6531 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -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={ - <> - - - - } statStrip={} diversityNudge={ - {/* v1.12.0 — range pills + period-over-period delta below the chart. */} - diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx index 116422bc8..9266068e7 100644 --- a/src/app/insights/sleep/page.tsx +++ b/src/app/insights/sleep/page.tsx @@ -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={} diversityNudge={ - {/* v1.12.0 — range pills + period-over-period delta below the chart. */} - - {/* diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index 391c8de98..afac21922 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -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={ - <> - - - - } statStrip={} diversityNudge={ - {/* v1.12.0 — range pills + period-over-period delta below the chart. */} - diff --git a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx index 5a398339f..d03212622 100644 --- a/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx +++ b/src/components/insights/__tests__/healthkit-metric-page-status.test.tsx @@ -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(); diff --git a/src/components/insights/__tests__/metric-range-controls.test.tsx b/src/components/insights/__tests__/metric-range-controls.test.tsx deleted file mode 100644 index dfd58a797..000000000 --- a/src/components/insights/__tests__/metric-range-controls.test.tsx +++ /dev/null @@ -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 — `` is the shared range-controls block lifted - * out of `` 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( - {node}, - ); -} - -beforeEach(() => { - rangeMock.mockReset(); - rangeMock.mockReturnValue({ data: undefined, isLoading: false }); -}); - -describe("", () => { - it("renders the time-range pills and the delta slot", () => { - const html = render(); - 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(); - expect(rangeMock).toHaveBeenCalledWith("PULSE", "30d", false); - }); - - it("defaults the range read to enabled", () => { - render(); - expect(rangeMock).toHaveBeenCalledWith("SLEEP_DURATION", "30d", true); - }); -}); diff --git a/src/components/insights/__tests__/metric-range-delta.test.tsx b/src/components/insights/__tests__/metric-range-delta.test.tsx deleted file mode 100644 index dd5006a09..000000000 --- a/src/components/insights/__tests__/metric-range-delta.test.tsx +++ /dev/null @@ -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( - - - , - ); -} - -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"); - }); -}); diff --git a/src/components/insights/__tests__/time-range-pills.test.tsx b/src/components/insights/__tests__/time-range-pills.test.tsx deleted file mode 100644 index 0138a7f8e..000000000 --- a/src/components/insights/__tests__/time-range-pills.test.tsx +++ /dev/null @@ -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( - - {}} /> - , - ); -} - -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"/); - }); -}); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index 9e3547344..aa86c5c5c 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -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={ - <> - - - - } statStrip={} diversityNudge={ - {/* 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. */} - {targetSummarySlug ? ( ) : null} diff --git a/src/components/insights/metric-last-measurement-card.tsx b/src/components/insights/metric-last-measurement-card.tsx deleted file mode 100644 index 997383cf5..000000000 --- a/src/components/insights/metric-last-measurement-card.tsx +++ /dev/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 `` 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 ( -
-
-
-
-

- {t("insights.subPage.lastMeasurement.label")} -

-

- {fmt.dateWithWeekday(parsed)} - - {formatRelativeTime(lastSeenAt, t)} - -

-
-
- ); -} diff --git a/src/components/insights/metric-primary-tile.tsx b/src/components/insights/metric-primary-tile.tsx deleted file mode 100644 index 7594ef0dc..000000000 --- a/src/components/insights/metric-primary-tile.tsx +++ /dev/null @@ -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 - * ``). - */ - -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 = { - 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 ( -
-
-

- {headline} -

- {avg30 ? ( -

- {t("insights.subPage.primary.avg30Label")}: {avg30} -

- ) : null} -
- - {/* "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 ? ( -
-
-

- {t("insights.subPage.primary.inRangeLabel")} -

-

{sharePct}%

-
-
-
= 70 ? "bg-dracula-green" : "bg-dracula-orange", - )} - style={{ width: `${Math.min(100, Math.max(0, sharePct))}%` }} - /> -
-
- ) : null} -
- ); -} diff --git a/src/components/insights/metric-range-controls.tsx b/src/components/insights/metric-range-controls.tsx deleted file mode 100644 index 7f1a0ee79..000000000 --- a/src/components/insights/metric-range-controls.tsx +++ /dev/null @@ -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 `` 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 ( -
- - -
- ); -} diff --git a/src/components/insights/metric-range-delta.tsx b/src/components/insights/metric-range-delta.tsx deleted file mode 100644 index 477ce1aea..000000000 --- a/src/components/insights/metric-range-delta.tsx +++ /dev/null @@ -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 ( -