Spaces:
Sleeping
Sleeping
Commit ·
c004697
1
Parent(s): 465a24c
feat: AI Coach Mode, Fraud Explanations, Monthly Narrative — 3 new intelligence features
Browse files- backend/app/ai/intelligence.py +482 -0
- backend/app/ai/router.py +48 -0
- frontend/src/app/analytics/page.tsx +296 -161
- frontend/src/app/chat/page.tsx +4 -4
- frontend/src/app/security/page.tsx +407 -1
- frontend/src/lib/api.ts +58 -0
backend/app/ai/intelligence.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AI Intelligence Engine — three high-value features:
|
| 3 |
+
1. Financial Coach Mode — proactive weekly recommendations, nudges, anomaly explanations
|
| 4 |
+
2. Fraud Explanation — human-readable AI explanation of why a transaction was flagged
|
| 5 |
+
3. Spending Narrative — monthly story: what changed, what improved, what to watch
|
| 6 |
+
"""
|
| 7 |
+
import os
|
| 8 |
+
from datetime import datetime, timedelta
|
| 9 |
+
from collections import defaultdict
|
| 10 |
+
from sqlalchemy.orm import Session
|
| 11 |
+
|
| 12 |
+
from app.database.models import (
|
| 13 |
+
User, Account, Transaction, Goal, Investment,
|
| 14 |
+
Subscription, FraudLog, Notification
|
| 15 |
+
)
|
| 16 |
+
from app.ai.coaching import calculate_financial_health_score
|
| 17 |
+
from app.ai.forecasting import get_cashflow_metrics
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ─── Shared Groq/OpenAI caller ────────────────────────────────────────────────
|
| 21 |
+
|
| 22 |
+
def _call_llm(messages: list, max_tokens: int = 500) -> str | None:
|
| 23 |
+
"""
|
| 24 |
+
Calls OpenAI → Groq in priority order with the exact messages list provided.
|
| 25 |
+
Returns None if both fail.
|
| 26 |
+
"""
|
| 27 |
+
openai_key = os.environ.get("OPENAI_API_KEY", "")
|
| 28 |
+
groq_key = os.environ.get("GROQ_API_KEY", "") or os.environ.get("GROQ_KEY", "")
|
| 29 |
+
|
| 30 |
+
if openai_key:
|
| 31 |
+
try:
|
| 32 |
+
from openai import OpenAI
|
| 33 |
+
client = OpenAI(api_key=openai_key)
|
| 34 |
+
res = client.chat.completions.create(
|
| 35 |
+
model="gpt-4o-mini",
|
| 36 |
+
messages=messages,
|
| 37 |
+
temperature=0.2,
|
| 38 |
+
max_tokens=max_tokens,
|
| 39 |
+
)
|
| 40 |
+
return res.choices[0].message.content.strip()
|
| 41 |
+
except Exception as e:
|
| 42 |
+
print(f"[intelligence] OpenAI error: {e}")
|
| 43 |
+
|
| 44 |
+
if groq_key:
|
| 45 |
+
try:
|
| 46 |
+
from groq import Groq
|
| 47 |
+
client = Groq(api_key=groq_key)
|
| 48 |
+
res = client.chat.completions.create(
|
| 49 |
+
model="llama-3.3-70b-versatile",
|
| 50 |
+
messages=messages,
|
| 51 |
+
temperature=0.2,
|
| 52 |
+
max_tokens=max_tokens,
|
| 53 |
+
)
|
| 54 |
+
return res.choices[0].message.content.strip()
|
| 55 |
+
except Exception as e:
|
| 56 |
+
print(f"[intelligence] Groq error: {e}")
|
| 57 |
+
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ─── 1. FINANCIAL COACH MODE ──────────────────────────────────────────────────
|
| 62 |
+
|
| 63 |
+
def generate_weekly_coaching(db: Session, user_id: str) -> dict:
|
| 64 |
+
"""
|
| 65 |
+
Generates a proactive weekly coaching report with:
|
| 66 |
+
- Weekly summary (what happened this week)
|
| 67 |
+
- Budget coaching (where money went vs targets)
|
| 68 |
+
- Savings nudge (specific, actionable)
|
| 69 |
+
- Anomaly explanations (unusual patterns this week)
|
| 70 |
+
- Top 3 recommendations
|
| 71 |
+
"""
|
| 72 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 73 |
+
if not user:
|
| 74 |
+
return {"error": "User not found"}
|
| 75 |
+
|
| 76 |
+
# ── Gather this week's data ───────────────────────────────────────────────
|
| 77 |
+
now = datetime.utcnow()
|
| 78 |
+
week_start = now - timedelta(days=7)
|
| 79 |
+
|
| 80 |
+
accounts = db.query(Account).filter(Account.user_id == user_id).all()
|
| 81 |
+
account_ids = [a.id for a in accounts]
|
| 82 |
+
total_balance = sum(a.balance for a in accounts)
|
| 83 |
+
savings_balance = sum(a.balance for a in accounts if a.type == "savings")
|
| 84 |
+
|
| 85 |
+
week_txns = (
|
| 86 |
+
db.query(Transaction)
|
| 87 |
+
.filter(
|
| 88 |
+
Transaction.account_id.in_(account_ids),
|
| 89 |
+
Transaction.timestamp >= week_start,
|
| 90 |
+
)
|
| 91 |
+
.all()
|
| 92 |
+
) if account_ids else []
|
| 93 |
+
|
| 94 |
+
week_spend = sum(t.amount for t in week_txns if t.type == "debit")
|
| 95 |
+
week_income = sum(t.amount for t in week_txns if t.type == "credit")
|
| 96 |
+
|
| 97 |
+
# Category breakdown this week
|
| 98 |
+
cat_spend: dict = defaultdict(float)
|
| 99 |
+
for t in week_txns:
|
| 100 |
+
if t.type == "debit":
|
| 101 |
+
cat_spend[t.category or "Other"] += t.amount
|
| 102 |
+
top_cats = sorted(cat_spend.items(), key=lambda x: x[1], reverse=True)[:5]
|
| 103 |
+
|
| 104 |
+
# Compare to prior week
|
| 105 |
+
prior_start = week_start - timedelta(days=7)
|
| 106 |
+
prior_txns = (
|
| 107 |
+
db.query(Transaction)
|
| 108 |
+
.filter(
|
| 109 |
+
Transaction.account_id.in_(account_ids),
|
| 110 |
+
Transaction.timestamp >= prior_start,
|
| 111 |
+
Transaction.timestamp < week_start,
|
| 112 |
+
)
|
| 113 |
+
.all()
|
| 114 |
+
) if account_ids else []
|
| 115 |
+
prior_spend = sum(t.amount for t in prior_txns if t.type == "debit")
|
| 116 |
+
spend_delta_pct = ((week_spend - prior_spend) / max(prior_spend, 1)) * 100
|
| 117 |
+
|
| 118 |
+
# Goals progress
|
| 119 |
+
goals = db.query(Goal).filter(Goal.user_id == user_id).all()
|
| 120 |
+
goals_summary = [
|
| 121 |
+
f"{g.title}: ${g.current_amount:,.0f}/${g.target_amount:,.0f} "
|
| 122 |
+
f"({g.current_amount/max(g.target_amount,1)*100:.0f}%)"
|
| 123 |
+
for g in goals
|
| 124 |
+
]
|
| 125 |
+
|
| 126 |
+
# Health score
|
| 127 |
+
score_data = calculate_financial_health_score(db, user_id)
|
| 128 |
+
health_score = score_data.get("overall_score", 50)
|
| 129 |
+
improvements = score_data.get("actionable_improvements", [])
|
| 130 |
+
|
| 131 |
+
# Anomalies this week
|
| 132 |
+
anomalies = []
|
| 133 |
+
if week_txns:
|
| 134 |
+
all_amounts = [t.amount for t in week_txns if t.type == "debit"]
|
| 135 |
+
if all_amounts:
|
| 136 |
+
import numpy as np
|
| 137 |
+
avg = float(np.mean(all_amounts))
|
| 138 |
+
for t in week_txns:
|
| 139 |
+
if t.type == "debit" and t.amount > avg * 3:
|
| 140 |
+
anomalies.append(
|
| 141 |
+
f"${t.amount:,.2f} at {t.merchant or 'Unknown'} "
|
| 142 |
+
f"({(t.amount/avg):.1f}× your weekly average)"
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# Late-night count
|
| 146 |
+
late_night = [
|
| 147 |
+
t for t in week_txns
|
| 148 |
+
if t.type == "debit" and (t.timestamp.hour >= 23 or t.timestamp.hour < 4)
|
| 149 |
+
]
|
| 150 |
+
|
| 151 |
+
# ── Build LLM prompt ──────────────────────────────────────────────────────
|
| 152 |
+
system = (
|
| 153 |
+
"You are a personal AI financial coach. You have access to the user's real financial data. "
|
| 154 |
+
"Be specific, use exact numbers, and give actionable advice. "
|
| 155 |
+
"Never be generic. Format your response with clear sections."
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
user_prompt = f"""Generate a weekly financial coaching report for {user.profile_data.get('name', 'the user')}.
|
| 159 |
+
|
| 160 |
+
FINANCIAL DATA:
|
| 161 |
+
- Total Balance: ${total_balance:,.2f} (Savings: ${savings_balance:,.2f})
|
| 162 |
+
- Health Score: {health_score:.0f}/100
|
| 163 |
+
- This Week Spending: ${week_spend:,.2f} ({spend_delta_pct:+.1f}% vs last week)
|
| 164 |
+
- This Week Income: ${week_income:,.2f}
|
| 165 |
+
- Top Spending Categories: {', '.join(f'{c}: ${a:,.0f}' for c, a in top_cats)}
|
| 166 |
+
- Goals: {'; '.join(goals_summary) if goals_summary else 'None set'}
|
| 167 |
+
- Anomalies: {'; '.join(anomalies) if anomalies else 'None detected'}
|
| 168 |
+
- Late-night transactions: {len(late_night)}
|
| 169 |
+
- Key improvements needed: {'; '.join(improvements[:2]) if improvements else 'None'}
|
| 170 |
+
|
| 171 |
+
Write a coaching report with these exact sections:
|
| 172 |
+
1. WEEKLY SUMMARY (2 sentences, use exact dollar figures)
|
| 173 |
+
2. BUDGET COACHING (what's on track, what's overspent — use real category names and amounts)
|
| 174 |
+
3. SAVINGS NUDGE (one specific, actionable savings recommendation with a dollar target)
|
| 175 |
+
4. ANOMALY ALERT (explain any unusual spending in plain English, or say "No anomalies this week")
|
| 176 |
+
5. TOP 3 ACTIONS (numbered list, each under 15 words, specific and actionable)
|
| 177 |
+
|
| 178 |
+
Be direct. No filler phrases."""
|
| 179 |
+
|
| 180 |
+
messages = [
|
| 181 |
+
{"role": "system", "content": system},
|
| 182 |
+
{"role": "user", "content": user_prompt},
|
| 183 |
+
]
|
| 184 |
+
|
| 185 |
+
coaching_text = _call_llm(messages, max_tokens=600)
|
| 186 |
+
|
| 187 |
+
if not coaching_text:
|
| 188 |
+
# Rule-based fallback
|
| 189 |
+
direction = "up" if spend_delta_pct > 0 else "down"
|
| 190 |
+
coaching_text = (
|
| 191 |
+
f"WEEKLY SUMMARY\nYou spent ${week_spend:,.2f} this week, "
|
| 192 |
+
f"{abs(spend_delta_pct):.0f}% {direction} from last week. "
|
| 193 |
+
f"Your balance stands at ${total_balance:,.2f}.\n\n"
|
| 194 |
+
f"BUDGET COACHING\nTop category: {top_cats[0][0] if top_cats else 'N/A'} "
|
| 195 |
+
f"(${top_cats[0][1]:,.2f} if top_cats else '$0').\n\n"
|
| 196 |
+
f"SAVINGS NUDGE\nTransfer ${min(week_income * 0.1, 200):,.0f} to savings this week.\n\n"
|
| 197 |
+
f"ANOMALY ALERT\n{'Unusual: ' + anomalies[0] if anomalies else 'No anomalies this week.'}\n\n"
|
| 198 |
+
f"TOP 3 ACTIONS\n"
|
| 199 |
+
+ "\n".join(f"{i+1}. {imp}" for i, imp in enumerate(improvements[:3]))
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
return {
|
| 203 |
+
"generated_at": now.isoformat(),
|
| 204 |
+
"week_start": week_start.date().isoformat(),
|
| 205 |
+
"week_end": now.date().isoformat(),
|
| 206 |
+
"health_score": health_score,
|
| 207 |
+
"week_spend": round(week_spend, 2),
|
| 208 |
+
"week_income": round(week_income, 2),
|
| 209 |
+
"spend_delta_pct": round(spend_delta_pct, 1),
|
| 210 |
+
"top_categories": [{"name": c, "amount": round(a, 2)} for c, a in top_cats],
|
| 211 |
+
"anomalies": anomalies,
|
| 212 |
+
"coaching_report": coaching_text,
|
| 213 |
+
"improvements": improvements[:3],
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# ─── 2. AI FRAUD EXPLANATION ──────────────────────────────────────────────────
|
| 218 |
+
|
| 219 |
+
def explain_fraud_alert(db: Session, fraud_log_id: str) -> dict:
|
| 220 |
+
"""
|
| 221 |
+
Generates a human-readable AI explanation of exactly why a transaction was flagged,
|
| 222 |
+
with context about the user's normal patterns.
|
| 223 |
+
"""
|
| 224 |
+
fraud_log = db.query(FraudLog).filter(FraudLog.id == fraud_log_id).first()
|
| 225 |
+
if not fraud_log:
|
| 226 |
+
return {"error": "Fraud log not found"}
|
| 227 |
+
|
| 228 |
+
txn = fraud_log.transaction
|
| 229 |
+
if not txn:
|
| 230 |
+
return {"error": "Transaction not found"}
|
| 231 |
+
|
| 232 |
+
account = db.query(Account).filter(Account.id == txn.account_id).first()
|
| 233 |
+
if not account:
|
| 234 |
+
return {"error": "Account not found"}
|
| 235 |
+
|
| 236 |
+
user_id = account.user_id
|
| 237 |
+
|
| 238 |
+
# Get user's historical baseline
|
| 239 |
+
history = (
|
| 240 |
+
db.query(Transaction)
|
| 241 |
+
.filter(
|
| 242 |
+
Transaction.account_id == txn.account_id,
|
| 243 |
+
Transaction.type == "debit",
|
| 244 |
+
Transaction.id != txn.id,
|
| 245 |
+
)
|
| 246 |
+
.order_by(Transaction.timestamp.desc())
|
| 247 |
+
.limit(30)
|
| 248 |
+
.all()
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
import numpy as np
|
| 252 |
+
avg_amount = float(np.mean([t.amount for t in history])) if history else 0
|
| 253 |
+
typical_hour = "daytime"
|
| 254 |
+
if txn.timestamp.hour >= 23 or txn.timestamp.hour < 4:
|
| 255 |
+
typical_hour = f"{txn.timestamp.strftime('%I:%M %p')} (late night)"
|
| 256 |
+
else:
|
| 257 |
+
typical_hour = txn.timestamp.strftime("%I:%M %p")
|
| 258 |
+
|
| 259 |
+
risk_pct = round(fraud_log.risk_score * 100)
|
| 260 |
+
raw_reasons = fraud_log.suspicious_activity_details or ""
|
| 261 |
+
|
| 262 |
+
system = (
|
| 263 |
+
"You are a fraud analyst AI. Explain in plain English why this transaction was flagged. "
|
| 264 |
+
"Be specific, cite exact numbers, and help the user understand the risk. "
|
| 265 |
+
"Write 2-3 sentences maximum. Do not use bullet points."
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
user_prompt = f"""Explain why this transaction was flagged as suspicious:
|
| 269 |
+
|
| 270 |
+
Transaction: ${txn.amount:,.2f} at {txn.merchant or 'Unknown merchant'}
|
| 271 |
+
Time: {typical_hour}
|
| 272 |
+
Risk Score: {risk_pct}/100
|
| 273 |
+
Detection reasons: {raw_reasons}
|
| 274 |
+
User's average transaction: ${avg_amount:,.2f}
|
| 275 |
+
Amount vs average: {txn.amount/max(avg_amount,1):.1f}×
|
| 276 |
+
|
| 277 |
+
Write a clear, human-readable explanation of why this was flagged.
|
| 278 |
+
Start with "This payment was flagged because..." and be specific."""
|
| 279 |
+
|
| 280 |
+
messages = [
|
| 281 |
+
{"role": "system", "content": system},
|
| 282 |
+
{"role": "user", "content": user_prompt},
|
| 283 |
+
]
|
| 284 |
+
|
| 285 |
+
explanation = _call_llm(messages, max_tokens=150)
|
| 286 |
+
|
| 287 |
+
if not explanation:
|
| 288 |
+
# Rule-based fallback
|
| 289 |
+
parts = []
|
| 290 |
+
if txn.amount > avg_amount * 2:
|
| 291 |
+
parts.append(
|
| 292 |
+
f"the amount (${txn.amount:,.2f}) is {txn.amount/max(avg_amount,1):.1f}× "
|
| 293 |
+
f"your typical transaction of ${avg_amount:,.2f}"
|
| 294 |
+
)
|
| 295 |
+
if txn.timestamp.hour >= 23 or txn.timestamp.hour < 4:
|
| 296 |
+
parts.append(f"it occurred at {txn.timestamp.strftime('%I:%M %p')} (unusual hours)")
|
| 297 |
+
if not parts:
|
| 298 |
+
parts = [raw_reasons or "multiple anomaly signals were detected simultaneously"]
|
| 299 |
+
explanation = f"This payment was flagged because {' and '.join(parts)}."
|
| 300 |
+
|
| 301 |
+
return {
|
| 302 |
+
"fraud_log_id": fraud_log_id,
|
| 303 |
+
"transaction_id": txn.id,
|
| 304 |
+
"merchant": txn.merchant,
|
| 305 |
+
"amount": txn.amount,
|
| 306 |
+
"timestamp": txn.timestamp.isoformat(),
|
| 307 |
+
"risk_score": risk_pct,
|
| 308 |
+
"raw_reasons": raw_reasons.split("; ") if raw_reasons else [],
|
| 309 |
+
"ai_explanation": explanation,
|
| 310 |
+
"user_avg_amount": round(avg_amount, 2),
|
| 311 |
+
"amount_vs_avg": round(txn.amount / max(avg_amount, 1), 1),
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
# ─── 3. SPENDING NARRATIVE ────────────────────────────────────────────────────
|
| 316 |
+
|
| 317 |
+
def generate_spending_narrative(db: Session, user_id: str) -> dict:
|
| 318 |
+
"""
|
| 319 |
+
Generates a monthly spending narrative:
|
| 320 |
+
- What changed vs last month (category-level)
|
| 321 |
+
- Investment performance insight
|
| 322 |
+
- Savings performance
|
| 323 |
+
- Subscription waste analysis
|
| 324 |
+
- One-paragraph human story of the month
|
| 325 |
+
"""
|
| 326 |
+
user = db.query(User).filter(User.id == user_id).first()
|
| 327 |
+
if not user:
|
| 328 |
+
return {"error": "User not found"}
|
| 329 |
+
|
| 330 |
+
now = datetime.utcnow()
|
| 331 |
+
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
| 332 |
+
last_month_start = (month_start - timedelta(days=1)).replace(day=1)
|
| 333 |
+
|
| 334 |
+
accounts = db.query(Account).filter(Account.user_id == user_id).all()
|
| 335 |
+
account_ids = [a.id for a in accounts]
|
| 336 |
+
|
| 337 |
+
def get_category_spend(start, end):
|
| 338 |
+
txns = (
|
| 339 |
+
db.query(Transaction)
|
| 340 |
+
.filter(
|
| 341 |
+
Transaction.account_id.in_(account_ids),
|
| 342 |
+
Transaction.type == "debit",
|
| 343 |
+
Transaction.timestamp >= start,
|
| 344 |
+
Transaction.timestamp < end,
|
| 345 |
+
)
|
| 346 |
+
.all()
|
| 347 |
+
) if account_ids else []
|
| 348 |
+
cats: dict = defaultdict(float)
|
| 349 |
+
for t in txns:
|
| 350 |
+
cats[t.category or "Other"] += t.amount
|
| 351 |
+
return dict(cats), txns
|
| 352 |
+
|
| 353 |
+
this_cats, this_txns = get_category_spend(month_start, now)
|
| 354 |
+
last_cats, last_txns = get_category_spend(last_month_start, month_start)
|
| 355 |
+
|
| 356 |
+
this_total = sum(this_cats.values())
|
| 357 |
+
last_total = sum(last_cats.values())
|
| 358 |
+
total_delta_pct = ((this_total - last_total) / max(last_total, 1)) * 100
|
| 359 |
+
|
| 360 |
+
# Category changes
|
| 361 |
+
all_cats = set(this_cats) | set(last_cats)
|
| 362 |
+
cat_changes = []
|
| 363 |
+
for cat in all_cats:
|
| 364 |
+
this_amt = this_cats.get(cat, 0)
|
| 365 |
+
last_amt = last_cats.get(cat, 0)
|
| 366 |
+
if last_amt > 0:
|
| 367 |
+
delta_pct = ((this_amt - last_amt) / last_amt) * 100
|
| 368 |
+
cat_changes.append({
|
| 369 |
+
"category": cat,
|
| 370 |
+
"this_month": round(this_amt, 2),
|
| 371 |
+
"last_month": round(last_amt, 2),
|
| 372 |
+
"delta_pct": round(delta_pct, 1),
|
| 373 |
+
})
|
| 374 |
+
cat_changes.sort(key=lambda x: abs(x["delta_pct"]), reverse=True)
|
| 375 |
+
|
| 376 |
+
# Investment performance
|
| 377 |
+
investments = db.query(Investment).filter(Investment.user_id == user_id).all()
|
| 378 |
+
inv_total_invested = sum(i.amount_invested for i in investments)
|
| 379 |
+
inv_total_value = sum(i.current_value for i in investments)
|
| 380 |
+
inv_gain = inv_total_value - inv_total_invested
|
| 381 |
+
inv_gain_pct = (inv_gain / max(inv_total_invested, 1)) * 100
|
| 382 |
+
|
| 383 |
+
# Savings
|
| 384 |
+
savings_acct = next((a for a in accounts if a.type == "savings"), None)
|
| 385 |
+
savings_balance = savings_acct.balance if savings_acct else 0
|
| 386 |
+
|
| 387 |
+
# Subscriptions
|
| 388 |
+
subs = db.query(Subscription).filter(
|
| 389 |
+
Subscription.user_id == user_id, Subscription.active == True
|
| 390 |
+
).all()
|
| 391 |
+
monthly_sub_cost = sum(
|
| 392 |
+
s.amount if s.billing_cycle == "monthly" else s.amount / 12
|
| 393 |
+
for s in subs
|
| 394 |
+
)
|
| 395 |
+
|
| 396 |
+
# Income this month
|
| 397 |
+
this_income = sum(
|
| 398 |
+
t.amount for t in this_txns
|
| 399 |
+
if t.type == "credit"
|
| 400 |
+
) if this_txns else 0
|
| 401 |
+
# Also check credit transactions
|
| 402 |
+
income_txns = (
|
| 403 |
+
db.query(Transaction)
|
| 404 |
+
.filter(
|
| 405 |
+
Transaction.account_id.in_(account_ids),
|
| 406 |
+
Transaction.type == "credit",
|
| 407 |
+
Transaction.timestamp >= month_start,
|
| 408 |
+
)
|
| 409 |
+
.all()
|
| 410 |
+
) if account_ids else []
|
| 411 |
+
this_income = sum(t.amount for t in income_txns)
|
| 412 |
+
|
| 413 |
+
# ── Build narrative prompt ────────────────────────────────────────────────
|
| 414 |
+
top_changes = cat_changes[:4]
|
| 415 |
+
changes_text = "\n".join(
|
| 416 |
+
f" - {c['category']}: ${c['this_month']:,.0f} this month vs ${c['last_month']:,.0f} last month "
|
| 417 |
+
f"({c['delta_pct']:+.0f}%)"
|
| 418 |
+
for c in top_changes
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
system = (
|
| 422 |
+
"You are a personal finance narrator. Write in a warm, intelligent, first-person style "
|
| 423 |
+
"as if you're a financial advisor summarizing the month for your client. "
|
| 424 |
+
"Use exact numbers. Be insightful, not generic."
|
| 425 |
+
)
|
| 426 |
+
|
| 427 |
+
user_prompt = f"""Write a monthly financial narrative for {user.profile_data.get('name', 'the user')}.
|
| 428 |
+
|
| 429 |
+
THIS MONTH'S DATA:
|
| 430 |
+
- Total Spending: ${this_total:,.2f} ({total_delta_pct:+.1f}% vs last month)
|
| 431 |
+
- Total Income: ${this_income:,.2f}
|
| 432 |
+
- Savings Balance: ${savings_balance:,.2f}
|
| 433 |
+
- Investment Portfolio: ${inv_total_value:,.2f} ({inv_gain_pct:+.1f}% return)
|
| 434 |
+
- Monthly Subscriptions: ${monthly_sub_cost:,.2f}/month ({len(subs)} active)
|
| 435 |
+
|
| 436 |
+
CATEGORY CHANGES:
|
| 437 |
+
{changes_text if changes_text else ' - No prior month data available'}
|
| 438 |
+
|
| 439 |
+
Write 4 short sections:
|
| 440 |
+
1. MONTH IN REVIEW (1-2 sentences: overall spending story, use exact figures)
|
| 441 |
+
2. WHAT IMPROVED (1 sentence: best positive change with exact numbers)
|
| 442 |
+
3. WATCH OUT (1 sentence: biggest concern or overspend with exact numbers)
|
| 443 |
+
4. NEXT MONTH GOAL (1 sentence: one specific, measurable target)
|
| 444 |
+
|
| 445 |
+
Keep each section to 1-2 sentences. Use real numbers throughout."""
|
| 446 |
+
|
| 447 |
+
messages = [
|
| 448 |
+
{"role": "system", "content": system},
|
| 449 |
+
{"role": "user", "content": user_prompt},
|
| 450 |
+
]
|
| 451 |
+
|
| 452 |
+
narrative = _call_llm(messages, max_tokens=400)
|
| 453 |
+
|
| 454 |
+
if not narrative:
|
| 455 |
+
direction = "increased" if total_delta_pct > 0 else "decreased"
|
| 456 |
+
best_cat = min(cat_changes, key=lambda x: x["delta_pct"], default=None)
|
| 457 |
+
worst_cat = max(cat_changes, key=lambda x: x["delta_pct"], default=None)
|
| 458 |
+
narrative = (
|
| 459 |
+
f"MONTH IN REVIEW\nYour spending {direction} by {abs(total_delta_pct):.0f}% "
|
| 460 |
+
f"to ${this_total:,.2f} this month.\n\n"
|
| 461 |
+
f"WHAT IMPROVED\n"
|
| 462 |
+
+ (f"{best_cat['category']} spending dropped {abs(best_cat['delta_pct']):.0f}%." if best_cat and best_cat['delta_pct'] < 0 else "Spending patterns are stable.")
|
| 463 |
+
+ f"\n\nWATCH OUT\n"
|
| 464 |
+
+ (f"{worst_cat['category']} increased {worst_cat['delta_pct']:.0f}% — review this category." if worst_cat and worst_cat['delta_pct'] > 10 else "No major overspends detected.")
|
| 465 |
+
+ f"\n\nNEXT MONTH GOAL\nAim to keep total spending under ${this_total * 0.95:,.0f}."
|
| 466 |
+
)
|
| 467 |
+
|
| 468 |
+
return {
|
| 469 |
+
"month": month_start.strftime("%B %Y"),
|
| 470 |
+
"generated_at": now.isoformat(),
|
| 471 |
+
"summary": {
|
| 472 |
+
"total_spend": round(this_total, 2),
|
| 473 |
+
"total_income": round(this_income, 2),
|
| 474 |
+
"spend_delta_pct": round(total_delta_pct, 1),
|
| 475 |
+
"savings_balance": round(savings_balance, 2),
|
| 476 |
+
"investment_value": round(inv_total_value, 2),
|
| 477 |
+
"investment_gain_pct": round(inv_gain_pct, 1),
|
| 478 |
+
"monthly_subscriptions": round(monthly_sub_cost, 2),
|
| 479 |
+
},
|
| 480 |
+
"category_changes": cat_changes[:6],
|
| 481 |
+
"narrative": narrative,
|
| 482 |
+
}
|
backend/app/ai/router.py
CHANGED
|
@@ -15,6 +15,7 @@ from app.ai.coaching import calculate_financial_health_score, generate_daily_bri
|
|
| 15 |
from app.ai.subscriptions import analyze_subscriptions
|
| 16 |
from app.ai.fraud import evaluate_transaction_for_fraud, get_user_fraud_alerts
|
| 17 |
from app.ai.chat import get_chat_response
|
|
|
|
| 18 |
|
| 19 |
router = APIRouter(prefix="/api/ai", tags=["AI Intelligence"])
|
| 20 |
|
|
@@ -179,3 +180,50 @@ def post_chat(req: ChatMessageRequest, user_id: Optional[str] = None, db: Sessio
|
|
| 179 |
uid = get_user_id_fallback(db, user_id)
|
| 180 |
response_msg = get_chat_response(db, uid, req.message)
|
| 181 |
return {"response": response_msg}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
from app.ai.subscriptions import analyze_subscriptions
|
| 16 |
from app.ai.fraud import evaluate_transaction_for_fraud, get_user_fraud_alerts
|
| 17 |
from app.ai.chat import get_chat_response
|
| 18 |
+
from app.ai.intelligence import generate_weekly_coaching, explain_fraud_alert, generate_spending_narrative
|
| 19 |
|
| 20 |
router = APIRouter(prefix="/api/ai", tags=["AI Intelligence"])
|
| 21 |
|
|
|
|
| 180 |
uid = get_user_id_fallback(db, user_id)
|
| 181 |
response_msg = get_chat_response(db, uid, req.message)
|
| 182 |
return {"response": response_msg}
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# ─── FINANCIAL COACH MODE ──────────────────────────────────────────────────────
|
| 186 |
+
|
| 187 |
+
@router.get("/coach/weekly")
|
| 188 |
+
def get_weekly_coaching(user_id: Optional[str] = None, db: Session = Depends(get_db)):
|
| 189 |
+
"""
|
| 190 |
+
Proactive weekly coaching report: summary, budget coaching, savings nudge,
|
| 191 |
+
anomaly explanations, and top 3 actions — all grounded in real account data.
|
| 192 |
+
"""
|
| 193 |
+
uid = get_user_id_fallback(db, user_id)
|
| 194 |
+
cache_key = f"ai:coach:weekly:{uid}"
|
| 195 |
+
cached = cache.get(cache_key)
|
| 196 |
+
if cached:
|
| 197 |
+
return cached
|
| 198 |
+
result = generate_weekly_coaching(db, uid)
|
| 199 |
+
cache.set(cache_key, result, ttl=3600) # cache 1 hour
|
| 200 |
+
return result
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
# ─── AI FRAUD EXPLANATION ──────────────────────────────────────────────────────
|
| 204 |
+
|
| 205 |
+
@router.get("/fraud/explain/{fraud_log_id}")
|
| 206 |
+
def get_fraud_explanation(fraud_log_id: str, db: Session = Depends(get_db)):
|
| 207 |
+
"""
|
| 208 |
+
Returns a human-readable AI explanation of exactly why a specific
|
| 209 |
+
transaction was flagged, with context about the user's normal patterns.
|
| 210 |
+
"""
|
| 211 |
+
return explain_fraud_alert(db, fraud_log_id)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# ─── SPENDING NARRATIVE ────────────────────────────────────────────────────────
|
| 215 |
+
|
| 216 |
+
@router.get("/narrative/monthly")
|
| 217 |
+
def get_monthly_narrative(user_id: Optional[str] = None, db: Session = Depends(get_db)):
|
| 218 |
+
"""
|
| 219 |
+
Monthly spending narrative: what changed, what improved, what to watch,
|
| 220 |
+
investment insights, and a one-paragraph human story of the month.
|
| 221 |
+
"""
|
| 222 |
+
uid = get_user_id_fallback(db, user_id)
|
| 223 |
+
cache_key = f"ai:narrative:monthly:{uid}"
|
| 224 |
+
cached = cache.get(cache_key)
|
| 225 |
+
if cached:
|
| 226 |
+
return cached
|
| 227 |
+
result = generate_spending_narrative(db, uid)
|
| 228 |
+
cache.set(cache_key, result, ttl=1800) # cache 30 min
|
| 229 |
+
return result
|
frontend/src/app/analytics/page.tsx
CHANGED
|
@@ -7,10 +7,13 @@ import {
|
|
| 7 |
Tooltip, ResponsiveContainer, RadarChart, Radar, PolarGrid,
|
| 8 |
PolarAngleAxis, PolarRadiusAxis
|
| 9 |
} from "recharts";
|
| 10 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 11 |
import { aiApi, dashboardApi } from "@/lib/api";
|
|
|
|
| 12 |
|
| 13 |
-
// ─── Types ────────────────────────────────────────────────────────────────────
|
| 14 |
interface CategoryData { name: string; value: number; color: string }
|
| 15 |
interface CashFlowPoint { month: string; income: number; expenses: number; savings: number }
|
| 16 |
|
|
@@ -19,7 +22,6 @@ const CATEGORY_COLORS = [
|
|
| 19 |
"#ec4899", "#06b6d4", "#84cc16", "#f97316",
|
| 20 |
];
|
| 21 |
|
| 22 |
-
// ─── Animation ────────────────────────────────────────────────────────────────
|
| 23 |
const containerVariants = {
|
| 24 |
hidden: { opacity: 0 },
|
| 25 |
visible: { opacity: 1, transition: { staggerChildren: 0.07 } },
|
|
@@ -29,7 +31,6 @@ const itemVariants = {
|
|
| 29 |
visible: { opacity: 1, y: 0, transition: { duration: 0.45 } },
|
| 30 |
};
|
| 31 |
|
| 32 |
-
// ─── Custom Tooltip ───────────────────────────────────────────────────────────
|
| 33 |
interface TooltipProps {
|
| 34 |
active?: boolean;
|
| 35 |
payload?: Array<{ name: string; value: number; color?: string; fill?: string }>;
|
|
@@ -50,7 +51,6 @@ function CustomTooltip({ active, payload, label }: TooltipProps) {
|
|
| 50 |
);
|
| 51 |
}
|
| 52 |
|
| 53 |
-
// ─── Heatmap Cell ─────────────────────────────────────────────────────────────
|
| 54 |
function HeatmapCell({ value, max }: { value: number; max: number }) {
|
| 55 |
const intensity = max > 0 ? Math.min(value / max, 1) : 0;
|
| 56 |
const alpha = 0.08 + intensity * 0.85;
|
|
@@ -66,22 +66,44 @@ function HeatmapCell({ value, max }: { value: number; max: number }) {
|
|
| 66 |
|
| 67 |
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
| 68 |
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
export default function AnalyticsPage() {
|
| 71 |
-
const
|
|
|
|
| 72 |
const [loading, setLoading] = useState(true);
|
| 73 |
const [error, setError] = useState<string | null>(null);
|
| 74 |
|
| 75 |
-
// Data state
|
| 76 |
const [cashFlow, setCashFlow] = useState<CashFlowPoint[]>([]);
|
| 77 |
const [categoryData, setCategoryData] = useState<CategoryData[]>([]);
|
| 78 |
-
const [healthScore, setHealthScore] = useState
|
| 79 |
const [radarData, setRadarData] = useState<Array<{ subject: string; A: number }>>([]);
|
| 80 |
const [behaviorInsights, setBehaviorInsights] = useState<string[]>([]);
|
| 81 |
const [heatmapData, setHeatmapData] = useState<number[][]>(
|
| 82 |
Array.from({ length: 7 }, () => Array.from({ length: 12 }, () => 0))
|
| 83 |
);
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
const loadData = async () => {
|
| 86 |
setLoading(true);
|
| 87 |
setError(null);
|
|
@@ -91,22 +113,13 @@ export default function AnalyticsPage() {
|
|
| 91 |
aiApi.healthScore(),
|
| 92 |
aiApi.behaviorInsights(),
|
| 93 |
]);
|
| 94 |
-
|
| 95 |
-
// Cash flow
|
| 96 |
setCashFlow(dashData.cash_flow || []);
|
| 97 |
-
|
| 98 |
-
// Category data with colors
|
| 99 |
setCategoryData(
|
| 100 |
(dashData.spending_by_category || []).slice(0, 8).map((c, i) => ({
|
| 101 |
-
...c,
|
| 102 |
-
color: CATEGORY_COLORS[i % CATEGORY_COLORS.length],
|
| 103 |
}))
|
| 104 |
);
|
| 105 |
-
|
| 106 |
-
// Health score
|
| 107 |
setHealthScore(scoreData.overall_score || 0);
|
| 108 |
-
|
| 109 |
-
// Radar from score categories
|
| 110 |
const cats = scoreData.categories || {};
|
| 111 |
setRadarData(
|
| 112 |
Object.entries(cats).map(([key, val]) => ({
|
|
@@ -114,11 +127,7 @@ export default function AnalyticsPage() {
|
|
| 114 |
A: Math.round(((val as { score: number; max: number }).score / (val as { score: number; max: number }).max) * 100),
|
| 115 |
}))
|
| 116 |
);
|
| 117 |
-
|
| 118 |
-
// Behavior insights
|
| 119 |
setBehaviorInsights(behaviorData.insights || []);
|
| 120 |
-
|
| 121 |
-
// Build heatmap from metrics (simulated weekly pattern from behavior data)
|
| 122 |
const metrics = behaviorData.metrics || {};
|
| 123 |
const weekendPct = (metrics.weekend_pct as number) || 0.3;
|
| 124 |
const lateNight = (metrics.late_night_count as number) || 2;
|
|
@@ -126,9 +135,7 @@ export default function AnalyticsPage() {
|
|
| 126 |
Array.from({ length: 7 }, (_, day) =>
|
| 127 |
Array.from({ length: 12 }, (_, week) => {
|
| 128 |
const base = 50 + Math.random() * 150;
|
| 129 |
-
|
| 130 |
-
const lateBoost = (week > 8 ? lateNight * 10 : 0);
|
| 131 |
-
return Math.round(base + weekendBoost + lateBoost);
|
| 132 |
})
|
| 133 |
)
|
| 134 |
);
|
|
@@ -142,22 +149,22 @@ export default function AnalyticsPage() {
|
|
| 142 |
useEffect(() => { loadData(); }, []);
|
| 143 |
|
| 144 |
const heatmapMax = Math.max(...heatmapData.flat(), 1);
|
|
|
|
| 145 |
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
return (
|
| 155 |
-
<motion.div
|
| 156 |
-
variants={containerVariants}
|
| 157 |
-
initial="hidden"
|
| 158 |
-
animate="visible"
|
| 159 |
-
className="flex flex-col gap-6"
|
| 160 |
-
>
|
| 161 |
{/* Header */}
|
| 162 |
<motion.div variants={itemVariants} className="flex items-center justify-between">
|
| 163 |
<div>
|
|
@@ -165,65 +172,46 @@ export default function AnalyticsPage() {
|
|
| 165 |
<p className="text-zinc-400 mt-1 text-sm">Deep insights into your financial behavior</p>
|
| 166 |
</div>
|
| 167 |
<div className="flex items-center gap-2">
|
| 168 |
-
<button
|
| 169 |
-
|
| 170 |
-
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"
|
| 171 |
-
>
|
| 172 |
-
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
|
| 173 |
-
Refresh
|
| 174 |
</button>
|
| 175 |
<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">
|
| 176 |
-
<Calendar className="h-3.5 w-3.5" />
|
| 177 |
-
Last 6 months
|
| 178 |
</button>
|
| 179 |
<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">
|
| 180 |
-
<Filter className="h-3.5 w-3.5" />
|
| 181 |
-
Filter
|
| 182 |
</button>
|
| 183 |
</div>
|
| 184 |
</motion.div>
|
| 185 |
|
| 186 |
-
{/* Error */}
|
| 187 |
{error && (
|
| 188 |
<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">
|
| 189 |
<AlertTriangle className="h-4 w-4 text-amber-400 flex-shrink-0" />
|
| 190 |
-
<p className="text-xs text-zinc-400">
|
| 191 |
-
<span className="text-amber-400 font-medium">Backend offline</span> — showing cached data.
|
| 192 |
-
</p>
|
| 193 |
</motion.div>
|
| 194 |
)}
|
| 195 |
|
| 196 |
-
{/*
|
| 197 |
-
<motion.div variants={itemVariants} className="flex gap-1 rounded-xl border border-white/10 bg-white/5 p-1 w-fit">
|
| 198 |
-
{(["overview", "categories", "networth"] as
|
| 199 |
-
<button
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
}
|
| 205 |
-
>
|
| 206 |
-
{tab === "networth" ? "Net Worth" : tab.charAt(0).toUpperCase() + tab.slice(1)}
|
| 207 |
</button>
|
| 208 |
))}
|
| 209 |
</motion.div>
|
| 210 |
|
| 211 |
-
{/* ── Overview
|
| 212 |
{activeTab === "overview" && (
|
| 213 |
<>
|
| 214 |
-
{/* Cash Flow Chart */}
|
| 215 |
<motion.div variants={itemVariants} className="glass-card p-6">
|
| 216 |
<div className="flex items-center justify-between mb-6">
|
| 217 |
-
<div>
|
| 218 |
-
<h2 className="text-base font-semibold text-white">Monthly Cash Flow</h2>
|
| 219 |
-
<p className="text-xs text-zinc-400 mt-0.5">Income vs Expenses vs Savings</p>
|
| 220 |
-
</div>
|
| 221 |
<div className="flex gap-3 flex-wrap">
|
| 222 |
-
{[
|
| 223 |
-
{ label: "Income", color: "#10b981" },
|
| 224 |
-
{ label: "Expenses", color: "#f59e0b" },
|
| 225 |
-
{ label: "Savings", color: "#3b82f6" },
|
| 226 |
-
].map((l) => (
|
| 227 |
<div key={l.label} className="flex items-center gap-1.5">
|
| 228 |
<div className="h-2 w-2 rounded-full" style={{ background: l.color }} />
|
| 229 |
<span className="text-xs text-zinc-400">{l.label}</span>
|
|
@@ -231,9 +219,7 @@ export default function AnalyticsPage() {
|
|
| 231 |
))}
|
| 232 |
</div>
|
| 233 |
</div>
|
| 234 |
-
{loading ? (
|
| 235 |
-
<div className="h-[280px] shimmer rounded-xl" />
|
| 236 |
-
) : (
|
| 237 |
<ResponsiveContainer width="100%" height={280}>
|
| 238 |
<BarChart data={stackedData} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}>
|
| 239 |
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
|
@@ -248,59 +234,36 @@ export default function AnalyticsPage() {
|
|
| 248 |
)}
|
| 249 |
</motion.div>
|
| 250 |
|
| 251 |
-
{/* Heatmap + Radar */}
|
| 252 |
<div className="grid gap-6 lg:grid-cols-2">
|
| 253 |
-
{/* Spending Heatmap */}
|
| 254 |
<motion.div variants={itemVariants} className="glass-card p-6">
|
| 255 |
-
<div className="mb-5">
|
| 256 |
-
|
| 257 |
-
<p className="text-xs text-zinc-400 mt-0.5">Daily activity over 12 weeks</p>
|
| 258 |
-
</div>
|
| 259 |
-
{loading ? (
|
| 260 |
-
<div className="h-[180px] shimmer rounded-xl" />
|
| 261 |
-
) : (
|
| 262 |
<>
|
| 263 |
<div className="space-y-1.5">
|
| 264 |
{DAYS.map((day, dayIdx) => (
|
| 265 |
<div key={day} className="flex items-center gap-2">
|
| 266 |
<span className="text-xs text-zinc-500 w-7 flex-shrink-0">{day}</span>
|
| 267 |
<div className="flex gap-1">
|
| 268 |
-
{heatmapData[dayIdx]?.map((val, week) =>
|
| 269 |
-
<HeatmapCell key={week} value={val} max={heatmapMax} />
|
| 270 |
-
))}
|
| 271 |
</div>
|
| 272 |
</div>
|
| 273 |
))}
|
| 274 |
</div>
|
| 275 |
<div className="mt-4 flex items-center gap-2">
|
| 276 |
<span className="text-xs text-zinc-500">Less</span>
|
| 277 |
-
{[0.1, 0.3, 0.5, 0.7, 0.9].map((a) => (
|
| 278 |
-
<div key={a} className="h-3 w-3 rounded-sm" style={{ background: `rgba(16,185,129,${a})` }} />
|
| 279 |
-
))}
|
| 280 |
<span className="text-xs text-zinc-500">More</span>
|
| 281 |
</div>
|
| 282 |
</>
|
| 283 |
)}
|
| 284 |
</motion.div>
|
| 285 |
|
| 286 |
-
{/* Financial Health Radar */}
|
| 287 |
<motion.div variants={itemVariants} className="glass-card p-6">
|
| 288 |
-
<div className="mb-5">
|
| 289 |
-
|
| 290 |
-
<p className="text-xs text-zinc-400 mt-0.5">Multi-dimensional AI assessment</p>
|
| 291 |
-
</div>
|
| 292 |
-
{loading ? (
|
| 293 |
-
<div className="h-[240px] shimmer rounded-xl" />
|
| 294 |
-
) : (
|
| 295 |
<>
|
| 296 |
<ResponsiveContainer width="100%" height={220}>
|
| 297 |
-
<RadarChart data={radarData.length ? radarData : [
|
| 298 |
-
{ subject: "Savings", A: 80 },
|
| 299 |
-
{ subject: "Spending", A: 70 },
|
| 300 |
-
{ subject: "Income", A: 90 },
|
| 301 |
-
{ subject: "Debt", A: 95 },
|
| 302 |
-
{ subject: "Investment", A: 60 },
|
| 303 |
-
]}>
|
| 304 |
<PolarGrid stroke="rgba(255,255,255,0.08)" />
|
| 305 |
<PolarAngleAxis dataKey="subject" tick={{ fill: "#71717a", fontSize: 10 }} />
|
| 306 |
<PolarRadiusAxis angle={30} domain={[0, 100]} tick={{ fill: "#71717a", fontSize: 9 }} />
|
|
@@ -319,35 +282,22 @@ export default function AnalyticsPage() {
|
|
| 319 |
</>
|
| 320 |
)}
|
| 321 |
|
| 322 |
-
{/* ── Categories
|
| 323 |
{activeTab === "categories" && (
|
| 324 |
<motion.div variants={itemVariants} className="glass-card p-6">
|
| 325 |
<div className="flex items-center justify-between mb-6">
|
| 326 |
-
<div>
|
| 327 |
-
<h2 className="text-base font-semibold text-white">Category Intelligence</h2>
|
| 328 |
-
<p className="text-xs text-zinc-400 mt-0.5">AI-powered spending analysis per category</p>
|
| 329 |
-
</div>
|
| 330 |
<div className="flex items-center gap-1.5 rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-3 py-1.5">
|
| 331 |
-
<Sparkles className="h-3.5 w-3.5 text-emerald-400" />
|
| 332 |
-
<span className="text-xs text-emerald-400 font-medium">AI Active</span>
|
| 333 |
</div>
|
| 334 |
</div>
|
| 335 |
-
|
| 336 |
-
{loading ? (
|
| 337 |
-
<div className="space-y-3">
|
| 338 |
-
{[1, 2, 3, 4, 5].map((i) => <div key={i} className="h-12 shimmer rounded-xl" />)}
|
| 339 |
-
</div>
|
| 340 |
-
) : (
|
| 341 |
<div className="space-y-1">
|
| 342 |
{categoryData.map((cat) => {
|
| 343 |
const total = categoryData.reduce((s, c) => s + c.value, 0);
|
| 344 |
const pct = total > 0 ? (cat.value / total) * 100 : 0;
|
| 345 |
return (
|
| 346 |
-
<motion.div
|
| 347 |
-
key={cat.name}
|
| 348 |
-
whileHover={{ backgroundColor: "rgba(255,255,255,0.02)" }}
|
| 349 |
-
className="flex items-center gap-4 rounded-xl px-3 py-3 transition-colors"
|
| 350 |
-
>
|
| 351 |
<div className="h-3 w-3 rounded-full flex-shrink-0" style={{ background: cat.color }} />
|
| 352 |
<div className="flex-1 min-w-0">
|
| 353 |
<div className="flex items-center justify-between mb-1.5">
|
|
@@ -355,13 +305,7 @@ export default function AnalyticsPage() {
|
|
| 355 |
<span className="text-xs text-zinc-400">${cat.value.toLocaleString()} ({pct.toFixed(1)}%)</span>
|
| 356 |
</div>
|
| 357 |
<div className="h-1.5 w-full rounded-full bg-white/5 overflow-hidden">
|
| 358 |
-
<motion.div
|
| 359 |
-
initial={{ width: 0 }}
|
| 360 |
-
animate={{ width: `${pct}%` }}
|
| 361 |
-
transition={{ duration: 0.8, ease: "easeOut", delay: 0.1 }}
|
| 362 |
-
className="h-full rounded-full"
|
| 363 |
-
style={{ background: cat.color }}
|
| 364 |
-
/>
|
| 365 |
</div>
|
| 366 |
</div>
|
| 367 |
</motion.div>
|
|
@@ -369,19 +313,13 @@ export default function AnalyticsPage() {
|
|
| 369 |
})}
|
| 370 |
</div>
|
| 371 |
)}
|
| 372 |
-
|
| 373 |
-
{/* AI Behavior Insights */}
|
| 374 |
{behaviorInsights.length > 0 && (
|
| 375 |
<div className="mt-6 rounded-xl border border-blue-500/20 bg-blue-500/5 p-4">
|
| 376 |
<div className="flex items-start gap-3">
|
| 377 |
<Sparkles className="h-4 w-4 text-blue-400 flex-shrink-0 mt-0.5" />
|
| 378 |
<div>
|
| 379 |
<p className="text-sm font-medium text-white mb-2">AI Behavioral Insights</p>
|
| 380 |
-
<ul className="space-y-1">
|
| 381 |
-
{behaviorInsights.slice(0, 3).map((insight, i) => (
|
| 382 |
-
<li key={i} className="text-xs text-zinc-400">• {insight}</li>
|
| 383 |
-
))}
|
| 384 |
-
</ul>
|
| 385 |
</div>
|
| 386 |
</div>
|
| 387 |
</div>
|
|
@@ -389,29 +327,21 @@ export default function AnalyticsPage() {
|
|
| 389 |
</motion.div>
|
| 390 |
)}
|
| 391 |
|
| 392 |
-
{/* ── Net Worth
|
| 393 |
{activeTab === "networth" && (
|
| 394 |
<motion.div variants={itemVariants} className="glass-card p-6">
|
| 395 |
<div className="flex items-center justify-between mb-6">
|
| 396 |
-
<div>
|
| 397 |
-
<h2 className="text-base font-semibold text-white">Net Worth Timeline</h2>
|
| 398 |
-
<p className="text-xs text-zinc-400 mt-0.5">Balance trajectory over time</p>
|
| 399 |
-
</div>
|
| 400 |
{!loading && (
|
| 401 |
<div className="text-right">
|
| 402 |
-
<p className="text-2xl font-bold text-white">
|
| 403 |
-
${cashFlow.length ? cashFlow[cashFlow.length - 1]?.income?.toLocaleString() : "—"}
|
| 404 |
-
</p>
|
| 405 |
<div className="flex items-center gap-1 justify-end mt-0.5">
|
| 406 |
-
<TrendingUp className="h-3.5 w-3.5 text-emerald-400" />
|
| 407 |
-
<span className="text-xs text-emerald-400 font-medium">Growing</span>
|
| 408 |
</div>
|
| 409 |
</div>
|
| 410 |
)}
|
| 411 |
</div>
|
| 412 |
-
{loading ? (
|
| 413 |
-
<div className="h-[320px] shimmer rounded-xl" />
|
| 414 |
-
) : (
|
| 415 |
<ResponsiveContainer width="100%" height={320}>
|
| 416 |
<LineChart data={cashFlow} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}>
|
| 417 |
<defs>
|
|
@@ -424,19 +354,224 @@ export default function AnalyticsPage() {
|
|
| 424 |
<XAxis dataKey="month" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} />
|
| 425 |
<YAxis tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
|
| 426 |
<Tooltip content={<CustomTooltip />} />
|
| 427 |
-
<Line type="monotone" dataKey="income" name="Income" stroke="#10b981" strokeWidth={2.5}
|
| 428 |
-
|
| 429 |
-
activeDot={{ r: 6, fill: "#10b981", strokeWidth: 2, stroke: "#000" }}
|
| 430 |
-
/>
|
| 431 |
-
<Line type="monotone" dataKey="savings" name="Savings" stroke="#3b82f6" strokeWidth={2}
|
| 432 |
-
strokeDasharray="4 4"
|
| 433 |
-
dot={{ fill: "#3b82f6", r: 3, strokeWidth: 0 }}
|
| 434 |
-
/>
|
| 435 |
</LineChart>
|
| 436 |
</ResponsiveContainer>
|
| 437 |
)}
|
| 438 |
</motion.div>
|
| 439 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
</motion.div>
|
| 441 |
);
|
| 442 |
}
|
|
|
|
| 7 |
Tooltip, ResponsiveContainer, RadarChart, Radar, PolarGrid,
|
| 8 |
PolarAngleAxis, PolarRadiusAxis
|
| 9 |
} from "recharts";
|
| 10 |
+
import {
|
| 11 |
+
TrendingUp, Sparkles, Calendar, Filter, AlertTriangle,
|
| 12 |
+
RefreshCw, Brain, BookOpen,
|
| 13 |
+
} from "lucide-react";
|
| 14 |
import { aiApi, dashboardApi } from "@/lib/api";
|
| 15 |
+
import { useAuthStore } from "@/lib/stores/authStore";
|
| 16 |
|
|
|
|
| 17 |
interface CategoryData { name: string; value: number; color: string }
|
| 18 |
interface CashFlowPoint { month: string; income: number; expenses: number; savings: number }
|
| 19 |
|
|
|
|
| 22 |
"#ec4899", "#06b6d4", "#84cc16", "#f97316",
|
| 23 |
];
|
| 24 |
|
|
|
|
| 25 |
const containerVariants = {
|
| 26 |
hidden: { opacity: 0 },
|
| 27 |
visible: { opacity: 1, transition: { staggerChildren: 0.07 } },
|
|
|
|
| 31 |
visible: { opacity: 1, y: 0, transition: { duration: 0.45 } },
|
| 32 |
};
|
| 33 |
|
|
|
|
| 34 |
interface TooltipProps {
|
| 35 |
active?: boolean;
|
| 36 |
payload?: Array<{ name: string; value: number; color?: string; fill?: string }>;
|
|
|
|
| 51 |
);
|
| 52 |
}
|
| 53 |
|
|
|
|
| 54 |
function HeatmapCell({ value, max }: { value: number; max: number }) {
|
| 55 |
const intensity = max > 0 ? Math.min(value / max, 1) : 0;
|
| 56 |
const alpha = 0.08 + intensity * 0.85;
|
|
|
|
| 66 |
|
| 67 |
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
| 68 |
|
| 69 |
+
type Tab = "overview" | "categories" | "networth" | "coach" | "narrative";
|
| 70 |
+
|
| 71 |
+
type CoachData = {
|
| 72 |
+
coaching_report: string; week_spend: number; week_income: number;
|
| 73 |
+
spend_delta_pct: number; top_categories: Array<{ name: string; amount: number }>;
|
| 74 |
+
anomalies: string[]; improvements: string[]; health_score: number;
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
type NarrativeData = {
|
| 78 |
+
month: string; narrative: string;
|
| 79 |
+
summary: {
|
| 80 |
+
total_spend: number; total_income: number; spend_delta_pct: number;
|
| 81 |
+
savings_balance: number; investment_value: number;
|
| 82 |
+
investment_gain_pct: number; monthly_subscriptions: number;
|
| 83 |
+
};
|
| 84 |
+
category_changes: Array<{ category: string; this_month: number; last_month: number; delta_pct: number }>;
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
export default function AnalyticsPage() {
|
| 88 |
+
const { user } = useAuthStore();
|
| 89 |
+
const [activeTab, setActiveTab] = useState<Tab>("overview");
|
| 90 |
const [loading, setLoading] = useState(true);
|
| 91 |
const [error, setError] = useState<string | null>(null);
|
| 92 |
|
|
|
|
| 93 |
const [cashFlow, setCashFlow] = useState<CashFlowPoint[]>([]);
|
| 94 |
const [categoryData, setCategoryData] = useState<CategoryData[]>([]);
|
| 95 |
+
const [healthScore, setHealthScore] = useState(0);
|
| 96 |
const [radarData, setRadarData] = useState<Array<{ subject: string; A: number }>>([]);
|
| 97 |
const [behaviorInsights, setBehaviorInsights] = useState<string[]>([]);
|
| 98 |
const [heatmapData, setHeatmapData] = useState<number[][]>(
|
| 99 |
Array.from({ length: 7 }, () => Array.from({ length: 12 }, () => 0))
|
| 100 |
);
|
| 101 |
|
| 102 |
+
const [coachData, setCoachData] = useState<CoachData | null>(null);
|
| 103 |
+
const [narrativeData, setNarrativeData] = useState<NarrativeData | null>(null);
|
| 104 |
+
const [coachLoading, setCoachLoading] = useState(false);
|
| 105 |
+
const [narrativeLoading, setNarrativeLoading] = useState(false);
|
| 106 |
+
|
| 107 |
const loadData = async () => {
|
| 108 |
setLoading(true);
|
| 109 |
setError(null);
|
|
|
|
| 113 |
aiApi.healthScore(),
|
| 114 |
aiApi.behaviorInsights(),
|
| 115 |
]);
|
|
|
|
|
|
|
| 116 |
setCashFlow(dashData.cash_flow || []);
|
|
|
|
|
|
|
| 117 |
setCategoryData(
|
| 118 |
(dashData.spending_by_category || []).slice(0, 8).map((c, i) => ({
|
| 119 |
+
...c, color: CATEGORY_COLORS[i % CATEGORY_COLORS.length],
|
|
|
|
| 120 |
}))
|
| 121 |
);
|
|
|
|
|
|
|
| 122 |
setHealthScore(scoreData.overall_score || 0);
|
|
|
|
|
|
|
| 123 |
const cats = scoreData.categories || {};
|
| 124 |
setRadarData(
|
| 125 |
Object.entries(cats).map(([key, val]) => ({
|
|
|
|
| 127 |
A: Math.round(((val as { score: number; max: number }).score / (val as { score: number; max: number }).max) * 100),
|
| 128 |
}))
|
| 129 |
);
|
|
|
|
|
|
|
| 130 |
setBehaviorInsights(behaviorData.insights || []);
|
|
|
|
|
|
|
| 131 |
const metrics = behaviorData.metrics || {};
|
| 132 |
const weekendPct = (metrics.weekend_pct as number) || 0.3;
|
| 133 |
const lateNight = (metrics.late_night_count as number) || 2;
|
|
|
|
| 135 |
Array.from({ length: 7 }, (_, day) =>
|
| 136 |
Array.from({ length: 12 }, (_, week) => {
|
| 137 |
const base = 50 + Math.random() * 150;
|
| 138 |
+
return Math.round(base + (day >= 5 ? weekendPct * 200 : 0) + (week > 8 ? lateNight * 10 : 0));
|
|
|
|
|
|
|
| 139 |
})
|
| 140 |
)
|
| 141 |
);
|
|
|
|
| 149 |
useEffect(() => { loadData(); }, []);
|
| 150 |
|
| 151 |
const heatmapMax = Math.max(...heatmapData.flat(), 1);
|
| 152 |
+
const stackedData = cashFlow.map((m) => ({ month: m.month, income: m.income, expenses: m.expenses, savings: m.savings }));
|
| 153 |
|
| 154 |
+
const handleTabClick = (tab: Tab) => {
|
| 155 |
+
setActiveTab(tab);
|
| 156 |
+
if (tab === "coach" && !coachData) {
|
| 157 |
+
setCoachLoading(true);
|
| 158 |
+
aiApi.weeklyCoaching(user?.user_id).then(setCoachData).catch(() => {}).finally(() => setCoachLoading(false));
|
| 159 |
+
}
|
| 160 |
+
if (tab === "narrative" && !narrativeData) {
|
| 161 |
+
setNarrativeLoading(true);
|
| 162 |
+
aiApi.monthlyNarrative(user?.user_id).then(setNarrativeData).catch(() => {}).finally(() => setNarrativeLoading(false));
|
| 163 |
+
}
|
| 164 |
+
};
|
| 165 |
|
| 166 |
return (
|
| 167 |
+
<motion.div variants={containerVariants} initial="hidden" animate="visible" className="flex flex-col gap-6">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
{/* Header */}
|
| 169 |
<motion.div variants={itemVariants} className="flex items-center justify-between">
|
| 170 |
<div>
|
|
|
|
| 172 |
<p className="text-zinc-400 mt-1 text-sm">Deep insights into your financial behavior</p>
|
| 173 |
</div>
|
| 174 |
<div className="flex items-center gap-2">
|
| 175 |
+
<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">
|
| 176 |
+
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />Refresh
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
</button>
|
| 178 |
<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">
|
| 179 |
+
<Calendar className="h-3.5 w-3.5" />Last 6 months
|
|
|
|
| 180 |
</button>
|
| 181 |
<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">
|
| 182 |
+
<Filter className="h-3.5 w-3.5" />Filter
|
|
|
|
| 183 |
</button>
|
| 184 |
</div>
|
| 185 |
</motion.div>
|
| 186 |
|
|
|
|
| 187 |
{error && (
|
| 188 |
<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">
|
| 189 |
<AlertTriangle className="h-4 w-4 text-amber-400 flex-shrink-0" />
|
| 190 |
+
<p className="text-xs text-zinc-400"><span className="text-amber-400 font-medium">Backend offline</span> — showing cached data.</p>
|
|
|
|
|
|
|
| 191 |
</motion.div>
|
| 192 |
)}
|
| 193 |
|
| 194 |
+
{/* Tab bar */}
|
| 195 |
+
<motion.div variants={itemVariants} className="flex gap-1 rounded-xl border border-white/10 bg-white/5 p-1 w-fit flex-wrap">
|
| 196 |
+
{(["overview", "categories", "networth", "coach", "narrative"] as Tab[]).map((tab) => (
|
| 197 |
+
<button key={tab} onClick={() => handleTabClick(tab)}
|
| 198 |
+
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"}`}>
|
| 199 |
+
{tab === "coach" && <Brain className="h-3 w-3" />}
|
| 200 |
+
{tab === "narrative" && <BookOpen className="h-3 w-3" />}
|
| 201 |
+
{tab === "networth" ? "Net Worth" : tab === "coach" ? "AI Coach" : tab === "narrative" ? "Monthly Story" : tab.charAt(0).toUpperCase() + tab.slice(1)}
|
| 202 |
+
{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>}
|
|
|
|
|
|
|
| 203 |
</button>
|
| 204 |
))}
|
| 205 |
</motion.div>
|
| 206 |
|
| 207 |
+
{/* ── Overview ── */}
|
| 208 |
{activeTab === "overview" && (
|
| 209 |
<>
|
|
|
|
| 210 |
<motion.div variants={itemVariants} className="glass-card p-6">
|
| 211 |
<div className="flex items-center justify-between mb-6">
|
| 212 |
+
<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>
|
|
|
|
|
|
|
|
|
|
| 213 |
<div className="flex gap-3 flex-wrap">
|
| 214 |
+
{[{ label: "Income", color: "#10b981" }, { label: "Expenses", color: "#f59e0b" }, { label: "Savings", color: "#3b82f6" }].map((l) => (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
<div key={l.label} className="flex items-center gap-1.5">
|
| 216 |
<div className="h-2 w-2 rounded-full" style={{ background: l.color }} />
|
| 217 |
<span className="text-xs text-zinc-400">{l.label}</span>
|
|
|
|
| 219 |
))}
|
| 220 |
</div>
|
| 221 |
</div>
|
| 222 |
+
{loading ? <div className="h-[280px] shimmer rounded-xl" /> : (
|
|
|
|
|
|
|
| 223 |
<ResponsiveContainer width="100%" height={280}>
|
| 224 |
<BarChart data={stackedData} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}>
|
| 225 |
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
|
|
|
| 234 |
)}
|
| 235 |
</motion.div>
|
| 236 |
|
|
|
|
| 237 |
<div className="grid gap-6 lg:grid-cols-2">
|
|
|
|
| 238 |
<motion.div variants={itemVariants} className="glass-card p-6">
|
| 239 |
+
<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>
|
| 240 |
+
{loading ? <div className="h-[180px] shimmer rounded-xl" /> : (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
<>
|
| 242 |
<div className="space-y-1.5">
|
| 243 |
{DAYS.map((day, dayIdx) => (
|
| 244 |
<div key={day} className="flex items-center gap-2">
|
| 245 |
<span className="text-xs text-zinc-500 w-7 flex-shrink-0">{day}</span>
|
| 246 |
<div className="flex gap-1">
|
| 247 |
+
{heatmapData[dayIdx]?.map((val, week) => <HeatmapCell key={week} value={val} max={heatmapMax} />)}
|
|
|
|
|
|
|
| 248 |
</div>
|
| 249 |
</div>
|
| 250 |
))}
|
| 251 |
</div>
|
| 252 |
<div className="mt-4 flex items-center gap-2">
|
| 253 |
<span className="text-xs text-zinc-500">Less</span>
|
| 254 |
+
{[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})` }} />)}
|
|
|
|
|
|
|
| 255 |
<span className="text-xs text-zinc-500">More</span>
|
| 256 |
</div>
|
| 257 |
</>
|
| 258 |
)}
|
| 259 |
</motion.div>
|
| 260 |
|
|
|
|
| 261 |
<motion.div variants={itemVariants} className="glass-card p-6">
|
| 262 |
+
<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>
|
| 263 |
+
{loading ? <div className="h-[240px] shimmer rounded-xl" /> : (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
<>
|
| 265 |
<ResponsiveContainer width="100%" height={220}>
|
| 266 |
+
<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 }]}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
<PolarGrid stroke="rgba(255,255,255,0.08)" />
|
| 268 |
<PolarAngleAxis dataKey="subject" tick={{ fill: "#71717a", fontSize: 10 }} />
|
| 269 |
<PolarRadiusAxis angle={30} domain={[0, 100]} tick={{ fill: "#71717a", fontSize: 9 }} />
|
|
|
|
| 282 |
</>
|
| 283 |
)}
|
| 284 |
|
| 285 |
+
{/* ── Categories ── */}
|
| 286 |
{activeTab === "categories" && (
|
| 287 |
<motion.div variants={itemVariants} className="glass-card p-6">
|
| 288 |
<div className="flex items-center justify-between mb-6">
|
| 289 |
+
<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>
|
|
|
|
|
|
|
|
|
|
| 290 |
<div className="flex items-center gap-1.5 rounded-xl border border-emerald-500/20 bg-emerald-500/10 px-3 py-1.5">
|
| 291 |
+
<Sparkles className="h-3.5 w-3.5 text-emerald-400" /><span className="text-xs text-emerald-400 font-medium">AI Active</span>
|
|
|
|
| 292 |
</div>
|
| 293 |
</div>
|
| 294 |
+
{loading ? <div className="space-y-3">{[1,2,3,4,5].map((i) => <div key={i} className="h-12 shimmer rounded-xl" />)}</div> : (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
<div className="space-y-1">
|
| 296 |
{categoryData.map((cat) => {
|
| 297 |
const total = categoryData.reduce((s, c) => s + c.value, 0);
|
| 298 |
const pct = total > 0 ? (cat.value / total) * 100 : 0;
|
| 299 |
return (
|
| 300 |
+
<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">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
<div className="h-3 w-3 rounded-full flex-shrink-0" style={{ background: cat.color }} />
|
| 302 |
<div className="flex-1 min-w-0">
|
| 303 |
<div className="flex items-center justify-between mb-1.5">
|
|
|
|
| 305 |
<span className="text-xs text-zinc-400">${cat.value.toLocaleString()} ({pct.toFixed(1)}%)</span>
|
| 306 |
</div>
|
| 307 |
<div className="h-1.5 w-full rounded-full bg-white/5 overflow-hidden">
|
| 308 |
+
<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 }} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
</div>
|
| 310 |
</div>
|
| 311 |
</motion.div>
|
|
|
|
| 313 |
})}
|
| 314 |
</div>
|
| 315 |
)}
|
|
|
|
|
|
|
| 316 |
{behaviorInsights.length > 0 && (
|
| 317 |
<div className="mt-6 rounded-xl border border-blue-500/20 bg-blue-500/5 p-4">
|
| 318 |
<div className="flex items-start gap-3">
|
| 319 |
<Sparkles className="h-4 w-4 text-blue-400 flex-shrink-0 mt-0.5" />
|
| 320 |
<div>
|
| 321 |
<p className="text-sm font-medium text-white mb-2">AI Behavioral Insights</p>
|
| 322 |
+
<ul className="space-y-1">{behaviorInsights.slice(0, 3).map((insight, i) => <li key={i} className="text-xs text-zinc-400">• {insight}</li>)}</ul>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
</div>
|
| 324 |
</div>
|
| 325 |
</div>
|
|
|
|
| 327 |
</motion.div>
|
| 328 |
)}
|
| 329 |
|
| 330 |
+
{/* ── Net Worth ── */}
|
| 331 |
{activeTab === "networth" && (
|
| 332 |
<motion.div variants={itemVariants} className="glass-card p-6">
|
| 333 |
<div className="flex items-center justify-between mb-6">
|
| 334 |
+
<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>
|
|
|
|
|
|
|
|
|
|
| 335 |
{!loading && (
|
| 336 |
<div className="text-right">
|
| 337 |
+
<p className="text-2xl font-bold text-white">${cashFlow.length ? cashFlow[cashFlow.length - 1]?.income?.toLocaleString() : "—"}</p>
|
|
|
|
|
|
|
| 338 |
<div className="flex items-center gap-1 justify-end mt-0.5">
|
| 339 |
+
<TrendingUp className="h-3.5 w-3.5 text-emerald-400" /><span className="text-xs text-emerald-400 font-medium">Growing</span>
|
|
|
|
| 340 |
</div>
|
| 341 |
</div>
|
| 342 |
)}
|
| 343 |
</div>
|
| 344 |
+
{loading ? <div className="h-[320px] shimmer rounded-xl" /> : (
|
|
|
|
|
|
|
| 345 |
<ResponsiveContainer width="100%" height={320}>
|
| 346 |
<LineChart data={cashFlow} margin={{ top: 5, right: 5, left: -20, bottom: 0 }}>
|
| 347 |
<defs>
|
|
|
|
| 354 |
<XAxis dataKey="month" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} />
|
| 355 |
<YAxis tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
|
| 356 |
<Tooltip content={<CustomTooltip />} />
|
| 357 |
+
<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" }} />
|
| 358 |
+
<Line type="monotone" dataKey="savings" name="Savings" stroke="#3b82f6" strokeWidth={2} strokeDasharray="4 4" dot={{ fill: "#3b82f6", r: 3, strokeWidth: 0 }} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
</LineChart>
|
| 360 |
</ResponsiveContainer>
|
| 361 |
)}
|
| 362 |
</motion.div>
|
| 363 |
)}
|
| 364 |
+
|
| 365 |
+
{/* ── AI Coach ── */}
|
| 366 |
+
{activeTab === "coach" && (
|
| 367 |
+
<motion.div variants={itemVariants} className="space-y-4">
|
| 368 |
+
{coachLoading ? (
|
| 369 |
+
<div className="glass-card p-8 flex flex-col items-center gap-4">
|
| 370 |
+
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-500/15 border border-emerald-500/20">
|
| 371 |
+
<Brain className="h-6 w-6 text-emerald-400 animate-pulse" />
|
| 372 |
+
</div>
|
| 373 |
+
<p className="text-sm text-zinc-400">Generating your weekly coaching report...</p>
|
| 374 |
+
<div className="flex gap-1">
|
| 375 |
+
{[0,1,2].map(i => (
|
| 376 |
+
<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" />
|
| 377 |
+
))}
|
| 378 |
+
</div>
|
| 379 |
+
</div>
|
| 380 |
+
) : coachData ? (
|
| 381 |
+
<>
|
| 382 |
+
<div className="grid grid-cols-3 gap-4">
|
| 383 |
+
<div className="glass-card p-4">
|
| 384 |
+
<p className="text-xs text-zinc-400">This Week Spend</p>
|
| 385 |
+
<p className="text-xl font-bold text-white mt-1">${coachData.week_spend.toLocaleString("en-US", { minimumFractionDigits: 2 })}</p>
|
| 386 |
+
<p className={`text-xs mt-1 ${coachData.spend_delta_pct > 0 ? "text-red-400" : "text-emerald-400"}`}>
|
| 387 |
+
{coachData.spend_delta_pct > 0 ? "▲" : "▼"} {Math.abs(coachData.spend_delta_pct).toFixed(1)}% vs last week
|
| 388 |
+
</p>
|
| 389 |
+
</div>
|
| 390 |
+
<div className="glass-card p-4">
|
| 391 |
+
<p className="text-xs text-zinc-400">This Week Income</p>
|
| 392 |
+
<p className="text-xl font-bold text-emerald-400 mt-1">${coachData.week_income.toLocaleString("en-US", { minimumFractionDigits: 2 })}</p>
|
| 393 |
+
</div>
|
| 394 |
+
<div className="glass-card p-4">
|
| 395 |
+
<p className="text-xs text-zinc-400">Health Score</p>
|
| 396 |
+
<p className="text-xl font-bold text-blue-400 mt-1">{coachData.health_score.toFixed(0)}/100</p>
|
| 397 |
+
</div>
|
| 398 |
+
</div>
|
| 399 |
+
|
| 400 |
+
{coachData.top_categories.length > 0 && (
|
| 401 |
+
<div className="glass-card p-5">
|
| 402 |
+
<p className="text-sm font-semibold text-white mb-3">Top Spending This Week</p>
|
| 403 |
+
<div className="space-y-2">
|
| 404 |
+
{coachData.top_categories.map((cat, i) => {
|
| 405 |
+
const max = coachData.top_categories[0].amount;
|
| 406 |
+
return (
|
| 407 |
+
<div key={i} className="flex items-center gap-3">
|
| 408 |
+
<span className="text-xs text-zinc-400 w-24 truncate">{cat.name}</span>
|
| 409 |
+
<div className="flex-1 h-1.5 rounded-full bg-white/5 overflow-hidden">
|
| 410 |
+
<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" />
|
| 411 |
+
</div>
|
| 412 |
+
<span className="text-xs font-medium text-white w-16 text-right">${cat.amount.toLocaleString()}</span>
|
| 413 |
+
</div>
|
| 414 |
+
);
|
| 415 |
+
})}
|
| 416 |
+
</div>
|
| 417 |
+
</div>
|
| 418 |
+
)}
|
| 419 |
+
|
| 420 |
+
{coachData.anomalies.length > 0 && (
|
| 421 |
+
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
|
| 422 |
+
<div className="flex items-start gap-2">
|
| 423 |
+
<AlertTriangle className="h-4 w-4 text-amber-400 flex-shrink-0 mt-0.5" />
|
| 424 |
+
<div>
|
| 425 |
+
<p className="text-xs font-semibold text-amber-400 mb-1">Anomalies Detected This Week</p>
|
| 426 |
+
{coachData.anomalies.map((a, i) => <p key={i} className="text-xs text-zinc-300">• {a}</p>)}
|
| 427 |
+
</div>
|
| 428 |
+
</div>
|
| 429 |
+
</div>
|
| 430 |
+
)}
|
| 431 |
+
|
| 432 |
+
<div className="glass-card p-6">
|
| 433 |
+
<div className="flex items-center gap-2 mb-4">
|
| 434 |
+
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-emerald-500/15 border border-emerald-500/20">
|
| 435 |
+
<Brain className="h-4 w-4 text-emerald-400" />
|
| 436 |
+
</div>
|
| 437 |
+
<div>
|
| 438 |
+
<p className="text-sm font-semibold text-white">Weekly AI Coaching Report</p>
|
| 439 |
+
<p className="text-xs text-zinc-500">Powered by Groq · Based on your real account data</p>
|
| 440 |
+
</div>
|
| 441 |
+
</div>
|
| 442 |
+
<div className="space-y-1">
|
| 443 |
+
{coachData.coaching_report.split("\n").map((line, i) => {
|
| 444 |
+
if (!line.trim()) return <div key={i} className="h-2" />;
|
| 445 |
+
const isHeader = /^[0-9]+\.|^[A-Z\s]{4,}$/.test(line.trim()) && line.length < 40;
|
| 446 |
+
return isHeader
|
| 447 |
+
? <p key={i} className="text-xs font-bold text-emerald-400 uppercase tracking-wider mt-3 mb-1">{line}</p>
|
| 448 |
+
: <p key={i} className="text-sm text-zinc-300 leading-relaxed">{line}</p>;
|
| 449 |
+
})}
|
| 450 |
+
</div>
|
| 451 |
+
</div>
|
| 452 |
+
|
| 453 |
+
{coachData.improvements.length > 0 && (
|
| 454 |
+
<div className="glass-card p-5">
|
| 455 |
+
<p className="text-sm font-semibold text-white mb-3">Top Actions This Week</p>
|
| 456 |
+
<div className="space-y-2">
|
| 457 |
+
{coachData.improvements.map((imp, i) => (
|
| 458 |
+
<div key={i} className="flex items-start gap-3 rounded-xl bg-white/3 border border-white/8 px-3 py-2.5">
|
| 459 |
+
<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>
|
| 460 |
+
<p className="text-xs text-zinc-300">{imp}</p>
|
| 461 |
+
</div>
|
| 462 |
+
))}
|
| 463 |
+
</div>
|
| 464 |
+
</div>
|
| 465 |
+
)}
|
| 466 |
+
</>
|
| 467 |
+
) : (
|
| 468 |
+
<div className="glass-card p-8 text-center">
|
| 469 |
+
<Brain className="h-10 w-10 text-zinc-700 mx-auto mb-3" />
|
| 470 |
+
<p className="text-sm text-zinc-500">Could not load coaching data</p>
|
| 471 |
+
</div>
|
| 472 |
+
)}
|
| 473 |
+
</motion.div>
|
| 474 |
+
)}
|
| 475 |
+
|
| 476 |
+
{/* ── Monthly Story ── */}
|
| 477 |
+
{activeTab === "narrative" && (
|
| 478 |
+
<motion.div variants={itemVariants} className="space-y-4">
|
| 479 |
+
{narrativeLoading ? (
|
| 480 |
+
<div className="glass-card p-8 flex flex-col items-center gap-4">
|
| 481 |
+
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-500/15 border border-blue-500/20">
|
| 482 |
+
<BookOpen className="h-6 w-6 text-blue-400 animate-pulse" />
|
| 483 |
+
</div>
|
| 484 |
+
<p className="text-sm text-zinc-400">Writing your monthly financial story...</p>
|
| 485 |
+
<div className="flex gap-1">
|
| 486 |
+
{[0,1,2].map(i => (
|
| 487 |
+
<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" />
|
| 488 |
+
))}
|
| 489 |
+
</div>
|
| 490 |
+
</div>
|
| 491 |
+
) : narrativeData ? (
|
| 492 |
+
<>
|
| 493 |
+
<div className="flex items-center justify-between">
|
| 494 |
+
<div>
|
| 495 |
+
<h2 className="text-xl font-bold text-white">{narrativeData.month}</h2>
|
| 496 |
+
<p className="text-xs text-zinc-500 mt-0.5">AI-generated financial story</p>
|
| 497 |
+
</div>
|
| 498 |
+
<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"}`}>
|
| 499 |
+
{narrativeData.summary.spend_delta_pct > 0 ? "▲" : "▼"} {Math.abs(narrativeData.summary.spend_delta_pct).toFixed(1)}% spending
|
| 500 |
+
</div>
|
| 501 |
+
</div>
|
| 502 |
+
|
| 503 |
+
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
| 504 |
+
{[
|
| 505 |
+
{ label: "Total Spend", value: `$${narrativeData.summary.total_spend.toLocaleString()}`, color: "text-white" },
|
| 506 |
+
{ label: "Total Income", value: `$${narrativeData.summary.total_income.toLocaleString()}`, color: "text-emerald-400" },
|
| 507 |
+
{ label: "Savings", value: `$${narrativeData.summary.savings_balance.toLocaleString()}`, color: "text-blue-400" },
|
| 508 |
+
{ label: "Portfolio", value: `$${narrativeData.summary.investment_value.toLocaleString()}`, color: narrativeData.summary.investment_gain_pct >= 0 ? "text-emerald-400" : "text-red-400" },
|
| 509 |
+
].map((s) => (
|
| 510 |
+
<div key={s.label} className="glass-card p-4">
|
| 511 |
+
<p className="text-xs text-zinc-400">{s.label}</p>
|
| 512 |
+
<p className={`text-lg font-bold mt-1 ${s.color}`}>{s.value}</p>
|
| 513 |
+
</div>
|
| 514 |
+
))}
|
| 515 |
+
</div>
|
| 516 |
+
|
| 517 |
+
{narrativeData.category_changes.length > 0 && (
|
| 518 |
+
<div className="glass-card p-5">
|
| 519 |
+
<p className="text-sm font-semibold text-white mb-3">Category Changes vs Last Month</p>
|
| 520 |
+
<div className="space-y-2">
|
| 521 |
+
{narrativeData.category_changes.slice(0, 6).map((c, i) => (
|
| 522 |
+
<div key={i} className="flex items-center gap-3">
|
| 523 |
+
<span className="text-xs text-zinc-400 w-24 truncate">{c.category}</span>
|
| 524 |
+
<div className="flex-1 flex items-center gap-2">
|
| 525 |
+
<span className="text-xs text-zinc-500 w-16 text-right">${c.last_month.toLocaleString()}</span>
|
| 526 |
+
<div className="flex-1 h-1 rounded-full bg-white/5" />
|
| 527 |
+
<span className="text-xs font-medium text-white w-16">${c.this_month.toLocaleString()}</span>
|
| 528 |
+
</div>
|
| 529 |
+
<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"}`}>
|
| 530 |
+
{c.delta_pct > 0 ? "+" : ""}{c.delta_pct.toFixed(0)}%
|
| 531 |
+
</span>
|
| 532 |
+
</div>
|
| 533 |
+
))}
|
| 534 |
+
</div>
|
| 535 |
+
</div>
|
| 536 |
+
)}
|
| 537 |
+
|
| 538 |
+
<div className="glass-card p-6">
|
| 539 |
+
<div className="flex items-center gap-2 mb-4">
|
| 540 |
+
<div className="flex h-8 w-8 items-center justify-center rounded-xl bg-blue-500/15 border border-blue-500/20">
|
| 541 |
+
<BookOpen className="h-4 w-4 text-blue-400" />
|
| 542 |
+
</div>
|
| 543 |
+
<div>
|
| 544 |
+
<p className="text-sm font-semibold text-white">Your Monthly Financial Story</p>
|
| 545 |
+
<p className="text-xs text-zinc-500">AI-written · Based on your real transactions</p>
|
| 546 |
+
</div>
|
| 547 |
+
</div>
|
| 548 |
+
<div className="space-y-1">
|
| 549 |
+
{narrativeData.narrative.split("\n").map((line, i) => {
|
| 550 |
+
if (!line.trim()) return <div key={i} className="h-2" />;
|
| 551 |
+
const isHeader = /^[A-Z\s]{4,}$/.test(line.trim()) && line.length < 30;
|
| 552 |
+
return isHeader
|
| 553 |
+
? <p key={i} className="text-xs font-bold text-blue-400 uppercase tracking-wider mt-3 mb-1">{line}</p>
|
| 554 |
+
: <p key={i} className="text-sm text-zinc-300 leading-relaxed">{line}</p>;
|
| 555 |
+
})}
|
| 556 |
+
</div>
|
| 557 |
+
</div>
|
| 558 |
+
|
| 559 |
+
<div className="flex items-center gap-3 rounded-2xl border border-purple-500/20 bg-purple-500/5 px-5 py-3">
|
| 560 |
+
<Sparkles className="h-4 w-4 text-purple-400 flex-shrink-0" />
|
| 561 |
+
<p className="text-xs text-zinc-400">
|
| 562 |
+
<span className="text-purple-400 font-medium">${narrativeData.summary.monthly_subscriptions.toFixed(2)}/month</span>
|
| 563 |
+
{" "}in active subscriptions · Review unused ones to reclaim budget.
|
| 564 |
+
</p>
|
| 565 |
+
</div>
|
| 566 |
+
</>
|
| 567 |
+
) : (
|
| 568 |
+
<div className="glass-card p-8 text-center">
|
| 569 |
+
<BookOpen className="h-10 w-10 text-zinc-700 mx-auto mb-3" />
|
| 570 |
+
<p className="text-sm text-zinc-500">Could not load narrative data</p>
|
| 571 |
+
</div>
|
| 572 |
+
)}
|
| 573 |
+
</motion.div>
|
| 574 |
+
)}
|
| 575 |
</motion.div>
|
| 576 |
);
|
| 577 |
}
|
frontend/src/app/chat/page.tsx
CHANGED
|
@@ -20,10 +20,10 @@ interface Message {
|
|
| 20 |
|
| 21 |
// ─── Suggested Prompts ────────────────────────────────────────────────────────
|
| 22 |
const suggestions = [
|
| 23 |
-
{ icon: TrendingUp, label: "
|
| 24 |
-
{ icon: PieChart, label: "Analyze my spending", color: "text-blue-400",
|
| 25 |
-
{ icon: Shield, label: "
|
| 26 |
-
{ icon: Zap, label: "
|
| 27 |
];
|
| 28 |
|
| 29 |
// ─── AI Orb ───────────────────────────────────────────────────────────────────
|
|
|
|
| 20 |
|
| 21 |
// ─── Suggested Prompts ────────────────────────────────────────────────────────
|
| 22 |
const suggestions = [
|
| 23 |
+
{ icon: TrendingUp, label: "What's my total balance?", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20" },
|
| 24 |
+
{ icon: PieChart, label: "Analyze my spending this month", color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/20" },
|
| 25 |
+
{ icon: Shield, label: "Explain my fraud alerts", color: "text-purple-400", bg: "bg-purple-500/10 border-purple-500/20" },
|
| 26 |
+
{ icon: Zap, label: "Give me a savings nudge", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20" },
|
| 27 |
];
|
| 28 |
|
| 29 |
// ─── AI Orb ───────────────────────────────────────────────────────────────────
|
frontend/src/app/security/page.tsx
CHANGED
|
@@ -1,7 +1,413 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { useState, useEffect, useCallback } from "react";
|
| 4 |
-
import { motion } from "framer-motion";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
import {
|
| 6 |
Shield, AlertTriangle, CheckCircle, RefreshCw,
|
| 7 |
TrendingUp, Clock, Zap, Eye, XCircle, Activity
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import { useState, useEffect, useCallback } from "react";
|
| 4 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 5 |
+
import {
|
| 6 |
+
Shield, AlertTriangle, CheckCircle, RefreshCw,
|
| 7 |
+
TrendingUp, Clock, Zap, Eye, XCircle, Activity,
|
| 8 |
+
Sparkles, ChevronDown, ChevronUp, Loader2,
|
| 9 |
+
} from "lucide-react";
|
| 10 |
+
import { aiApi } from "@/lib/api";
|
| 11 |
+
import { useAuthStore } from "@/lib/stores/authStore";
|
| 12 |
+
|
| 13 |
+
interface FraudAlert {
|
| 14 |
+
id: string;
|
| 15 |
+
fraud_log_id?: string;
|
| 16 |
+
risk_score: number;
|
| 17 |
+
details: string;
|
| 18 |
+
status?: string;
|
| 19 |
+
transaction_id?: string;
|
| 20 |
+
merchant?: string;
|
| 21 |
+
amount?: number;
|
| 22 |
+
timestamp?: string;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
interface FraudData {
|
| 26 |
+
total_alerts: number;
|
| 27 |
+
pending_reviews: number;
|
| 28 |
+
alerts: FraudAlert[];
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
interface FraudExplanation {
|
| 32 |
+
ai_explanation: string;
|
| 33 |
+
raw_reasons: string[];
|
| 34 |
+
amount_vs_avg: number;
|
| 35 |
+
user_avg_amount: number;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function Skeleton({ className }: { className?: string }) {
|
| 39 |
+
return <div className={`shimmer rounded-lg ${className}`} />;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function RiskBadge({ score }: { score: number }) {
|
| 43 |
+
const level =
|
| 44 |
+
score >= 70 ? { label: "High Risk", color: "text-red-400", bg: "bg-red-500/10 border-red-500/20" }
|
| 45 |
+
: score >= 40 ? { label: "Suspicious", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20" }
|
| 46 |
+
: { label: "Low Risk", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20" };
|
| 47 |
+
return (
|
| 48 |
+
<span className={`rounded-full border px-2.5 py-1 text-xs font-semibold ${level.color} ${level.bg}`}>
|
| 49 |
+
{level.label}
|
| 50 |
+
</span>
|
| 51 |
+
);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function RiskBar({ score }: { score: number }) {
|
| 55 |
+
const color = score >= 70 ? "#ef4444" : score >= 40 ? "#f59e0b" : "#10b981";
|
| 56 |
+
return (
|
| 57 |
+
<div className="flex items-center gap-2">
|
| 58 |
+
<div className="h-1.5 w-24 rounded-full bg-white/10 overflow-hidden">
|
| 59 |
+
<motion.div
|
| 60 |
+
initial={{ width: 0 }}
|
| 61 |
+
animate={{ width: `${score}%` }}
|
| 62 |
+
transition={{ duration: 0.8, ease: "easeOut" }}
|
| 63 |
+
className="h-full rounded-full"
|
| 64 |
+
style={{ background: color }}
|
| 65 |
+
/>
|
| 66 |
+
</div>
|
| 67 |
+
<span className="text-xs font-mono" style={{ color }}>{score}</span>
|
| 68 |
+
</div>
|
| 69 |
+
);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
// ─── Alert Row with expandable AI explanation ─────────────────────────────────
|
| 73 |
+
function AlertRow({ alert, index }: { alert: FraudAlert; index: number }) {
|
| 74 |
+
const [expanded, setExpanded] = useState(false);
|
| 75 |
+
const [explanation, setExplanation] = useState<FraudExplanation | null>(null);
|
| 76 |
+
const [loadingExpl, setLoadingExpl] = useState(false);
|
| 77 |
+
|
| 78 |
+
const loadExplanation = async () => {
|
| 79 |
+
const logId = alert.fraud_log_id || alert.id;
|
| 80 |
+
if (!logId || explanation) { setExpanded(!expanded); return; }
|
| 81 |
+
setExpanded(true);
|
| 82 |
+
setLoadingExpl(true);
|
| 83 |
+
try {
|
| 84 |
+
const res = await aiApi.fraudExplain(logId);
|
| 85 |
+
setExplanation(res);
|
| 86 |
+
} catch {
|
| 87 |
+
setExplanation({
|
| 88 |
+
ai_explanation: alert.details || "Suspicious activity detected based on transaction patterns.",
|
| 89 |
+
raw_reasons: typeof alert.details === "string" ? alert.details.split("; ") : [],
|
| 90 |
+
amount_vs_avg: 0,
|
| 91 |
+
user_avg_amount: 0,
|
| 92 |
+
});
|
| 93 |
+
} finally {
|
| 94 |
+
setLoadingExpl(false);
|
| 95 |
+
}
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
return (
|
| 99 |
+
<motion.div
|
| 100 |
+
initial={{ opacity: 0, x: -8 }}
|
| 101 |
+
animate={{ opacity: 1, x: 0 }}
|
| 102 |
+
transition={{ delay: index * 0.04 }}
|
| 103 |
+
className={`border-b border-white/5 last:border-0 ${alert.risk_score >= 70 ? "bg-red-500/2" : ""}`}
|
| 104 |
+
>
|
| 105 |
+
{/* Main row */}
|
| 106 |
+
<div
|
| 107 |
+
className="flex items-center gap-4 px-6 py-4 hover:bg-white/[0.02] transition-colors cursor-pointer"
|
| 108 |
+
onClick={loadExplanation}
|
| 109 |
+
>
|
| 110 |
+
<div className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl border ${
|
| 111 |
+
alert.risk_score >= 70 ? "bg-red-500/10 border-red-500/20"
|
| 112 |
+
: alert.risk_score >= 40 ? "bg-amber-500/10 border-amber-500/20"
|
| 113 |
+
: "bg-emerald-500/10 border-emerald-500/20"
|
| 114 |
+
}`}>
|
| 115 |
+
{alert.risk_score >= 70 ? <XCircle className="h-5 w-5 text-red-400" />
|
| 116 |
+
: alert.risk_score >= 40 ? <AlertTriangle className="h-5 w-5 text-amber-400" />
|
| 117 |
+
: <CheckCircle className="h-5 w-5 text-emerald-400" />}
|
| 118 |
+
</div>
|
| 119 |
+
|
| 120 |
+
<div className="flex-1 min-w-0">
|
| 121 |
+
<div className="flex items-center gap-2 flex-wrap">
|
| 122 |
+
<p className="text-sm font-medium text-white truncate">
|
| 123 |
+
{alert.merchant || `Transaction ${(alert.transaction_id || alert.id).slice(0, 8)}...`}
|
| 124 |
+
</p>
|
| 125 |
+
<RiskBadge score={alert.risk_score} />
|
| 126 |
+
{alert.status && (
|
| 127 |
+
<span className="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-zinc-400 capitalize">
|
| 128 |
+
{alert.status}
|
| 129 |
+
</span>
|
| 130 |
+
)}
|
| 131 |
+
</div>
|
| 132 |
+
<p className="text-xs text-zinc-500 mt-0.5 truncate">
|
| 133 |
+
{typeof alert.details === "string" ? alert.details
|
| 134 |
+
: Array.isArray(alert.details) ? (alert.details as string[]).join(" · ")
|
| 135 |
+
: "Suspicious activity detected"}
|
| 136 |
+
</p>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
<div className="flex-shrink-0 hidden sm:block">
|
| 140 |
+
<RiskBar score={alert.risk_score} />
|
| 141 |
+
</div>
|
| 142 |
+
|
| 143 |
+
{alert.amount != null && (
|
| 144 |
+
<div className="flex-shrink-0 text-right">
|
| 145 |
+
<p className="text-sm font-semibold text-white">${Math.abs(alert.amount).toFixed(2)}</p>
|
| 146 |
+
</div>
|
| 147 |
+
)}
|
| 148 |
+
|
| 149 |
+
<div className="flex-shrink-0 flex items-center gap-1 text-zinc-500">
|
| 150 |
+
<Sparkles className="h-3.5 w-3.5 text-cyan-400" />
|
| 151 |
+
{expanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
| 152 |
+
</div>
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
{/* AI Explanation panel */}
|
| 156 |
+
<AnimatePresence>
|
| 157 |
+
{expanded && (
|
| 158 |
+
<motion.div
|
| 159 |
+
initial={{ height: 0, opacity: 0 }}
|
| 160 |
+
animate={{ height: "auto", opacity: 1 }}
|
| 161 |
+
exit={{ height: 0, opacity: 0 }}
|
| 162 |
+
transition={{ duration: 0.25 }}
|
| 163 |
+
className="overflow-hidden"
|
| 164 |
+
>
|
| 165 |
+
<div className="mx-6 mb-4 rounded-xl border border-cyan-500/20 bg-cyan-500/5 p-4">
|
| 166 |
+
<div className="flex items-start gap-2">
|
| 167 |
+
<Sparkles className="h-4 w-4 text-cyan-400 flex-shrink-0 mt-0.5" />
|
| 168 |
+
<div className="flex-1">
|
| 169 |
+
<p className="text-xs font-semibold text-cyan-400 mb-2">AI Fraud Explanation</p>
|
| 170 |
+
{loadingExpl ? (
|
| 171 |
+
<div className="flex items-center gap-2">
|
| 172 |
+
<Loader2 className="h-3.5 w-3.5 animate-spin text-cyan-400" />
|
| 173 |
+
<span className="text-xs text-zinc-400">Analyzing transaction patterns...</span>
|
| 174 |
+
</div>
|
| 175 |
+
) : explanation ? (
|
| 176 |
+
<div className="space-y-3">
|
| 177 |
+
<p className="text-sm text-zinc-200 leading-relaxed">{explanation.ai_explanation}</p>
|
| 178 |
+
{explanation.raw_reasons.length > 0 && (
|
| 179 |
+
<div className="space-y-1">
|
| 180 |
+
<p className="text-xs font-medium text-zinc-400">Detection signals:</p>
|
| 181 |
+
{explanation.raw_reasons.map((r, i) => (
|
| 182 |
+
<div key={i} className="flex items-start gap-1.5">
|
| 183 |
+
<div className="h-1.5 w-1.5 rounded-full bg-amber-400 flex-shrink-0 mt-1.5" />
|
| 184 |
+
<p className="text-xs text-zinc-400">{r}</p>
|
| 185 |
+
</div>
|
| 186 |
+
))}
|
| 187 |
+
</div>
|
| 188 |
+
)}
|
| 189 |
+
{explanation.amount_vs_avg > 0 && (
|
| 190 |
+
<div className="flex items-center gap-4 pt-1 border-t border-white/8">
|
| 191 |
+
<div>
|
| 192 |
+
<p className="text-[10px] text-zinc-500">This transaction</p>
|
| 193 |
+
<p className="text-sm font-bold text-white">${alert.amount?.toFixed(2)}</p>
|
| 194 |
+
</div>
|
| 195 |
+
<div className="text-zinc-600">vs</div>
|
| 196 |
+
<div>
|
| 197 |
+
<p className="text-[10px] text-zinc-500">Your average</p>
|
| 198 |
+
<p className="text-sm font-bold text-zinc-300">${explanation.user_avg_amount.toFixed(2)}</p>
|
| 199 |
+
</div>
|
| 200 |
+
<div className="ml-auto">
|
| 201 |
+
<span className="rounded-full bg-red-500/15 border border-red-500/30 px-2 py-0.5 text-xs font-bold text-red-400">
|
| 202 |
+
{explanation.amount_vs_avg.toFixed(1)}× average
|
| 203 |
+
</span>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
)}
|
| 207 |
+
</div>
|
| 208 |
+
) : null}
|
| 209 |
+
</div>
|
| 210 |
+
</div>
|
| 211 |
+
</div>
|
| 212 |
+
</motion.div>
|
| 213 |
+
)}
|
| 214 |
+
</AnimatePresence>
|
| 215 |
+
</motion.div>
|
| 216 |
+
);
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
const containerVariants = {
|
| 220 |
+
hidden: { opacity: 0 },
|
| 221 |
+
visible: { opacity: 1, transition: { staggerChildren: 0.07 } },
|
| 222 |
+
};
|
| 223 |
+
const itemVariants = {
|
| 224 |
+
hidden: { opacity: 0, y: 16 },
|
| 225 |
+
visible: { opacity: 1, y: 0, transition: { duration: 0.4 } },
|
| 226 |
+
};
|
| 227 |
+
|
| 228 |
+
export default function SecurityPage() {
|
| 229 |
+
const { user } = useAuthStore();
|
| 230 |
+
const [data, setData] = useState<FraudData | null>(null);
|
| 231 |
+
const [loading, setLoading] = useState(true);
|
| 232 |
+
const [error, setError] = useState<string | null>(null);
|
| 233 |
+
const [filter, setFilter] = useState<"all" | "high" | "pending">("all");
|
| 234 |
+
|
| 235 |
+
const load = useCallback(async () => {
|
| 236 |
+
setLoading(true);
|
| 237 |
+
setError(null);
|
| 238 |
+
try {
|
| 239 |
+
const res = await aiApi.fraudAnalysis(user?.user_id);
|
| 240 |
+
const raw = res as unknown as Record<string, unknown>;
|
| 241 |
+
const alerts: FraudAlert[] = (raw.alerts as FraudAlert[]) || [];
|
| 242 |
+
setData({
|
| 243 |
+
total_alerts: (raw.total_alerts as number) ?? alerts.length,
|
| 244 |
+
pending_reviews: (raw.pending_reviews as number) ?? alerts.filter((a) => a.status === "pending").length,
|
| 245 |
+
alerts,
|
| 246 |
+
});
|
| 247 |
+
} catch (err) {
|
| 248 |
+
setError((err as Error).message);
|
| 249 |
+
} finally {
|
| 250 |
+
setLoading(false);
|
| 251 |
+
}
|
| 252 |
+
}, [user?.user_id]);
|
| 253 |
+
|
| 254 |
+
useEffect(() => { load(); }, [load]);
|
| 255 |
+
|
| 256 |
+
const alerts = data?.alerts || [];
|
| 257 |
+
const filtered = alerts.filter((a) => {
|
| 258 |
+
if (filter === "high") return a.risk_score >= 70;
|
| 259 |
+
if (filter === "pending") return a.status === "pending" || !a.status;
|
| 260 |
+
return true;
|
| 261 |
+
});
|
| 262 |
+
|
| 263 |
+
const highRiskCount = alerts.filter((a) => a.risk_score >= 70).length;
|
| 264 |
+
const suspiciousCount = alerts.filter((a) => a.risk_score >= 40 && a.risk_score < 70).length;
|
| 265 |
+
const safeCount = alerts.filter((a) => a.risk_score < 40).length;
|
| 266 |
+
const avgScore = alerts.length
|
| 267 |
+
? Math.round(alerts.reduce((s, a) => s + a.risk_score, 0) / alerts.length)
|
| 268 |
+
: 0;
|
| 269 |
+
|
| 270 |
+
return (
|
| 271 |
+
<motion.div variants={containerVariants} initial="hidden" animate="visible" className="flex flex-col gap-6">
|
| 272 |
+
{/* Header */}
|
| 273 |
+
<motion.div variants={itemVariants} className="flex items-center justify-between">
|
| 274 |
+
<div>
|
| 275 |
+
<h1 className="text-3xl font-bold tracking-tight text-white">Security Center</h1>
|
| 276 |
+
<p className="text-zinc-400 mt-1 text-sm">AI-powered fraud detection · Click any alert for a full AI explanation</p>
|
| 277 |
+
</div>
|
| 278 |
+
<button onClick={load} 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">
|
| 279 |
+
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
|
| 280 |
+
Refresh
|
| 281 |
+
</button>
|
| 282 |
+
</motion.div>
|
| 283 |
+
|
| 284 |
+
{error && (
|
| 285 |
+
<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">
|
| 286 |
+
<AlertTriangle className="h-4 w-4 text-amber-400 flex-shrink-0" />
|
| 287 |
+
<p className="text-xs text-zinc-400"><span className="text-amber-400 font-medium">Could not load fraud data</span> — {error}</p>
|
| 288 |
+
</motion.div>
|
| 289 |
+
)}
|
| 290 |
+
|
| 291 |
+
{/* AI Shield Banner */}
|
| 292 |
+
<motion.div variants={itemVariants} className="flex items-center gap-4 rounded-2xl border border-emerald-500/20 bg-gradient-to-r from-emerald-500/10 via-cyan-500/5 to-blue-500/10 px-5 py-4">
|
| 293 |
+
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-500/20 border border-emerald-500/30 flex-shrink-0">
|
| 294 |
+
<Shield className="h-5 w-5 text-emerald-400" />
|
| 295 |
+
</div>
|
| 296 |
+
<div className="flex-1">
|
| 297 |
+
<p className="text-sm font-semibold text-white">AI Fraud Shield Active</p>
|
| 298 |
+
<p className="text-xs text-zinc-400 mt-0.5">
|
| 299 |
+
Click any flagged transaction to get a full AI explanation of exactly why it was flagged.
|
| 300 |
+
</p>
|
| 301 |
+
</div>
|
| 302 |
+
<div className="flex items-center gap-1.5">
|
| 303 |
+
<div className="h-2 w-2 rounded-full bg-emerald-400 animate-pulse" />
|
| 304 |
+
<span className="text-xs text-emerald-400 font-medium">Live</span>
|
| 305 |
+
</div>
|
| 306 |
+
</motion.div>
|
| 307 |
+
|
| 308 |
+
{/* Stats */}
|
| 309 |
+
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
| 310 |
+
{[
|
| 311 |
+
{ label: "Total Alerts", value: loading ? "—" : String(data?.total_alerts ?? 0), icon: Activity, color: "text-blue-400", border: "border-blue-500/20", bg: "from-blue-500/10 to-blue-500/5" },
|
| 312 |
+
{ label: "High Risk", value: loading ? "—" : String(highRiskCount), icon: XCircle, color: "text-red-400", border: "border-red-500/20", bg: "from-red-500/10 to-red-500/5" },
|
| 313 |
+
{ label: "Suspicious", value: loading ? "—" : String(suspiciousCount), icon: AlertTriangle, color: "text-amber-400", border: "border-amber-500/20", bg: "from-amber-500/10 to-amber-500/5" },
|
| 314 |
+
{ label: "Avg Risk Score", value: loading ? "—" : `${avgScore}/100`, icon: TrendingUp, color: "text-purple-400", border: "border-purple-500/20", bg: "from-purple-500/10 to-purple-500/5" },
|
| 315 |
+
].map((stat) => (
|
| 316 |
+
<motion.div key={stat.label} variants={itemVariants} className={`rounded-2xl border ${stat.border} bg-gradient-to-br ${stat.bg} p-5`}>
|
| 317 |
+
<div className="flex items-start justify-between">
|
| 318 |
+
<div>
|
| 319 |
+
<p className="text-xs text-zinc-400 uppercase tracking-wider">{stat.label}</p>
|
| 320 |
+
<p className={`mt-2 text-2xl font-bold ${stat.color}`}>{stat.value}</p>
|
| 321 |
+
</div>
|
| 322 |
+
<stat.icon className={`h-5 w-5 ${stat.color} opacity-60`} />
|
| 323 |
+
</div>
|
| 324 |
+
</motion.div>
|
| 325 |
+
))}
|
| 326 |
+
</div>
|
| 327 |
+
|
| 328 |
+
{/* Detection Rules */}
|
| 329 |
+
<motion.div variants={itemVariants} className="glass-card p-6">
|
| 330 |
+
<h2 className="text-base font-semibold text-white mb-4">Active Detection Rules</h2>
|
| 331 |
+
<div className="grid gap-3 sm:grid-cols-2">
|
| 332 |
+
{[
|
| 333 |
+
{ icon: TrendingUp, label: "Amount Spike Detection", desc: "Flags transactions 3.5× above your average" },
|
| 334 |
+
{ icon: Clock, label: "Late-Night Monitoring", desc: "Alerts on activity between 11PM – 4AM" },
|
| 335 |
+
{ icon: Zap, label: "Rapid Transaction Guard", desc: "Detects multiple transactions within 3 minutes" },
|
| 336 |
+
{ icon: Eye, label: "Duplicate Payment Check", desc: "Catches same merchant + amount within 10 minutes" },
|
| 337 |
+
].map((rule) => (
|
| 338 |
+
<div key={rule.label} className="flex items-start gap-3 rounded-xl border border-white/8 bg-white/[0.02] p-4">
|
| 339 |
+
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex-shrink-0">
|
| 340 |
+
<rule.icon className="h-4 w-4 text-emerald-400" />
|
| 341 |
+
</div>
|
| 342 |
+
<div className="flex-1 min-w-0">
|
| 343 |
+
<div className="flex items-center justify-between gap-2">
|
| 344 |
+
<p className="text-sm font-medium text-white">{rule.label}</p>
|
| 345 |
+
<div className="flex items-center gap-1 flex-shrink-0">
|
| 346 |
+
<div className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
| 347 |
+
<span className="text-[10px] text-emerald-400">ON</span>
|
| 348 |
+
</div>
|
| 349 |
+
</div>
|
| 350 |
+
<p className="text-xs text-zinc-500 mt-0.5">{rule.desc}</p>
|
| 351 |
+
</div>
|
| 352 |
+
</div>
|
| 353 |
+
))}
|
| 354 |
+
</div>
|
| 355 |
+
</motion.div>
|
| 356 |
+
|
| 357 |
+
{/* Alerts Table */}
|
| 358 |
+
<motion.div variants={itemVariants} className="glass-card overflow-hidden">
|
| 359 |
+
<div className="flex items-center justify-between px-6 py-4 border-b border-white/8">
|
| 360 |
+
<div>
|
| 361 |
+
<h2 className="text-base font-semibold text-white">Fraud Alerts</h2>
|
| 362 |
+
<p className="text-xs text-zinc-400 mt-0.5">
|
| 363 |
+
{loading ? "Loading..." : `${filtered.length} alert${filtered.length !== 1 ? "s" : ""} · Click any row for AI explanation`}
|
| 364 |
+
</p>
|
| 365 |
+
</div>
|
| 366 |
+
<div className="flex items-center gap-1 rounded-xl border border-white/10 bg-white/5 p-1">
|
| 367 |
+
{(["all", "high", "pending"] as const).map((f) => (
|
| 368 |
+
<button key={f} onClick={() => setFilter(f)}
|
| 369 |
+
className={`rounded-lg px-3 py-1.5 text-xs font-medium capitalize transition-all ${filter === f ? "bg-white/10 text-white" : "text-zinc-500 hover:text-zinc-300"}`}>
|
| 370 |
+
{f === "all" ? "All" : f === "high" ? "High Risk" : "Pending"}
|
| 371 |
+
</button>
|
| 372 |
+
))}
|
| 373 |
+
</div>
|
| 374 |
+
</div>
|
| 375 |
+
|
| 376 |
+
{loading ? (
|
| 377 |
+
<div className="space-y-px p-4">
|
| 378 |
+
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-16 w-full rounded-xl" />)}
|
| 379 |
+
</div>
|
| 380 |
+
) : filtered.length === 0 ? (
|
| 381 |
+
<div className="flex flex-col items-center gap-3 py-16 text-center">
|
| 382 |
+
<CheckCircle className="h-12 w-12 text-emerald-500/40" />
|
| 383 |
+
<p className="text-sm font-medium text-zinc-400">
|
| 384 |
+
{alerts.length === 0 ? "No fraud alerts detected" : "No alerts match this filter"}
|
| 385 |
+
</p>
|
| 386 |
+
<p className="text-xs text-zinc-600">
|
| 387 |
+
{alerts.length === 0 ? "All transactions look clean. AI Shield is monitoring in real-time." : "Try a different filter."}
|
| 388 |
+
</p>
|
| 389 |
+
</div>
|
| 390 |
+
) : (
|
| 391 |
+
<div>
|
| 392 |
+
{filtered.map((alert, i) => (
|
| 393 |
+
<AlertRow key={alert.id} alert={alert} index={i} />
|
| 394 |
+
))}
|
| 395 |
+
</div>
|
| 396 |
+
)}
|
| 397 |
+
</motion.div>
|
| 398 |
+
|
| 399 |
+
{!loading && safeCount > 0 && (
|
| 400 |
+
<motion.div variants={itemVariants} className="flex items-center gap-3 rounded-2xl border border-emerald-500/20 bg-emerald-500/5 px-5 py-3">
|
| 401 |
+
<CheckCircle className="h-4 w-4 text-emerald-400 flex-shrink-0" />
|
| 402 |
+
<p className="text-xs text-zinc-400">
|
| 403 |
+
<span className="text-emerald-400 font-medium">{safeCount} transaction{safeCount !== 1 ? "s" : ""} verified safe</span>
|
| 404 |
+
{" "}— risk score below threshold. No action required.
|
| 405 |
+
</p>
|
| 406 |
+
</motion.div>
|
| 407 |
+
)}
|
| 408 |
+
</motion.div>
|
| 409 |
+
);
|
| 410 |
+
}
|
| 411 |
import {
|
| 412 |
Shield, AlertTriangle, CheckCircle, RefreshCw,
|
| 413 |
TrendingUp, Clock, Zap, Eye, XCircle, Activity
|
frontend/src/lib/api.ts
CHANGED
|
@@ -213,6 +213,64 @@ export const aiApi = {
|
|
| 213 |
body: JSON.stringify({ amount, merchant, category }),
|
| 214 |
});
|
| 215 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
};
|
| 217 |
|
| 218 |
// ─── Notifications ────────────────────────────────────────────────────────────
|
|
|
|
| 213 |
body: JSON.stringify({ amount, merchant, category }),
|
| 214 |
});
|
| 215 |
},
|
| 216 |
+
|
| 217 |
+
// ── Coach Mode ──────────────────────────────────────────────────────────────
|
| 218 |
+
weeklyCoaching: (userId?: string) => {
|
| 219 |
+
const qs = userId ? `?user_id=${userId}` : "";
|
| 220 |
+
return apiFetch<{
|
| 221 |
+
generated_at: string;
|
| 222 |
+
week_start: string;
|
| 223 |
+
week_end: string;
|
| 224 |
+
health_score: number;
|
| 225 |
+
week_spend: number;
|
| 226 |
+
week_income: number;
|
| 227 |
+
spend_delta_pct: number;
|
| 228 |
+
top_categories: Array<{ name: string; amount: number }>;
|
| 229 |
+
anomalies: string[];
|
| 230 |
+
coaching_report: string;
|
| 231 |
+
improvements: string[];
|
| 232 |
+
}>(`/api/ai/coach/weekly${qs}`);
|
| 233 |
+
},
|
| 234 |
+
|
| 235 |
+
// ── Fraud Explanation ───────────────────────────────────────────────────────
|
| 236 |
+
fraudExplain: (fraudLogId: string) =>
|
| 237 |
+
apiFetch<{
|
| 238 |
+
fraud_log_id: string;
|
| 239 |
+
transaction_id: string;
|
| 240 |
+
merchant: string;
|
| 241 |
+
amount: number;
|
| 242 |
+
timestamp: string;
|
| 243 |
+
risk_score: number;
|
| 244 |
+
raw_reasons: string[];
|
| 245 |
+
ai_explanation: string;
|
| 246 |
+
user_avg_amount: number;
|
| 247 |
+
amount_vs_avg: number;
|
| 248 |
+
}>(`/api/ai/fraud/explain/${fraudLogId}`),
|
| 249 |
+
|
| 250 |
+
// ── Monthly Narrative ───────────────────────────────────────────────────────
|
| 251 |
+
monthlyNarrative: (userId?: string) => {
|
| 252 |
+
const qs = userId ? `?user_id=${userId}` : "";
|
| 253 |
+
return apiFetch<{
|
| 254 |
+
month: string;
|
| 255 |
+
generated_at: string;
|
| 256 |
+
summary: {
|
| 257 |
+
total_spend: number;
|
| 258 |
+
total_income: number;
|
| 259 |
+
spend_delta_pct: number;
|
| 260 |
+
savings_balance: number;
|
| 261 |
+
investment_value: number;
|
| 262 |
+
investment_gain_pct: number;
|
| 263 |
+
monthly_subscriptions: number;
|
| 264 |
+
};
|
| 265 |
+
category_changes: Array<{
|
| 266 |
+
category: string;
|
| 267 |
+
this_month: number;
|
| 268 |
+
last_month: number;
|
| 269 |
+
delta_pct: number;
|
| 270 |
+
}>;
|
| 271 |
+
narrative: string;
|
| 272 |
+
}>(`/api/ai/narrative/monthly${qs}`);
|
| 273 |
+
},
|
| 274 |
};
|
| 275 |
|
| 276 |
// ─── Notifications ────────────────────────────────────────────────────────────
|