mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(insights): stop the pulse chart from faking a continuous shape, add honest error states
Intraday pulse rendered sparse spot readings as a smooth interpolated
curve across hours-wide gaps, implying continuous monitoring the data
never had. The chart now grids one point per bucket, breaks the area
over any missing bucket (connectNulls={false}), and discloses coverage
("based on N readings across M hours") whenever that produces a visible
break.
Four surfaces also collapsed a failed fetch into the honest empty/no-
data state instead of surfacing the error: the Today hero, the
medication efficacy tab (previously stuck on the loading skeleton
forever on error), the intraday pulse chart, and the recent-workouts
dashboard tile. All four now branch on isError and render
QueryErrorCard with a retry action; the tile's "loading…" text line is
replaced with row-shaped skeletons.
This commit is contained in:
1 parent
fb73c97125
commit
d124a3239d
12 files changed
+176
-36
No files matched your search
+2
-1
@@ -405,7 +405,6 @@
|
||||
"recentWorkouts": {
|
||||
"title": "Letzte Workouts",
|
||||
"viewAll": "Alle anzeigen",
|
||||
"loading": "Workouts werden geladen…",
|
||||
"empty": {
|
||||
"title": "Noch keine Workouts",
|
||||
"cta": "Öffne die Apple-Health-App auf dem iPhone, damit Workouts hier erscheinen."
|
||||
@@ -3879,6 +3878,8 @@
|
||||
"caption": "Dein Puls über den Tag, gemittelt in 10-Minuten-Schritten.",
|
||||
"hourlyNote": "Stündlicher Durchschnitt für diesen Tag — aktuelle Tage zeigen das volle 10-Minuten-Detail.",
|
||||
"empty": "Für diesen Tag noch nicht genug Pulswerte, um den Tagesverlauf zu zeigen.",
|
||||
"coverageOne": "Basierend auf 1 Messung über {hours}h.",
|
||||
"coverageMany": "Basierend auf {count} Messungen über {hours}h.",
|
||||
"baseline": "Ruhe",
|
||||
"today": "Heute",
|
||||
"previousDay": "Vorheriger Tag",
|
||||
|
||||
+2
-1
@@ -405,7 +405,6 @@
|
||||
"recentWorkouts": {
|
||||
"title": "Recent workouts",
|
||||
"viewAll": "View all",
|
||||
"loading": "Loading workouts…",
|
||||
"empty": {
|
||||
"title": "No workouts yet",
|
||||
"cta": "Open Apple Health on your iPhone to sync workouts here."
|
||||
@@ -3879,6 +3878,8 @@
|
||||
"caption": "Your heart rate through the day, averaged in 10-minute steps.",
|
||||
"hourlyNote": "Showing this day's hourly average — recent days show the full 10-minute detail.",
|
||||
"empty": "Not enough heart-rate readings for this day to show its shape yet.",
|
||||
"coverageOne": "Based on 1 reading across {hours}h.",
|
||||
"coverageMany": "Based on {count} readings across {hours}h.",
|
||||
"baseline": "Resting",
|
||||
"today": "Today",
|
||||
"previousDay": "Previous day",
|
||||
|
||||
+2
-1
@@ -405,7 +405,6 @@
|
||||
"recentWorkouts": {
|
||||
"title": "Entrenamientos recientes",
|
||||
"viewAll": "Ver todos",
|
||||
"loading": "Cargando entrenamientos…",
|
||||
"empty": {
|
||||
"title": "Aún no hay entrenamientos",
|
||||
"cta": "Abre la app Apple Salud en el iPhone para sincronizar los entrenamientos."
|
||||
@@ -3879,6 +3878,8 @@
|
||||
"caption": "Tu frecuencia cardíaca a lo largo del día, promediada en tramos de 10 minutos.",
|
||||
"hourlyNote": "Mostrando el promedio por hora de este día — los días recientes muestran el detalle completo de 10 minutos.",
|
||||
"empty": "Aún no hay suficientes lecturas para este día para mostrar su forma.",
|
||||
"coverageOne": "Basado en 1 lectura a lo largo de {hours}h.",
|
||||
"coverageMany": "Basado en {count} lecturas a lo largo de {hours}h.",
|
||||
"baseline": "Reposo",
|
||||
"today": "Hoy",
|
||||
"previousDay": "Día anterior",
|
||||
|
||||
+2
-1
@@ -405,7 +405,6 @@
|
||||
"recentWorkouts": {
|
||||
"title": "Entraînements récents",
|
||||
"viewAll": "Tout voir",
|
||||
"loading": "Chargement des entraînements…",
|
||||
"empty": {
|
||||
"title": "Aucun entraînement",
|
||||
"cta": "Ouvre l'app Apple Santé sur ton iPhone pour synchroniser les entraînements."
|
||||
@@ -3879,6 +3878,8 @@
|
||||
"caption": "Votre fréquence cardiaque au fil de la journée, moyennée par tranches de 10 minutes.",
|
||||
"hourlyNote": "Moyenne horaire affichée pour cette journée — les jours récents affichent le détail complet par tranches de 10 minutes.",
|
||||
"empty": "Pas encore assez de mesures pour cette journée pour en afficher le rythme.",
|
||||
"coverageOne": "Basé sur 1 mesure sur {hours}h.",
|
||||
"coverageMany": "Basé sur {count} mesures sur {hours}h.",
|
||||
"baseline": "Repos",
|
||||
"today": "Aujourd'hui",
|
||||
"previousDay": "Jour précédent",
|
||||
|
||||
+2
-1
@@ -405,7 +405,6 @@
|
||||
"recentWorkouts": {
|
||||
"title": "Allenamenti recenti",
|
||||
"viewAll": "Vedi tutti",
|
||||
"loading": "Caricamento allenamenti…",
|
||||
"empty": {
|
||||
"title": "Nessun allenamento",
|
||||
"cta": "Apri l'app Salute di Apple sull'iPhone per sincronizzare gli allenamenti."
|
||||
@@ -3879,6 +3878,8 @@
|
||||
"caption": "La tua frequenza cardiaca durante il giorno, mediata in intervalli di 10 minuti.",
|
||||
"hourlyNote": "Media oraria per questo giorno — i giorni recenti mostrano il dettaglio completo a 10 minuti.",
|
||||
"empty": "Non ci sono ancora abbastanza misurazioni per questo giorno per mostrarne l'andamento.",
|
||||
"coverageOne": "Basato su 1 misurazione in {hours}h.",
|
||||
"coverageMany": "Basato su {count} misurazioni in {hours}h.",
|
||||
"baseline": "Riposo",
|
||||
"today": "Oggi",
|
||||
"previousDay": "Giorno precedente",
|
||||
|
||||
+2
-1
@@ -405,7 +405,6 @@
|
||||
"recentWorkouts": {
|
||||
"title": "Ostatnie treningi",
|
||||
"viewAll": "Zobacz wszystkie",
|
||||
"loading": "Wczytywanie treningów…",
|
||||
"empty": {
|
||||
"title": "Brak treningów",
|
||||
"cta": "Otwórz aplikację Apple Zdrowie na iPhonie, aby zsynchronizować treningi."
|
||||
@@ -3879,6 +3878,8 @@
|
||||
"caption": "Twoje tętno w ciągu dnia, uśrednione w 10-minutowych odcinkach.",
|
||||
"hourlyNote": "Pokazano średnią godzinową dla tego dnia — ostatnie dni pokazują pełny, 10-minutowy szczegół.",
|
||||
"empty": "Za mało pomiarów z tego dnia, aby pokazać jego przebieg.",
|
||||
"coverageOne": "Na podstawie 1 pomiaru w ciągu {hours}h.",
|
||||
"coverageMany": "Na podstawie {count} pomiarów w ciągu {hours}h.",
|
||||
"baseline": "Spoczynek",
|
||||
"today": "Dziś",
|
||||
"previousDay": "Poprzedni dzień",
|
||||
|
||||
@@ -35,6 +35,7 @@ import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
|
||||
@@ -790,8 +791,11 @@ export default function DashboardPageClient() {
|
||||
decision 1). Renders the S1 daily digest: the day's read, the
|
||||
honest score/freshness face, and the worth-a-look rail. Shimmer
|
||||
skeleton while the cached digest loads; a genuinely empty account
|
||||
degrades to nothing (the hero itself returns null). The dense
|
||||
tile grid + charts below are untouched. */}
|
||||
degrades to nothing (the hero itself returns null). A FAILED fetch
|
||||
is a distinct state from that honest empty degrade — it renders
|
||||
`<QueryErrorCard>` with a retry action rather than silently
|
||||
falling through to nothing. The dense tile grid + charts below
|
||||
are untouched. */}
|
||||
{insightsEnabled &&
|
||||
// `!mounted` pins the SSR pass AND the first hydration render to the
|
||||
// skeleton (v1.16.4 pattern): the digest query rehydrates from the
|
||||
@@ -800,6 +804,8 @@ export default function DashboardPageClient() {
|
||||
// skeleton — a text-content hydration mismatch (React #418).
|
||||
(!mounted || digestQuery.isLoading ? (
|
||||
<TodayHeroSkeleton />
|
||||
) : digestQuery.isError ? (
|
||||
<QueryErrorCard onRetry={() => digestQuery.refetch()} />
|
||||
) : digestQuery.data ? (
|
||||
<TodayHero digest={digestQuery.data} />
|
||||
) : null)}
|
||||
|
||||
@@ -32,11 +32,15 @@ let mockResult: {
|
||||
isLoading: boolean;
|
||||
isEmpty: boolean;
|
||||
error: Error | null;
|
||||
isError: boolean;
|
||||
refetch: () => void;
|
||||
} = {
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isEmpty: false,
|
||||
error: null,
|
||||
isError: false,
|
||||
refetch: () => {},
|
||||
};
|
||||
|
||||
vi.mock("@/hooks/use-workouts", async () => {
|
||||
@@ -66,15 +70,26 @@ beforeEach(() => {
|
||||
isLoading: false,
|
||||
isEmpty: false,
|
||||
error: null,
|
||||
isError: false,
|
||||
refetch: () => {},
|
||||
};
|
||||
});
|
||||
|
||||
describe("<RecentWorkoutsTile>", () => {
|
||||
it("renders the loading placeholder while the query is in flight", () => {
|
||||
it("renders row-shaped skeletons while the query is in flight", () => {
|
||||
mockResult.isLoading = true;
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="recent-workouts-loading"');
|
||||
expect(html).toContain("Loading workouts");
|
||||
expect(html).toContain('data-slot="skeleton"');
|
||||
});
|
||||
|
||||
it("renders a retry card instead of the empty state on a failed fetch", () => {
|
||||
mockResult.isError = true;
|
||||
const html = render();
|
||||
expect(html).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"');
|
||||
});
|
||||
|
||||
it("renders the empty-state with the Apple-Health onboarding cue", () => {
|
||||
|
||||
@@ -17,6 +17,8 @@ import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
|
||||
/**
|
||||
* v1.4.32 — `<RecentWorkoutsTile>`.
|
||||
@@ -99,7 +101,7 @@ function renderRow(workout: WorkoutListEntry, sportName: string) {
|
||||
|
||||
export function RecentWorkoutsTile() {
|
||||
const { t, locale } = useTranslations();
|
||||
const { data, isLoading } = useWorkouts({ limit: 3 });
|
||||
const { data, isLoading, isError, refetch } = useWorkouts({ limit: 3 });
|
||||
const workouts = data?.workouts ?? [];
|
||||
|
||||
return (
|
||||
@@ -131,12 +133,24 @@ export function RecentWorkoutsTile() {
|
||||
// v1.4.43 W11-L6 — reserve roughly the loaded tile height
|
||||
// (3 workouts × ~3 rem rows + spacing) so the surrounding
|
||||
// dashboard layout doesn't reflow once the data lands.
|
||||
<p
|
||||
data-slot="recent-workouts-loading"
|
||||
className="text-muted-foreground min-h-[10rem] text-xs"
|
||||
>
|
||||
{t("dashboard.recentWorkouts.loading")}
|
||||
</p>
|
||||
// v1.29.x — row-shaped skeletons instead of a "loading…" text
|
||||
// line, matching the sport-icon + label + day + duration shape
|
||||
// the loaded rows render.
|
||||
<div data-slot="recent-workouts-loading" className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-1 py-1">
|
||||
<Skeleton className="size-7 shrink-0 rounded-full" />
|
||||
<Skeleton className="h-4 min-w-0 flex-1" />
|
||||
<Skeleton className="h-4 w-12 shrink-0" />
|
||||
<Skeleton className="h-4 w-8 shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<QueryErrorCard
|
||||
onRetry={() => refetch()}
|
||||
className="border-0 bg-transparent shadow-none"
|
||||
/>
|
||||
) : workouts.length === 0 ? (
|
||||
<div data-slot="recent-workouts-empty" className="space-y-1">
|
||||
<p className="text-sm">{t("dashboard.recentWorkouts.empty.title")}</p>
|
||||
|
||||
@@ -22,10 +22,12 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { userDayKey, shiftDateKey } from "@/lib/tz/format";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type {
|
||||
IntradayHrBucket,
|
||||
IntradayResolution,
|
||||
TensionWindow,
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import {
|
||||
BUCKET_MINUTES,
|
||||
type IntradayHrBucket,
|
||||
type IntradayResolution,
|
||||
type TensionWindow,
|
||||
} from "@/lib/analytics/intraday-pulse";
|
||||
|
||||
/** Slim DTO the intraday route returns (type-only — no server code bundled). */
|
||||
@@ -57,13 +59,23 @@ const AXIS_TICKS = [0, 360, 720, 1080, 1440];
|
||||
* and non-diagnostic — "possible tension", never a stress score. Empty / no-
|
||||
* baseline days render an honest one-liner rather than an empty grid.
|
||||
*
|
||||
* The area never interpolates across a gap of missing buckets — `chartData`
|
||||
* is a full-day grid with `null` for every absent bucket and the `Area`
|
||||
* renders with `connectNulls={false}`, so a handful of spot readings shows
|
||||
* as isolated points rather than a fabricated smooth curve. Whenever that
|
||||
* produces a visible break, a small "based on N readings across M hours"
|
||||
* line discloses the coverage so the shape doesn't read as more continuous
|
||||
* than it is.
|
||||
*
|
||||
* v1.29.x — a day navigator (`TileHeader`'s `right` slot, per UI-STANDARDS
|
||||
* §11) pages the same route backward through prior days; "next" is disabled
|
||||
* at today, so the view can never step into the future. A day outside the
|
||||
* dense retention window (`DENSE_INTRADAY_RETENTION_DAYS`) reads back at the
|
||||
* 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).
|
||||
* hourly day (it needs per-sample resolution to stay honest). A failed fetch
|
||||
* renders `<QueryErrorCard>` with a retry action instead of falling through
|
||||
* to the empty-day copy.
|
||||
*/
|
||||
export function IntradayPulseChart({
|
||||
userTimezone,
|
||||
@@ -91,7 +103,7 @@ export function IntradayPulseChart({
|
||||
setSelectedDateKey(next === todayKey ? null : next);
|
||||
};
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.insightsPulseIntraday(dateKey),
|
||||
queryFn: () =>
|
||||
apiGet<IntradayPulseDto>(`/api/insights/pulse/intraday?date=${dateKey}`),
|
||||
@@ -100,15 +112,51 @@ export function IntradayPulseChart({
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
(data?.series ?? []).map((b) => ({
|
||||
minute: b.startMinute,
|
||||
label: minuteLabel(b.startMinute),
|
||||
bpm: b.mean,
|
||||
})),
|
||||
[data?.series],
|
||||
);
|
||||
const bucketMinutes = data?.bucketMinutes ?? BUCKET_MINUTES;
|
||||
|
||||
// A handful of spot readings scattered across the day must never read as
|
||||
// a smooth, continuous curve — that fabricates a shape the data doesn't
|
||||
// support. Building one grid point per possible bucket (present buckets
|
||||
// carry their mean, absent ones are `null`) lets `connectNulls={false}`
|
||||
// below break the area over any missing bucket instead of interpolating
|
||||
// across it, so only genuinely adjacent readings connect.
|
||||
const chartData = useMemo(() => {
|
||||
const series = data?.series;
|
||||
if (!series || series.length === 0) return [];
|
||||
const byMinute = new Map(series.map((b) => [b.startMinute, b.mean]));
|
||||
const points: Array<{ minute: number; label: string; bpm: number | null }> =
|
||||
[];
|
||||
for (let minute = 0; minute < 1440; minute += bucketMinutes) {
|
||||
points.push({
|
||||
minute,
|
||||
label: minuteLabel(minute),
|
||||
bpm: byMinute.get(minute) ?? null,
|
||||
});
|
||||
}
|
||||
return points;
|
||||
}, [data?.series, bucketMinutes]);
|
||||
|
||||
// The coverage disclosure only fires when the chart actually contains a
|
||||
// break — a fully contiguous day already reads honestly without it. Whole
|
||||
// hours (not fractional) keep the copy simple across locales.
|
||||
const coverage = useMemo(() => {
|
||||
const series = data?.series;
|
||||
if (!series || series.length < 2) return null;
|
||||
const sorted = [...series].sort((a, b) => a.startMinute - b.startMinute);
|
||||
let hasGap = false;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
if (sorted[i].startMinute - sorted[i - 1].startMinute > bucketMinutes) {
|
||||
hasGap = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasGap) return null;
|
||||
const readingCount = sorted.reduce((sum, b) => sum + b.count, 0);
|
||||
const first = sorted[0].startMinute;
|
||||
const last = sorted[sorted.length - 1].startMinute + bucketMinutes;
|
||||
const hours = Math.max(1, Math.round((last - first) / 60));
|
||||
return { readingCount, hours };
|
||||
}, [data?.series, bucketMinutes]);
|
||||
|
||||
const caption = data?.tension
|
||||
? t(`insights.intradayPulse.tension.${data.tension.partOfDay}`)
|
||||
@@ -116,6 +164,15 @@ export function IntradayPulseChart({
|
||||
? t("insights.intradayPulse.hourlyNote")
|
||||
: t("insights.intradayPulse.caption");
|
||||
|
||||
const coverageNote = coverage
|
||||
? coverage.readingCount === 1
|
||||
? t("insights.intradayPulse.coverageOne", { hours: coverage.hours })
|
||||
: t("insights.intradayPulse.coverageMany", {
|
||||
count: coverage.readingCount,
|
||||
hours: coverage.hours,
|
||||
})
|
||||
: null;
|
||||
|
||||
const dayLabel = isToday
|
||||
? t("insights.intradayPulse.today")
|
||||
: fmt.dateShort(new Date(`${dateKey}T12:00:00.000Z`));
|
||||
@@ -167,9 +224,22 @@ export function IntradayPulseChart({
|
||||
}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs leading-snug">{caption}</p>
|
||||
{coverageNote ? (
|
||||
<p
|
||||
data-slot="intraday-pulse-coverage"
|
||||
className="text-muted-foreground text-xs leading-snug"
|
||||
>
|
||||
{coverageNote}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="bg-muted/40 h-44 animate-pulse rounded-lg motion-reduce:animate-none" />
|
||||
) : isError ? (
|
||||
<QueryErrorCard
|
||||
onRetry={() => refetch()}
|
||||
className="border-0 bg-transparent shadow-none"
|
||||
/>
|
||||
) : chartData.length === 0 ? (
|
||||
<div className="text-muted-foreground flex h-44 items-center justify-center rounded-lg border border-dashed text-sm">
|
||||
{t("insights.intradayPulse.empty")}
|
||||
@@ -254,15 +324,22 @@ export function IntradayPulseChart({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{/* `connectNulls={false}` (the default, spelled out here for
|
||||
intent) is the honesty fix: `chartData` carries one point
|
||||
per possible bucket, `null` where no reading exists, so a
|
||||
gap of missing buckets breaks the line/fill instead of
|
||||
interpolating a shape across hours of silence. `dot`
|
||||
keeps an isolated reading visible as a point even where
|
||||
it has no neighbour to connect to. */}
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="bpm"
|
||||
stroke="var(--chart-1)"
|
||||
strokeWidth={2}
|
||||
fill="url(#intradayPulseFill)"
|
||||
dot={false}
|
||||
dot={{ r: 2, fill: "var(--chart-1)", strokeWidth: 0 }}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls
|
||||
connectNulls={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { Glp1PlateauNote } from "@/components/insights/glp1-plateau-note";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -85,7 +86,7 @@ export function EfficacyTab({
|
||||
const timezone = user?.timezone || DEFAULT_TIMEZONE;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.medicationEfficacy(medicationId),
|
||||
queryFn: () =>
|
||||
apiGet<MedicationEfficacyDTO>(
|
||||
@@ -95,7 +96,7 @@ export function EfficacyTab({
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (isLoading || !data) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<MedicationDetailSection
|
||||
titleId="medication-wirkung-heading"
|
||||
@@ -107,6 +108,21 @@ export function EfficacyTab({
|
||||
);
|
||||
}
|
||||
|
||||
// A failed fetch previously fell through to the loading skeleton forever
|
||||
// (the guard above checked `isLoading || !data`, and `data` never
|
||||
// arrives on error) — an honest retry surface instead of a silent hang.
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<MedicationDetailSection
|
||||
titleId="medication-wirkung-heading"
|
||||
title={t("medications.efficacy.title")}
|
||||
dataSlot="medication-wirkung-section"
|
||||
>
|
||||
<QueryErrorCard onRetry={() => refetch()} />
|
||||
</MedicationDetailSection>
|
||||
);
|
||||
}
|
||||
|
||||
// The tab is hidden upstream when ineligible; this is a defensive fallback.
|
||||
if (!data.eligible) {
|
||||
return (
|
||||
|
||||
@@ -82,6 +82,10 @@ export interface UseWorkoutsResult {
|
||||
isLoading: boolean;
|
||||
isEmpty: boolean;
|
||||
error: Error | null;
|
||||
/** True once the query has exhausted retries and settled on a failure. */
|
||||
isError: boolean;
|
||||
/** Re-runs the query — the retry action for a failed fetch. */
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export function useWorkouts(opts: UseWorkoutsOptions = {}): UseWorkoutsResult {
|
||||
@@ -109,6 +113,8 @@ export function useWorkouts(opts: UseWorkoutsOptions = {}): UseWorkoutsResult {
|
||||
isLoading: query.isLoading,
|
||||
isEmpty,
|
||||
error: query.error as Error | null,
|
||||
isError: query.isError,
|
||||
refetch: () => void query.refetch(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user