Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions __tests__/currency-input.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<number | null>(initialValue);

return (
<CurrencyInput
aria-label="Valor"
value={value}
onValueChange={(rawValue) => setValue(rawValue === "" ? null : parseCurrency(rawValue))}
/>
);
}

describe("CurrencyInput", () => {
it("formats the amount it is given", () => {
render(<CurrencyInput aria-label="Valor" value={1234.5} onValueChange={vi.fn()} />);

expect(screen.getByLabelText("Valor")).toHaveValue("1.234,50");
});

it("shows an empty field when there is no amount", () => {
render(<CurrencyInput aria-label="Valor" value={null} onValueChange={vi.fn()} />);

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(<CurrencyInput aria-label="Valor" value={0} onValueChange={onValueChange} />);

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(<CurrencyHarness initialValue={1234.56} />);

const field = screen.getByLabelText<HTMLInputElement>("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(<CurrencyInput aria-label="Valor" value={10} onValueChange={vi.fn()} />);

rerender(<CurrencyInput aria-label="Valor" value={20} onValueChange={vi.fn()} />);

expect(screen.getByLabelText("Valor")).toHaveValue("20,00");
});
});
17 changes: 16 additions & 1 deletion __tests__/journey-form.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(<JourneyHarness onReset={onReset} />);

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(<JourneyHarness onReset={onReset} />);

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();
});
});
12 changes: 11 additions & 1 deletion __tests__/utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -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", () => {
Expand DownExpand Up@@ -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");
});
});
1 change: 1 addition & 0 deletions __tests__/work-calculator.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -246,6 +246,7 @@ describe("WorkCalculator", () => {
render(<WorkCalculator />);

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");
});
Expand Down
4 changes: 4 additions & 0 deletions app/layout.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,6 +54,10 @@ export const viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="pt-BR" suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://www.googletagmanager.com" />
<link rel="preconnect" href="https://pagead2.googlesyndication.com" crossOrigin="anonymous" />
</head>
<body className={inter.className} suppressHydrationWarning>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
{children}
Expand Down
4 changes: 2 additions & 2 deletions app/page.tsx
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
"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";
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 = "--:--:--";

