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 (
+
+
+
{message}
+ {onRetry && (
+
+ )}
+
+ );
+}
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}
/>
+ )}