Spaces:
Running
Running
| "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> | |
| ); | |
| } | |