refactor: optimize codebase for React 19 compiler (v1.2.6)

React 19 compiler auto-optimizes memoization, making manual hooks unnecessary.

Changes:
- Remove ~60 useCallback/useMemo across 16 files
- Remove React.memo from nav-button and return-button
- Simplify hydration with useSyncExternalStore (privacy-provider)
- Add CHANGELOG.md for version tracking

No functional changes - internal optimization only.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-02-04 13:14:10 +00:00
parent a70a83dd9d
commit 757626c468
18 changed files with 571 additions and 617 deletions

62
CHANGELOG.md Normal file
View File

@@ -0,0 +1,62 @@
# Changelog
Todas as mudanças notáveis deste projeto serão documentadas neste arquivo.
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.1.0/),
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
## [1.2.6] - 2025-02-04
### Alterado
- Refatoração para otimização do React 19 compiler
- Removidos `useCallback` e `useMemo` desnecessários (~60 instâncias)
- Removidos `React.memo` wrappers desnecessários
- Simplificados padrões de hidratação com `useSyncExternalStore`
### Arquivos modificados
- `hooks/use-calculator-state.ts`
- `hooks/use-form-state.ts`
- `hooks/use-month-period.ts`
- `components/auth/signup-form.tsx`
- `components/contas/accounts-page.tsx`
- `components/contas/transfer-dialog.tsx`
- `components/lancamentos/table/lancamentos-filters.tsx`
- `components/sidebar/nav-main.tsx`
- `components/month-picker/nav-button.tsx`
- `components/month-picker/return-button.tsx`
- `components/privacy-provider.tsx`
- `components/dashboard/category-history-widget.tsx`
- `components/anotacoes/note-dialog.tsx`
- `components/categorias/category-dialog.tsx`
- `components/confirm-action-dialog.tsx`
- `components/orcamentos/budget-dialog.tsx`
## [1.2.5] - 2025-02-01
### Adicionado
- Widget de pagadores no dashboard
- Avatares atualizados para pagadores
## [1.2.4] - 2025-01-22
### Corrigido
- Preservar formatação nas anotações
- Layout do card de anotações
## [1.2.3] - 2025-01-22
### Adicionado
- Versão exibida na sidebar
- Documentação atualizada
## [1.2.2] - 2025-01-22
### Alterado
- Atualização de dependências
- Aplicada formatação no código

View File

