From 84f0256b7c573245d8bd0f12025c6d2b3b64822a Mon Sep 17 00:00:00 2001 From: Carlos Escalante Date: Fri, 12 Jun 2026 16:59:29 -0600 Subject: [PATCH] Rate history chart and pension contribution calculator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analytics gains a 90-day USD/CRC buy/sell chart from the existing history endpoint (wishlist: rate history). Pensiones gains a Calculadora de Aporte: fund + target amount + target age → required monthly contribution via the annuity future-value formula, using the fund's live balance and configured rate (wishlist: contribution calculator). Co-Authored-By: Claude Fable 5 --- frontend/src/lib/api.ts | 7 +++ frontend/src/pages/Analytics.tsx | 78 +++++++++++++++++++++++++++- frontend/src/pages/Pensions.tsx | 87 +++++++++++++++++++++++++++++++- 3 files changed, 170 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index af10707..e4227f1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -337,3 +337,10 @@ export type UserSettingsData = Record; export type UserSettingsRead = Schema<'UserSettingsRead'>; export const getUserSettings = () => api.get('/settings/'); + +// --- Exchange Rate --- + +export type ExchangeRatePoint = Schema<'ExchangeRateRead'>; + +export const getRateHistory = (days = 90) => + api.get('/exchange-rate/history', { params: { days } }); diff --git a/frontend/src/pages/Analytics.tsx b/frontend/src/pages/Analytics.tsx index ebf9246..476d1d4 100644 --- a/frontend/src/pages/Analytics.tsx +++ b/frontend/src/pages/Analytics.tsx @@ -13,7 +13,7 @@ import { } from 'recharts'; import { BarChart3 } from 'lucide-react'; -import api from '@/lib/api'; +import api, { getRateHistory } from '@/lib/api'; import ErrorState from '@/components/ErrorState'; import BillingCycleSelector from '@/components/BillingCycleSelector'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -63,6 +63,11 @@ const trendChartConfig = { }, } satisfies ChartConfig; +const rateChartConfig = { + sell_rate: { label: 'Venta', color: 'var(--chart-1)' }, + buy_rate: { label: 'Compra', color: 'var(--chart-2)' }, +} satisfies ChartConfig; + const dailyChartConfig = { total: { label: 'Gasto Diario', @@ -103,6 +108,16 @@ export default function Analytics() { const byCategory = byCategoryQ.data ?? []; const trend = trendQ.data ?? []; const daily = dailyQ.data ?? []; + const ratesQ = useQuery({ + queryKey: ['exchange-rate', 'history'], + queryFn: () => + getRateHistory(90).then((r) => + [...r.data] + .sort((a, b) => a.date.localeCompare(b.date)) + .map((p) => ({ ...p, day: p.date.slice(0, 10) })), + ), + staleTime: 60 * 60_000, + }); const anyError = byCategoryQ.isError || trendQ.isError || dailyQ.isError; const retryAll = () => { byCategoryQ.refetch(); @@ -328,6 +343,67 @@ export default function Analytics() { )} + {/* Exchange rate history */} + + + + Tipo de Cambio USD/CRC (90 días) + + + + {(ratesQ.data?.length ?? 0) < 2 ? ( +
+ Sin historial suficiente +
+ ) : ( + + + + new Date(v).toLocaleDateString('es-CR', { + month: 'short', + day: 'numeric', + }) + } + /> + + `₡${Number(value).toFixed(2)}`} + /> + } + /> + + + + + )} +
+
); } diff --git a/frontend/src/pages/Pensions.tsx b/frontend/src/pages/Pensions.tsx index e5ac7f0..a68696f 100644 --- a/frontend/src/pages/Pensions.tsx +++ b/frontend/src/pages/Pensions.tsx @@ -28,6 +28,13 @@ import { import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { Separator } from '@/components/ui/separator'; @@ -40,7 +47,7 @@ import { type PensionUploadResult, } from '@/lib/api'; import PensionManualEntryModal from '@/components/PensionManualEntryModal'; -import { ClipboardPaste, SlidersHorizontal } from 'lucide-react'; +import { ClipboardPaste, SlidersHorizontal, Target } from 'lucide-react'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -317,6 +324,26 @@ export default function Pensions() { ); const [showAssumptions, setShowAssumptions] = useState(false); + // ── Contribution calculator (wishlist #9): required monthly aporte to + // reach a target balance by a target age, given the fund's annual rate. + const [calcFund, setCalcFund] = useState('ROP'); + const [calcTarget, setCalcTarget] = useState('50000000'); + const [calcAge, setCalcAge] = useState('65'); + + const requiredMonthly = useMemo(() => { + const fund = FUNDS[calcFund]; + const target = parseFloat(calcTarget); + const targetAge = parseInt(calcAge, 10); + const months = (targetAge - CURRENT_AGE) * 12; + if (!fund || isNaN(target) || isNaN(targetAge) || months <= 0) return null; + const r = fund.annualRate / 100 / 12; + const growth = Math.pow(1 + r, months); + const fromBalance = fund.startBalance * growth; + if (fromBalance >= target) return 0; + // FV of an annuity: PMT * ((1+r)^n - 1) / r + return ((target - fromBalance) * r) / (growth - 1); + }, [FUNDS, calcFund, calcTarget, calcAge]); + // Build a map of fund -> latest snapshot for rendimientos display const snapshotByFund = useMemo(() => { const map: Partial> = {}; @@ -773,6 +800,64 @@ export default function Pensions() { /> )} + {/* ── Contribution calculator ──────────────────────────────────────── */} +
+

+ + Calculadora de Aporte +

+ + +
+ + +
+
+ + setCalcTarget(e.target.value)} + /> +
+
+ + setCalcAge(e.target.value)} + /> +
+
+

Aporte mensual requerido

+

+ {requiredMonthly === null + ? '—' + : requiredMonthly === 0 + ? 'Meta ya alcanzable' + : formatCRC(Math.ceil(requiredMonthly / 1000) * 1000)} +

+

+ al {FUNDS[calcFund].annualRate}% anual, saldo actual incluido +

+
+
+
+
+ {/* ── Section 5: PDF Upload ────────────────────────────────────────── */}