Expand DownExpand Up@@ -68,7 +68,7 @@ export default function Home() {
<div className="hidden md:flex items-center gap-2 px-4 py-2 bg-neutral-100 dark:bg-neutral-800 rounded-xl text-sm font-bold">
<Clock className="w-4 h-4 text-indigo-500" aria-hidden="true" />
<span className="tabular-nums">
{currentTime === null ? PLACEHOLDER_CLOCK : format(currentTime, "HH:mm:ss")}
{currentTime === null ? PLACEHOLDER_CLOCK : formatClockTime(currentTime)}
</span>
</div>
<Button
Expand Down
69 changes: 69 additions & 0 deletions components/atoms/currency-input.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
"use client";

import * as React from "react";
import { formatCurrencySimple, parseCurrency } from "@/lib/utils";
import { Input, type InputProps } from "./input";

const NON_DIGITS = /\D/g;

function countDigits(text: string): number {
return text.replace(NON_DIGITS, "").length;
}

function caretAfterDigits(text: string, digitsFromStart: number): number {
let seenDigits = 0;
let caret = 0;

while (caret < text.length && seenDigits < digitsFromStart) {
if (countDigits(text[caret]) === 1) seenDigits += 1;
caret += 1;
}

return caret;
}

export interface CurrencyInputProps extends Omit<InputProps, "value" | "onChange" | "type"> {
value: number | null;
onValueChange: (rawValue: string) => void;
}

export function CurrencyInput({ value, onValueChange, ...inputProps }: CurrencyInputProps) {
const inputRef = React.useRef<HTMLInputElement>(null);
const pendingCaretDigits = React.useRef<number | null>(null);

const displayValue = value === null ? "" : formatCurrencySimple(value);

React.useLayoutEffect(() => {
const input = inputRef.current;
const digitsFromStart = pendingCaretDigits.current;
pendingCaretDigits.current = null;

if (input === null || digitsFromStart === null || document.activeElement !== input) return;

const caret = caretAfterDigits(input.value, digitsFromStart);
input.setSelectionRange(caret, caret);
});

const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const typedValue = event.target.value;
const digitsBeforeCaret = countDigits(typedValue.slice(0, Number(event.target.selectionStart)));
const digitsAfterCaret = countDigits(typedValue) - digitsBeforeCaret;

onValueChange(typedValue);
pendingCaretDigits.current = Math.max(
0,
countDigits(formatCurrencySimple(parseCurrency(typedValue))) - digitsAfterCaret,
);
};

return (
<Input
ref={inputRef}
type="text"
inputMode="decimal"
value={displayValue}
onChange={handleChange}
{...inputProps}
/>
);
}
3 changes: 3 additions & 0 deletions components/atoms/input.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,9 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type,
{icon && <div className="absolute left-4 top-1/2 -translate-y-1/2 text-neutral-500">{icon}</div>}
<input
type={type}
autoComplete="off"
spellCheck={false}
enterKeyHint="done"
className={cn(
"flex h-14 w-full rounded-2xl border border-neutral-500 dark:border-neutral-600 bg-white/50 dark:bg-neutral-900/50 px-4 py-2 text-lg ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-neutral-500 dark:placeholder:text-neutral-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50 transition-colors font-mono",
icon ? "pl-12" : "",
Expand Down
4 changes: 2 additions & 2 deletions components/molecules/cookie-consent.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,7 @@ export function CookieConsent() {
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
className="fixed bottom-6 left-6 right-6 z-[60] mx-auto max-w-4xl"
className="fixed bottom-[max(1.5rem,env(safe-area-inset-bottom))] left-6 right-6 z-[60] mx-auto max-w-4xl"
>
<div className="overflow-hidden rounded-3xl border border-neutral-200 dark:border-neutral-800 bg-white/95 dark:bg-neutral-950/95 backdrop-blur-xl shadow-[0_20px_50px_-12px_rgba(0,0,0,0.3)]">
<div className="flex flex-col md:flex-row items-center gap-6 p-6 md:p-8">
Expand DownExpand Up@@ -165,7 +165,7 @@ export function CookieConsent() {
<button
type="button"
onClick={() => setShowSettings(true)}
className="fixed bottom-4 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 text-neutral-500 dark:text-neutral-400 hover:text-indigo-500 transition-colors opacity-30 hover:opacity-100"
aria-label="Configurações de Privacidade"
>
<Shield size={18} aria-hidden="true" />
Expand Down
23 changes: 23 additions & 0 deletions components/molecules/currency-field.tsx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
import type * as React from "react";
import { cn } from "@/lib/utils";
import { CurrencyInput, type CurrencyInputProps } from "../atoms/currency-input";
import { Label } from "../atoms/label";

interface CurrencyFieldProps extends CurrencyInputProps {
label: string;
id: string;
icon?: React.ReactNode;
labelIcon?: React.ReactNode;
}

export function CurrencyField({ label, icon, id, className, labelIcon, ...props }: CurrencyFieldProps) {
return (
<div className={cn("space-y-3", className)}>
<Label htmlFor={id}>
{labelIcon}
{label}
</Label>
<CurrencyInput id={id} icon={icon} {...props} />
</div>
);
}
11 changes: 5 additions & 6 deletions components/molecules/extra-entry-row.tsx
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
"use client";

import { Trash2 } from "lucide-react";
import { formatCurrencySimple, parseCurrency } from "@/lib/utils";
import { parseCurrency } from "@/lib/utils";
import { Button } from "../atoms/button";
import { CurrencyInput } from "../atoms/currency-input";
import { Input } from "../atoms/input";

const COMPACT_FIELD_CLASSES = "h-12 rounded-xl text-sm";
Expand DownExpand Up@@ -43,13 +44,11 @@ export function ExtraEntryRow({
/>
</div>
<div className="w-24 sm:w-32 shrink-0">
<Input
type="text"
inputMode="decimal"
<CurrencyInput
aria-label={valueLabel}
placeholder="Valor"
value={formatCurrencySimple(value)}
onChange={(event) => onValueChange(parseCurrency(event.target.value))}
value={value}
onValueChange={(rawValue) => onValueChange(parseCurrency(rawValue))}
className={COMPACT_FIELD_CLASSES}
/>
</div>
Expand Down
4 changes: 2 additions & 2 deletions components/molecules/side-ads.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -36,7 +36,7 @@ export function SideAds({ onClose }: SideAdsProps) {
initial={{ opacity: 0, x: -100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -100 }}
className="fixed left-4 top-1/2 -translate-y-1/2 z-[40] hidden 2xl:block w-[160px] h-[600px]"
className="fixed left-4 top-1/2 -translate-y-1/2 z-[40] hidden min-[1980px]:block w-[160px] h-[600px]"
>
<div className="relative group bg-card border rounded-2xl p-1 shadow-xl">
<button
Expand All@@ -60,7 +60,7 @@ export function SideAds({ onClose }: SideAdsProps) {
initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 100 }}
className="fixed right-4 top-1/2 -translate-y-1/2 z-[40] hidden 2xl:block w-[160px] h-[600px]"
className="fixed right-4 top-1/2 -translate-y-1/2 z-[40] hidden min-[1980px]:block w-[160px] h-[600px]"
>
<div className="relative group bg-card border rounded-2xl p-1 shadow-xl">
<button
Expand Down
7 changes: 5 additions & 2 deletions components/organisms/calculator-views.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -32,7 +32,10 @@ export function toCalculatorView(rawView: string | null): CalculatorView {
export function CalculatorViews({ activeView }: { activeView: CalculatorView }) {
return (
<>
<nav aria-label="Calculadoras" className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50">
<nav
aria-label="Calculadoras"
className="fixed bottom-[max(1rem,env(safe-area-inset-bottom))] sm:bottom-8 left-1/2 -translate-x-1/2 z-50"
>
<ul className="bg-white/80 dark:bg-neutral-900/80 backdrop-blur-xl border border-neutral-200 dark:border-neutral-800 p-1.5 rounded-2xl shadow-2xl flex items-center gap-1">
{VIEW_TABS.map(({ view, label, icon: Icon }) => (
<li key={view}>
Expand All@@ -54,7 +57,7 @@ export function CalculatorViews({ activeView }: { activeView: CalculatorView })
<div
id="main-content"
tabIndex={-1}
className="pt-32 pb-32 px-4 sm:px-6 lg:px-8 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
className="pt-24 pb-28 sm:pt-32 sm:pb-32 px-4 sm:px-6 lg:px-8 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
>
<AnimatePresence mode="wait">
<motion.div key={activeView} {...PANEL_TRANSITION}>
Expand Down
Loading