From 3fbd16a0a6d7b845b6f1d3e5f06579c99271e311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 10:10:31 +0200 Subject: [PATCH] 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. --- .../query-error-error-prop-guard.test.ts | 4 +- .../__tests__/chart-error-state.test.tsx | 28 ++++++-- src/components/charts/chart-error-state.tsx | 68 +++++++++++++------ src/components/charts/health-chart.tsx | 5 +- .../charts/medication-compliance-chart.tsx | 3 +- src/components/charts/mood-chart.tsx | 3 +- .../insights/derived/score-trend-chart.tsx | 1 + src/components/ui/query-error-card.tsx | 6 +- src/components/ui/query-error-row.tsx | 9 ++- 9 files changed, 91 insertions(+), 36 deletions(-) diff --git a/src/__tests__/query-error-error-prop-guard.test.ts b/src/__tests__/query-error-error-prop-guard.test.ts index 6544cba0c..57befcb56 100644 --- a/src/__tests__/query-error-error-prop-guard.test.ts +++ b/src/__tests__/query-error-error-prop-guard.test.ts @@ -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; diff --git a/src/components/charts/__tests__/chart-error-state.test.tsx b/src/components/charts/__tests__/chart-error-state.test.tsx index 9197fce14..df069e970 100644 --- a/src/components/charts/__tests__/chart-error-state.test.tsx +++ b/src/components/charts/__tests__/chart-error-state.test.tsx @@ -130,16 +130,32 @@ describe(" — announcement, sizing, accessible name", () => { props: Partial = {}, ) { const { ChartErrorState } = await import("../chart-error-state"); + const { I18nProvider } = await import("@/lib/i18n/context"); return renderToStaticMarkup( - undefined} - {...props} - />, + + undefined} + {...props} + /> + , ); } + 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(" { const html = await renderState(); expect(html).toContain('role="status"'); diff --git a/src/components/charts/chart-error-state.tsx b/src/components/charts/chart-error-state.tsx index b81af179c..b75814847 100644 --- a/src/components/charts/chart-error-state.tsx +++ b/src/components/charts/chart-error-state.tsx @@ -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. `` 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 (
); } 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 `` instead. */ export class ChartBoundary extends Component { - 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 { } 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 ( ( // 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" > window.location.reload()} role="alert" /> - } + )} > {children} diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index fdbe42f25..1cdbca5a8 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -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". => { return apiGet( @@ -548,6 +548,7 @@ export function MedicationComplianceChart({ // through to the empty branch (data undefined → hasData false) // and an outage read as "no doses tracked". => { return apiGet("/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). ) : query.isError ? (