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>