@@ -3,7 +3,6 @@
import { RiAddLine, RiDeleteBinLine } from "@remixicon/react";
import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
@@ -121,24 +120,18 @@ export function NoteDialog({
const disableSubmit = isPending || onlySpaces || unchanged || invalidLen;
const handleOpenChange = useCallback(
(v: boolean) => {
setDialogOpen(v);
if (!v) setErrorMessage(null);
},
[setDialogOpen],
);
const handleOpenChange = (v: boolean) => {
setDialogOpen(v);
if (!v) setErrorMessage(null);
};
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter")
(e.currentTarget as HTMLFormElement).requestSubmit();
if (e.key === "Escape") handleOpenChange(false);
},
[handleOpenChange],
);
const handleKeyDown = (e: React.KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter")
(e.currentTarget as HTMLFormElement).requestSubmit();
if (e.key === "Escape") handleOpenChange(false);
};
const handleAddTask = useCallback(() => {
const handleAddTask = () => {
const text = normalize(newTaskText);
if (!text) return;
@@ -151,97 +144,77 @@ export function NoteDialog({
updateField("tasks", [...(formState.tasks || []), newTask]);
setNewTaskText("");
requestAnimationFrame(() => newTaskRef.current?.focus());
}, [newTaskText, formState.tasks, updateField]);
};
const handleRemoveTask = useCallback(
(taskId: string) => {
updateField(
"tasks",
(formState.tasks || []).filter((t) => t.id !== taskId),
);
},
[formState.tasks, updateField],
);
const handleRemoveTask = (taskId: string) => {
updateField(
"tasks",
(formState.tasks || []).filter((t) => t.id !== taskId),
);
};
const handleToggleTask = useCallback(
(taskId: string) => {
updateField(
"tasks",
(formState.tasks || []).map((t) =>
t.id === taskId ? { ...t, completed: !t.completed } : t,
),
);
},
[formState.tasks, updateField],
);
const handleToggleTask = (taskId: string) => {
updateField(
"tasks",
(formState.tasks || []).map((t) =>
t.id === taskId ? { ...t, completed: !t.completed } : t,
),
);
};
const handleSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMessage(null);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErrorMessage(null);
const payload = {
title: normalize(formState.title),
description: formState.description.trim(),
type: formState.type,
tasks: formState.tasks,
};
const payload = {
title: normalize(formState.title),
description: formState.description.trim(),
type: formState.type,
tasks: formState.tasks,
};
if (onlySpaces || invalidLen) {
setErrorMessage("Preencha os campos respeitando os limites.");
titleRef.current?.focus();
return;
}
if (onlySpaces || invalidLen) {
setErrorMessage("Preencha os campos respeitando os limites.");
titleRef.current?.focus();
return;
}
if (mode === "update" && !note?.id) {
const msg = "Não foi possível identificar a anotação a ser editada.";
setErrorMessage(msg);
toast.error(msg);
return;
}
if (mode === "update" && !note?.id) {
const msg = "Não foi possível identificar a anotação a ser editada.";
setErrorMessage(msg);
toast.error(msg);
return;
}
if (unchanged) {
toast.info("Nada para atualizar.");
return;
}
if (unchanged) {
toast.info("Nada para atualizar.");
return;
}
startTransition(async () => {
let result;
if (mode === "create") {
result = await createNoteAction(payload);
} else {
if (!note?.id) {
const msg = "ID da anotação não encontrado.";
setErrorMessage(msg);
toast.error(msg);
return;
}
result = await updateNoteAction({ id: note.id, ...payload });
}
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
startTransition(async () => {
let result;
if (mode === "create") {
result = await createNoteAction(payload);
} else {
if (!note?.id) {
const msg = "ID da anotação não encontrado.";
setErrorMessage(msg);
toast.error(msg);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
titleRef.current?.focus();
});
},
[
formState.title,
formState.description,
formState.type,
formState.tasks,
mode,
note,
setDialogOpen,
onlySpaces,
unchanged,
invalidLen,
],
);
result = await updateNoteAction({ id: note.id, ...payload });
}
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
titleRef.current?.focus();
});
};
return (
<Dialog open={dialogOpen} onOpenChange={handleOpenChange}>

View File

@@ -1,7 +1,7 @@
"use client";
import { RiCheckLine, RiCloseLine, RiLoader4Line } from "@remixicon/react";
import { useRouter } from "next/navigation";
import { type FormEvent, useMemo, useState } from "react";
import { type FormEvent, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -90,10 +90,7 @@ export function SignupForm({ className, ...props }: DivProps) {
const [loadingEmail, setLoadingEmail] = useState(false);
const [loadingGoogle, setLoadingGoogle] = useState(false);
const passwordValidation = useMemo(
() => validatePassword(password),
[password],
);
const passwordValidation = validatePassword(password);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();

View File

@@ -1,12 +1,6 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import {
createCategoryAction,
@@ -76,14 +70,10 @@ export function CategoryDialog({
onOpenChange,
);
const initialState = useMemo(
() =>
buildInitialValues({
category,
defaultType,
}),
[category, defaultType],
);
const initialState = buildInitialValues({
category,
defaultType,
});
// Use form state hook for form management
const { formState, updateField, setFormState } =
@@ -95,7 +85,7 @@ export function CategoryDialog({
setFormState(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, setFormState]);
}, [dialogOpen, setFormState, category, defaultType]);
// Clear error when dialog closes
useEffect(() => {
@@ -104,46 +94,43 @@ export function CategoryDialog({
}
}, [dialogOpen]);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
if (mode === "update" && !category?.id) {
const message = "Categoria inválida.";
setErrorMessage(message);
toast.error(message);
if (mode === "update" && !category?.id) {
const message = "Categoria inválida.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload = {
name: formState.name.trim(),
type: formState.type,
icon: formState.icon.trim(),
};
startTransition(async () => {
const result =
mode === "create"
? await createCategoryAction(payload)
: await updateCategoryAction({
id: category?.id ?? "",
...payload,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
return;
}
const payload = {
name: formState.name.trim(),
type: formState.type,
icon: formState.icon.trim(),
};
startTransition(async () => {
const result =
mode === "create"
? await createCategoryAction(payload)
: await updateCategoryAction({
id: category?.id ?? "",
...payload,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
},
[category?.id, formState, initialState, mode, setDialogOpen, setFormState],
);
setErrorMessage(result.error);
toast.error(result.error);
});
};
const title = mode === "create" ? "Nova categoria" : "Editar categoria";
const description =

View File

@@ -1,7 +1,7 @@
"use client";
import type { VariantProps } from "class-variance-authority";
import { useCallback, useMemo, useState, useTransition } from "react";
import { useState, useTransition } from "react";
import {
AlertDialog,
AlertDialogAction,
@@ -49,22 +49,16 @@ export function ConfirmActionDialog({
const [isPending, startTransition] = useTransition();
const dialogOpen = open ?? internalOpen;
const setDialogOpen = useCallback(
(value: boolean) => {
if (open === undefined) {
setInternalOpen(value);
}
onOpenChange?.(value);
},
[onOpenChange, open],
);
const setDialogOpen = (value: boolean) => {
if (open === undefined) {
setInternalOpen(value);
}
onOpenChange?.(value);
};
const resolvedPendingLabel = useMemo(
() => pendingLabel ?? confirmLabel,
[pendingLabel, confirmLabel],
);
const resolvedPendingLabel = pendingLabel ?? confirmLabel;
const handleConfirm = useCallback(() => {
const handleConfirm = () => {
if (!onConfirm) {
setDialogOpen(false);
return;
@@ -78,7 +72,7 @@ export function ConfirmActionDialog({
// Mantém o diálogo aberto para que o chamador trate o erro.
}
});
}, [onConfirm, setDialogOpen]);
};
return (
<AlertDialog open={dialogOpen} onOpenChange={setDialogOpen}>

View File

@@ -3,7 +3,7 @@
import { RiAddCircleLine, RiBankLine } from "@remixicon/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { useState } from "react";
import { toast } from "sonner";
import { deleteAccountAction } from "@/app/(dashboard)/contas/actions";
import { ConfirmActionDialog } from "@/components/confirm-action-dialog";
@@ -47,45 +47,43 @@ export function AccountsPage({
const hasAccounts = accounts.length > 0;
const orderedAccounts = useMemo(() => {
return [...accounts].sort((a, b) => {
// Coloca inativas no final
const aIsInactive = a.status?.toLowerCase() === "inativa";
const bIsInactive = b.status?.toLowerCase() === "inativa";
const orderedAccounts = [...accounts].sort((a, b) => {
// Coloca inativas no final
const aIsInactive = a.status?.toLowerCase() === "inativa";
const bIsInactive = b.status?.toLowerCase() === "inativa";
if (aIsInactive && !bIsInactive) return 1;
if (!aIsInactive && bIsInactive) return -1;
if (aIsInactive && !bIsInactive) return 1;
if (!aIsInactive && bIsInactive) return -1;
// Mesma ordem alfabética dentro de cada grupo
return a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" });
});
}, [accounts]);
// Mesma ordem alfabética dentro de cada grupo
return a.name.localeCompare(b.name, "pt-BR", { sensitivity: "base" });
});
const handleEdit = useCallback((account: Account) => {
const handleEdit = (account: Account) => {
setSelectedAccount(account);
setEditOpen(true);
}, []);
};
const handleEditOpenChange = useCallback((open: boolean) => {
const handleEditOpenChange = (open: boolean) => {
setEditOpen(open);
if (!open) {
setSelectedAccount(null);
}
}, []);
};
const handleRemoveRequest = useCallback((account: Account) => {
const handleRemoveRequest = (account: Account) => {
setAccountToRemove(account);
setRemoveOpen(true);
}, []);
};
const handleRemoveOpenChange = useCallback((open: boolean) => {
const handleRemoveOpenChange = (open: boolean) => {
setRemoveOpen(open);
if (!open) {
setAccountToRemove(null);
}
}, []);
};
const handleRemoveConfirm = useCallback(async () => {
const handleRemoveConfirm = async () => {
if (!accountToRemove) {
return;
}
@@ -99,19 +97,19 @@ export function AccountsPage({
toast.error(result.error);
throw new Error(result.error);
}, [accountToRemove]);
};
const handleTransferRequest = useCallback((account: Account) => {
const handleTransferRequest = (account: Account) => {
setTransferFromAccount(account);
setTransferOpen(true);
}, []);
};
const handleTransferOpenChange = useCallback((open: boolean) => {
const handleTransferOpenChange = (open: boolean) => {
setTransferOpen(open);
if (!open) {
setTransferFromAccount(null);
}
}, []);
};
const removeTitle = accountToRemove
? `Remover conta "${accountToRemove.name}"?`

View File

@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import { useState, useTransition } from "react";
import { toast } from "sonner";
import { transferBetweenAccountsAction } from "@/app/(dashboard)/contas/actions";
import type { AccountData } from "@/app/(dashboard)/contas/data";
@@ -62,15 +62,13 @@ export function TransferDialog({
const [period, setPeriod] = useState(currentPeriod);
// Available destination accounts (exclude source account)
const availableAccounts = useMemo(
() => accounts.filter((account) => account.id !== fromAccountId),
[accounts, fromAccountId],
const availableAccounts = accounts.filter(
(account) => account.id !== fromAccountId,
);
// Source account info
const fromAccount = useMemo(
() => accounts.find((account) => account.id === fromAccountId),
[accounts, fromAccountId],
const fromAccount = accounts.find(
(account) => account.id === fromAccountId,
);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {

View File

@@ -4,7 +4,7 @@ import {
RiBarChartBoxLine,
RiCloseLine,
} from "@remixicon/react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -49,36 +49,37 @@ export function CategoryHistoryWidget({ data }: CategoryHistoryWidgetProps) {
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [isClient, setIsClient] = useState(false);
const [open, setOpen] = useState(false);
const isFirstRender = useRef(true);
// Load selected categories from sessionStorage on mount
// Load from sessionStorage on mount and save on changes
useEffect(() => {
setIsClient(true);
const stored = sessionStorage.getItem(STORAGE_KEY_SELECTED);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
const validCategories = parsed.filter((id) =>
data.allCategories.some((cat) => cat.id === id),
);
setSelectedCategories(validCategories.slice(0, 5));
// Only load from storage on first render
if (isFirstRender.current) {
const stored = sessionStorage.getItem(STORAGE_KEY_SELECTED);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
const validCategories = parsed.filter((id) =>
data.allCategories.some((cat) => cat.id === id),
);
setSelectedCategories(validCategories.slice(0, 5));
}
} catch (_e) {
// Invalid JSON, ignore
}
} catch (_e) {
// Invalid JSON, ignore
}
}
}, [data.allCategories]);
// Save to sessionStorage when selection changes
useEffect(() => {
if (isClient) {
isFirstRender.current = false;
} else {
// Save to storage on subsequent changes
sessionStorage.setItem(
STORAGE_KEY_SELECTED,
JSON.stringify(selectedCategories),
);
}
}, [selectedCategories, isClient]);
}, [selectedCategories, data.allCategories]);
// Filter data to show only selected categories with vibrant colors
const filteredCategories = useMemo(() => {

View File

@@ -6,14 +6,7 @@ import {
RiFilter3Line,
} from "@remixicon/react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { type ReactNode, useEffect, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
@@ -147,29 +140,24 @@ export function LancamentosFilters({
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const getParamValue = useCallback(
(key: string) => searchParams.get(key) ?? FILTER_EMPTY_VALUE,
[searchParams],
);
const getParamValue = (key: string) =>
searchParams.get(key) ?? FILTER_EMPTY_VALUE;
const handleFilterChange = useCallback(
(key: string, value: string | null) => {
const nextParams = new URLSearchParams(searchParams.toString());
const handleFilterChange = (key: string, value: string | null) => {
const nextParams = new URLSearchParams(searchParams.toString());
if (value && value !== FILTER_EMPTY_VALUE) {
nextParams.set(key, value);
} else {
nextParams.delete(key);
}
if (value && value !== FILTER_EMPTY_VALUE) {
nextParams.set(key, value);
} else {
nextParams.delete(key);
}
startTransition(() => {
router.replace(`${pathname}?${nextParams.toString()}`, {
scroll: false,
});
startTransition(() => {
router.replace(`${pathname}?${nextParams.toString()}`, {
scroll: false,
});
},
[pathname, router, searchParams],
);
});
};
const [searchValue, setSearchValue] = useState(searchParams.get("q") ?? "");
const currentSearchParam = searchParams.get("q") ?? "";
@@ -191,7 +179,7 @@ export function LancamentosFilters({
return () => clearTimeout(timeout);
}, [searchValue, currentSearchParam, handleFilterChange]);
const handleReset = useCallback(() => {
const handleReset = () => {
const periodValue = searchParams.get("periodo");
const nextParams = new URLSearchParams();
if (periodValue) {
@@ -205,41 +193,29 @@ export function LancamentosFilters({
: pathname;
router.replace(target, { scroll: false });
});
}, [pathname, router, searchParams]);
};
const pagadorSelectOptions = useMemo(
() =>
pagadorOptions.map((option) => ({
value: option.slug,
label: option.label,
avatarUrl: option.avatarUrl,
})),
[pagadorOptions],
);
const pagadorSelectOptions = pagadorOptions.map((option) => ({
value: option.slug,
label: option.label,
avatarUrl: option.avatarUrl,
}));
const contaOptions = useMemo(
() =>
contaCartaoOptions
.filter((option) => option.kind === "conta")
.map((option) => ({
value: option.slug,
label: option.label,
logo: option.logo,
})),
[contaCartaoOptions],
);
const contaOptions = contaCartaoOptions
.filter((option) => option.kind === "conta")
.map((option) => ({
value: option.slug,
label: option.label,
logo: option.logo,
}));
const cartaoOptions = useMemo(
() =>
contaCartaoOptions
.filter((option) => option.kind === "cartao")
.map((option) => ({
value: option.slug,
label: option.label,
logo: option.logo,
})),
[contaCartaoOptions],
);
const cartaoOptions = contaCartaoOptions
.filter((option) => option.kind === "cartao")
.map((option) => ({
value: option.slug,
label: option.label,
logo: option.logo,
}));
const categoriaValue = getParamValue("categoria");
const selectedCategoria =
@@ -262,21 +238,18 @@ export function LancamentosFilters({
const [categoriaOpen, setCategoriaOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const hasActiveFilters = useMemo(() => {
return (
searchParams.get("transacao") ||
searchParams.get("condicao") ||
searchParams.get("pagamento") ||
searchParams.get("pagador") ||
searchParams.get("categoria") ||
searchParams.get("contaCartao")
);
}, [searchParams]);
const hasActiveFilters =
searchParams.get("transacao") ||
searchParams.get("condicao") ||
searchParams.get("pagamento") ||
searchParams.get("pagador") ||
searchParams.get("categoria") ||
searchParams.get("contaCartao");
const handleResetFilters = useCallback(() => {
const handleResetFilters = () => {
handleReset();
setDrawerOpen(false);
}, [handleReset]);
};
return (
<div className={cn("flex flex-wrap items-center gap-2", className)}>

View File

@@ -1,7 +1,6 @@
"use client";
import { RiArrowLeftSLine, RiArrowRightSLine } from "@remixicon/react";
import React from "react";
interface NavigationButtonProps {
direction: "left" | "right";
@@ -9,25 +8,23 @@ interface NavigationButtonProps {
onClick: () => void;
}
const NavigationButton = React.memo(
({ direction, disabled, onClick }: NavigationButtonProps) => {
const Icon = direction === "left" ? RiArrowLeftSLine : RiArrowRightSLine;
export default function NavigationButton({
direction,
disabled,
onClick,
}: NavigationButtonProps) {
const Icon = direction === "left" ? RiArrowLeftSLine : RiArrowRightSLine;
return (
<button
onClick={onClick}
className="text-month-picker-foreground transition-all duration-200 cursor-pointer rounded-lg p-1 hover:bg-month-picker-foreground/10 focus:outline-hidden focus:ring-2 focus:ring-month-picker-foreground/30 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
disabled={disabled}
aria-label={`Navegar para o mês ${
direction === "left" ? "anterior" : "seguinte"
}`}
>
<Icon className="text-primary" size={18} />
</button>
);
},
);
NavigationButton.displayName = "NavigationButton";
export default NavigationButton;
return (
<button
onClick={onClick}
className="text-month-picker-foreground transition-all duration-200 cursor-pointer rounded-lg p-1 hover:bg-month-picker-foreground/10 focus:outline-hidden focus:ring-2 focus:ring-month-picker-foreground/30 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
disabled={disabled}
aria-label={`Navegar para o mês ${
direction === "left" ? "anterior" : "seguinte"
}`}
>
<Icon className="text-primary" size={18} />
</button>
);
}

View File

@@ -1,6 +1,5 @@
"use client";
import React from "react";
import { Button } from "../ui/button";
interface ReturnButtonProps {
@@ -8,7 +7,7 @@ interface ReturnButtonProps {
onClick: () => void;
}
const ReturnButton = React.memo(({ disabled, onClick }: ReturnButtonProps) => {
export default function ReturnButton({ disabled, onClick }: ReturnButtonProps) {
return (
<Button
className="w-32 h-6 rounded-sm lowercase"
@@ -20,8 +19,4 @@ const ReturnButton = React.memo(({ disabled, onClick }: ReturnButtonProps) => {
Ir para Mês Atual
</Button>
);
});
ReturnButton.displayName = "ReturnButton";
export default ReturnButton;
}

View File

@@ -1,12 +1,6 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { useEffect, useState, useTransition } from "react";
import { toast } from "sonner";
import {
createBudgetAction,
@@ -79,14 +73,10 @@ export function BudgetDialog({
onOpenChange,
);
const initialState = useMemo(
() =>
buildInitialValues({
budget,
defaultPeriod,
}),
[budget, defaultPeriod],
);
const initialState = buildInitialValues({
budget,
defaultPeriod,
});
// Use form state hook for form management
const { formState, updateField, setFormState } =
@@ -98,7 +88,7 @@ export function BudgetDialog({
setFormState(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, setFormState]);
}, [dialogOpen, setFormState, budget, defaultPeriod]);
// Clear error when dialog closes
useEffect(() => {
@@ -107,67 +97,64 @@ export function BudgetDialog({
}
}, [dialogOpen]);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
if (mode === "update" && !budget?.id) {
const message = "Orçamento inválido.";
setErrorMessage(message);
toast.error(message);
if (mode === "update" && !budget?.id) {
const message = "Orçamento inválido.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.categoriaId.length === 0) {
const message = "Selecione uma categoria.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.period.length === 0) {
const message = "Informe o período.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.amount.length === 0) {
const message = "Informe o valor limite.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload = {
categoriaId: formState.categoriaId,
period: formState.period,
amount: formState.amount,
};
startTransition(async () => {
const result =
mode === "create"
? await createBudgetAction(payload)
: await updateBudgetAction({
id: budget?.id ?? "",
...payload,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
return;
}
if (formState.categoriaId.length === 0) {
const message = "Selecione uma categoria.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.period.length === 0) {
const message = "Informe o período.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.amount.length === 0) {
const message = "Informe o valor limite.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload = {
categoriaId: formState.categoriaId,
period: formState.period,
amount: formState.amount,
};
startTransition(async () => {
const result =
mode === "create"
? await createBudgetAction(payload)
: await updateBudgetAction({
id: budget?.id ?? "",
...payload,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
},
[budget?.id, formState, initialState, mode, setDialogOpen, setFormState],
);
setErrorMessage(result.error);
toast.error(result.error);
});
};
const title = mode === "create" ? "Novo orçamento" : "Editar orçamento";
const description =

View File

@@ -1,7 +1,14 @@
"use client";
import type React from "react";
import { createContext, useContext, useEffect, useState } from "react";
import {
createContext,
useContext,
useEffect,
useRef,
useState,
useSyncExternalStore,
} from "react";
interface PrivacyContextType {
privacyMode: boolean;
@@ -13,25 +20,41 @@ const PrivacyContext = createContext<PrivacyContextType | undefined>(undefined);
const STORAGE_KEY = "app:privacyMode";
// Read from localStorage safely (returns false on server)
function getStoredValue(): boolean {
if (typeof window === "undefined") return false;
return localStorage.getItem(STORAGE_KEY) === "true";
}
// Subscribe to storage changes
function subscribeToStorage(callback: () => void) {
window.addEventListener("storage", callback);
return () => window.removeEventListener("storage", callback);
}
export function PrivacyProvider({ children }: { children: React.ReactNode }) {
const [privacyMode, setPrivacyMode] = useState(false);
const [hydrated, setHydrated] = useState(false);
// useSyncExternalStore handles hydration safely
const storedValue = useSyncExternalStore(
subscribeToStorage,
getStoredValue,
() => false, // Server snapshot
);
// Sincronizar com localStorage na montagem (evitar mismatch SSR/CSR)
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored !== null) {
setPrivacyMode(stored === "true");
}
setHydrated(true);
}, []);
const [privacyMode, setPrivacyMode] = useState(storedValue);
const isFirstRender = useRef(true);
// Persistir mudanças no localStorage
// Sync with stored value on mount
useEffect(() => {
if (hydrated) {
localStorage.setItem(STORAGE_KEY, String(privacyMode));
if (isFirstRender.current) {
setPrivacyMode(storedValue);
isFirstRender.current = false;
}
}, [privacyMode, hydrated]);
}, [storedValue]);
// Persist to localStorage when privacyMode changes
useEffect(() => {
localStorage.setItem(STORAGE_KEY, String(privacyMode));
}, [privacyMode]);
const toggle = () => {
setPrivacyMode((prev) => !prev);

View File

@@ -63,50 +63,45 @@ export function NavMain({ sections }: { sections: NavSection[] }) {
const searchParams = useSearchParams();
const periodParam = searchParams.get(MONTH_PERIOD_PARAM);
const isLinkActive = React.useCallback(
(url: string) => {
const normalizedPathname =
pathname.endsWith("/") && pathname !== "/"
? pathname.slice(0, -1)
: pathname;
const normalizedUrl =
url.endsWith("/") && url !== "/" ? url.slice(0, -1) : url;
const normalizedPathname =
pathname.endsWith("/") && pathname !== "/"
? pathname.slice(0, -1)
: pathname;
// Verifica se é exatamente igual ou se o pathname começa com a URL
return (
normalizedPathname === normalizedUrl ||
normalizedPathname.startsWith(`${normalizedUrl}/`)
);
},
[pathname],
);
const isLinkActive = (url: string) => {
const normalizedUrl =
url.endsWith("/") && url !== "/" ? url.slice(0, -1) : url;
const buildHrefWithPeriod = React.useCallback(
(url: string) => {
if (!periodParam) {
return url;
}
// Verifica se é exatamente igual ou se o pathname começa com a URL
return (
normalizedPathname === normalizedUrl ||
normalizedPathname.startsWith(`${normalizedUrl}/`)
);
};
const [rawPathname, existingSearch = ""] = url.split("?");
const normalizedPathname =
rawPathname.endsWith("/") && rawPathname !== "/"
? rawPathname.slice(0, -1)
: rawPathname;
const buildHrefWithPeriod = (url: string) => {
if (!periodParam) {
return url;
}
if (!PERIOD_AWARE_PATHS.has(normalizedPathname)) {
return url;
}
const [rawPathname, existingSearch = ""] = url.split("?");
const normalizedRawPathname =
rawPathname.endsWith("/") && rawPathname !== "/"
? rawPathname.slice(0, -1)
: rawPathname;
const params = new URLSearchParams(existingSearch);
params.set(MONTH_PERIOD_PARAM, periodParam);
if (!PERIOD_AWARE_PATHS.has(normalizedRawPathname)) {
return url;
}
const queryString = params.toString();
return queryString
? `${normalizedPathname}?${queryString}`
: normalizedPathname;
},
[periodParam],
);
const params = new URLSearchParams(existingSearch);
params.set(MONTH_PERIOD_PARAM, periodParam);
const queryString = params.toString();
return queryString
? `${normalizedRawPathname}?${queryString}`
: normalizedRawPathname;
};
const activeLinkClasses =
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-dark! hover:text-primary!";

View File

@@ -1,5 +1,5 @@
import type { VariantProps } from "class-variance-authority";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { buttonVariants } from "@/components/ui/button";
import {
formatLocaleValue,
@@ -26,9 +26,9 @@ export function useCalculatorState() {
const [copied, setCopied] = useState(false);
const resetCopiedTimeoutRef = useRef<number | undefined>(undefined);
const currentValue = useMemo(() => Number(display), [display]);
const currentValue = Number(display);
const resultText = useMemo(() => {
const resultText = (() => {
if (display === "Erro") {
return null;
}
@@ -39,49 +39,46 @@ export function useCalculatorState() {
}
return formatLocaleValue(normalized);
}, [currentValue, display]);
})();
const reset = useCallback(() => {
const reset = () => {
setDisplay("0");
setAccumulator(null);
setOperator(null);
setOverwrite(false);
setHistory(null);
}, []);
};
const inputDigit = useCallback(
(digit: string) => {
// Check conditions before state updates
const shouldReset = overwrite || display === "Erro";
const inputDigit = (digit: string) => {
// Check conditions before state updates
const shouldReset = overwrite || display === "Erro";
setDisplay((prev) => {
if (shouldReset) {
return digit;
}
if (prev === "0") {
return digit;
}
// Limitar a 10 dígitos (excluindo sinal negativo e ponto decimal)
const digitCount = prev.replace(/[-.]/g, "").length;
if (digitCount >= 10) {
return prev;
}
return `${prev}${digit}`;
});
// Update related states after display update
setDisplay((prev) => {
if (shouldReset) {
setOverwrite(false);
setHistory(null);
return digit;
}
},
[overwrite, display],
);
const inputDecimal = useCallback(() => {
if (prev === "0") {
return digit;
}
// Limitar a 10 dígitos (excluindo sinal negativo e ponto decimal)
const digitCount = prev.replace(/[-.]/g, "").length;
if (digitCount >= 10) {
return prev;
}
return `${prev}${digit}`;
});
// Update related states after display update
if (shouldReset) {
setOverwrite(false);
setHistory(null);
}
};
const inputDecimal = () => {
// Check conditions before state updates
const shouldReset = overwrite || display === "Erro";
@@ -108,40 +105,37 @@ export function useCalculatorState() {
setOverwrite(false);
setHistory(null);
}
}, [overwrite, display]);
};
const setNextOperator = useCallback(
(nextOperator: Operator) => {
if (display === "Erro") {
reset();
const setNextOperator = (nextOperator: Operator) => {
if (display === "Erro") {
reset();
return;
}
const value = currentValue;
if (accumulator === null || operator === null || overwrite) {
setAccumulator(value);
} else {
const result = performOperation(accumulator, value, operator);
const formatted = formatNumber(result);
setAccumulator(Number.isFinite(result) ? result : null);
setDisplay(formatted);
if (!Number.isFinite(result)) {
setOperator(null);
setOverwrite(true);
setHistory(null);
return;
}
}
const value = currentValue;
setOperator(nextOperator);
setOverwrite(true);
setHistory(null);
};
if (accumulator === null || operator === null || overwrite) {
setAccumulator(value);
} else {
const result = performOperation(accumulator, value, operator);
const formatted = formatNumber(result);
setAccumulator(Number.isFinite(result) ? result : null);
setDisplay(formatted);
if (!Number.isFinite(result)) {
setOperator(null);
setOverwrite(true);
setHistory(null);
return;
}
}
setOperator(nextOperator);
setOverwrite(true);
setHistory(null);
},
[accumulator, currentValue, display, operator, overwrite, reset],
);
const evaluate = useCallback(() => {
const evaluate = () => {
if (operator === null || accumulator === null || display === "Erro") {
return;
}
@@ -161,9 +155,9 @@ export function useCalculatorState() {
setOperator(null);
setOverwrite(true);
setHistory(operation);
}, [accumulator, currentValue, display, operator]);
};
const toggleSign = useCallback(() => {
const toggleSign = () => {
setDisplay((prev) => {
if (prev === "Erro") {
return prev;
@@ -177,9 +171,9 @@ export function useCalculatorState() {
setOverwrite(false);
setHistory(null);
}
}, [overwrite]);
};
const deleteLastDigit = useCallback(() => {
const deleteLastDigit = () => {
setHistory(null);
// Check conditions before state updates
@@ -209,9 +203,9 @@ export function useCalculatorState() {
} else if (overwrite) {
setOverwrite(false);
}
}, [overwrite, display]);
};
const applyPercent = useCallback(() => {
const applyPercent = () => {
setDisplay((prev) => {
if (prev === "Erro") {
return prev;
@@ -221,9 +215,9 @@ export function useCalculatorState() {
});
setOverwrite(true);
setHistory(null);
}, []);
};
const expression = useMemo(() => {
const expression = (() => {
if (display === "Erro") {
return "Erro";
}
@@ -240,68 +234,57 @@ export function useCalculatorState() {
}
return formatLocaleValue(display);
}, [accumulator, display, operator, overwrite]);
})();
const buttons = useMemo(() => {
const makeOperatorHandler = (nextOperator: Operator) => () =>
setNextOperator(nextOperator);
const makeOperatorHandler = (nextOperator: Operator) => () =>
setNextOperator(nextOperator);
return [
[
{ label: "C", onClick: reset, variant: "destructive" },
{ label: "⌫", onClick: deleteLastDigit, variant: "default" },
{ label: "%", onClick: applyPercent, variant: "default" },
{
label: "÷",
onClick: makeOperatorHandler("divide"),
variant: "outline",
},
],
[
{ label: "7", onClick: () => inputDigit("7") },
{ label: "8", onClick: () => inputDigit("8") },
{ label: "9", onClick: () => inputDigit("9") },
{
label: "×",
onClick: makeOperatorHandler("multiply"),
variant: "outline",
},
],
[
{ label: "4", onClick: () => inputDigit("4") },
{ label: "5", onClick: () => inputDigit("5") },
{ label: "6", onClick: () => inputDigit("6") },
{
label: "-",
onClick: makeOperatorHandler("subtract"),
variant: "outline",
},
],
[
{ label: "1", onClick: () => inputDigit("1") },
{ label: "2", onClick: () => inputDigit("2") },
{ label: "3", onClick: () => inputDigit("3") },
{ label: "+", onClick: makeOperatorHandler("add"), variant: "outline" },
],
[
{ label: "±", onClick: toggleSign, variant: "default" },
{ label: "0", onClick: () => inputDigit("0") },
{ label: ",", onClick: inputDecimal },
{ label: "=", onClick: evaluate, variant: "default" },
],
] satisfies CalculatorButtonConfig[][];
}, [
applyPercent,
deleteLastDigit,
evaluate,
inputDecimal,
inputDigit,
reset,
setNextOperator,
toggleSign,
]);
const buttons: CalculatorButtonConfig[][] = [
[
{ label: "C", onClick: reset, variant: "destructive" },
{ label: "⌫", onClick: deleteLastDigit, variant: "default" },
{ label: "%", onClick: applyPercent, variant: "default" },
{
label: "÷",
onClick: makeOperatorHandler("divide"),
variant: "outline",
},
],
[
{ label: "7", onClick: () => inputDigit("7") },
{ label: "8", onClick: () => inputDigit("8") },
{ label: "9", onClick: () => inputDigit("9") },
{
label: "×",
onClick: makeOperatorHandler("multiply"),
variant: "outline",
},
],
[
{ label: "4", onClick: () => inputDigit("4") },
{ label: "5", onClick: () => inputDigit("5") },
{ label: "6", onClick: () => inputDigit("6") },
{
label: "-",
onClick: makeOperatorHandler("subtract"),
variant: "outline",
},
],
[
{ label: "1", onClick: () => inputDigit("1") },
{ label: "2", onClick: () => inputDigit("2") },
{ label: "3", onClick: () => inputDigit("3") },
{ label: "+", onClick: makeOperatorHandler("add"), variant: "outline" },
],
[
{ label: "±", onClick: toggleSign, variant: "default" },
{ label: "0", onClick: () => inputDigit("0") },
{ label: ",", onClick: inputDecimal },
{ label: "=", onClick: evaluate, variant: "default" },
],
];
const copyToClipboard = useCallback(async () => {
const copyToClipboard = async () => {
if (!resultText) return;
try {
@@ -320,9 +303,9 @@ export function useCalculatorState() {
error,
);
}
}, [resultText]);
};
const pasteFromClipboard = useCallback(async () => {
const pasteFromClipboard = async () => {
if (!navigator.clipboard?.readText) return;
try {
@@ -364,7 +347,7 @@ export function useCalculatorState() {
} catch (error) {
console.error("Não foi possível colar o valor na calculadora.", error);
}
}, []);
};
useEffect(() => {
return () => {

View File

@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useState } from "react";
/**
* Hook for managing form state with type-safe field updates
@@ -22,26 +22,23 @@ export function useFormState<T extends Record<string, any>>(initialValues: T) {
/**
* Updates a single field in the form state
*/
const updateField = useCallback(
<K extends keyof T>(field: K, value: T[K]) => {
setFormState((prev) => ({ ...prev, [field]: value }));
},
[],
);
const updateField = <K extends keyof T>(field: K, value: T[K]) => {
setFormState((prev) => ({ ...prev, [field]: value }));
};
/**
* Resets form to initial values
*/
const resetForm = useCallback(() => {
const resetForm = () => {
setFormState(initialValues);
}, [initialValues]);
};
/**
* Updates multiple fields at once
*/
const updateFields = useCallback((updates: Partial<T>) => {
const updateFields = (updates: Partial<T>) => {
setFormState((prev) => ({ ...prev, ...updates }));
}, []);
};
return {
formState,

View File

@@ -1,7 +1,7 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo } from "react";
import { useMemo } from "react";
import { MONTH_NAMES } from "@/lib/utils/period";
@@ -46,29 +46,23 @@ export function useMonthPeriod() {
};
}, [periodFromParams, defaultMonth, defaultYear, optionsMeses]);
const buildHref = useCallback(
(month: string, year: string | number) => {
const normalizedMonth = normalizeMonth(month);
const normalizedYear = String(year).trim();
const buildHref = (month: string, year: string | number) => {
const normalizedMonth = normalizeMonth(month);
const normalizedYear = String(year).trim();
const params = new URLSearchParams(searchParams.toString());
params.set(PERIOD_PARAM, `${normalizedMonth}-${normalizedYear}`);
const params = new URLSearchParams(searchParams.toString());
params.set(PERIOD_PARAM, `${normalizedMonth}-${normalizedYear}`);
return `${pathname}?${params.toString()}`;
},
[pathname, searchParams],
);
return `${pathname}?${params.toString()}`;
};
const replacePeriod = useCallback(
(target: string) => {
if (!target) {
return;
}
const replacePeriod = (target: string) => {
if (!target) {
return;
}
router.replace(target, { scroll: false });
},
[router],
);
router.replace(target, { scroll: false });
};
return {
monthNames: optionsMeses,

View File

@@ -1,6 +1,6 @@
{
"name": "opensheets",
"version": "1.2.5",
"version": "1.2.6",
"private": true,
"scripts": {
"dev": "next dev --turbopack",