mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +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
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user