fix(ui): let the chart error card decide from the error too

ChartErrorState took no error, so a chart whose read was refused or
whose session had ended offered a retry that could not help. It now
takes the error and branches the way QueryErrorCard and QueryErrorRow
do, the chunk boundary hands it the error it caught, and the error-prop
guard covers it.

QueryErrorRow gains an inset form for a failure inside a card body, and
its frame takes the md:p-6 step. QueryErrorCard drops its fixed px-6 for
the card's own inset and both icons move onto the size scale.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 10:10:31 +02:00
1 parent 6e54f33901
commit 3fbd16a0a6
9 files changed
+91 -36

No files matched your search

@@ -1,7 +1,7 @@
/**
* Every read-failure primitive is handed the error it reports.
*
* `QueryErrorRow` and `QueryErrorCard` decide what a failed read offers from
* `QueryErrorRow`, `QueryErrorCard` and `ChartErrorState` decide what a failed read offers from
* the error itself: nothing (or the surface's refused state) for a 403, a
* note without a retry for a 429, sign-in for a 401, a retry otherwise. A
* call site that renders one without the error falls back to "failed" for
@@ -49,7 +49,7 @@ function walk(
/** The opening tag's attribute text, from the name to its closing `>`. */
function openingTags(source: string): string[] {
const tags: string[] = [];
const open = /<(QueryErrorRow|QueryErrorCard)(?=[\s/>])/g;
const open = /<(QueryErrorRow|QueryErrorCard|ChartErrorState)(?=[\s/>])/g;
for (let m = open.exec(source); m; m = open.exec(source)) {
let depth = 0;
let i = m.index + m[0].length;
@@ -130,16 +130,32 @@ describe("<ChartErrorState> — announcement, sizing, accessible name", () => {
props: Partial<import("../chart-error-state").ChartErrorStateProps> = {},
) {
const { ChartErrorState } = await import("../chart-error-state");
const { I18nProvider } = await import("@/lib/i18n/context");
return renderToStaticMarkup(
<ChartErrorState
title="Data could not be loaded"
actionLabel="Retry"
onAction={() => undefined}
{...props}
/>,
<I18nProvider initialLocale="en">
<ChartErrorState
error={new Error("boom")}
title="Data could not be loaded"
actionLabel="Retry"
onAction={() => undefined}
{...props}
/>
</I18nProvider>,
);
}
it("decides from the error, like the other read-failure primitives", async () => {
const { ApiError } = await import("@/lib/api/api-fetch");
// A refused read draws nothing, not a retry that cannot help.
expect(await renderState({ error: new ApiError("x", 403) })).toBe("");
const ended = await renderState({ error: new ApiError("x", 401) });
expect(ended).toContain('data-slot="query-error-sign-in"');
expect(ended).not.toContain("Retry");
const throttled = await renderState({ error: new ApiError("x", 429) });
expect(throttled).not.toContain("<button");
expect(await renderState()).toContain("Retry");
});
it("announces the failure as a status by default", async () => {
const html = await renderState();
expect(html).toContain('role="status"');
+47 -21
View File
@@ -4,6 +4,7 @@ import { Component, type ReactElement, type ReactNode } from "react";
import { AlertTriangle, RotateCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useReadFailure } from "@/hooks/use-read-failure";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -22,9 +23,18 @@ import { cn } from "@/lib/utils";
* reflows — with a compact action button. `<ChartErrorBoundary>` wraps
* each lazy chart mount so a failed chunk degrades to one card with a
* reload affordance instead of taking the page down.
*
* Like `QueryErrorCard` and `QueryErrorRow`, the card reads the failure
* itself (`error`): a refusal draws `refused` (nothing by default), a spent
* budget says so without an action, an ended session offers sign-in, and only
* a real failure offers `onAction`.
*/
export interface ChartErrorStateProps {
/** The failure. It decides what the card offers. */
error: unknown;
/** What a refused (403) read draws instead of the card. */
refused?: ReactNode;
/** Translated headline, e.g. "Data could not be loaded". */
title: string;
/** Translated action-button label, e.g. "Retry". */
@@ -54,6 +64,8 @@ export interface ChartErrorStateProps {
}
export function ChartErrorState({
error,
refused = null,
title,
actionLabel,
onAction,
@@ -61,6 +73,10 @@ export function ChartErrorState({
actionContext,
role = "status",
}: ChartErrorStateProps): ReactElement {
const failure = useReadFailure(error, { message: title, onRetry: onAction });
if (!failure) return <>{refused}</>;
const { action } = failure;
const label = action?.kind === "retry" ? actionLabel : action?.label;
return (
<div
data-slot="chart-error-state"
@@ -73,42 +89,48 @@ export function ChartErrorState({
style={height === undefined ? undefined : { height }}
>
<AlertTriangle
className="text-muted-foreground h-8 w-8"
className="text-muted-foreground size-6"
aria-hidden="true"
/>
<p className="text-sm font-medium">{title}</p>
<Button
variant="outline"
size="sm"
onClick={onAction}
aria-label={
actionContext ? `${actionLabel}: ${actionContext}` : undefined
}
>
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
{actionLabel}
</Button>
<p className="text-sm font-medium">{failure.message}</p>
{action ? (
<Button
variant="outline"
size="sm"
onClick={action.onClick}
data-slot={
action.kind === "signIn" ? "query-error-sign-in" : undefined
}
aria-label={actionContext ? `${label}: ${actionContext}` : undefined}
>
{action.kind === "retry" ? (
<RotateCw className="size-3.5" aria-hidden="true" />
) : null}
{label}
</Button>
) : null}
</div>
);
}
interface BoundaryProps {
fallback: ReactNode;
/** Drawn once caught; a function receives the caught error. */
fallback: ReactNode | ((error: Error) => ReactNode);
children: ReactNode;
/** Called once the boundary has caught, after the fallback commits. */
onError?: (error: Error) => void;
}
interface BoundaryState {
failed: boolean;
error: Error | null;
}
/** Exported for its unit test; mount `<ChartErrorBoundary>` instead. */
export class ChartBoundary extends Component<BoundaryProps, BoundaryState> {
state: BoundaryState = { failed: false };
state: BoundaryState = { error: null };
static getDerivedStateFromError(): BoundaryState {
return { failed: true };
static getDerivedStateFromError(error: Error): BoundaryState {
return { error };
}
componentDidCatch(error: Error): void {
@@ -125,7 +147,10 @@ export class ChartBoundary extends Component<BoundaryProps, BoundaryState> {
}
render(): ReactNode {
return this.state.failed ? this.props.fallback : this.props.children;
const { error } = this.state;
if (!error) return this.props.children;
const { fallback } = this.props;
return typeof fallback === "function" ? fallback(error) : fallback;
}
}
@@ -152,7 +177,7 @@ export function ChartErrorBoundary({
return (
<ChartBoundary
onError={onError}
fallback={
fallback={(error) => (
// Wrapped in the chart-card shell so a chunk failure renders
// as one solid card among the other cards, not a bare dashed
// box; `role="alert"` because the whole chart is gone, not
@@ -162,13 +187,14 @@ export function ChartErrorBoundary({
className="bg-card border-border rounded-xl border p-4 md:p-6"
>
<ChartErrorState
error={error}
title={t("charts.loadErrorTitle")}
actionLabel={t("charts.loadErrorReload")}
onAction={() => window.location.reload()}
role="alert"
/>
</div>
}
)}
>
{children}
</ChartBoundary>
+4 -1
View File
@@ -820,7 +820,7 @@ export function HealthChart({
);
}, [preloadedSeries, preloadedCoverageDays, fetchWindow.windowDays, types]);
const { data, isLoading, isError, isPlaceholderData, refetch } = useQuery({
const chartQuery = useQuery({
// A range-tab change re-keys the query; without placeholder data every
// switch dropped the painted chart to a skeleton for the fetch round-trip.
// Keeping the previous range's series visible is only honest because the
@@ -1096,6 +1096,8 @@ export function HealthChart({
),
enabled: isAuthenticated,
});
const { data, isLoading, isError, error, isPlaceholderData, refetch } =
chartQuery;
// v1.16.0 — report the settled data query to the dashboard's shared
// reveal gate. `isLoading` only covers the INITIAL fetch (a later
@@ -1921,6 +1923,7 @@ export function HealthChart({
// Pre-fix `isError` fell through to the empty branch and an
// outage read as "you have no measurements".
<ChartErrorState
error={error}
title={t("charts.errorTitle")}
actionLabel={t("common.retry")}
actionContext={title}
@@ -270,7 +270,7 @@ export function MedicationComplianceChart({
// `["dashboard-medication-compliance"]` lands in
// `medicationDependentKeys` and an intake POST refreshes the chart
// immediately rather than waiting for `staleTime` (audit L4).
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.dashboardMedicationCompliance(days),
queryFn: async (): Promise<DailyCompliancePoint[]> => {
return apiGet<DailyCompliancePoint[]>(
@@ -548,6 +548,7 @@ export function MedicationComplianceChart({
// through to the empty branch (data undefined → hasData false)
// and an outage read as "no doses tracked".
<ChartErrorState
error={error}
title={t("charts.errorTitle")}
actionLabel={t("common.retry")}
actionContext={displayTitle}
+2 -1
View File
@@ -406,7 +406,7 @@ export function MoodChart({
// cache fresh; the chart's own subscription becomes the cache writer
// for any insights / mood sub-page that mounts the chart without the
// dashboard parent having fetched first.
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.moodAnalytics(),
queryFn: async (): Promise<MoodAnalyticsData> => {
return apiGet<MoodAnalyticsData>("/api/mood/analytics");
@@ -756,6 +756,7 @@ export function MoodChart({
// v1.16.8 — a failed query paints as an ERROR with a retry
// affordance instead of unmounting the card (see the gate above).
<ChartErrorState
error={error}
title={t("charts.errorTitle")}
actionLabel={t("common.retry")}
actionContext={displayTitle}
@@ -203,6 +203,7 @@ export function ScoreTrendChart({
<Skeleton className={`w-full ${heightClass}`} />
) : query.isError ? (
<ChartErrorState
error={query.error}
title={t("charts.errorTitle")}
actionLabel={t("common.retry")}
actionContext={label}
+3 -3
View File
@@ -53,10 +53,10 @@ export function QueryErrorCard({
<Card data-slot="query-error-card" className={cn(className)}>
<CardContent
role="alert"
className="flex flex-col items-center justify-center gap-3 px-6 py-10 text-center"
className="flex flex-col items-center justify-center gap-3 py-10 text-center"
>
<AlertTriangle
className="text-muted-foreground h-8 w-8"
className="text-muted-foreground size-6"
aria-hidden="true"
/>
<p className="text-sm font-medium">{failure.message}</p>
@@ -74,7 +74,7 @@ export function QueryErrorCard({
size="sm"
onClick={action.onClick}
>
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
<RotateCw className="size-3.5" aria-hidden="true" />
{retryLabel ?? action.label}
</Button>
) : action ? (
+8 -1
View File
@@ -29,6 +29,9 @@ import { cn } from "@/lib/utils";
*
* The message renders in `text-foreground`: an alert IS the content of a
* failed surface, and §3 reserves muted for meta.
*
* `inset` drops the row's own frame for a failure inside a card or tile body
* that already draws one; a framed row there would be a card inside a card.
*/
export function QueryErrorRow({
error,
@@ -38,6 +41,7 @@ export function QueryErrorRow({
retryLabel,
slot,
retrySlot,
inset = false,
className,
}: {
/** The query's error. It decides what the row offers. */
@@ -56,6 +60,8 @@ export function QueryErrorRow({
slot?: string;
/** Same, for the Retry button's own `data-slot`. */
retrySlot?: string;
/** Inside a card or tile body: no frame of its own. */
inset?: boolean;
className?: string;
}) {
const failure = useReadFailure(error, { message, onRetry });
@@ -66,7 +72,8 @@ export function QueryErrorRow({
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",
"text-foreground flex flex-col items-start gap-3 text-sm sm:flex-row sm:items-center sm:justify-between",
!inset && "bg-card border-border rounded-xl border p-4 md:p-6",
className,
)}
>