From 923249dbf64801f1ed5e157dfe0ecfc8a95eeb92 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 20 Oct 2026 10:10:49 +0200
Subject: [PATCH] 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.
---
messages/de.json | 6 +-
messages/en.json | 6 +-
messages/es.json | 6 +-
messages/fr.json | 6 +-
messages/it.json | 6 +-
messages/ko.json | 6 +-
messages/nl.json | 6 +-
messages/pl.json | 6 +-
.../read-failure-markup-guard.test.ts | 182 ++++++++++++++++++
src/app/insights/catalog/page.tsx | 1 +
src/app/insights/page-client.tsx | 1 +
src/components/admin/ai-quality-section.tsx | 1 +
src/components/admin/backups-section.tsx | 1 +
.../admin/coach-feedback-section.tsx | 1 +
src/components/admin/encryption-section.tsx | 13 +-
src/components/admin/host-metrics-chart.tsx | 1 +
.../admin/invite-tokens-section.tsx | 18 +-
src/components/admin/key-backup-card.tsx | 1 +
.../admin/offhost-backups-section.tsx | 1 +
.../admin/provider-health-section.tsx | 13 +-
src/components/admin/recent-audit-preview.tsx | 1 +
.../admin/system-status-section.tsx | 1 +
.../admin/system-status-summary.tsx | 1 +
.../cycle-insight-summary-card.test.tsx | 16 +-
.../cycle/cycle-insight-summary-card.tsx | 27 ++-
src/components/cycle/cycle-view.tsx | 2 +-
.../__tests__/recent-workouts-tile.test.tsx | 6 +-
.../dashboard/recent-workouts-tile.tsx | 9 +-
.../documents/document-detail-sheet.tsx | 11 +-
.../illness/illness-correlation-card.tsx | 14 +-
.../illness/illness-day-timeline.tsx | 16 +-
.../illness/illness-insights-card.tsx | 21 +-
.../__tests__/recovery-empty-state.test.tsx | 21 +-
.../coach-panel/attachment-picker.tsx | 11 +-
.../coach-panel/coach-model-picker.tsx | 25 +--
.../insights/coach-panel/history-rail.tsx | 20 +-
.../insights/derived/coach-read-strip.tsx | 3 +-
.../insights/derived/wellness-scores.tsx | 39 ++--
.../insights/intraday-pulse-chart.tsx | 11 +-
.../insights/mood/mood-what-stands-out.tsx | 1 +
.../insights/nutrients/caffeine-card.tsx | 22 ++-
.../insights/nutrients/hydration-card.tsx | 23 ++-
.../insights/period-narrative-card.tsx | 33 +++-
.../insights/recovery/recovery-section.tsx | 29 ++-
.../insights/workout-detail/day-context.tsx | 2 +
.../vorsorge-dashboard-card.tsx | 2 +-
.../medications/dose-history-ledger.tsx | 14 +-
.../scheduling/schedule-history-timeline.tsx | 15 +-
.../sections/phase-config-sheet.tsx | 16 +-
.../assessment-item-breakdown.tsx | 17 +-
.../coach-conversations-memory-card.tsx | 1 +
.../settings/coach-memory-section.tsx | 1 +
.../settings/coach-reminders-section.tsx | 1 +
.../integrations/apple-health-card.tsx | 12 +-
.../settings/integrations/dawarich-card.tsx | 11 +-
src/components/settings/privacy-section.tsx | 13 +-
src/components/settings/score-section.tsx | 1 +
.../settings/security-activity-card.tsx | 1 +
.../settings/security-sessions-card.tsx | 1 +
.../settings/trusted-devices-card.tsx | 1 +
60 files changed, 579 insertions(+), 175 deletions(-)
create mode 100644 src/__tests__/read-failure-markup-guard.test.ts
diff --git a/messages/de.json b/messages/de.json
index b2667a7de..72b690df6 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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)",
diff --git a/messages/en.json b/messages/en.json
index 3e30108aa..da940b4e7 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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)",
diff --git a/messages/es.json b/messages/es.json
index a7e92f85e..aeab27a15 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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)",
diff --git a/messages/fr.json b/messages/fr.json
index 2626bfb40..92a8ae59b 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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)",
diff --git a/messages/it.json b/messages/it.json
index 9f6082888..4ca892e15 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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)",
diff --git a/messages/ko.json b/messages/ko.json
index 291d55291..7d8c1316b 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -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)",
diff --git a/messages/nl.json b/messages/nl.json
index 21d713639..8028f2dc4 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -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)",
diff --git a/messages/pl.json b/messages/pl.json
index 4735fd572..3aa3bd00e 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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)",
diff --git a/src/__tests__/read-failure-markup-guard.test.ts b/src/__tests__/read-failure-markup-guard.test.ts
new file mode 100644
index 000000000..b8c46a4c6
--- /dev/null
+++ b/src/__tests__/read-failure-markup-guard.test.ts
@@ -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();
+ 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("(? = [];
+ 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(//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 ? (
+
{isError ? (
void refetch()}
- className="flex-row items-center justify-between rounded-none border-0 bg-transparent p-0"
+ className="flex-row items-center justify-between"
/>
) : (
diff --git a/src/components/insights/derived/wellness-scores.tsx b/src/components/insights/derived/wellness-scores.tsx
index 2ed39c544..848773990 100644
--- a/src/components/insights/derived/wellness-scores.tsx
+++ b/src/components/insights/derived/wellness-scores.tsx
@@ -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")}
/>
-
- {t("insights.derived.scores.loadError")}
-
-
+ refetch?.()}
+ />
);
}
diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx
index deff1ae99..da0cabb36 100644
--- a/src/components/insights/intraday-pulse-chart.tsx
+++ b/src/components/insights/intraday-pulse-chart.tsx
@@ -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 `` with a retry action instead of falling through
+ * renders `` 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 ? (
) : isError ? (
- 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
diff --git a/src/components/insights/mood/mood-what-stands-out.tsx b/src/components/insights/mood/mood-what-stands-out.tsx
index 46148152d..c16adb3a7 100644
--- a/src/components/insights/mood/mood-what-stands-out.tsx
+++ b/src/components/insights/mood/mood-what-stands-out.tsx
@@ -81,6 +81,7 @@ export function MoodWhatStandsOut({
) : null}
{isError ? (
void refetch()}
- />
+
+
+
+
+
+ void refetch()}
+ />
+
+
);
}
diff --git a/src/components/insights/nutrients/hydration-card.tsx b/src/components/insights/nutrients/hydration-card.tsx
index 2f722cba9..5d61aa219 100644
--- a/src/components/insights/nutrients/hydration-card.tsx
+++ b/src/components/insights/nutrients/hydration-card.tsx
@@ -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 ;
+ // 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 (
+
+
+
+
+
+ void refetch()}
+ />
+
+
+ );
}
const todayTotal = data?.days.at(-1)?.amount ?? 0;
diff --git a/src/components/insights/period-narrative-card.tsx b/src/components/insights/period-narrative-card.tsx
index 2495101b1..94249a5a1 100644
--- a/src/components/insights/period-narrative-card.tsx
+++ b/src/components/insights/period-narrative-card.tsx
@@ -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 (
+
+
+ void query.refetch()}
+ />
+
+ );
+ }
const narrative = query.data?.narrative ?? null;
- if (query.isError || !narrative) {
+ if (!narrative) {
return null;
}
diff --git a/src/components/insights/recovery/recovery-section.tsx b/src/components/insights/recovery/recovery-section.tsx
index 060b067ab..f21fa05de 100644
--- a/src/components/insights/recovery/recovery-section.tsx
+++ b/src/components/insights/recovery/recovery-section.tsx
@@ -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 (
+
+ );
+ }
+
if (isLoading || summaries == null) {
return (
@@ -207,11 +220,15 @@ export function RecoverySection() {
title={t("insights.recovery.emptyTitle")}
description={t(EMPTY_COPY[emptyReason])}
action={
-
+ // 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 : (
+
+ )
}
/>
);
diff --git a/src/components/insights/workout-detail/day-context.tsx b/src/components/insights/workout-detail/day-context.tsx
index 5286b8ee4..d262f13d3 100644
--- a/src/components/insights/workout-detail/day-context.tsx
+++ b/src/components/insights/workout-detail/day-context.tsx
@@ -120,6 +120,7 @@ export function WorkoutDetailDayContext({
{sleep.isError ? (
// A failed read is not an absent night.
{mood.isError ? (
refetch()} />
+ refetch()} />
) : upcoming.length === 0 ? (
diff --git a/src/components/medications/dose-history-ledger.tsx b/src/components/medications/dose-history-ledger.tsx
index e52462fd2..0ff9aa79a 100644
--- a/src/components/medications/dose-history-ledger.tsx
+++ b/src/components/medications/dose-history-ledger.tsx
@@ -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({
+ const { data, isLoading, isError, error, refetch } = useQuery({
queryKey,
queryFn: async () => {
const from = new Date(Date.now() - windowDays * DAY_MS).toISOString();
@@ -465,9 +466,14 @@ export function DoseHistoryLedger({
)}
{isError && (
-