From a43ba408f25b286884b77cc10a40015d4c0cce26 Mon Sep 17 00:00:00 2001 From: Rafael Martins Date: Sun, 2 Aug 2026 16:31:42 -0300 Subject: [PATCH] feat(salary): accept minutes in the daily journey The field only took whole hours, so a 44h week (08:48 a day) could not be entered and every per-day and per-week value was off. It now uses the same HH:MM mask as the Jornada tab, and the state is kept in minutes with 08:48 as the default to match the 220h month. Co-Authored-By: Claude Opus 5 --- __tests__/duration-field.test.tsx | 63 ++++++++++++++++++++++ __tests__/salary-calculator.test.tsx | 8 +-- __tests__/use-salary-calculator.test.ts | 14 +++++ components/molecules/duration-field.tsx | 52 ++++++++++++++++++ components/organisms/salary-calculator.tsx | 17 +++--- hooks/use-salary-calculator.ts | 21 ++++---- 6 files changed, 152 insertions(+), 23 deletions(-) create mode 100644 __tests__/duration-field.test.tsx create mode 100644 components/molecules/duration-field.tsx diff --git a/__tests__/duration-field.test.tsx b/__tests__/duration-field.test.tsx new file mode 100644 index 0000000..bc97dbe --- /dev/null +++ b/__tests__/duration-field.test.tsx @@ -0,0 +1,63 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { DurationField } from "@/components/molecules/duration-field"; + +describe("DurationField", () => { + it("shows the minutes as a padded duration", () => { + render(); + + expect(screen.getByLabelText("Jornada Diária")).toHaveValue("08:48"); + }); + + it("commits the typed duration as minutes", async () => { + const onMinutesChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const field = screen.getByLabelText("Jornada Diária"); + await user.clear(field); + await user.type(field, "0730"); + + expect(onMinutesChange).toHaveBeenLastCalledWith(450); + }); + + it("keeps impossible durations out of the calculation", async () => { + const onMinutesChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const field = screen.getByLabelText("Jornada Diária"); + await user.clear(field); + await user.type(field, "2599"); + + expect(onMinutesChange).not.toHaveBeenCalled(); + }); + + it("describes the field with the hint when one is given", () => { + render( + , + ); + + expect(screen.getByLabelText("Jornada Diária")).toHaveAccessibleDescription("Ex.: 08:48"); + }); + + it("renders both icons and merges the className", () => { + const { container } = render( + } + labelIcon={} + minutes={480} + onMinutesChange={vi.fn()} + />, + ); + + expect(screen.getByTestId("input-icon")).toBeInTheDocument(); + expect(screen.getByTestId("label-icon")).toBeInTheDocument(); + expect(container.firstElementChild?.className).toContain("my-custom"); + expect(screen.getByLabelText("Jornada Diária")).not.toHaveAttribute("aria-describedby"); + }); +}); diff --git a/__tests__/salary-calculator.test.tsx b/__tests__/salary-calculator.test.tsx index 330be19..7f0fc04 100644 --- a/__tests__/salary-calculator.test.tsx +++ b/__tests__/salary-calculator.test.tsx @@ -84,11 +84,11 @@ describe("SalaryCalculator", () => { render(); await user.click(screen.getByRole("radio", { name: "Dia" })); - expect(screen.getByText(/163,58/)).toBeInTheDocument(); + expect(screen.getByText(/179,94/)).toBeInTheDocument(); - const dailyHours = screen.getByLabelText("Jornada Diária (horas)"); - await user.clear(dailyHours); - await user.type(dailyHours, "6"); + const dailyJourney = screen.getByLabelText("Jornada Diária"); + await user.clear(dailyJourney); + await user.type(dailyJourney, "0600"); expect(screen.getByText(/122,69/)).toBeInTheDocument(); }); diff --git a/__tests__/use-salary-calculator.test.ts b/__tests__/use-salary-calculator.test.ts index 8f3f902..03347fd 100644 --- a/__tests__/use-salary-calculator.test.ts +++ b/__tests__/use-salary-calculator.test.ts @@ -203,6 +203,20 @@ describe("useSalaryCalculator", () => { expect(result.current.autoIrrf).toBeGreaterThan(withAutoInss); }); + it("keeps the daily journey in minutes and scales the daily value by it", () => { + const { result } = renderHook(() => useSalaryCalculator()); + + expect(result.current.dailyMinutes).toBe(528); + + act(() => { + result.current.setPeriod("day"); + result.current.setDailyMinutes(450); + }); + + expect(localStorage.getItem("dailyMinutes")).toBe("450"); + expect(result.current.stats.periodValue).toBeCloseTo(result.current.stats.hourlyRate * 7.5, 6); + }); + it("avoids dividing by zero when the monthly hours are cleared", () => { const { result } = renderHook(() => useSalaryCalculator()); diff --git a/components/molecules/duration-field.tsx b/components/molecules/duration-field.tsx new file mode 100644 index 0000000..9f03d53 --- /dev/null +++ b/components/molecules/duration-field.tsx @@ -0,0 +1,52 @@ +import type { ReactNode } from "react"; +import { DURATION_GROUP_SIZES, formatPaddedDuration, isRealDuration, parsePaddedDuration } from "@/lib/duration"; +import { cn } from "@/lib/utils"; +import { Label } from "../atoms/label"; +import { MaskedInput } from "../atoms/masked-input"; + +interface DurationFieldProps { + id: string; + label: string; + hint?: string; + icon?: ReactNode; + labelIcon?: ReactNode; + minutes: number; + onMinutesChange: (minutes: number) => void; + className?: string; +} + +export function DurationField({ + id, + label, + hint, + icon, + labelIcon, + minutes, + onMinutesChange, + className, +}: DurationFieldProps) { + return ( +
+ + onMinutesChange(parsePaddedDuration(duration))} + /> + {hint ? ( +

+ {hint} +

+ ) : null} +
+ ); +} diff --git a/components/organisms/salary-calculator.tsx b/components/organisms/salary-calculator.tsx index 06908cc..776ba8d 100644 --- a/components/organisms/salary-calculator.tsx +++ b/components/organisms/salary-calculator.tsx @@ -7,6 +7,7 @@ import { SALARY_PERIOD_LABELS } from "@/lib/salary-period"; import { formatCurrency, parseCurrency } from "@/lib/utils"; import { CollapsiblePanel } from "../molecules/collapsible-panel"; import { CurrencyField } from "../molecules/currency-field"; +import { DurationField } from "../molecules/duration-field"; import { FormField } from "../molecules/form-field"; import { HeroPanel } from "../molecules/hero-panel"; import { PeriodSelector } from "../molecules/period-selector"; @@ -23,8 +24,8 @@ export function SalaryCalculator() { setGrossSalary, monthlyHours, setMonthlyHours, - dailyHours, - setDailyHours, + dailyMinutes, + setDailyMinutes, dependents, setDependents, regime, @@ -81,15 +82,13 @@ export function SalaryCalculator() { value={monthlyHours || ""} onChange={(event) => setMonthlyHours(Number(event.target.value))} /> -