Bankbot / frontend /src /app /simulator /page.tsx
mohsin-devs's picture
Fix: Unignore and track frontend/src/ directory in Git
0f226f2
Raw
History Blame Contribute Delete
19.7 kB
"use client";
import { useState, useMemo, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, ReferenceLine
} from "recharts";
import { Sparkles, TrendingUp, TrendingDown, Zap, RotateCcw, ChevronRight, AlertTriangle } from "lucide-react";
import { aiApi } from "@/lib/api";
// ─── Types ────────────────────────────────────────────────────────────────────
interface SimParams {
monthlyIncome: number;
monthlyExpenses: number;
savingsRate: number;
investmentReturn: number;
loanPayment: number;
emergencyMonths: number;
}
const DEFAULT_PARAMS: SimParams = {
monthlyIncome: 10200,
monthlyExpenses: 6100,
savingsRate: 40,
investmentReturn: 7,
loanPayment: 450,
emergencyMonths: 6,
};
const scenarios = [
{
id: "aggressive",
label: "Aggressive Saver",
icon: "🚀",
color: "text-emerald-400",
bg: "bg-emerald-500/10 border-emerald-500/20",
params: { savingsRate: 55, monthlyExpenses: 4600, investmentReturn: 9 },
},
{
id: "balanced",
label: "Balanced Growth",
icon: "⚖️",
color: "text-blue-400",
bg: "bg-blue-500/10 border-blue-500/20",
params: { savingsRate: 40, monthlyExpenses: 6100, investmentReturn: 7 },
},
{
id: "conservative",
label: "Conservative",
icon: "🛡️",
color: "text-purple-400",
bg: "bg-purple-500/10 border-purple-500/20",
params: { savingsRate: 25, monthlyExpenses: 7600, investmentReturn: 4 },
},
];
// ─── Local projection engine (instant feedback while API loads) ───────────────
function projectBalance(params: SimParams, startBalance: number, months = 36) {
const monthlySavings = params.monthlyIncome * (params.savingsRate / 100);
const monthlyReturn = params.investmentReturn / 100 / 12;
let balance = startBalance;
return Array.from({ length: months + 1 }, (_, i) => {
if (i > 0) balance = balance * (1 + monthlyReturn) + monthlySavings - params.loanPayment;
return { month: i === 0 ? "Now" : `M${i}`, balance: Math.round(balance), savings: Math.round(monthlySavings * i) };
});
}
// ─── Custom Tooltip ───────────────────────────────────────────────────────────
interface TooltipProps {
active?: boolean;
payload?: Array<{ name: string; value: number; stroke?: string; fill?: string }>;
label?: string;
}
function CustomTooltip({ active, payload, label }: TooltipProps) {
if (!active || !payload?.length) return null;
return (
<div className="glass-card p-3 text-xs space-y-1">
<p className="text-zinc-400 font-medium">{label}</p>
{payload.map((entry) => (
<div key={entry.name} className="flex justify-between gap-4">
<span style={{ color: entry.stroke || entry.fill }}>{entry.name}</span>
<span className="text-white font-semibold">${entry.value?.toLocaleString()}</span>
</div>
))}
</div>
);
}
// ─── Slider ───────────────────────────────────────────────────────────────────
function Slider({ label, value, min, max, step, format, onChange, color = "emerald" }: {
label: string; value: number; min: number; max: number; step: number;
format: (v: number) => string; onChange: (v: number) => void; color?: string;
}) {
const pct = ((value - min) / (max - min)) * 100;
const colorMap: Record<string, string> = {
emerald: "#10b981", blue: "#3b82f6", purple: "#8b5cf6", amber: "#f59e0b", red: "#ef4444",
};
const c = colorMap[color] || colorMap.emerald;
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-zinc-400">{label}</span>
<motion.span
key={value}
initial={{ scale: 1.15, color: c }}
animate={{ scale: 1, color: "#ffffff" }}
transition={{ duration: 0.25 }}
className="text-sm font-bold text-white"
>
{format(value)}
</motion.span>
</div>
<div className="relative h-2 w-full rounded-full bg-white/10">
<div className="absolute h-full rounded-full transition-all duration-100"
style={{ width: `${pct}%`, background: `linear-gradient(90deg, ${c}88, ${c})` }} />
<input type="range" min={min} max={max} step={step} value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" style={{ zIndex: 10 }} />
<div className="absolute top-1/2 -translate-y-1/2 h-4 w-4 rounded-full border-2 border-white shadow-lg transition-all duration-100"
style={{ left: `calc(${pct}% - 8px)`, background: c }} />
</div>
</div>
);
}
// ─── Metric Card ──────────────────────────────────────────────────────────────
function MetricCard({ label, value, sub, positive, icon }: {
label: string; value: string; sub: string; positive: boolean; icon: React.ReactNode;
}) {
return (
<motion.div whileHover={{ scale: 1.02 }} className="glass-card p-4">
<div className="flex items-start justify-between">
<p className="text-xs text-zinc-400">{label}</p>
<div className="text-zinc-500">{icon}</div>
</div>
<p className="mt-2 text-xl font-bold text-white">{value}</p>
<p className={`text-xs mt-1 ${positive ? "text-emerald-400" : "text-red-400"}`}>{sub}</p>
</motion.div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function SimulatorPage() {
const [params, setParams] = useState<SimParams>(DEFAULT_PARAMS);
const [activeScenario, setActiveScenario] = useState<string | null>("balanced");
const [startBalance, setStartBalance] = useState(24563);
const [apiScenarios, setApiScenarios] = useState<Record<string, number> | null>(null);
const [loadingApi, setLoadingApi] = useState(false);
const [apiError, setApiError] = useState(false);
// Load real starting balance and API scenarios on mount
useEffect(() => {
const loadApiData = async () => {
setLoadingApi(true);
try {
const [twinData, scenariosData] = await Promise.all([
aiApi.twinPredict(),
aiApi.twinScenarios(6),
]);
// Extract starting balance from twin prediction
const projections = twinData.monthly_projections;
if (projections && projections.length > 0) {
setStartBalance(projections[0].balance || 24563);
}
// Extract scenario end values
if (scenariosData.scenarios) {
const s = scenariosData.scenarios;
const getEnd = (arr: number[]) => arr[arr.length - 1] || 0;
setApiScenarios({
conservative: getEnd(s.conservative || []),
expected: getEnd(s.expected || []),
optimistic: getEnd(s.optimistic || []),
});
}
setApiError(false);
} catch {
setApiError(true);
} finally {
setLoadingApi(false);
}
};
loadApiData();
}, []);
const projection = useMemo(() => projectBalance(params, startBalance), [params, startBalance]);
const baseProjection = useMemo(() => projectBalance(DEFAULT_PARAMS, startBalance), [startBalance]);
const finalBalance = projection[projection.length - 1].balance;
const baseBalance = baseProjection[baseProjection.length - 1].balance;
const delta = finalBalance - baseBalance;
const monthlySavings = params.monthlyIncome * (params.savingsRate / 100);
const emergencyTarget = params.monthlyExpenses * params.emergencyMonths;
const monthsToEmergency = Math.ceil(Math.max(0, emergencyTarget - startBalance) / monthlySavings);
const update = (key: keyof SimParams) => (v: number) => {
setParams((p) => ({ ...p, [key]: v }));
setActiveScenario(null);
};
const applyScenario = (s: typeof scenarios[0]) => {
setParams((p) => ({ ...p, ...s.params }));
setActiveScenario(s.id);
};
const reset = useCallback(() => {
setParams(DEFAULT_PARAMS);
setActiveScenario("balanced");
}, []);
const comparisonData = projection.map((p, i) => ({
...p,
base: baseProjection[i]?.balance,
}));
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
className="flex flex-col gap-6"
>
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-white">What-If Simulator</h1>
<p className="text-zinc-400 mt-1 text-sm">
Model financial scenarios and see your future balance in real-time
{loadingApi && <span className="ml-2 text-zinc-600">· Loading real data...</span>}
</p>
</div>
<button onClick={reset} className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-xs text-zinc-400 hover:text-white transition-colors">
<RotateCcw className="h-3.5 w-3.5" />
Reset
</button>
</div>
{/* API error notice */}
{apiError && (
<div className="flex items-center gap-3 rounded-2xl border border-amber-500/20 bg-amber-500/5 px-5 py-3">
<AlertTriangle className="h-4 w-4 text-amber-400 flex-shrink-0" />
<p className="text-xs text-zinc-400">
<span className="text-amber-400 font-medium">Using local projections</span> — backend offline. Sliders still work with estimated data.
</p>
</div>
)}
{/* API Scenario Cards (from backend) */}
{apiScenarios && (
<div className="glass-card p-4">
<div className="flex items-center gap-2 mb-3">
<Sparkles className="h-4 w-4 text-emerald-400" />
<span className="text-xs font-semibold text-white">AI 6-Month Forecast (from your real data)</span>
</div>
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Conservative", value: apiScenarios.conservative, color: "text-zinc-400" },
{ label: "Expected", value: apiScenarios.expected, color: "text-blue-400" },
{ label: "Optimistic", value: apiScenarios.optimistic, color: "text-emerald-400" },
].map((s) => (
<div key={s.label} className="text-center">
<p className="text-xs text-zinc-500">{s.label}</p>
<p className={`text-lg font-bold mt-1 ${s.color}`}>
${s.value.toLocaleString()}
</p>
</div>
))}
</div>
</div>
)}
{/* Scenario Presets */}
<div className="grid grid-cols-3 gap-3">
{scenarios.map((s) => (
<motion.button
key={s.id}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => applyScenario(s)}
className={`flex items-center gap-3 rounded-2xl border p-4 text-left transition-all ${s.bg} ${
activeScenario === s.id ? "ring-1 ring-white/20" : ""
}`}
>
<span className="text-2xl">{s.icon}</span>
<div>
<p className={`text-sm font-semibold ${s.color}`}>{s.label}</p>
<p className="text-xs text-zinc-500 mt-0.5">Apply preset</p>
</div>
{activeScenario === s.id && <div className="ml-auto h-2 w-2 rounded-full bg-white/60" />}
</motion.button>
))}
</div>
<div className="grid gap-6 lg:grid-cols-5">
{/* Controls */}
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.4 }}
className="col-span-2 glass-card p-6 space-y-6"
>
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-amber-400" />
<h2 className="text-sm font-semibold text-white">Adjust Parameters</h2>
</div>
<Slider label="Monthly Income" value={params.monthlyIncome} min={3000} max={25000} step={100}
format={(v) => `$${v.toLocaleString()}`} onChange={update("monthlyIncome")} color="emerald" />
<Slider label="Monthly Expenses" value={params.monthlyExpenses} min={1500} max={15000} step={100}
format={(v) => `$${v.toLocaleString()}`} onChange={update("monthlyExpenses")} color="amber" />
<Slider label="Savings Rate" value={params.savingsRate} min={5} max={80} step={1}
format={(v) => `${v}%`} onChange={update("savingsRate")} color="blue" />
<Slider label="Investment Return (Annual)" value={params.investmentReturn} min={1} max={20} step={0.5}
format={(v) => `${v}%`} onChange={update("investmentReturn")} color="purple" />
<Slider label="Loan Payment" value={params.loanPayment} min={0} max={3000} step={50}
format={(v) => `$${v.toLocaleString()}`} onChange={update("loanPayment")} color="red" />
<Slider label="Emergency Fund Target (months)" value={params.emergencyMonths} min={1} max={12} step={1}
format={(v) => `${v}mo`} onChange={update("emergencyMonths")} color="emerald" />
</motion.div>
{/* Chart + Metrics */}
<div className="col-span-3 flex flex-col gap-4">
{/* Metrics */}
<div className="grid grid-cols-3 gap-3">
<MetricCard
label="36-Month Balance"
value={`$${(finalBalance / 1000).toFixed(1)}k`}
sub={`${delta >= 0 ? "+" : ""}$${Math.abs(delta / 1000).toFixed(1)}k vs baseline`}
positive={delta >= 0}
icon={<TrendingUp className="h-4 w-4" />}
/>
<MetricCard
label="Monthly Savings"
value={`$${monthlySavings.toFixed(0)}`}
sub={`${params.savingsRate}% of income`}
positive={params.savingsRate >= 20}
icon={<Sparkles className="h-4 w-4" />}
/>
<MetricCard
label="Emergency Fund"
value={monthsToEmergency === 0 ? "Funded ✓" : `${monthsToEmergency}mo`}
sub={monthsToEmergency === 0 ? "Goal achieved" : `$${emergencyTarget.toLocaleString()} target`}
positive={monthsToEmergency === 0}
icon={<TrendingDown className="h-4 w-4" />}
/>
</div>
{/* Projection Chart */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="glass-card p-6 flex-1"
>
<div className="flex items-center justify-between mb-5">
<div>
<h2 className="text-base font-semibold text-white">36-Month Projection</h2>
<p className="text-xs text-zinc-400 mt-0.5">Your scenario vs baseline</p>
</div>
<div className="flex gap-4 text-xs">
<div className="flex items-center gap-1.5">
<div className="h-2 w-2 rounded-full bg-emerald-400" />
<span className="text-zinc-400">Your scenario</span>
</div>
<div className="flex items-center gap-1.5">
<div className="h-2 w-2 rounded-full bg-zinc-600" />
<span className="text-zinc-400">Baseline</span>
</div>
</div>
</div>
<ResponsiveContainer width="100%" height={260}>
<AreaChart data={comparisonData} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="simGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
<linearGradient id="baseGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6b7280" stopOpacity={0.2} />
<stop offset="95%" stopColor="#6b7280" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
<XAxis dataKey="month" tick={{ fill: "#71717a", fontSize: 10 }} axisLine={false} tickLine={false} interval={5} />
<YAxis tick={{ fill: "#71717a", fontSize: 10 }} axisLine={false} tickLine={false}
tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
<Tooltip content={<CustomTooltip />} />
<ReferenceLine y={startBalance} stroke="rgba(255,255,255,0.1)" strokeDasharray="4 4" />
<Area type="monotone" dataKey="base" name="Baseline" stroke="#6b7280" strokeWidth={1.5} fill="url(#baseGrad)" strokeDasharray="4 4" />
<Area type="monotone" dataKey="balance" name="Scenario" stroke="#10b981" strokeWidth={2.5} fill="url(#simGrad)" />
</AreaChart>
</ResponsiveContainer>
</motion.div>
{/* Dynamic AI Insight */}
<AnimatePresence mode="wait">
<motion.div
key={`${params.savingsRate}-${params.investmentReturn}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
className="flex items-start gap-3 rounded-2xl border border-blue-500/20 bg-blue-500/5 p-4"
>
<Sparkles className="h-4 w-4 text-blue-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-white">AI Insight</p>
<p className="text-xs text-zinc-400 mt-1">
{params.savingsRate >= 50
? `At ${params.savingsRate}% savings rate, you'll reach $100k in approximately ${Math.ceil((100000 - startBalance) / (params.monthlyIncome * params.savingsRate / 100))} months. That's an aggressive but achievable goal.`
: params.savingsRate >= 30
? `Your ${params.savingsRate}% savings rate is above average. Increasing by just 5% more would add $${Math.round(params.monthlyIncome * 0.05 * 36).toLocaleString()} to your 3-year balance.`
: `At ${params.savingsRate}% savings rate, consider reducing discretionary spending. Even a 5% increase would significantly accelerate your goals.`}
</p>
</div>
<button className="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300 transition-colors whitespace-nowrap">
Ask AI <ChevronRight className="h-3 w-3" />
</button>
</motion.div>
</AnimatePresence>
</div>
</div>
</motion.div>
);
}