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} +
+ ); +}