refactor(ui): promote the compact read-failure row to a primitive

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 14:02:59 +02:00
1 parent 933dfcfdfb
commit b4f4cb8b55
4 files changed
+161 -39

No files matched your search

@@ -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")}
/>
<div
data-slot="vitals-dashboard-error"
role="alert"
className="bg-card border-border text-muted-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("insights.derived.vitals.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refetch()}
data-slot="vitals-dashboard-retry"
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
<QueryErrorRow
slot="vitals-dashboard-error"
retrySlot="vitals-dashboard-retry"
message={t("insights.derived.vitals.loadError")}
onRetry={() => refetch()}
/>
</section>
</div>
);
@@ -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}
>
<div
data-slot="healthkit-metric-error"
role="alert"
className="bg-card border-border text-muted-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("insights.subPage.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refetch()}
data-slot="healthkit-metric-retry"
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
<QueryErrorRow
slot="healthkit-metric-error"
retrySlot="healthkit-metric-retry"
message={t("insights.subPage.loadError")}
onRetry={() => refetch()}
/>
</SubPageShell>
);
}
@@ -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(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<QueryErrorRow>", () => {
it("renders the compact alert row with foreground message text", () => {
const html = ssr(<QueryErrorRow message="Could not load vitals" />);
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 `<QueryErrorCard>`.
expect(html).toContain("p-4");
expect(html).not.toContain("py-10");
});
it("falls back to the generic load-failed copy", () => {
expect(ssr(<QueryErrorRow />)).toContain("Could not load");
});
it("only renders the retry affordance when a handler is wired", () => {
expect(ssr(<QueryErrorRow message="boom" />)).not.toContain("<button");
const withRetry = ssr(<QueryErrorRow message="boom" onRetry={() => {}} />);
expect(withRetry).toContain("<button");
expect(withRetry).toContain("Retry");
expect(withRetry).toContain('data-slot="query-error-row-retry"');
});
it("honours the slot overrides the converted surfaces depend on", () => {
const html = ssr(
<QueryErrorRow
message="boom"
onRetry={() => {}}
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(
<QueryErrorRow message="boom" onRetry={() => {}} retryLabel="Again" />,
),
).toContain("Again");
});
});
+84
View File
@@ -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 `<QueryErrorCard>`.
*
* 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 (
<div
data-slot={slot ?? "query-error-row"}
role="alert"
className={cn(
"bg-card border-border text-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between",
className,
)}
>
<span className="flex items-start gap-2 sm:items-center">
<AlertTriangle
className="text-muted-foreground mt-0.5 size-4 shrink-0 sm:mt-0"
aria-hidden="true"
/>
<span>{message ?? t("common.loadFailed")}</span>
</span>
{onRetry ? (
<Button
type="button"
size="sm"
variant="outline"
onClick={onRetry}
data-slot={retrySlot ?? "query-error-row-retry"}
className="gap-1.5"
>
<RotateCw className="size-3.5" aria-hidden="true" />
<span>{retryLabel ?? t("common.retry")}</span>
</Button>
) : null}
</div>
);
}