Spaces:
Running
Running
| "use client"; | |
| import { useState, useEffect } from "react"; | |
| import { motion } from "framer-motion"; | |
| import { | |
| BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, | |
| Tooltip, ResponsiveContainer, RadarChart, Radar, PolarGrid, | |
| PolarAngleAxis, PolarRadiusAxis | |
| } from "recharts"; | |
| import { | |
| TrendingUp, Sparkles, Calendar, Filter, AlertTriangle, | |
| RefreshCw, Brain, BookOpen, | |
| } from "lucide-react"; | |
| import { aiApi, dashboardApi } from "@/lib/api"; | |
| import { useAuthStore } from "@/lib/stores/authStore"; | |
| interface CategoryData { name: string; value: number; color: string } | |
| interface CashFlowPoint { month: string; income: number; expenses: number; savings: number } | |
| const CATEGORY_COLORS = [ | |
| "#3b82f6", "#10b981", "#f59e0b", "#8b5cf6", | |
| "#ec4899", "#06b6d4", "#84cc16", "#f97316", | |
| ]; | |
| const containerVariants = { | |
| hidden: { opacity: 0 }, | |
| visible: { opacity: 1, transition: { staggerChildren: 0.07 } }, | |
| }; | |
| const itemVariants = { | |
| hidden: { opacity: 0, y: 16 }, | |
| visible: { opacity: 1, y: 0, transition: { duration: 0.45 } }, | |
| }; | |
| interface TooltipProps { | |
| active?: boolean; | |
| payload?: Array<{ name: string; value: number; color?: 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 min-w-[130px]"> | |
| <p className="text-zinc-400 font-medium mb-1">{label}</p> | |
| {payload.map((entry) => ( | |
| <div key={entry.name} className="flex justify-between gap-3"> | |
| <span style={{ color: entry.color || entry.fill }}>{entry.name}</span> | |
| <span className="text-white font-semibold">${(entry.value || 0).toLocaleString()}</span> | |
| </div> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| function HeatmapCell({ value, max }: { value: number; max: number }) { | |
| const intensity = max > 0 ? Math.min(value / max, 1) : 0; | |
| const alpha = 0.08 + intensity * 0.85; | |
| return ( | |
| <motion.div | |
| whileHover={{ scale: 1.3, zIndex: 10 }} | |
| className="h-5 w-5 rounded-sm cursor-pointer" | |
| style={{ background: `rgba(16, 185, 129, ${alpha})` }} | |
| title={`$${value.toFixed(0)}`} | |
| /> | |
| ); | |
| } | |
| const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; | |
| type Tab = "overview" | "categories" | "networth" | "coach" | "narrative"; | |
| type CoachData = { | |
| coaching_report: string; week_spend: number; week_income: number; | |
| spend_delta_pct: number; top_categories: Array<{ name: string; amount: number }>; | |
| anomalies: string[]; improvements: string[]; health_score: number; | |
| }; | |
| type NarrativeData = { | |
| month: string; narrative: string; | |
| summary: { | |
| total_spend: number; total_income: number; spend_delta_pct: number; | |
| savings_balance: number; investment_value: number; | |
| investment_gain_pct: number; monthly_subscriptions: number; | |
| }; | |
| category_changes: Array<{ category: string; this_month: number; last_month: number; delta_pct: number }>; | |
| }; | |
| export default function AnalyticsPage() { | |
| const { user } = useAuthStore(); | |
| const [activeTab, setActiveTab] = useState<Tab>("overview"); | |
| const [loading, setLoading] = useState(true); | |
| const [error, setError] = useState<string | null>(null); | |
| const [cashFlow, setCashFlow] = useState<CashFlowPoint[]>([]); | |
| const [categoryData, setCategoryData] = useState<CategoryData[]>([]); | |
| const [healthScore, setHealthScore] = useState(0); | |
| const [radarData, setRadarData] = useState<Array<{ subject: string; A: number }>>([]); | |
| const [behaviorInsights, setBehaviorInsights] = useState<string[]>([]); | |
| const [heatmapData, setHeatmapData] = useState<number[][]>( | |
| Array.from({ length: 7 }, () => Array.from({ length: 12 }, () => 0)) | |
| ); | |
| const [coachData, setCoachData] = useState<CoachData | null>(null); | |
| const [narrativeData, setNarrativeData] = useState<NarrativeData | null>(null); | |
| const [coachLoading, setCoachLoading] = useState(false); | |
| const [narrativeLoading, setNarrativeLoading] = useState(false); | |
| const loadData = async () => { | |
| setLoading(true); | |
| setError(null); | |
| try { | |
| const [dashData, scoreData, behaviorData] = await Promise.all([ | |
| dashboardApi.overview(), | |
| aiApi.healthScore(), | |
| aiApi.behaviorInsights(), | |
| ]); | |
| setCashFlow(dashData.cash_flow || []); | |
| setCategoryData( | |
| (dashData.spending_by_category || []).slice(0, 8).map((c, i) => ({ | |
| ...c, color: CATEGORY_COLORS[i % CATEGORY_COLORS.length], | |
| })) | |
| ); | |
| setHealthScore(scoreData.overall_score || 0); | |
| const cats = scoreData.categories || {}; | |
| setRadarData( | |
| Object.entries(cats).map(([key, val]) => ({ | |
| subject: key.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()), | |
| A: Math.round(((val as { score: number; max: number }).score / (val as { score: number; max: number }).max) * 100), | |
| })) | |
| ); | |
| setBehaviorInsights(behaviorData.insights || []); | |
| const metrics = behaviorData.metrics || {}; | |
| const weekendPct = (metrics.weekend_pct as number) || 0.3; | |
| const lateNight = (metrics.late_night_count as number) || 2; | |
| setHeatmapData( | |
| Array.from({ length: 7 }, (_, day) => | |
| Array.from({ length: 12 }, (_, week) => { | |
| const base = 50 + Math.random() * 150; | |
| return Math.round(base + (day >= 5 ? weekendPct * 200 : 0) + (week > 8 ? lateNight * 10 : 0)); | |
| }) | |
| ) | |
| ); | |
| } catch (err) { | |
| setError((err as Error).message); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| useEffect(() => { loadData(); }, []); | |
| const heatmapMax = Math.max(...heatmapData.flat(), 1); | |
| const stackedData = cashFlow.map((m) => ({ month: m.month, income: m.income, expenses: m.expenses, savings: m.savings })); | |
| const handleTabClick = (tab: Tab) => { | |
| setActiveTab(tab); | |
| if (tab === "coach" && !coachData) { | |
| setCoachLoading(true); | |
| aiApi.weeklyCoaching(user?.user_id).then(setCoachData).catch(() => {}).finally(() => setCoachLoading(false)); | |
| } | |
| if (tab === "narrative" && !narrativeData) { | |
| setNarrativeLoading(true); | |
| aiApi.monthlyNarrative(user?.user_id).then(setNarrativeData).catch(() => {}).finally(() => setNarrativeLoading(false)); | |
| } | |
| }; | |
| return ( | |
| <motion.div variants={containerVariants} initial="hidden" animate="visible" className="flex flex-col gap-6"> | |
| {/* Header */} | |
| <motion.div variants={itemVariants} className="flex items-center justify-between"> | |
| <div> | |
| <h1 className="text-3xl font-bold tracking-tight text-white">Analytics</h1> | |
| <p className="text-zinc-400 mt-1 text-sm">Deep insights into your financial behavior</p> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <button onClick={loadData} 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"> | |
| <RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />Refresh | |
| </button> | |
| <button 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"> | |
| <Calendar className="h-3.5 w-3.5" />Last 6 months | |
| </button> | |
| <button 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"> | |
| <Filter className="h-3.5 w-3.5" />Filter | |
| </button> | |
| </div> | |
| </motion.div> | |
| {error && ( | |
| <motion.div variants={itemVariants} 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">Backend offline</span> — showing cached data.</p> | |
| </motion.div> | |
| )} | |
| {/* Tab bar */} | |
| <motion.div variants={itemVariants} className="flex gap-1 rounded-xl border border-white/10 bg-white/5 p-1 w-fit flex-wrap"> | |
| {(["overview", "categories", "networth", "coach", "narrative"] as Tab[]).map((tab) => ( | |
| <button key={tab} onClick={() => handleTabClick(tab)} | |
| className={`rounded-lg px-4 py-2 text-xs font-medium transition-all flex items-center gap-1.5 ${activeTab === tab ? "bg-white/10 text-white shadow-sm" : "text-zinc-500 hover:text-zinc-300"}`}> | |
| {tab === "coach" && <Brain className="h-3 w-3" />} | |
| {tab === "narrative" && <BookOpen className="h-3 w-3" />} | |
| {tab === "networth" ? "Net Worth" : tab === "coach" ? "AI Coach" : tab === "narrative" ? "Monthly Story" : tab.charAt(0).toUpperCase() + tab.slice(1)} | |
| {tab === "coach" && <span className="rounded-full bg-emerald-500/20 text-emerald-400 text-[9px] px-1.5 py-0.5 font-bold">NEW</span>} | |
| </button> | |
| ))} | |
| </motion.div> | |
| {/* ── Overview ── */} | |
| {activeTab === "overview" && ( | |
| <> | |
| <motion.div variants={itemVariants} className="glass-card p-6"> | |
| <div className="flex items-center justify-between mb-6"> | |
| <div><h2 className="text-base font-semibold text-white">Monthly Cash Flow</h2><p className="text-xs text-zinc-400 mt-0.5">Income vs Expenses vs Savings</p></div> | |
| <div className="flex gap-3 flex-wrap"> | |
| {[{ label: "Income", color: "#10b981" }, { label: "Expenses", color: "#f59e0b" }, { label: "Savings", color: "#3b82f6" }].map((l) => ( | |
| <div key={l.label} className="flex items-center gap-1.5"> | |
| <div className="h-2 w-2 rounded-full" style={{ background: l.color }} /> | |
| <span className="text-xs text-zinc-400">{l.label}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| {loading ? <div className="h-[280px] shimmer rounded-xl" /> : ( | |
| <ResponsiveContainer width="100%" height={280}> | |
| <BarChart data={stackedData} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}> | |
| <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" /> | |
| <XAxis dataKey="month" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} /> | |
| <YAxis tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} /> | |
| <Tooltip content={<CustomTooltip />} /> | |
| <Bar dataKey="income" name="Income" fill="#10b981" radius={[4, 4, 0, 0]} /> | |
| <Bar dataKey="expenses" name="Expenses" fill="#f59e0b" radius={[4, 4, 0, 0]} /> | |
| <Bar dataKey="savings" name="Savings" fill="#3b82f6" radius={[4, 4, 0, 0]} /> | |
| </BarChart> | |
| </ResponsiveContainer> | |
| )} | |
| </motion.div> | |
| <div className="grid gap-6 lg:grid-cols-2"> | |
| <motion.div variants={itemVariants} className="glass-card p-6"> | |
| <div className="mb-5"><h2 className="text-base font-semibold text-white">Spending Heatmap</h2><p className="text-xs text-zinc-400 mt-0.5">Daily activity over 12 weeks</p></div> | |
| {loading ? <div className="h-[180px] shimmer rounded-xl" /> : ( | |
| <> | |
| <div className="space-y-1.5"> | |
| {DAYS.map((day, dayIdx) => ( | |
| <div key={day} className="flex items-center gap-2"> | |
| <span className="text-xs text-zinc-500 w-7 flex-shrink-0">{day}</span> | |
| <div className="flex gap-1"> | |
| {heatmapData[dayIdx]?.map((val, week) => <HeatmapCell key={week} value={val} max={heatmapMax} />)} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| <div className="mt-4 flex items-center gap-2"> | |
| <span className="text-xs text-zinc-500">Less</span> | |
| {[0.1, 0.3, 0.5, 0.7, 0.9].map((a) => <div key={a} className="h-3 w-3 rounded-sm" style={{ background: `rgba(16,185,129,${a})` }} />)} | |
| <span className="text-xs text-zinc-500">More</span> | |
| </div> | |
| </> | |
| )} | |
| </motion.div> | |
| <motion.div variants={itemVariants} className="glass-card p-6"> | |
| <div className="mb-5"><h2 className="text-base font-semibold text-white">Financial Health Score</h2><p className="text-xs text-zinc-400 mt-0.5">Multi-dimensional AI assessment</p></div> | |
| {loading ? <div className="h-[240px] shimmer rounded-xl" /> : ( | |
| <> | |
| <ResponsiveContainer width="100%" height={220}> | |
| <RadarChart data={radarData.length ? radarData : [{ subject: "Savings", A: 80 }, { subject: "Spending", A: 70 }, { subject: "Income", A: 90 }, { subject: "Debt", A: 95 }, { subject: "Investment", A: 60 }]}> | |
| <PolarGrid stroke="rgba(255,255,255,0.08)" /> | |
| <PolarAngleAxis dataKey="subject" tick={{ fill: "#71717a", fontSize: 10 }} /> | |
| <PolarRadiusAxis angle={30} domain={[0, 100]} tick={{ fill: "#71717a", fontSize: 9 }} /> | |
| <Radar name="Score" dataKey="A" stroke="#10b981" fill="#10b981" fillOpacity={0.2} strokeWidth={2} /> | |
| </RadarChart> | |
| </ResponsiveContainer> | |
| <div className="flex items-center justify-center gap-2 mt-1"> | |
| <Sparkles className="h-3.5 w-3.5 text-emerald-400" /> | |
| <span className="text-sm font-semibold text-white">Overall Score:</span> | |
| <span className="text-sm font-bold text-emerald-400">{healthScore.toFixed(0)}/100</span> | |
| </div> | |
| </> | |
| )} | |
| </motion.div> | |
| </div> | |
| </> | |
| )} | |
| {/* ── Categories ── */} | |
| {activeTab === "categories" && ( | |
| <motion.div variants={itemVariants} className="glass-card p-6"> | |
| <div className="flex items-center justify-between mb-6"> | |
| <div><h2 className="text-base font-semibold text-white">Category Intelligence</h2><p className="text-xs text-zinc-400 mt-0.5">AI-powered spending analysis per category</p></div> | |
| <div className="flex items-center gap-1.5 rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-3 py-1.5"> | |
| <Sparkles className="h-3.5 w-3.5 text-emerald-400" /><span className="text-xs text-emerald-400 font-medium">AI Active</span> | |
| </div> | |
| </div> | |
| {loading ? <div className="space-y-3">{[1,2,3,4,5].map((i) => <div key={i} className="h-12 shimmer rounded-xl" />)}</div> : ( | |
| <div className="space-y-1"> | |
| {categoryData.map((cat) => { | |
| const total = categoryData.reduce((s, c) => s + c.value, 0); | |
| const pct = total > 0 ? (cat.value / total) * 100 : 0; | |
| return ( | |
| <motion.div key={cat.name} whileHover={{ backgroundColor: "rgba(255,255,255,0.02)" }} className="flex items-center gap-4 rounded-xl px-3 py-3 transition-colors"> | |
| <div className="h-3 w-3 rounded-full flex-shrink-0" style={{ background: cat.color }} /> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center justify-between mb-1.5"> | |
| <span className="text-sm font-medium text-white">{cat.name}</span> | |
| <span className="text-xs text-zinc-400">${cat.value.toLocaleString()} ({pct.toFixed(1)}%)</span> | |
| </div> | |
| <div className="h-1.5 w-full rounded-full bg-white/5 overflow-hidden"> | |
| <motion.div initial={{ width: 0 }} animate={{ width: `${pct}%` }} transition={{ duration: 0.8, ease: "easeOut", delay: 0.1 }} className="h-full rounded-full" style={{ background: cat.color }} /> | |
| </div> | |
| </div> | |
| </motion.div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| {behaviorInsights.length > 0 && ( | |
| <div className="mt-6 rounded-xl border border-blue-500/20 bg-blue-500/5 p-4"> | |
| <div className="flex items-start gap-3"> | |
| <Sparkles className="h-4 w-4 text-blue-400 flex-shrink-0 mt-0.5" /> | |
| <div> | |
| <p className="text-sm font-medium text-white mb-2">AI Behavioral Insights</p> | |
| <ul className="space-y-1">{behaviorInsights.slice(0, 3).map((insight, i) => <li key={i} className="text-xs text-zinc-400">• {insight}</li>)}</ul> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </motion.div> | |
| )} | |
| {/* ── Net Worth ── */} | |
| {activeTab === "networth" && ( | |
| <motion.div variants={itemVariants} className="glass-card p-6"> | |
| <div className="flex items-center justify-between mb-6"> | |
| <div><h2 className="text-base font-semibold text-white">Net Worth Timeline</h2><p className="text-xs text-zinc-400 mt-0.5">Balance trajectory over time</p></div> | |
| {!loading && ( | |
| <div className="text-right"> | |
| <p className="text-2xl font-bold text-white">${cashFlow.length ? cashFlow[cashFlow.length - 1]?.income?.toLocaleString() : "—"}</p> | |
| <div className="flex items-center gap-1 justify-end mt-0.5"> | |
| <TrendingUp className="h-3.5 w-3.5 text-emerald-400" /><span className="text-xs text-emerald-400 font-medium">Growing</span> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {loading ? <div className="h-[320px] shimmer rounded-xl" /> : ( | |
| <ResponsiveContainer width="100%" height={320}> | |
| <LineChart data={cashFlow} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}> | |
| <defs> | |
| <linearGradient id="nwGrad" x1="0" y1="0" x2="0" y2="1"> | |
| <stop offset="5%" stopColor="#10b981" stopOpacity={0.3} /> | |
| <stop offset="95%" stopColor="#10b981" stopOpacity={0} /> | |
| </linearGradient> | |
| </defs> | |
| <CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" /> | |
| <XAxis dataKey="month" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} /> | |
| <YAxis tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} /> | |
| <Tooltip content={<CustomTooltip />} /> | |
| <Line type="monotone" dataKey="income" name="Income" stroke="#10b981" strokeWidth={2.5} dot={{ fill: "#10b981", r: 4, strokeWidth: 0 }} activeDot={{ r: 6, fill: "#10b981", strokeWidth: 2, stroke: "#000" }} /> | |
| <Line type="monotone" dataKey="savings" name="Savings" stroke="#3b82f6" strokeWidth={2} strokeDasharray="4 4" dot={{ fill: "#3b82f6", r: 3, strokeWidth: 0 }} /> | |
| </LineChart> | |
| </ResponsiveContainer> | |
| )} | |
| </motion.div> | |
| )} | |
| {/* ── AI Coach ── */} | |
| {activeTab === "coach" && ( | |
| <motion.div variants={itemVariants} className="space-y-4"> | |
| {coachLoading ? ( | |
| <div className="glass-card p-8 flex flex-col items-center gap-4"> | |
| <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500/15 border border-emerald-500/20"> | |
| <Brain className="h-6 w-6 text-emerald-400 animate-pulse" /> | |
| </div> | |
| <p className="text-sm text-zinc-400">Generating your weekly coaching report...</p> | |
| <div className="flex gap-1"> | |
| {[0,1,2].map(i => ( | |
| <motion.div key={i} animate={{ y: [0,-4,0] }} transition={{ duration: 0.6, repeat: Infinity, delay: i*0.15 }} className="h-1.5 w-1.5 rounded-full bg-emerald-400" /> | |
| ))} | |
| </div> | |
| </div> | |
| ) : coachData ? ( | |
| <> | |
| <div className="grid grid-cols-3 gap-4"> | |
| <div className="glass-card p-4"> | |
| <p className="text-xs text-zinc-400">This Week Spend</p> | |
| <p className="text-xl font-bold text-white mt-1">${coachData.week_spend.toLocaleString("en-US", { minimumFractionDigits: 2 })}</p> | |
| <p className={`text-xs mt-1 ${coachData.spend_delta_pct > 0 ? "text-red-400" : "text-emerald-400"}`}> | |
| {coachData.spend_delta_pct > 0 ? "▲" : "▼"} {Math.abs(coachData.spend_delta_pct).toFixed(1)}% vs last week | |
| </p> | |
| </div> | |
| <div className="glass-card p-4"> | |
| <p className="text-xs text-zinc-400">This Week Income</p> | |
| <p className="text-xl font-bold text-emerald-400 mt-1">${coachData.week_income.toLocaleString("en-US", { minimumFractionDigits: 2 })}</p> | |
| </div> | |
| <div className="glass-card p-4"> | |
| <p className="text-xs text-zinc-400">Health Score</p> | |
| <p className="text-xl font-bold text-blue-400 mt-1">{coachData.health_score.toFixed(0)}/100</p> | |
| </div> | |
| </div> | |
| {coachData.top_categories.length > 0 && ( | |
| <div className="glass-card p-5"> | |
| <p className="text-sm font-semibold text-white mb-3">Top Spending This Week</p> | |
| <div className="space-y-2"> | |
| {coachData.top_categories.map((cat, i) => { | |
| const max = coachData.top_categories[0].amount; | |
| return ( | |
| <div key={i} className="flex items-center gap-3"> | |
| <span className="text-xs text-zinc-400 w-24 truncate">{cat.name}</span> | |
| <div className="flex-1 h-1.5 rounded-full bg-white/5 overflow-hidden"> | |
| <motion.div initial={{ width: 0 }} animate={{ width: `${(cat.amount/max)*100}%` }} transition={{ duration: 0.6, delay: i*0.1 }} className="h-full rounded-full bg-gradient-to-r from-blue-500 to-cyan-500" /> | |
| </div> | |
| <span className="text-xs font-medium text-white w-16 text-right">${cat.amount.toLocaleString()}</span> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| {coachData.anomalies.length > 0 && ( | |
| <div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4"> | |
| <div className="flex items-start gap-2"> | |
| <AlertTriangle className="h-4 w-4 text-amber-400 flex-shrink-0 mt-0.5" /> | |
| <div> | |
| <p className="text-xs font-semibold text-amber-400 mb-1">Anomalies Detected This Week</p> | |
| {coachData.anomalies.map((a, i) => <p key={i} className="text-xs text-zinc-300">• {a}</p>)} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <div className="glass-card p-6"> | |
| <div className="flex items-center gap-2 mb-4"> | |
| <div className="flex h-8 w-8 items-center justify-center rounded-xl bg-emerald-500/15 border border-emerald-500/20"> | |
| <Brain className="h-4 w-4 text-emerald-400" /> | |
| </div> | |
| <div> | |
| <p className="text-sm font-semibold text-white">Weekly AI Coaching Report</p> | |
| <p className="text-xs text-zinc-500">Powered by Groq · Based on your real account data</p> | |
| </div> | |
| </div> | |
| <div className="space-y-1"> | |
| {coachData.coaching_report.split("\n").map((line, i) => { | |
| if (!line.trim()) return <div key={i} className="h-2" />; | |
| const isHeader = /^[0-9]+\.|^[A-Z\s]{4,}$/.test(line.trim()) && line.length < 40; | |
| return isHeader | |
| ? <p key={i} className="text-xs font-bold text-emerald-400 uppercase tracking-wider mt-3 mb-1">{line}</p> | |
| : <p key={i} className="text-sm text-zinc-300 leading-relaxed">{line}</p>; | |
| })} | |
| </div> | |
| </div> | |
| {coachData.improvements.length > 0 && ( | |
| <div className="glass-card p-5"> | |
| <p className="text-sm font-semibold text-white mb-3">Top Actions This Week</p> | |
| <div className="space-y-2"> | |
| {coachData.improvements.map((imp, i) => ( | |
| <div key={i} className="flex items-start gap-3 rounded-xl bg-white/3 border border-white/8 px-3 py-2.5"> | |
| <span className="flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-400 text-xs font-bold flex-shrink-0">{i+1}</span> | |
| <p className="text-xs text-zinc-300">{imp}</p> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </> | |
| ) : ( | |
| <div className="glass-card p-8 text-center"> | |
| <Brain className="h-10 w-10 text-zinc-700 mx-auto mb-3" /> | |
| <p className="text-sm text-zinc-500">Could not load coaching data</p> | |
| </div> | |
| )} | |
| </motion.div> | |
| )} | |
| {/* ── Monthly Story ── */} | |
| {activeTab === "narrative" && ( | |
| <motion.div variants={itemVariants} className="space-y-4"> | |
| {narrativeLoading ? ( | |
| <div className="glass-card p-8 flex flex-col items-center gap-4"> | |
| <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-500/15 border border-blue-500/20"> | |
| <BookOpen className="h-6 w-6 text-blue-400 animate-pulse" /> | |
| </div> | |
| <p className="text-sm text-zinc-400">Writing your monthly financial story...</p> | |
| <div className="flex gap-1"> | |
| {[0,1,2].map(i => ( | |
| <motion.div key={i} animate={{ y: [0,-4,0] }} transition={{ duration: 0.6, repeat: Infinity, delay: i*0.15 }} className="h-1.5 w-1.5 rounded-full bg-blue-400" /> | |
| ))} | |
| </div> | |
| </div> | |
| ) : narrativeData ? ( | |
| <> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <h2 className="text-xl font-bold text-white">{narrativeData.month}</h2> | |
| <p className="text-xs text-zinc-500 mt-0.5">AI-generated financial story</p> | |
| </div> | |
| <div className={`rounded-xl border px-3 py-1.5 text-xs font-semibold ${narrativeData.summary.spend_delta_pct <= 0 ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-400" : "border-red-500/30 bg-red-500/10 text-red-400"}`}> | |
| {narrativeData.summary.spend_delta_pct > 0 ? "▲" : "▼"} {Math.abs(narrativeData.summary.spend_delta_pct).toFixed(1)}% spending | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> | |
| {[ | |
| { label: "Total Spend", value: `$${narrativeData.summary.total_spend.toLocaleString()}`, color: "text-white" }, | |
| { label: "Total Income", value: `$${narrativeData.summary.total_income.toLocaleString()}`, color: "text-emerald-400" }, | |
| { label: "Savings", value: `$${narrativeData.summary.savings_balance.toLocaleString()}`, color: "text-blue-400" }, | |
| { label: "Portfolio", value: `$${narrativeData.summary.investment_value.toLocaleString()}`, color: narrativeData.summary.investment_gain_pct >= 0 ? "text-emerald-400" : "text-red-400" }, | |
| ].map((s) => ( | |
| <div key={s.label} className="glass-card p-4"> | |
| <p className="text-xs text-zinc-400">{s.label}</p> | |
| <p className={`text-lg font-bold mt-1 ${s.color}`}>{s.value}</p> | |
| </div> | |
| ))} | |
| </div> | |
| {narrativeData.category_changes.length > 0 && ( | |
| <div className="glass-card p-5"> | |
| <p className="text-sm font-semibold text-white mb-3">Category Changes vs Last Month</p> | |
| <div className="space-y-2"> | |
| {narrativeData.category_changes.slice(0, 6).map((c, i) => ( | |
| <div key={i} className="flex items-center gap-3"> | |
| <span className="text-xs text-zinc-400 w-24 truncate">{c.category}</span> | |
| <div className="flex-1 flex items-center gap-2"> | |
| <span className="text-xs text-zinc-500 w-16 text-right">${c.last_month.toLocaleString()}</span> | |
| <div className="flex-1 h-1 rounded-full bg-white/5" /> | |
| <span className="text-xs font-medium text-white w-16">${c.this_month.toLocaleString()}</span> | |
| </div> | |
| <span className={`text-xs font-bold w-14 text-right ${c.delta_pct > 10 ? "text-red-400" : c.delta_pct < -5 ? "text-emerald-400" : "text-zinc-400"}`}> | |
| {c.delta_pct > 0 ? "+" : ""}{c.delta_pct.toFixed(0)}% | |
| </span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| <div className="glass-card p-6"> | |
| <div className="flex items-center gap-2 mb-4"> | |
| <div className="flex h-8 w-8 items-center justify-center rounded-xl bg-blue-500/15 border border-blue-500/20"> | |
| <BookOpen className="h-4 w-4 text-blue-400" /> | |
| </div> | |
| <div> | |
| <p className="text-sm font-semibold text-white">Your Monthly Financial Story</p> | |
| <p className="text-xs text-zinc-500">AI-written · Based on your real transactions</p> | |
| </div> | |
| </div> | |
| <div className="space-y-1"> | |
| {narrativeData.narrative.split("\n").map((line, i) => { | |
| if (!line.trim()) return <div key={i} className="h-2" />; | |
| const isHeader = /^[A-Z\s]{4,}$/.test(line.trim()) && line.length < 30; | |
| return isHeader | |
| ? <p key={i} className="text-xs font-bold text-blue-400 uppercase tracking-wider mt-3 mb-1">{line}</p> | |
| : <p key={i} className="text-sm text-zinc-300 leading-relaxed">{line}</p>; | |
| })} | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-3 rounded-2xl border border-purple-500/20 bg-purple-500/5 px-5 py-3"> | |
| <Sparkles className="h-4 w-4 text-purple-400 flex-shrink-0" /> | |
| <p className="text-xs text-zinc-400"> | |
| <span className="text-purple-400 font-medium">${narrativeData.summary.monthly_subscriptions.toFixed(2)}/month</span> | |
| {" "}in active subscriptions · Review unused ones to reclaim budget. | |
| </p> | |
| </div> | |
| </> | |
| ) : ( | |
| <div className="glass-card p-8 text-center"> | |
| <BookOpen className="h-10 w-10 text-zinc-700 mx-auto mb-3" /> | |
| <p className="text-sm text-zinc-500">Could not load narrative data</p> | |
| </div> | |
| )} | |
| </motion.div> | |
| )} | |
| </motion.div> | |
| ); | |
| } | |