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, {