diff --git a/__tests__/ad-block-modal.test.tsx b/__tests__/ad-block-modal.test.tsx
deleted file mode 100644
index 8a36b2e..0000000
--- a/__tests__/ad-block-modal.test.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import { fireEvent, render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { AdBlockModal } from "@/components/molecules/ad-block-modal";
-
-describe("AdBlockModal", () => {
- const mockOnClose = vi.fn();
- const mockOnConfirm = vi.fn();
-
- beforeEach(() => {
- vi.clearAllMocks();
- });
-
- it("does not render when isOpen is false", () => {
- const { container } = render( );
- expect(container.querySelector("h2")).toBeNull();
- });
-
- it("renders modal content when isOpen is true", () => {
- render( );
- expect(screen.getByText("Opa! Uma ajudinha?")).toBeDefined();
- expect(screen.getByText("Prometemos não ser chatos")).toBeDefined();
- expect(screen.getByText("Já desativei, pode contar comigo!")).toBeDefined();
- expect(screen.getByText("Continuar com AdBlock ativo")).toBeDefined();
- });
-
- it("calls onConfirm when confirm button is clicked", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByText("Já desativei, pode contar comigo!"));
- expect(mockOnConfirm).toHaveBeenCalledOnce();
- });
-
- it("calls onClose when continue button is clicked", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(screen.getByText("Continuar com AdBlock ativo"));
- expect(mockOnClose).toHaveBeenCalled();
- });
-
- it("calls onClose when X button is clicked", async () => {
- const user = userEvent.setup();
- render( );
- await user.click(
- screen.getByRole("button", {
- name: "Fechar aviso do bloqueador de anúncios",
- }),
- );
- expect(mockOnClose).toHaveBeenCalledOnce();
- });
-
- it("opens as a modal dialog named by its heading", () => {
- const { container } = render( );
- const dialog = container.querySelector("dialog") as HTMLDialogElement;
- expect(dialog.open).toBe(true);
- expect(dialog.getAttribute("aria-labelledby")).toBe("ad-block-modal-title");
- expect(screen.getByText("Opa! Uma ajudinha?").id).toBe("ad-block-modal-title");
- });
-
- it("calls onClose when the native close event fires", () => {
- const { container } = render( );
- fireEvent(container.querySelector("dialog") as Element, new Event("close"));
- expect(mockOnClose).toHaveBeenCalledOnce();
- });
-});
diff --git a/__tests__/ad-block-notice.test.tsx b/__tests__/ad-block-notice.test.tsx
new file mode 100644
index 0000000..bec3cd2
--- /dev/null
+++ b/__tests__/ad-block-notice.test.tsx
@@ -0,0 +1,52 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { AdBlockNotice } from "@/components/molecules/ad-block-notice";
+
+describe("AdBlockNotice", () => {
+ const mockOnClose = vi.fn();
+ const mockOnConfirm = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("does not render when isOpen is false", () => {
+ const { container } = render( );
+ expect(container.innerHTML).toBe("");
+ });
+
+ it("renders the notice content when isOpen is true", () => {
+ render( );
+ expect(screen.getByText("Opa! Uma ajudinha?")).toBeDefined();
+ expect(screen.getByText("Prometemos não ser chatos")).toBeDefined();
+ expect(screen.getByText("Já desativei, pode contar comigo!")).toBeDefined();
+ expect(screen.getByText("Continuar com AdBlock ativo")).toBeDefined();
+ });
+
+ it("renders as a dismissible footer notice instead of a blocking dialog", () => {
+ const { container } = render( );
+
+ expect(container.querySelector("dialog")).toBeNull();
+
+ const notice = screen.getByRole("complementary");
+ expect(notice.getAttribute("aria-labelledby")).toBe("ad-block-notice-title");
+ expect(screen.getByText("Opa! Uma ajudinha?").id).toBe("ad-block-notice-title");
+ expect(notice.className).toContain("fixed");
+ expect(notice.className).toContain("env(safe-area-inset-bottom)");
+ });
+
+ it("calls onConfirm when confirm button is clicked", async () => {
+ const user = userEvent.setup();
+ render( );
+ await user.click(screen.getByText("Já desativei, pode contar comigo!"));
+ expect(mockOnConfirm).toHaveBeenCalledOnce();
+ });
+
+ it("calls onClose when the dismiss button is clicked", async () => {
+ const user = userEvent.setup();
+ render( );
+ await user.click(screen.getByText("Continuar com AdBlock ativo"));
+ expect(mockOnClose).toHaveBeenCalledOnce();
+ });
+});
diff --git a/__tests__/ad-manager.test.tsx b/__tests__/ad-manager.test.tsx
index 6514236..d59c8e6 100644
--- a/__tests__/ad-manager.test.tsx
+++ b/__tests__/ad-manager.test.tsx
@@ -5,7 +5,8 @@ import { AdManager } from "@/components/organisms/ad-manager";
const MOCK_ADSENSE_ID = "ca-pub-123456789";
const SIDE_AD_KEY = "workload_side_ads_last_view";
const VIDEO_AD_KEY = "workload_video_ad_last_view";
-const _ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
+const VIDEO_AD_DELAY_MS = 120000;
+const REQUIRED_IDLE_MS = 30000;
const mockFetch = vi.fn();
global.fetch = mockFetch;
@@ -16,6 +17,17 @@ Object.defineProperty(window, "location", {
value: { ...window.location, reload: mockReload },
});
+const enableAdsEnv = () => {
+ vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID);
+ vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true");
+};
+
+const advanceBy = (milliseconds: number) => {
+ act(() => {
+ vi.advanceTimersByTime(milliseconds);
+ });
+};
+
describe("AdManager", () => {
beforeEach(() => {
localStorage.clear();
@@ -32,83 +44,103 @@ describe("AdManager", () => {
});
it("checks cooldowns in localStorage on mount", () => {
- vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID);
- vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true");
+ enableAdsEnv();
const spy = vi.spyOn(Storage.prototype, "getItem");
render( );
expect(spy).toHaveBeenCalledWith(SIDE_AD_KEY);
expect(spy).toHaveBeenCalledWith(VIDEO_AD_KEY);
});
- it("shows side ads after 2 seconds when no previous view exists", async () => {
- vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID);
- vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true");
+ it("shows side ads after 2 seconds when no previous view exists", () => {
+ enableAdsEnv();
render( );
expect(screen.queryByText("Espaço do Apoiador")).toBeNull();
- act(() => {
- vi.advanceTimersByTime(2000);
- });
+ advanceBy(2000);
expect(screen.getAllByText("Espaço do Apoiador")).toHaveLength(2);
});
- it("shows video modal after 30 seconds", async () => {
- vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID);
- vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true");
+ it("shows the video modal once the user has been idle for the whole delay", () => {
+ enableAdsEnv();
render( );
expect(screen.queryByText("Vídeo da Semana")).toBeNull();
- act(() => {
- vi.advanceTimersByTime(120000);
- });
+ advanceBy(VIDEO_AD_DELAY_MS);
expect(screen.getByText("Vídeo da Semana")).toBeDefined();
});
+ it("reschedules the video modal while the user is still interacting", () => {
+ enableAdsEnv();
+ render( );
+
+ advanceBy(VIDEO_AD_DELAY_MS - 10000);
+ fireEvent.keyDown(document.body, { key: "a" });
+ advanceBy(10000);
+
+ expect(screen.queryByText("Vídeo da Semana")).toBeNull();
+
+ advanceBy(REQUIRED_IDLE_MS);
+
+ expect(screen.getByText("Vídeo da Semana")).toBeDefined();
+ });
+
+ it("reschedules the video modal while a form field holds the focus", () => {
+ enableAdsEnv();
+ const formField = document.createElement("input");
+ document.body.appendChild(formField);
+ formField.focus();
+
+ render( );
+
+ advanceBy(VIDEO_AD_DELAY_MS);
+ expect(screen.queryByText("Vídeo da Semana")).toBeNull();
+
+ formField.blur();
+ advanceBy(REQUIRED_IDLE_MS);
+
+ expect(screen.getByText("Vídeo da Semana")).toBeDefined();
+ formField.remove();
+ });
+
it("hides side ads when viewed less than a week ago", () => {
- vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID);
- vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true");
+ enableAdsEnv();
localStorage.setItem(SIDE_AD_KEY, Date.now().toString());
render( );
- act(() => {
- vi.advanceTimersByTime(2000);
- });
+ advanceBy(2000);
expect(screen.queryByText("Espaço do Apoiador")).toBeNull();
});
it("hides video ad when viewed less than a week ago", () => {
- vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID);
- vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true");
+ enableAdsEnv();
localStorage.setItem(VIDEO_AD_KEY, Date.now().toString());
render( );
- act(() => {
- vi.advanceTimersByTime(120000);
- });
+ advanceBy(VIDEO_AD_DELAY_MS);
expect(screen.queryByText("Vídeo da Semana")).toBeNull();
});
- it("shows adblock modal when fetch fails", async () => {
- vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID);
- vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true");
+ it("shows the adblock notice without blocking the page when fetch fails", async () => {
+ enableAdsEnv();
mockFetch.mockRejectedValueOnce(new Error("blocked"));
- render( );
+ const { container } = render( );
await vi.waitFor(() => {
expect(screen.getByText("Opa! Uma ajudinha?")).toBeDefined();
});
+ expect(container.querySelector("dialog[open]")).toBeNull();
+ expect(document.body.style.overflow).toBe("");
});
- it("closes the adblock modal without reloading when dismissed", async () => {
- vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID);
- vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true");
+ it("closes the adblock notice without reloading when dismissed", async () => {
+ enableAdsEnv();
mockFetch.mockRejectedValueOnce(new Error("blocked"));
render( );
@@ -119,12 +151,12 @@ describe("AdManager", () => {
fireEvent.click(screen.getByText("Continuar com AdBlock ativo"));
+ expect(screen.queryByText("Opa! Uma ajudinha?")).toBeNull();
expect(mockReload).not.toHaveBeenCalled();
});
- it("reloads the page and closes the modal when confirming adblock is disabled", async () => {
- vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID);
- vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true");
+ it("reloads the page and closes the notice when confirming adblock is disabled", async () => {
+ enableAdsEnv();
mockFetch.mockRejectedValueOnce(new Error("blocked"));
render( );
@@ -138,37 +170,28 @@ describe("AdManager", () => {
expect(mockReload).toHaveBeenCalledOnce();
});
- it("persists the side ads cooldown and hides them when closed early", () => {
- vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID);
- vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true");
+ it("persists the side ads cooldown and hides them when closed", () => {
+ enableAdsEnv();
render( );
- act(() => {
- vi.advanceTimersByTime(2000);
- });
+ advanceBy(2000);
expect(screen.getAllByText("Espaço do Apoiador")).toHaveLength(2);
- const closeButtons = screen.getAllByRole("button");
- fireEvent.click(closeButtons[0]);
+ fireEvent.click(screen.getByRole("button", { name: "Fechar anúncio do lado esquerdo" }));
expect(localStorage.getItem(SIDE_AD_KEY)).not.toBeNull();
expect(screen.queryByText("Espaço do Apoiador")).toBeNull();
});
it("marks the video ad as watched and closes it once playback completes", () => {
- vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID);
- vi.stubEnv("NEXT_PUBLIC_ENABLE_ADS", "true");
+ enableAdsEnv();
render( );
- act(() => {
- vi.advanceTimersByTime(120000);
- });
+ advanceBy(VIDEO_AD_DELAY_MS);
expect(screen.getByText("Vídeo da Semana")).toBeDefined();
fireEvent.click(screen.getByText("Ver vídeo e apoiar o projeto"));
- act(() => {
- vi.advanceTimersByTime(15000);
- });
+ advanceBy(15000);
fireEvent.click(screen.getByRole("button", { name: "Fechar vídeo" }));
diff --git a/__tests__/analytics-wrapper.test.tsx b/__tests__/analytics-wrapper.test.tsx
index db46f5a..98a359d 100644
--- a/__tests__/analytics-wrapper.test.tsx
+++ b/__tests__/analytics-wrapper.test.tsx
@@ -1,6 +1,7 @@
-import { render } from "@testing-library/react";
+import { act, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AnalyticsWrapper } from "@/components/organisms/analytics-wrapper";
+import { CONSENT_CHANGED_EVENT } from "@/lib/consent";
const CONSENT_KEY = "workload_cookie_consent";
@@ -43,4 +44,28 @@ describe("AnalyticsWrapper", () => {
const { getByTestId } = render( );
expect(getByTestId("google-analytics")).toHaveAttribute("data-ga-id", "GA-TEST-ID");
});
+
+ it("loads analytics as soon as consent is granted, without a reload", () => {
+ vi.stubEnv("NEXT_PUBLIC_GA_ID", "GA-TEST-ID");
+ const { container, queryByTestId } = render( );
+ expect(container).toBeEmptyDOMElement();
+
+ localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: true }));
+ act(() => {
+ window.dispatchEvent(new Event(CONSENT_CHANGED_EVENT));
+ });
+
+ expect(queryByTestId("google-analytics")).toBeInTheDocument();
+ });
+
+ it("stops listening for consent changes once unmounted", () => {
+ vi.stubEnv("NEXT_PUBLIC_GA_ID", "GA-TEST-ID");
+ const removeEventListener = vi.spyOn(window, "removeEventListener");
+ const { unmount } = render( );
+
+ unmount();
+
+ expect(removeEventListener).toHaveBeenCalledWith(CONSENT_CHANGED_EVENT, expect.any(Function));
+ removeEventListener.mockRestore();
+ });
});
diff --git a/__tests__/app-header.test.tsx b/__tests__/app-header.test.tsx
new file mode 100644
index 0000000..ed7576b
--- /dev/null
+++ b/__tests__/app-header.test.tsx
@@ -0,0 +1,87 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { renderToString } from "react-dom/server";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { AppHeader } from "@/components/organisms/app-header";
+import { safeGAEvent } from "@/lib/analytics";
+
+vi.mock("@/lib/analytics", () => ({
+ safeGAEvent: vi.fn(),
+}));
+
+const themeState: { resolvedTheme: string | undefined; setTheme: () => void } = {
+ resolvedTheme: undefined,
+ setTheme: vi.fn(),
+};
+
+vi.mock("next-themes", () => ({
+ useTheme: () => themeState,
+}));
+
+function renderAtFixedTime() {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2025-01-06T09:30:00"));
+ return render( );
+}
+
+describe("AppHeader", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ themeState.resolvedTheme = undefined;
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("names the application and holds the clock still on the server", () => {
+ const markup = renderToString( );
+
+ expect(markup).toContain("WorkLoad");
+ expect(markup).toContain("Sua jornada de trabalho, clara e no seu controle");
+ expect(markup).toContain("--:--:--");
+ });
+
+ it("shows the live clock once the client takes over", () => {
+ renderAtFixedTime();
+
+ expect(screen.getByText("09:30:00")).toBeInTheDocument();
+ });
+
+ it("hides the ticking clock from assistive technology", () => {
+ renderAtFixedTime();
+
+ expect(screen.getByText("09:30:00").closest("[aria-hidden='true']")).toBeInTheDocument();
+ });
+
+ it("reports the session metadata on mount", () => {
+ render( );
+
+ expect(safeGAEvent).toHaveBeenCalledWith(
+ "session_metadata",
+ expect.objectContaining({ viewport_width: window.innerWidth }),
+ );
+ });
+
+ it("offers the dark theme while the light one is active", async () => {
+ themeState.resolvedTheme = "light";
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole("button", { name: "Alternar tema" }));
+
+ expect(themeState.setTheme).toHaveBeenCalledWith("dark");
+ expect(safeGAEvent).toHaveBeenCalledWith("toggle_theme", { theme: "dark" });
+ });
+
+ it("offers the light theme while the dark one is active", async () => {
+ themeState.resolvedTheme = "dark";
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(screen.getByRole("button", { name: "Alternar tema" }));
+
+ expect(themeState.setTheme).toHaveBeenCalledWith("light");
+ expect(safeGAEvent).toHaveBeenCalledWith("toggle_theme", { theme: "light" });
+ });
+});
diff --git a/__tests__/calculator-page.test.tsx b/__tests__/calculator-page.test.tsx
new file mode 100644
index 0000000..4a0bb4b
--- /dev/null
+++ b/__tests__/calculator-page.test.tsx
@@ -0,0 +1,73 @@
+import { render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { CalculatorPage } from "@/components/templates/calculator-page";
+
+vi.mock("@/lib/analytics", () => ({
+ safeGAEvent: vi.fn(),
+}));
+
+vi.mock("next-themes", () => ({
+ useTheme: () => ({ resolvedTheme: undefined, setTheme: vi.fn() }),
+}));
+
+function renderShell() {
+ return render(
+
+ Conteúdo da calculadora
+ ,
+ );
+}
+
+describe("CalculatorPage", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2025-01-06T09:30:00"));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("wraps its children in the application shell", () => {
+ renderShell();
+
+ expect(screen.getByText("Conteúdo da calculadora")).toBeInTheDocument();
+ expect(screen.getByRole("heading", { name: "WorkLoad" })).toBeInTheDocument();
+ expect(screen.getByRole("main")).toContainElement(screen.getByText("Conteúdo da calculadora"));
+ });
+
+ it("offers a skip link that jumps straight to the main content", () => {
+ renderShell();
+
+ expect(screen.getByRole("link", { name: "Pular para o conteúdo principal" })).toHaveAttribute(
+ "href",
+ "#main-content",
+ );
+ });
+
+ it("describes the application to search engines with valid structured data", () => {
+ const { container } = renderShell();
+ const script = container.querySelector('script[type="application/ld+json"]');
+
+ expect(script).not.toBeNull();
+ expect(JSON.parse(script?.textContent ?? "")).toEqual({
+ "@context": "https://schema.org",
+ "@type": "WebApplication",
+ name: "WorkLoad",
+ url: "https://workload.devrma.com",
+ description: "Calculadora inteligente de jornada e valor de trabalho.",
+ applicationCategory: "BusinessApplication",
+ operatingSystem: "Any",
+ author: {
+ "@type": "Person",
+ name: "Rafael Augusto",
+ },
+ });
+ });
+
+ it("keeps the decorative background out of the accessibility tree", () => {
+ const { container } = renderShell();
+
+ expect(container.querySelectorAll(".pointer-events-none .blur-\\[120px\\]")).toHaveLength(3);
+ });
+});
diff --git a/__tests__/calculator-view.test.ts b/__tests__/calculator-view.test.ts
new file mode 100644
index 0000000..3b8d664
--- /dev/null
+++ b/__tests__/calculator-view.test.ts
@@ -0,0 +1,8 @@
+import { describe, expect, it } from "vitest";
+import { VIEW_PATHS } from "@/lib/calculator-view";
+
+describe("VIEW_PATHS", () => {
+ it("gives each view its own static route", () => {
+ expect(VIEW_PATHS).toEqual({ work: "/", salary: "/custo-da-hora" });
+ });
+});
diff --git a/__tests__/calculator-views.test.tsx b/__tests__/calculator-views.test.tsx
index 310288e..bdfd4a8 100644
--- a/__tests__/calculator-views.test.tsx
+++ b/__tests__/calculator-views.test.tsx
@@ -1,7 +1,7 @@
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 { CalculatorViews } from "@/components/organisms/calculator-views";
import { safeGAEvent } from "@/lib/analytics";
vi.mock("@/lib/analytics", () => ({
@@ -16,32 +16,16 @@ 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: "Jornada" })).toHaveAttribute("href", "/");
+ expect(screen.getByRole("link", { name: "Custo da Hora" })).toHaveAttribute("href", "/custo-da-hora");
expect(screen.getByRole("link", { name: "Custo da Hora" })).toHaveAttribute("aria-current", "page");
expect(screen.getByRole("link", { name: "Jornada" })).not.toHaveAttribute("aria-current");
});
@@ -63,16 +47,25 @@ describe("CalculatorViews", () => {
expect(safeGAEvent).toHaveBeenCalledWith("switch_tab", { tab: "salary" });
});
- it("reads the active view from the query string", () => {
- searchParams.current = new URLSearchParams("view=salary");
- render( );
+ it("leaves the focus alone on the first render", () => {
+ const { container } = render( );
- expect(screen.getByText("Painel do custo da hora")).toBeInTheDocument();
+ expect(container.querySelector("#main-content")).not.toHaveFocus();
+ expect(document.activeElement).toBe(document.body);
});
- it("falls back to the journey when the query string has no view", () => {
- render( );
+ it("moves the focus to the panel once the view changes", () => {
+ const { container, rerender } = render( );
- expect(screen.getByText("Painel da jornada")).toBeInTheDocument();
+ rerender( );
+
+ expect(container.querySelector("#main-content")).toHaveFocus();
+ });
+
+ it("promises that nothing leaves the browser and that the numbers are an estimate", () => {
+ render( );
+
+ expect(screen.getByText(/fica salvo apenas neste navegador/)).toBeInTheDocument();
+ expect(screen.getByText(/não substituem seu holerite/)).toBeInTheDocument();
});
});
diff --git a/__tests__/cookie-consent.test.tsx b/__tests__/cookie-consent.test.tsx
index b1fdd28..ebd2eff 100644
--- a/__tests__/cookie-consent.test.tsx
+++ b/__tests__/cookie-consent.test.tsx
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CookieConsent } from "@/components/molecules/cookie-consent";
const CONSENT_KEY = "workload_cookie_consent";
+const CONSENT_CHANGED_EVENT = "workload:consent-changed";
const BANNER_DELAY_MS = 1500;
const mockReload = vi.fn();
@@ -11,20 +12,34 @@ Object.defineProperty(window, "location", {
value: { ...window.location, reload: mockReload },
});
+let consentEventDetails: { telemetry: boolean }[] = [];
+const captureConsentEvent: EventListener = (event) => {
+ consentEventDetails.push((event as CustomEvent<{ telemetry: boolean }>).detail);
+};
+
const openSettingsFromShieldButton = () => {
fireEvent.click(screen.getByRole("button", { name: "Configurações de Privacidade" }));
};
const getTelemetryToggle = () => screen.getByRole("switch");
+const showBanner = () => {
+ act(() => {
+ vi.advanceTimersByTime(BANNER_DELAY_MS);
+ });
+};
+
describe("CookieConsent", () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
vi.useFakeTimers();
+ consentEventDetails = [];
+ window.addEventListener(CONSENT_CHANGED_EVENT, captureConsentEvent);
});
afterEach(() => {
+ window.removeEventListener(CONSENT_CHANGED_EVENT, captureConsentEvent);
vi.useRealTimers();
});
@@ -35,10 +50,8 @@ describe("CookieConsent", () => {
it("renders the banner after the delay elapses when no consent is stored", () => {
render( );
- act(() => {
- vi.advanceTimersByTime(BANNER_DELAY_MS);
- });
- expect(screen.getByText("Respeitamos sua privacidade")).toBeDefined();
+ showBanner();
+ expect(screen.getByRole("heading", { level: 2, name: "Respeitamos sua privacidade" })).toBeDefined();
});
it("clears the mount timer on unmount so it never fires", () => {
@@ -51,62 +64,54 @@ describe("CookieConsent", () => {
it("does not show the banner and seeds the toggle as enabled when consent was stored as true", () => {
localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: true }));
render( );
- act(() => {
- vi.advanceTimersByTime(BANNER_DELAY_MS);
- });
+ showBanner();
expect(screen.queryByText("Respeitamos sua privacidade")).toBeNull();
openSettingsFromShieldButton();
fireEvent.click(screen.getByText("Salvar Preferências"));
expect(JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry).toBe(true);
- expect(mockReload).toHaveBeenCalledOnce();
+ expect(consentEventDetails).toEqual([{ telemetry: true }]);
});
it("does not show the banner and seeds the toggle as disabled when consent was stored as false", () => {
localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: false }));
render( );
- act(() => {
- vi.advanceTimersByTime(BANNER_DELAY_MS);
- });
+ showBanner();
expect(screen.queryByText("Respeitamos sua privacidade")).toBeNull();
openSettingsFromShieldButton();
fireEvent.click(screen.getByText("Salvar Preferências"));
expect(JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry).toBe(false);
- expect(mockReload).toHaveBeenCalledOnce();
+ expect(consentEventDetails).toEqual([{ telemetry: false }]);
});
- it("persists accepting all cookies and reloads the page", () => {
+ it("persists accepting all cookies and announces the change without reloading", () => {
render( );
- act(() => {
- vi.advanceTimersByTime(BANNER_DELAY_MS);
- });
+ showBanner();
fireEvent.click(screen.getByText("Aceitar Tudo"));
expect(JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry).toBe(true);
- expect(mockReload).toHaveBeenCalledOnce();
+ expect(consentEventDetails).toEqual([{ telemetry: true }]);
+ expect(mockReload).not.toHaveBeenCalled();
});
- it("persists refusing cookies and reloads the page", () => {
+ it("persists refusing cookies and announces the change without reloading", () => {
render( );
- act(() => {
- vi.advanceTimersByTime(BANNER_DELAY_MS);
- });
+ showBanner();
fireEvent.click(screen.getByText("Recusar"));
expect(JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry).toBe(false);
- expect(mockReload).toHaveBeenCalledOnce();
+ expect(consentEventDetails).toEqual([{ telemetry: false }]);
+ expect(mockReload).not.toHaveBeenCalled();
});
it("opens the settings dialog from the banner's Configurar link", () => {
render( );
- act(() => {
- vi.advanceTimersByTime(BANNER_DELAY_MS);
- });
+ showBanner();
fireEvent.click(screen.getByText("Configurar"));
@@ -115,9 +120,7 @@ describe("CookieConsent", () => {
it("opens the settings dialog as a modal named by its heading", () => {
const { container } = render( );
- act(() => {
- vi.advanceTimersByTime(BANNER_DELAY_MS);
- });
+ showBanner();
fireEvent.click(screen.getByText("Configurar"));
const dialog = container.querySelector("dialog") as HTMLDialogElement;
@@ -128,23 +131,19 @@ describe("CookieConsent", () => {
it("closes the settings dialog from the backdrop without persisting anything", () => {
const { container } = render( );
- act(() => {
- vi.advanceTimersByTime(BANNER_DELAY_MS);
- });
+ showBanner();
fireEvent.click(screen.getByText("Configurar"));
fireEvent.click(container.querySelector("dialog") as Element);
expect(screen.queryByText("Privacidade")).toBeNull();
expect(localStorage.getItem(CONSENT_KEY)).toBeNull();
- expect(mockReload).not.toHaveBeenCalled();
+ expect(consentEventDetails).toEqual([]);
});
it("closes the settings dialog when the native close event fires", () => {
const { container } = render( );
- act(() => {
- vi.advanceTimersByTime(BANNER_DELAY_MS);
- });
+ showBanner();
fireEvent.click(screen.getByText("Configurar"));
fireEvent(container.querySelector("dialog") as Element, new Event("close"));
@@ -155,9 +154,7 @@ describe("CookieConsent", () => {
it("closes the settings dialog from the X button without persisting anything", () => {
render( );
- act(() => {
- vi.advanceTimersByTime(BANNER_DELAY_MS);
- });
+ showBanner();
fireEvent.click(screen.getByText("Configurar"));
fireEvent.click(
@@ -168,24 +165,20 @@ describe("CookieConsent", () => {
expect(screen.queryByText("Privacidade")).toBeNull();
expect(localStorage.getItem(CONSENT_KEY)).toBeNull();
- expect(mockReload).not.toHaveBeenCalled();
+ expect(consentEventDetails).toEqual([]);
});
it("announces the always-on state of the essential cookies", () => {
render( );
- act(() => {
- vi.advanceTimersByTime(BANNER_DELAY_MS);
- });
+ showBanner();
fireEvent.click(screen.getByText("Configurar"));
expect(screen.getByText("Sempre ativo")).toBeDefined();
});
- it("toggles telemetry off and saves the toggled value", () => {
+ it("toggles telemetry off, saves the toggled value and closes the dialog", () => {
render( );
- act(() => {
- vi.advanceTimersByTime(BANNER_DELAY_MS);
- });
+ showBanner();
fireEvent.click(screen.getByText("Configurar"));
const toggle = getTelemetryToggle();
@@ -197,15 +190,14 @@ describe("CookieConsent", () => {
fireEvent.click(screen.getByText("Salvar Preferências"));
expect(JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry).toBe(false);
- expect(mockReload).toHaveBeenCalledOnce();
+ expect(consentEventDetails).toEqual([{ telemetry: false }]);
+ expect(screen.queryByText("Privacidade")).toBeNull();
});
it("toggles telemetry back on from a stored disabled consent and saves the toggled value", () => {
localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: false }));
render( );
- act(() => {
- vi.advanceTimersByTime(BANNER_DELAY_MS);
- });
+ showBanner();
openSettingsFromShieldButton();
@@ -216,6 +208,16 @@ describe("CookieConsent", () => {
fireEvent.click(screen.getByText("Salvar Preferências"));
expect(JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry).toBe(true);
- expect(mockReload).toHaveBeenCalledOnce();
+ expect(consentEventDetails).toEqual([{ telemetry: true }]);
+ });
+
+ it("keeps the privacy shortcut readable and focusable", () => {
+ localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: true }));
+ render( );
+ showBanner();
+
+ const shortcut = screen.getByRole("button", { name: "Configurações de Privacidade" });
+ expect(shortcut.className).not.toContain("opacity-30");
+ expect(shortcut.className).toContain("focus-visible:ring-2");
});
});
diff --git a/__tests__/custo-da-hora-page.test.tsx b/__tests__/custo-da-hora-page.test.tsx
new file mode 100644
index 0000000..bb89e01
--- /dev/null
+++ b/__tests__/custo-da-hora-page.test.tsx
@@ -0,0 +1,59 @@
+import { renderToString } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+import CostPerHour, { metadata } from "@/app/custo-da-hora/page";
+
+vi.mock("@/lib/analytics", () => ({
+ safeGAEvent: vi.fn(),
+}));
+
+vi.mock("next-themes", () => ({
+ useTheme: () => ({ resolvedTheme: undefined, setTheme: vi.fn() }),
+}));
+
+vi.mock("@/components/organisms/work-calculator", () => ({
+ WorkCalculator: () => Painel da jornada
,
+}));
+
+vi.mock("@/components/organisms/salary-calculator", () => ({
+ SalaryCalculator: () => Painel do custo da hora
,
+}));
+
+describe("CostPerHour", () => {
+ it("renders the whole shell on the server instead of a blank document", () => {
+ const markup = renderToString( );
+
+ expect(markup).toContain("WorkLoad");
+ expect(markup).toContain("Jornada");
+ expect(markup).toContain("Custo da Hora");
+ expect(markup).toContain("Pular para o conteúdo principal");
+ expect(markup).toContain("--:--:--");
+ });
+
+ it("serves the salary panel on the very first frame", () => {
+ const markup = renderToString( );
+
+ expect(markup).toContain("Painel do custo da hora");
+ expect(markup).not.toContain("Painel da jornada");
+ });
+
+ it("links back to the journey route", () => {
+ const markup = renderToString( );
+
+ expect(markup).toContain('href="/"');
+ expect(markup).toContain('href="/custo-da-hora"');
+ });
+});
+
+describe("metadata", () => {
+ it("describes the salary view with its own canonical", () => {
+ expect(metadata.title).toEqual({
+ absolute: "Calculadora de Valor da Hora e Salário Líquido CLT | WorkLoad",
+ });
+ expect(metadata.description).toContain("salário bruto");
+ expect(metadata.alternates).toEqual({ canonical: "/custo-da-hora" });
+ expect(metadata.openGraph).toMatchObject({
+ title: "Calculadora de Valor da Hora e Salário Líquido CLT",
+ url: "/custo-da-hora",
+ });
+ });
+});
diff --git a/__tests__/day-summary.test.tsx b/__tests__/day-summary.test.tsx
index e287033..3158478 100644
--- a/__tests__/day-summary.test.tsx
+++ b/__tests__/day-summary.test.tsx
@@ -149,7 +149,7 @@ describe("DaySummary", () => {
it("invites the reader to calculate the hourly value when it is unknown", () => {
renderSummary({ firstTierMinutes: 60, extraTierMinutes: 30 });
- expect(screen.getByRole("link", { name: /Calcule o valor da sua hora/ })).toHaveAttribute("href", "/?view=salary");
+ expect(screen.getByRole("link", { name: /Calcule o valor da sua hora/ })).toHaveAttribute("href", "/custo-da-hora");
expect(screen.queryByText(/R\$/)).toBeNull();
});
diff --git a/__tests__/page.test.tsx b/__tests__/page.test.tsx
index df5b83b..963fff8 100644
--- a/__tests__/page.test.tsx
+++ b/__tests__/page.test.tsx
@@ -1,21 +1,13 @@
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
import { renderToString } from "react-dom/server";
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import Home from "@/app/page";
-import { safeGAEvent } from "@/lib/analytics";
+import { describe, expect, it, vi } from "vitest";
+import Home, { metadata } from "@/app/page";
vi.mock("@/lib/analytics", () => ({
safeGAEvent: vi.fn(),
}));
-const themeState: { resolvedTheme: string | undefined; setTheme: () => void } = {
- resolvedTheme: undefined,
- setTheme: vi.fn(),
-};
-
vi.mock("next-themes", () => ({
- useTheme: () => themeState,
+ useTheme: () => ({ resolvedTheme: undefined, setTheme: vi.fn() }),
}));
vi.mock("@/components/organisms/work-calculator", () => ({
@@ -26,16 +18,7 @@ vi.mock("@/components/organisms/salary-calculator", () => ({
SalaryCalculator: () => Painel do custo da hora
,
}));
-vi.mock("next/navigation", () => ({
- useSearchParams: () => new URLSearchParams(),
-}));
-
describe("Home", () => {
- beforeEach(() => {
- vi.clearAllMocks();
- themeState.resolvedTheme = undefined;
- });
-
it("renders the whole shell on the server instead of a blank document", () => {
const markup = renderToString( );
@@ -46,56 +29,31 @@ describe("Home", () => {
expect(markup).toContain("--:--:--");
});
- it("describes the application for search engines", () => {
+ it("serves the journey panel on the very first frame", () => {
const markup = renderToString( );
- expect(markup).toContain("application/ld+json");
- expect(markup).toContain("WebApplication");
+ expect(markup).toContain("Painel da jornada");
+ expect(markup).not.toContain("Painel do custo da hora");
});
- it("shows the live clock once the client takes over", () => {
- render( );
-
- expect(screen.getByText(/^\d{2}:\d{2}:\d{2}$/)).toBeInTheDocument();
- });
-
- it("reports the session metadata on mount", () => {
- render( );
-
- expect(safeGAEvent).toHaveBeenCalledWith(
- "session_metadata",
- expect.objectContaining({ viewport_width: window.innerWidth }),
- );
- });
-
- it("starts on the journey view", () => {
- render( );
+ it("marks the journey tab as the current page", () => {
+ const markup = renderToString( );
- expect(screen.getByText("Painel da jornada")).toBeInTheDocument();
- expect(screen.getByRole("link", { name: "Jornada" })).toHaveAttribute("aria-current", "page");
+ expect(markup).toContain('href="/"');
+ expect(markup).toContain('aria-current="page"');
});
+});
- it("offers the dark theme while the light one is active", async () => {
- themeState.resolvedTheme = "light";
- const user = userEvent.setup();
- render( );
-
- await user.click(screen.getByTitle("Alternar tema"));
-
- expect(safeGAEvent).toHaveBeenCalledWith("toggle_theme", {
- theme: "dark",
+describe("metadata", () => {
+ it("describes the journey view with its own canonical", () => {
+ expect(metadata.title).toEqual({
+ absolute: "Calculadora de Jornada, Horas Extras e Banco de Horas | WorkLoad",
});
- });
-
- it("offers the light theme while the dark one is active", async () => {
- themeState.resolvedTheme = "dark";
- const user = userEvent.setup();
- render( );
-
- await user.click(screen.getByTitle("Alternar tema"));
-
- expect(safeGAEvent).toHaveBeenCalledWith("toggle_theme", {
- theme: "light",
+ expect(metadata.description).toContain("hora extra");
+ expect(metadata.alternates).toEqual({ canonical: "/" });
+ expect(metadata.openGraph).toMatchObject({
+ title: "Calculadora de Jornada, Horas Extras e Banco de Horas",
+ url: "/",
});
});
});
diff --git a/__tests__/salary-calculator.test.tsx b/__tests__/salary-calculator.test.tsx
index 4a34bc5..a705430 100644
--- a/__tests__/salary-calculator.test.tsx
+++ b/__tests__/salary-calculator.test.tsx
@@ -16,7 +16,7 @@ describe("SalaryCalculator", () => {
render( );
expect(screen.getByLabelText("Salário Bruto (R$)")).toHaveValue("0,00");
- expect(screen.getByText(/0,00 por hora/)).toBeInTheDocument();
+ expect(screen.getByText(/0,00 por minuto/)).toBeInTheDocument();
});
it("shows the stored salary, the workload and the resulting hourly value", () => {
@@ -110,14 +110,29 @@ describe("SalaryCalculator", () => {
expect(screen.getByText(/58\.480,37/)).toBeInTheDocument();
});
- it("summarises net salary, total received and total deductions", () => {
+ it("summarises net salary and total deductions", () => {
render( );
expect(screen.getByText("Salário Líquido")).toBeInTheDocument();
- expect(screen.getByText("Total Recebido")).toBeInTheDocument();
expect(screen.getByText("Total Descontos")).toBeInTheDocument();
});
+ it("keeps the total received hidden while it would only repeat the net salary", () => {
+ localStorage.setItem("grossSalary", "5000");
+ render( );
+
+ expect(screen.queryByText("Total Recebido")).toBeNull();
+ });
+
+ it("shows the total received once there are extra gains to add", () => {
+ localStorage.setItem("grossSalary", "5000");
+ localStorage.setItem("extraGains", JSON.stringify([{ id: "bonus", name: "Bônus", value: 300 }]));
+ render( );
+
+ expect(screen.getByText("Total Recebido")).toBeInTheDocument();
+ expect(screen.getByText("Líquido + Extras")).toBeInTheDocument();
+ });
+
it("recalculates the hourly value when the salary changes", async () => {
const user = userEvent.setup();
render( );
@@ -152,7 +167,17 @@ describe("SalaryCalculator", () => {
render( );
expect(screen.queryByRole("alert")).toBeNull();
- expect(screen.getByText(/20,45 por hora/)).toBeInTheDocument();
+ expect(screen.getByText(/0,34 por minuto/)).toBeInTheDocument();
+ });
+
+ it("spells out the hourly rate once the headline shows another period", async () => {
+ const user = userEvent.setup();
+ localStorage.setItem("grossSalary", "5000");
+ render( );
+
+ await user.click(screen.getByRole("radio", { name: "Mês" }));
+
+ expect(screen.getByText(/20,45 por hora · R\$ 0,34 por minuto/)).toBeInTheDocument();
});
it("accepts a new workload", async () => {
diff --git a/__tests__/side-ads.test.tsx b/__tests__/side-ads.test.tsx
index bc885c0..4a20091 100644
--- a/__tests__/side-ads.test.tsx
+++ b/__tests__/side-ads.test.tsx
@@ -23,13 +23,20 @@ describe("SideAds", () => {
expect(screen.getAllByText("Espaço do Apoiador")).toHaveLength(2);
});
- it("auto-hides and calls onClose after 45 seconds", () => {
+ it("keeps the ads on screen until the user closes them", () => {
render( );
act(() => {
- vi.advanceTimersByTime(45000);
+ vi.advanceTimersByTime(600000);
});
- expect(mockOnClose).toHaveBeenCalledOnce();
- expect(screen.queryByText("Espaço do Apoiador")).toBeNull();
+ expect(screen.getAllByText("Espaço do Apoiador")).toHaveLength(2);
+ expect(mockOnClose).not.toHaveBeenCalled();
+ });
+
+ it("clears the reveal timer on unmount so it never fires", () => {
+ const clearTimeoutSpy = vi.spyOn(window, "clearTimeout");
+ const { unmount } = render( );
+ unmount();
+ expect(clearTimeoutSpy).toHaveBeenCalled();
});
it("closes the left ad and calls onClose when its close button is clicked", () => {
@@ -37,8 +44,7 @@ describe("SideAds", () => {
act(() => {
vi.advanceTimersByTime(2000);
});
- const closeButtons = screen.getAllByRole("button");
- fireEvent.click(closeButtons[0]);
+ fireEvent.click(screen.getByRole("button", { name: "Fechar anúncio do lado esquerdo" }));
expect(mockOnClose).toHaveBeenCalledOnce();
});
@@ -47,8 +53,17 @@ describe("SideAds", () => {
act(() => {
vi.advanceTimersByTime(2000);
});
- const closeButtons = screen.getAllByRole("button");
- fireEvent.click(closeButtons[1]);
+ fireEvent.click(screen.getByRole("button", { name: "Fechar anúncio do lado direito" }));
expect(mockOnClose).toHaveBeenCalledOnce();
});
+
+ it("reveals the close buttons on keyboard focus", () => {
+ render( );
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+ for (const closeButton of screen.getAllByRole("button")) {
+ expect(closeButton.className).toContain("focus-visible:opacity-100");
+ }
+ });
});
diff --git a/__tests__/sitemap.test.ts b/__tests__/sitemap.test.ts
index 7744e65..80a6c54 100644
--- a/__tests__/sitemap.test.ts
+++ b/__tests__/sitemap.test.ts
@@ -2,12 +2,24 @@ import { describe, expect, it } from "vitest";
import sitemap from "@/app/sitemap";
describe("sitemap", () => {
- it("returns the expected sitemap entries", () => {
+ it("lists both calculator routes", () => {
const result = sitemap();
- expect(result).toHaveLength(1);
- expect(result[0]?.url).toBe("https://workload.devrma.com");
- expect(result[0]?.changeFrequency).toBe("weekly");
- expect(result[0]?.priority).toBe(1);
- expect(result[0]?.lastModified).toBeInstanceOf(Date);
+
+ expect(result).toHaveLength(2);
+ expect(result.map((entry) => entry.url)).toEqual([
+ "https://workload.devrma.com",
+ "https://workload.devrma.com/custo-da-hora",
+ ]);
+ });
+
+ it("ranks the journey above the hourly cost and refreshes both weekly", () => {
+ const [journey, hourlyCost] = sitemap();
+
+ expect(journey?.priority).toBe(1);
+ expect(hourlyCost?.priority).toBe(0.9);
+ expect(journey?.changeFrequency).toBe("weekly");
+ expect(hourlyCost?.changeFrequency).toBe("weekly");
+ expect(journey?.lastModified).toBeInstanceOf(Date);
+ expect(hourlyCost?.lastModified).toBeInstanceOf(Date);
});
});
diff --git a/__tests__/use-hourly-rate.test.ts b/__tests__/use-hourly-rate.test.ts
new file mode 100644
index 0000000..6b07528
--- /dev/null
+++ b/__tests__/use-hourly-rate.test.ts
@@ -0,0 +1,32 @@
+import { renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it } from "vitest";
+import { useHourlyRate } from "@/hooks/use-hourly-rate";
+import { HOURLY_RATE_KEY } from "@/lib/storage";
+
+describe("useHourlyRate", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("reads the stored hourly rate", () => {
+ localStorage.setItem(HOURLY_RATE_KEY, "20.45");
+
+ const { result } = renderHook(() => useHourlyRate());
+
+ expect(result.current).toBe(20.45);
+ });
+
+ it("stays unknown while nothing was stored", () => {
+ const { result } = renderHook(() => useHourlyRate());
+
+ expect(result.current).toBeNull();
+ });
+
+ it("treats a zeroed rate as unknown instead of free work", () => {
+ localStorage.setItem(HOURLY_RATE_KEY, "0");
+
+ const { result } = renderHook(() => useHourlyRate());
+
+ expect(result.current).toBeNull();
+ });
+});
diff --git a/__tests__/use-salary-calculator.test.ts b/__tests__/use-salary-calculator.test.ts
index 3669e3a..950b67a 100644
--- a/__tests__/use-salary-calculator.test.ts
+++ b/__tests__/use-salary-calculator.test.ts
@@ -1,6 +1,7 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { useSalaryCalculator } from "@/hooks/use-salary-calculator";
+import { DAILY_MINUTES_KEY, HOURLY_RATE_KEY } from "@/lib/storage";
describe("useSalaryCalculator", () => {
beforeEach(() => {
@@ -215,10 +216,34 @@ describe("useSalaryCalculator", () => {
result.current.setDailyMinutes(450);
});
- expect(localStorage.getItem("dailyMinutes")).toBe("450");
expect(result.current.stats.periodValue).toBeCloseTo(result.current.stats.hourlyRate * 7.5, 6);
});
+ it("shares the daily journey with the journey tab instead of keeping a second copy", () => {
+ localStorage.setItem(DAILY_MINUTES_KEY, "450");
+
+ const { result } = renderHook(() => useSalaryCalculator());
+ expect(result.current.dailyMinutes).toBe(450);
+
+ act(() => {
+ result.current.setDailyMinutes(480);
+ });
+
+ expect(localStorage.getItem(DAILY_MINUTES_KEY)).toBe("480");
+ });
+
+ it("publishes the hourly value for the journey tab to price overtime with", () => {
+ const { result } = renderHook(() => useSalaryCalculator(5000));
+
+ expect(Number(localStorage.getItem(HOURLY_RATE_KEY))).toBeCloseTo(20.4477, 4);
+
+ act(() => {
+ result.current.setGrossSalary(10000);
+ });
+
+ expect(Number(localStorage.getItem(HOURLY_RATE_KEY))).toBeCloseTo(result.current.stats.hourlyRate, 6);
+ });
+
it("has no hourly value while the monthly hours are cleared", () => {
const { result } = renderHook(() => useSalaryCalculator());
diff --git a/__tests__/work-calculator.test.tsx b/__tests__/work-calculator.test.tsx
index de4e456..343d550 100644
--- a/__tests__/work-calculator.test.tsx
+++ b/__tests__/work-calculator.test.tsx
@@ -198,12 +198,20 @@ describe("WorkCalculator", () => {
});
it("prices the overtime once the salary tab knows the hourly value", () => {
- localStorage.setItem("grossSalary", "5000");
+ localStorage.setItem("hourlyRate", "20");
vi.setSystemTime(new Date(`${DAY}T19:00:00`));
render( );
expect(screen.queryByRole("link", { name: /Calcule o valor da sua hora/ })).toBeNull();
- expect(screen.getByText(/36,81/)).toBeInTheDocument();
+ expect(screen.getByText(/36,00/)).toBeInTheDocument();
+ });
+
+ it("ignores a zeroed hourly value instead of pricing the day at nothing", () => {
+ localStorage.setItem("hourlyRate", "0");
+ vi.setSystemTime(new Date(`${DAY}T19:00:00`));
+ render( );
+
+ expect(screen.getByRole("link", { name: /Calcule o valor da sua hora/ })).toBeInTheDocument();
});
it("offers the salary tab while the hourly value is unknown", () => {
diff --git a/app/custo-da-hora/page.tsx b/app/custo-da-hora/page.tsx
new file mode 100644
index 0000000..d015904
--- /dev/null
+++ b/app/custo-da-hora/page.tsx
@@ -0,0 +1,23 @@
+import type { Metadata } from "next";
+import { CalculatorViews } from "@/components/organisms/calculator-views";
+import { CalculatorPage } from "@/components/templates/calculator-page";
+
+export const metadata: Metadata = {
+ title: { absolute: "Calculadora de Valor da Hora e Salário Líquido CLT | WorkLoad" },
+ description:
+ "Descubra quanto vale a sua hora de trabalho a partir do salário bruto, com INSS, IRRF, dependentes, descontos e ganhos extras.",
+ alternates: { canonical: "/custo-da-hora" },
+ openGraph: {
+ title: "Calculadora de Valor da Hora e Salário Líquido CLT",
+ description: "Descubra quanto vale a sua hora de trabalho, já com INSS, IRRF e dependentes.",
+ url: "/custo-da-hora",
+ },
+};
+
+export default function CostPerHour() {
+ return (
+
+
+
+ );
+}
diff --git a/app/page.tsx b/app/page.tsx
index f306055..73b7e23 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,109 +1,23 @@
-"use client";
-
-import { Clock, Moon, Sun, Wallet } from "lucide-react";
-import { useTheme } from "next-themes";
-import { Suspense, useEffect } from "react";
-import { Button } from "@/components/atoms/button";
-import { CalculatorViews, CalculatorViewsFromUrl } from "@/components/organisms/calculator-views";
-import { useCurrentTime } from "@/hooks/use-current-time";
-import { safeGAEvent } from "@/lib/analytics";
-import { formatClockTime } from "@/lib/utils";
-
-const PLACEHOLDER_CLOCK = "--:--:--";
+import type { Metadata } from "next";
+import { CalculatorViews } from "@/components/organisms/calculator-views";
+import { CalculatorPage } from "@/components/templates/calculator-page";
+
+export const metadata: Metadata = {
+ title: { absolute: "Calculadora de Jornada, Horas Extras e Banco de Horas | WorkLoad" },
+ description:
+ "Veja a que horas você pode sair, quanto já trabalhou hoje e quanto tem de hora extra, com adicional noturno e os limites da CLT.",
+ alternates: { canonical: "/" },
+ openGraph: {
+ title: "Calculadora de Jornada, Horas Extras e Banco de Horas",
+ description: "Veja a que horas você pode sair, quanto já trabalhou hoje e quanto tem de hora extra.",
+ url: "/",
+ },
+};
export default function Home() {
- const currentTime = useCurrentTime();
- const { setTheme, resolvedTheme } = useTheme();
-
- useEffect(() => {
- safeGAEvent("session_metadata", {
- screen_width: window.screen.width,
- screen_height: window.screen.height,
- viewport_width: window.innerWidth,
- viewport_height: window.innerHeight,
- device_pixel_ratio: window.devicePixelRatio,
- user_language: navigator.language,
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
- });
- }, []);
-
return (
- <>
-
-
- Pular para o conteúdo principal
-
-
-
-
-
-
-
-
-
-
- {currentTime === null ? PLACEHOLDER_CLOCK : formatClockTime(currentTime)}
-
-
-
{
- const newTheme = resolvedTheme === "dark" ? "light" : "dark";
- setTheme(newTheme);
- safeGAEvent("toggle_theme", {
- theme: newTheme,
- });
- }}
- title="Alternar tema"
- aria-label="Alternar tema"
- >
- {resolvedTheme === "dark" ? (
-
- ) : (
-
- )}
-
-
-
-
-
- }>
-
-
-
-
-
- >
+
+
+
);
}
diff --git a/app/sitemap.ts b/app/sitemap.ts
index 767fe6d..56b3295 100644
--- a/app/sitemap.ts
+++ b/app/sitemap.ts
@@ -1,12 +1,22 @@
import type { MetadataRoute } from "next";
+const SITE_URL = "https://workload.devrma.com";
+
export default function sitemap(): MetadataRoute.Sitemap {
+ const lastModified = new Date();
+
return [
{
- url: "https://workload.devrma.com",
- lastModified: new Date(),
+ url: SITE_URL,
+ lastModified,
changeFrequency: "weekly",
priority: 1,
},
+ {
+ url: `${SITE_URL}/custo-da-hora`,
+ lastModified,
+ changeFrequency: "weekly",
+ priority: 0.9,
+ },
];
}
diff --git a/components/molecules/ad-block-modal.tsx b/components/molecules/ad-block-modal.tsx
deleted file mode 100644
index 2adf4e9..0000000
--- a/components/molecules/ad-block-modal.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-"use client";
-
-import { Heart, ShieldAlert, X } from "lucide-react";
-import { Button } from "@/components/atoms/button";
-import { ModalDialog } from "@/components/atoms/modal-dialog";
-
-interface AdBlockModalProps {
- isOpen: boolean;
- onClose: () => void;
- onConfirm: () => void;
-}
-
-export function AdBlockModal({ isOpen, onClose, onConfirm }: AdBlockModalProps) {
- return (
-
-
-
-
-
-
-
-
-
-
- Opa! Uma ajudinha?
-
-
- Este projeto é gratuito e mantido com carinho. Exibimos apenas{" "}
- um único anúncio por semana — o
- suficiente para me ajudar a pagar um café e continuar codando! ☕
-
-
-
-
-
-
-
-
-
-
Prometemos não ser chatos
-
- Sua visualização semanal garante que o WorkLoad continue online e evoluindo para todos.
-
-
-
-
-
-
-
- Já desativei, pode contar comigo!
-
-
- Continuar com AdBlock ativo
-
-
-
-
- );
-}
diff --git a/components/molecules/ad-block-notice.tsx b/components/molecules/ad-block-notice.tsx
new file mode 100644
index 0000000..1af1875
--- /dev/null
+++ b/components/molecules/ad-block-notice.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { Heart } from "lucide-react";
+import { motion } from "motion/react";
+import { Button } from "@/components/atoms/button";
+
+interface AdBlockNoticeProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+}
+
+export function AdBlockNotice({ isOpen, onClose, onConfirm }: AdBlockNoticeProps) {
+ if (!isOpen) return null;
+
+ return (
+
+
+
+
+
+
+
+
+ Opa! Uma ajudinha?
+
+
+ Este projeto é gratuito e mantido com carinho. Exibimos apenas{" "}
+ um único anúncio por semana — o
+ suficiente para me ajudar a pagar um café e continuar codando! ☕
+
+
+ Prometemos não ser chatos : sua visualização semanal garante que o
+ WorkLoad continue online e evoluindo para todos.
+
+
+
+
+
+ Já desativei, pode contar comigo!
+
+
+ Continuar com AdBlock ativo
+
+
+
+
+ );
+}
diff --git a/components/molecules/cookie-consent.tsx b/components/molecules/cookie-consent.tsx
index ce94bde..825fc5f 100644
--- a/components/molecules/cookie-consent.tsx
+++ b/components/molecules/cookie-consent.tsx
@@ -5,7 +5,7 @@ import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { Button } from "@/components/atoms/button";
import { ModalDialog } from "@/components/atoms/modal-dialog";
-import { readTelemetryConsent, writeTelemetryConsent } from "@/lib/consent";
+import { CONSENT_CHANGED_EVENT, readTelemetryConsent, writeTelemetryConsent } from "@/lib/consent";
const BANNER_DELAY_MS = 1500;
@@ -29,7 +29,8 @@ export function CookieConsent() {
writeTelemetryConsent(telemetry);
setTelemetryEnabled(telemetry);
setIsVisible(false);
- window.location.reload();
+ setShowSettings(false);
+ window.dispatchEvent(new CustomEvent(CONSENT_CHANGED_EVENT, { detail: { telemetry } }));
};
return (
@@ -49,7 +50,7 @@ export function CookieConsent() {
-
Respeitamos sua privacidade
+
Respeitamos sua privacidade
Usamos cookies para melhorar sua experiência e entender como você usa o WorkLoad. Você pode optar
por desativar a telemetria a qualquer momento.
@@ -165,7 +166,7 @@ export function CookieConsent() {
setShowSettings(true)}
- className="fixed bottom-[max(1rem,env(safe-area-inset-bottom))] right-4 z-40 flex h-11 w-11 items-center justify-center rounded-full text-neutral-500 dark:text-neutral-400 hover:text-indigo-500 transition-colors opacity-30 hover:opacity-100"
+ className="fixed bottom-[max(1rem,env(safe-area-inset-bottom))] right-4 z-40 flex h-11 w-11 items-center justify-center rounded-full border border-neutral-200 dark:border-neutral-800 bg-white/90 dark:bg-neutral-950/90 text-neutral-600 dark:text-neutral-300 shadow-sm backdrop-blur-sm hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
aria-label="Configurações de Privacidade"
>
diff --git a/components/molecules/extra-entry-row.tsx b/components/molecules/extra-entry-row.tsx
index a228726..336e356 100644
--- a/components/molecules/extra-entry-row.tsx
+++ b/components/molecules/extra-entry-row.tsx
@@ -40,7 +40,7 @@ export function ExtraEntryRow({
placeholder={namePlaceholder}
value={name}
onChange={(event) => onNameChange(event.target.value)}
- className={COMPACT_FIELD_CLASSES}
+ className={`${COMPACT_FIELD_CLASSES} font-sans`}
/>
diff --git a/components/molecules/period-selector.tsx b/components/molecules/period-selector.tsx
index 452b640..c9e98c5 100644
--- a/components/molecules/period-selector.tsx
+++ b/components/molecules/period-selector.tsx
@@ -7,12 +7,12 @@ interface PeriodSelectorProps {
export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
return (
-
+
Visualizar o valor por período
{SALARY_PERIODS.map((period) => (
void;
}
+const CLOSE_BUTTON_CLASSES =
+ "absolute -top-3 z-50 bg-white dark:bg-neutral-950 border rounded-full p-1.5 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 transition-opacity";
+
export function SideAds({ onClose }: SideAdsProps) {
const [isVisible, setIsVisible] = useState(false);
@@ -17,16 +20,13 @@ export function SideAds({ onClose }: SideAdsProps) {
setIsVisible(true);
}, 2000);
- const hideTimer = setTimeout(() => {
- setIsVisible(false);
- onClose();
- }, 45000);
+ return () => clearTimeout(timer);
+ }, []);
- return () => {
- clearTimeout(timer);
- clearTimeout(hideTimer);
- };
- }, [onClose]);
+ const closeAds = () => {
+ setIsVisible(false);
+ onClose();
+ };
return (
@@ -41,13 +41,11 @@ export function SideAds({ onClose }: SideAdsProps) {
{
- setIsVisible(false);
- onClose();
- }}
- className="absolute -top-3 -right-3 z-50 bg-white dark:bg-neutral-950 border rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
+ onClick={closeAds}
+ aria-label="Fechar anúncio do lado esquerdo"
+ className={`${CLOSE_BUTTON_CLASSES} -right-3`}
>
-
+
Espaço do Apoiador
@@ -65,13 +63,11 @@ export function SideAds({ onClose }: SideAdsProps) {
{
- setIsVisible(false);
- onClose();
- }}
- className="absolute -top-3 -left-3 z-50 bg-white dark:bg-neutral-950 border rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
+ onClick={closeAds}
+ aria-label="Fechar anúncio do lado direito"
+ className={`${CLOSE_BUTTON_CLASSES} -left-3`}
>
-
+
Espaço do Apoiador
diff --git a/components/organisms/ad-manager.tsx b/components/organisms/ad-manager.tsx
index 2ea8a50..5e0991f 100644
--- a/components/organisms/ad-manager.tsx
+++ b/components/organisms/ad-manager.tsx
@@ -1,16 +1,24 @@
"use client";
import { useCallback, useEffect, useState } from "react";
-import { AdBlockModal } from "@/components/molecules/ad-block-modal";
+import { AdBlockNotice } from "@/components/molecules/ad-block-notice";
import { SideAds } from "@/components/molecules/side-ads";
import { VideoAdModal } from "@/components/molecules/video-ad-modal";
const SIDE_AD_KEY = "workload_side_ads_last_view";
const VIDEO_AD_KEY = "workload_video_ad_last_view";
const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
+const VIDEO_AD_DELAY_MS = 120000;
+const REQUIRED_IDLE_MS = 30000;
+const INTERACTION_EVENTS = ["keydown", "pointerdown"] as const;
+const FOCUSED_FORM_FIELD_SELECTOR = "input:focus, textarea:focus, select:focus, [contenteditable]:focus";
+
+function isFormFieldFocused() {
+ return document.querySelector(FOCUSED_FORM_FIELD_SELECTOR) !== null;
+}
export function AdManager() {
- const [showAdBlockModal, setShowAdBlockModal] = useState(false);
+ const [showAdBlockNotice, setShowAdBlockNotice] = useState(false);
const [showVideoModal, setShowVideoModal] = useState(false);
const [canShowSideAds, setCanShowSideAds] = useState(false);
const [canShowVideoAd, setCanShowVideoAd] = useState(false);
@@ -29,7 +37,7 @@ export function AdManager() {
setIsAdBlockActive(false);
} catch (_error) {
setIsAdBlockActive(true);
- setShowAdBlockModal(true);
+ setShowAdBlockNotice(true);
}
}, [enableAds]);
@@ -54,12 +62,34 @@ export function AdManager() {
}, [checkAdBlock, checkCooldowns]);
useEffect(() => {
- if (enableAds && canShowVideoAd && !isAdBlockActive) {
- const timer = setTimeout(() => {
- setShowVideoModal(true);
- }, 120000);
- return () => clearTimeout(timer);
+ if (!enableAds || !canShowVideoAd || isAdBlockActive) return;
+
+ let lastInteractionAt = Date.now();
+ const registerInteraction = () => {
+ lastInteractionAt = Date.now();
+ };
+ for (const eventName of INTERACTION_EVENTS) {
+ window.addEventListener(eventName, registerInteraction);
}
+
+ let timer: ReturnType
;
+ const openWhenIdle = (delay: number) => {
+ timer = setTimeout(() => {
+ if (Date.now() - lastInteractionAt < REQUIRED_IDLE_MS || isFormFieldFocused()) {
+ openWhenIdle(REQUIRED_IDLE_MS);
+ return;
+ }
+ setShowVideoModal(true);
+ }, delay);
+ };
+ openWhenIdle(VIDEO_AD_DELAY_MS);
+
+ return () => {
+ clearTimeout(timer);
+ for (const eventName of INTERACTION_EVENTS) {
+ window.removeEventListener(eventName, registerInteraction);
+ }
+ };
}, [canShowVideoAd, isAdBlockActive, enableAds]);
useEffect(() => {
@@ -85,7 +115,7 @@ export function AdManager() {
};
const handleAdBlockConfirm = () => {
- setShowAdBlockModal(false);
+ setShowAdBlockNotice(false);
window.location.reload();
};
@@ -93,9 +123,9 @@ export function AdManager() {
return (
<>
- setShowAdBlockModal(false)}
+ setShowAdBlockNotice(false)}
onConfirm={handleAdBlockConfirm}
/>
diff --git a/components/organisms/analytics-wrapper.tsx b/components/organisms/analytics-wrapper.tsx
index b2aada7..e5729e2 100644
--- a/components/organisms/analytics-wrapper.tsx
+++ b/components/organisms/analytics-wrapper.tsx
@@ -2,14 +2,18 @@
import { GoogleAnalytics } from "@next/third-parties/google";
import { useEffect, useState } from "react";
-import { readTelemetryConsent } from "@/lib/consent";
+import { CONSENT_CHANGED_EVENT, readTelemetryConsent } from "@/lib/consent";
export function AnalyticsWrapper() {
const [shouldLoad, setShouldLoad] = useState(false);
const gaId = process.env.NEXT_PUBLIC_GA_ID;
useEffect(() => {
- setShouldLoad(readTelemetryConsent() === true);
+ const syncConsent = () => setShouldLoad(readTelemetryConsent() === true);
+
+ syncConsent();
+ window.addEventListener(CONSENT_CHANGED_EVENT, syncConsent);
+ return () => window.removeEventListener(CONSENT_CHANGED_EVENT, syncConsent);
}, []);
if (!gaId || !shouldLoad) return null;
diff --git a/components/organisms/app-header.tsx b/components/organisms/app-header.tsx
new file mode 100644
index 0000000..aab8040
--- /dev/null
+++ b/components/organisms/app-header.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { Clock, Moon, Sun, Wallet } from "lucide-react";
+import { useTheme } from "next-themes";
+import { useEffect } from "react";
+import { Button } from "@/components/atoms/button";
+import { useCurrentTime } from "@/hooks/use-current-time";
+import { safeGAEvent } from "@/lib/analytics";
+import { formatClockTime } from "@/lib/utils";
+
+const PLACEHOLDER_CLOCK = "--:--:--";
+
+export function AppHeader() {
+ const currentTime = useCurrentTime();
+ const { setTheme, resolvedTheme } = useTheme();
+
+ useEffect(() => {
+ safeGAEvent("session_metadata", {
+ screen_width: window.screen.width,
+ screen_height: window.screen.height,
+ viewport_width: window.innerWidth,
+ viewport_height: window.innerHeight,
+ device_pixel_ratio: window.devicePixelRatio,
+ user_language: navigator.language,
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ });
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
WorkLoad
+
+ Sua jornada de trabalho, clara e no seu controle
+
+
+
+
+
+
+
+
+ {currentTime === null ? PLACEHOLDER_CLOCK : formatClockTime(currentTime)}
+
+
+
{
+ const newTheme = resolvedTheme === "dark" ? "light" : "dark";
+ setTheme(newTheme);
+ safeGAEvent("toggle_theme", {
+ theme: newTheme,
+ });
+ }}
+ title="Alternar tema"
+ aria-label="Alternar tema"
+ >
+ {resolvedTheme === "dark" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/components/organisms/calculator-views.tsx b/components/organisms/calculator-views.tsx
index f956c59..f1ff646 100644
--- a/components/organisms/calculator-views.tsx
+++ b/components/organisms/calculator-views.tsx
@@ -3,15 +3,12 @@
import { Clock, DollarSign } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
-import { useSearchParams } from "next/navigation";
+import { useEffect, useRef } from "react";
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";
+import { type CalculatorView, VIEW_PATHS } from "@/lib/calculator-view";
const VIEW_TABS: readonly { view: CalculatorView; label: string; icon: typeof Clock }[] = [
{ view: "work", label: "Jornada", icon: Clock },
@@ -25,11 +22,16 @@ const PANEL_TRANSITION = {
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 }) {
+ const mainRef = useRef(null);
+ const renderedView = useRef(activeView);
+
+ useEffect(() => {
+ if (renderedView.current === activeView) return;
+ renderedView.current = activeView;
+ mainRef.current?.focus();
+ }, [activeView]);
+
return (
<>
(
safeGAEvent("switch_tab", { tab: view })}
@@ -55,6 +57,7 @@ export function CalculatorViews({ activeView }: { activeView: CalculatorView })
: }
+
+
+
+ Tudo o que você digita fica salvo apenas neste navegador. Nada é enviado para nenhum servidor, e ninguém
+ além de você vê seus horários ou seu salário.
+
+
+ Os valores são uma estimativa para você se organizar — não substituem seu holerite nem valem como registro
+ oficial de ponto.
+
+
>
);
}
-
-export function CalculatorViewsFromUrl() {
- return
;
-}
diff --git a/components/organisms/day-summary.tsx b/components/organisms/day-summary.tsx
index d6c2acf..8cc0e26 100644
--- a/components/organisms/day-summary.tsx
+++ b/components/organisms/day-summary.tsx
@@ -1,5 +1,6 @@
import { AlertTriangle, Coffee, MoonStar, Sunrise, Sunset, Zap } from "lucide-react";
import Link from "next/link";
+import { VIEW_PATHS } from "@/lib/calculator-view";
import type { ComplianceWarning } from "@/lib/compliance";
import type { DayBreakdown, DaySegmentKind } from "@/lib/day-breakdown";
import { formatHoursAndMinutes, formatSignedHoursAndMinutes } from "@/lib/duration";
@@ -191,7 +192,7 @@ export function DaySummary({
{hourlyRate === null ? (
diff --git a/components/organisms/journey-form.tsx b/components/organisms/journey-form.tsx
index b4a8be3..ffd0dde 100644
--- a/components/organisms/journey-form.tsx
+++ b/components/organisms/journey-form.tsx
@@ -69,15 +69,20 @@ export function JourneyForm({
return (
-
-
Sua Jornada
+
+
+
Sua Jornada
+
+ Informe seus horários para ver quando pode sair e quanto já trabalhou.
+
+
setShowSettings(!showSettings)}
aria-label="Configurações da Jornada"
aria-expanded={showSettings}
aria-controls={SETTINGS_PANEL_ID}
- className={`p-3 rounded-xl transition-colors ${showSettings ? "bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400" : "hover:bg-neutral-100 dark:hover:bg-neutral-800 text-neutral-600"}`}
+ className={`p-3 rounded-xl transition-colors ${showSettings ? "bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400" : "hover:bg-neutral-100 dark:hover:bg-neutral-800 text-neutral-600 dark:text-neutral-400"}`}
>
-
- Modo de cálculo da saída
- {EXIT_MODES.map(({ label, isManual }) => (
-
- onManualExitChange(isManual)}
- className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
- />
- {label}
-
- ))}
-
+
+
+ Modo de cálculo da saída
+ {EXIT_MODES.map(({ label, isManual }) => (
+
+ onManualExitChange(isManual)}
+ className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
+ />
+ {label}
+
+ ))}
+
+
+ {isManualExit ? "Você informa o horário que bateu na saída." : "Calculamos sua saída a partir da jornada."}
+
+
-
+
Tempo de Trabalho Diário
-
- Define o tempo total de trabalho esperado por dia para o cálculo de banco de horas.
+
+ Define o tempo total de trabalho esperado por dia. Vale também para o cálculo do valor da sua hora.
-
+
}
+ labelIcon={
}
value={extraTierRate}
onChange={(event) => onExtraTierRateChange(Number(event.target.value))}
/>
@@ -210,7 +220,7 @@ export function JourneyForm({
type="button"
aria-label="Resetar Horários"
onClick={() => setIsConfirmingReset(true)}
- className="flex items-center gap-2 -mx-2 px-2 py-3 text-sm font-medium text-neutral-600 hover:text-emerald-500 transition-colors"
+ className="flex items-center gap-2 -mx-2 px-2 py-3 text-sm font-medium text-neutral-600 dark:text-neutral-400 hover:text-emerald-500 dark:hover:text-emerald-400 transition-colors"
>
Resetar Horários
diff --git a/components/organisms/salary-calculator.tsx b/components/organisms/salary-calculator.tsx
index 7e14564..c1c093c 100644
--- a/components/organisms/salary-calculator.tsx
+++ b/components/organisms/salary-calculator.tsx
@@ -60,6 +60,10 @@ export function SalaryCalculator() {
const [showDetails, setShowDetails] = useState(false);
const hasMonthlyHours = monthlyHours > 0;
+ const supportingRate =
+ period === "hour"
+ ? `${formatCurrency(stats.minuteRate)} por minuto`
+ : `${formatCurrency(stats.hourlyRate)} por hora · ${formatCurrency(stats.minuteRate)} por minuto`;
return (
-
Custo da Hora
+
+
Custo da Hora
+
+ Descubra quanto vale cada hora do seu trabalho, já com os descontos.
+
+
@@ -98,7 +107,7 @@ export function SalaryCalculator() {
}
minutes={dailyMinutes}
onMinutesChange={setDailyMinutes}
@@ -150,20 +159,22 @@ export function SalaryCalculator() {
-
+
}
variant="default"
/>
-
}
- variant="success"
- />
+ {stats.totalExtraGains > 0 ? (
+
}
+ variant="success"
+ />
+ ) : null}
- {hasMonthlyHours
- ? `${formatCurrency(stats.hourlyRate)} por hora · ${formatCurrency(stats.minuteRate)} por minuto`
- : "Informe a carga horária mensal para calcular"}
+ {hasMonthlyHours ? supportingRate : "Informe a carga horária mensal para calcular"}
diff --git a/components/organisms/work-calculator.tsx b/components/organisms/work-calculator.tsx
index 0f43a79..ed545cc 100644
--- a/components/organisms/work-calculator.tsx
+++ b/components/organisms/work-calculator.tsx
@@ -2,7 +2,7 @@
import { Clock, LogIn } from "lucide-react";
import { useMemo } from "react";
-import { useSalaryCalculator } from "@/hooks/use-salary-calculator";
+import { useHourlyRate } from "@/hooks/use-hourly-rate";
import { useWorkCalculator } from "@/hooks/use-work-calculator";
import { safeGAEvent } from "@/lib/analytics";
import { findComplianceWarnings } from "@/lib/compliance";
@@ -81,7 +81,7 @@ export function WorkCalculator() {
issue,
resetDefaults,
} = useWorkCalculator();
- const { stats: salaryStats } = useSalaryCalculator();
+ const hourlyRate = useHourlyRate();
const breakdown = useMemo(
() =>
@@ -173,7 +173,7 @@ export function WorkCalculator() {
nightMinutes={stats.nightMinutes}
firstTierRate={firstTierRate}
extraTierRate={extraTierRate}
- hourlyRate={salaryStats.hourlyRate > 0 ? salaryStats.hourlyRate : null}
+ hourlyRate={hourlyRate}
warnings={warnings}
/>
)}
@@ -186,15 +186,22 @@ export function WorkCalculator() {
value={exitLabel}
tone={isInDebt ? "rose" : "emerald"}
badge={
-
+
{currentTime === null ? PLACEHOLDER_CLOCK : formatClockTime(currentTime)}
}
media={
-
- {timerData.statusLabel}
- {timerData.statusTime}
-
+
+
+
+ {timerData.statusLabel}
+
+ {timerData.statusTime}
+
+
}
footer={
diff --git a/components/templates/calculator-page.tsx b/components/templates/calculator-page.tsx
new file mode 100644
index 0000000..ed9eb7b
--- /dev/null
+++ b/components/templates/calculator-page.tsx
@@ -0,0 +1,45 @@
+import type { ReactNode } from "react";
+import { AppHeader } from "@/components/organisms/app-header";
+
+const STRUCTURED_DATA = JSON.stringify({
+ "@context": "https://schema.org",
+ "@type": "WebApplication",
+ name: "WorkLoad",
+ url: "https://workload.devrma.com",
+ description: "Calculadora inteligente de jornada e valor de trabalho.",
+ applicationCategory: "BusinessApplication",
+ operatingSystem: "Any",
+ author: {
+ "@type": "Person",
+ name: "Rafael Augusto",
+ },
+});
+
+export function CalculatorPage({ children }: { children: ReactNode }) {
+ return (
+ <>
+
+
+ Pular para o conteúdo principal
+
+
+
+
+ {children}
+
+
+
+ >
+ );
+}
diff --git a/hooks/use-hourly-rate.ts b/hooks/use-hourly-rate.ts
new file mode 100644
index 0000000..846de0c
--- /dev/null
+++ b/hooks/use-hourly-rate.ts
@@ -0,0 +1,15 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { HOURLY_RATE_KEY, readStoredNumber } from "@/lib/storage";
+
+export function useHourlyRate(): number | null {
+ const [hourlyRate, setHourlyRate] = useState
(null);
+
+ useEffect(() => {
+ const stored = readStoredNumber(HOURLY_RATE_KEY, 0);
+ setHourlyRate(stored > 0 ? stored : null);
+ }, []);
+
+ return hourlyRate;
+}
diff --git a/hooks/use-salary-calculator.ts b/hooks/use-salary-calculator.ts
index ec4b3d5..9b60f26 100644
--- a/hooks/use-salary-calculator.ts
+++ b/hooks/use-salary-calculator.ts
@@ -8,7 +8,14 @@ import {
type WorkRegime,
} from "@/lib/payroll";
import { amountForPeriod, SALARY_PERIODS, type SalaryPeriod } from "@/lib/salary-period";
-import { readStoredList, readStoredNumber, readStoredOptionalNumber, writeStoredOptionalNumber } from "@/lib/storage";
+import {
+ DAILY_MINUTES_KEY,
+ HOURLY_RATE_KEY,
+ readStoredList,
+ readStoredNumber,
+ readStoredOptionalNumber,
+ writeStoredOptionalNumber,
+} from "@/lib/storage";
export interface ExtraItem {
id: string;
@@ -27,7 +34,7 @@ const DEFAULT_PERIOD: SalaryPeriod = "hour";
const STORAGE_KEYS = {
grossSalary: "grossSalary",
monthlyHours: "monthlyHours",
- dailyMinutes: "dailyMinutes",
+ dailyMinutes: DAILY_MINUTES_KEY,
dependents: "dependents",
manualInss: "manualInss",
manualIrrf: "manualIrrf",
@@ -84,33 +91,6 @@ export function useSalaryCalculator(initialSalary = DEFAULT_GROSS_SALARY, initia
setIsRestored(true);
}, [initialSalary, initialHours]);
- useEffect(() => {
- if (!isRestored) return;
-
- localStorage.setItem(STORAGE_KEYS.grossSalary, grossSalary.toString());
- localStorage.setItem(STORAGE_KEYS.monthlyHours, monthlyHours.toString());
- localStorage.setItem(STORAGE_KEYS.dailyMinutes, dailyMinutes.toString());
- localStorage.setItem(STORAGE_KEYS.dependents, dependents.toString());
- localStorage.setItem(STORAGE_KEYS.regime, regime);
- localStorage.setItem(STORAGE_KEYS.period, period);
- localStorage.setItem(STORAGE_KEYS.extraDeductions, JSON.stringify(extraDeductions));
- localStorage.setItem(STORAGE_KEYS.extraGains, JSON.stringify(extraGains));
- writeStoredOptionalNumber(STORAGE_KEYS.manualInss, manualInss);
- writeStoredOptionalNumber(STORAGE_KEYS.manualIrrf, manualIrrf);
- }, [
- isRestored,
- grossSalary,
- monthlyHours,
- dailyMinutes,
- dependents,
- regime,
- period,
- extraDeductions,
- extraGains,
- manualInss,
- manualIrrf,
- ]);
-
const autoInss = useMemo(() => calculateSocialSecurity(grossSalary, regime), [grossSalary, regime]);
const autoIrrf = useMemo(
() => calculateIncomeTax(grossSalary, manualInss ?? autoInss, dependents),
@@ -152,6 +132,35 @@ export function useSalaryCalculator(initialSalary = DEFAULT_GROSS_SALARY, initia
extraGains,
]);
+ useEffect(() => {
+ if (!isRestored) return;
+
+ localStorage.setItem(STORAGE_KEYS.grossSalary, grossSalary.toString());
+ localStorage.setItem(STORAGE_KEYS.monthlyHours, monthlyHours.toString());
+ localStorage.setItem(STORAGE_KEYS.dailyMinutes, dailyMinutes.toString());
+ localStorage.setItem(STORAGE_KEYS.dependents, dependents.toString());
+ localStorage.setItem(STORAGE_KEYS.regime, regime);
+ localStorage.setItem(STORAGE_KEYS.period, period);
+ localStorage.setItem(STORAGE_KEYS.extraDeductions, JSON.stringify(extraDeductions));
+ localStorage.setItem(STORAGE_KEYS.extraGains, JSON.stringify(extraGains));
+ writeStoredOptionalNumber(STORAGE_KEYS.manualInss, manualInss);
+ writeStoredOptionalNumber(STORAGE_KEYS.manualIrrf, manualIrrf);
+ localStorage.setItem(HOURLY_RATE_KEY, stats.hourlyRate.toString());
+ }, [
+ isRestored,
+ grossSalary,
+ monthlyHours,
+ dailyMinutes,
+ dependents,
+ regime,
+ period,
+ extraDeductions,
+ extraGains,
+ manualInss,
+ manualIrrf,
+ stats.hourlyRate,
+ ]);
+
const addExtra = (kind: ExtraKind) => {
const newItem: ExtraItem = {
id: crypto.randomUUID(),
diff --git a/lib/calculator-view.ts b/lib/calculator-view.ts
new file mode 100644
index 0000000..015f7d9
--- /dev/null
+++ b/lib/calculator-view.ts
@@ -0,0 +1,6 @@
+export type CalculatorView = "work" | "salary";
+
+export const VIEW_PATHS: Record = {
+ work: "/",
+ salary: "/custo-da-hora",
+};
diff --git a/lib/consent.ts b/lib/consent.ts
index e594226..05a8fc9 100644
--- a/lib/consent.ts
+++ b/lib/consent.ts
@@ -1,5 +1,7 @@
const CONSENT_KEY = "workload_cookie_consent";
+export const CONSENT_CHANGED_EVENT = "workload:consent-changed";
+
export function readTelemetryConsent(): boolean | null {
try {
const raw = localStorage.getItem(CONSENT_KEY);
diff --git a/lib/storage.ts b/lib/storage.ts
index fa8a04e..de721bf 100644
--- a/lib/storage.ts
+++ b/lib/storage.ts
@@ -1,3 +1,6 @@
+export const HOURLY_RATE_KEY = "hourlyRate";
+export const DAILY_MINUTES_KEY = "workMinutes";
+
export function readStoredNumber(key: string, fallback: number): number {
const raw = localStorage.getItem(key);
if (raw === null || raw.trim() === "") return fallback;
diff --git a/next.config.ts b/next.config.ts
index b72dd64..eaf1c56 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -3,6 +3,16 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
poweredByHeader: false,
+ async redirects() {
+ return [
+ {
+ source: "/",
+ has: [{ type: "query", key: "view", value: "salary" }],
+ destination: "/custo-da-hora",
+ permanent: true,
+ },
+ ];
+ },
};
export default nextConfig;