diff --git a/codebase-review/PROGRESS.md b/codebase-review/PROGRESS.md index 197a0c6..39590db 100644 --- a/codebase-review/PROGRESS.md +++ b/codebase-review/PROGRESS.md @@ -98,6 +98,20 @@ Completed 2026-06-10. Tests: 55 → **69** (new: model serialization, agent tools, upload validation). +**Production verification (2026-06-10, deploy after CI test gate):** boot logs show +both migrations applied in order; `alembic_version = 7884505b16b3`; +`transaction.amount` is `numeric(15,2)`; FK rules live (`SET NULL`×2, `CASCADE`); +data intact through the type change (238 transactions, sum unchanged); live API +emits amounts as JSON numbers. + +--- + +## Phase 3 — Frontend Data Layer & Feedback 🔄 IN PROGRESS + +Started 2026-06-10. Scope per plan: TanStack Query adoption (3.1), toast + +error-state standard (3.2), auth-flow fixes (3.3), type alignment (3.4 — scope +assessed at the end), small-fix batch (3.5). + ## Phase 3 — Frontend Data Layer & Feedback ⏳ NOT STARTED ## Phase 4 — UX, Trust & Features ⏳ NOT STARTED diff --git a/frontend/package.json b/frontend/package.json index 7b9c8c9..63d7912 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "@fontsource-variable/ibm-plex-sans": "^5.2.8", "@fontsource-variable/noto-sans": "^5.2.10", "@hono/node-server": "^1.14.4", + "@tanstack/react-query": "^5.101.0", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -30,6 +31,7 @@ "react-router-dom": "^7.6.0", "recharts": "^3.8.1", "rxjs": "^7.8.1", + "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tsx": "^4.19.4", "tw-animate-css": "^1.4.0" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 899225c..cdee852 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@hono/node-server': specifier: ^1.14.4 version: 1.19.14(hono@4.12.15) + '@tanstack/react-query': + specifier: ^5.101.0 + version: 5.101.0(react@19.2.5) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -65,6 +68,9 @@ importers: rxjs: specifier: ^7.8.1 version: 7.8.2 + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) tailwind-merge: specifier: ^3.5.0 version: 3.5.0 @@ -1606,6 +1612,14 @@ packages: resolution: {integrity: sha512-ZNQ1bIL6eUXVKdic0tiImvBVkWrg/IoSK6VIacTrO3d3HAGnd70qFJNJagR/YOJIOw4EKGWnodwpYZkN1pWuVQ==} engines: {node: '>=18'} + '@tanstack/query-core@5.101.0': + resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} + + '@tanstack/react-query@5.101.0': + resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==} + peerDependencies: + react: ^18 || ^19 + '@tanstack/react-table@8.21.3': resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} engines: {node: '>=12'} @@ -3852,6 +3866,12 @@ packages: sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5705,6 +5725,13 @@ snapshots: '@tanstack/devtools-event-client': 0.4.3 '@tanstack/store': 0.9.3 + '@tanstack/query-core@5.101.0': {} + + '@tanstack/react-query@5.101.0(react@19.2.5)': + dependencies: + '@tanstack/query-core': 5.101.0 + react: 19.2.5 + '@tanstack/react-table@8.21.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@tanstack/table-core': 8.21.3 @@ -8634,6 +8661,11 @@ snapshots: dependencies: atomic-sleep: 1.0.0 + sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + source-map-js@1.2.1: {} space-separated-tokens@1.1.5: {} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7776c46..9ee13d0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,8 +1,19 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { CopilotKit } from "@copilotkit/react-core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Toaster } from "sonner"; import { AuthProvider, useAuth } from "./AuthContext"; import { ThemeProvider } from "./contexts/theme-context"; import { PrivacyProvider } from "./contexts/privacy-context"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: 1, + }, + }, +}); import Layout from "./components/Layout"; import LoginPage from "./pages/Login"; import Asistente from "./pages/Asistente"; @@ -58,9 +69,12 @@ export default function App() { - - - + + + + + + diff --git a/frontend/src/components/ErrorState.tsx b/frontend/src/components/ErrorState.tsx new file mode 100644 index 0000000..f6c9014 --- /dev/null +++ b/frontend/src/components/ErrorState.tsx @@ -0,0 +1,28 @@ +import { AlertTriangle, RotateCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface Props { + message?: string; + onRetry?: () => void; +} + +export default function ErrorState({ + message = 'No se pudieron cargar los datos', + onRetry, +}: Props) { + return ( +
+
+ ); +} diff --git a/frontend/src/hooks/useBudget.ts b/frontend/src/hooks/useBudget.ts index 01f3317..673b563 100644 --- a/frontend/src/hooks/useBudget.ts +++ b/frontend/src/hooks/useBudget.ts @@ -1,8 +1,7 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; import { - type YearlyProjection, - type MonthlyDetail, - type RecurringItem, type RecurringItemCreate, type RecurringItemUpdate, getYearlyProjection, @@ -15,89 +14,90 @@ import { deleteBalanceOverride, } from '@/lib/api'; +/** All budget data hangs off the ['budget', ...] key family; every mutation + * invalidates the family so projection, month detail and items stay in sync. */ export function useBudget(initialYear: number) { + const queryClient = useQueryClient(); const [year, setYear] = useState(initialYear); - const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth() + 1); - const [projection, setProjection] = useState(null); - const [monthDetail, setMonthDetail] = useState(null); - const [recurringItems, setRecurringItems] = useState([]); - const [loading, setLoading] = useState(true); - const [monthLoading, setMonthLoading] = useState(false); + const [selectedMonth, setSelectedMonth] = useState( + new Date().getMonth() + 1, + ); - const fetchProjection = useCallback(async () => { - setLoading(true); + const projectionQ = useQuery({ + queryKey: ['budget', 'projection', year], + queryFn: () => getYearlyProjection(year).then((r) => r.data), + }); + + const monthDetailQ = useQuery({ + queryKey: ['budget', 'month', year, selectedMonth], + queryFn: () => getMonthlyDetail(year, selectedMonth).then((r) => r.data), + placeholderData: (prev) => prev, // keep last month visible while navigating + }); + + const recurringQ = useQuery({ + queryKey: ['budget', 'recurring'], + queryFn: () => getRecurringItems().then((r) => r.data), + }); + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ['budget'] }); + + async function mutateWithToast( + action: () => Promise, + success: string, + failure: string, + ) { try { - const { data } = await getYearlyProjection(year); - setProjection(data); - } finally { - setLoading(false); + await action(); + } catch (err) { + toast.error(failure); + throw err; } - }, [year]); - - const fetchMonthDetail = useCallback(async () => { - setMonthLoading(true); - try { - const { data } = await getMonthlyDetail(year, selectedMonth); - setMonthDetail(data); - } finally { - setMonthLoading(false); - } - }, [year, selectedMonth]); - - const fetchRecurringItems = useCallback(async () => { - const { data } = await getRecurringItems(); - setRecurringItems(data); - }, []); - - useEffect(() => { - fetchProjection(); - fetchRecurringItems(); - }, [fetchProjection, fetchRecurringItems]); - - useEffect(() => { - fetchMonthDetail(); - }, [fetchMonthDetail]); - - const addItem = async (data: RecurringItemCreate) => { - await createRecurringItem(data); - await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]); - }; - - const updateItem = async (id: number, data: RecurringItemUpdate) => { - await apiUpdateItem(id, data); - await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]); - }; - - const deleteItem = async (id: number) => { - await apiDeleteItem(id); - await Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]); - }; - - const saveBalanceOverride = async (overrideYear: number, month: number, value: number) => { - await upsertBalanceOverride(overrideYear, month, value); - await fetchProjection(); - }; - - const clearBalanceOverride = async (overrideYear: number, month: number) => { - await deleteBalanceOverride(overrideYear, month); - await fetchProjection(); - }; + toast.success(success); + await invalidate(); + } return { year, setYear, selectedMonth, setSelectedMonth, - projection, - monthDetail, - recurringItems, - loading, - monthLoading, - addItem, - updateItem, - deleteItem, - saveBalanceOverride, - clearBalanceOverride, - refresh: () => Promise.all([fetchProjection(), fetchMonthDetail(), fetchRecurringItems()]), + projection: projectionQ.data ?? null, + monthDetail: monthDetailQ.data ?? null, + recurringItems: recurringQ.data ?? [], + loading: projectionQ.isPending, + monthLoading: monthDetailQ.isFetching, + error: projectionQ.error ?? monthDetailQ.error ?? recurringQ.error ?? null, + addItem: (data: RecurringItemCreate) => + mutateWithToast( + () => createRecurringItem(data), + 'Item recurrente creado', + 'No se pudo crear el item', + ), + updateItem: (id: number, data: RecurringItemUpdate) => + mutateWithToast( + () => apiUpdateItem(id, data), + 'Item recurrente actualizado', + 'No se pudo actualizar el item', + ), + deleteItem: (id: number) => + mutateWithToast( + () => apiDeleteItem(id), + 'Item recurrente eliminado', + 'No se pudo eliminar el item', + ), + saveBalanceOverride: (overrideYear: number, month: number, value: number) => + mutateWithToast( + () => upsertBalanceOverride(overrideYear, month, value), + 'Balance ajustado', + 'No se pudo ajustar el balance', + ), + clearBalanceOverride: (overrideYear: number, month: number) => + mutateWithToast( + () => deleteBalanceOverride(overrideYear, month), + 'Ajuste de balance eliminado', + 'No se pudo eliminar el ajuste', + ), + refresh: invalidate, }; } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fa95c22..b955ff9 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -10,8 +10,13 @@ class ApiError extends Error { interface RequestConfig { params?: Record; + /** Caller cancellation (TanStack Query passes this); combined with the + * default 30s timeout. */ + signal?: AbortSignal; } +const REQUEST_TIMEOUT_MS = 30_000; + async function request( method: string, url: string, @@ -38,11 +43,17 @@ async function request( fetchBody = JSON.stringify(body); } + const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + const signal = config?.signal + ? AbortSignal.any([config.signal, timeout]) + : timeout; + const res = await fetch(fullUrl, { method, headers, body: fetchBody, credentials: "same-origin", + signal, }); if (res.status === 401) { diff --git a/frontend/src/pages/Budget.tsx b/frontend/src/pages/Budget.tsx index 523fac9..86af5c7 100644 --- a/frontend/src/pages/Budget.tsx +++ b/frontend/src/pages/Budget.tsx @@ -1,10 +1,13 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useCallback } from 'react'; import { ChevronLeft, ChevronRight, Calculator } from 'lucide-react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; import api, { type Transaction } from '@/lib/api'; import { useBudget } from '@/hooks/useBudget'; import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import ErrorState from '@/components/ErrorState'; import MonthlyDetail from '@/components/budget/MonthlyDetail'; import RecurringItemsManager from '@/components/budget/RecurringItemsManager'; import TransactionList from '@/components/TransactionList'; @@ -27,28 +30,25 @@ export default function Budget() { monthDetail, recurringItems, monthLoading, + error: budgetError, addItem, updateItem, deleteItem, refresh, } = useBudget(currentYear); + const queryClient = useQueryClient(); const [subTab, setSubTab] = useState<'detail' | 'transactions'>('detail'); - - // Transaction list state for the selected month - const [transactions, setTransactions] = useState([]); - const [txLoading, setTxLoading] = useState(false); const [txSearch, setTxSearch] = useState(''); const [txSource, setTxSource] = useState<'CREDIT_CARD' | 'CASH_AND_TRANSFER'>('CREDIT_CARD'); - const fetchTransactions = useCallback(async () => { - setTxLoading(true); - try { + const txQuery = useQuery({ + queryKey: ['transactions', 'budget', year, selectedMonth, txSource, txSearch], + queryFn: async ({ signal }) => { const params: Record = { search: txSearch || undefined, limit: 200, }; - if (txSource === 'CREDIT_CARD') { params.source = 'CREDIT_CARD'; // Credit card: billing cycle that ends around the 18th of selectedMonth @@ -66,33 +66,41 @@ export default function Budget() { params.start_date = startDate; params.end_date = endDate; } - - const { data } = await api.get('/transactions/', { params }); + const { data } = await api.get('/transactions/', { params, signal }); const INCOME_TYPES = ['DEPOSITO', 'SALARY']; - const filtered = data.filter((tx) => !INCOME_TYPES.includes(tx.transaction_type)); - setTransactions(filtered); - } finally { - setTxLoading(false); - } - }, [year, selectedMonth, txSource, txSearch]); + return data.filter((tx) => !INCOME_TYPES.includes(tx.transaction_type)); + }, + placeholderData: (prev) => prev, + }); + const transactions = txQuery.data ?? []; + + const invalidateTransactionsAndBudget = useCallback(() => { + queryClient.invalidateQueries({ queryKey: ['transactions'] }); + refresh(); + }, [queryClient, refresh]); const handleToggleDeferred = useCallback(async (tx: Transaction) => { - await api.patch(`/transactions/${tx.id}`, { - deferred_to_next_cycle: !tx.deferred_to_next_cycle, - }); - fetchTransactions(); - refresh(); - }, [fetchTransactions, refresh]); + try { + await api.patch(`/transactions/${tx.id}`, { + deferred_to_next_cycle: !tx.deferred_to_next_cycle, + }); + toast.success( + tx.deferred_to_next_cycle + ? 'Transacción restaurada a su ciclo original' + : 'Transacción diferida al siguiente ciclo', + ); + } catch { + toast.error('No se pudo actualizar la transacción'); + return; + } + invalidateTransactionsAndBudget(); + }, [invalidateTransactionsAndBudget]); const handleNavigateToTransactions = useCallback(() => { setTxSource('CASH_AND_TRANSFER'); setSubTab('transactions'); }, []); - useEffect(() => { - fetchTransactions(); - }, [fetchTransactions]); - return (
{/* Header */} @@ -154,11 +162,15 @@ export default function Budget() {
- + {budgetError ? ( + + ) : ( + + )} @@ -171,21 +183,25 @@ export default function Budget() { Efectivo y Transferencias + {txQuery.isError ? ( + txQuery.refetch()} + /> + ) : ( { - fetchTransactions(); - refresh(); - }} + onRefresh={invalidateTransactionsAndBudget} showCategory={txSource === 'CREDIT_CARD'} showSourceIcon={txSource === 'CASH_AND_TRANSFER'} emptyMessage={`Sin transacciones de ${txSource === 'CREDIT_CARD' ? 'tarjeta' : 'efectivo o transferencia'} en ${MONTH_NAMES[selectedMonth]}`} onToggleDeferred={txSource === 'CREDIT_CARD' ? handleToggleDeferred : undefined} /> + )}