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 && (
-