From b4f4cb8b5531bb3a61579ab0e265526332acfd2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 14:02:59 +0200 Subject: [PATCH] refactor(ui): promote the compact read-failure row to a primitive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HealthKit metric sub-pages and the vitals dashboard had each hand-rolled the same failed-read row, byte for byte. The tall centred error card is the right shape for a list surface but dominates a dense metric strip, so the row stays — as a named primitive rather than a third copy. `QueryErrorRow` carries the shipped shape with one correction: the failure message renders in the foreground tier. An alert is the content of a failed surface, not meta about it, and both copies had it muted. The retry glyph matches its tall sibling. Both converted surfaces pass their existing slot names through, so their hooks are unchanged. --- .../insights/derived/vitals-dashboard.tsx | 28 ++----- .../insights/healthkit-metric-page.tsx | 27 ++---- .../ui/__tests__/query-error-row.test.tsx | 61 ++++++++++++++ src/components/ui/query-error-row.tsx | 84 +++++++++++++++++++ 4 files changed, 161 insertions(+), 39 deletions(-) create mode 100644 src/components/ui/__tests__/query-error-row.test.tsx create mode 100644 src/components/ui/query-error-row.tsx diff --git a/src/components/insights/derived/vitals-dashboard.tsx b/src/components/insights/derived/vitals-dashboard.tsx index a643b3c67..a1bfb06cd 100644 --- a/src/components/insights/derived/vitals-dashboard.tsx +++ b/src/components/insights/derived/vitals-dashboard.tsx @@ -1,9 +1,9 @@ "use client"; import { useMemo, type ReactNode } from "react"; -import { Footprints, Gauge, HeartPulse, RefreshCw, Scale } from "lucide-react"; +import { Footprints, Gauge, HeartPulse, Scale } from "lucide-react"; import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { getUnitForType } from "@/lib/validations/measurement"; @@ -744,24 +744,12 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) { title={t("insights.derived.vitals.sectionTitle")} subtitle={t("insights.derived.vitals.sectionSubtitle")} /> -
- {t("insights.derived.vitals.loadError")} - -
+ refetch()} + /> ); diff --git a/src/components/insights/healthkit-metric-page.tsx b/src/components/insights/healthkit-metric-page.tsx index e551109f1..093cb3129 100644 --- a/src/components/insights/healthkit-metric-page.tsx +++ b/src/components/insights/healthkit-metric-page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import type { ComponentProps, ComponentType, ReactNode } from "react"; -import { RefreshCw, Sparkles } from "lucide-react"; +import { Sparkles } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useUnitDisplay } from "@/hooks/use-unit-display"; @@ -15,6 +15,7 @@ import type { InsightMetric } from "@/lib/insights/metric-availability"; import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; import type { ChartOverlayKey } from "@/lib/dashboard-layout"; import { Button } from "@/components/ui/button"; +import { QueryErrorRow } from "@/components/ui/query-error-row"; import { Card, CardContent } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; @@ -342,24 +343,12 @@ export function HealthKitMetricPage({ description={description} explainerMetric={explainerMetric} > -
- {t("insights.subPage.loadError")} - -
+ refetch()} + /> ); } diff --git a/src/components/ui/__tests__/query-error-row.test.tsx b/src/components/ui/__tests__/query-error-row.test.tsx new file mode 100644 index 000000000..6e30b88dc --- /dev/null +++ b/src/components/ui/__tests__/query-error-row.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { QueryErrorRow } from "../query-error-row"; + +function ssr(node: React.ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +describe("", () => { + it("renders the compact alert row with foreground message text", () => { + const html = ssr(); + expect(html).toContain('role="alert"'); + expect(html).toContain('data-slot="query-error-row"'); + expect(html).toContain("Could not load vitals"); + // §3 — an alert IS the surface's content, so it is foreground. Both + // hand-rolled copies this primitive replaces put it in the muted tier. + expect(html).toContain("text-foreground"); + // The compact row shape, not the tall centred ``. + expect(html).toContain("p-4"); + expect(html).not.toContain("py-10"); + }); + + it("falls back to the generic load-failed copy", () => { + expect(ssr()).toContain("Could not load"); + }); + + it("only renders the retry affordance when a handler is wired", () => { + expect(ssr()).not.toContain(" {}} />); + expect(withRetry).toContain(" { + const html = ssr( + {}} + slot="healthkit-metric-error" + retrySlot="healthkit-metric-retry" + />, + ); + expect(html).toContain('data-slot="healthkit-metric-error"'); + expect(html).toContain('data-slot="healthkit-metric-retry"'); + expect(html).not.toContain('data-slot="query-error-row"'); + }); + + it("takes a custom retry label", () => { + expect( + ssr( + {}} retryLabel="Again" />, + ), + ).toContain("Again"); + }); +}); diff --git a/src/components/ui/query-error-row.tsx b/src/components/ui/query-error-row.tsx new file mode 100644 index 000000000..d278c6c25 --- /dev/null +++ b/src/components/ui/query-error-row.tsx @@ -0,0 +1,84 @@ +"use client"; + +import type { ReactNode } from "react"; +import { AlertTriangle, RotateCw } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { useTranslations } from "@/lib/i18n/context"; +import { cn } from "@/lib/utils"; + +/** + * Read-failure row — the compact sibling of ``. + * + * Two dense metric surfaces (the HealthKit metric sub-pages and the vitals + * dashboard) had each hand-rolled the identical row: a one-line message with + * a Retry button on the same baseline. The tall centred card is the right + * shape for a primary list surface, but on a strip of tiles it dominates the + * geometry and pushes everything the user came for below the fold — so this + * shape stays, promoted to a primitive instead of being copied a third time. + * + * Pick by surface (UI-STANDARDS §6): + * - primary / list surface → `QueryErrorCard` + * - dense metric strip or tile where the tall card would dominate → + * `QueryErrorRow` + * - chart body → `ChartErrorState` + * + * One deliberate change from the copies it replaces: the message renders in + * `text-foreground`. An alert IS the content of a failed surface, and §3 + * reserves muted for meta — both copies had it muted. + */ +export function QueryErrorRow({ + message, + onRetry, + retryLabel, + slot, + retrySlot, + className, +}: { + /** The failure line. Defaults to the generic `common.loadFailed`. */ + message?: ReactNode; + onRetry?: () => void; + retryLabel?: string; + /** + * Overrides the row's `data-slot`. The two converted surfaces keep their + * shipped hooks (`healthkit-metric-error`, `vitals-dashboard-error`) so + * their tests and any e2e selector stay stable. + */ + slot?: string; + /** Same, for the Retry button's own `data-slot`. */ + retrySlot?: string; + className?: string; +}) { + const { t } = useTranslations(); + return ( +
+ + + {onRetry ? ( + + ) : null} +
+ ); +}