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:
Marc-André Bombeck committed 2026-07-17 23:50:06 +02:00
1 parent fb73c97125
commit d124a3239d
12 files changed
+176 -36

No files matched your search

+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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ń",
+8 -2
View File
@@ -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 (
+6
View File
@@ -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(),
};
}