mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
fix(ui): let the read-failure primitives decide by the error
About forty compact rows and seventy cards still offered a retry on a spent read budget and drew an error card on a refusal, while a third primitive (ReadFailureRow) did the right thing at eight places. QueryErrorRow and QueryErrorCard now take the query's `error` and read it through readFailureKind, which moves to src/lib/api next to ApiError: a refusal (403) draws the surface's refused state or nothing, a spent budget (429) says so without a retry, an ended session (401) says so and offers sign-in instead of going blank, and only a real failure offers the retry. ReadFailureRow is gone and every site passes its error; a guard test fails on a site that does not. The coach read strip keeps its card in the error state and holds one body height across loading, failure and a one-line read.
This commit is contained in:
1 parent
37b65fa9c8
commit
3cf67d05f9
144 files changed
+818
-331
No files matched your search
+3
-1
@@ -55,7 +55,9 @@
|
||||
"errorIdLabel": "Fehler-ID",
|
||||
"errorIdCopy": "Fehler-ID kopieren",
|
||||
"errorIdCopied": "Fehler-ID kopiert",
|
||||
"readThrottled": "Gerade zu viele Anfragen. Versuch es später noch einmal."
|
||||
"readThrottled": "Gerade zu viele Anfragen. Versuch es später noch einmal.",
|
||||
"sessionEnded": "Deine Sitzung ist abgelaufen. Melde dich erneut an, um weiterzumachen.",
|
||||
"signInAgain": "Anmelden"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
|
||||
+3
-1
@@ -55,7 +55,9 @@
|
||||
"errorIdLabel": "Error ID",
|
||||
"errorIdCopy": "Copy error ID",
|
||||
"errorIdCopied": "Error ID copied",
|
||||
"readThrottled": "Too many requests just now. Try again later."
|
||||
"readThrottled": "Too many requests just now. Try again later.",
|
||||
"sessionEnded": "Your session has ended. Sign in again to continue.",
|
||||
"signInAgain": "Sign in"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
|
||||
+3
-1
@@ -55,7 +55,9 @@
|
||||
"errorIdLabel": "ID de error",
|
||||
"errorIdCopy": "Copiar ID de error",
|
||||
"errorIdCopied": "ID de error copiado",
|
||||
"readThrottled": "Demasiadas solicitudes ahora mismo. Vuelve a intentarlo más tarde."
|
||||
"readThrottled": "Demasiadas solicitudes ahora mismo. Vuelve a intentarlo más tarde.",
|
||||
"sessionEnded": "Tu sesión ha caducado. Inicia sesión de nuevo para continuar.",
|
||||
"signInAgain": "Iniciar sesión"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
|
||||
+3
-1
@@ -55,7 +55,9 @@
|
||||
"errorIdLabel": "ID d'erreur",
|
||||
"errorIdCopy": "Copier l'ID d'erreur",
|
||||
"errorIdCopied": "ID d'erreur copié",
|
||||
"readThrottled": "Trop de requêtes pour le moment. Réessaie plus tard."
|
||||
"readThrottled": "Trop de requêtes pour le moment. Réessaie plus tard.",
|
||||
"sessionEnded": "Ta session a expiré. Reconnecte-toi pour continuer.",
|
||||
"signInAgain": "Se connecter"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
|
||||
+3
-1
@@ -55,7 +55,9 @@
|
||||
"errorIdLabel": "ID errore",
|
||||
"errorIdCopy": "Copia ID errore",
|
||||
"errorIdCopied": "ID errore copiato",
|
||||
"readThrottled": "Troppe richieste in questo momento. Riprova più tardi."
|
||||
"readThrottled": "Troppe richieste in questo momento. Riprova più tardi.",
|
||||
"sessionEnded": "La tua sessione è scaduta. Accedi di nuovo per continuare.",
|
||||
"signInAgain": "Accedi"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
|
||||
+3
-1
@@ -55,7 +55,9 @@
|
||||
"errorIdLabel": "오류 ID",
|
||||
"errorIdCopy": "오류 ID 복사",
|
||||
"errorIdCopied": "오류 ID 복사됨",
|
||||
"readThrottled": "지금은 요청이 너무 많아요. 나중에 다시 시도해 주세요."
|
||||
"readThrottled": "지금은 요청이 너무 많아요. 나중에 다시 시도해 주세요.",
|
||||
"sessionEnded": "세션이 만료되었어요. 계속하려면 다시 로그인해 주세요.",
|
||||
"signInAgain": "로그인"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
|
||||
+3
-1
@@ -55,7 +55,9 @@
|
||||
"errorIdLabel": "ID błędu",
|
||||
"errorIdCopy": "Kopiuj ID błędu",
|
||||
"errorIdCopied": "Skopiowano ID błędu",
|
||||
"readThrottled": "Zbyt wiele zapytań w tej chwili. Spróbuj ponownie później."
|
||||
"readThrottled": "Zbyt wiele zapytań w tej chwili. Spróbuj ponownie później.",
|
||||
"sessionEnded": "Twoja sesja wygasła. Zaloguj się ponownie, aby kontynuować.",
|
||||
"signInAgain": "Zaloguj się"
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Every read-failure primitive is handed the error it reports.
|
||||
*
|
||||
* `QueryErrorRow` and `QueryErrorCard` 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
|
||||
* every status, which is how a refused share showed an error card and a spent
|
||||
* budget offered a retry that could only be refused again.
|
||||
*
|
||||
* The prop is required in the type, so the compiler already catches a missing
|
||||
* one. What it cannot catch is a literal that defeats it (`error={null}`,
|
||||
* `error={undefined}`), so this guard reads every opening tag under `src/`
|
||||
* and refuses both. The matcher is whitespace-tolerant (tags span lines) and
|
||||
* the match count is asserted, so a matcher that stops finding the tags fails
|
||||
* rather than passing over nothing.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const SRC = join(__dirname, "..");
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.name === "__tests__" || entry.name === "generated") continue;
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(path, out);
|
||||
else if (entry.name.endsWith(".tsx") && !entry.name.includes(".test."))
|
||||
out.push(path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
for (let m = open.exec(source); m; m = open.exec(source)) {
|
||||
let depth = 0;
|
||||
let i = m.index + m[0].length;
|
||||
for (; i < source.length; i++) {
|
||||
const ch = source[i];
|
||||
if (ch === "{") depth++;
|
||||
else if (ch === "}") depth--;
|
||||
else if (ch === ">" && depth === 0) break;
|
||||
}
|
||||
tags.push(source.slice(m.index, i + 1));
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
/** Comments mention the primitives by name (`<QueryErrorCard>`); skip them. */
|
||||
function stripComments(source: string): string {
|
||||
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
||||
}
|
||||
|
||||
const sites = walk(SRC).flatMap((file) =>
|
||||
openingTags(stripComments(readFileSync(file, "utf8"))).map((tag) => ({
|
||||
file: relative(SRC, file),
|
||||
tag,
|
||||
})),
|
||||
);
|
||||
|
||||
describe("read-failure primitives carry their error", () => {
|
||||
it("finds the call sites it guards", () => {
|
||||
// About 120 at the time of writing. A drop to a handful means the
|
||||
// matcher broke, not that the sites went away.
|
||||
expect(sites.length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it("passes the query's error at every site", () => {
|
||||
const offenders = sites
|
||||
.filter(
|
||||
({ tag }) =>
|
||||
!/\serror=\{/.test(tag) ||
|
||||
/\serror=\{\s*(null|undefined)\s*\}/.test(tag),
|
||||
)
|
||||
.map(({ file, tag }) => `${file}: ${tag.split("\n")[0]}`);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -293,7 +293,7 @@ export default function AchievementsPage() {
|
||||
// alongside `<RecentAchievementsCard>` and
|
||||
// `<AchievementUnlockNotifier>` so the three consumers never trigger
|
||||
// more than one network call on a cold dashboard mount.
|
||||
const { data, isLoading, isError, refetch } = useAchievementsQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useAchievementsQuery({
|
||||
enabled: isAuthenticated && achievementsEnabled,
|
||||
});
|
||||
|
||||
@@ -326,7 +326,7 @@ export default function AchievementsPage() {
|
||||
title={t("achievements.title")}
|
||||
description={t("achievements.subtitle")}
|
||||
/>
|
||||
<QueryErrorCard onRetry={() => refetch()} />
|
||||
<QueryErrorCard error={error} onRetry={() => refetch()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,8 +24,11 @@ export default function AdminOverviewPage() {
|
||||
// Many sub-sections share `useAdminSettings()` and render defaults
|
||||
// silently on error — the banner makes the failure visible so the
|
||||
// admin knows the toggles below aren't reflecting real state.
|
||||
const { isError: settingsError, refetch: refetchSettings } =
|
||||
useAdminSettings();
|
||||
const {
|
||||
isError: settingsError,
|
||||
error: settingsFailure,
|
||||
refetch: refetchSettings,
|
||||
} = useAdminSettings();
|
||||
|
||||
if (!user || user.role !== "ADMIN") return null;
|
||||
|
||||
@@ -36,6 +39,7 @@ export default function AdminOverviewPage() {
|
||||
now (its own grid row, so the nav lines up with the first card). */}
|
||||
{settingsError && (
|
||||
<QueryErrorRow
|
||||
error={settingsFailure}
|
||||
message={t("admin.adminSettingsLoadError")}
|
||||
onRetry={() => void refetchSettings()}
|
||||
/>
|
||||
|
||||
@@ -69,6 +69,7 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) {
|
||||
conversations,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
@@ -182,7 +183,7 @@ function CoachConversationsBody({ readOnly }: { readOnly: boolean }) {
|
||||
// A load failure must not masquerade as "no conversations yet" —
|
||||
// that reads as a data-loss scare and offers no way back. Mirror
|
||||
// /coach/plans and surface a retry.
|
||||
<QueryErrorCard onRetry={() => refetch()} />
|
||||
<QueryErrorCard error={error} onRetry={() => refetch()} />
|
||||
) : groups.length === 0 ? (
|
||||
<div
|
||||
data-slot="coach-conversations-empty"
|
||||
|
||||
@@ -240,7 +240,7 @@ function CoachPlansBody({ readOnly }: { readOnly: boolean }) {
|
||||
)}
|
||||
>
|
||||
{query.isError ? (
|
||||
<QueryErrorCard onRetry={() => query.refetch()} />
|
||||
<QueryErrorCard error={query.error} onRetry={() => query.refetch()} />
|
||||
) : query.isLoading ? (
|
||||
<div
|
||||
data-slot="coach-plans-loading"
|
||||
|
||||
@@ -278,31 +278,41 @@ export default function MetricCatalogPage() {
|
||||
// the whole page, and never reads as an honest "Not tracked yet".
|
||||
const probes: Record<
|
||||
ProbeId,
|
||||
{ isLoading: boolean; isError: boolean; retry: () => void }
|
||||
{
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: unknown;
|
||||
retry: () => void;
|
||||
}
|
||||
> = {
|
||||
analytics: {
|
||||
isLoading: analyticsQuery.isLoading,
|
||||
isError: analyticsQuery.isError,
|
||||
error: analyticsQuery.error,
|
||||
retry: () => void analyticsQuery.refetch(),
|
||||
},
|
||||
comprehensive: {
|
||||
isLoading: comprehensiveQuery.isLoading,
|
||||
isError: comprehensiveQuery.isError,
|
||||
error: comprehensiveQuery.error,
|
||||
retry: () => void comprehensiveQuery.refetch(),
|
||||
},
|
||||
workouts: {
|
||||
isLoading: workoutsProbe.isLoading,
|
||||
isError: workoutsProbe.isError,
|
||||
error: workoutsProbe.error,
|
||||
retry: () => workoutsProbe.refetch(),
|
||||
},
|
||||
nutrients: {
|
||||
isLoading: nutrientsModuleEnabled && nutrientsProbe.isLoading,
|
||||
isError: nutrientsModuleEnabled && nutrientsProbe.isError,
|
||||
error: nutrientsProbe.error,
|
||||
retry: () => void nutrientsProbe.refetch(),
|
||||
},
|
||||
ecg: {
|
||||
isLoading: ecgQuery.isLoading,
|
||||
isError: ecgQuery.isError,
|
||||
error: ecgQuery.error,
|
||||
retry: () => void ecgQuery.refetch(),
|
||||
},
|
||||
};
|
||||
@@ -358,6 +368,7 @@ export default function MetricCatalogPage() {
|
||||
<CardContent className="space-y-2">
|
||||
{failed.length > 0 ? (
|
||||
<QueryErrorRow
|
||||
error={probes[failed[0]].error}
|
||||
slot="catalog-group-error"
|
||||
message={t("metricCatalog.loadError")}
|
||||
onRetry={() => failed.forEach((id) => probes[id].retry())}
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function InsightsEcgPage() {
|
||||
// Reuse the SAME query cell `<EcgSection>` reads (`insightsEcgList`) so the
|
||||
// page and the section share one cache entry. The page only needs the
|
||||
// `hasRecordings` flag to choose between the empty state and the section.
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.insightsEcgList(),
|
||||
queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"),
|
||||
enabled: isAuthenticated,
|
||||
@@ -55,7 +55,7 @@ export default function InsightsEcgPage() {
|
||||
title={t("insights.ecg.sectionTitle")}
|
||||
description={t("insights.subPage.ecgDescription")}
|
||||
>
|
||||
<QueryErrorCard onRetry={() => refetch()} />
|
||||
<QueryErrorCard error={error} onRetry={() => refetch()} />
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ export default function InsightsMedikamentePage() {
|
||||
data: comprehensive,
|
||||
isLoading: isComprehensiveLoading,
|
||||
isError: isComprehensiveError,
|
||||
error: comprehensiveError,
|
||||
refetch: refetchComprehensive,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.insightsComprehensive(),
|
||||
@@ -179,6 +180,7 @@ export default function InsightsMedikamentePage() {
|
||||
explainerMetric="medications"
|
||||
>
|
||||
<QueryErrorCard
|
||||
error={comprehensiveError}
|
||||
description={t("insights.loadError")}
|
||||
onRetry={() => refetchComprehensive()}
|
||||
/>
|
||||
|
||||
@@ -99,6 +99,7 @@ export default function InsightsNutrientsPage() {
|
||||
description={t("nutrients.page.description")}
|
||||
>
|
||||
<QueryErrorCard
|
||||
error={overview.error}
|
||||
title={t("nutrients.page.loadError")}
|
||||
onRetry={() => void overview.refetch()}
|
||||
/>
|
||||
|
||||
@@ -45,9 +45,11 @@ export default function InsightsSchlafPage() {
|
||||
// to paint the same sentence three times, none of them offering a way out.
|
||||
// The page owns the shared state and renders one notice with a retry; the
|
||||
// cards no longer carry their own copy of it.
|
||||
const { isError: rhythmError, refetch: refetchRhythm } = useSleepRhythm(
|
||||
Boolean(user) && !isEmpty,
|
||||
);
|
||||
const {
|
||||
isError: rhythmError,
|
||||
error: rhythmFailure,
|
||||
refetch: refetchRhythm,
|
||||
} = useSleepRhythm(Boolean(user) && !isEmpty);
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
@@ -127,6 +129,7 @@ export default function InsightsSchlafPage() {
|
||||
*/}
|
||||
{rhythmError ? (
|
||||
<QueryErrorCard
|
||||
error={rhythmFailure}
|
||||
description={t("insights.sleep.rhythm.loadError")}
|
||||
onRetry={() => void refetchRhythm()}
|
||||
/>
|
||||
|
||||
@@ -99,7 +99,7 @@ export default function InsightsWorkoutDetailPage({
|
||||
</div>
|
||||
) : error ? (
|
||||
// A failed query must never read as "no data" (UI-STANDARDS §6).
|
||||
<QueryErrorCard onRetry={() => refetch()} />
|
||||
<QueryErrorCard error={error} onRetry={() => refetch()} />
|
||||
) : !data ? (
|
||||
<p
|
||||
data-slot="workout-detail-error"
|
||||
|
||||
@@ -60,6 +60,7 @@ export default function InsightsWorkoutsPageClient() {
|
||||
isLoading,
|
||||
isEmpty,
|
||||
isError,
|
||||
error,
|
||||
isFetchNextPageError,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
@@ -111,6 +112,7 @@ export default function InsightsWorkoutsPageClient() {
|
||||
nothing to aggregate. */}
|
||||
{isError ? (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
title={t("insights.workouts.loadError")}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function MedicationDetailPage({
|
||||
data: medication,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery<MedicationDetailSnapshot>({
|
||||
queryKey: queryKeys.medicationDetail(id),
|
||||
@@ -83,6 +84,7 @@ export default function MedicationDetailPage({
|
||||
<BackLink href="/medications" label={t("medications.back")} />
|
||||
<div data-slot="medication-detail-error-card">
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
description={t("medications.detail.shell.loadFailed")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -333,6 +333,7 @@ export default function MedicationsPageClient() {
|
||||
data: medications,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch: refetchMedications,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.medications(),
|
||||
@@ -656,6 +657,7 @@ export default function MedicationsPageClient() {
|
||||
</div>
|
||||
) : isError ? (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
description={t("medications.loadFailed")}
|
||||
retryLabel={t("medications.retryLoad")}
|
||||
onRetry={() => void refetchMedications()}
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function NotificationsPage() {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.notificationsPreferences(),
|
||||
queryFn: async () => {
|
||||
return apiGet<PreferencesData>("/api/notifications/preferences");
|
||||
@@ -186,6 +186,7 @@ export default function NotificationsPage() {
|
||||
description={t("notifications.subtitle")}
|
||||
/>
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
description={t("notifications.loadError")}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
|
||||
@@ -58,6 +58,7 @@ export default function HandoverDecisionPage() {
|
||||
|
||||
{decision.isError ? (
|
||||
<QueryErrorCard
|
||||
error={decision.error}
|
||||
title={t("recordSharing.handoverDecision.loadError")}
|
||||
onRetry={() => void decision.refetch()}
|
||||
/>
|
||||
|
||||
@@ -998,7 +998,10 @@ export default function DashboardPageClient({
|
||||
) : !mounted || digestQuery.isLoading ? (
|
||||
<TodayHeroSkeleton />
|
||||
) : digestQuery.isError ? (
|
||||
<QueryErrorCard onRetry={() => digestQuery.refetch()} />
|
||||
<QueryErrorCard
|
||||
error={digestQuery.error}
|
||||
onRetry={() => digestQuery.refetch()}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,9 @@ describe("<PageHeader> — one canonical H1", () => {
|
||||
|
||||
describe("<QueryErrorCard> — retry affordance", () => {
|
||||
it("renders a retry button when onRetry is provided", () => {
|
||||
const html = render(<QueryErrorCard onRetry={() => {}} />);
|
||||
const html = render(
|
||||
<QueryErrorCard error={new Error("boom")} onRetry={() => {}} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="query-error-card"');
|
||||
expect(html).toContain('role="alert"');
|
||||
// The retry affordance is a real <button> so a failed read is
|
||||
@@ -85,7 +87,7 @@ describe("<QueryErrorCard> — retry affordance", () => {
|
||||
});
|
||||
|
||||
it("omits the retry button when no onRetry is supplied", () => {
|
||||
const html = render(<QueryErrorCard />);
|
||||
const html = render(<QueryErrorCard error={new Error("boom")} />);
|
||||
expect(html).toContain('data-slot="query-error-card"');
|
||||
expect(html).not.toContain("<button");
|
||||
});
|
||||
|
||||
@@ -26,7 +26,10 @@ import en from "../../../messages/en.json";
|
||||
* rather than by driving a real fetch.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
|
||||
// `ApiError` stays real: the error primitives read its status.
|
||||
ApiError: (await importOriginal<typeof import("@/lib/api/api-fetch")>())
|
||||
.ApiError,
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiPost: () => new Promise(() => {}),
|
||||
}));
|
||||
|
||||
@@ -25,7 +25,9 @@ function markup(node: ReactNode): string {
|
||||
);
|
||||
}
|
||||
|
||||
function boundaryWith(fallback: (retry: () => void) => ReactNode) {
|
||||
function boundaryWith(
|
||||
fallback: (retry: () => void, error: unknown) => ReactNode,
|
||||
) {
|
||||
const boundary = new Boundary({
|
||||
renderFallback: fallback,
|
||||
children: <p>card body</p>,
|
||||
@@ -53,18 +55,27 @@ describe("AdminCardBoundary", () => {
|
||||
});
|
||||
|
||||
it("turns a thrown render into the fallback, not a page failure", () => {
|
||||
expect(Boundary.getDerivedStateFromError()).toEqual({ failed: true });
|
||||
const thrown = new Error("render failed");
|
||||
expect(Boundary.getDerivedStateFromError(thrown)).toEqual({
|
||||
failed: true,
|
||||
error: thrown,
|
||||
});
|
||||
|
||||
const fallback = vi.fn(() => <p data-slot="fallback">fallback</p>);
|
||||
const fallback = vi.fn<(retry: () => void, error: unknown) => ReactNode>(
|
||||
() => <p data-slot="fallback">fallback</p>,
|
||||
);
|
||||
const boundary = boundaryWith(fallback);
|
||||
boundary.setState(Boundary.getDerivedStateFromError());
|
||||
boundary.setState(Boundary.getDerivedStateFromError(thrown));
|
||||
|
||||
expect(markup(boundary.render())).toContain('data-slot="fallback"');
|
||||
expect(fallback).toHaveBeenCalledWith(boundary.retry);
|
||||
// The fallback is handed what was thrown, so its error card reads it.
|
||||
expect(fallback).toHaveBeenCalledWith(boundary.retry, thrown);
|
||||
});
|
||||
|
||||
it("paints the translated error card with a keyboard-reachable retry", () => {
|
||||
const html = markup(<AdminCardError onRetry={() => {}} />);
|
||||
const html = markup(
|
||||
<AdminCardError error={new Error("render failed")} onRetry={() => {}} />,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="query-error-card"');
|
||||
expect(html).toContain('role="alert"');
|
||||
|
||||
@@ -18,17 +18,28 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
*/
|
||||
export function AdminCardBoundary({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Boundary renderFallback={(retry) => <AdminCardError onRetry={retry} />}>
|
||||
<Boundary
|
||||
renderFallback={(retry, error) => (
|
||||
<AdminCardError error={error} onRetry={retry} />
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Boundary>
|
||||
);
|
||||
}
|
||||
|
||||
/** What a failed card paints in its place. */
|
||||
export function AdminCardError({ onRetry }: { onRetry: () => void }) {
|
||||
export function AdminCardError({
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
error: unknown;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
title={t("admin.cardError.title")}
|
||||
description={t("admin.cardError.description")}
|
||||
onRetry={onRetry}
|
||||
@@ -37,12 +48,13 @@ export function AdminCardError({ onRetry }: { onRetry: () => void }) {
|
||||
}
|
||||
|
||||
export interface BoundaryProps {
|
||||
renderFallback: (retry: () => void) => ReactNode;
|
||||
renderFallback: (retry: () => void, error: unknown) => ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface BoundaryState {
|
||||
failed: boolean;
|
||||
error?: unknown;
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
@@ -50,8 +62,10 @@ interface BoundaryState {
|
||||
export class Boundary extends Component<BoundaryProps, BoundaryState> {
|
||||
state: BoundaryState = { failed: false, attempt: 0 };
|
||||
|
||||
static getDerivedStateFromError(): Pick<BoundaryState, "failed"> {
|
||||
return { failed: true };
|
||||
static getDerivedStateFromError(
|
||||
error: unknown,
|
||||
): Pick<BoundaryState, "failed" | "error"> {
|
||||
return { failed: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
@@ -64,11 +78,17 @@ export class Boundary extends Component<BoundaryProps, BoundaryState> {
|
||||
}
|
||||
|
||||
retry = (): void => {
|
||||
this.setState((s) => ({ failed: false, attempt: s.attempt + 1 }));
|
||||
this.setState((s) => ({
|
||||
failed: false,
|
||||
error: undefined,
|
||||
attempt: s.attempt + 1,
|
||||
}));
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.failed) return this.props.renderFallback(this.retry);
|
||||
if (this.state.failed) {
|
||||
return this.props.renderFallback(this.retry, this.state.error);
|
||||
}
|
||||
// A new key on retry remounts the card, so it starts from a clean state
|
||||
// rather than re-rendering the tree that threw.
|
||||
return <Fragment key={this.state.attempt}>{this.props.children}</Fragment>;
|
||||
|
||||
@@ -104,6 +104,7 @@ export function AiQualitySection() {
|
||||
</div>
|
||||
) : query.isError ? (
|
||||
<QueryErrorRow
|
||||
error={query.error}
|
||||
message={t("admin.aiQuality.loadError")}
|
||||
onRetry={() => void query.refetch()}
|
||||
/>
|
||||
|
||||
@@ -847,7 +847,7 @@ export function BackupsSection() {
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.adminBackups(),
|
||||
queryFn: async () => {
|
||||
return apiGet<BackupsList>("/api/admin/backups");
|
||||
@@ -1243,6 +1243,7 @@ export function BackupsSection() {
|
||||
</div>
|
||||
) : isError ? (
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
message={t("admin.section.backups.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -99,6 +99,7 @@ export function CoachFeedbackSection() {
|
||||
</div>
|
||||
) : query.isError ? (
|
||||
<QueryErrorRow
|
||||
error={query.error}
|
||||
message={t("admin.coachFeedback.loadError")}
|
||||
onRetry={() => void query.refetch()}
|
||||
/>
|
||||
|
||||
@@ -144,7 +144,7 @@ export function HostMetricsChart() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.adminHostMetrics("2h"),
|
||||
queryFn: async () => {
|
||||
return apiGet<HostMetricsApiResponse>("/api/admin/host-metrics?since=2h");
|
||||
@@ -198,6 +198,7 @@ export function HostMetricsChart() {
|
||||
}
|
||||
/>
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
message={t("admin.hostMetrics.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -155,6 +155,7 @@ export function KeyBackupCard() {
|
||||
/>
|
||||
{status.isError ? (
|
||||
<QueryErrorRow
|
||||
error={status.error}
|
||||
message={t("admin.keyBackup.loadError")}
|
||||
onRetry={() => void status.refetch()}
|
||||
/>
|
||||
|
||||
@@ -111,6 +111,7 @@ export function MeasurementMaintenanceCard() {
|
||||
if (status.isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={status.error}
|
||||
title={t("admin.section.maintenance.loadError")}
|
||||
onRetry={() => void status.refetch()}
|
||||
/>
|
||||
|
||||
@@ -141,7 +141,7 @@ function AccountRow({ row }: { row: OffhostAccountRow }) {
|
||||
export function OffhostBackupsSection() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.adminBackups(),
|
||||
queryFn: async () => apiGet<BackupsList>("/api/admin/backups"),
|
||||
});
|
||||
@@ -170,6 +170,7 @@ export function OffhostBackupsSection() {
|
||||
</div>
|
||||
) : isError ? (
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
message={t("admin.section.backups.offhost.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -125,6 +125,7 @@ export function PulseDayMeanCard() {
|
||||
if (status.isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={status.error}
|
||||
title={t("admin.section.pulseDayMean.loadError")}
|
||||
onRetry={() => void status.refetch()}
|
||||
/>
|
||||
|
||||
@@ -38,7 +38,7 @@ interface AuditLogResponse {
|
||||
export function RecentAuditPreview() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.adminAuditOverview(),
|
||||
queryFn: async () => {
|
||||
return apiGet<AuditLogResponse>(
|
||||
@@ -84,6 +84,7 @@ export function RecentAuditPreview() {
|
||||
</div>
|
||||
) : isError ? (
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
message={t("admin.overview.auditLoadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -128,7 +128,7 @@ export function FailingJobsCard({
|
||||
export function SystemStatusSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { data: status, isError, refetch } = useSystemStatus();
|
||||
const { data: status, isError, error, refetch } = useSystemStatus();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -277,6 +277,7 @@ export function SystemStatusSection() {
|
||||
// transient 500 (rolling deploy, DB blip) doesn't require a
|
||||
// full page reload to recover.
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
message={t("admin.systemStatusLoadError")}
|
||||
onRetry={() => void refetch()}
|
||||
retryLabel={t("admin.systemStatusRetry")}
|
||||
|
||||
@@ -31,7 +31,7 @@ import { useKeyBackupStatus } from "./use-key-backup-status";
|
||||
|
||||
export function SystemStatusSummary() {
|
||||
const { t } = useTranslations();
|
||||
const { data: status, isError, refetch } = useSystemStatus();
|
||||
const { data: status, isError, error, refetch } = useSystemStatus();
|
||||
const { data: keyBackup } = useKeyBackupStatus();
|
||||
|
||||
return (
|
||||
@@ -110,6 +110,7 @@ export function SystemStatusSummary() {
|
||||
</div>
|
||||
) : isError ? (
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
message={t("admin.overview.snapshotLoadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -93,6 +93,7 @@ export function BodySitesSection({
|
||||
|
||||
{list.isError ? (
|
||||
<QueryErrorCard
|
||||
error={list.error}
|
||||
title={t("bodySites.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
|
||||
@@ -87,6 +87,7 @@ export function CustomMetricDetail({
|
||||
const {
|
||||
data: metric,
|
||||
isError: metricError,
|
||||
error: metricFailure,
|
||||
refetch: refetchMetric,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.customMetricDetail(customMetricId),
|
||||
@@ -98,6 +99,7 @@ export function CustomMetricDetail({
|
||||
data: list,
|
||||
isLoading,
|
||||
isError: listError,
|
||||
error: listFailure,
|
||||
refetch: refetchList,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKeys.customMetricEntries({
|
||||
@@ -179,6 +181,7 @@ export function CustomMetricDetail({
|
||||
description={description}
|
||||
/>
|
||||
<QueryErrorCard
|
||||
error={metricError ? metricFailure : listFailure}
|
||||
description={t("customMetrics.loadError")}
|
||||
onRetry={() => {
|
||||
if (metricError) void refetchMetric();
|
||||
|
||||
@@ -45,7 +45,7 @@ export function CustomMetricList() {
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.customMetrics(),
|
||||
queryFn: () => apiGet<CustomMetricListResponse>("/api/custom-metrics"),
|
||||
});
|
||||
@@ -93,6 +93,7 @@ export function CustomMetricList() {
|
||||
</Card>
|
||||
) : isError ? (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
description={t("customMetrics.loadError")}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
|
||||
@@ -42,6 +42,7 @@ export function CustomMetricValuesList({
|
||||
data: list,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
@@ -76,6 +77,7 @@ export function CustomMetricValuesList({
|
||||
if (isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
description={t("customMetrics.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Droplets } from "lucide-react";
|
||||
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CycleRing } from "./cycle-ring";
|
||||
@@ -71,7 +71,7 @@ export function CycleRingTile({ className }: { className?: string }) {
|
||||
if (calendar.isLoading && !calendar.data) return null;
|
||||
if (calendar.isError && !calendar.data) {
|
||||
return (
|
||||
<ReadFailureRow
|
||||
<QueryErrorRow
|
||||
error={calendar.error}
|
||||
slot="wellness-cycle-tile-error"
|
||||
message={t("cycle.wellnessRing.loadError")}
|
||||
|
||||
@@ -362,6 +362,7 @@ export function CycleView() {
|
||||
// Inside the calendar card already — a borderless row keeps
|
||||
// it in line with the plain loading line above it (§3/§6).
|
||||
<QueryErrorRow
|
||||
error={calendar.error}
|
||||
message={t("cycle.loadError")}
|
||||
onRetry={() => void calendar.refetch()}
|
||||
className="border-0 bg-transparent p-0"
|
||||
@@ -405,6 +406,7 @@ export function CycleView() {
|
||||
<TabsContent value="predictions" className="mt-4 space-y-4">
|
||||
{calendarError ? (
|
||||
<QueryErrorCard
|
||||
error={calendar.error}
|
||||
title={t("cycle.loadError")}
|
||||
onRetry={() => void calendar.refetch()}
|
||||
/>
|
||||
@@ -435,6 +437,7 @@ export function CycleView() {
|
||||
<TabsContent value="insights" className="mt-4 space-y-4">
|
||||
{insights.isError ? (
|
||||
<QueryErrorCard
|
||||
error={insights.error}
|
||||
title={t("cycle.loadError")}
|
||||
onRetry={() => void insights.refetch()}
|
||||
/>
|
||||
@@ -454,6 +457,7 @@ export function CycleView() {
|
||||
<TabsContent value="settings" className="mt-4">
|
||||
{profileQuery.isError ? (
|
||||
<QueryErrorCard
|
||||
error={profileQuery.error}
|
||||
title={t("cycle.loadError")}
|
||||
onRetry={() => void profileQuery.refetch()}
|
||||
/>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { randomId } from "@/lib/random-id";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { useCalendarDate } from "@/hooks/use-calendar-date";
|
||||
import { readFailureKind } from "@/components/ui/read-failure-row";
|
||||
import { readFailureKind } from "@/lib/api/read-failure";
|
||||
|
||||
import {
|
||||
cycleDependentKeys,
|
||||
@@ -158,7 +158,7 @@ export function useCycleRingDial(enabled: boolean): {
|
||||
enabled &&
|
||||
calendar.isError &&
|
||||
!calendar.data &&
|
||||
readFailureKind(calendar.error) !== "denied",
|
||||
readFailureKind(calendar.error) !== "refused",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ function renderRow(workout: WorkoutListEntry, sportName: string) {
|
||||
export function RecentWorkoutsTile() {
|
||||
const { t, locale } = useTranslations();
|
||||
const timeZone = useDisplayTimezone();
|
||||
const { data, isError, refetch } = useWorkouts({ limit: 3 });
|
||||
const { data, isError, error, refetch } = useWorkouts({ limit: 3 });
|
||||
|
||||
// A query's `isLoading` is not a hydration-safe branch: TanStack reports the
|
||||
// optimistic mount fetch on the client's very first render and cannot on the
|
||||
@@ -116,6 +116,7 @@ export function RecentWorkoutsTile() {
|
||||
</div>
|
||||
) : isError ? (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
className="border-0 bg-transparent shadow-none"
|
||||
/>
|
||||
|
||||
@@ -274,6 +274,7 @@ export function DayView({
|
||||
|
||||
{day.isError ? (
|
||||
<QueryErrorRow
|
||||
error={day.error}
|
||||
message={t("day.loadFailed")}
|
||||
onRetry={() => void day.refetch()}
|
||||
slot="day-error"
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import type { EncounterDTO } from "@/lib/encounters/dto";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import type {
|
||||
@@ -195,7 +195,7 @@ export function SinceLastVisit({
|
||||
|
||||
if (window.isError && rows.length === 0) {
|
||||
return (
|
||||
<ReadFailureRow
|
||||
<QueryErrorRow
|
||||
error={window.error}
|
||||
slot="since-last-visit-error"
|
||||
message={t("day.sinceLastVisit.loadError")}
|
||||
|
||||
@@ -135,6 +135,7 @@ export function DocumentLinkPicker({
|
||||
</div>
|
||||
) : list.isError ? (
|
||||
<QueryErrorRow
|
||||
error={list.error}
|
||||
message={t("documents.list.loadError")}
|
||||
onRetry={() => list.refetch()}
|
||||
/>
|
||||
|
||||
@@ -287,7 +287,7 @@ export function DocumentRecordLinks({
|
||||
title={t("documents.detail.vaccinationsLabel")}
|
||||
slot="document-vaccination-links"
|
||||
pending={vaccinations.isPending || !seedFresh}
|
||||
error={vaccinations.isError || seedError}
|
||||
error={vaccinations.error || seedError}
|
||||
errorLabel={t("links.picker.loadError")}
|
||||
onRetry={() => {
|
||||
if (vaccinations.isError) void vaccinations.refetch();
|
||||
|
||||
@@ -1076,6 +1076,7 @@ export function DocumentsView() {
|
||||
)
|
||||
) : list.isError ? (
|
||||
<QueryErrorCard
|
||||
error={list.error}
|
||||
title={t("documents.list.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
|
||||
@@ -77,6 +77,7 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
|
||||
if (list.isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={list.error}
|
||||
title={t("documents.episodeCard.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
|
||||
@@ -552,6 +552,7 @@ export function DocumentSourcePicker({
|
||||
</div>
|
||||
) : search.isError ? (
|
||||
<QueryErrorRow
|
||||
error={search.error}
|
||||
message={sourceErrorMessage(t, errorCodeOf(search.error))}
|
||||
onRetry={() => void search.refetch()}
|
||||
slot="document-source-error"
|
||||
|
||||
@@ -96,6 +96,7 @@ export function ProceduresSection({ enabled = true }: { enabled?: boolean }) {
|
||||
|
||||
{list.isError ? (
|
||||
<QueryErrorCard
|
||||
error={list.error}
|
||||
title={t("encounters.procedures.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
|
||||
@@ -110,6 +110,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
||||
|
||||
{list.isError ? (
|
||||
<QueryErrorCard
|
||||
error={list.error}
|
||||
title={t("encounters.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
|
||||
@@ -52,6 +52,7 @@ export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
|
||||
if (list.isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={list.error}
|
||||
title={t("encounters.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
|
||||
@@ -58,6 +58,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
data: episode,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useIllnessEpisode(episodeId);
|
||||
const resolve = useResolveEpisode();
|
||||
@@ -69,6 +70,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
if (isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
title={t("illness.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -370,6 +370,7 @@ export function IllnessView() {
|
||||
data: episodes,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useIllnessEpisodes(true);
|
||||
const resolve = useResolveEpisode();
|
||||
@@ -460,6 +461,7 @@ export function IllnessView() {
|
||||
// A read failure is NOT an empty journal — surface the error + Retry so
|
||||
// an outage never reads as "no episodes yet".
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
title={t("illness.listLoadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -205,7 +205,7 @@ export function CoachResults({
|
||||
const expected = hasLive
|
||||
? countResultsInSection(liveWithProvenance(live ?? [], metas), section)
|
||||
: countResultsInSection(metas, section);
|
||||
const { ref, results, isError, refetch } = useCoachMessageResults({
|
||||
const { ref, results, isError, error, refetch } = useCoachMessageResults({
|
||||
conversationId,
|
||||
messageId,
|
||||
enabled: !hasLive && expected > 0,
|
||||
@@ -222,6 +222,7 @@ export function CoachResults({
|
||||
>
|
||||
{!hasLive && isError ? (
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
slot="coach-results-error"
|
||||
retrySlot="coach-results-retry"
|
||||
message={t(COACH_RESULT_UI_KEYS.loadFailed)}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useCoachConversation } from "./use-coach";
|
||||
*/
|
||||
export function ConversationTranscript({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError, refetch } = useCoachConversation(id);
|
||||
const { data, isLoading, isError, error, refetch } = useCoachConversation(id);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<p className="text-muted-foreground px-3 pb-3 text-xs">
|
||||
@@ -23,6 +23,7 @@ export function ConversationTranscript({ id }: { id: string }) {
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
className="mx-3 mb-3"
|
||||
onRetry={() => void refetch()}
|
||||
slot="coach-conversations-transcript-error"
|
||||
|
||||
@@ -270,6 +270,7 @@ export function useCoachConversationHistory(
|
||||
conversations,
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
hasNextPage: query.hasNextPage,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
|
||||
@@ -173,10 +173,8 @@ export function CoincidentDeviationCard({
|
||||
className,
|
||||
}: CoincidentDeviationCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { data, isError, refetch } = useDerivedMetric<CoincidentDeviationValue>(
|
||||
COINCIDENT_METRIC,
|
||||
{ enabled },
|
||||
);
|
||||
const { data, isError, error, refetch } =
|
||||
useDerivedMetric<CoincidentDeviationValue>(COINCIDENT_METRIC, { enabled });
|
||||
|
||||
// A failed read says so with a retry. Without this branch the card sat on
|
||||
// its skeleton forever, which read as "still loading" rather than failed.
|
||||
@@ -192,6 +190,7 @@ export function CoincidentDeviationCard({
|
||||
title={t("insights.derived.coincident.cardTitle")}
|
||||
/>
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
slot="coincident-deviation-error"
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* The strip sits above the chart on every metric sub-page. While its read is
|
||||
@@ -37,4 +39,57 @@ describe("CoachReadStrip — placeholder while the read loads", () => {
|
||||
expect(markup).toContain('data-state="loading"');
|
||||
expect(markup).toContain('aria-busy="true"');
|
||||
});
|
||||
|
||||
function renderFailed(error: unknown) {
|
||||
// `retryOnMount: false` keeps the seeded failure from being reset to a
|
||||
// fresh load on render.
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, enabled: false, retryOnMount: false },
|
||||
},
|
||||
});
|
||||
client
|
||||
.getQueryCache()
|
||||
.build(client, { queryKey: queryKeys.insightsCoachRead("WEIGHT", "en") })
|
||||
.setState({ status: "error", error, fetchStatus: "idle" });
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<CoachReadStrip metricType="WEIGHT" unit="kg" />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** The class list of the strip body, the element after the header. */
|
||||
function bodyClass(markup: string): string | undefined {
|
||||
return /data-slot="card-content" class="([^"]*)"/.exec(markup)?.[1];
|
||||
}
|
||||
|
||||
it("holds the same frame and body height when the read fails", () => {
|
||||
const loading = renderToStaticMarkup(
|
||||
<QueryClientProvider
|
||||
client={
|
||||
new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, enabled: false } },
|
||||
})
|
||||
}
|
||||
>
|
||||
<I18nProvider initialLocale="en">
|
||||
<CoachReadStrip metricType="WEIGHT" unit="kg" />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const failed = renderFailed(new ApiError("Boom", 500));
|
||||
expect(failed).toContain('data-slot="coach-read-strip"');
|
||||
expect(failed).toContain('data-state="error"');
|
||||
expect(failed).toContain('data-slot="coach-read-strip-error"');
|
||||
expect(bodyClass(failed)).toBeDefined();
|
||||
expect(bodyClass(failed)).toBe(bodyClass(loading));
|
||||
expect(bodyClass(failed)).toContain("min-h-8");
|
||||
});
|
||||
|
||||
it("draws nothing for a refused read", () => {
|
||||
expect(renderFailed(new ApiError("Forbidden", 403))).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -10,8 +10,9 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { metricFractionDigits } from "@/lib/measurements/value-domain";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { readFailureKind } from "@/lib/api/read-failure";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape";
|
||||
@@ -60,6 +61,13 @@ export interface CoachReadStripProps {
|
||||
valueScale?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The strip's body holds the height of the error row's Retry button (32 px,
|
||||
* 44 px on a touch screen) in every state, so the placeholder, a failure and
|
||||
* a one-line read all stand the same height and the chart below never moves.
|
||||
*/
|
||||
const STRIP_BODY = "min-h-8 pointer-coarse:min-h-11";
|
||||
|
||||
export function CoachReadStrip({
|
||||
metricType,
|
||||
unit,
|
||||
@@ -97,26 +105,19 @@ export function CoachReadStrip({
|
||||
// baseline line degrades to the "learning" copy), so the placeholder only
|
||||
// ever gives way to a card of its own height. A failed read gives way to a
|
||||
// notice with a retry: dropping the strip read as "nothing to say about
|
||||
// today", which is not what happened. The route is a manage-level read, so
|
||||
// a viewer on a read-only share is refused it; that draws nothing, and a
|
||||
// spent analytics budget says so without offering a retry.
|
||||
// today", which is not what happened. The notice sits inside the same card
|
||||
// shell as the placeholder, so a failure does not move the chart either.
|
||||
// The route is a manage-level read, so a viewer on a read-only share is
|
||||
// refused it; that draws nothing, and a spent analytics budget says so
|
||||
// without offering a retry.
|
||||
if (!mounted || !data) {
|
||||
if (!isAuthenticated) return null;
|
||||
if (isError) {
|
||||
return (
|
||||
<ReadFailureRow
|
||||
error={error}
|
||||
slot="coach-read-strip-error"
|
||||
message={t("insights.coach.readStrip.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isError && readFailureKind(error) === "refused") return null;
|
||||
return (
|
||||
<Card
|
||||
data-slot="coach-read-strip"
|
||||
data-state="loading"
|
||||
aria-busy="true"
|
||||
data-state={isError ? "error" : "loading"}
|
||||
aria-busy={isError ? undefined : "true"}
|
||||
className="gap-2 py-3 md:py-4"
|
||||
>
|
||||
<CardHeader>
|
||||
@@ -126,8 +127,18 @@ export function CoachReadStrip({
|
||||
titleAs="h2"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-5.5 w-3/4" />
|
||||
<CardContent className={STRIP_BODY}>
|
||||
{isError ? (
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
slot="coach-read-strip-error"
|
||||
message={t("insights.coach.readStrip.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
className="flex-row items-center justify-between rounded-none border-0 bg-transparent p-0"
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-5.5 w-3/4" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -241,7 +252,7 @@ export function CoachReadStrip({
|
||||
titleAs="h2"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className={STRIP_BODY}>
|
||||
<div className="min-w-0 space-y-1 text-sm leading-relaxed">
|
||||
{baselineLine ? (
|
||||
<p data-slot="coach-read-baseline" className="text-pretty">
|
||||
|
||||
@@ -186,6 +186,7 @@ export function CompositeScoreAnatomy({
|
||||
return (
|
||||
<div className={className}>
|
||||
<QueryErrorRow
|
||||
error={query.error}
|
||||
slot="composite-anatomy-error"
|
||||
onRetry={() => void query.refetch()}
|
||||
/>
|
||||
|
||||
@@ -891,6 +891,7 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
|
||||
subtitle={t("insights.derived.vitals.sectionSubtitle")}
|
||||
/>
|
||||
<QueryErrorRow
|
||||
error={batch.error}
|
||||
slot="vitals-dashboard-error"
|
||||
retrySlot="vitals-dashboard-retry"
|
||||
message={t("insights.derived.vitals.loadError")}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.insightsEcgDetail(recordingId, false),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
@@ -63,6 +63,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) {
|
||||
// alert is content, not muted meta — it also has to be recoverable.
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
title={t("insights.ecg.loadError")}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
|
||||
@@ -392,6 +392,7 @@ export function HealthKitMetricPage({
|
||||
explainerParams={explainerParams}
|
||||
>
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
slot="healthkit-metric-error"
|
||||
retrySlot="healthkit-metric-retry"
|
||||
message={t("insights.subPage.loadError")}
|
||||
|
||||
@@ -120,7 +120,7 @@ export function IntradayPulseChart({
|
||||
setSelectedDateKey(next === todayKey ? null : next);
|
||||
};
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.insightsPulseIntraday(dateKey),
|
||||
queryFn: () =>
|
||||
apiGet<IntradayPulseDto>(`/api/insights/pulse/intraday?date=${dateKey}`),
|
||||
@@ -255,6 +255,7 @@ export function IntradayPulseChart({
|
||||
<Skeleton className="h-[180px] w-full rounded-lg" />
|
||||
) : isError ? (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
className="border-0 bg-transparent shadow-none"
|
||||
/>
|
||||
|
||||
@@ -132,7 +132,8 @@ export function MoodInsightsSections({
|
||||
} = {}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading, isPending, isError, refetch } = useMoodInsights();
|
||||
const { data, isLoading, isPending, isError, error, refetch } =
|
||||
useMoodInsights();
|
||||
|
||||
// `isPending`, not `isLoading`: before the session resolves the read is
|
||||
// disabled rather than loading, and the frame must already be there.
|
||||
@@ -161,7 +162,7 @@ export function MoodInsightsSections({
|
||||
// "rest" region (mirroring the loading convention above), rather than
|
||||
// stacking an identical row in all three regions down the page.
|
||||
return region === "rest" ? (
|
||||
<QueryErrorRow onRetry={() => refetch()} />
|
||||
<QueryErrorRow error={error} onRetry={() => refetch()} />
|
||||
) : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import { Compass } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { formatDate } from "@/lib/date-format";
|
||||
@@ -71,7 +71,7 @@ export function MoodPrognosisCard() {
|
||||
|
||||
if (isError && !data) {
|
||||
return (
|
||||
<ReadFailureRow
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
slot="mood-prognosis-error"
|
||||
message={t("insights.mood.prognosis.loadError")}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function MoodWhatStandsOut({
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.insightsCorrelations(),
|
||||
queryFn: async () => {
|
||||
// Correlations are statistics, never gated on AI; a rejection is a
|
||||
@@ -81,6 +81,7 @@ export function MoodWhatStandsOut({
|
||||
) : null}
|
||||
{isError ? (
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
slot="mood-discovery-error"
|
||||
message={t("insights.pattern.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Coffee } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { NutrientDailyBarChartDynamic } from "@/components/charts/nutrient-daily-bar-chart-dynamic";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
@@ -43,7 +43,7 @@ export function CaffeineCard() {
|
||||
|
||||
if (isError && !data) {
|
||||
return (
|
||||
<ReadFailureRow
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
slot="nutrients-caffeine-error"
|
||||
message={t("nutrients.caffeine.loadError")}
|
||||
|
||||
@@ -30,7 +30,7 @@ export function HydrationCard() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.nutrientDaily("water", WINDOW_DAYS),
|
||||
queryFn: () =>
|
||||
apiGet<NutrientDailySeries>(
|
||||
@@ -39,7 +39,7 @@ export function HydrationCard() {
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return <QueryErrorCard onRetry={refetch} />;
|
||||
return <QueryErrorCard error={error} onRetry={refetch} />;
|
||||
}
|
||||
|
||||
const todayTotal = data?.days.at(-1)?.amount ?? 0;
|
||||
|
||||
@@ -159,7 +159,7 @@ export function MicronutrientsCard() {
|
||||
// its "no micronutrient data" EmptyState — the same output as an account that
|
||||
// genuinely logs no nutrients. The sibling hydration card already routes
|
||||
// through QueryErrorCard; this one now matches it.
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.nutrientIntake(WINDOW_DAYS),
|
||||
queryFn: () =>
|
||||
apiGet<NutrientIntakeOverview>(`/api/nutrients?days=${WINDOW_DAYS}`),
|
||||
@@ -180,6 +180,7 @@ export function MicronutrientsCard() {
|
||||
<CardContent className="space-y-3">
|
||||
{isError ? (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
title={t("nutrients.micronutrients.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -30,13 +30,14 @@ interface FenceProps {
|
||||
|
||||
interface FenceState {
|
||||
failed: boolean;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
class SectionFence extends Component<FenceProps, FenceState> {
|
||||
state: FenceState = { failed: false };
|
||||
|
||||
static getDerivedStateFromError(): FenceState {
|
||||
return { failed: true };
|
||||
static getDerivedStateFromError(error: unknown): FenceState {
|
||||
return { failed: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
@@ -58,9 +59,10 @@ class SectionFence extends Component<FenceProps, FenceState> {
|
||||
data-section={this.props.sectionId}
|
||||
>
|
||||
<QueryErrorRow
|
||||
error={this.state.error}
|
||||
message={this.props.message}
|
||||
retryLabel={this.props.retryLabel}
|
||||
onRetry={() => this.setState({ failed: false })}
|
||||
onRetry={() => this.setState({ failed: false, error: undefined })}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -91,7 +91,7 @@ export function RhythmEventsCard({
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.insightsRhythmEvents(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
@@ -119,6 +119,7 @@ export function RhythmEventsCard({
|
||||
title={t("insights.rhythmEvents.sectionTitle")}
|
||||
/>
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
slot="rhythm-events-error"
|
||||
message={t("insights.rhythmEvents.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
|
||||
@@ -120,6 +120,7 @@ export function WorkoutDetailDayContext({
|
||||
{sleep.isError ? (
|
||||
// A failed read is not an absent night.
|
||||
<QueryErrorRow
|
||||
error={sleep.error}
|
||||
onRetry={sleep.refetch}
|
||||
slot="workout-detail-day-sleep-error"
|
||||
/>
|
||||
@@ -169,6 +170,7 @@ export function WorkoutDetailDayContext({
|
||||
</p>
|
||||
{mood.isError ? (
|
||||
<QueryErrorRow
|
||||
error={mood.error}
|
||||
onRetry={mood.refetch}
|
||||
slot="workout-detail-day-mood-error"
|
||||
/>
|
||||
|
||||
@@ -57,7 +57,7 @@ export function BiomarkerManager() {
|
||||
const canMerge = canManageDomain("labs");
|
||||
const [mergeOpen, setMergeOpen] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.biomarkers(),
|
||||
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
|
||||
});
|
||||
@@ -195,6 +195,7 @@ export function BiomarkerManager() {
|
||||
</div>
|
||||
) : isError ? (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
title={t("labs.biomarker.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -191,6 +191,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
data: list,
|
||||
isLoading,
|
||||
isError: listError,
|
||||
error: listFailure,
|
||||
refetch: refetchList,
|
||||
} = useInfiniteQuery({
|
||||
queryKey: queryKeys.labResultsInfinite({ biomarkerId, sortDir: "desc" }),
|
||||
@@ -332,6 +333,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
description={description}
|
||||
/>
|
||||
<QueryErrorCard
|
||||
error={markerError ? markerFailure : listFailure}
|
||||
title={t("labs.loadError")}
|
||||
onRetry={() => {
|
||||
if (markerError) void refetchMarker();
|
||||
|
||||
@@ -146,7 +146,7 @@ export function LabList({
|
||||
// (beneath the heading); the overview rows no longer carry it.
|
||||
// One entry per biomarker over the whole history, grouped server-side: a
|
||||
// capped page of raw rows dropped every marker measured only long ago.
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.labResultsOverview(),
|
||||
queryFn: () => apiGet<LabOverviewResponse>("/api/labs/overview"),
|
||||
});
|
||||
@@ -203,6 +203,7 @@ export function LabList({
|
||||
// outage never reads as "you have no lab results".
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
title={t("labs.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -34,6 +34,7 @@ export function LabValuesList({ biomarkerId }: { biomarkerId: string }) {
|
||||
data: list,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
@@ -63,6 +64,7 @@ export function LabValuesList({ biomarkerId }: { biomarkerId: string }) {
|
||||
if (isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
title={t("labs.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -243,7 +243,7 @@ export function EntityLinkPicker({
|
||||
onChange,
|
||||
searchPlaceholder,
|
||||
emptyLabel,
|
||||
error = false,
|
||||
error = null,
|
||||
errorLabel,
|
||||
onRetry,
|
||||
extraAction,
|
||||
@@ -263,9 +263,11 @@ export function EntityLinkPicker({
|
||||
/**
|
||||
* The option read failed. Renders an error row with Retry instead of the
|
||||
* empty label: a failed read must never pass for "nothing to link", which
|
||||
* is exactly how a refused page size once hid a whole vault.
|
||||
* is exactly how a refused page size once hid a whole vault. Pass the
|
||||
* failed read's error (or any truthy value for a failure without one); the
|
||||
* row reads its status.
|
||||
*/
|
||||
error?: boolean;
|
||||
error?: unknown;
|
||||
errorLabel?: string;
|
||||
onRetry?: () => void;
|
||||
/**
|
||||
@@ -369,6 +371,7 @@ export function EntityLinkPicker({
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
) : error ? (
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
message={errorLabel}
|
||||
onRetry={onRetry}
|
||||
slot={`${slot}-error`}
|
||||
|
||||
@@ -365,7 +365,7 @@ function MedicationPicker({ selected, onChange, known }: PickerProps) {
|
||||
title={t("links.related.pick.medication")}
|
||||
slot="related-link-medication"
|
||||
pending={list.isPending}
|
||||
error={list.isError}
|
||||
error={list.error}
|
||||
errorLabel={t("links.picker.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
selected={selected}
|
||||
@@ -403,7 +403,7 @@ function DosePicker({ selected, onChange, anchor, known }: PickerProps) {
|
||||
title={t("links.related.pick.vaccination")}
|
||||
slot="related-link-vaccination"
|
||||
pending={doses.isPending}
|
||||
error={doses.isError}
|
||||
error={doses.error}
|
||||
errorLabel={t("links.picker.loadError")}
|
||||
onRetry={() => void doses.refetch()}
|
||||
selected={selected}
|
||||
|
||||
@@ -199,9 +199,11 @@ export function useLinkPickerPages<T extends { id: string }>({
|
||||
return mergeWindowPages(newer.data?.pages ?? [], older.data?.pages ?? []);
|
||||
}, [searching, search.data, newer.data, older.data]);
|
||||
|
||||
const activeError = searching
|
||||
? search.isError
|
||||
: older.isError || (newerRequested && newer.isError);
|
||||
// The failed read's error itself, not a flag: the picker's error row reads
|
||||
// the status to tell a refusal or a spent budget from a real failure.
|
||||
const activeError: unknown = searching
|
||||
? search.error
|
||||
: (older.error ?? (newerRequested ? newer.error : null));
|
||||
|
||||
return {
|
||||
items,
|
||||
|
||||
@@ -81,7 +81,12 @@ export function VorsorgeDashboardCard() {
|
||||
// tell that this row had never been asked.
|
||||
const { canManageDomain } = useRecordCapabilities();
|
||||
const canManageMeasurements = canManageDomain("measurements");
|
||||
const { data: reminders, isError, refetch } = useMeasurementReminders();
|
||||
const {
|
||||
data: reminders,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useMeasurementReminders();
|
||||
|
||||
// A query's `isLoading` is not a hydration-safe branch: TanStack reports the
|
||||
// optimistic mount fetch on the client's very first render and cannot on the
|
||||
@@ -161,7 +166,7 @@ export function VorsorgeDashboardCard() {
|
||||
) : isError ? (
|
||||
// A read failure must not read as "no upcoming reminders" — a missed
|
||||
// checkup is the harm behind that fall-through (§6).
|
||||
<QueryErrorRow onRetry={() => refetch()} />
|
||||
<QueryErrorRow error={error} onRetry={() => refetch()} />
|
||||
) : upcoming.length === 0 ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
|
||||
@@ -243,6 +243,7 @@ export function VorsorgeSection({
|
||||
data: reminders,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useMeasurementReminders(enabled);
|
||||
const { create, update, remove, satisfy, skip, snooze } =
|
||||
@@ -736,6 +737,7 @@ export function VorsorgeSection({
|
||||
|
||||
{!isLoading && isError && (
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
message={t("measurementReminders.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ListRow } from "@/components/ui/list-row";
|
||||
import {
|
||||
@@ -606,7 +606,7 @@ export function MeasurementList({
|
||||
? "groupBy=day"
|
||||
: "raw";
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.measurementsList({
|
||||
type: typeFilter === "ALL" ? undefined : typeFilter,
|
||||
sourceEq,
|
||||
@@ -1034,7 +1034,7 @@ export function MeasurementList({
|
||||
) : isError ? (
|
||||
// A read failure is NOT an empty list — surface the error + Retry so
|
||||
// an outage never reads as "you have no measurements".
|
||||
<QueryErrorCard onRetry={() => void refetch()} />
|
||||
<QueryErrorCard error={error} onRetry={() => void refetch()} />
|
||||
) : !data?.measurements?.length ? (
|
||||
<>
|
||||
{/* v1.4.15 phase-C5: replaces the bare-text empty rectangle.
|
||||
@@ -1924,7 +1924,7 @@ function DayDrillDown({
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<ReadFailureRow
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
slot="measurement-drilldown-error"
|
||||
message={t("measurements.loadError")}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function EfficacyTab({
|
||||
const timezone = user?.timezone || DEFAULT_TIMEZONE;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.medicationEfficacy(medicationId),
|
||||
queryFn: () =>
|
||||
apiGet<MedicationEfficacyDTO>(
|
||||
@@ -128,7 +128,7 @@ export function EfficacyTab({
|
||||
title={t("medications.efficacy.title")}
|
||||
dataSlot="medication-wirkung-section"
|
||||
>
|
||||
<QueryErrorCard onRetry={() => refetch()} />
|
||||
<QueryErrorCard error={error} onRetry={() => refetch()} />
|
||||
</MedicationDetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -201,28 +201,29 @@ export function IntakeHistoryListV2({
|
||||
|
||||
const offset = page * pageSize;
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery<IntakeListResponse>({
|
||||
queryKey: queryKeys.medicationIntakeList(medicationId, {
|
||||
sortBy,
|
||||
sortDir,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
status: STATUS_FILTER,
|
||||
}),
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
const { data, isLoading, isError, error, refetch } =
|
||||
useQuery<IntakeListResponse>({
|
||||
queryKey: queryKeys.medicationIntakeList(medicationId, {
|
||||
sortBy,
|
||||
sortDir,
|
||||
limit: String(pageSize),
|
||||
offset: String(offset),
|
||||
limit: pageSize,
|
||||
offset,
|
||||
status: STATUS_FILTER,
|
||||
});
|
||||
return apiGet<IntakeListResponse>(
|
||||
`/api/medications/${medicationId}/intake?${params.toString()}`,
|
||||
);
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
}),
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
sortBy,
|
||||
sortDir,
|
||||
limit: String(pageSize),
|
||||
offset: String(offset),
|
||||
status: STATUS_FILTER,
|
||||
});
|
||||
return apiGet<IntakeListResponse>(
|
||||
`/api/medications/${medicationId}/intake?${params.toString()}`,
|
||||
);
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const sourceLabels: Record<IntakeSourceValue, string> = useMemo(
|
||||
() =>
|
||||
@@ -274,6 +275,7 @@ export function IntakeHistoryListV2({
|
||||
</div>
|
||||
) : isError ? (
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
title={t("medications.loadFailed")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -242,7 +242,10 @@ export function MedicationCategoriesSheet({
|
||||
>
|
||||
<div className="space-y-2" data-slot="medication-categories-sheet">
|
||||
{categories.isError ? (
|
||||
<QueryErrorRow onRetry={() => void categories.refetch()} />
|
||||
<QueryErrorRow
|
||||
error={categories.error}
|
||||
onRetry={() => void categories.refetch()}
|
||||
/>
|
||||
) : (
|
||||
(categories.data ?? []).map((category) => (
|
||||
<CategoryRow
|
||||
|
||||
@@ -155,6 +155,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
@@ -330,7 +331,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
)}
|
||||
|
||||
{!isLoading && isError && (
|
||||
<QueryErrorCard onRetry={() => void refetch()} />
|
||||
<QueryErrorCard error={error} onRetry={() => void refetch()} />
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && items.length === 0 && (
|
||||
|
||||
@@ -75,6 +75,7 @@ export function MentalWellbeing() {
|
||||
data: history,
|
||||
isPending: historyPending,
|
||||
isError: historyError,
|
||||
error: historyFailure,
|
||||
refetch: refetchHistory,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.mentalHealthAssessments(),
|
||||
@@ -166,6 +167,7 @@ export function MentalWellbeing() {
|
||||
<h2 className="sr-only">{t("mentalHealth.choosePrompt")}</h2>
|
||||
{historyError ? (
|
||||
<QueryErrorCard
|
||||
error={historyFailure}
|
||||
title={t("mentalHealth.historyLoadError")}
|
||||
description={t("mentalHealth.historyLoadErrorHint")}
|
||||
onRetry={() => void refetchHistory()}
|
||||
|
||||
@@ -340,7 +340,7 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) {
|
||||
const toParam = toDay || undefined;
|
||||
const sourceParam = sourceFilter === "ALL" ? undefined : sourceFilter;
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: queryKeys.moodEntriesList({
|
||||
mood: moodFilter === "ALL" ? undefined : moodFilter,
|
||||
source: sourceParam,
|
||||
@@ -714,6 +714,7 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) {
|
||||
// A read failure is NOT an empty list — surface the error + Retry so
|
||||
// an outage never reads as "you have no mood entries".
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
title={t("mood.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -75,7 +75,7 @@ export function MoodQuickTags({
|
||||
}
|
||||
if (isError && !data) {
|
||||
return (
|
||||
<ReadFailureRow
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
slot="mood-quick-tags-error"
|
||||
message={t("mood.tagCatalogLoadError")}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Plus, Tag } from "lucide-react";
|
||||
|
||||
import { ReadFailureRow } from "@/components/ui/read-failure-row";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -124,7 +124,7 @@ export function MoodTagPicker({
|
||||
|
||||
if (isError && !data) {
|
||||
return (
|
||||
<ReadFailureRow
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
slot="mood-tag-picker-error"
|
||||
message={t("mood.tagCatalogLoadError")}
|
||||
|
||||
@@ -129,6 +129,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
|
||||
{list.isError ? (
|
||||
<QueryErrorCard
|
||||
error={list.error}
|
||||
title={t("practitioners.loadError")}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
|
||||
@@ -184,6 +184,7 @@ export function AboutMeNoteManager() {
|
||||
|
||||
{query.isError && (
|
||||
<QueryErrorRow
|
||||
error={query.error}
|
||||
message={t("settings.ai.aboutMe.loadError")}
|
||||
onRetry={() => query.refetch()}
|
||||
/>
|
||||
|
||||
@@ -110,6 +110,7 @@ export function AiProfileInclusionManager() {
|
||||
if (query.isError) {
|
||||
return (
|
||||
<QueryErrorCard
|
||||
error={query.error}
|
||||
title={t("records.aiInclusion.loadError")}
|
||||
onRetry={() => void query.refetch()}
|
||||
/>
|
||||
|
||||
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user