Rate history chart and pension contribution calculator

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 <noreply@anthropic.com>
This commit is contained in:
Carlos Escalante
2026-06-12 16:59:29 -06:00
parent fe62e50a85
commit 84f0256b7c
3 changed files with 170 additions and 2 deletions

View File

@@ -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() {
</CardContent>
</Card>
)}
{/* Exchange rate history */}
<Card>
<CardHeader>
<CardTitle className="text-sm uppercase tracking-wider text-muted-foreground">
Tipo de Cambio USD/CRC (90 días)
</CardTitle>
</CardHeader>
<CardContent>
{(ratesQ.data?.length ?? 0) < 2 ? (
<div className="h-40 flex items-center justify-center text-muted-foreground text-sm">
Sin historial suficiente
</div>
) : (
<ChartContainer
config={rateChartConfig}
className="h-[200px] w-full"
>
<LineChart data={ratesQ.data}>
<XAxis
dataKey="day"
axisLine={false}
tickLine={false}
tickFormatter={(v) =>
new Date(v).toLocaleDateString('es-CR', {
month: 'short',
day: 'numeric',
})
}
/>
<YAxis
axisLine={false}
tickLine={false}
domain={['auto', 'auto']}
width={44}
/>
<ChartTooltip
content={
<ChartTooltipContent
formatter={(value) => `${Number(value).toFixed(2)}`}
/>
}
/>
<Line
type="monotone"
dataKey="sell_rate"
stroke="var(--chart-1)"
dot={false}
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="buy_rate"
stroke="var(--chart-2)"
dot={false}
strokeWidth={2}
/>
</LineChart>
</ChartContainer>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<FundKey>('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<Record<FundKey, PensionSnapshot>> = {};
@@ -773,6 +800,64 @@ export default function Pensions() {
/>
)}
{/* ── Contribution calculator ──────────────────────────────────────── */}
<section className="space-y-3">
<h2 className="text-base font-semibold font-heading flex items-center gap-2 text-muted-foreground uppercase tracking-wider">
<Target className="w-4 h-4" />
Calculadora de Aporte
</h2>
<Card>
<CardContent className="p-4 grid grid-cols-1 sm:grid-cols-4 gap-4 items-end">
<div className="space-y-1">
<Label className="text-xs">Fondo</Label>
<Select value={calcFund} onValueChange={(v) => v && setCalcFund(v as FundKey)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FUND_KEYS.map((k) => (
<SelectItem key={k} value={k}>{FUNDS[k].name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Meta ()</Label>
<Input
type="number"
min="0"
step="1000000"
value={calcTarget}
onChange={(e) => setCalcTarget(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">A los (edad)</Label>
<Input
type="number"
min={CURRENT_AGE + 1}
max="80"
value={calcAge}
onChange={(e) => setCalcAge(e.target.value)}
/>
</div>
<div className="text-right sm:text-left">
<p className="text-xs text-muted-foreground">Aporte mensual requerido</p>
<p data-sensitive className="text-xl font-bold font-mono text-primary">
{requiredMonthly === null
? '—'
: requiredMonthly === 0
? 'Meta ya alcanzable'
: formatCRC(Math.ceil(requiredMonthly / 1000) * 1000)}
</p>
<p className="text-[11px] text-muted-foreground">
al {FUNDS[calcFund].annualRate}% anual, saldo actual incluido
</p>
</div>
</CardContent>
</Card>
</section>
{/* ── Section 5: PDF Upload ────────────────────────────────────────── */}
<section className="space-y-3">
<div className="flex items-center justify-between">