fix(ui): draw every failed read through a read-failure primitive

Around thirty surfaces drew a failed read their own way: a bare
destructive paragraph with no retry, a muted line saying the data was
still being learned, a skeleton that never ended, an error card nested
in a tile, or nothing at all. None of them told an ended session from a
refusal. They now use QueryErrorRow or QueryErrorCard with the query's
error and a retry, inset where a card already frames them, and keep
their card and title where the page would otherwise jump. Hydration and
caffeine now fail the same way.

The recovery page no longer points to Integrations while Oura's first
resilience level is simply on its way.

A guard reads every read hook's isError branch and refuses hand-rolled
markup there, and refuses a framed error row inside a card.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 10:10:49 +02:00
1 parent a1b56f0b75
commit 923249dbf6
60 files changed
+579 -175

No files matched your search

+4 -2
View File
@@ -4678,7 +4678,8 @@
"averageHeartRateExplainer": "Deine durchschnittliche Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
"maxHeartRateExplainer": "Deine höchste Herzfrequenz über den ganzen Tag bzw. Zyklus, wie dein Wearable sie meldet.",
"energyExpenditureKjExplainer": "Die Energie, die dein Körper über den Tag verbraucht hat, in Kilojoule, wie dein Gerät sie nativ meldet."
}
},
"loadError": "Deine Erholungswerte konnten nicht geladen werden."
},
"bodyTempDeviation": {
"title": "Körpertemperatur-Abweichung",
@@ -11906,7 +11907,8 @@
}
},
"hydration": {
"referenceMeta": "Richtwert {value} {unit}/Tag (EFSA)"
"referenceMeta": "Richtwert {value} {unit}/Tag (EFSA)",
"loadError": "Wasser konnte nicht geladen werden."
},
"caffeine": {
"ceilingMeta": "Sichere Obergrenze {value} {unit}/Tag (EFSA)",
+4 -2
View File
@@ -4678,7 +4678,8 @@
"averageHeartRateExplainer": "Your average heart rate across the whole day or cycle, as reported by your wearable.",
"maxHeartRateExplainer": "Your peak heart rate across the whole day or cycle, as reported by your wearable.",
"energyExpenditureKjExplainer": "The energy your body expended over the day, in kilojoules, as your device reports it natively."
}
},
"loadError": "Your recovery readings could not be loaded."
},
"bodyTempDeviation": {
"title": "Body temperature deviation",
@@ -11906,7 +11907,8 @@
}
},
"hydration": {
"referenceMeta": "Reference intake {value} {unit}/day (EFSA)"
"referenceMeta": "Reference intake {value} {unit}/day (EFSA)",
"loadError": "Could not load water."
},
"caffeine": {
"ceilingMeta": "Safe-level ceiling {value} {unit}/day (EFSA)",
+4 -2
View File
@@ -4678,7 +4678,8 @@
"averageHeartRateExplainer": "Tu frecuencia cardíaca media a lo largo de todo el día o ciclo, según informa tu wearable.",
"maxHeartRateExplainer": "Tu frecuencia cardíaca máxima a lo largo de todo el día o ciclo, según informa tu wearable.",
"energyExpenditureKjExplainer": "La energía que tu cuerpo gastó durante el día, en kilojulios, tal como tu dispositivo la informa de forma nativa."
}
},
"loadError": "No se pudieron cargar tus valores de recuperación."
},
"bodyTempDeviation": {
"title": "Desviación de la temperatura corporal",
@@ -11906,7 +11907,8 @@
}
},
"hydration": {
"referenceMeta": "Ingesta de referencia {value} {unit}/día (EFSA)"
"referenceMeta": "Ingesta de referencia {value} {unit}/día (EFSA)",
"loadError": "No se pudo cargar el agua."
},
"caffeine": {
"ceilingMeta": "Límite de seguridad {value} {unit}/día (EFSA)",
+4 -2
View File
@@ -4678,7 +4678,8 @@
"averageHeartRateExplainer": "Votre fréquence cardiaque moyenne sur toute la journée ou le cycle, telle que rapportée par votre wearable.",
"maxHeartRateExplainer": "Votre fréquence cardiaque maximale sur toute la journée ou le cycle, telle que rapportée par votre wearable.",
"energyExpenditureKjExplainer": "L'énergie que votre corps a dépensée sur la journée, en kilojoules, telle que votre appareil la rapporte nativement."
}
},
"loadError": "Vos valeurs de récupération n'ont pas pu être chargées."
},
"bodyTempDeviation": {
"title": "Écart de température corporelle",
@@ -11906,7 +11907,8 @@
}
},
"hydration": {
"referenceMeta": "Apport de référence {value} {unit}/jour (EFSA)"
"referenceMeta": "Apport de référence {value} {unit}/jour (EFSA)",
"loadError": "Impossible de charger l'eau."
},
"caffeine": {
"ceilingMeta": "Plafond de sécurité {value} {unit}/jour (EFSA)",
+4 -2
View File
@@ -4678,7 +4678,8 @@
"averageHeartRateExplainer": "La tua frequenza cardiaca media nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.",
"maxHeartRateExplainer": "La tua frequenza cardiaca massima nell'arco dell'intera giornata o ciclo, come riportata dal tuo wearable.",
"energyExpenditureKjExplainer": "L'energia che il tuo corpo ha consumato nella giornata, in kilojoule, come la riporta nativamente il tuo dispositivo."
}
},
"loadError": "Non è stato possibile caricare i tuoi valori di recupero."
},
"bodyTempDeviation": {
"title": "Deviazione della temperatura corporea",
@@ -11906,7 +11907,8 @@
}
},
"hydration": {
"referenceMeta": "Apporto di riferimento {value} {unit}/giorno (EFSA)"
"referenceMeta": "Apporto di riferimento {value} {unit}/giorno (EFSA)",
"loadError": "Impossibile caricare l'acqua."
},
"caffeine": {
"ceilingMeta": "Limite di sicurezza {value} {unit}/giorno (EFSA)",
+4 -2
View File
@@ -4678,7 +4678,8 @@
"averageHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 평균 심박수예요.",
"maxHeartRateExplainer": "웨어러블이 보고한 하루 또는 주기 전체의 최고 심박수예요.",
"energyExpenditureKjExplainer": "기기가 자체 단위로 보고한, 하루 동안 몸이 쓴 에너지예요(킬로줄)."
}
},
"loadError": "회복 값을 불러오지 못했어요."
},
"bodyTempDeviation": {
"title": "체온 편차",
@@ -11906,7 +11907,8 @@
}
},
"hydration": {
"referenceMeta": "권장 섭취량 {value} {unit}/일 (EFSA)"
"referenceMeta": "권장 섭취량 {value} {unit}/일 (EFSA)",
"loadError": "수분을 불러오지 못했어요."
},
"caffeine": {
"ceilingMeta": "안전 상한 {value} {unit}/일 (EFSA)",
+4 -2
View File
@@ -4678,7 +4678,8 @@
},
"emptyAction": "Integraties openen",
"emptyOuraNoResilience": "Oura is verbonden, en het veerkrachtniveau verschijnt hier zodra Oura er een levert.",
"emptyOuraPermission": "Het veerkrachtniveau van Oura heeft toegang tot hartgezondheid nodig, dus verbind Oura één keer opnieuw onder Integraties en sta dat toe."
"emptyOuraPermission": "Het veerkrachtniveau van Oura heeft toegang tot hartgezondheid nodig, dus verbind Oura één keer opnieuw onder Integraties en sta dat toe.",
"loadError": "Je herstelwaarden konden niet worden geladen."
},
"bodyTempDeviation": {
"title": "Afwijking lichaamstemperatuur",
@@ -11906,7 +11907,8 @@
}
},
"hydration": {
"referenceMeta": "Referentie-inname {value} {unit}/dag (EFSA)"
"referenceMeta": "Referentie-inname {value} {unit}/dag (EFSA)",
"loadError": "Water kon niet worden geladen."
},
"caffeine": {
"ceilingMeta": "Veilige bovengrens {value} {unit}/dag (EFSA)",
+4 -2
View File
@@ -4678,7 +4678,8 @@
"averageHeartRateExplainer": "Twoje średnie tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.",
"maxHeartRateExplainer": "Twoje maksymalne tętno w ciągu całego dnia lub cyklu, zgodnie z odczytem Twojego urządzenia.",
"energyExpenditureKjExplainer": "Energia, którą Twój organizm wydatkował w ciągu dnia, w kilodżulach, zgodnie z natywnym odczytem urządzenia."
}
},
"loadError": "Nie udało się załadować Twoich wartości regeneracji."
},
"bodyTempDeviation": {
"title": "Odchylenie temperatury ciała",
@@ -11906,7 +11907,8 @@
}
},
"hydration": {
"referenceMeta": "Zalecane spożycie {value} {unit}/dzień (EFSA)"
"referenceMeta": "Zalecane spożycie {value} {unit}/dzień (EFSA)",
"loadError": "Nie udało się załadować wody."
},
"caffeine": {
"ceilingMeta": "Bezpieczny limit {value} {unit}/dzień (EFSA)",
@@ -0,0 +1,182 @@
/**
* A failed read draws a read-failure primitive, and draws it the right way.
*
* Two shapes kept coming back beside the primitives:
*
* - A read's `isError` branch rendering its own markup: a bare
* `text-destructive` paragraph with no retry, a muted line that reads as
* "still learning", a hand-rolled row. None of them knows an ended session
* from a refusal, which is the decision `QueryErrorRow`, `QueryErrorCard`
* and `ChartErrorState` make from the error.
* - A `QueryErrorRow` with its own frame inside a card that already draws
* one, a card inside a card. Inside a card body the row is `inset`.
*
* Both readers are lexical. A read is a hook call (`const x = useFoo(` or a
* destructure with `isError`) that is not `useMutation` and whose result is
* never `.mutate`d; a mutation's error is inline `role="alert"` text by the
* standards, so it is left alone. The branch is the text after `isError … ?`
* or `&&` up to the next `) : `. Nesting is the count of card tags opened and
* not closed before the row. Each reader is checked against a fixture of the
* shape it refuses, and the tree must yield a floor of matches, so a matcher
* that stops finding anything fails.
*/
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;
}
function escape(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const PRIMITIVE = /QueryErrorRow|QueryErrorCard|ChartErrorState/;
/** Every read `isError` branch: its line and whether it hand-rolls markup. */
function readErrorBranches(
source: string,
): Array<{ line: number; handRolled: boolean }> {
const reads = new Set<string>();
for (const m of source.matchAll(
/const\s+(\w+)\s*=\s*(use\w+)\s*(?:<[^(]*>)?\(/g,
)) {
if (m[2] === "useMutation") continue;
if (new RegExp(`${escape(m[1])}\\.mutate(?:Async)?\\(`).test(source))
continue;
reads.add(m[1]);
}
const alternatives = [...reads].map((v) => `${escape(v)}\\.isError`);
const destructured = [
...source.matchAll(
/const\s*\{[^}]*\bisError\b[^}]*\}\s*=\s*(use\w+)\s*(?:<[^(]*>)?\(/g,
),
].some((m) => m[1] !== "useMutation");
if (destructured) alternatives.push("(?<![\\w.])isError");
if (alternatives.length === 0) return [];
const branch = new RegExp(
`(?:${alternatives.join("|")})\\b[^\\n]*?(\\?|&&)`,
"g",
);
const found: Array<{ line: number; handRolled: boolean }> = [];
for (const m of source.matchAll(branch)) {
if (source[m.index - 1] === "!") continue;
const body = source.slice(
m.index + m[0].length,
m.index + m[0].length + 500,
);
const arm = body.split(") : ")[0];
found.push({
line: source.slice(0, m.index).split("\n").length,
handRolled:
!PRIMITIVE.test(arm) && /<(p|div|span)\b/.test(arm.slice(0, 250)),
});
}
return found;
}
const CARD_TAGS = ["SettingsCard", "Card", "CardContent", "InsightSectionCard"];
/** Lines of `QueryErrorRow`s without `inset` inside an open card tag. */
function framedRowsInCards(source: string): number[] {
const lines: number[] = [];
for (const m of source.matchAll(/<QueryErrorRow\b([\s\S]*?)\/>/g)) {
if (/\binset\b/.test(m[1])) continue;
const before = source.slice(0, m.index);
let depth = 0;
for (const tag of CARD_TAGS) {
depth += (before.match(new RegExp(`<${tag}(?=[\\s>])`, "g")) ?? [])
.length;
depth -= (before.match(new RegExp(`</${tag}>`, "g")) ?? []).length;
depth -= (before.match(new RegExp(`<${tag}(?=\\s)[^>]*?/>`, "g")) ?? [])
.length;
}
if (depth > 0) lines.push(before.split("\n").length);
}
return lines;
}
const files = walk(SRC).map((file) => ({
file: relative(SRC, file),
source: readFileSync(file, "utf8"),
}));
describe("read failures render through a primitive", () => {
it("recognises a hand-rolled read failure", () => {
const rolled = `
const { data, isError } = useQuery({ queryKey: k, queryFn: f });
return isError ? (
<p className="text-destructive text-sm">{t("x.loadError")}</p>
) : null;`;
expect(readErrorBranches(rolled)).toEqual([{ line: 3, handRolled: true }]);
const mutation = `
const save = useSave();
save.mutate(body);
return save.isError ? <p role="alert">{t("x.saveError")}</p> : null;`;
expect(readErrorBranches(mutation)).toEqual([]);
const primitive = `
const list = useList();
return list.isError ? (
<QueryErrorRow error={list.error} onRetry={list.refetch} />
) : null;`;
expect(readErrorBranches(primitive)).toEqual([
{ line: 3, handRolled: false },
]);
});
it("finds the read branches it guards", () => {
const branches = files.flatMap(({ source }) => readErrorBranches(source));
// About 100 at the time of writing.
expect(branches.length).toBeGreaterThan(80);
});
it("never hand-rolls one beside a read", () => {
const offenders = files.flatMap(({ file, source }) =>
readErrorBranches(source)
.filter((b) => b.handRolled)
.map((b) => `${file}:${b.line}`),
);
expect(offenders).toEqual([]);
});
});
describe("an error row inside a card draws no frame of its own", () => {
it("recognises a framed row inside a card", () => {
expect(
framedRowsInCards(
`<Card>\n<CardContent>\n<QueryErrorRow error={e} onRetry={() => r()} />\n</CardContent>\n</Card>`,
),
).toEqual([3]);
expect(
framedRowsInCards(
`<Card>\n</Card>\n<QueryErrorRow error={e} onRetry={() => r()} />`,
),
).toEqual([]);
});
it("finds inset rows in the tree, so the reader is live", () => {
// About 40 files at the time of writing.
const inset = files.filter(({ source }) =>
/<QueryErrorRow\b[^/]*\binset\b/.test(source),
);
expect(inset.length).toBeGreaterThan(20);
});
it("insets every row a card already frames", () => {
const offenders = files.flatMap(({ file, source }) =>
framedRowsInCards(source).map((line) => `${file}:${line}`),
);
expect(offenders).toEqual([]);
});
});
+1
View File
@@ -368,6 +368,7 @@ export default function MetricCatalogPage() {
<CardContent className="space-y-2">
{failed.length > 0 ? (
<QueryErrorRow
inset
error={probes[failed[0]].error}
slot="catalog-group-error"
message={t("metricCatalog.loadError")}
+1
View File
@@ -479,6 +479,7 @@ export default function InsightsPageClient() {
read={dashboardDerived.read}
isLoading={dashboardDerived.isLoading || cycleRing.pending}
isError={dashboardDerived.isError}
error={dashboardDerived.error}
refetch={dashboardDerived.refetch}
// v1.15.3 — the cycle ring rides the scores strip as a gated sibling
// tile, only for a cycle-tracking account, so its calendar read never
@@ -104,6 +104,7 @@ export function AiQualitySection() {
</div>
) : query.isError ? (
<QueryErrorRow
inset
error={query.error}
message={t("admin.aiQuality.loadError")}
onRetry={() => void query.refetch()}
+1
View File
@@ -1243,6 +1243,7 @@ export function BackupsSection() {
</div>
) : isError ? (
<QueryErrorRow
inset
error={error}
message={t("admin.section.backups.loadError")}
onRetry={() => void refetch()}
@@ -99,6 +99,7 @@ export function CoachFeedbackSection() {
</div>
) : query.isError ? (
<QueryErrorRow
inset
error={query.error}
message={t("admin.coachFeedback.loadError")}
onRetry={() => void query.refetch()}
+10 -3
View File
@@ -52,6 +52,7 @@ import {
} from "@/components/settings/security-section/use-recent-proof";
import { KeyBackupCard } from "./key-backup-card";
import { useKeyBackupStatus } from "./use-key-backup-status";
import { QueryErrorRow } from "@/components/ui/query-error-row";
interface ColumnScan {
model: string;
@@ -182,9 +183,15 @@ export function EncryptionSection() {
{t("admin.section.encryption.coverageDetail")}
</p>
<p role="alert" className="text-destructive text-sm">
{t("admin.section.encryption.loadError")}
</p>
{statusQuery.isError ? (
<QueryErrorRow
inset
error={statusQuery.error}
slot="encryption-status-error"
message={t("admin.section.encryption.loadError")}
onRetry={() => void statusQuery.refetch()}
/>
) : null}
</SettingsCard>
</div>
);
@@ -198,6 +198,7 @@ export function HostMetricsChart() {
}
/>
<QueryErrorRow
inset
error={error}
message={t("admin.hostMetrics.loadError")}
onRetry={() => void refetch()}
+14 -4
View File
@@ -75,6 +75,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
import { QueryErrorRow } from "@/components/ui/query-error-row";
interface InviteRedemptionEntry {
id: string;
@@ -192,7 +193,12 @@ export function InviteTokensSection() {
const [copied, setCopied] = useState(false);
const [revokeTarget, setRevokeTarget] = useState<AdminInvite | null>(null);
const { data: invites, isError } = useQuery({
const {
data: invites,
isError,
error,
refetch,
} = useQuery({
queryKey: queryKeys.adminInvites(),
queryFn: async () => {
return apiGet<AdminInvite[]>("/api/admin/invites");
@@ -313,9 +319,13 @@ export function InviteTokensSection() {
/>
{isError && (
<p className="text-destructive text-sm">
{t("admin.invites.loadError")}
</p>
<QueryErrorRow
inset
error={error}
slot="admin-invites-error"
message={t("admin.invites.loadError")}
onRetry={() => void refetch()}
/>
)}
{invites && invites.length === 0 && (
+1
View File
@@ -155,6 +155,7 @@ export function KeyBackupCard() {
/>
{status.isError ? (
<QueryErrorRow
inset
error={status.error}
message={t("admin.keyBackup.loadError")}
onRetry={() => void status.refetch()}
@@ -170,6 +170,7 @@ export function OffhostBackupsSection() {
</div>
) : isError ? (
<QueryErrorRow
inset
error={error}
message={t("admin.section.backups.offhost.loadError")}
onRetry={() => void refetch()}
@@ -10,6 +10,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { formatDateTime } from "@/lib/format";
import { QueryErrorRow } from "@/components/ui/query-error-row";
/**
* v1.37.31 — the delivery-health readout the `provider_health` ledger never
@@ -66,7 +67,7 @@ export function ProviderHealthSection() {
const groupDigits = (value: number): string =>
new Intl.NumberFormat(locale).format(value);
const { data, isPending, isError } = useQuery({
const { data, isPending, isError, error, refetch } = useQuery({
queryKey: queryKeys.adminProviderHealth(),
queryFn: () =>
apiGet<{
@@ -134,9 +135,13 @@ export function ProviderHealthSection() {
{t("admin.providerHealth.loading")}
</p>
) : isError ? (
<p className="text-muted-foreground text-sm">
{t("admin.providerHealth.loadError")}
</p>
<QueryErrorRow
inset
error={error}
slot="provider-health-error"
message={t("admin.providerHealth.loadError")}
onRetry={() => void refetch()}
/>
) : providers.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("admin.providerHealth.empty")}
@@ -84,6 +84,7 @@ export function RecentAuditPreview() {
</div>
) : isError ? (
<QueryErrorRow
inset
error={error}
message={t("admin.overview.auditLoadError")}
onRetry={() => void refetch()}
@@ -277,6 +277,7 @@ export function SystemStatusSection() {
// transient 500 (rolling deploy, DB blip) doesn't require a
// full page reload to recover.
<QueryErrorRow
inset
error={error}
message={t("admin.systemStatusLoadError")}
onRetry={() => void refetch()}
@@ -110,6 +110,7 @@ export function SystemStatusSummary() {
</div>
) : isError ? (
<QueryErrorRow
inset
error={error}
message={t("admin.overview.snapshotLoadError")}
onRetry={() => void refetch()}
@@ -61,9 +61,15 @@ vi.mock("next/link", () => ({
const calendarState = vi.hoisted(() => ({
current: {
data: undefined as CalendarResponse | undefined,
data: undefined,
isLoading: false,
isError: false,
} as {
data: CalendarResponse | undefined;
isLoading: boolean;
isError: boolean;
error?: unknown;
refetch?: () => unknown;
},
}));
const insightsState = vi.hoisted(() => ({
@@ -223,13 +229,17 @@ describe("<CycleInsightSummaryCard>", () => {
expect(html).toBe("");
});
it("renders nothing on a hard calendar read error", () => {
it("says a hard calendar read error, with a retry", () => {
// Drawing nothing would read as "no cycle tracked".
calendarState.current = {
data: undefined,
isLoading: false,
isError: true,
error: new Error("boom"),
refetch: () => undefined,
};
const html = render(<CycleInsightSummaryCard />);
expect(html).toBe("");
expect(html).toContain('data-slot="cycle-insight-summary-error"');
expect(html).toContain('data-slot="query-error-row-retry"');
});
});
@@ -9,6 +9,7 @@ import { SectionHeading } from "@/components/ui/section-heading";
import { CyclePhaseHeadline } from "./cycle-phase-crosstab";
import { PHASE_HUE } from "./phase-tokens";
import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle";
import { QueryErrorRow } from "@/components/ui/query-error-row";
/**
* v1.15.2 — the gated cycle-insights summary card for the main Insights page.
@@ -56,11 +57,29 @@ export function CycleInsightSummaryCard() {
// The server's resolved verdict — the teaser reports it, never recomputes it.
const verdict = calendar.data?.verdict;
// Stay silent until the calendar read resolves, and on a hard error: the
// overview must never show a half-painted or error-framed cycle teaser. The
// full cycle page owns the retry affordance.
// Stay silent until the calendar read resolves: the overview must never
// show a half-painted teaser. A failed read is said with a retry, since
// drawing nothing would read as "no cycle tracked".
if (calendar.isLoading && !calendar.data) return null;
if (calendar.isError && !calendar.data) return null;
if (calendar.isError && !calendar.data) {
return (
<section
data-slot="cycle-insight-summary-section"
aria-label={t("cycle.insightsSummary.title")}
className="space-y-3"
>
<SectionHeading
icon={Droplets}
title={t("cycle.insightsSummary.title")}
/>
<QueryErrorRow
error={calendar.error}
slot="cycle-insight-summary-error"
onRetry={() => void calendar.refetch()}
/>
</section>
);
}
const phase = verdict?.phase ?? null;
const hue = phase ? PHASE_HUE[phase] : PHASE_HUE.LUTEAL;
+1 -1
View File
@@ -362,10 +362,10 @@ 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
inset
error={calendar.error}
message={t("cycle.loadError")}
onRetry={() => void calendar.refetch()}
className="border-0 bg-transparent p-0"
/>
) : (
<>
@@ -91,10 +91,12 @@ describe("<RecentWorkoutsTile>", () => {
expect(html).toContain('data-slot="skeleton"');
});
it("renders a retry card instead of the empty state on a failed fetch", () => {
it("renders a retry row instead of the empty state on a failed fetch", () => {
mockResult.isError = true;
const html = render();
expect(html).toContain('data-slot="query-error-card"');
// A row without a frame of its own: the tile is already the card.
expect(html).toContain('data-slot="recent-workouts-error"');
expect(html).not.toContain('data-slot="query-error-card"');
// A failed fetch must never read as the honest "no workouts yet"
// empty state — that would misreport an outage as an empty account.
expect(html).not.toContain('data-slot="recent-workouts-empty"');
@@ -10,7 +10,7 @@ import { cn } from "@/lib/utils";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { iconForSport } from "@/lib/workouts/sport-icons";
import { translateOrNull } from "@/lib/i18n/translate-code";
@@ -105,10 +105,11 @@ export function RecentWorkoutsTile() {
))}
</div>
) : isError ? (
<QueryErrorCard
<QueryErrorRow
inset
error={error}
onRetry={() => refetch()}
className="border-0 bg-transparent shadow-none"
slot="recent-workouts-error"
onRetry={() => void refetch()}
/>
) : workouts.length === 0 ? (
<div data-slot="recent-workouts-empty" className="space-y-1">
@@ -103,6 +103,7 @@ import {
useSuggestDetails,
} from "./use-document-assist";
import { formatBytes } from "./vault-utils";
import { QueryErrorRow } from "@/components/ui/query-error-row";
type PatchInput = {
title?: string | null;
@@ -895,9 +896,13 @@ export function DocumentDetailSheet({
) : null}
{detail.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.detail.loadError")}
</p>
<QueryErrorRow
inset
error={detail.error}
slot="document-detail-error"
message={t("documents.detail.loadError")}
onRetry={() => void detail.refetch()}
/>
) : null}
{doc ? (
@@ -18,6 +18,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { useIllnessCorrelation } from "./use-illness";
import type { IllnessCorrelationValue, IllnessVitalDeviation } from "./types";
import { QueryErrorRow } from "@/components/ui/query-error-row";
/** Translate a MeasurementType to its illness-surface label. */
function useVitalLabel() {
@@ -168,7 +169,8 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
export function IllnessCorrelationCard({ episodeId }: { episodeId: string }) {
const { t } = useTranslations();
const { data, isLoading, isError } = useIllnessCorrelation(episodeId);
const { data, isLoading, isError, error, refetch } =
useIllnessCorrelation(episodeId);
return (
<Card>
@@ -186,7 +188,15 @@ export function IllnessCorrelationCard({ episodeId }: { episodeId: string }) {
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</div>
) : isError || !data || data.status !== "ok" || !data.value ? (
) : isError && !data ? (
// A failed read is not "still learning": it says so and can retry.
<QueryErrorRow
inset
error={error}
slot="illness-correlation-error"
onRetry={() => void refetch()}
/>
) : !data || data.status !== "ok" || !data.value ? (
<p className="text-muted-foreground text-sm">
{t("illness.correlation.learning")}
</p>
@@ -25,6 +25,7 @@ import { useUnitDisplay } from "@/hooks/use-unit-display";
import { ILLNESS_SYMPTOM_CATALOG } from "./symptom-catalog";
import { useIllnessDayLogList } from "./use-illness";
import type { IllnessDayLogDTO } from "./types";
import { QueryErrorRow } from "@/components/ui/query-error-row";
function symptomLabelKey(key: string): string | null {
return ILLNESS_SYMPTOM_CATALOG.find((s) => s.key === key)?.labelKey ?? null;
@@ -106,7 +107,10 @@ export function IllnessDayTimeline({
// v1.36.x — logging a day is not a delegated verb.
const { canManageDomain } = useRecordCapabilities();
const canManageIllness = canManageDomain("illness");
const { data, isLoading, isError } = useIllnessDayLogList(episodeId, "desc");
const { data, isLoading, isError, error, refetch } = useIllnessDayLogList(
episodeId,
"desc",
);
const dayLogs = data?.dayLogs ?? [];
@@ -122,9 +126,13 @@ export function IllnessDayTimeline({
<Skeleton className="h-4 w-2/3" />
</div>
) : isError ? (
<p className="text-muted-foreground text-sm">
{t("illness.timeline.error")}
</p>
<QueryErrorRow
inset
error={error}
slot="illness-timeline-error"
message={t("illness.timeline.error")}
onRetry={() => void refetch()}
/>
) : dayLogs.length === 0 ? (
<div className="space-y-3">
<p className="text-muted-foreground text-sm">
@@ -20,6 +20,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { useIllnessInsights } from "./use-illness";
import { QueryErrorRow } from "@/components/ui/query-error-row";
export function IllnessInsightsCard({
windowDays = 365,
@@ -55,6 +56,13 @@ export function IllnessInsightsCard({
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</div>
) : counts.isError && !data ? (
<QueryErrorRow
inset
error={counts.error}
slot="illness-insights-error"
onRetry={() => void counts.refetch()}
/>
) : !hasEpisodes ? (
<p className="text-muted-foreground text-sm">
{t("illness.insights.none")}
@@ -83,11 +91,14 @@ export function IllnessInsightsCard({
<div className="space-y-2">
<Skeleton className="h-4 w-1/2" />
</div>
) : gap.isError || !gap.data ? (
<p className="text-muted-foreground">
{t("illness.insights.typicalGapLearning")}
</p>
) : gap.data.typicalRecoveryGapDays === null ? (
) : gap.isError && !gap.data ? (
<QueryErrorRow
inset
error={gap.error}
slot="illness-insights-gap-error"
onRetry={() => void gap.refetch()}
/>
) : !gap.data || gap.data.typicalRecoveryGapDays === null ? (
<p className="text-muted-foreground">
{t("illness.insights.typicalGapLearning")}
</p>
@@ -54,12 +54,12 @@ const OURA_SUMMARIES = {
HRV_RMSSD: summary(30),
};
function render(recoveryEmpty: string | null) {
function render(recoveryEmpty: string | null, error: unknown = null) {
analyticsMock.mockReturnValue({
data: { summaries: OURA_SUMMARIES, recoveryEmpty },
data: error ? undefined : { summaries: OURA_SUMMARIES, recoveryEmpty },
isLoading: false,
isEmpty: false,
error: null,
error,
refetch: () => {},
});
const queryClient = new QueryClient({
@@ -81,7 +81,6 @@ beforeEach(() => {
describe("<RecoverySection> empty note", () => {
it.each([
["oura_permission", "heart-health access"],
["oura_no_resilience", "as soon as Oura provides one"],
["general", "WHOOP or Polar"],
])("renders the server's %s reason with the one action", (reason, text) => {
const html = render(reason);
@@ -90,6 +89,20 @@ describe("<RecoverySection> empty note", () => {
expect(html).toContain('href="/settings/integrations"');
});
it("offers nothing to open while Oura's first level is on its way", () => {
// The connection is fine; there is nothing under Integrations to do.
const html = render("oura_no_resilience");
expect(html).toContain('data-empty-case="oura_no_resilience"');
expect(html).toContain("as soon as Oura provides one");
expect(html).not.toContain('href="/settings/integrations"');
});
it("says a failed read instead of a skeleton that never ends", () => {
const html = render(null, new Error("boom"));
expect(html).toContain('data-slot="query-error-card"');
expect(html).not.toContain('data-slot="recovery-loading"');
});
it("shows no note when the server says the page has data", () => {
const html = render(null);
expect(html).not.toContain("data-empty-case");
@@ -15,6 +15,7 @@ import { cn } from "@/lib/utils";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "@/components/documents/document-kind-meta";
import { QueryErrorRow } from "@/components/ui/query-error-row";
/**
* v1.29.x (S7) — the "Choose from documents" picker for attaching stored vault
@@ -181,9 +182,13 @@ export function AttachmentPicker({
))}
</div>
) : list.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.list.loadError")}
</p>
<QueryErrorRow
inset
error={list.error}
slot="coach-attachment-picker-error"
message={t("documents.list.loadError")}
onRetry={() => void list.refetch()}
/>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{t("insights.coach.attach.pickerEmpty")}
@@ -24,6 +24,7 @@ import {
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
import { QueryErrorRow } from "@/components/ui/query-error-row";
/**
* The quick model choice in the Coach's settings overlay: which provider
@@ -195,21 +196,15 @@ export function CoachModelPicker() {
if (chainQuery.isError || providerQuery.isError) {
return (
<div className="flex flex-col items-start gap-2 text-sm">
<p role="alert" className="text-destructive">
{t("common.loadFailed")}
</p>
<Button
variant="outline"
size="sm"
onClick={() => {
void chainQuery.refetch();
void providerQuery.refetch();
}}
>
{t("common.retry")}
</Button>
</div>
<QueryErrorRow
inset
error={chainQuery.error ?? providerQuery.error}
slot="coach-model-picker-error"
onRetry={() => {
void chainQuery.refetch();
void providerQuery.refetch();
}}
/>
);
}
@@ -7,7 +7,6 @@ import {
MoreHorizontal,
Paperclip,
Pencil,
RotateCw,
Trash2,
} from "lucide-react";
import { toast } from "sonner";
@@ -30,6 +29,7 @@ import {
useCoachConversationHistory,
useDeleteCoachConversationWithUndo,
} from "./use-coach";
import { QueryErrorRow } from "@/components/ui/query-error-row";
/**
* The Coach's conversation list: the full history grouped by recency
@@ -88,6 +88,7 @@ export function HistoryRail({
conversations,
isLoading,
isError,
error,
refetch,
hasNextPage,
isFetchingNextPage,
@@ -160,16 +161,13 @@ export function HistoryRail({
</p>
) : isError && visible.length === 0 ? (
// A load failure must not read as "no conversations yet".
<div
data-slot="coach-history-error"
className="text-muted-foreground flex flex-col items-start gap-2 px-2 py-3 text-sm"
>
<p>{t("common.loadFailed")}</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RotateCw className="size-3.5" aria-hidden="true" />
{t("common.retry")}
</Button>
</div>
<QueryErrorRow
inset
error={error}
slot="coach-history-error"
onRetry={() => void refetch()}
className="px-2 py-3"
/>
) : groups.length === 0 ? (
<p
data-slot="coach-history-empty"
@@ -130,11 +130,12 @@ export function CoachReadStrip({
<CardContent className={STRIP_BODY}>
{isError ? (
<QueryErrorRow
inset
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"
className="flex-row items-center justify-between"
/>
) : (
<Skeleton className="h-5.5 w-3/4" />
@@ -3,16 +3,9 @@
import { useState, type ComponentType } from "react";
import dynamic from "next/dynamic";
import Link from "next/link";
import {
Activity,
Flame,
Gauge,
HeartPulse,
Moon,
RefreshCw,
} from "lucide-react";
import { Activity, Flame, Gauge, HeartPulse, Moon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/ui/section-heading";
@@ -65,6 +58,8 @@ interface ScoreStripProps {
* Vitals slot).
*/
isError?: boolean;
/** The batch query's error, which decides what the error row offers. */
error?: unknown;
/** Refetch the one shared batch query. Recovers both strips at once. */
refetch?: () => void;
/**
@@ -273,6 +268,7 @@ export function WellnessScores({
read,
isLoading,
isError = false,
error = null,
refetch,
extraTile,
hideStrain = false,
@@ -351,24 +347,13 @@ export function WellnessScores({
title={t("insights.derived.scores.sectionTitle")}
subtitle={t("insights.derived.scores.sectionSubtitle")}
/>
<div
data-slot="wellness-scores-error"
role="alert"
className="bg-card border-border text-muted-foreground flex flex-col items-start gap-3 rounded-xl border p-4 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("insights.derived.scores.loadError")}</span>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => refetch?.()}
data-slot="wellness-scores-retry"
className="gap-1.5"
>
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("common.retry")}</span>
</Button>
</div>
<QueryErrorRow
error={error}
slot="wellness-scores-error"
retrySlot="wellness-scores-retry"
message={t("insights.derived.scores.loadError")}
onRetry={() => refetch?.()}
/>
</section>
);
}
@@ -23,7 +23,7 @@ import { userDayKey, shiftDateKey, DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { TileHeader } from "@/components/insights/tile-header";
import { DayLink } from "@/components/day/day-link";
import { Button } from "@/components/ui/button";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { axisUnitSuffix } from "@/lib/charts/axis-unit";
import {
@@ -77,7 +77,7 @@ const AXIS_TICKS = [0, 360, 720, 1080, 1440];
* coarser hourly grain instead of an empty chart — `resolution` on the DTO
* tells the caption which, and the tension read is always absent on an
* hourly day (it needs per-sample resolution to stay honest). A failed fetch
* renders `<QueryErrorCard>` with a retry action instead of falling through
* renders `<QueryErrorRow>` with a retry action instead of falling through
* to the empty-day copy.
*
* `initialDateKey` pins the first day shown, for callers that already know
@@ -254,10 +254,11 @@ export function IntradayPulseChart({
{isLoading ? (
<Skeleton className="h-[180px] w-full rounded-lg" />
) : isError ? (
<QueryErrorCard
<QueryErrorRow
inset
error={error}
onRetry={() => refetch()}
className="border-0 bg-transparent shadow-none"
slot="intraday-pulse-error"
onRetry={() => void refetch()}
/>
) : (data?.series?.length ?? 0) < MIN_BUCKETS_FOR_COURSE ? (
// A lone reading or two on a 24-hour axis drew a dot in an empty
@@ -81,6 +81,7 @@ export function MoodWhatStandsOut({
) : null}
{isError ? (
<QueryErrorRow
inset
error={error}
slot="mood-discovery-error"
message={t("insights.pattern.loadError")}
@@ -41,14 +41,24 @@ export function CaffeineCard() {
staleTime: 60_000,
});
// The card keeps its frame and title on a failed read, like the hydration
// card beside it.
if (isError && !data) {
return (
<QueryErrorRow
error={error}
slot="nutrients-caffeine-error"
message={t("nutrients.caffeine.loadError")}
onRetry={() => void refetch()}
/>
<Card data-slot="nutrients-caffeine-card">
<CardHeader>
<TileHeader icon={Coffee} title={t("nutrients.names.caffeine")} />
</CardHeader>
<CardContent>
<QueryErrorRow
inset
error={error}
slot="nutrients-caffeine-error"
message={t("nutrients.caffeine.loadError")}
onRetry={() => void refetch()}
/>
</CardContent>
</Card>
);
}
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { GlassWater } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { TileHeader } from "@/components/insights/tile-header";
import { NutrientDailyBarChartDynamic } from "@/components/charts/nutrient-daily-bar-chart-dynamic";
@@ -38,8 +38,25 @@ export function HydrationCard() {
),
});
if (isError) {
return <QueryErrorCard error={error} onRetry={refetch} />;
// The card keeps its frame and title on a failed read, like the caffeine
// card beside it, so the page does not change shape under the reader.
if (isError && !data) {
return (
<Card data-slot="nutrients-hydration-card">
<CardHeader>
<TileHeader icon={GlassWater} title={t("nutrients.names.water")} />
</CardHeader>
<CardContent>
<QueryErrorRow
inset
error={error}
slot="nutrients-hydration-error"
message={t("nutrients.hydration.loadError")}
onRetry={() => void refetch()}
/>
</CardContent>
</Card>
);
}
const todayTotal = data?.days.at(-1)?.amount ?? 0;
@@ -18,6 +18,7 @@ import { AskCoachIconButton } from "@/components/insights/ask-coach-action";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { apiGet } from "@/lib/api/api-fetch";
import { InsightSectionCard } from "./insight-section-card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
/**
* v1.11.0 — period-narrative card (Pillar P1).
@@ -121,13 +122,33 @@ export function PeriodNarrativeCard({
);
}
// The route never errors the page; on a fetch error or whenever there is no
// real generated narrative yet (empty account, or a first warm still in
// flight) the card un-mounts itself rather than showing an error, a
// "preparing" husk, or the methodology framing. The query stays mounted and
// polls in the background, so the card appears the moment a narrative lands.
// A failed read is said, with a retry: hiding it would look exactly like
// "no narrative yet". Whenever there is no real generated narrative yet
// (empty account, the capability off, or a first warm still in flight) the
// card un-mounts itself rather than showing a "preparing" husk or the
// methodology framing. The query stays mounted and polls in the background,
// so the card appears the moment a narrative lands.
if (query.isError && !query.data) {
return (
<section
data-slot="period-narrative-section"
aria-label={t("insights.narrativeTitle")}
className={cn("space-y-3", className)}
>
<SectionHeading
icon={CalendarRange}
title={t("insights.narrativeTitle")}
/>
<QueryErrorRow
error={query.error}
slot="period-narrative-error"
onRetry={() => void query.refetch()}
/>
</section>
);
}
const narrative = query.data?.narrative ?? null;
if (query.isError || !narrative) {
if (!narrative) {
return null;
}
@@ -21,6 +21,7 @@ import type {
RecoveryEmptyReason,
} from "@/lib/insights/recovery-empty";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { RestModeBanner } from "@/components/insights/rest-mode-banner";
import type { RestModeAnnotation } from "@/lib/analytics/score/types";
import type { ChartOverlayKey } from "@/lib/dashboard-layout";
@@ -79,7 +80,8 @@ const EMPTY_COPY = {
*/
export function RecoverySection() {
const { t } = useTranslations();
const { data, isLoading } = useInsightsAnalytics("SLEEP_DURATION");
const { data, isLoading, error, refetch } =
useInsightsAnalytics("SLEEP_DURATION");
// v1.18.1 — Rest Mode parity. The annotation rides the thick analytics
// payload's `healthScore` block (server-authoritative; iOS mirrors it). We
// read it off the same `["analytics"]` cache the insights mother page
@@ -190,6 +192,17 @@ export function RecoverySection() {
// answer (`recoveryEmpty`), so web and iOS read one decision.
const emptyReason = data?.recoveryEmpty ?? null;
// A failed read is said, never left as a skeleton that loads forever.
if (error && summaries == null) {
return (
<QueryErrorCard
error={error}
title={t("insights.recovery.loadError")}
onRetry={refetch}
/>
);
}
if (isLoading || summaries == null) {
return (
<div className="space-y-3" data-slot="recovery-loading">
@@ -207,11 +220,15 @@ export function RecoverySection() {
title={t("insights.recovery.emptyTitle")}
description={t(EMPTY_COPY[emptyReason])}
action={
<Button size="sm" variant="outline" asChild>
<Link href="/settings/integrations">
{t("insights.recovery.emptyAction")}
</Link>
</Button>
// Waiting for a first resilience level has nothing to open: the
// connection is fine, the level simply has not arrived.
emptyReason === "oura_no_resilience" ? undefined : (
<Button size="sm" variant="outline" asChild>
<Link href="/settings/integrations">
{t("insights.recovery.emptyAction")}
</Link>
</Button>
)
}
/>
);
@@ -120,6 +120,7 @@ export function WorkoutDetailDayContext({
{sleep.isError ? (
// A failed read is not an absent night.
<QueryErrorRow
inset
error={sleep.error}
onRetry={sleep.refetch}
slot="workout-detail-day-sleep-error"
@@ -170,6 +171,7 @@ export function WorkoutDetailDayContext({
</p>
{mood.isError ? (
<QueryErrorRow
inset
error={mood.error}
onRetry={mood.refetch}
slot="workout-detail-day-mood-error"
@@ -166,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 error={error} onRetry={() => refetch()} />
<QueryErrorRow inset error={error} onRetry={() => refetch()} />
) : upcoming.length === 0 ? (
<div className="space-y-2">
<p className="text-muted-foreground text-sm">
@@ -114,6 +114,7 @@ import {
} from "@/components/medications/dose-history-ledger-compute";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { QueryErrorRow } from "@/components/ui/query-error-row";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -210,7 +211,7 @@ export function DoseHistoryLedger({
// `from` travels: the server's `to` defaults to its own now.
const queryKey = queryKeys.medicationDoseHistory(medicationId, windowDays);
const { data, isLoading, isError } = useQuery<LedgerPayload>({
const { data, isLoading, isError, error, refetch } = useQuery<LedgerPayload>({
queryKey,
queryFn: async () => {
const from = new Date(Date.now() - windowDays * DAY_MS).toISOString();
@@ -465,9 +466,14 @@ export function DoseHistoryLedger({
)}
{isError && (
<p className="text-destructive py-6 text-sm">
{t("medications.detail.verlauf.loadError")}
</p>
<QueryErrorRow
inset
error={error}
slot="dose-history-error"
message={t("medications.detail.verlauf.loadError")}
onRetry={() => void refetch()}
className="py-6"
/>
)}
{data && !isLoading && groups.length === 0 && (
@@ -60,6 +60,7 @@ import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { QueryErrorRow } from "@/components/ui/query-error-row";
interface RevisionEntrySummary {
timesOfDay: string[];
@@ -116,7 +117,7 @@ export function ScheduleHistoryTimeline({
null,
);
const { data, isError } = useQuery({
const { data, isError, error, refetch } = useQuery({
queryKey: queryKeys.medicationScheduleRevisions(medicationId),
queryFn: async () =>
apiGet<ScheduleRevisionsResponse>(
@@ -151,11 +152,15 @@ export function ScheduleHistoryTimeline({
},
});
if (isError) {
if (isError && !data) {
return (
<p className="text-muted-foreground text-sm" data-slot="zeitplan-history">
{t("medications.detail.zeitplan.history.loadError")}
</p>
<QueryErrorRow
inset
error={error}
slot="zeitplan-history"
message={t("medications.detail.zeitplan.history.loadError")}
onRetry={() => void refetch()}
/>
);
}
if (!data) return null;
@@ -49,6 +49,7 @@ import {
queryKeys,
} from "@/lib/query-keys";
import { ApiError, apiGet, apiPut } from "@/lib/api/api-fetch";
import { QueryErrorRow } from "@/components/ui/query-error-row";
type PhaseMode = "MINUTES" | "PERCENT";
@@ -137,7 +138,7 @@ export function PhaseConfigSheet({
}: PhaseConfigSheetProps) {
const { t } = useTranslations();
const { data, isLoading, isError } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.medicationPhaseConfig(medicationId),
queryFn: async () => {
return fromApi(
@@ -170,13 +171,12 @@ export function PhaseConfigSheet({
/>
</div>
) : isError ? (
<p
className="text-destructive text-sm"
role="alert"
data-slot="phase-config-error"
>
{t("common.loadFailed")}
</p>
<QueryErrorRow
inset
error={error}
slot="phase-config-error"
onRetry={() => void refetch()}
/>
) : data ? (
<PhaseConfigForm
key={snapshotKey(data)}
@@ -27,6 +27,7 @@ import {
} from "@/lib/mental-health/instruments";
import type { AssessmentDetail, InstrumentId } from "./types";
import { QueryErrorRow } from "@/components/ui/query-error-row";
export function AssessmentItemBreakdown({
assessmentId,
@@ -38,7 +39,7 @@ export function AssessmentItemBreakdown({
const { t } = useTranslations();
const def = INSTRUMENTS[instrument];
const { data, isPending, isError } = useQuery({
const { data, isPending, isError, error, refetch } = useQuery({
queryKey: queryKeys.mentalHealthAssessmentDetail(assessmentId),
queryFn: () =>
apiGet<{ assessment: AssessmentDetail }>(
@@ -62,13 +63,13 @@ export function AssessmentItemBreakdown({
if (isError || !data) {
return (
<p
className="text-destructive text-sm"
role="alert"
data-slot="assessment-item-breakdown"
>
{t("mentalHealth.error")}
</p>
<QueryErrorRow
inset
error={error}
slot="assessment-item-breakdown"
message={t("mentalHealth.error")}
onRetry={() => void refetch()}
/>
);
}
@@ -80,6 +80,7 @@ export function CoachConversationsMemoryCard({
/>
{history.isError ? (
<QueryErrorRow
inset
error={history.error}
onRetry={() => void history.refetch()}
/>
@@ -326,6 +326,7 @@ export function CoachMemorySection({
{query.isError && (
<QueryErrorRow
inset
error={query.error}
message={t("settings.ai.coachMemory.loadError")}
onRetry={() => query.refetch()}
@@ -188,6 +188,7 @@ export function CoachRemindersSection({
{query.isError && (
<QueryErrorRow
inset
error={query.error}
message={t("settings.ai.coachReminders.loadError")}
onRetry={() => query.refetch()}
@@ -16,6 +16,7 @@ import { HeartPulse, Upload } from "lucide-react";
import { formatRelative } from "@/components/settings/integration-status-pill";
import { Button } from "@/components/ui/button";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { apiGet } from "@/lib/api/api-fetch";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
@@ -303,6 +304,8 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
data: status,
isLoading,
isError,
error,
refetch,
} = useQuery({
queryKey: queryKeys.healthKitStatus(),
queryFn: () => apiGet<HealthKitStatus>("/api/integrations/healthkit"),
@@ -384,9 +387,12 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
}
>
{isError ? (
<p role="alert" className="text-destructive text-sm">
{t("settings.appleHealth.status.unavailable")}
</p>
<QueryErrorRow
error={error}
slot="apple-health-status-error"
message={t("settings.appleHealth.status.unavailable")}
onRetry={() => void refetch()}
/>
) : null}
</IntegrationCard>
);
@@ -47,6 +47,7 @@ import {
integrationDocsHref,
} from "./setup-guide-link";
import type { IntegrationStatusViewModel } from "./shared";
import { QueryErrorRow } from "@/components/ui/query-error-row";
interface DawarichStatus {
connected: boolean;
@@ -298,9 +299,13 @@ function DawarichHome() {
{t("settings.dawarichHomePlace")}
</Label>
{places.isError ? (
<p role="alert" className="text-destructive text-sm">
{t(errorKeyOf(places.error))}
</p>
<QueryErrorRow
inset
error={places.error}
slot="dawarich-places-error"
message={t(errorKeyOf(places.error))}
onRetry={() => void places.refetch()}
/>
) : (
<NativeSelect
id="dawarich-home-place"
+12 -1
View File
@@ -37,6 +37,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { MODULE_KEYS, MODULE_REGISTRY } from "@/lib/modules/registry";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { QueryErrorRow } from "@/components/ui/query-error-row";
interface PrivacySummary {
retention: {
@@ -73,6 +74,8 @@ export function PrivacySection() {
data: summary,
isLoading: summaryLoading,
isError: summaryError,
error: summaryFailure,
refetch: refetchSummary,
} = useQuery({
queryKey: queryKeys.privacySummary(),
queryFn: () => apiGet<PrivacySummary>("/api/settings/privacy-summary"),
@@ -141,7 +144,15 @@ export function PrivacySection() {
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</div>
) : summaryError || !summary ? (
) : summaryError && !summary ? (
<QueryErrorRow
inset
error={summaryFailure}
slot="privacy-retention-error"
message={t("settings.privacy.retention.unavailable")}
onRetry={() => void refetchSummary()}
/>
) : !summary ? (
<p className="text-muted-foreground text-sm">
{t("settings.privacy.retention.unavailable")}
</p>
@@ -445,6 +445,7 @@ export function ScoreSection() {
{analyticsQuery.isError ? (
<QueryErrorRow
inset
error={analyticsQuery.error}
slot="score-config-eligibility-error"
message={t("settings.sections.score.eligibilityFailed")}
@@ -110,6 +110,7 @@ export function SecurityActivityCard({
{isError && (
<QueryErrorRow
inset
error={error}
message={t("settings.security.activityLoadError")}
onRetry={() => refetch()}
@@ -165,6 +165,7 @@ export function SecuritySessionsCard({
{isError && (
<QueryErrorRow
inset
error={error}
message={t("settings.security.sessionsLoadError")}
onRetry={() => refetch()}
@@ -105,6 +105,7 @@ export function TrustedDevicesCard({
{isError && (
<QueryErrorRow
inset
error={error}
message={t("settings.security.trustedDevices.loadError")}
onRetry={() => refetch()}