mohsin-devs commited on
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 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 { TrendingUp, Sparkles, Calendar, Filter, AlertTriangle, RefreshCw } from "lucide-react";
 
 
 
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
- // ─── Main Page ────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  export default function AnalyticsPage() {
71
- const [activeTab, setActiveTab] = useState<"overview" | "categories" | "networth">("overview");
 
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<number>(0);
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
- const weekendBoost = (day >= 5 ? weekendPct * 200 : 0);
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
- // Build stacked bar data from cash flow (add category breakdown per month)
147
- const stackedData = cashFlow.map((m) => ({
148
- month: m.month,
149
- income: m.income,
150
- expenses: m.expenses,
151
- savings: m.savings,
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
- onClick={loadData}
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
- {/* Tabs */}
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 const).map((tab) => (
199
- <button
200
- key={tab}
201
- onClick={() => setActiveTab(tab)}
202
- className={`rounded-lg px-4 py-2 text-xs font-medium capitalize transition-all ${
203
- activeTab === tab ? "bg-white/10 text-white shadow-sm" : "text-zinc-500 hover:text-zinc-300"
204
- }`}
205
- >
206
- {tab === "networth" ? "Net Worth" : tab.charAt(0).toUpperCase() + tab.slice(1)}
207
  </button>
208
  ))}
209
  </motion.div>
210
 
211
- {/* ── Overview Tab ── */}
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
- <h2 className="text-base font-semibold text-white">Spending Heatmap</h2>
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
- <h2 className="text-base font-semibold text-white">Financial Health Score</h2>
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 Tab ── */}
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 Tab ── */}
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
- dot={{ fill: "#10b981", r: 4, strokeWidth: 0 }}
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: "Forecast my balance", color: "text-emerald-400", bg: "bg-emerald-500/10 border-emerald-500/20" },
24
- { icon: PieChart, label: "Analyze my spending", color: "text-blue-400", bg: "bg-blue-500/10 border-blue-500/20" },
25
- { icon: Shield, label: "Check fraud risk", color: "text-purple-400", bg: "bg-purple-500/10 border-purple-500/20" },
26
- { icon: Zap, label: "Optimize my budget", color: "text-amber-400", bg: "bg-amber-500/10 border-amber-500/20" },
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 ────────────────────────────────────────────────────────────