mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(admin): align Coach-Feedback header with adjacent sections to prevent layout shift
The Coach-Feedback admin section previously rendered four distinct top- level structures across its loading / error / empty / data branches. The loading branch was a thin flex-row stub without the heading, the error branch was a p-4 alert with no card chrome at all, and only the empty + data branches mounted the Sparkles icon + 'Coach Feedback' title inside the bg-card border-border rounded-xl border p-6 wrapper. The result: when the user clicked into /admin/coach-feedback the section first measured at ~52 px (loading) and then snapped down to its full height once the query resolved, shifting everything below it. Mirror the canonical pattern used by SystemStatusSection: render the outer card + heading unconditionally, and switch only the body between spinner / alert / empty / table. The header now sits at a constant y-offset from the section root regardless of fetch state. Adds a regression test that asserts the 'Coach Feedback' heading and the bg-card rounded-xl outer chrome are present in every render state.
This commit is contained in:
1 parent
00105e25f7
commit
91d5db5853
2 files changed
+129
-106
No files matched your search
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -64,125 +64,118 @@ export function CoachFeedbackSection() {
|
||||
},
|
||||
});
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="bg-card border-border flex items-center gap-2 rounded-xl border p-6">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.coachFeedback.loading")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isError) {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 rounded-xl border p-4 text-sm"
|
||||
>
|
||||
{t("admin.coachFeedback.loadError")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-dracula-purple h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.coachFeedback.title")}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-3 text-sm">
|
||||
{t("admin.coachFeedback.empty")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
// <SystemStatusSection> (header outside the fetch-state branch).
|
||||
return (
|
||||
<div className="bg-card border-border space-y-4 rounded-xl border p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="text-dracula-purple h-5 w-5" />
|
||||
<div className="text-lg font-semibold">
|
||||
{t("admin.coachFeedback.title")}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.coachFeedback.windowLabel", {
|
||||
days: String(summary.windowDays),
|
||||
})}
|
||||
{" · "}
|
||||
{t("admin.coachFeedback.generatedAtLabel")}{" "}
|
||||
{fmt.dateTime(summary.generatedAt)}
|
||||
</p>
|
||||
{hasData && summary && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("admin.coachFeedback.windowLabel", {
|
||||
days: String(summary.windowDays),
|
||||
})}
|
||||
{" · "}
|
||||
{t("admin.coachFeedback.generatedAtLabel")}{" "}
|
||||
{fmt.dateTime(summary.generatedAt)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" data-slot="coach-feedback-table">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-border border-b text-left text-xs tracking-wider uppercase">
|
||||
<th className="py-2 pr-3 font-medium">
|
||||
{t("admin.coachFeedback.colPromptVersion")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium">
|
||||
{t("admin.coachFeedback.colTone")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium">
|
||||
{t("admin.coachFeedback.colVerbosity")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium tabular-nums">
|
||||
{t("admin.coachFeedback.colHelpful")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium tabular-nums">
|
||||
{t("admin.coachFeedback.colNotHelpful")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium tabular-nums">
|
||||
{t("admin.coachFeedback.colN")}
|
||||
</th>
|
||||
<th className="py-2 font-medium tabular-nums">
|
||||
{t("admin.coachFeedback.colHelpfulRate")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{buckets.map((bucket, idx) => {
|
||||
const ratePct = Math.round(bucket.helpfulRate * 100);
|
||||
return (
|
||||
<tr
|
||||
key={`${bucket.promptVersion}-${bucket.tone}-${bucket.verbosity}-${idx}`}
|
||||
className="border-border/40 border-b last:border-0"
|
||||
data-slot="coach-feedback-bucket"
|
||||
>
|
||||
<td className="text-muted-foreground py-2 pr-3 text-xs">
|
||||
{bucket.promptVersion}
|
||||
</td>
|
||||
<td className="py-2 pr-3">{bucket.tone}</td>
|
||||
<td className="py-2 pr-3">{bucket.verbosity}</td>
|
||||
<td className="py-2 pr-3 tabular-nums">{bucket.helpful}</td>
|
||||
<td className="py-2 pr-3 tabular-nums">
|
||||
{bucket.notHelpful}
|
||||
</td>
|
||||
<td className="py-2 pr-3 tabular-nums">{bucket.total}</td>
|
||||
<td
|
||||
className={`py-2 font-semibold tabular-nums ${helpfulRateColour(
|
||||
bucket.helpfulRate,
|
||||
)}`}
|
||||
{query.isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("admin.coachFeedback.loading")}
|
||||
</span>
|
||||
</div>
|
||||
) : query.isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border p-3 text-sm"
|
||||
>
|
||||
{t("admin.coachFeedback.loadError")}
|
||||
</div>
|
||||
) : !hasData ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("admin.coachFeedback.empty")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" data-slot="coach-feedback-table">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground border-border border-b text-left text-xs tracking-wider uppercase">
|
||||
<th className="py-2 pr-3 font-medium">
|
||||
{t("admin.coachFeedback.colPromptVersion")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium">
|
||||
{t("admin.coachFeedback.colTone")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium">
|
||||
{t("admin.coachFeedback.colVerbosity")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium tabular-nums">
|
||||
{t("admin.coachFeedback.colHelpful")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium tabular-nums">
|
||||
{t("admin.coachFeedback.colNotHelpful")}
|
||||
</th>
|
||||
<th className="py-2 pr-3 font-medium tabular-nums">
|
||||
{t("admin.coachFeedback.colN")}
|
||||
</th>
|
||||
<th className="py-2 font-medium tabular-nums">
|
||||
{t("admin.coachFeedback.colHelpfulRate")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{buckets.map((bucket, idx) => {
|
||||
const ratePct = Math.round(bucket.helpfulRate * 100);
|
||||
return (
|
||||
<tr
|
||||
key={`${bucket.promptVersion}-${bucket.tone}-${bucket.verbosity}-${idx}`}
|
||||
className="border-border/40 border-b last:border-0"
|
||||
data-slot="coach-feedback-bucket"
|
||||
>
|
||||
{ratePct}%
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<td className="text-muted-foreground py-2 pr-3 text-xs">
|
||||
{bucket.promptVersion}
|
||||
</td>
|
||||
<td className="py-2 pr-3">{bucket.tone}</td>
|
||||
<td className="py-2 pr-3">{bucket.verbosity}</td>
|
||||
<td className="py-2 pr-3 tabular-nums">{bucket.helpful}</td>
|
||||
<td className="py-2 pr-3 tabular-nums">
|
||||
{bucket.notHelpful}
|
||||
</td>
|
||||
<td className="py-2 pr-3 tabular-nums">{bucket.total}</td>
|
||||
<td
|
||||
className={`py-2 font-semibold tabular-nums ${helpfulRateColour(
|
||||
bucket.helpfulRate,
|
||||
)}`}
|
||||
>
|
||||
{ratePct}%
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user