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
2 changes: 1 addition & 1 deletion frontend/src/app/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export default function DashboardPage() {

{/* Quick actions */}
<div className="animate-fade-in-up" style={{ animationDelay: "350ms" }}>
<QuickActions />
<QuickActions stats={dashboard.stats} />
</div>
</div>
</PullToRefresh>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,14 @@ describe("DashboardSkeleton", () => {
expect(chipContainer).toBeInTheDocument();
expect(chipContainer?.children.length).toBe(6);
});

it("renders all 8 sections in correct order", () => {
render(<DashboardSkeleton />);
const container = screen.getByRole("status");
// 8 visible sections + 1 sr-only span from SkeletonContainer
expect(container.children.length).toBe(9);
// Quick actions is the 8th section (index 7), before sr-only span
const quickActions = container.children[7] as HTMLElement;
expect(quickActions.matches(".grid.grid-cols-2")).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,9 @@ export function DashboardSkeleton() {
{Array.from({ length: 4 }, (_, i) => (
<div
key={i}
className="card flex flex-col items-center gap-2 py-3 lg:py-5"
className="card flex flex-col items-center gap-2 py-4 lg:py-6"
>
<Skeleton variant="rect" width={28} height={28} className="rounded-md!" />
<Skeleton variant="rect" width={40} height={40} className="rounded-xl!" />
<Skeleton variant="text" width="3.5rem" height={12} />
</div>
))}
Expand Down
65 changes: 65 additions & 0 deletions frontend/src/components/dashboard/QuickActions.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { QuickActions } from "./QuickActions";
import type { DashboardStats } from "@/lib/types";

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

Expand All @@ -13,13 +14,24 @@ vi.mock("next/link", () => ({
href: string;
children: React.ReactNode;
className?: string;
style?: React.CSSProperties;
}) => (
<a href={href} {...rest}>
{children}
</a>
),
}));

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

const mockStats: DashboardStats = {
total_scanned: 12,
total_viewed: 30,
lists_count: 5,
favorites_count: 8,
most_viewed_category: "Dairy",
};

// ─── Tests ──────────────────────────────────────────────────────────────────

describe("QuickActions", () => {
Expand Down Expand Up @@ -51,4 +63,57 @@ describe("QuickActions", () => {
);
expect(decorativeSpans.length).toBeGreaterThanOrEqual(4);
});

// ─── New tests: colored icons, badges, animation ──────────────────────────

it("renders colored icon backgrounds for each action", () => {
const { container } = render(<QuickActions />);
const iconSpans = container.querySelectorAll("span.rounded-xl[aria-hidden='true']");
expect(iconSpans[0]?.className).toContain("bg-emerald-100");
expect(iconSpans[1]?.className).toContain("bg-blue-100");
expect(iconSpans[2]?.className).toContain("bg-amber-100");
expect(iconSpans[3]?.className).toContain("bg-purple-100");
});

it("shows count badge when stats has lists_count > 0", () => {
render(<QuickActions stats={mockStats} />);
const badge = screen.getByLabelText("5");
expect(badge).toBeInTheDocument();
expect(badge.textContent).toBe("5");
});

it("hides count badge when stats is undefined", () => {
const { container } = render(<QuickActions />);
const badges = container.querySelectorAll('[aria-label]');
// Only the section aria-label, no count badges
const countBadges = Array.from(badges).filter((el) =>
/^\d+$/.test(el.getAttribute("aria-label") ?? ""),
);
expect(countBadges).toHaveLength(0);
});

it("hides count badge when lists_count is 0", () => {
const zeroStats: DashboardStats = { ...mockStats, lists_count: 0 };
const { container } = render(<QuickActions stats={zeroStats} />);
const countBadges = Array.from(
container.querySelectorAll('[aria-label]'),
).filter((el) => /^\d+$/.test(el.getAttribute("aria-label") ?? ""));
expect(countBadges).toHaveLength(0);
});

it("renders staggered bounceIn animation delays", () => {
render(<QuickActions />);
const links = screen.getAllByRole("link");
expect((links[0] as HTMLElement).style.animation).toContain("0ms");
expect((links[1] as HTMLElement).style.animation).toContain("100ms");
expect((links[2] as HTMLElement).style.animation).toContain("200ms");
expect((links[3] as HTMLElement).style.animation).toContain("300ms");
});

it("caps badge display at 99+", () => {
const bigStats: DashboardStats = { ...mockStats, lists_count: 150 };
render(<QuickActions stats={bigStats} />);
const badge = screen.getByLabelText("150");
expect(badge.textContent).toBe("99+");
});
});
71 changes: 49 additions & 22 deletions frontend/src/components/dashboard/QuickActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,66 @@
// ─── QuickActions — primary action buttons for dashboard ────────────────────

import { useTranslation } from "@/lib/i18n";
import type { DashboardStats } from "@/lib/types";
import { Camera, ClipboardList, Scale, Search, type LucideIcon } from "lucide-react";
import Link from "next/link";

const ACTIONS: readonly { key: string; icon: LucideIcon; href: string }[] = [
{ key: "scan", icon: Camera, href: "/app/scan" },
{ key: "search", icon: Search, href: "/app/search" },
{ key: "compare", icon: Scale, href: "/app/compare" },
{ key: "lists", icon: ClipboardList, href: "/app/lists" },
interface ActionDef {
key: string;
icon: LucideIcon;
href: string;
iconBg: string;
badgeKey?: keyof Pick<DashboardStats, "lists_count" | "favorites_count">;
}

const ACTIONS: readonly ActionDef[] = [
{ key: "scan", icon: Camera, href: "/app/scan", iconBg: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400" },
{ key: "search", icon: Search, href: "/app/search", iconBg: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400" },
{ key: "compare", icon: Scale, href: "/app/compare", iconBg: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400" },
{ key: "lists", icon: ClipboardList, href: "/app/lists", iconBg: "bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400", badgeKey: "lists_count" },
];

export function QuickActions() {
interface QuickActionsProps {
stats?: DashboardStats | null;
}

export function QuickActions({ stats }: QuickActionsProps) {
const { t } = useTranslation();

return (
<section aria-label={t("dashboard.quickActions")} data-testid="quick-actions">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:gap-4">
{ACTIONS.map((action) => (
<Link
key={action.key}
href={action.href}
className="card hover-lift-press group flex flex-col items-center gap-2 py-4 text-center transition-shadow hover:shadow-md lg:py-6"
>
<span
className="flex items-center justify-center"
aria-hidden="true"
{ACTIONS.map((action, index) => {
const badge = action.badgeKey && stats?.[action.badgeKey];
return (
<Link
key={action.key}
href={action.href}
className="card group relative flex flex-col items-center gap-2 py-4 text-center transition-transform transition-shadow duration-200 hover:scale-[1.04] hover:shadow-md lg:py-6"
style={{
animation: `bounceIn 400ms ease-out ${index * 100}ms both`,
}}
Comment on lines +41 to +44
>
<action.icon size={28} />
</span>
<span className="text-xs font-medium text-foreground-secondary group-hover:text-foreground sm:text-sm lg:text-base">
{t(`dashboard.action.${action.key}`)}
</span>
</Link>
))}
<span
className={`flex h-10 w-10 items-center justify-center rounded-xl ${action.iconBg}`}
aria-hidden="true"
>
<action.icon size={22} />
</span>
<span className="text-xs font-medium text-foreground-secondary group-hover:text-foreground sm:text-sm lg:text-base">
{t(`dashboard.action.${action.key}`)}
</span>
{typeof badge === "number" && badge > 0 && (
<span
className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-brand-primary px-1 text-[10px] font-bold text-white"
aria-label={`${badge}`}
>
{badge > 99 ? "99+" : badge}
</span>
Comment on lines +55 to +61
)}
</Link>
);
})}
</div>
</section>
);
Expand Down
Loading