diff --git a/__tests__/currency-input.test.tsx b/__tests__/currency-input.test.tsx new file mode 100644 index 0000000..b322e7b --- /dev/null +++ b/__tests__/currency-input.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { CurrencyInput } from "@/components/atoms/currency-input"; +import { parseCurrency } from "@/lib/utils"; + +function CurrencyHarness({ initialValue = 0 }: { initialValue?: number }) { + const [value, setValue] = useState(initialValue); + + return ( + setValue(rawValue === "" ? null : parseCurrency(rawValue))} + /> + ); +} + +describe("CurrencyInput", () => { + it("formats the amount it is given", () => { + render(); + + expect(screen.getByLabelText("Valor")).toHaveValue("1.234,50"); + }); + + it("shows an empty field when there is no amount", () => { + render(); + + expect(screen.getByLabelText("Valor")).toHaveValue(""); + }); + + it("reports what was typed so the caller can parse it", async () => { + const onValueChange = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Valor"), "5"); + + expect(onValueChange).toHaveBeenCalledWith("0,005"); + }); + + it("keeps the caret next to the digit that was just typed", async () => { + const user = userEvent.setup(); + render(); + + const field = screen.getByLabelText("Valor"); + field.setSelectionRange(1, 1); + await user.type(field, "9", { initialSelectionStart: 1, initialSelectionEnd: 1 }); + + expect(field.value).toBe("19.234,56"); + expect(field.selectionStart).toBe(2); + }); + + it("leaves the caret alone while the field is not focused", () => { + const { rerender } = render(); + + rerender(); + + expect(screen.getByLabelText("Valor")).toHaveValue("20,00"); + }); +}); diff --git a/__tests__/journey-form.test.tsx b/__tests__/journey-form.test.tsx index f11b4dc..054f209 100644 --- a/__tests__/journey-form.test.tsx +++ b/__tests__/journey-form.test.tsx @@ -140,13 +140,28 @@ describe("JourneyForm", () => { expect(onManualExitChange).toHaveBeenLastCalledWith(false); }); - it("asks for a reset when the reset action is used", async () => { + it("asks for a reset when the reset action is confirmed", async () => { const onReset = vi.fn(); const user = userEvent.setup(); render(); await user.click(screen.getByRole("button", { name: "Resetar Horários" })); + expect(onReset).not.toHaveBeenCalled(); + + await user.click(screen.getByRole("button", { name: "Resetar horários" })); expect(onReset).toHaveBeenCalledOnce(); }); + + it("keeps the times when the reset is dismissed", async () => { + const onReset = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Resetar Horários" })); + await user.click(screen.getByRole("button", { name: "Cancelar" })); + + expect(onReset).not.toHaveBeenCalled(); + expect(screen.queryByText("Resetar os horários?")).not.toBeInTheDocument(); + }); }); diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts index b005285..1dd8c11 100644 --- a/__tests__/utils.test.ts +++ b/__tests__/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { cn, formatCurrency, formatCurrencySimple, parseCurrency } from "@/lib/utils"; +import { cn, formatClockTime, formatCurrency, formatCurrencySimple, parseCurrency } from "@/lib/utils"; describe("cn", () => { it("merges class names", () => { @@ -65,3 +65,13 @@ describe("parseCurrency", () => { expect(parseCurrency("9".repeat(400))).toBe(0); }); }); + +describe("formatClockTime", () => { + it("renders a 24-hour clock with seconds, matching the pt-BR interface", () => { + expect(formatClockTime(new Date(2026, 7, 2, 14, 33, 54))).toBe("14:33:54"); + }); + + it("keeps midnight and single digits padded", () => { + expect(formatClockTime(new Date(2026, 7, 2, 0, 5, 9))).toBe("00:05:09"); + }); +}); diff --git a/__tests__/work-calculator.test.tsx b/__tests__/work-calculator.test.tsx index f196440..983a99d 100644 --- a/__tests__/work-calculator.test.tsx +++ b/__tests__/work-calculator.test.tsx @@ -246,6 +246,7 @@ describe("WorkCalculator", () => { render(); await user.click(screen.getByRole("button", { name: "Resetar Horários" })); + await user.click(screen.getByRole("button", { name: "Resetar horários" })); expect(safeGAEvent).toHaveBeenCalledWith("reset_defaults"); }); diff --git a/app/layout.tsx b/app/layout.tsx index e0f2dcc..fea2f2a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -54,6 +54,10 @@ export const viewport = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( + + + + {children} diff --git a/app/page.tsx b/app/page.tsx index bbad6b5..aedab40 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,5 @@ "use client"; -import { format } from "date-fns"; import { Clock, Moon, Sun, Wallet } from "lucide-react"; import { useTheme } from "next-themes"; import { Suspense, useEffect } from "react"; @@ -8,6 +7,7 @@ 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 = "--:--:--"; @@ -68,7 +68,7 @@ export default function Home() {
+ + setIsConfirmingReset(false)} + labelledBy={RESET_DIALOG_TITLE_ID} + className="w-full max-w-md rounded-3xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-950 p-8 shadow-2xl" + > +
+

+ Resetar os horários? +

+

+ Entrada, almoço, saída e as configurações da jornada voltam aos valores padrão. Não dá para desfazer. +

+
+ + +
+
+
); } diff --git a/components/organisms/salary-calculator.tsx b/components/organisms/salary-calculator.tsx index 4c30767..95296eb 100644 --- a/components/organisms/salary-calculator.tsx +++ b/components/organisms/salary-calculator.tsx @@ -15,8 +15,9 @@ import { useState } from "react"; import { useSalaryCalculator } from "@/hooks/use-salary-calculator"; import type { WorkRegime } from "@/lib/payroll"; import { SALARY_PERIOD_LABELS } from "@/lib/salary-period"; -import { formatCurrency, formatCurrencySimple, parseCurrency } from "@/lib/utils"; +import { formatCurrency, parseCurrency } from "@/lib/utils"; import { CollapsiblePanel } from "../molecules/collapsible-panel"; +import { CurrencyField } from "../molecules/currency-field"; import { FormField } from "../molecules/form-field"; import { HeroPanel } from "../molecules/hero-panel"; import { PeriodSelector } from "../molecules/period-selector"; @@ -77,15 +78,13 @@ export function SalaryCalculator() {
- R$} placeholder="0,00" - value={formatCurrencySimple(grossSalary)} - onChange={(event) => setGrossSalary(parseCurrency(event.target.value))} + value={grossSalary} + onValueChange={(rawValue) => setGrossSalary(parseCurrency(rawValue))} /> onDependentsChange(Number(event.target.value))} /> - R$} placeholder={formatCurrencySimple(autoInss)} - value={manualInss !== null ? formatCurrencySimple(manualInss) : ""} - onChange={(event) => onManualInssChange(toManualAmount(event.target.value))} + value={manualInss} + onValueChange={(rawValue) => onManualInssChange(toManualAmount(rawValue))} /> - R$} placeholder={formatCurrencySimple(autoIrrf)} - value={manualIrrf !== null ? formatCurrencySimple(manualIrrf) : ""} - onChange={(event) => onManualIrrfChange(toManualAmount(event.target.value))} + value={manualIrrf} + onValueChange={(rawValue) => onManualIrrfChange(toManualAmount(rawValue))} />
diff --git a/components/organisms/work-calculator.tsx b/components/organisms/work-calculator.tsx index 27a7a36..2a7aed9 100644 --- a/components/organisms/work-calculator.tsx +++ b/components/organisms/work-calculator.tsx @@ -7,6 +7,7 @@ import { useMemo } from "react"; import { useCurrentTime } from "@/hooks/use-current-time"; import { useWorkCalculator } from "@/hooks/use-work-calculator"; import { safeGAEvent } from "@/lib/analytics"; +import { formatClockTime } from "@/lib/utils"; import { CopyButton } from "../molecules/copy-button"; import { HeroPanel } from "../molecules/hero-panel"; import { CalculatorLayout } from "../templates/calculator-layout"; @@ -221,7 +222,7 @@ export function WorkCalculator() { tone={timerData.isOvertime ? "rose" : "emerald"} badge={ - {currentTime === null ? PLACEHOLDER_CLOCK : format(currentTime, "HH:mm:ss")} + {currentTime === null ? PLACEHOLDER_CLOCK : formatClockTime(currentTime)} } footer={ diff --git a/lib/utils.ts b/lib/utils.ts index ebbea11..45f1816 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -5,18 +5,28 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +const CURRENCY_FORMATTER = new Intl.NumberFormat("pt-BR", { + style: "currency", + currency: "BRL", +}); + +const AMOUNT_FORMATTER = new Intl.NumberFormat("pt-BR", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +const TIME_FORMATTER = new Intl.DateTimeFormat("pt-BR", { timeStyle: "medium" }); + export function formatCurrency(value: number): string { - return new Intl.NumberFormat("pt-BR", { - style: "currency", - currency: "BRL", - }).format(value); + return CURRENCY_FORMATTER.format(value); } export function formatCurrencySimple(value: number): string { - return new Intl.NumberFormat("pt-BR", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(value); + return AMOUNT_FORMATTER.format(value); +} + +export function formatClockTime(date: Date): string { + return TIME_FORMATTER.format(date); } export function parseCurrency(value: string): number { diff --git a/tests/e2e/google-tracking.spec.ts b/tests/e2e/google-tracking.spec.ts index fb87afd..3f9dfc9 100644 --- a/tests/e2e/google-tracking.spec.ts +++ b/tests/e2e/google-tracking.spec.ts @@ -23,7 +23,7 @@ test.describe("Google Tracking & Ads", () => { test("should show side ads on desktop after delay", async ({ page }) => { const viewport = page.viewportSize(); - test.skip(!viewport || viewport.width < 1536, "side ads only render from the 2xl breakpoint up"); + test.skip(!viewport || viewport.width < 1980, "side ads only render from 1980px up, where they clear the content"); await page.click('button:has-text("Aceitar Tudo")');