Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions frontend/src/app/app/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,4 +304,24 @@ describe("DashboardPage", () => {
expect(screen.getByTestId("quick-actions")).toBeInTheDocument();
});
});

it("applies staggered fade-in-up animations to dashboard sections", async () => {
const { container } = render(<DashboardPage />, {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(screen.getByTestId("health-summary")).toBeInTheDocument();
});
const animated = container.querySelectorAll(".animate-fade-in-up");
expect(animated.length).toBeGreaterThanOrEqual(5);
// First section (greeting) has no delay; subsequent have staggered delays
const delays = Array.from(animated).map(
(el) => (el as HTMLElement).style.animationDelay,
);
expect(delays[0]).toBe(""); // no explicit delay
expect(delays[1]).toBe("50ms");
expect(delays[2]).toBe("100ms");
expect(delays[3]).toBe("150ms");
expect(delays[4]).toBe("200ms");
});
});
34 changes: 22 additions & 12 deletions frontend/src/app/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,28 +89,38 @@ export default function DashboardPage() {
return (
<PullToRefresh onRefresh={handleRefresh}>
<div className="space-y-6 lg:space-y-8">
<DashboardGreeting displayName={displayName} />
<div className="animate-fade-in-up">
<DashboardGreeting displayName={displayName} />
</div>

{/* Health summary — average score + band distribution */}
<HealthSummary products={dashboard.recently_viewed} />
<div className="animate-fade-in-up" style={{ animationDelay: "50ms" }}>
<HealthSummary products={dashboard.recently_viewed} />
</div>

{/* Quick win — swap suggestion for worst product */}
<ErrorBoundary level="section" context={{ section: "quick-win" }}>
<QuickWinCard products={dashboard.recently_viewed} />
</ErrorBoundary>
<div className="animate-fade-in-up" style={{ animationDelay: "100ms" }}>
<ErrorBoundary level="section" context={{ section: "quick-win" }}>
<QuickWinCard products={dashboard.recently_viewed} />
</ErrorBoundary>
</div>

{/* Recently viewed — compact card list */}
{dashboard.recently_viewed.length > 0 && (
<ErrorBoundary
level="section"
context={{ section: "recently-viewed" }}
>
<RecentlyViewed products={dashboard.recently_viewed} />
</ErrorBoundary>
<div className="animate-fade-in-up" style={{ animationDelay: "150ms" }}>
<ErrorBoundary
level="section"
context={{ section: "recently-viewed" }}
>
<RecentlyViewed products={dashboard.recently_viewed} />
</ErrorBoundary>
</div>
)}

{/* Quick actions */}
<QuickActions />
<div className="animate-fade-in-up" style={{ animationDelay: "200ms" }}>
<QuickActions />
</div>
</div>
</PullToRefresh>
);
Expand Down
54 changes: 46 additions & 8 deletions frontend/src/components/dashboard/HealthSummary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ vi.mock("@/lib/i18n", () => ({
"dashboard.healthSummaryTitle": "Health Summary",
"dashboard.healthSummaryAvg": "Avg. TryVit Score",
"dashboard.healthSummaryNoData": "Scan or browse products to see your health summary.",
"score.excellent": "Excellent",
"score.good": "Good",
"score.moderate": "Moderate",
"score.poor": "Poor",
"score.bad": "Bad",
"scoreBand.excellent": "Excellent",
"scoreBand.good": "Good",
"scoreBand.moderate": "Moderate",
"scoreBand.poor": "Poor",
"scoreBand.bad": "Bad",
};
if (key === "dashboard.healthSummaryProducts" && params) {
return `across ${params.count} products`;
Expand All @@ -21,6 +31,12 @@ vi.mock("@/lib/i18n", () => ({
}),
}));

vi.mock("@/components/product/ScoreGauge", () => ({
ScoreGauge: ({ score }: { score: number }) => (
<div data-testid="score-gauge-mock">{100 - score}</div>
),
}));

// ─── Helpers ────────────────────────────────────────────────────────────────

function makeProduct(overrides: Partial<RecentlyViewedProduct> = {}): RecentlyViewedProduct {
Expand Down Expand Up @@ -56,14 +72,15 @@ describe("HealthSummary", () => {
expect(screen.getByText(/Scan or browse/)).toBeInTheDocument();
});

it("renders score circle with TryVit score", () => {
it("renders ScoreGauge with TryVit score", () => {
// unhealthiness 40 → TryVit 60
const products = [makeProduct({ unhealthiness_score: 40 })];
render(<HealthSummary products={products} />);

const circle = screen.getByTestId("health-score-circle");
expect(circle).toBeInTheDocument();
expect(circle.textContent).toBe("60");
const gauge = screen.getByTestId("health-score-gauge");
expect(gauge).toBeInTheDocument();
// ScoreGauge mock renders 100 - score = TryVit score
expect(screen.getByTestId("score-gauge-mock").textContent).toBe("60");
});

it("computes average across multiple products", () => {
Expand All @@ -74,8 +91,7 @@ describe("HealthSummary", () => {
];
render(<HealthSummary products={products} />);

const circle = screen.getByTestId("health-score-circle");
expect(circle.textContent).toBe("60");
expect(screen.getByTestId("score-gauge-mock").textContent).toBe("60");
});

it("shows product count", () => {
Expand Down Expand Up @@ -129,8 +145,30 @@ describe("HealthSummary", () => {
];
render(<HealthSummary products={products} />);

const circle = screen.getByTestId("health-score-circle");
expect(circle.textContent).toBe("80");
expect(screen.getByTestId("score-gauge-mock").textContent).toBe("80");
expect(screen.getByText("across 1 products")).toBeInTheDocument();
});

it("renders distribution legend with band labels and counts", () => {
const products = [
makeProduct({ product_id: 1, unhealthiness_score: 10 }), // green
makeProduct({ product_id: 2, unhealthiness_score: 30 }), // yellow
makeProduct({ product_id: 3, unhealthiness_score: 50 }), // orange
];
render(<HealthSummary products={products} />);

const legend = screen.getByTestId("health-distribution-legend");
expect(legend).toBeInTheDocument();
expect(legend.textContent).toContain("Excellent (1)");
expect(legend.textContent).toContain("Good (1)");
expect(legend.textContent).toContain("Moderate (1)");
});

it("wraps gauge in scale-in animation", () => {
const products = [makeProduct({ unhealthiness_score: 30 })];
render(<HealthSummary products={products} />);

const gaugeWrapper = screen.getByTestId("health-score-gauge");
expect(gaugeWrapper.className).toContain("animate-scale-in");
});
});
37 changes: 26 additions & 11 deletions frontend/src/components/dashboard/HealthSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

// ─── HealthSummary — avg TryVit score + band distribution bar ───────────────

import { ScoreGauge } from "@/components/product/ScoreGauge";
import { useTranslation } from "@/lib/i18n";
import {
getAllBands,
Expand Down Expand Up @@ -59,7 +60,7 @@ export function HealthSummary({ products }: Readonly<HealthSummaryProps>) {
);
}

const { avgTryVit, band, distribution, total } = analysis;
const { avgTryVit, distribution, total } = analysis;

return (
<section
Expand All @@ -68,16 +69,9 @@ export function HealthSummary({ products }: Readonly<HealthSummaryProps>) {
aria-label={t("dashboard.healthSummaryTitle")}
>
<div className="flex items-center gap-4">
{/* Score circle */}
<div
data-testid="health-score-circle"
className={`flex h-16 w-16 shrink-0 items-center justify-center rounded-full ${band?.bgColor ?? "bg-muted"}`}
>
<span
className={`text-2xl font-bold tabular-nums ${band?.textColor ?? "text-foreground"}`}
>
{avgTryVit}
</span>
{/* Score gauge */}
<div className="animate-scale-in" data-testid="health-score-gauge">
<ScoreGauge score={100 - avgTryVit} size="lg" />
</div>
Comment on lines +72 to 75

<div className="min-w-0 flex-1">
Expand Down Expand Up @@ -114,6 +108,27 @@ export function HealthSummary({ products }: Readonly<HealthSummaryProps>) {
) : null,
)}
</div>

{/* Band legend */}
<div
data-testid="health-distribution-legend"
className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground"
>
{distribution
.filter((d) => d.count > 0)
.map((d) => (
<span key={d.band} className="inline-flex items-center gap-1">
<span
className="inline-block h-2 w-2 rounded-full"
style={{
backgroundColor:
SCORE_BAND_HEX[d.band as keyof typeof SCORE_BAND_HEX],
}}
Comment on lines +122 to +126
/>
Comment on lines +121 to +127
{t(d.labelKey)} ({d.count})
</span>
))}
</div>
</section>
);
}
6 changes: 6 additions & 0 deletions frontend/src/components/dashboard/QuickWinCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import { QuickWinCard } from "./QuickWinCard";

// ─── Mocks ──────────────────────────────────────────────────────────────────

vi.mock("@/components/product/ScoreGauge", () => ({
ScoreGauge: ({ score }: { score: number }) => (
<div data-testid="score-gauge-mock">{100 - score}</div>
),
}));

const mockAlternativesData = {
alternatives: [
{
Expand Down
24 changes: 8 additions & 16 deletions frontend/src/components/dashboard/QuickWinCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

// ─── QuickWinCard — swap suggestion for worst-scoring product ───────────────

import { ScoreGauge } from "@/components/product/ScoreGauge";
import { useAlternativesV2 } from "@/hooks/use-alternatives-v2";
import { useTranslation } from "@/lib/i18n";
import { getScoreBand, toTryVitScore } from "@/lib/score-utils";
import { toTryVitScore } from "@/lib/score-utils";
import type { RecentlyViewedProduct } from "@/lib/types";
import { ArrowRight } from "lucide-react";
import Link from "next/link";
Expand Down Expand Up @@ -47,9 +48,6 @@ export function QuickWinCard({ products }: Readonly<QuickWinCardProps>) {
toTryVitScore(worstScore)
: 0;

const worstBand = getScoreBand(worstScore);
const altBand = alternative ? getScoreBand(alternative.unhealthiness_score) : null;

return (
<section
data-testid="quick-win-card"
Expand Down Expand Up @@ -77,19 +75,13 @@ export function QuickWinCard({ products }: Readonly<QuickWinCardProps>) {

{/* Score comparison */}
<div className="flex items-center gap-3">
<span
data-testid="quick-win-from-score"
className={`inline-flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold tabular-nums ${worstBand?.bgColor ?? "bg-muted"} ${worstBand?.textColor ?? "text-foreground"}`}
>
{toTryVitScore(worstScore)}
</span>
<div data-testid="quick-win-from-score">
<ScoreGauge score={worstScore} size="sm" />
</div>
<ArrowRight className="h-4 w-4 text-foreground-secondary" aria-hidden="true" />
<span
data-testid="quick-win-to-score"
className={`inline-flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold tabular-nums ${altBand?.bgColor ?? "bg-muted"} ${altBand?.textColor ?? "text-foreground"}`}
>
{toTryVitScore(alternative.unhealthiness_score)}
</span>
<div data-testid="quick-win-to-score">
<ScoreGauge score={alternative.unhealthiness_score} size="sm" />
</div>
{scoreDelta > 0 && (
<span
data-testid="quick-win-gain"
Expand Down
Loading