diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8fe22c1 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/components/anotacoes/note-dialog.tsx b/components/anotacoes/note-dialog.tsx index 106ee70..1234cb9 100644 --- a/components/anotacoes/note-dialog.tsx +++ b/components/anotacoes/note-dialog.tsx @@ -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) => { - e.preventDefault(); - setErrorMessage(null); + const handleSubmit = (e: React.FormEvent) => { + 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 ( diff --git a/components/auth/signup-form.tsx b/components/auth/signup-form.tsx index 65c2548..8569ef5 100644 --- a/components/auth/signup-form.tsx +++ b/components/auth/signup-form.tsx @@ -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) { e.preventDefault(); diff --git a/components/categorias/category-dialog.tsx b/components/categorias/category-dialog.tsx index 398ac5a..46b4426 100644 --- a/components/categorias/category-dialog.tsx +++ b/components/categorias/category-dialog.tsx @@ -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) => { - event.preventDefault(); - setErrorMessage(null); + const handleSubmit = (event: React.FormEvent) => { + 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 = diff --git a/components/confirm-action-dialog.tsx b/components/confirm-action-dialog.tsx index 4354ed9..48cdce6 100644 --- a/components/confirm-action-dialog.tsx +++ b/components/confirm-action-dialog.tsx @@ -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 ( diff --git a/components/contas/accounts-page.tsx b/components/contas/accounts-page.tsx index ea318f5..135628b 100644 --- a/components/contas/accounts-page.tsx +++ b/components/contas/accounts-page.tsx @@ -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}"?` diff --git a/components/contas/transfer-dialog.tsx b/components/contas/transfer-dialog.tsx index 3f765f5..94319af 100644 --- a/components/contas/transfer-dialog.tsx +++ b/components/contas/transfer-dialog.tsx @@ -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) => { diff --git a/components/dashboard/category-history-widget.tsx b/components/dashboard/category-history-widget.tsx index 4fb3359..fe3af94 100644 --- a/components/dashboard/category-history-widget.tsx +++ b/components/dashboard/category-history-widget.tsx @@ -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([]); 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(() => { diff --git a/components/lancamentos/table/lancamentos-filters.tsx b/components/lancamentos/table/lancamentos-filters.tsx index 6578381..df1cbde 100644 --- a/components/lancamentos/table/lancamentos-filters.tsx +++ b/components/lancamentos/table/lancamentos-filters.tsx @@ -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 (
diff --git a/components/month-picker/nav-button.tsx b/components/month-picker/nav-button.tsx index 894bacf..8611ece 100644 --- a/components/month-picker/nav-button.tsx +++ b/components/month-picker/nav-button.tsx @@ -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 ( - - ); - }, -); - -NavigationButton.displayName = "NavigationButton"; - -export default NavigationButton; + return ( + + ); +} diff --git a/components/month-picker/return-button.tsx b/components/month-picker/return-button.tsx index ce35718..a73345d 100644 --- a/components/month-picker/return-button.tsx +++ b/components/month-picker/return-button.tsx @@ -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 ( ); -}); - -ReturnButton.displayName = "ReturnButton"; - -export default ReturnButton; +} diff --git a/components/orcamentos/budget-dialog.tsx b/components/orcamentos/budget-dialog.tsx index 1a36b38..8eedbdd 100644 --- a/components/orcamentos/budget-dialog.tsx +++ b/components/orcamentos/budget-dialog.tsx @@ -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) => { - event.preventDefault(); - setErrorMessage(null); + const handleSubmit = (event: React.FormEvent) => { + 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 = diff --git a/components/privacy-provider.tsx b/components/privacy-provider.tsx index 48bd39e..4e671a1 100644 --- a/components/privacy-provider.tsx +++ b/components/privacy-provider.tsx @@ -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(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); diff --git a/components/sidebar/nav-main.tsx b/components/sidebar/nav-main.tsx index 5a93530..5636e6d 100644 --- a/components/sidebar/nav-main.tsx +++ b/components/sidebar/nav-main.tsx @@ -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!"; diff --git a/hooks/use-calculator-state.ts b/hooks/use-calculator-state.ts index aff749e..7033701 100644 --- a/hooks/use-calculator-state.ts +++ b/hooks/use-calculator-state.ts @@ -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(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 () => { diff --git a/hooks/use-form-state.ts b/hooks/use-form-state.ts index 97c539d..193d475 100644 --- a/hooks/use-form-state.ts +++ b/hooks/use-form-state.ts @@ -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>(initialValues: T) { /** * Updates a single field in the form state */ - const updateField = useCallback( - (field: K, value: T[K]) => { - setFormState((prev) => ({ ...prev, [field]: value })); - }, - [], - ); + const updateField = (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) => { + const updateFields = (updates: Partial) => { setFormState((prev) => ({ ...prev, ...updates })); - }, []); + }; return { formState, diff --git a/hooks/use-month-period.ts b/hooks/use-month-period.ts index 3423597..11ca635 100644 --- a/hooks/use-month-period.ts +++ b/hooks/use-month-period.ts @@ -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, diff --git a/package.json b/package.json index 6e31fe7..755061c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opensheets", - "version": "1.2.5", + "version": "1.2.6", "private": true, "scripts": { "dev": "next dev --turbopack",