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
78 changes: 78 additions & 0 deletions __tests__/calculator-views.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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: () => <p>Painel da jornada</p>,
}));

vi.mock("@/components/organisms/salary-calculator", () => ({
SalaryCalculator: () => <p>Painel do custo da hora</p>,
}));

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(<CalculatorViews activeView="salary" />);

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(<CalculatorViews activeView="work" />);
expect(screen.getByText("Painel da jornada")).toBeInTheDocument();

rerender(<CalculatorViews activeView="salary" />);
expect(await screen.findByText("Painel do custo da hora")).toBeInTheDocument();
});

it("tracks the tab the visitor moves to", async () => {
const user = userEvent.setup();
render(<CalculatorViews activeView="work" />);

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(<CalculatorViewsFromUrl />);

expect(screen.getByText("Painel do custo da hora")).toBeInTheDocument();
});

it("falls back to the journey when the query string has no view", () => {
render(<CalculatorViewsFromUrl />);

expect(screen.getByText("Painel da jornada")).toBeInTheDocument();
});
});
14 changes: 7 additions & 7 deletions __tests__/journey-form.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -125,17 +125,17 @@ describe("JourneyForm", () => {
const user = userEvent.setup();
render(<JourneyHarness onManualExitChange={onManualExitChange} />);

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);
});
Expand Down
20 changes: 5 additions & 15 deletions __tests__/page.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -26,6 +26,10 @@ vi.mock("@/components/organisms/salary-calculator", () => ({
SalaryCalculator: () => <p>Painel do custo da hora</p>,
}));

vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams(),
}));

describe("Home", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand DownExpand Up@@ -68,21 +72,7 @@ describe("Home", () => {
render(<Home />);

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(<Home />);

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 () => {
Expand Down
6 changes: 3 additions & 3 deletions __tests__/work-calculator.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -215,15 +215,15 @@ describe("WorkCalculator", () => {
const user = userEvent.setup();
render(<WorkCalculator />);

await user.click(screen.getByRole("button", { name: "MANUAL" }));
await user.click(screen.getByRole("radio", { name: "MANUAL" }));

expect(safeGAEvent).toHaveBeenCalledWith("toggle_manual_mode", {
value: "manual",
});
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",
Expand All@@ -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 () => {
Expand Down
72 changes: 6 additions & 66 deletions app/page.tsx
Original file line numberDiff line numberDiff line change
@@ -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<View>("work");
const currentTime = useCurrentTime();
const { setTheme, resolvedTheme } = useTheme();

Expand DownExpand Up@@ -99,64 +94,9 @@ export default function Home() {
</div>
</header>

<nav className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50">
<div className="bg-white/80 dark:bg-neutral-900/80 backdrop-blur-xl border border-neutral-200 dark:border-neutral-800 p-1.5 rounded-2xl shadow-2xl flex items-center gap-1">
<Button
variant={activeView === "work" ? "default" : "ghost"}
onClick={() => {
setActiveView("work");
safeGAEvent("switch_tab", { tab: "work" });
}}
className="gap-2"
aria-pressed={activeView === "work"}
>
<Clock className="w-4 h-4" aria-hidden="true" />
<span>Jornada</span>
</Button>
<Button
variant={activeView === "salary" ? "default" : "ghost"}
onClick={() => {
setActiveView("salary");
safeGAEvent("switch_tab", { tab: "salary" });
}}
className="gap-2"
aria-pressed={activeView === "salary"}
>
<DollarSign className="w-4 h-4" aria-hidden="true" />
<span>Custo da Hora</span>
</Button>
</div>
</nav>

<div
id="main-content"
tabIndex={-1}
className="pt-32 pb-32 px-4 sm:px-6 lg:px-8 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
>
<AnimatePresence mode="wait">
{activeView === "work" ? (
<motion.div
key="work"
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] }}
>
<WorkCalculator />
</motion.div>
) : (
<motion.div
key="salary"
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] }}
>
<SalaryCalculator />
</motion.div>
)}
</AnimatePresence>
</div>
<Suspense fallback={<CalculatorViews activeView="work" />}>
<CalculatorViewsFromUrl />
</Suspense>

<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none">
<div className="absolute -top-[10%] -left-[10%] w-[40%] h-[40%] bg-indigo-500/5 blur-[120px] rounded-full" />
Expand Down
47 changes: 24 additions & 23 deletions components/atoms/button.tsx
Original file line numberDiff line numberDiff line change
@@ -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<HTMLButtonElement> {
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<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "default", ...props }, ref) => {
return (
<button
ref={ref}
type={props.type || "button"}
className={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,
)}
{...props}
/>
<button ref={ref} type={props.type || "button"} className={buttonClasses(variant, size, className)} {...props} />
);
},
);
Expand Down
71 changes: 71 additions & 0 deletions components/organisms/calculator-views.tsx
Original file line numberDiff line numberDiff line change
@@ -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 (
<>
<nav aria-label="Calculadoras" className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50">
Comment thread
devRMA marked this conversation as resolved.
<ul className="bg-white/80 dark:bg-neutral-900/80 backdrop-blur-xl border border-neutral-200 dark:border-neutral-800 p-1.5 rounded-2xl shadow-2xl flex items-center gap-1">
{VIEW_TABS.map(({ view, label, icon: Icon }) => (
<li key={view}>
<Link
href={`/?view=${view}`}
scroll={false}
aria-current={activeView === view ? "page" : undefined}
onClick={() => safeGAEvent("switch_tab", { tab: view })}
className={buttonClasses(activeView === view ? "default" : "ghost", "default", "gap-2")}
>
<Icon className="w-4 h-4" aria-hidden="true" />
<span>{label}</span>
</Link>
</li>
))}
</ul>
</nav>

<div
id="main-content"
tabIndex={-1}
className="pt-32 pb-32 px-4 sm:px-6 lg:px-8 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
Comment thread
devRMA marked this conversation as resolved.
>
<AnimatePresence mode="wait">
<motion.div key={activeView} {...PANEL_TRANSITION}>
{activeView === "work" ? <WorkCalculator /> : <SalaryCalculator />}
</motion.div>
</AnimatePresence>
</div>
</>
);
}

export function CalculatorViewsFromUrl() {
return <CalculatorViews activeView={toCalculatorView(useSearchParams().get("view"))} />;
}
Loading