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

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