diff --git a/src/components/insights/derived/__tests__/coach-read-strip-placeholder.test.tsx b/src/components/insights/derived/__tests__/coach-read-strip-placeholder.test.tsx new file mode 100644 index 000000000..ab5660e09 --- /dev/null +++ b/src/components/insights/derived/__tests__/coach-read-strip-placeholder.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * The strip sits above the chart on every metric sub-page. While its read is + * on the way it holds its place with a card of the same anatomy, so the chart + * and everything below it do not jump down when the read lands. + */ + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { timezone: "UTC", dateOfBirth: null, gender: null }, + isAuthenticated: true, + }), +})); + +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); + +import { CoachReadStrip } from "../coach-read-strip"; + +describe("CoachReadStrip — placeholder while the read loads", () => { + it("paints the card frame and title before the read lands", () => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, enabled: false } }, + }); + const markup = renderToStaticMarkup( + + + + + , + ); + expect(markup).toContain('data-slot="coach-read-strip"'); + expect(markup).toContain('data-state="loading"'); + expect(markup).toContain('aria-busy="true"'); + }); +}); diff --git a/src/components/insights/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx index 9fc2688e6..844ba9528 100644 --- a/src/components/insights/derived/coach-read-strip.tsx +++ b/src/components/insights/derived/coach-read-strip.tsx @@ -11,6 +11,7 @@ import { queryKeys } from "@/lib/query-keys"; import { metricFractionDigits } from "@/lib/measurements/value-domain"; import { apiGet } from "@/lib/api/api-fetch"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; import { TileHeader } from "@/components/insights/tile-header"; import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape"; @@ -78,7 +79,7 @@ export function CoachReadStrip({ const transformed = unitDisplay.isTransformed(metricType); const resolvedUnit = transformed ? unitDisplay.unitFor(metricType) : unit; - const { data } = useQuery({ + const { data, isError } = useQuery({ queryKey: queryKeys.insightsCoachRead(metricType, locale), queryFn: () => apiGet( @@ -88,10 +89,34 @@ export function CoachReadStrip({ staleTime: 5 * 60 * 1000, }); - // Match the rest of the query-dependent insights chrome: don't paint a - // branch during SSR / hydration (React #418) and don't paint until the - // read lands. - if (!mounted || !data) return null; + // Don't paint a data branch during SSR / hydration (React #418). While the + // read is on its way the strip holds its place with a card of the same + // shape: it lands above the chart, and popping in later pushed the chart + // and everything under it down the page. A landed read always paints (the + // baseline line degrades to the "learning" copy), so the placeholder only + // ever gives way to a card of its own height; a failed read drops it. + if (!mounted || !data) { + if (!isAuthenticated || isError) return null; + return ( + + + + + + + + + ); + } const fmt = (value: number): string => new Intl.NumberFormat(locale, {