mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(insights): polished loading + empty + error states
Loading state replaces the spinner-only placeholder with a 3-card skeleton grid that mirrors the final layout, so the page doesn't visually jump when content lands. Each placeholder pulses with a 100ms staggered delay. Empty state gains an illustrated sparkle glyph + a centred copy block (max-w-prose) so the affordance reads as an invitation rather than a muted dead-end. Error state moves from a flat banner to a card with a discoverable retry button (data-slot=insight-retry-button) when an onRegenerate handler is supplied. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
b76351e1a8
commit
0c287f1545
2 files changed
+124
-11
No files matched your search
@@ -221,3 +221,63 @@ describe("<InsightAdvisorCard> — summary typography polish (B1b)", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<InsightAdvisorCard> — polished loading / empty / error states (B1b)", () => {
|
||||
it("loading state shows a skeleton that mirrors the final layout", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard title="Loading test" insight={null} loading />
|
||||
</I18nProvider>,
|
||||
);
|
||||
// A discoverable skeleton slot replaces the spinner-only loading state.
|
||||
expect(html).toMatch(/data-slot="insight-skeleton"/);
|
||||
// 3 placeholder rec rows match the final card grid (so the page
|
||||
// doesn't visually jump when content lands).
|
||||
expect(html).toMatch(/data-slot="insight-skeleton-card"/);
|
||||
});
|
||||
|
||||
it("empty state shows a friendly illustration + CTA", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard
|
||||
title="Empty test"
|
||||
insight={null}
|
||||
onRegenerate={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="insight-empty-state"/);
|
||||
// The Start-Analysis button stays — that's the CTA.
|
||||
expect(html).toContain("Start analysis");
|
||||
});
|
||||
|
||||
it("error state surfaces a retry button when onRegenerate is provided", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard
|
||||
title="Error test"
|
||||
insight={null}
|
||||
error="Something went wrong"
|
||||
onRegenerate={() => {}}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toMatch(/data-slot="insight-error-state"/);
|
||||
expect(html).toContain("Something went wrong");
|
||||
expect(html).toMatch(/data-slot="insight-retry-button"/);
|
||||
});
|
||||
|
||||
it("error state without onRegenerate hides the retry button", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<InsightAdvisorCard
|
||||
title="Error test"
|
||||
insight={null}
|
||||
error="No retry available"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toContain("No retry available");
|
||||
expect(html).not.toContain('data-slot="insight-retry-button"');
|
||||
});
|
||||
});
|
||||
@@ -317,14 +317,36 @@ export function InsightAdvisorCard({
|
||||
: CLASSIFICATION_STYLES.gut;
|
||||
|
||||
// ── Loading State ─────────────────────────────────────
|
||||
// v1.4.16 phase B1b — skeleton mirrors the final card layout (header
|
||||
// pulse + 3 placeholder rec rows) so the page doesn't visually jump
|
||||
// when content lands. Animation keys off Tailwind `animate-pulse`,
|
||||
// which `prefers-reduced-motion: reduce` already silences via the
|
||||
// `motion-reduce:animate-none` utility on the same elements.
|
||||
if (loading && !insight) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<Loader2 className="text-dracula-purple h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2 text-sm">
|
||||
{t("insights.generating")}
|
||||
</span>
|
||||
<Card data-slot="insight-skeleton">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{icon ?? <Sparkles className="text-dracula-purple h-5 w-5" />}
|
||||
<CardTitle className="text-lg">
|
||||
{t("insights.aiAnalysisTitle")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">{title}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-muted/50 motion-reduce:animate-none h-4 w-3/4 animate-pulse rounded-md" />
|
||||
<div className="bg-muted/40 motion-reduce:animate-none h-3 w-2/3 animate-pulse rounded-md" />
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
data-slot="insight-skeleton-card"
|
||||
className="bg-muted/30 motion-reduce:animate-none h-20 animate-pulse rounded-lg"
|
||||
style={{ animationDelay: `${i * 100}ms` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -347,14 +369,45 @@ export function InsightAdvisorCard({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<div className="bg-destructive/10 text-destructive flex items-center gap-2 rounded-lg p-3 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
<div
|
||||
data-slot="insight-error-state"
|
||||
className="border-destructive/30 bg-destructive/10 text-destructive flex flex-col items-start gap-3 rounded-lg border p-4 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{onRegenerate && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRegenerate}
|
||||
disabled={regenerating}
|
||||
data-slot="insight-retry-button"
|
||||
className="self-start gap-1.5"
|
||||
>
|
||||
{regenerating ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("insights.refreshAnalysis")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!error && (
|
||||
<div className="flex flex-col items-center gap-3 py-6">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
<div
|
||||
data-slot="insight-empty-state"
|
||||
className="flex flex-col items-center gap-3 py-6 text-center"
|
||||
>
|
||||
<div className="bg-dracula-purple/10 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<Sparkles
|
||||
className="text-dracula-purple h-6 w-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-prose text-sm">
|
||||
{t("insights.noAnalysisYet")}
|
||||
</p>
|
||||
{onRegenerate && (
|
||||
|
||||
Reference in new issue
Block a user