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 (
-
+
);
},
);
diff --git a/components/organisms/calculator-views.tsx b/components/organisms/calculator-views.tsx
new file mode 100644
index 0000000..476ef51
--- /dev/null
+++ b/components/organisms/calculator-views.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { Clock, DollarSign } from "lucide-react";
+import { AnimatePresence, motion } from "motion/react";
+import Link from "next/link";
+import { useSearchParams } from "next/navigation";
+import { buttonClasses } from "@/components/atoms/button";
+import { SalaryCalculator } from "@/components/organisms/salary-calculator";
+import { WorkCalculator } from "@/components/organisms/work-calculator";
+import { safeGAEvent } from "@/lib/analytics";
+
+export type CalculatorView = "work" | "salary";
+
+const DEFAULT_VIEW: CalculatorView = "work";
+
+const VIEW_TABS: readonly { view: CalculatorView; label: string; icon: typeof Clock }[] = [
+ { view: "work", label: "Jornada", icon: Clock },
+ { view: "salary", label: "Custo da Hora", icon: DollarSign },
+];
+
+const PANEL_TRANSITION = {
+ initial: { opacity: 0, y: 20, scale: 0.98 },
+ animate: { opacity: 1, y: 0, scale: 1 },
+ exit: { opacity: 0, y: -20, scale: 0.98 },
+ transition: { duration: 0.4, ease: [0.23, 1, 0.32, 1] },
+} as const;
+
+export function toCalculatorView(rawView: string | null): CalculatorView {
+ return rawView === "salary" ? "salary" : DEFAULT_VIEW;
+}
+
+export function CalculatorViews({ activeView }: { activeView: CalculatorView }) {
+ return (
+ <>
+
+
+
+
+
+ {activeView === "work" ? : }
+
+
+
+ >
+ );
+}
+
+export function CalculatorViewsFromUrl() {
+ return ;
+}
diff --git a/components/organisms/journey-form.tsx b/components/organisms/journey-form.tsx
index cc04057..aa7a800 100644
--- a/components/organisms/journey-form.tsx
+++ b/components/organisms/journey-form.tsx
@@ -13,6 +13,11 @@ const DURATION_GROUPS = [2, 2] as const;
const MINUTES_PER_HOUR = 60;
const HOURS_IN_DAY = 24;
+const EXIT_MODES = [
+ { label: "AUTO", isManual: false },
+ { label: "MANUAL", isManual: true },
+] as const;
+
function formatDuration(minutes: number): string {
const hours = Math.floor(minutes / MINUTES_PER_HOUR);
const remainingMinutes = minutes % MINUTES_PER_HOUR;
@@ -89,24 +94,25 @@ export function JourneyForm({
/>
-
-
-
-
+
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();
});