diff --git a/src/components/admin/__tests__/coach-feedback-section.test.tsx b/src/components/admin/__tests__/coach-feedback-section.test.tsx index 47b9d88e8..3d21af81b 100644 --- a/src/components/admin/__tests__/coach-feedback-section.test.tsx +++ b/src/components/admin/__tests__/coach-feedback-section.test.tsx @@ -107,4 +107,34 @@ describe("CoachFeedbackSection", () => { expect(html).toContain("Failed to load Coach feedback summary"); expect(html).toContain('role="alert"'); }); + + // v1.4.25 W8 regression — Marc reported a layout shift on + // /admin/coach-feedback. Root cause was the title only rendering in the + // empty + data branches, so the section grew taller (loading → data) + // and snapped down. Lock the heading + outer-card structure across + // every render state so the section maintains a constant top-of-card + // baseline. + it("keeps the section heading rendered across loading / error / empty states", () => { + for (const queryState of [ + { isLoading: true, isError: false, data: null }, + { isLoading: false, isError: true, data: null }, + { + isLoading: false, + isError: false, + data: { + generatedAt: "2026-05-10T04:00:00Z", + windowDays: 30, + coachBuckets: [], + }, + }, + ]) { + useQueryMock.mockReturnValue(queryState); + const html = render(); + // Sparkles icon + h2 title rendered in every state ⇒ constant + // header height ⇒ no layout shift when the query resolves. + expect(html).toContain("Coach Feedback"); + expect(html).toContain("bg-card"); + expect(html).toContain("rounded-xl"); + } + }); }); diff --git a/src/components/admin/coach-feedback-section.tsx b/src/components/admin/coach-feedback-section.tsx index 3bf76b386..4121c4fd6 100644 --- a/src/components/admin/coach-feedback-section.tsx +++ b/src/components/admin/coach-feedback-section.tsx @@ -64,125 +64,118 @@ export function CoachFeedbackSection() { }, }); - if (query.isLoading) { - return ( -
- - - {t("admin.coachFeedback.loading")} - -
- ); - } - - if (query.isError) { - return ( -
- {t("admin.coachFeedback.loadError")} -
- ); - } - - const summary = query.data; + const summary = query.data ?? null; const buckets = summary?.coachBuckets ?? []; + const hasData = !!summary && buckets.length > 0; - if (!summary || buckets.length === 0) { - return ( -
-
- -
- {t("admin.coachFeedback.title")} -
-
-

- {t("admin.coachFeedback.empty")} -

-
- ); - } - + // v1.4.25 W8 — keep the section's outer card + header rendered across + // every query state (loading / error / empty / data) so the heading + // baseline stays at a constant Y-offset. Previously the loading branch + // returned a thin flex-row stub and the error branch a p-4 alert with + // NO heading, which made the entire section snap downward when the + // fetched data resolved — Marc reported the visible layout shift on + // /admin/coach-feedback. Mirrors the canonical structure used by + // (header outside the fetch-state branch). return (
-
+
{t("admin.coachFeedback.title")}
-

- {t("admin.coachFeedback.windowLabel", { - days: String(summary.windowDays), - })} - {" · "} - {t("admin.coachFeedback.generatedAtLabel")}{" "} - {fmt.dateTime(summary.generatedAt)} -

+ {hasData && summary && ( +

+ {t("admin.coachFeedback.windowLabel", { + days: String(summary.windowDays), + })} + {" · "} + {t("admin.coachFeedback.generatedAtLabel")}{" "} + {fmt.dateTime(summary.generatedAt)} +

+ )}
-
- - - - - - - - - - - - - - {buckets.map((bucket, idx) => { - const ratePct = Math.round(bucket.helpfulRate * 100); - return ( - - - - - - - - + + + + + + + + ); + })} + +
- {t("admin.coachFeedback.colPromptVersion")} - - {t("admin.coachFeedback.colTone")} - - {t("admin.coachFeedback.colVerbosity")} - - {t("admin.coachFeedback.colHelpful")} - - {t("admin.coachFeedback.colNotHelpful")} - - {t("admin.coachFeedback.colN")} - - {t("admin.coachFeedback.colHelpfulRate")} -
- {bucket.promptVersion} - {bucket.tone}{bucket.verbosity}{bucket.helpful} - {bucket.notHelpful} - {bucket.total} + + + {t("admin.coachFeedback.loading")} + + + ) : query.isError ? ( +
+ {t("admin.coachFeedback.loadError")} +
+ ) : !hasData ? ( +

+ {t("admin.coachFeedback.empty")} +

+ ) : ( +
+ + + + + + + + + + + + + + {buckets.map((bucket, idx) => { + const ratePct = Math.round(bucket.helpfulRate * 100); + return ( + - {ratePct}% - - - ); - })} - -
+ {t("admin.coachFeedback.colPromptVersion")} + + {t("admin.coachFeedback.colTone")} + + {t("admin.coachFeedback.colVerbosity")} + + {t("admin.coachFeedback.colHelpful")} + + {t("admin.coachFeedback.colNotHelpful")} + + {t("admin.coachFeedback.colN")} + + {t("admin.coachFeedback.colHelpfulRate")} +
-
+
+ {bucket.promptVersion} + {bucket.tone}{bucket.verbosity}{bucket.helpful} + {bucket.notHelpful} + {bucket.total} + {ratePct}% +
+
+ )}
); }