diff --git a/__tests__/calculator-views.test.tsx b/__tests__/calculator-views.test.tsx new file mode 100644 index 0000000..310288e --- /dev/null +++ b/__tests__/calculator-views.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CalculatorViews, CalculatorViewsFromUrl, toCalculatorView } from "@/components/organisms/calculator-views"; +import { safeGAEvent } from "@/lib/analytics"; + +vi.mock("@/lib/analytics", () => ({ + safeGAEvent: vi.fn(), +})); + +vi.mock("@/components/organisms/work-calculator", () => ({ + WorkCalculator: () =>

Painel da jornada

, +})); + +vi.mock("@/components/organisms/salary-calculator", () => ({ + SalaryCalculator: () =>

Painel do custo da hora

, +})); + +const searchParams = { current: new URLSearchParams() }; + +vi.mock("next/navigation", () => ({ + useSearchParams: () => searchParams.current, +})); + +describe("toCalculatorView", () => { + it("only accepts the salary view, falling back to the journey", () => { + expect(toCalculatorView("salary")).toBe("salary"); + expect(toCalculatorView("work")).toBe("work"); + expect(toCalculatorView("anything-else")).toBe("work"); + expect(toCalculatorView(null)).toBe("work"); + }); +}); + +describe("CalculatorViews", () => { + beforeEach(() => { + vi.clearAllMocks(); + searchParams.current = new URLSearchParams(); + }); + + it("marks the active tab and links both views by URL", () => { + render(); + + expect(screen.getByRole("link", { name: "Jornada" })).toHaveAttribute("href", "/?view=work"); + expect(screen.getByRole("link", { name: "Custo da Hora" })).toHaveAttribute("href", "/?view=salary"); + expect(screen.getByRole("link", { name: "Custo da Hora" })).toHaveAttribute("aria-current", "page"); + expect(screen.getByRole("link", { name: "Jornada" })).not.toHaveAttribute("aria-current"); + }); + + it("renders the panel matching the active view", async () => { + const { rerender } = render(); + expect(screen.getByText("Painel da jornada")).toBeInTheDocument(); + + rerender(); + expect(await screen.findByText("Painel do custo da hora")).toBeInTheDocument(); + }); + + it("tracks the tab the visitor moves to", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("link", { name: "Custo da Hora" })); + + expect(safeGAEvent).toHaveBeenCalledWith("switch_tab", { tab: "salary" }); + }); + + it("reads the active view from the query string", () => { + searchParams.current = new URLSearchParams("view=salary"); + render(); + + expect(screen.getByText("Painel do custo da hora")).toBeInTheDocument(); + }); + + it("falls back to the journey when the query string has no view", () => { + render(); + + expect(screen.getByText("Painel da jornada")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/journey-form.test.tsx b/__tests__/journey-form.test.tsx index 21fe7a8..f11b4dc 100644 --- a/__tests__/journey-form.test.tsx +++ b/__tests__/journey-form.test.tsx @@ -125,17 +125,17 @@ describe("JourneyForm", () => { const user = userEvent.setup(); render(); - const autoButton = screen.getByRole("button", { name: "AUTO" }); - const manualButton = screen.getByRole("button", { name: "MANUAL" }); - expect(autoButton).toHaveAttribute("aria-pressed", "true"); - expect(manualButton).toHaveAttribute("aria-pressed", "false"); + const autoOption = screen.getByRole("radio", { name: "AUTO" }); + const manualOption = screen.getByRole("radio", { name: "MANUAL" }); + expect(autoOption).toBeChecked(); + expect(manualOption).not.toBeChecked(); - await user.click(manualButton); + await user.click(manualOption); expect(onManualExitChange).toHaveBeenCalledWith(true); - expect(manualButton).toHaveAttribute("aria-pressed", "true"); + expect(manualOption).toBeChecked(); - await user.click(autoButton); + await user.click(autoOption); expect(onManualExitChange).toHaveBeenLastCalledWith(false); }); diff --git a/__tests__/page.test.tsx b/__tests__/page.test.tsx index 419752c..df5b83b 100644 --- a/__tests__/page.test.tsx +++ b/__tests__/page.test.tsx @@ -26,6 +26,10 @@ vi.mock("@/components/organisms/salary-calculator", () => ({ SalaryCalculator: () =>

Painel do custo da hora

, })); +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(), +})); + describe("Home", () => { beforeEach(() => { vi.clearAllMocks(); @@ -68,21 +72,7 @@ describe("Home", () => { render(); expect(screen.getByText("Painel da jornada")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Jornada" })).toHaveAttribute("aria-pressed", "true"); - }); - - it("switches to the hourly cost view and tracks it", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole("button", { name: "Custo da Hora" })); - - expect(safeGAEvent).toHaveBeenCalledWith("switch_tab", { tab: "salary" }); - expect(await screen.findByText("Painel do custo da hora")).toBeInTheDocument(); - - await user.click(screen.getByRole("button", { name: "Jornada" })); - - expect(safeGAEvent).toHaveBeenCalledWith("switch_tab", { tab: "work" }); + expect(screen.getByRole("link", { name: "Jornada" })).toHaveAttribute("aria-current", "page"); }); it("offers the dark theme while the light one is active", async () => { diff --git a/__tests__/work-calculator.test.tsx b/__tests__/work-calculator.test.tsx index c662526..f196440 100644 --- a/__tests__/work-calculator.test.tsx +++ b/__tests__/work-calculator.test.tsx @@ -215,7 +215,7 @@ describe("WorkCalculator", () => { const user = userEvent.setup(); render(); - await user.click(screen.getByRole("button", { name: "MANUAL" })); + await user.click(screen.getByRole("radio", { name: "MANUAL" })); expect(safeGAEvent).toHaveBeenCalledWith("toggle_manual_mode", { value: "manual", @@ -223,7 +223,7 @@ describe("WorkCalculator", () => { expect(screen.getByText("BALANÇO FINAL")).toBeInTheDocument(); expect(screen.getAllByText("Saída Real").length).toBeGreaterThan(0); - await user.click(screen.getByRole("button", { name: "AUTO" })); + await user.click(screen.getByRole("radio", { name: "AUTO" })); expect(safeGAEvent).toHaveBeenCalledWith("toggle_manual_mode", { value: "auto", @@ -238,7 +238,7 @@ describe("WorkCalculator", () => { await user.clear(exitTimeField); await user.type(exitTimeField, "1900"); - expect(screen.getByRole("button", { name: "MANUAL" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("radio", { name: "MANUAL" })).toBeChecked(); }); it("restores the defaults and tracks the reset", async () => { diff --git a/app/page.tsx b/app/page.tsx index 67c83e9..bbad6b5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,22 +1,17 @@ "use client"; import { format } from "date-fns"; -import { Clock, DollarSign, Moon, Sun, Wallet } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; +import { Clock, Moon, Sun, Wallet } from "lucide-react"; import { useTheme } from "next-themes"; -import { useEffect, useState } from "react"; +import { Suspense, useEffect } from "react"; import { Button } from "@/components/atoms/button"; -import { SalaryCalculator } from "@/components/organisms/salary-calculator"; -import { WorkCalculator } from "@/components/organisms/work-calculator"; +import { CalculatorViews, CalculatorViewsFromUrl } from "@/components/organisms/calculator-views"; import { useCurrentTime } from "@/hooks/use-current-time"; import { safeGAEvent } from "@/lib/analytics"; -type View = "work" | "salary"; - const PLACEHOLDER_CLOCK = "--:--:--"; export default function Home() { - const [activeView, setActiveView] = useState("work"); const currentTime = useCurrentTime(); const { setTheme, resolvedTheme } = useTheme(); @@ -99,64 +94,9 @@ export default function Home() { - - -
- - {activeView === "work" ? ( - - - - ) : ( - - - - )} - -
+ }> + +
diff --git a/components/atoms/button.tsx b/components/atoms/button.tsx index ce2125d..96e1e52 100644 --- a/components/atoms/button.tsx +++ b/components/atoms/button.tsx @@ -1,35 +1,36 @@ import * as React from "react"; import { cn } from "@/lib/utils"; +export type ButtonVariant = "default" | "outline" | "ghost" | "danger"; +export type ButtonSize = "default" | "sm" | "lg" | "icon"; + export interface ButtonProps extends React.ButtonHTMLAttributes { - variant?: "default" | "outline" | "ghost" | "danger"; - size?: "default" | "sm" | "lg" | "icon"; + variant?: ButtonVariant; + size?: ButtonSize; +} + +export function buttonClasses(variant: ButtonVariant = "default", size: ButtonSize = "default", className?: string) { + return cn( + "inline-flex items-center justify-center whitespace-nowrap rounded-xl font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-neutral-950 disabled:pointer-events-none disabled:opacity-50", + { + "bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-500/20 active:scale-95": variant === "default", + "border border-neutral-200 dark:border-neutral-800 bg-transparent hover:bg-neutral-100 dark:hover:bg-neutral-800 text-neutral-900 dark:text-white": + variant === "outline", + "hover:bg-neutral-100 dark:hover:bg-neutral-800 text-neutral-900 dark:text-white": variant === "ghost", + "bg-red-500/10 text-red-600 hover:bg-red-500/20": variant === "danger", + "h-12 px-6 py-2 text-base": size === "default", + "h-11 rounded-md px-3 text-sm": size === "sm", + "h-14 rounded-2xl px-8 text-lg": size === "lg", + "h-11 w-11": size === "icon", + }, + className, + ); } const Button = React.forwardRef( ({ className, variant = "default", size = "default", ...props }, ref) => { return ( -
-
- - -
+
+ Modo de cálculo da saída + {EXIT_MODES.map(({ label, isManual }) => ( + + ))} +
diff --git a/tests/e2e/salary-calculator.spec.ts b/tests/e2e/salary-calculator.spec.ts index 4006c70..653de76 100644 --- a/tests/e2e/salary-calculator.spec.ts +++ b/tests/e2e/salary-calculator.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test"; test.describe("Salary Calculator (Custo da Hora)", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); - await page.click('button:has-text("Custo da Hora")'); + await page.getByRole("link", { name: "Custo da Hora" }).click(); }); test("should display default salary elements", async ({ page }) => { diff --git a/tests/e2e/work-calculator.spec.ts b/tests/e2e/work-calculator.spec.ts index 1942526..5cd340d 100644 --- a/tests/e2e/work-calculator.spec.ts +++ b/tests/e2e/work-calculator.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from "@playwright/test"; test.describe("Work Calculator (Jornada)", () => { test.beforeEach(async ({ page }) => { await page.goto("/"); - await page.click('button:has-text("Jornada")'); + await page.getByRole("link", { name: "Jornada" }).click(); }); test("should display default values correctly", async ({ page }) => { @@ -14,7 +14,7 @@ test.describe("Work Calculator (Jornada)", () => { }); test("should allow manual exit input", async ({ page }) => { - await page.click('button:has-text("MANUAL")'); + await page.getByRole("radio", { name: "MANUAL" }).check(); await expect(page.locator('text="Saída Real"').first()).toBeVisible(); });