mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
a1b56f0b75
commit
923249dbf6
60 files changed
+579
-175
No files matched your search
+4
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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")}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()}
|
||||
|
||||
Reference in new issue
Block a user