stanlee47 Claude Opus 4.5 commited on
Commit
71a5dfb
·
1 Parent(s): da3e914

Add admin panel with dashboard, patient management, and crisis alerts

Browse files

- Add admin authentication via ADMIN_EMAILS environment variable
- Create admin blueprint with page routes and API endpoints
- Add admin database methods for users, crisis flags, stats, and charts
- Create dashboard with stats cards, session trends, distortion distribution
- Add patient list with search, filters, sorting, pagination, CSV export
- Create patient detail page with Overview, Vitals, Sessions, Crisis History tabs
- Include Chart.js visualizations for distortion patterns and mood tracking
- Add responsive sidebar navigation and toast notifications

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

admin.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Admin Panel Module
3
+ Handles admin routes, authentication, and dashboard functionality
4
+ """
5
+
6
+ from flask import Blueprint, render_template, jsonify, request
7
+ from auth import admin_required, login_user, is_admin, generate_token
8
+ from database import get_db
9
+
10
+ admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
11
+
12
+
13
+ # ==================== PAGE ROUTES ====================
14
+
15
+ @admin_bp.route('/login')
16
+ def admin_login_page():
17
+ """Render admin login page."""
18
+ return render_template('admin_login.html')
19
+
20
+
21
+ @admin_bp.route('/dashboard')
22
+ def admin_dashboard_page():
23
+ """Render admin dashboard page."""
24
+ return render_template('admin_dashboard.html')
25
+
26
+
27
+ @admin_bp.route('/patients')
28
+ def admin_patients_page():
29
+ """Render patient list page."""
30
+ return render_template('admin_patients.html')
31
+
32
+
33
+ @admin_bp.route('/patients/<user_id>')
34
+ def admin_patient_detail_page(user_id):
35
+ """Render patient detail page."""
36
+ return render_template('admin_patient_detail.html', user_id=user_id)
37
+
38
+
39
+ # ==================== API ROUTES ====================
40
+
41
+ @admin_bp.route('/api/login', methods=['POST'])
42
+ def admin_login():
43
+ """Admin login endpoint."""
44
+ data = request.json
45
+ email = data.get('email', '')
46
+ password = data.get('password', '')
47
+
48
+ # Use existing login logic
49
+ result = login_user(email, password)
50
+
51
+ if 'error' in result:
52
+ return jsonify(result), 401
53
+
54
+ # Check if user is admin
55
+ if not is_admin(email):
56
+ return jsonify({'error': 'Admin access required'}), 403
57
+
58
+ return jsonify(result)
59
+
60
+
61
+ @admin_bp.route('/api/stats')
62
+ @admin_required
63
+ def get_dashboard_stats():
64
+ """Get dashboard statistics."""
65
+ db = get_db()
66
+ stats = db.get_dashboard_stats()
67
+ return jsonify(stats)
68
+
69
+
70
+ @admin_bp.route('/api/alerts')
71
+ @admin_required
72
+ def get_alerts():
73
+ """Get crisis alerts."""
74
+ reviewed = request.args.get('reviewed')
75
+
76
+ if reviewed is not None:
77
+ reviewed = reviewed.lower() == 'true'
78
+
79
+ db = get_db()
80
+ alerts = db.get_all_crisis_flags(reviewed=reviewed)
81
+ return jsonify({'alerts': alerts})
82
+
83
+
84
+ @admin_bp.route('/api/alerts/<flag_id>/review', methods=['POST'])
85
+ @admin_required
86
+ def review_alert(flag_id):
87
+ """Mark an alert as reviewed."""
88
+ db = get_db()
89
+ db.mark_crisis_reviewed(flag_id)
90
+ return jsonify({'success': True})
91
+
92
+
93
+ @admin_bp.route('/api/patients')
94
+ @admin_required
95
+ def get_patients():
96
+ """Get all patients."""
97
+ db = get_db()
98
+ patients = db.get_all_users()
99
+ return jsonify({'patients': patients})
100
+
101
+
102
+ @admin_bp.route('/api/patients/<user_id>')
103
+ @admin_required
104
+ def get_patient_detail(user_id):
105
+ """Get full patient data."""
106
+ db = get_db()
107
+ patient = db.get_user_full_details(user_id)
108
+
109
+ if not patient:
110
+ return jsonify({'error': 'Patient not found'}), 404
111
+
112
+ # Add distortion pattern for radar chart
113
+ patient['distortion_pattern'] = db.get_user_distortion_pattern(user_id)
114
+
115
+ # Add mood history
116
+ patient['mood_history'] = db.get_user_mood_history(user_id)
117
+
118
+ # Add wearable summary
119
+ patient['wearable_summary'] = db.get_user_wearable_summary(user_id)
120
+
121
+ return jsonify(patient)
122
+
123
+
124
+ @admin_bp.route('/api/charts/sessions')
125
+ @admin_required
126
+ def get_session_chart_data():
127
+ """Get session trend data for chart."""
128
+ days = request.args.get('days', 30, type=int)
129
+ db = get_db()
130
+ data = db.get_daily_session_counts(days)
131
+ return jsonify({'data': data})
132
+
133
+
134
+ @admin_bp.route('/api/charts/distortions')
135
+ @admin_required
136
+ def get_distortion_chart_data():
137
+ """Get distortion distribution for chart."""
138
+ db = get_db()
139
+ data = db.get_distortion_distribution()
140
+ return jsonify(data)
141
+
142
+
143
+ @admin_bp.route('/api/charts/vitals/<user_id>')
144
+ @admin_required
145
+ def get_vitals_chart_data(user_id):
146
+ """Get vitals time-series for charts."""
147
+ hours = request.args.get('hours', 24, type=int)
148
+ db = get_db()
149
+ data = db.get_wearable_timeseries(user_id, hours)
150
+ return jsonify({'data': data})
151
+
152
+
153
+ @admin_bp.route('/api/charts/mood/<user_id>')
154
+ @admin_required
155
+ def get_mood_chart_data(user_id):
156
+ """Get mood history for charts."""
157
+ limit = request.args.get('limit', 20, type=int)
158
+ db = get_db()
159
+ data = db.get_user_mood_history(user_id, limit)
160
+ return jsonify({'data': data})
app.py CHANGED
@@ -13,11 +13,19 @@ from auth import register_user, login_user, token_required
13
  from crisis_detector import check_for_crisis, get_crisis_response, get_crisis_resources
14
  from prompts import STAGE_GOALS, SUMMARIES, NATURAL_ENDINGS
15
  from exercises import get_exercise_for_group
 
 
16
  import os
17
 
18
- app = Flask(__name__)
 
 
19
  CORS(app)
20
 
 
 
 
 
21
  # Initialize components
22
  classifier = DistortionClassifier()
23
  groq_client = GroqClient(api_key=os.environ.get("GROQ_API_KEY"))
@@ -28,8 +36,8 @@ def home():
28
  return jsonify({
29
  "status": "online",
30
  "app": "CBT Companion",
31
- "version": "2.0.0",
32
- "features": ["multi-user", "auth", "crisis-detection"]
33
  })
34
 
35
 
 
13
  from crisis_detector import check_for_crisis, get_crisis_response, get_crisis_resources
14
  from prompts import STAGE_GOALS, SUMMARIES, NATURAL_ENDINGS
15
  from exercises import get_exercise_for_group
16
+ from wearable import wearable_bp
17
+ from admin import admin_bp
18
  import os
19
 
20
+ app = Flask(__name__,
21
+ template_folder='templates',
22
+ static_folder='static')
23
  CORS(app)
24
 
25
+ # Register blueprints
26
+ app.register_blueprint(wearable_bp)
27
+ app.register_blueprint(admin_bp)
28
+
29
  # Initialize components
30
  classifier = DistortionClassifier()
31
  groq_client = GroqClient(api_key=os.environ.get("GROQ_API_KEY"))
 
36
  return jsonify({
37
  "status": "online",
38
  "app": "CBT Companion",
39
+ "version": "2.1.0",
40
+ "features": ["multi-user", "auth", "crisis-detection", "wearable-integration"]
41
  })
42
 
43
 
auth.py CHANGED
@@ -16,6 +16,14 @@ from database import get_db
16
  JWT_SECRET = os.environ.get("JWT_SECRET", "your-secret-key-change-in-production")
17
  JWT_EXPIRY_HOURS = 24 * 7 # 7 days
18
 
 
 
 
 
 
 
 
 
19
 
20
  def hash_password(password: str) -> str:
21
  """Hash password using SHA-256."""
@@ -128,24 +136,24 @@ def register_user(email: str, password: str, name: str, context: str = "person")
128
  def login_user(email: str, password: str) -> dict:
129
  """
130
  Login a user.
131
-
132
  Returns:
133
  dict with user info and token, or error
134
  """
135
  if not email or not password:
136
  return {"error": "Email and password are required"}
137
-
138
  db = get_db()
139
  user = db.get_user_by_email(email)
140
-
141
  if not user:
142
  return {"error": "Invalid email or password"}
143
-
144
  if not verify_password(password, user["password_hash"]):
145
  return {"error": "Invalid email or password"}
146
-
147
  token = generate_token(user["id"], user["email"])
148
-
149
  return {
150
  "success": True,
151
  "user": {
@@ -156,3 +164,45 @@ def login_user(email: str, password: str) -> dict:
156
  },
157
  "token": token
158
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  JWT_SECRET = os.environ.get("JWT_SECRET", "your-secret-key-change-in-production")
17
  JWT_EXPIRY_HOURS = 24 * 7 # 7 days
18
 
19
+ # Admin Configuration
20
+ # Comma-separated list of admin emails
21
+ ADMIN_EMAILS = [
22
+ email.strip().lower()
23
+ for email in os.environ.get("ADMIN_EMAILS", "").split(",")
24
+ if email.strip()
25
+ ]
26
+
27
 
28
  def hash_password(password: str) -> str:
29
  """Hash password using SHA-256."""
 
136
  def login_user(email: str, password: str) -> dict:
137
  """
138
  Login a user.
139
+
140
  Returns:
141
  dict with user info and token, or error
142
  """
143
  if not email or not password:
144
  return {"error": "Email and password are required"}
145
+
146
  db = get_db()
147
  user = db.get_user_by_email(email)
148
+
149
  if not user:
150
  return {"error": "Invalid email or password"}
151
+
152
  if not verify_password(password, user["password_hash"]):
153
  return {"error": "Invalid email or password"}
154
+
155
  token = generate_token(user["id"], user["email"])
156
+
157
  return {
158
  "success": True,
159
  "user": {
 
164
  },
165
  "token": token
166
  }
167
+
168
+
169
+ def is_admin(email: str) -> bool:
170
+ """Check if an email is in the admin list."""
171
+ return email.lower() in ADMIN_EMAILS
172
+
173
+
174
+ def admin_required(f):
175
+ """Decorator to require valid JWT token AND admin privileges."""
176
+ @wraps(f)
177
+ def decorated(*args, **kwargs):
178
+ token = None
179
+
180
+ # Get token from header
181
+ auth_header = request.headers.get("Authorization")
182
+ if auth_header and auth_header.startswith("Bearer "):
183
+ token = auth_header.split(" ")[1]
184
+
185
+ if not token:
186
+ return jsonify({"error": "Token is missing"}), 401
187
+
188
+ # Decode token
189
+ payload = decode_token(token)
190
+ if not payload:
191
+ return jsonify({"error": "Token is invalid or expired"}), 401
192
+
193
+ # Get user from database
194
+ db = get_db()
195
+ user = db.get_user_by_id(payload["user_id"])
196
+ if not user:
197
+ return jsonify({"error": "User not found"}), 401
198
+
199
+ # Check if user is admin
200
+ if not is_admin(user["email"]):
201
+ return jsonify({"error": "Admin access required"}), 403
202
+
203
+ # Add user to request context
204
+ request.current_user = user
205
+
206
+ return f(*args, **kwargs)
207
+
208
+ return decorated
database.py CHANGED
@@ -119,7 +119,29 @@ class Database:
119
  FOREIGN KEY (user_id) REFERENCES users(id)
120
  )
121
  """)
122
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  self.conn.commit()
124
 
125
  # ==================== USER OPERATIONS ====================
@@ -431,6 +453,414 @@ class Database:
431
  )
432
  self.conn.commit()
433
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
 
435
  # Singleton instance
436
  _db_instance = None
 
119
  FOREIGN KEY (user_id) REFERENCES users(id)
120
  )
121
  """)
122
+
123
+ # Wearable sensor data
124
+ self.conn.execute("""
125
+ CREATE TABLE IF NOT EXISTS wearable_data (
126
+ id TEXT PRIMARY KEY,
127
+ user_id TEXT NOT NULL,
128
+ ppg REAL NOT NULL,
129
+ gsr REAL NOT NULL,
130
+ acc_x REAL NOT NULL,
131
+ acc_y REAL NOT NULL,
132
+ acc_z REAL NOT NULL,
133
+ device_timestamp TEXT,
134
+ recorded_at TEXT DEFAULT CURRENT_TIMESTAMP,
135
+ FOREIGN KEY (user_id) REFERENCES users(id)
136
+ )
137
+ """)
138
+
139
+ # Create index for faster queries on wearable data
140
+ self.conn.execute("""
141
+ CREATE INDEX IF NOT EXISTS idx_wearable_user_time
142
+ ON wearable_data(user_id, recorded_at DESC)
143
+ """)
144
+
145
  self.conn.commit()
146
 
147
  # ==================== USER OPERATIONS ====================
 
453
  )
454
  self.conn.commit()
455
 
456
+ # ==================== WEARABLE DATA OPERATIONS ====================
457
+
458
+ def save_wearable_data(self, user_id: str, ppg: float, gsr: float,
459
+ acc_x: float, acc_y: float, acc_z: float,
460
+ device_timestamp: str = None) -> str:
461
+ """Save wearable sensor data."""
462
+ record_id = str(uuid.uuid4())
463
+
464
+ self.conn.execute(
465
+ """INSERT INTO wearable_data
466
+ (id, user_id, ppg, gsr, acc_x, acc_y, acc_z, device_timestamp)
467
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
468
+ (record_id, user_id, ppg, gsr, acc_x, acc_y, acc_z, device_timestamp)
469
+ )
470
+ self.conn.commit()
471
+
472
+ return record_id
473
+
474
+ def get_latest_wearable_data(self, user_id: str) -> dict:
475
+ """Get the most recent wearable data for a user."""
476
+ result = self.conn.execute(
477
+ """SELECT id, ppg, gsr, acc_x, acc_y, acc_z, device_timestamp, recorded_at
478
+ FROM wearable_data WHERE user_id = ?
479
+ ORDER BY recorded_at DESC LIMIT 1""",
480
+ (user_id,)
481
+ ).fetchone()
482
+
483
+ if result:
484
+ return {
485
+ "id": result[0],
486
+ "ppg": result[1],
487
+ "gsr": result[2],
488
+ "acc_x": result[3],
489
+ "acc_y": result[4],
490
+ "acc_z": result[5],
491
+ "device_timestamp": result[6],
492
+ "recorded_at": result[7]
493
+ }
494
+ return None
495
+
496
+ def get_wearable_history(self, user_id: str, limit: int = 100,
497
+ offset: int = 0, start_date: str = None,
498
+ end_date: str = None) -> list:
499
+ """Get wearable data history for a user."""
500
+ query = """SELECT id, ppg, gsr, acc_x, acc_y, acc_z, device_timestamp, recorded_at
501
+ FROM wearable_data WHERE user_id = ?"""
502
+ params = [user_id]
503
+
504
+ if start_date:
505
+ query += " AND recorded_at >= ?"
506
+ params.append(start_date)
507
+
508
+ if end_date:
509
+ query += " AND recorded_at <= ?"
510
+ params.append(end_date)
511
+
512
+ query += " ORDER BY recorded_at DESC LIMIT ? OFFSET ?"
513
+ params.extend([limit, offset])
514
+
515
+ results = self.conn.execute(query, tuple(params)).fetchall()
516
+
517
+ return [
518
+ {
519
+ "id": r[0],
520
+ "ppg": r[1],
521
+ "gsr": r[2],
522
+ "acc_x": r[3],
523
+ "acc_y": r[4],
524
+ "acc_z": r[5],
525
+ "device_timestamp": r[6],
526
+ "recorded_at": r[7]
527
+ }
528
+ for r in results
529
+ ]
530
+
531
+ def get_wearable_stats(self, user_id: str, period: str = "day") -> dict:
532
+ """Get aggregated statistics for wearable data."""
533
+ # Calculate date filter based on period
534
+ if period == "day":
535
+ date_filter = "datetime('now', '-1 day')"
536
+ elif period == "week":
537
+ date_filter = "datetime('now', '-7 days')"
538
+ else: # month
539
+ date_filter = "datetime('now', '-30 days')"
540
+
541
+ result = self.conn.execute(
542
+ f"""SELECT
543
+ COUNT(*) as count,
544
+ AVG(ppg) as avg_ppg,
545
+ MIN(ppg) as min_ppg,
546
+ MAX(ppg) as max_ppg,
547
+ AVG(gsr) as avg_gsr,
548
+ MIN(gsr) as min_gsr,
549
+ MAX(gsr) as max_gsr,
550
+ AVG(acc_x) as avg_acc_x,
551
+ AVG(acc_y) as avg_acc_y,
552
+ AVG(acc_z) as avg_acc_z
553
+ FROM wearable_data
554
+ WHERE user_id = ? AND recorded_at >= {date_filter}""",
555
+ (user_id,)
556
+ ).fetchone()
557
+
558
+ if result and result[0] > 0:
559
+ return {
560
+ "reading_count": result[0],
561
+ "ppg": {
562
+ "avg": round(result[1], 2) if result[1] else None,
563
+ "min": round(result[2], 2) if result[2] else None,
564
+ "max": round(result[3], 2) if result[3] else None
565
+ },
566
+ "gsr": {
567
+ "avg": round(result[4], 4) if result[4] else None,
568
+ "min": round(result[5], 4) if result[5] else None,
569
+ "max": round(result[6], 4) if result[6] else None
570
+ },
571
+ "accelerometer": {
572
+ "avg_x": round(result[7], 4) if result[7] else None,
573
+ "avg_y": round(result[8], 4) if result[8] else None,
574
+ "avg_z": round(result[9], 4) if result[9] else None
575
+ }
576
+ }
577
+
578
+ return {
579
+ "reading_count": 0,
580
+ "ppg": {"avg": None, "min": None, "max": None},
581
+ "gsr": {"avg": None, "min": None, "max": None},
582
+ "accelerometer": {"avg_x": None, "avg_y": None, "avg_z": None}
583
+ }
584
+
585
+ # ==================== ADMIN OPERATIONS ====================
586
+
587
+ def get_all_users(self) -> list:
588
+ """Get all users with basic info for admin panel."""
589
+ results = self.conn.execute(
590
+ """SELECT u.id, u.email, u.name, u.context, u.created_at,
591
+ s.total_sessions, s.total_exercises, s.current_streak, s.last_session_date
592
+ FROM users u
593
+ LEFT JOIN user_stats s ON u.id = s.user_id
594
+ ORDER BY u.created_at DESC"""
595
+ ).fetchall()
596
+
597
+ users = []
598
+ for r in results:
599
+ # Count crisis flags for this user
600
+ flag_count = self.conn.execute(
601
+ "SELECT COUNT(*) FROM crisis_flags WHERE user_id = ? AND reviewed = 0",
602
+ (r[0],)
603
+ ).fetchone()[0]
604
+
605
+ users.append({
606
+ "id": r[0],
607
+ "email": r[1],
608
+ "name": r[2],
609
+ "context": r[3],
610
+ "created_at": r[4],
611
+ "total_sessions": r[5] or 0,
612
+ "total_exercises": r[6] or 0,
613
+ "current_streak": r[7] or 0,
614
+ "last_session_date": r[8],
615
+ "unreviewed_alerts": flag_count
616
+ })
617
+
618
+ return users
619
+
620
+ def get_user_full_details(self, user_id: str) -> dict:
621
+ """Get complete user data for admin patient detail view."""
622
+ user = self.get_user_by_id(user_id)
623
+ if not user:
624
+ return None
625
+
626
+ # Get user stats
627
+ stats = self.get_user_stats(user_id)
628
+
629
+ # Get created_at
630
+ created_at = self.conn.execute(
631
+ "SELECT created_at FROM users WHERE id = ?",
632
+ (user_id,)
633
+ ).fetchone()
634
+
635
+ # Get sessions
636
+ sessions = self.get_user_sessions(user_id, limit=50)
637
+
638
+ # Get crisis history
639
+ crisis_flags = self.conn.execute(
640
+ """SELECT id, session_id, message_content, trigger_word, flagged_at, reviewed
641
+ FROM crisis_flags WHERE user_id = ?
642
+ ORDER BY flagged_at DESC""",
643
+ (user_id,)
644
+ ).fetchall()
645
+
646
+ crisis_history = [
647
+ {
648
+ "id": r[0],
649
+ "session_id": r[1],
650
+ "message_content": r[2],
651
+ "trigger_word": r[3],
652
+ "flagged_at": r[4],
653
+ "reviewed": bool(r[5])
654
+ }
655
+ for r in crisis_flags
656
+ ]
657
+
658
+ # Get latest wearable data
659
+ latest_wearable = self.get_latest_wearable_data(user_id)
660
+
661
+ return {
662
+ **user,
663
+ "created_at": created_at[0] if created_at else None,
664
+ "stats": stats,
665
+ "sessions": sessions,
666
+ "crisis_history": crisis_history,
667
+ "latest_wearable": latest_wearable
668
+ }
669
+
670
+ def get_all_crisis_flags(self, reviewed: bool = None) -> list:
671
+ """Get all crisis flags, optionally filtered by reviewed status."""
672
+ query = """SELECT cf.id, cf.user_id, cf.user_name, cf.user_email,
673
+ cf.session_id, cf.message_content, cf.trigger_word,
674
+ cf.flagged_at, cf.reviewed
675
+ FROM crisis_flags cf
676
+ ORDER BY cf.flagged_at DESC"""
677
+
678
+ if reviewed is not None:
679
+ query = """SELECT cf.id, cf.user_id, cf.user_name, cf.user_email,
680
+ cf.session_id, cf.message_content, cf.trigger_word,
681
+ cf.flagged_at, cf.reviewed
682
+ FROM crisis_flags cf
683
+ WHERE cf.reviewed = ?
684
+ ORDER BY cf.flagged_at DESC"""
685
+ results = self.conn.execute(query, (1 if reviewed else 0,)).fetchall()
686
+ else:
687
+ results = self.conn.execute(query).fetchall()
688
+
689
+ return [
690
+ {
691
+ "id": r[0],
692
+ "user_id": r[1],
693
+ "user_name": r[2],
694
+ "user_email": r[3],
695
+ "session_id": r[4],
696
+ "message_content": r[5],
697
+ "trigger_word": r[6],
698
+ "flagged_at": r[7],
699
+ "reviewed": bool(r[8])
700
+ }
701
+ for r in results
702
+ ]
703
+
704
+ def mark_crisis_reviewed(self, flag_id: str) -> bool:
705
+ """Mark a crisis flag as reviewed."""
706
+ self.conn.execute(
707
+ "UPDATE crisis_flags SET reviewed = 1 WHERE id = ?",
708
+ (flag_id,)
709
+ )
710
+ self.conn.commit()
711
+ return True
712
+
713
+ def get_dashboard_stats(self) -> dict:
714
+ """Get overview statistics for admin dashboard."""
715
+ # Total users
716
+ total_users = self.conn.execute(
717
+ "SELECT COUNT(*) FROM users"
718
+ ).fetchone()[0]
719
+
720
+ # Sessions today
721
+ sessions_today = self.conn.execute(
722
+ """SELECT COUNT(*) FROM sessions
723
+ WHERE DATE(started_at) = DATE('now')"""
724
+ ).fetchone()[0]
725
+
726
+ # Unreviewed crisis flags
727
+ unreviewed_alerts = self.conn.execute(
728
+ "SELECT COUNT(*) FROM crisis_flags WHERE reviewed = 0"
729
+ ).fetchone()[0]
730
+
731
+ # Average mood improvement (mood_end - mood_start for completed sessions)
732
+ mood_result = self.conn.execute(
733
+ """SELECT AVG(mood_end - mood_start)
734
+ FROM sessions
735
+ WHERE mood_start IS NOT NULL
736
+ AND mood_end IS NOT NULL
737
+ AND completed = 1"""
738
+ ).fetchone()
739
+ avg_mood_change = round(mood_result[0], 2) if mood_result[0] else 0
740
+
741
+ # Total sessions
742
+ total_sessions = self.conn.execute(
743
+ "SELECT COUNT(*) FROM sessions"
744
+ ).fetchone()[0]
745
+
746
+ # Completed sessions
747
+ completed_sessions = self.conn.execute(
748
+ "SELECT COUNT(*) FROM sessions WHERE completed = 1"
749
+ ).fetchone()[0]
750
+
751
+ return {
752
+ "total_users": total_users,
753
+ "sessions_today": sessions_today,
754
+ "unreviewed_alerts": unreviewed_alerts,
755
+ "avg_mood_change": avg_mood_change,
756
+ "total_sessions": total_sessions,
757
+ "completed_sessions": completed_sessions
758
+ }
759
+
760
+ def get_user_wearable_summary(self, user_id: str) -> dict:
761
+ """Get vitals summary for a patient."""
762
+ # Get stats for day, week, month
763
+ day_stats = self.get_wearable_stats(user_id, "day")
764
+ week_stats = self.get_wearable_stats(user_id, "week")
765
+ month_stats = self.get_wearable_stats(user_id, "month")
766
+
767
+ # Get latest reading
768
+ latest = self.get_latest_wearable_data(user_id)
769
+
770
+ return {
771
+ "latest": latest,
772
+ "day": day_stats,
773
+ "week": week_stats,
774
+ "month": month_stats
775
+ }
776
+
777
+ def get_wearable_timeseries(self, user_id: str, hours: int = 24) -> list:
778
+ """Get time-series wearable data for charts."""
779
+ results = self.conn.execute(
780
+ """SELECT ppg, gsr, acc_x, acc_y, acc_z, recorded_at
781
+ FROM wearable_data
782
+ WHERE user_id = ?
783
+ AND recorded_at >= datetime('now', ? || ' hours')
784
+ ORDER BY recorded_at ASC""",
785
+ (user_id, -hours)
786
+ ).fetchall()
787
+
788
+ return [
789
+ {
790
+ "ppg": r[0],
791
+ "gsr": r[1],
792
+ "acc_x": r[2],
793
+ "acc_y": r[3],
794
+ "acc_z": r[4],
795
+ "recorded_at": r[5]
796
+ }
797
+ for r in results
798
+ ]
799
+
800
+ def get_daily_session_counts(self, days: int = 30) -> list:
801
+ """Get daily session counts for trend chart."""
802
+ results = self.conn.execute(
803
+ """SELECT DATE(started_at) as day, COUNT(*) as count
804
+ FROM sessions
805
+ WHERE started_at >= datetime('now', ? || ' days')
806
+ GROUP BY DATE(started_at)
807
+ ORDER BY day ASC""",
808
+ (-days,)
809
+ ).fetchall()
810
+
811
+ return [{"date": r[0], "count": r[1]} for r in results]
812
+
813
+ def get_distortion_distribution(self) -> dict:
814
+ """Get aggregate distortion group distribution."""
815
+ results = self.conn.execute(
816
+ """SELECT locked_group, COUNT(*) as count
817
+ FROM sessions
818
+ WHERE locked_group IS NOT NULL AND locked_group != 'G0'
819
+ GROUP BY locked_group"""
820
+ ).fetchall()
821
+
822
+ distribution = {"G1": 0, "G2": 0, "G3": 0, "G4": 0}
823
+ for r in results:
824
+ if r[0] in distribution:
825
+ distribution[r[0]] = r[1]
826
+
827
+ return distribution
828
+
829
+ def get_user_mood_history(self, user_id: str, limit: int = 20) -> list:
830
+ """Get mood history for a user's sessions."""
831
+ results = self.conn.execute(
832
+ """SELECT started_at, mood_start, mood_end, locked_group, completed
833
+ FROM sessions
834
+ WHERE user_id = ?
835
+ AND mood_start IS NOT NULL
836
+ ORDER BY started_at DESC
837
+ LIMIT ?""",
838
+ (user_id, limit)
839
+ ).fetchall()
840
+
841
+ return [
842
+ {
843
+ "date": r[0],
844
+ "mood_start": r[1],
845
+ "mood_end": r[2],
846
+ "locked_group": r[3],
847
+ "completed": bool(r[4])
848
+ }
849
+ for r in reversed(results) # Chronological order
850
+ ]
851
+
852
+ def get_user_distortion_pattern(self, user_id: str) -> dict:
853
+ """Get distortion pattern for radar chart."""
854
+ stats = self.get_user_stats(user_id)
855
+ counts = stats.get("distortion_counts", {})
856
+
857
+ return {
858
+ "G1": counts.get("G1", 0),
859
+ "G2": counts.get("G2", 0),
860
+ "G3": counts.get("G3", 0),
861
+ "G4": counts.get("G4", 0)
862
+ }
863
+
864
 
865
  # Singleton instance
866
  _db_instance = None
static/css/admin.css ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* CBT Companion Admin Panel Styles */
2
+
3
+ /* CSS Variables */
4
+ :root {
5
+ --primary: #3B82F6;
6
+ --primary-dark: #2563EB;
7
+ --secondary: #10B981;
8
+ --danger: #EF4444;
9
+ --warning: #F59E0B;
10
+ --background: #F3F4F6;
11
+ --card-bg: #FFFFFF;
12
+ --text-primary: #1F2937;
13
+ --text-secondary: #6B7280;
14
+ --border-color: #E5E7EB;
15
+ }
16
+
17
+ /* Base Styles */
18
+ * {
19
+ box-sizing: border-box;
20
+ }
21
+
22
+ body {
23
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
24
+ background-color: var(--background);
25
+ color: var(--text-primary);
26
+ }
27
+
28
+ /* Background Pattern */
29
+ .bg-grid-pattern {
30
+ background-image:
31
+ linear-gradient(rgba(255,255,255,.1) 1px, transparent 1px),
32
+ linear-gradient(90deg, rgba(255,255,255,.1) 1px, transparent 1px);
33
+ background-size: 20px 20px;
34
+ }
35
+
36
+ /* Card Styles */
37
+ .card {
38
+ background: var(--card-bg);
39
+ border-radius: 0.75rem;
40
+ box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
41
+ transition: box-shadow 0.2s ease-in-out;
42
+ }
43
+
44
+ .card:hover {
45
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
46
+ }
47
+
48
+ /* Toast Notifications */
49
+ .toast {
50
+ padding: 1rem 1.5rem;
51
+ border-radius: 0.5rem;
52
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
53
+ display: flex;
54
+ align-items: center;
55
+ gap: 0.75rem;
56
+ animation: slideIn 0.3s ease-out;
57
+ max-width: 24rem;
58
+ }
59
+
60
+ .toast-success {
61
+ background-color: #ECFDF5;
62
+ border: 1px solid #A7F3D0;
63
+ color: #065F46;
64
+ }
65
+
66
+ .toast-error {
67
+ background-color: #FEF2F2;
68
+ border: 1px solid #FECACA;
69
+ color: #991B1B;
70
+ }
71
+
72
+ .toast-warning {
73
+ background-color: #FFFBEB;
74
+ border: 1px solid #FDE68A;
75
+ color: #92400E;
76
+ }
77
+
78
+ .toast-info {
79
+ background-color: #EFF6FF;
80
+ border: 1px solid #BFDBFE;
81
+ color: #1E40AF;
82
+ }
83
+
84
+ @keyframes slideIn {
85
+ from {
86
+ transform: translateX(100%);
87
+ opacity: 0;
88
+ }
89
+ to {
90
+ transform: translateX(0);
91
+ opacity: 1;
92
+ }
93
+ }
94
+
95
+ @keyframes slideOut {
96
+ from {
97
+ transform: translateX(0);
98
+ opacity: 1;
99
+ }
100
+ to {
101
+ transform: translateX(100%);
102
+ opacity: 0;
103
+ }
104
+ }
105
+
106
+ .toast-exit {
107
+ animation: slideOut 0.3s ease-in forwards;
108
+ }
109
+
110
+ /* Loading Spinner */
111
+ .spinner {
112
+ border: 3px solid rgba(59, 130, 246, 0.2);
113
+ border-top-color: var(--primary);
114
+ border-radius: 50%;
115
+ width: 2rem;
116
+ height: 2rem;
117
+ animation: spin 0.8s linear infinite;
118
+ }
119
+
120
+ @keyframes spin {
121
+ to {
122
+ transform: rotate(360deg);
123
+ }
124
+ }
125
+
126
+ /* Table Styles */
127
+ .data-table {
128
+ width: 100%;
129
+ border-collapse: collapse;
130
+ }
131
+
132
+ .data-table th {
133
+ background-color: #F9FAFB;
134
+ padding: 0.75rem 1.5rem;
135
+ text-align: left;
136
+ font-size: 0.75rem;
137
+ font-weight: 500;
138
+ text-transform: uppercase;
139
+ letter-spacing: 0.05em;
140
+ color: var(--text-secondary);
141
+ border-bottom: 1px solid var(--border-color);
142
+ }
143
+
144
+ .data-table td {
145
+ padding: 1rem 1.5rem;
146
+ border-bottom: 1px solid var(--border-color);
147
+ }
148
+
149
+ .data-table tbody tr:hover {
150
+ background-color: #F9FAFB;
151
+ }
152
+
153
+ .data-table tbody tr:last-child td {
154
+ border-bottom: none;
155
+ }
156
+
157
+ /* Badge Styles */
158
+ .badge {
159
+ display: inline-flex;
160
+ align-items: center;
161
+ padding: 0.25rem 0.75rem;
162
+ font-size: 0.75rem;
163
+ font-weight: 500;
164
+ border-radius: 9999px;
165
+ }
166
+
167
+ .badge-success {
168
+ background-color: #D1FAE5;
169
+ color: #065F46;
170
+ }
171
+
172
+ .badge-danger {
173
+ background-color: #FEE2E2;
174
+ color: #991B1B;
175
+ }
176
+
177
+ .badge-warning {
178
+ background-color: #FEF3C7;
179
+ color: #92400E;
180
+ }
181
+
182
+ .badge-info {
183
+ background-color: #DBEAFE;
184
+ color: #1E40AF;
185
+ }
186
+
187
+ .badge-gray {
188
+ background-color: #F3F4F6;
189
+ color: #374151;
190
+ }
191
+
192
+ /* Alert Panel Styles */
193
+ .alert-item {
194
+ padding: 1rem;
195
+ border-left: 4px solid var(--danger);
196
+ background-color: #FEF2F2;
197
+ transition: background-color 0.2s;
198
+ }
199
+
200
+ .alert-item:hover {
201
+ background-color: #FEE2E2;
202
+ }
203
+
204
+ /* Chart Container */
205
+ .chart-container {
206
+ position: relative;
207
+ width: 100%;
208
+ height: 100%;
209
+ }
210
+
211
+ /* Stats Card */
212
+ .stat-card {
213
+ background: white;
214
+ border-radius: 0.75rem;
215
+ padding: 1.5rem;
216
+ box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
217
+ transition: all 0.2s;
218
+ }
219
+
220
+ .stat-card:hover {
221
+ transform: translateY(-2px);
222
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
223
+ }
224
+
225
+ .stat-card .stat-icon {
226
+ width: 3rem;
227
+ height: 3rem;
228
+ border-radius: 0.75rem;
229
+ display: flex;
230
+ align-items: center;
231
+ justify-content: center;
232
+ }
233
+
234
+ .stat-card .stat-value {
235
+ font-size: 1.875rem;
236
+ font-weight: 700;
237
+ line-height: 1.2;
238
+ }
239
+
240
+ .stat-card .stat-label {
241
+ font-size: 0.875rem;
242
+ color: var(--text-secondary);
243
+ }
244
+
245
+ /* Sidebar Styles */
246
+ .sidebar {
247
+ background: white;
248
+ box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
249
+ }
250
+
251
+ .sidebar-nav-item {
252
+ display: flex;
253
+ align-items: center;
254
+ padding: 0.75rem 1rem;
255
+ border-radius: 0.5rem;
256
+ color: var(--text-secondary);
257
+ transition: all 0.2s;
258
+ }
259
+
260
+ .sidebar-nav-item:hover {
261
+ background-color: #F3F4F6;
262
+ color: var(--text-primary);
263
+ }
264
+
265
+ .sidebar-nav-item.active {
266
+ background-color: #EFF6FF;
267
+ color: var(--primary);
268
+ }
269
+
270
+ /* Tab Styles */
271
+ .tab-container {
272
+ border-bottom: 1px solid var(--border-color);
273
+ }
274
+
275
+ .tab-item {
276
+ padding: 1rem 1.5rem;
277
+ font-size: 0.875rem;
278
+ font-weight: 500;
279
+ color: var(--text-secondary);
280
+ border-bottom: 2px solid transparent;
281
+ transition: all 0.2s;
282
+ }
283
+
284
+ .tab-item:hover {
285
+ color: var(--text-primary);
286
+ }
287
+
288
+ .tab-item.active {
289
+ color: var(--primary);
290
+ border-bottom-color: var(--primary);
291
+ }
292
+
293
+ /* Responsive Table Scroll */
294
+ .table-scroll {
295
+ overflow-x: auto;
296
+ -webkit-overflow-scrolling: touch;
297
+ }
298
+
299
+ /* Line Clamp */
300
+ .line-clamp-2 {
301
+ display: -webkit-box;
302
+ -webkit-line-clamp: 2;
303
+ -webkit-box-orient: vertical;
304
+ overflow: hidden;
305
+ }
306
+
307
+ .line-clamp-3 {
308
+ display: -webkit-box;
309
+ -webkit-line-clamp: 3;
310
+ -webkit-box-orient: vertical;
311
+ overflow: hidden;
312
+ }
313
+
314
+ /* Pulse Animation for Alerts */
315
+ .pulse {
316
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
317
+ }
318
+
319
+ @keyframes pulse {
320
+ 0%, 100% {
321
+ opacity: 1;
322
+ }
323
+ 50% {
324
+ opacity: 0.5;
325
+ }
326
+ }
327
+
328
+ /* Gradient Backgrounds */
329
+ .gradient-blue {
330
+ background: linear-gradient(135deg, #3B82F6 0%, #6366F1 100%);
331
+ }
332
+
333
+ .gradient-green {
334
+ background: linear-gradient(135deg, #10B981 0%, #059669 100%);
335
+ }
336
+
337
+ .gradient-red {
338
+ background: linear-gradient(135deg, #EF4444 0%, #DC2626 100%);
339
+ }
340
+
341
+ .gradient-purple {
342
+ background: linear-gradient(135deg, #8B5CF6 0%, #7C3AED 100%);
343
+ }
344
+
345
+ /* Form Inputs */
346
+ .input-field {
347
+ width: 100%;
348
+ padding: 0.75rem 1rem;
349
+ border: 1px solid var(--border-color);
350
+ border-radius: 0.5rem;
351
+ transition: all 0.2s;
352
+ }
353
+
354
+ .input-field:focus {
355
+ outline: none;
356
+ border-color: var(--primary);
357
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
358
+ }
359
+
360
+ /* Button Styles */
361
+ .btn {
362
+ display: inline-flex;
363
+ align-items: center;
364
+ justify-content: center;
365
+ padding: 0.625rem 1.25rem;
366
+ font-size: 0.875rem;
367
+ font-weight: 500;
368
+ border-radius: 0.5rem;
369
+ transition: all 0.2s;
370
+ cursor: pointer;
371
+ }
372
+
373
+ .btn-primary {
374
+ background: linear-gradient(135deg, #3B82F6 0%, #2563EB 100%);
375
+ color: white;
376
+ border: none;
377
+ }
378
+
379
+ .btn-primary:hover {
380
+ background: linear-gradient(135deg, #2563EB 0%, #1D4ED8 100%);
381
+ transform: translateY(-1px);
382
+ }
383
+
384
+ .btn-secondary {
385
+ background: white;
386
+ color: var(--text-primary);
387
+ border: 1px solid var(--border-color);
388
+ }
389
+
390
+ .btn-secondary:hover {
391
+ background: #F9FAFB;
392
+ }
393
+
394
+ .btn-danger {
395
+ background: linear-gradient(135deg, #EF4444 0%, #DC2626 100%);
396
+ color: white;
397
+ border: none;
398
+ }
399
+
400
+ .btn-danger:hover {
401
+ background: linear-gradient(135deg, #DC2626 0%, #B91C1C 100%);
402
+ }
403
+
404
+ /* Avatar */
405
+ .avatar {
406
+ width: 2.5rem;
407
+ height: 2.5rem;
408
+ border-radius: 9999px;
409
+ display: flex;
410
+ align-items: center;
411
+ justify-content: center;
412
+ font-weight: 600;
413
+ font-size: 0.875rem;
414
+ }
415
+
416
+ .avatar-sm {
417
+ width: 2rem;
418
+ height: 2rem;
419
+ font-size: 0.75rem;
420
+ }
421
+
422
+ .avatar-lg {
423
+ width: 4rem;
424
+ height: 4rem;
425
+ font-size: 1.25rem;
426
+ }
427
+
428
+ /* Scrollbar Styling */
429
+ ::-webkit-scrollbar {
430
+ width: 8px;
431
+ height: 8px;
432
+ }
433
+
434
+ ::-webkit-scrollbar-track {
435
+ background: #F3F4F6;
436
+ border-radius: 4px;
437
+ }
438
+
439
+ ::-webkit-scrollbar-thumb {
440
+ background: #D1D5DB;
441
+ border-radius: 4px;
442
+ }
443
+
444
+ ::-webkit-scrollbar-thumb:hover {
445
+ background: #9CA3AF;
446
+ }
447
+
448
+ /* Responsive Utilities */
449
+ @media (max-width: 768px) {
450
+ .hide-mobile {
451
+ display: none !important;
452
+ }
453
+
454
+ .stat-card .stat-value {
455
+ font-size: 1.5rem;
456
+ }
457
+ }
458
+
459
+ @media (min-width: 1024px) {
460
+ .hide-desktop {
461
+ display: none !important;
462
+ }
463
+ }
464
+
465
+ /* Print Styles */
466
+ @media print {
467
+ .sidebar,
468
+ .no-print {
469
+ display: none !important;
470
+ }
471
+
472
+ body {
473
+ background: white;
474
+ }
475
+
476
+ .card {
477
+ box-shadow: none;
478
+ border: 1px solid #E5E7EB;
479
+ }
480
+ }
static/js/admin.js ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * CBT Companion Admin Panel JavaScript
3
+ * Handles authentication, API calls, and UI interactions
4
+ */
5
+
6
+ const AdminPanel = {
7
+ // Token Management
8
+ getToken() {
9
+ return localStorage.getItem('admin_token');
10
+ },
11
+
12
+ setToken(token) {
13
+ localStorage.setItem('admin_token', token);
14
+ },
15
+
16
+ removeToken() {
17
+ localStorage.removeItem('admin_token');
18
+ localStorage.removeItem('admin_user');
19
+ },
20
+
21
+ getUser() {
22
+ const user = localStorage.getItem('admin_user');
23
+ return user ? JSON.parse(user) : null;
24
+ },
25
+
26
+ isAuthenticated() {
27
+ return !!this.getToken();
28
+ },
29
+
30
+ // API Fetch Wrapper
31
+ async fetchAPI(url, options = {}) {
32
+ const token = this.getToken();
33
+
34
+ const config = {
35
+ ...options,
36
+ headers: {
37
+ 'Content-Type': 'application/json',
38
+ ...options.headers,
39
+ }
40
+ };
41
+
42
+ if (token) {
43
+ config.headers['Authorization'] = `Bearer ${token}`;
44
+ }
45
+
46
+ try {
47
+ const response = await fetch(url, config);
48
+
49
+ // Handle 401 - redirect to login
50
+ if (response.status === 401) {
51
+ this.removeToken();
52
+ window.location.href = '/admin/login';
53
+ throw new Error('Session expired');
54
+ }
55
+
56
+ // Handle 403 - not admin
57
+ if (response.status === 403) {
58
+ this.showToast('Admin access required', 'error');
59
+ throw new Error('Admin access required');
60
+ }
61
+
62
+ const data = await response.json();
63
+
64
+ if (!response.ok) {
65
+ throw new Error(data.error || 'Request failed');
66
+ }
67
+
68
+ return data;
69
+ } catch (error) {
70
+ console.error('API Error:', error);
71
+ throw error;
72
+ }
73
+ },
74
+
75
+ // Logout
76
+ logout() {
77
+ this.removeToken();
78
+ window.location.href = '/admin/login';
79
+ },
80
+
81
+ // Toast Notifications
82
+ showToast(message, type = 'info', duration = 4000) {
83
+ const container = document.getElementById('toast-container');
84
+ if (!container) return;
85
+
86
+ const toast = document.createElement('div');
87
+ toast.className = `toast toast-${type}`;
88
+
89
+ // Icon based on type
90
+ const icons = {
91
+ success: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>',
92
+ error: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>',
93
+ warning: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>',
94
+ info: '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'
95
+ };
96
+
97
+ toast.innerHTML = `
98
+ ${icons[type] || icons.info}
99
+ <span>${this.escapeHtml(message)}</span>
100
+ <button onclick="this.parentElement.remove()" class="ml-2 text-current opacity-50 hover:opacity-100">
101
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
102
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
103
+ </svg>
104
+ </button>
105
+ `;
106
+
107
+ container.appendChild(toast);
108
+
109
+ // Auto remove after duration
110
+ setTimeout(() => {
111
+ toast.classList.add('toast-exit');
112
+ setTimeout(() => toast.remove(), 300);
113
+ }, duration);
114
+ },
115
+
116
+ // Utility Functions
117
+ escapeHtml(text) {
118
+ if (!text) return '';
119
+ const div = document.createElement('div');
120
+ div.textContent = text;
121
+ return div.innerHTML;
122
+ },
123
+
124
+ formatDate(dateString) {
125
+ if (!dateString) return '--';
126
+ const date = new Date(dateString);
127
+ return date.toLocaleDateString('en-US', {
128
+ year: 'numeric',
129
+ month: 'short',
130
+ day: 'numeric'
131
+ });
132
+ },
133
+
134
+ formatDateTime(dateString) {
135
+ if (!dateString) return '--';
136
+ const date = new Date(dateString);
137
+ return date.toLocaleString('en-US', {
138
+ year: 'numeric',
139
+ month: 'short',
140
+ day: 'numeric',
141
+ hour: '2-digit',
142
+ minute: '2-digit'
143
+ });
144
+ },
145
+
146
+ formatTimeAgo(dateString) {
147
+ if (!dateString) return '--';
148
+
149
+ const date = new Date(dateString);
150
+ const now = new Date();
151
+ const seconds = Math.floor((now - date) / 1000);
152
+
153
+ if (seconds < 60) return 'just now';
154
+ if (seconds < 3600) return Math.floor(seconds / 60) + ' minutes ago';
155
+ if (seconds < 86400) return Math.floor(seconds / 3600) + ' hours ago';
156
+ if (seconds < 604800) return Math.floor(seconds / 86400) + ' days ago';
157
+
158
+ return this.formatDate(dateString);
159
+ },
160
+
161
+ // Generate initials from name
162
+ getInitials(name) {
163
+ if (!name) return '??';
164
+ return name.split(' ')
165
+ .map(n => n[0])
166
+ .join('')
167
+ .toUpperCase()
168
+ .slice(0, 2);
169
+ },
170
+
171
+ // Generate avatar color from name
172
+ getAvatarColor(name) {
173
+ if (!name) return 'from-gray-400 to-gray-500';
174
+
175
+ const colors = [
176
+ 'from-blue-400 to-indigo-500',
177
+ 'from-green-400 to-emerald-500',
178
+ 'from-purple-400 to-violet-500',
179
+ 'from-pink-400 to-rose-500',
180
+ 'from-yellow-400 to-orange-500',
181
+ 'from-cyan-400 to-teal-500',
182
+ 'from-red-400 to-pink-500',
183
+ 'from-indigo-400 to-purple-500'
184
+ ];
185
+
186
+ // Simple hash function
187
+ let hash = 0;
188
+ for (let i = 0; i < name.length; i++) {
189
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
190
+ }
191
+
192
+ return colors[Math.abs(hash) % colors.length];
193
+ },
194
+
195
+ // Debounce utility
196
+ debounce(func, wait) {
197
+ let timeout;
198
+ return function executedFunction(...args) {
199
+ const later = () => {
200
+ clearTimeout(timeout);
201
+ func(...args);
202
+ };
203
+ clearTimeout(timeout);
204
+ timeout = setTimeout(later, wait);
205
+ };
206
+ },
207
+
208
+ // Throttle utility
209
+ throttle(func, limit) {
210
+ let inThrottle;
211
+ return function(...args) {
212
+ if (!inThrottle) {
213
+ func.apply(this, args);
214
+ inThrottle = true;
215
+ setTimeout(() => inThrottle = false, limit);
216
+ }
217
+ };
218
+ }
219
+ };
220
+
221
+ // Chart.js Default Configuration
222
+ if (typeof Chart !== 'undefined') {
223
+ Chart.defaults.font.family = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
224
+ Chart.defaults.color = '#6B7280';
225
+
226
+ // Custom tooltip styling
227
+ Chart.defaults.plugins.tooltip.backgroundColor = 'rgba(31, 41, 55, 0.9)';
228
+ Chart.defaults.plugins.tooltip.titleColor = '#fff';
229
+ Chart.defaults.plugins.tooltip.bodyColor = '#fff';
230
+ Chart.defaults.plugins.tooltip.borderWidth = 0;
231
+ Chart.defaults.plugins.tooltip.cornerRadius = 8;
232
+ Chart.defaults.plugins.tooltip.padding = 12;
233
+ }
234
+
235
+ // Global error handler for unhandled promise rejections
236
+ window.addEventListener('unhandledrejection', event => {
237
+ console.error('Unhandled promise rejection:', event.reason);
238
+ AdminPanel.showToast('An unexpected error occurred', 'error');
239
+ });
240
+
241
+ // Export for module usage
242
+ if (typeof module !== 'undefined' && module.exports) {
243
+ module.exports = AdminPanel;
244
+ }
templates/admin_dashboard.html ADDED
@@ -0,0 +1,587 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Dashboard - CBT Companion Admin</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
9
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
10
+ <script>
11
+ tailwind.config = {
12
+ theme: {
13
+ extend: {
14
+ colors: {
15
+ primary: '#3B82F6',
16
+ secondary: '#10B981',
17
+ danger: '#EF4444',
18
+ warning: '#F59E0B',
19
+ }
20
+ }
21
+ }
22
+ }
23
+ </script>
24
+ </head>
25
+ <body class="bg-gray-100 min-h-screen">
26
+ <div class="flex h-screen overflow-hidden">
27
+ <!-- Sidebar -->
28
+ <aside id="sidebar" class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-xl transform transition-transform duration-300 lg:relative lg:translate-x-0">
29
+ <!-- Logo -->
30
+ <div class="flex items-center justify-between h-16 px-6 border-b border-gray-200">
31
+ <div class="flex items-center space-x-3">
32
+ <div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
33
+ <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
34
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"></path>
35
+ </svg>
36
+ </div>
37
+ <span class="text-lg font-bold text-gray-800">CBT Admin</span>
38
+ </div>
39
+ <button id="close-sidebar" class="lg:hidden p-2 rounded-lg hover:bg-gray-100">
40
+ <svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
41
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
42
+ </svg>
43
+ </button>
44
+ </div>
45
+
46
+ <!-- Navigation -->
47
+ <nav class="px-4 py-6 space-y-2">
48
+ <a href="/admin/dashboard" class="flex items-center px-4 py-3 text-blue-600 bg-blue-50 rounded-lg">
49
+ <svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
50
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
51
+ </svg>
52
+ Dashboard
53
+ </a>
54
+ <a href="/admin/patients" class="flex items-center px-4 py-3 text-gray-600 hover:bg-gray-50 rounded-lg transition-colors">
55
+ <svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
56
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>
57
+ </svg>
58
+ Patients
59
+ </a>
60
+ </nav>
61
+
62
+ <!-- User Info -->
63
+ <div class="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
64
+ <div class="flex items-center space-x-3">
65
+ <div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center">
66
+ <span id="user-initials" class="text-white font-semibold text-sm">AD</span>
67
+ </div>
68
+ <div class="flex-1 min-w-0">
69
+ <p id="user-name" class="text-sm font-medium text-gray-900 truncate">Admin</p>
70
+ <p id="user-email" class="text-xs text-gray-500 truncate">admin@example.com</p>
71
+ </div>
72
+ <button id="logout-btn" class="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors" title="Logout">
73
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
74
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
75
+ </svg>
76
+ </button>
77
+ </div>
78
+ </div>
79
+ </aside>
80
+
81
+ <!-- Sidebar Overlay -->
82
+ <div id="sidebar-overlay" class="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden hidden"></div>
83
+
84
+ <!-- Main Content -->
85
+ <main class="flex-1 overflow-y-auto">
86
+ <!-- Top Bar -->
87
+ <header class="bg-white shadow-sm sticky top-0 z-30">
88
+ <div class="flex items-center justify-between h-16 px-6">
89
+ <div class="flex items-center space-x-4">
90
+ <button id="open-sidebar" class="lg:hidden p-2 rounded-lg hover:bg-gray-100">
91
+ <svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
92
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
93
+ </svg>
94
+ </button>
95
+ <h1 class="text-xl font-semibold text-gray-800">Dashboard</h1>
96
+ </div>
97
+ <div class="flex items-center space-x-4">
98
+ <span id="last-updated" class="text-sm text-gray-500">Updated just now</span>
99
+ <button id="refresh-btn" class="p-2 text-gray-400 hover:text-blue-500 hover:bg-blue-50 rounded-lg transition-colors" title="Refresh">
100
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
101
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
102
+ </svg>
103
+ </button>
104
+ </div>
105
+ </div>
106
+ </header>
107
+
108
+ <!-- Dashboard Content -->
109
+ <div class="p-6 space-y-6">
110
+ <!-- Stats Cards Row -->
111
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
112
+ <!-- Total Patients Card -->
113
+ <div class="bg-white rounded-xl shadow-sm p-6 hover:shadow-md transition-shadow">
114
+ <div class="flex items-center justify-between">
115
+ <div>
116
+ <p class="text-sm font-medium text-gray-500">Total Patients</p>
117
+ <p id="stat-patients" class="text-3xl font-bold text-gray-800 mt-1">-</p>
118
+ </div>
119
+ <div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center">
120
+ <svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
121
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>
122
+ </svg>
123
+ </div>
124
+ </div>
125
+ </div>
126
+
127
+ <!-- Sessions Today Card -->
128
+ <div class="bg-white rounded-xl shadow-sm p-6 hover:shadow-md transition-shadow">
129
+ <div class="flex items-center justify-between">
130
+ <div>
131
+ <p class="text-sm font-medium text-gray-500">Sessions Today</p>
132
+ <p id="stat-sessions" class="text-3xl font-bold text-gray-800 mt-1">-</p>
133
+ </div>
134
+ <div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center">
135
+ <svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
136
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
137
+ </svg>
138
+ </div>
139
+ </div>
140
+ </div>
141
+
142
+ <!-- Crisis Alerts Card -->
143
+ <div class="bg-white rounded-xl shadow-sm p-6 hover:shadow-md transition-shadow border-l-4 border-red-500">
144
+ <div class="flex items-center justify-between">
145
+ <div>
146
+ <p class="text-sm font-medium text-gray-500">Crisis Alerts</p>
147
+ <p id="stat-alerts" class="text-3xl font-bold text-red-600 mt-1">-</p>
148
+ </div>
149
+ <div class="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center">
150
+ <svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
151
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
152
+ </svg>
153
+ </div>
154
+ </div>
155
+ </div>
156
+
157
+ <!-- Avg Mood Change Card -->
158
+ <div class="bg-white rounded-xl shadow-sm p-6 hover:shadow-md transition-shadow">
159
+ <div class="flex items-center justify-between">
160
+ <div>
161
+ <p class="text-sm font-medium text-gray-500">Avg Mood Change</p>
162
+ <p id="stat-mood" class="text-3xl font-bold text-gray-800 mt-1">-</p>
163
+ </div>
164
+ <div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center">
165
+ <svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
166
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"></path>
167
+ </svg>
168
+ </div>
169
+ </div>
170
+ </div>
171
+ </div>
172
+
173
+ <!-- Charts Row -->
174
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
175
+ <!-- Session Trends Chart -->
176
+ <div class="bg-white rounded-xl shadow-sm p-6">
177
+ <h3 class="text-lg font-semibold text-gray-800 mb-4">Session Trends (30 Days)</h3>
178
+ <div class="h-64">
179
+ <canvas id="sessionsChart"></canvas>
180
+ </div>
181
+ </div>
182
+
183
+ <!-- Distortion Distribution Chart -->
184
+ <div class="bg-white rounded-xl shadow-sm p-6">
185
+ <h3 class="text-lg font-semibold text-gray-800 mb-4">Distortion Distribution</h3>
186
+ <div class="h-64 flex items-center justify-center">
187
+ <canvas id="distortionChart"></canvas>
188
+ </div>
189
+ </div>
190
+ </div>
191
+
192
+ <!-- Crisis Alerts Panel -->
193
+ <div class="bg-white rounded-xl shadow-sm overflow-hidden">
194
+ <div class="px-6 py-4 border-b border-gray-200 bg-gradient-to-r from-red-500 to-red-600">
195
+ <div class="flex items-center justify-between">
196
+ <h3 class="text-lg font-semibold text-white flex items-center">
197
+ <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
198
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
199
+ </svg>
200
+ Crisis Alerts
201
+ </h3>
202
+ <span id="alert-badge" class="px-3 py-1 bg-white text-red-600 text-sm font-semibold rounded-full">0 pending</span>
203
+ </div>
204
+ </div>
205
+
206
+ <div id="alerts-container" class="divide-y divide-gray-100">
207
+ <!-- Alerts will be populated here -->
208
+ <div id="alerts-loading" class="p-8 text-center text-gray-500">
209
+ <svg class="animate-spin h-8 w-8 mx-auto mb-4 text-gray-400" fill="none" viewBox="0 0 24 24">
210
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
211
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
212
+ </svg>
213
+ Loading alerts...
214
+ </div>
215
+ <div id="no-alerts" class="hidden p-8 text-center">
216
+ <svg class="w-12 h-12 mx-auto text-green-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
217
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
218
+ </svg>
219
+ <p class="text-gray-500">No pending alerts</p>
220
+ </div>
221
+ </div>
222
+ </div>
223
+
224
+ <!-- Recent Patients -->
225
+ <div class="bg-white rounded-xl shadow-sm overflow-hidden">
226
+ <div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
227
+ <h3 class="text-lg font-semibold text-gray-800">Recent Patients</h3>
228
+ <a href="/admin/patients" class="text-blue-600 hover:text-blue-700 text-sm font-medium">View All</a>
229
+ </div>
230
+ <div id="recent-patients" class="divide-y divide-gray-100">
231
+ <!-- Recent patients will be populated here -->
232
+ <div id="patients-loading" class="p-8 text-center text-gray-500">
233
+ <svg class="animate-spin h-8 w-8 mx-auto mb-4 text-gray-400" fill="none" viewBox="0 0 24 24">
234
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
235
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
236
+ </svg>
237
+ Loading patients...
238
+ </div>
239
+ </div>
240
+ </div>
241
+ </div>
242
+ </main>
243
+ </div>
244
+
245
+ <!-- Toast Container -->
246
+ <div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2"></div>
247
+
248
+ <script src="{{ url_for('static', filename='js/admin.js') }}"></script>
249
+ <script>
250
+ // Initialize dashboard
251
+ document.addEventListener('DOMContentLoaded', () => {
252
+ // Check authentication
253
+ if (!AdminPanel.isAuthenticated()) {
254
+ window.location.href = '/admin/login';
255
+ return;
256
+ }
257
+
258
+ // Set user info
259
+ const user = AdminPanel.getUser();
260
+ if (user) {
261
+ document.getElementById('user-name').textContent = user.name;
262
+ document.getElementById('user-email').textContent = user.email;
263
+ document.getElementById('user-initials').textContent = user.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
264
+ }
265
+
266
+ // Initialize charts and data
267
+ initializeDashboard();
268
+
269
+ // Setup event listeners
270
+ setupEventListeners();
271
+
272
+ // Start auto-refresh
273
+ setInterval(refreshData, 30000); // 30 seconds
274
+ });
275
+
276
+ let sessionsChart = null;
277
+ let distortionChart = null;
278
+
279
+ async function initializeDashboard() {
280
+ await Promise.all([
281
+ loadStats(),
282
+ loadAlerts(),
283
+ loadRecentPatients(),
284
+ loadSessionsChart(),
285
+ loadDistortionChart()
286
+ ]);
287
+ }
288
+
289
+ async function refreshData() {
290
+ await Promise.all([
291
+ loadStats(),
292
+ loadAlerts()
293
+ ]);
294
+ document.getElementById('last-updated').textContent = 'Updated just now';
295
+ }
296
+
297
+ async function loadStats() {
298
+ try {
299
+ const data = await AdminPanel.fetchAPI('/admin/api/stats');
300
+ document.getElementById('stat-patients').textContent = data.total_users;
301
+ document.getElementById('stat-sessions').textContent = data.sessions_today;
302
+ document.getElementById('stat-alerts').textContent = data.unreviewed_alerts;
303
+
304
+ const moodChange = data.avg_mood_change;
305
+ const moodEl = document.getElementById('stat-mood');
306
+ if (moodChange > 0) {
307
+ moodEl.textContent = '+' + moodChange;
308
+ moodEl.classList.add('text-green-600');
309
+ } else if (moodChange < 0) {
310
+ moodEl.textContent = moodChange;
311
+ moodEl.classList.add('text-red-600');
312
+ } else {
313
+ moodEl.textContent = '0';
314
+ }
315
+ } catch (error) {
316
+ console.error('Error loading stats:', error);
317
+ }
318
+ }
319
+
320
+ async function loadAlerts() {
321
+ try {
322
+ const data = await AdminPanel.fetchAPI('/admin/api/alerts?reviewed=false');
323
+ const container = document.getElementById('alerts-container');
324
+ const loading = document.getElementById('alerts-loading');
325
+ const noAlerts = document.getElementById('no-alerts');
326
+ const badge = document.getElementById('alert-badge');
327
+
328
+ loading.classList.add('hidden');
329
+
330
+ if (data.alerts.length === 0) {
331
+ noAlerts.classList.remove('hidden');
332
+ badge.textContent = '0 pending';
333
+ return;
334
+ }
335
+
336
+ noAlerts.classList.add('hidden');
337
+ badge.textContent = data.alerts.length + ' pending';
338
+
339
+ // Clear and populate alerts
340
+ container.innerHTML = '';
341
+ data.alerts.slice(0, 5).forEach(alert => {
342
+ container.appendChild(createAlertElement(alert));
343
+ });
344
+
345
+ } catch (error) {
346
+ console.error('Error loading alerts:', error);
347
+ }
348
+ }
349
+
350
+ function createAlertElement(alert) {
351
+ const div = document.createElement('div');
352
+ div.className = 'p-4 hover:bg-red-50 transition-colors';
353
+ div.innerHTML = `
354
+ <div class="flex items-start justify-between">
355
+ <div class="flex items-start space-x-3">
356
+ <div class="w-2 h-2 bg-red-500 rounded-full mt-2 animate-pulse"></div>
357
+ <div>
358
+ <a href="/admin/patients/${alert.user_id}" class="font-medium text-gray-900 hover:text-blue-600">
359
+ ${AdminPanel.escapeHtml(alert.user_name)}
360
+ </a>
361
+ <span class="ml-2 px-2 py-0.5 bg-red-100 text-red-700 text-xs font-medium rounded">${AdminPanel.escapeHtml(alert.trigger_word)}</span>
362
+ <p class="text-sm text-gray-600 mt-1 line-clamp-2">${AdminPanel.escapeHtml(alert.message_content)}</p>
363
+ <p class="text-xs text-gray-400 mt-1">${AdminPanel.formatTimeAgo(alert.flagged_at)}</p>
364
+ </div>
365
+ </div>
366
+ <button onclick="reviewAlert('${alert.id}')" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm font-medium rounded-lg transition-colors">
367
+ Review
368
+ </button>
369
+ </div>
370
+ `;
371
+ return div;
372
+ }
373
+
374
+ async function reviewAlert(alertId) {
375
+ try {
376
+ await AdminPanel.fetchAPI(`/admin/api/alerts/${alertId}/review`, { method: 'POST' });
377
+ AdminPanel.showToast('Alert marked as reviewed', 'success');
378
+ await loadAlerts();
379
+ await loadStats();
380
+ } catch (error) {
381
+ AdminPanel.showToast('Failed to review alert', 'error');
382
+ }
383
+ }
384
+
385
+ async function loadRecentPatients() {
386
+ try {
387
+ const data = await AdminPanel.fetchAPI('/admin/api/patients');
388
+ const container = document.getElementById('recent-patients');
389
+ const loading = document.getElementById('patients-loading');
390
+
391
+ loading.classList.add('hidden');
392
+
393
+ if (data.patients.length === 0) {
394
+ container.innerHTML = '<div class="p-8 text-center text-gray-500">No patients yet</div>';
395
+ return;
396
+ }
397
+
398
+ container.innerHTML = '';
399
+ data.patients.slice(0, 5).forEach(patient => {
400
+ const div = document.createElement('div');
401
+ div.className = 'p-4 hover:bg-gray-50 transition-colors cursor-pointer';
402
+ div.onclick = () => window.location.href = `/admin/patients/${patient.id}`;
403
+ div.innerHTML = `
404
+ <div class="flex items-center justify-between">
405
+ <div class="flex items-center space-x-3">
406
+ <div class="w-10 h-10 bg-gradient-to-br from-blue-400 to-indigo-500 rounded-full flex items-center justify-center">
407
+ <span class="text-white font-semibold text-sm">${patient.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}</span>
408
+ </div>
409
+ <div>
410
+ <p class="font-medium text-gray-900">${AdminPanel.escapeHtml(patient.name)}</p>
411
+ <p class="text-sm text-gray-500">${patient.total_sessions} sessions</p>
412
+ </div>
413
+ </div>
414
+ <div class="flex items-center space-x-2">
415
+ ${patient.unreviewed_alerts > 0 ? `<span class="px-2 py-0.5 bg-red-100 text-red-700 text-xs font-medium rounded-full">${patient.unreviewed_alerts} alerts</span>` : ''}
416
+ <span class="text-sm text-gray-400">${patient.last_session_date ? AdminPanel.formatDate(patient.last_session_date) : 'Never'}</span>
417
+ </div>
418
+ </div>
419
+ `;
420
+ container.appendChild(div);
421
+ });
422
+
423
+ } catch (error) {
424
+ console.error('Error loading patients:', error);
425
+ }
426
+ }
427
+
428
+ async function loadSessionsChart() {
429
+ try {
430
+ const data = await AdminPanel.fetchAPI('/admin/api/charts/sessions');
431
+
432
+ const ctx = document.getElementById('sessionsChart').getContext('2d');
433
+
434
+ // Fill missing dates with 0
435
+ const dates = [];
436
+ const counts = [];
437
+ const today = new Date();
438
+
439
+ for (let i = 29; i >= 0; i--) {
440
+ const date = new Date(today);
441
+ date.setDate(date.getDate() - i);
442
+ const dateStr = date.toISOString().split('T')[0];
443
+ dates.push(date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }));
444
+
445
+ const found = data.data.find(d => d.date === dateStr);
446
+ counts.push(found ? found.count : 0);
447
+ }
448
+
449
+ if (sessionsChart) {
450
+ sessionsChart.destroy();
451
+ }
452
+
453
+ sessionsChart = new Chart(ctx, {
454
+ type: 'line',
455
+ data: {
456
+ labels: dates,
457
+ datasets: [{
458
+ label: 'Sessions',
459
+ data: counts,
460
+ borderColor: '#3B82F6',
461
+ backgroundColor: 'rgba(59, 130, 246, 0.1)',
462
+ fill: true,
463
+ tension: 0.4,
464
+ pointRadius: 0,
465
+ pointHoverRadius: 6,
466
+ pointHoverBackgroundColor: '#3B82F6',
467
+ pointHoverBorderColor: '#fff',
468
+ pointHoverBorderWidth: 2
469
+ }]
470
+ },
471
+ options: {
472
+ responsive: true,
473
+ maintainAspectRatio: false,
474
+ plugins: {
475
+ legend: { display: false }
476
+ },
477
+ scales: {
478
+ x: {
479
+ grid: { display: false },
480
+ ticks: {
481
+ maxRotation: 0,
482
+ maxTicksLimit: 7
483
+ }
484
+ },
485
+ y: {
486
+ beginAtZero: true,
487
+ grid: { color: 'rgba(0,0,0,0.05)' },
488
+ ticks: { stepSize: 1 }
489
+ }
490
+ },
491
+ interaction: {
492
+ intersect: false,
493
+ mode: 'index'
494
+ }
495
+ }
496
+ });
497
+
498
+ } catch (error) {
499
+ console.error('Error loading sessions chart:', error);
500
+ }
501
+ }
502
+
503
+ async function loadDistortionChart() {
504
+ try {
505
+ const data = await AdminPanel.fetchAPI('/admin/api/charts/distortions');
506
+
507
+ const ctx = document.getElementById('distortionChart').getContext('2d');
508
+
509
+ if (distortionChart) {
510
+ distortionChart.destroy();
511
+ }
512
+
513
+ const labels = {
514
+ 'G1': 'Binary Thinking',
515
+ 'G2': 'Overgeneralization',
516
+ 'G3': 'Attention Bias',
517
+ 'G4': 'Emotion-Driven'
518
+ };
519
+
520
+ const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444'];
521
+
522
+ distortionChart = new Chart(ctx, {
523
+ type: 'doughnut',
524
+ data: {
525
+ labels: Object.keys(data).map(k => labels[k] || k),
526
+ datasets: [{
527
+ data: Object.values(data),
528
+ backgroundColor: colors,
529
+ borderWidth: 0,
530
+ hoverOffset: 8
531
+ }]
532
+ },
533
+ options: {
534
+ responsive: true,
535
+ maintainAspectRatio: false,
536
+ plugins: {
537
+ legend: {
538
+ position: 'right',
539
+ labels: {
540
+ usePointStyle: true,
541
+ padding: 16
542
+ }
543
+ }
544
+ },
545
+ cutout: '65%'
546
+ }
547
+ });
548
+
549
+ } catch (error) {
550
+ console.error('Error loading distortion chart:', error);
551
+ }
552
+ }
553
+
554
+ function setupEventListeners() {
555
+ // Sidebar toggle
556
+ document.getElementById('open-sidebar').addEventListener('click', () => {
557
+ document.getElementById('sidebar').classList.remove('-translate-x-full');
558
+ document.getElementById('sidebar-overlay').classList.remove('hidden');
559
+ });
560
+
561
+ document.getElementById('close-sidebar').addEventListener('click', () => {
562
+ document.getElementById('sidebar').classList.add('-translate-x-full');
563
+ document.getElementById('sidebar-overlay').classList.add('hidden');
564
+ });
565
+
566
+ document.getElementById('sidebar-overlay').addEventListener('click', () => {
567
+ document.getElementById('sidebar').classList.add('-translate-x-full');
568
+ document.getElementById('sidebar-overlay').classList.add('hidden');
569
+ });
570
+
571
+ // Logout
572
+ document.getElementById('logout-btn').addEventListener('click', () => {
573
+ AdminPanel.logout();
574
+ });
575
+
576
+ // Refresh
577
+ document.getElementById('refresh-btn').addEventListener('click', async () => {
578
+ const btn = document.getElementById('refresh-btn');
579
+ btn.classList.add('animate-spin');
580
+ await refreshData();
581
+ btn.classList.remove('animate-spin');
582
+ AdminPanel.showToast('Data refreshed', 'success');
583
+ });
584
+ }
585
+ </script>
586
+ </body>
587
+ </html>
templates/admin_login.html ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Admin Login - CBT Companion</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
9
+ <script>
10
+ tailwind.config = {
11
+ theme: {
12
+ extend: {
13
+ colors: {
14
+ primary: '#3B82F6',
15
+ secondary: '#10B981',
16
+ danger: '#EF4444',
17
+ warning: '#F59E0B',
18
+ }
19
+ }
20
+ }
21
+ }
22
+ </script>
23
+ </head>
24
+ <body class="min-h-screen bg-gradient-to-br from-blue-600 via-blue-700 to-indigo-800 flex items-center justify-center p-4">
25
+ <!-- Background Pattern -->
26
+ <div class="absolute inset-0 bg-grid-pattern opacity-10"></div>
27
+
28
+ <!-- Login Card -->
29
+ <div class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-8 transform transition-all">
30
+ <!-- Logo & Branding -->
31
+ <div class="text-center mb-8">
32
+ <div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-2xl shadow-lg mb-4">
33
+ <svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
34
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"></path>
35
+ </svg>
36
+ </div>
37
+ <h1 class="text-2xl font-bold text-gray-800">CBT Companion</h1>
38
+ <p class="text-gray-500 mt-1">Admin Portal</p>
39
+ </div>
40
+
41
+ <!-- Error Message -->
42
+ <div id="error-message" class="hidden mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
43
+ <div class="flex items-center">
44
+ <svg class="w-5 h-5 text-red-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
45
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
46
+ </svg>
47
+ <span id="error-text" class="text-red-700 text-sm"></span>
48
+ </div>
49
+ </div>
50
+
51
+ <!-- Login Form -->
52
+ <form id="login-form" class="space-y-6">
53
+ <!-- Email Input -->
54
+ <div>
55
+ <label for="email" class="block text-sm font-medium text-gray-700 mb-2">Email Address</label>
56
+ <div class="relative">
57
+ <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
58
+ <svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
59
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"></path>
60
+ </svg>
61
+ </div>
62
+ <input type="email" id="email" name="email" required
63
+ class="block w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
64
+ placeholder="admin@example.com">
65
+ </div>
66
+ </div>
67
+
68
+ <!-- Password Input -->
69
+ <div>
70
+ <label for="password" class="block text-sm font-medium text-gray-700 mb-2">Password</label>
71
+ <div class="relative">
72
+ <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
73
+ <svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
74
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
75
+ </svg>
76
+ </div>
77
+ <input type="password" id="password" name="password" required
78
+ class="block w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
79
+ placeholder="Enter your password">
80
+ </div>
81
+ </div>
82
+
83
+ <!-- Submit Button -->
84
+ <button type="submit" id="submit-btn"
85
+ class="w-full py-3 px-4 bg-gradient-to-r from-blue-600 to-indigo-600 text-white font-semibold rounded-lg shadow-lg hover:from-blue-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transform transition-all hover:scale-[1.02] active:scale-[0.98]">
86
+ <span id="btn-text">Sign In</span>
87
+ <svg id="btn-spinner" class="hidden animate-spin ml-2 h-5 w-5 text-white inline" fill="none" viewBox="0 0 24 24">
88
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
89
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
90
+ </svg>
91
+ </button>
92
+ </form>
93
+
94
+ <!-- Footer -->
95
+ <p class="mt-8 text-center text-sm text-gray-500">
96
+ Protected area. Authorized personnel only.
97
+ </p>
98
+ </div>
99
+
100
+ <script>
101
+ // Check if already logged in
102
+ const token = localStorage.getItem('admin_token');
103
+ if (token) {
104
+ window.location.href = '/admin/dashboard';
105
+ }
106
+
107
+ // Handle form submission
108
+ document.getElementById('login-form').addEventListener('submit', async (e) => {
109
+ e.preventDefault();
110
+
111
+ const email = document.getElementById('email').value;
112
+ const password = document.getElementById('password').value;
113
+ const errorDiv = document.getElementById('error-message');
114
+ const errorText = document.getElementById('error-text');
115
+ const submitBtn = document.getElementById('submit-btn');
116
+ const btnText = document.getElementById('btn-text');
117
+ const btnSpinner = document.getElementById('btn-spinner');
118
+
119
+ // Show loading state
120
+ submitBtn.disabled = true;
121
+ btnText.textContent = 'Signing in...';
122
+ btnSpinner.classList.remove('hidden');
123
+ errorDiv.classList.add('hidden');
124
+
125
+ try {
126
+ const response = await fetch('/admin/api/login', {
127
+ method: 'POST',
128
+ headers: {
129
+ 'Content-Type': 'application/json'
130
+ },
131
+ body: JSON.stringify({ email, password })
132
+ });
133
+
134
+ const data = await response.json();
135
+
136
+ if (response.ok) {
137
+ // Store token and redirect
138
+ localStorage.setItem('admin_token', data.token);
139
+ localStorage.setItem('admin_user', JSON.stringify(data.user));
140
+ window.location.href = '/admin/dashboard';
141
+ } else {
142
+ // Show error
143
+ errorText.textContent = data.error || 'Login failed. Please try again.';
144
+ errorDiv.classList.remove('hidden');
145
+ }
146
+ } catch (error) {
147
+ errorText.textContent = 'Network error. Please check your connection.';
148
+ errorDiv.classList.remove('hidden');
149
+ } finally {
150
+ // Reset button state
151
+ submitBtn.disabled = false;
152
+ btnText.textContent = 'Sign In';
153
+ btnSpinner.classList.add('hidden');
154
+ }
155
+ });
156
+ </script>
157
+ </body>
158
+ </html>
templates/admin_patient_detail.html ADDED
@@ -0,0 +1,750 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Patient Details - CBT Companion Admin</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
9
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
10
+ <script>
11
+ tailwind.config = {
12
+ theme: {
13
+ extend: {
14
+ colors: {
15
+ primary: '#3B82F6',
16
+ secondary: '#10B981',
17
+ danger: '#EF4444',
18
+ warning: '#F59E0B',
19
+ }
20
+ }
21
+ }
22
+ }
23
+ </script>
24
+ </head>
25
+ <body class="bg-gray-100 min-h-screen">
26
+ <div class="flex h-screen overflow-hidden">
27
+ <!-- Sidebar -->
28
+ <aside id="sidebar" class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-xl transform -translate-x-full transition-transform duration-300 lg:relative lg:translate-x-0">
29
+ <!-- Logo -->
30
+ <div class="flex items-center justify-between h-16 px-6 border-b border-gray-200">
31
+ <div class="flex items-center space-x-3">
32
+ <div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
33
+ <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
34
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"></path>
35
+ </svg>
36
+ </div>
37
+ <span class="text-lg font-bold text-gray-800">CBT Admin</span>
38
+ </div>
39
+ <button id="close-sidebar" class="lg:hidden p-2 rounded-lg hover:bg-gray-100">
40
+ <svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
41
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
42
+ </svg>
43
+ </button>
44
+ </div>
45
+
46
+ <!-- Navigation -->
47
+ <nav class="px-4 py-6 space-y-2">
48
+ <a href="/admin/dashboard" class="flex items-center px-4 py-3 text-gray-600 hover:bg-gray-50 rounded-lg transition-colors">
49
+ <svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
50
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
51
+ </svg>
52
+ Dashboard
53
+ </a>
54
+ <a href="/admin/patients" class="flex items-center px-4 py-3 text-blue-600 bg-blue-50 rounded-lg">
55
+ <svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
56
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>
57
+ </svg>
58
+ Patients
59
+ </a>
60
+ </nav>
61
+
62
+ <!-- User Info -->
63
+ <div class="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
64
+ <div class="flex items-center space-x-3">
65
+ <div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center">
66
+ <span id="admin-initials" class="text-white font-semibold text-sm">AD</span>
67
+ </div>
68
+ <div class="flex-1 min-w-0">
69
+ <p id="admin-name" class="text-sm font-medium text-gray-900 truncate">Admin</p>
70
+ <p id="admin-email" class="text-xs text-gray-500 truncate">admin@example.com</p>
71
+ </div>
72
+ <button id="logout-btn" class="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors" title="Logout">
73
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
74
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
75
+ </svg>
76
+ </button>
77
+ </div>
78
+ </div>
79
+ </aside>
80
+
81
+ <!-- Sidebar Overlay -->
82
+ <div id="sidebar-overlay" class="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden hidden"></div>
83
+
84
+ <!-- Main Content -->
85
+ <main class="flex-1 overflow-y-auto">
86
+ <!-- Top Bar -->
87
+ <header class="bg-white shadow-sm sticky top-0 z-30">
88
+ <div class="flex items-center justify-between h-16 px-6">
89
+ <div class="flex items-center space-x-4">
90
+ <button id="open-sidebar" class="lg:hidden p-2 rounded-lg hover:bg-gray-100">
91
+ <svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
92
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
93
+ </svg>
94
+ </button>
95
+ <a href="/admin/patients" class="text-gray-400 hover:text-gray-600">
96
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
97
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
98
+ </svg>
99
+ </a>
100
+ <h1 class="text-xl font-semibold text-gray-800">Patient Details</h1>
101
+ </div>
102
+ </div>
103
+ </header>
104
+
105
+ <!-- Loading State -->
106
+ <div id="loading-state" class="flex items-center justify-center h-64">
107
+ <svg class="animate-spin h-8 w-8 text-blue-500" fill="none" viewBox="0 0 24 24">
108
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
109
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
110
+ </svg>
111
+ </div>
112
+
113
+ <!-- Patient Content (hidden until loaded) -->
114
+ <div id="patient-content" class="hidden p-6 space-y-6">
115
+ <!-- Patient Header -->
116
+ <div class="bg-white rounded-xl shadow-sm p-6">
117
+ <div class="flex flex-col md:flex-row md:items-center md:justify-between">
118
+ <div class="flex items-center space-x-4">
119
+ <div id="patient-avatar" class="w-16 h-16 bg-gradient-to-br from-blue-400 to-indigo-500 rounded-full flex items-center justify-center">
120
+ <span id="patient-initials" class="text-white font-bold text-xl">--</span>
121
+ </div>
122
+ <div>
123
+ <h2 id="patient-name" class="text-2xl font-bold text-gray-800">Loading...</h2>
124
+ <p id="patient-email" class="text-gray-500">loading@example.com</p>
125
+ <p id="patient-member-since" class="text-sm text-gray-400 mt-1">Member since --</p>
126
+ </div>
127
+ </div>
128
+ <div class="mt-4 md:mt-0 flex flex-wrap gap-4">
129
+ <div class="text-center px-4 py-2 bg-blue-50 rounded-lg">
130
+ <p id="stat-sessions" class="text-2xl font-bold text-blue-600">-</p>
131
+ <p class="text-xs text-gray-500">Sessions</p>
132
+ </div>
133
+ <div class="text-center px-4 py-2 bg-green-50 rounded-lg">
134
+ <p id="stat-exercises" class="text-2xl font-bold text-green-600">-</p>
135
+ <p class="text-xs text-gray-500">Exercises</p>
136
+ </div>
137
+ <div class="text-center px-4 py-2 bg-purple-50 rounded-lg">
138
+ <p id="stat-streak" class="text-2xl font-bold text-purple-600">-</p>
139
+ <p class="text-xs text-gray-500">Day Streak</p>
140
+ </div>
141
+ </div>
142
+ </div>
143
+ </div>
144
+
145
+ <!-- Tabs -->
146
+ <div class="bg-white rounded-xl shadow-sm overflow-hidden">
147
+ <div class="border-b border-gray-200">
148
+ <nav class="flex -mb-px overflow-x-auto">
149
+ <button class="tab-btn active px-6 py-4 text-sm font-medium border-b-2 whitespace-nowrap" data-tab="overview">
150
+ Overview
151
+ </button>
152
+ <button class="tab-btn px-6 py-4 text-sm font-medium border-b-2 whitespace-nowrap" data-tab="vitals">
153
+ Vitals
154
+ </button>
155
+ <button class="tab-btn px-6 py-4 text-sm font-medium border-b-2 whitespace-nowrap" data-tab="sessions">
156
+ Sessions
157
+ </button>
158
+ <button class="tab-btn px-6 py-4 text-sm font-medium border-b-2 whitespace-nowrap" data-tab="crisis">
159
+ Crisis History
160
+ </button>
161
+ </nav>
162
+ </div>
163
+
164
+ <!-- Tab Content -->
165
+ <div class="p-6">
166
+ <!-- Overview Tab -->
167
+ <div id="tab-overview" class="tab-content">
168
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
169
+ <!-- Distortion Pattern Radar Chart -->
170
+ <div class="bg-gray-50 rounded-lg p-4">
171
+ <h3 class="text-lg font-semibold text-gray-800 mb-4">Distortion Pattern</h3>
172
+ <div class="h-64">
173
+ <canvas id="distortionRadarChart"></canvas>
174
+ </div>
175
+ </div>
176
+
177
+ <!-- Mood Tracking Line Chart -->
178
+ <div class="bg-gray-50 rounded-lg p-4">
179
+ <h3 class="text-lg font-semibold text-gray-800 mb-4">Mood Tracking</h3>
180
+ <div class="h-64">
181
+ <canvas id="moodChart"></canvas>
182
+ </div>
183
+ </div>
184
+ </div>
185
+
186
+ <!-- Distortion Legend -->
187
+ <div class="mt-6 grid grid-cols-2 md:grid-cols-4 gap-4">
188
+ <div class="bg-blue-50 p-4 rounded-lg">
189
+ <h4 class="font-semibold text-blue-700">G1: Binary Thinking</h4>
190
+ <p class="text-sm text-gray-600 mt-1">All-or-nothing, black-and-white patterns</p>
191
+ </div>
192
+ <div class="bg-green-50 p-4 rounded-lg">
193
+ <h4 class="font-semibold text-green-700">G2: Overgeneralization</h4>
194
+ <p class="text-sm text-gray-600 mt-1">Broad conclusions from single events</p>
195
+ </div>
196
+ <div class="bg-yellow-50 p-4 rounded-lg">
197
+ <h4 class="font-semibold text-yellow-700">G3: Attention Bias</h4>
198
+ <p class="text-sm text-gray-600 mt-1">Focus on negatives, filtering positives</p>
199
+ </div>
200
+ <div class="bg-red-50 p-4 rounded-lg">
201
+ <h4 class="font-semibold text-red-700">G4: Emotion-Driven</h4>
202
+ <p class="text-sm text-gray-600 mt-1">Feelings treated as facts</p>
203
+ </div>
204
+ </div>
205
+ </div>
206
+
207
+ <!-- Vitals Tab -->
208
+ <div id="tab-vitals" class="tab-content hidden">
209
+ <!-- Current Readings -->
210
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
211
+ <div class="bg-red-50 rounded-lg p-4">
212
+ <div class="flex items-center justify-between">
213
+ <div>
214
+ <p class="text-sm text-gray-500">Heart Rate (PPG)</p>
215
+ <p id="vital-ppg" class="text-2xl font-bold text-red-600">--</p>
216
+ </div>
217
+ <svg class="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
218
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"></path>
219
+ </svg>
220
+ </div>
221
+ <p id="vital-ppg-time" class="text-xs text-gray-400 mt-2">Last updated: --</p>
222
+ </div>
223
+ <div class="bg-blue-50 rounded-lg p-4">
224
+ <div class="flex items-center justify-between">
225
+ <div>
226
+ <p class="text-sm text-gray-500">Skin Conductance (GSR)</p>
227
+ <p id="vital-gsr" class="text-2xl font-bold text-blue-600">--</p>
228
+ </div>
229
+ <svg class="w-8 h-8 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
230
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
231
+ </svg>
232
+ </div>
233
+ <p id="vital-gsr-time" class="text-xs text-gray-400 mt-2">Last updated: --</p>
234
+ </div>
235
+ <div class="bg-green-50 rounded-lg p-4">
236
+ <div class="flex items-center justify-between">
237
+ <div>
238
+ <p class="text-sm text-gray-500">Activity Level</p>
239
+ <p id="vital-activity" class="text-2xl font-bold text-green-600">--</p>
240
+ </div>
241
+ <svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
242
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path>
243
+ </svg>
244
+ </div>
245
+ <p id="vital-activity-time" class="text-xs text-gray-400 mt-2">X: -- Y: -- Z: --</p>
246
+ </div>
247
+ </div>
248
+
249
+ <!-- Vitals Charts -->
250
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
251
+ <div class="bg-gray-50 rounded-lg p-4">
252
+ <h3 class="text-lg font-semibold text-gray-800 mb-4">Heart Rate Trend (24h)</h3>
253
+ <div class="h-64">
254
+ <canvas id="ppgChart"></canvas>
255
+ </div>
256
+ </div>
257
+ <div class="bg-gray-50 rounded-lg p-4">
258
+ <h3 class="text-lg font-semibold text-gray-800 mb-4">Skin Conductance (24h)</h3>
259
+ <div class="h-64">
260
+ <canvas id="gsrChart"></canvas>
261
+ </div>
262
+ </div>
263
+ </div>
264
+
265
+ <!-- Vitals Summary Table -->
266
+ <div class="mt-6 bg-gray-50 rounded-lg p-4">
267
+ <h3 class="text-lg font-semibold text-gray-800 mb-4">Vitals Summary</h3>
268
+ <div class="overflow-x-auto">
269
+ <table class="min-w-full">
270
+ <thead>
271
+ <tr class="border-b border-gray-200">
272
+ <th class="text-left py-2 text-sm font-medium text-gray-500">Metric</th>
273
+ <th class="text-center py-2 text-sm font-medium text-gray-500">Min</th>
274
+ <th class="text-center py-2 text-sm font-medium text-gray-500">Avg</th>
275
+ <th class="text-center py-2 text-sm font-medium text-gray-500">Max</th>
276
+ </tr>
277
+ </thead>
278
+ <tbody id="vitals-summary-body">
279
+ <tr>
280
+ <td colspan="4" class="py-4 text-center text-gray-500">Loading...</td>
281
+ </tr>
282
+ </tbody>
283
+ </table>
284
+ </div>
285
+ </div>
286
+ </div>
287
+
288
+ <!-- Sessions Tab -->
289
+ <div id="tab-sessions" class="tab-content hidden">
290
+ <div id="sessions-timeline" class="space-y-4">
291
+ <!-- Sessions will be populated here -->
292
+ <div class="text-center text-gray-500 py-8">Loading sessions...</div>
293
+ </div>
294
+ </div>
295
+
296
+ <!-- Crisis History Tab -->
297
+ <div id="tab-crisis" class="tab-content hidden">
298
+ <div id="crisis-history" class="space-y-4">
299
+ <!-- Crisis history will be populated here -->
300
+ <div class="text-center text-gray-500 py-8">Loading crisis history...</div>
301
+ </div>
302
+ </div>
303
+ </div>
304
+ </div>
305
+ </div>
306
+ </main>
307
+ </div>
308
+
309
+ <!-- Toast Container -->
310
+ <div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2"></div>
311
+
312
+ <script src="{{ url_for('static', filename='js/admin.js') }}"></script>
313
+ <script>
314
+ const userId = '{{ user_id }}';
315
+ let patientData = null;
316
+ let distortionRadarChart = null;
317
+ let moodChart = null;
318
+ let ppgChart = null;
319
+ let gsrChart = null;
320
+
321
+ document.addEventListener('DOMContentLoaded', () => {
322
+ // Check authentication
323
+ if (!AdminPanel.isAuthenticated()) {
324
+ window.location.href = '/admin/login';
325
+ return;
326
+ }
327
+
328
+ // Set admin user info
329
+ const user = AdminPanel.getUser();
330
+ if (user) {
331
+ document.getElementById('admin-name').textContent = user.name;
332
+ document.getElementById('admin-email').textContent = user.email;
333
+ document.getElementById('admin-initials').textContent = user.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
334
+ }
335
+
336
+ // Load patient data
337
+ loadPatientData();
338
+
339
+ // Setup event listeners
340
+ setupEventListeners();
341
+ });
342
+
343
+ async function loadPatientData() {
344
+ try {
345
+ patientData = await AdminPanel.fetchAPI(`/admin/api/patients/${userId}`);
346
+
347
+ // Update header
348
+ document.getElementById('patient-name').textContent = patientData.name;
349
+ document.getElementById('patient-email').textContent = patientData.email;
350
+ document.getElementById('patient-initials').textContent = patientData.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
351
+ document.getElementById('patient-member-since').textContent = `Member since ${AdminPanel.formatDate(patientData.created_at)}`;
352
+
353
+ // Update stats
354
+ document.getElementById('stat-sessions').textContent = patientData.stats.total_sessions;
355
+ document.getElementById('stat-exercises').textContent = patientData.stats.total_exercises;
356
+ document.getElementById('stat-streak').textContent = patientData.stats.current_streak;
357
+
358
+ // Show content
359
+ document.getElementById('loading-state').classList.add('hidden');
360
+ document.getElementById('patient-content').classList.remove('hidden');
361
+
362
+ // Load charts
363
+ loadDistortionRadarChart();
364
+ loadMoodChart();
365
+ loadVitalsData();
366
+ loadSessionsTimeline();
367
+ loadCrisisHistory();
368
+
369
+ } catch (error) {
370
+ console.error('Error loading patient:', error);
371
+ AdminPanel.showToast('Failed to load patient data', 'error');
372
+ }
373
+ }
374
+
375
+ function loadDistortionRadarChart() {
376
+ const ctx = document.getElementById('distortionRadarChart').getContext('2d');
377
+ const pattern = patientData.distortion_pattern;
378
+
379
+ if (distortionRadarChart) distortionRadarChart.destroy();
380
+
381
+ distortionRadarChart = new Chart(ctx, {
382
+ type: 'radar',
383
+ data: {
384
+ labels: ['Binary Thinking (G1)', 'Overgeneralization (G2)', 'Attention Bias (G3)', 'Emotion-Driven (G4)'],
385
+ datasets: [{
386
+ label: 'Occurrences',
387
+ data: [pattern.G1, pattern.G2, pattern.G3, pattern.G4],
388
+ backgroundColor: 'rgba(59, 130, 246, 0.2)',
389
+ borderColor: '#3B82F6',
390
+ borderWidth: 2,
391
+ pointBackgroundColor: '#3B82F6',
392
+ pointBorderColor: '#fff',
393
+ pointBorderWidth: 2
394
+ }]
395
+ },
396
+ options: {
397
+ responsive: true,
398
+ maintainAspectRatio: false,
399
+ scales: {
400
+ r: {
401
+ beginAtZero: true,
402
+ ticks: { stepSize: 1 }
403
+ }
404
+ },
405
+ plugins: {
406
+ legend: { display: false }
407
+ }
408
+ }
409
+ });
410
+ }
411
+
412
+ function loadMoodChart() {
413
+ const ctx = document.getElementById('moodChart').getContext('2d');
414
+ const moodHistory = patientData.mood_history || [];
415
+
416
+ if (moodChart) moodChart.destroy();
417
+
418
+ const labels = moodHistory.map(m => AdminPanel.formatDate(m.date));
419
+ const moodStart = moodHistory.map(m => m.mood_start);
420
+ const moodEnd = moodHistory.map(m => m.mood_end);
421
+
422
+ moodChart = new Chart(ctx, {
423
+ type: 'line',
424
+ data: {
425
+ labels: labels,
426
+ datasets: [
427
+ {
428
+ label: 'Mood Start',
429
+ data: moodStart,
430
+ borderColor: '#EF4444',
431
+ backgroundColor: 'rgba(239, 68, 68, 0.1)',
432
+ tension: 0.4,
433
+ fill: false
434
+ },
435
+ {
436
+ label: 'Mood End',
437
+ data: moodEnd,
438
+ borderColor: '#10B981',
439
+ backgroundColor: 'rgba(16, 185, 129, 0.1)',
440
+ tension: 0.4,
441
+ fill: false
442
+ }
443
+ ]
444
+ },
445
+ options: {
446
+ responsive: true,
447
+ maintainAspectRatio: false,
448
+ scales: {
449
+ y: {
450
+ beginAtZero: true,
451
+ max: 10,
452
+ title: { display: true, text: 'Mood Level' }
453
+ }
454
+ },
455
+ plugins: {
456
+ legend: {
457
+ position: 'top'
458
+ }
459
+ }
460
+ }
461
+ });
462
+ }
463
+
464
+ async function loadVitalsData() {
465
+ try {
466
+ const vitalsData = await AdminPanel.fetchAPI(`/admin/api/charts/vitals/${userId}?hours=24`);
467
+ const summary = patientData.wearable_summary;
468
+
469
+ // Update current readings
470
+ if (summary.latest) {
471
+ document.getElementById('vital-ppg').textContent = summary.latest.ppg.toFixed(1);
472
+ document.getElementById('vital-gsr').textContent = summary.latest.gsr.toFixed(4);
473
+ const magnitude = Math.sqrt(
474
+ Math.pow(summary.latest.acc_x, 2) +
475
+ Math.pow(summary.latest.acc_y, 2) +
476
+ Math.pow(summary.latest.acc_z, 2)
477
+ ).toFixed(2);
478
+ document.getElementById('vital-activity').textContent = magnitude;
479
+ document.getElementById('vital-ppg-time').textContent = `Last updated: ${AdminPanel.formatTimeAgo(summary.latest.recorded_at)}`;
480
+ document.getElementById('vital-gsr-time').textContent = `Last updated: ${AdminPanel.formatTimeAgo(summary.latest.recorded_at)}`;
481
+ document.getElementById('vital-activity-time').textContent = `X: ${summary.latest.acc_x.toFixed(2)} Y: ${summary.latest.acc_y.toFixed(2)} Z: ${summary.latest.acc_z.toFixed(2)}`;
482
+ }
483
+
484
+ // Update summary table
485
+ const dayStats = summary.day;
486
+ const summaryBody = document.getElementById('vitals-summary-body');
487
+ summaryBody.innerHTML = `
488
+ <tr class="border-b border-gray-100">
489
+ <td class="py-2 text-sm text-gray-900">Heart Rate (PPG)</td>
490
+ <td class="py-2 text-sm text-center text-gray-600">${dayStats.ppg.min ?? '--'}</td>
491
+ <td class="py-2 text-sm text-center text-gray-600">${dayStats.ppg.avg ?? '--'}</td>
492
+ <td class="py-2 text-sm text-center text-gray-600">${dayStats.ppg.max ?? '--'}</td>
493
+ </tr>
494
+ <tr class="border-b border-gray-100">
495
+ <td class="py-2 text-sm text-gray-900">Skin Conductance (GSR)</td>
496
+ <td class="py-2 text-sm text-center text-gray-600">${dayStats.gsr.min ?? '--'}</td>
497
+ <td class="py-2 text-sm text-center text-gray-600">${dayStats.gsr.avg ?? '--'}</td>
498
+ <td class="py-2 text-sm text-center text-gray-600">${dayStats.gsr.max ?? '--'}</td>
499
+ </tr>
500
+ <tr>
501
+ <td class="py-2 text-sm text-gray-900">Readings (24h)</td>
502
+ <td colspan="3" class="py-2 text-sm text-center text-gray-600">${dayStats.reading_count} readings</td>
503
+ </tr>
504
+ `;
505
+
506
+ // Load charts
507
+ loadPPGChart(vitalsData.data);
508
+ loadGSRChart(vitalsData.data);
509
+
510
+ } catch (error) {
511
+ console.error('Error loading vitals:', error);
512
+ }
513
+ }
514
+
515
+ function loadPPGChart(data) {
516
+ const ctx = document.getElementById('ppgChart').getContext('2d');
517
+
518
+ if (ppgChart) ppgChart.destroy();
519
+
520
+ if (data.length === 0) {
521
+ ctx.font = '14px sans-serif';
522
+ ctx.fillStyle = '#9CA3AF';
523
+ ctx.textAlign = 'center';
524
+ ctx.fillText('No data available', ctx.canvas.width / 2, ctx.canvas.height / 2);
525
+ return;
526
+ }
527
+
528
+ ppgChart = new Chart(ctx, {
529
+ type: 'line',
530
+ data: {
531
+ labels: data.map(d => new Date(d.recorded_at).toLocaleTimeString()),
532
+ datasets: [{
533
+ label: 'PPG',
534
+ data: data.map(d => d.ppg),
535
+ borderColor: '#EF4444',
536
+ backgroundColor: 'rgba(239, 68, 68, 0.1)',
537
+ fill: true,
538
+ tension: 0.4,
539
+ pointRadius: 0
540
+ }]
541
+ },
542
+ options: {
543
+ responsive: true,
544
+ maintainAspectRatio: false,
545
+ plugins: { legend: { display: false } },
546
+ scales: {
547
+ x: { display: false },
548
+ y: { beginAtZero: false }
549
+ }
550
+ }
551
+ });
552
+ }
553
+
554
+ function loadGSRChart(data) {
555
+ const ctx = document.getElementById('gsrChart').getContext('2d');
556
+
557
+ if (gsrChart) gsrChart.destroy();
558
+
559
+ if (data.length === 0) {
560
+ ctx.font = '14px sans-serif';
561
+ ctx.fillStyle = '#9CA3AF';
562
+ ctx.textAlign = 'center';
563
+ ctx.fillText('No data available', ctx.canvas.width / 2, ctx.canvas.height / 2);
564
+ return;
565
+ }
566
+
567
+ gsrChart = new Chart(ctx, {
568
+ type: 'line',
569
+ data: {
570
+ labels: data.map(d => new Date(d.recorded_at).toLocaleTimeString()),
571
+ datasets: [{
572
+ label: 'GSR',
573
+ data: data.map(d => d.gsr),
574
+ borderColor: '#3B82F6',
575
+ backgroundColor: 'rgba(59, 130, 246, 0.1)',
576
+ fill: true,
577
+ tension: 0.4,
578
+ pointRadius: 0
579
+ }]
580
+ },
581
+ options: {
582
+ responsive: true,
583
+ maintainAspectRatio: false,
584
+ plugins: { legend: { display: false } },
585
+ scales: {
586
+ x: { display: false },
587
+ y: { beginAtZero: false }
588
+ }
589
+ }
590
+ });
591
+ }
592
+
593
+ function loadSessionsTimeline() {
594
+ const container = document.getElementById('sessions-timeline');
595
+ const sessions = patientData.sessions || [];
596
+
597
+ if (sessions.length === 0) {
598
+ container.innerHTML = `
599
+ <div class="text-center py-8">
600
+ <svg class="w-12 h-12 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
601
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
602
+ </svg>
603
+ <p class="text-gray-500">No sessions yet</p>
604
+ </div>
605
+ `;
606
+ return;
607
+ }
608
+
609
+ const groupNames = {
610
+ 'G1': 'Binary Thinking',
611
+ 'G2': 'Overgeneralization',
612
+ 'G3': 'Attention Bias',
613
+ 'G4': 'Emotion-Driven',
614
+ 'G0': 'No Distortion'
615
+ };
616
+
617
+ container.innerHTML = sessions.map(session => {
618
+ const moodChange = session.mood_end && session.mood_start ? session.mood_end - session.mood_start : null;
619
+ const moodArrow = moodChange !== null
620
+ ? (moodChange > 0 ? '<span class="text-green-500">+' + moodChange + '</span>' : (moodChange < 0 ? '<span class="text-red-500">' + moodChange + '</span>' : '<span class="text-gray-500">0</span>'))
621
+ : '<span class="text-gray-400">--</span>';
622
+
623
+ return `
624
+ <div class="bg-gray-50 rounded-lg p-4 hover:bg-gray-100 transition-colors">
625
+ <div class="flex items-center justify-between">
626
+ <div class="flex items-center space-x-4">
627
+ <div class="w-10 h-10 rounded-full flex items-center justify-center ${session.completed ? 'bg-green-100' : 'bg-gray-200'}">
628
+ ${session.completed
629
+ ? '<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>'
630
+ : '<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>'}
631
+ </div>
632
+ <div>
633
+ <p class="font-medium text-gray-900">${AdminPanel.formatDate(session.started_at)}</p>
634
+ <p class="text-sm text-gray-500">${session.locked_group ? groupNames[session.locked_group] || session.locked_group : 'N/A'}</p>
635
+ </div>
636
+ </div>
637
+ <div class="flex items-center space-x-6 text-sm">
638
+ <div class="text-center">
639
+ <p class="text-gray-500">Mood</p>
640
+ <p class="font-medium">${moodArrow}</p>
641
+ </div>
642
+ <div class="text-center">
643
+ <p class="text-gray-500">Stage</p>
644
+ <p class="font-medium text-gray-900">${session.stages_reached || 1}/3</p>
645
+ </div>
646
+ <span class="px-2 py-1 text-xs font-medium rounded-full ${session.completed ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}">
647
+ ${session.completed ? 'Completed' : 'Incomplete'}
648
+ </span>
649
+ </div>
650
+ </div>
651
+ </div>
652
+ `;
653
+ }).join('');
654
+ }
655
+
656
+ function loadCrisisHistory() {
657
+ const container = document.getElementById('crisis-history');
658
+ const crisisHistory = patientData.crisis_history || [];
659
+
660
+ if (crisisHistory.length === 0) {
661
+ container.innerHTML = `
662
+ <div class="text-center py-8">
663
+ <svg class="w-12 h-12 mx-auto text-green-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
664
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
665
+ </svg>
666
+ <p class="text-gray-500">No crisis flags recorded</p>
667
+ </div>
668
+ `;
669
+ return;
670
+ }
671
+
672
+ container.innerHTML = crisisHistory.map(crisis => `
673
+ <div class="bg-red-50 border border-red-200 rounded-lg p-4">
674
+ <div class="flex items-start justify-between">
675
+ <div class="flex items-start space-x-3">
676
+ <div class="w-2 h-2 bg-red-500 rounded-full mt-2 ${!crisis.reviewed ? 'animate-pulse' : ''}"></div>
677
+ <div>
678
+ <div class="flex items-center space-x-2">
679
+ <span class="px-2 py-0.5 bg-red-100 text-red-700 text-xs font-medium rounded">${AdminPanel.escapeHtml(crisis.trigger_word)}</span>
680
+ <span class="text-sm text-gray-500">${AdminPanel.formatTimeAgo(crisis.flagged_at)}</span>
681
+ </div>
682
+ <p class="text-gray-700 mt-2">${AdminPanel.escapeHtml(crisis.message_content)}</p>
683
+ </div>
684
+ </div>
685
+ <span class="px-2 py-1 text-xs font-medium rounded-full ${crisis.reviewed ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}">
686
+ ${crisis.reviewed ? 'Reviewed' : 'Pending'}
687
+ </span>
688
+ </div>
689
+ </div>
690
+ `).join('');
691
+ }
692
+
693
+ function setupEventListeners() {
694
+ // Tab switching
695
+ document.querySelectorAll('.tab-btn').forEach(btn => {
696
+ btn.addEventListener('click', () => {
697
+ // Update buttons
698
+ document.querySelectorAll('.tab-btn').forEach(b => {
699
+ b.classList.remove('active', 'text-blue-600', 'border-blue-600');
700
+ b.classList.add('text-gray-500', 'border-transparent', 'hover:text-gray-700');
701
+ });
702
+ btn.classList.add('active', 'text-blue-600', 'border-blue-600');
703
+ btn.classList.remove('text-gray-500', 'border-transparent', 'hover:text-gray-700');
704
+
705
+ // Update content
706
+ document.querySelectorAll('.tab-content').forEach(content => {
707
+ content.classList.add('hidden');
708
+ });
709
+ document.getElementById(`tab-${btn.dataset.tab}`).classList.remove('hidden');
710
+ });
711
+ });
712
+
713
+ // Sidebar toggle
714
+ document.getElementById('open-sidebar').addEventListener('click', () => {
715
+ document.getElementById('sidebar').classList.remove('-translate-x-full');
716
+ document.getElementById('sidebar-overlay').classList.remove('hidden');
717
+ });
718
+
719
+ document.getElementById('close-sidebar').addEventListener('click', () => {
720
+ document.getElementById('sidebar').classList.add('-translate-x-full');
721
+ document.getElementById('sidebar-overlay').classList.add('hidden');
722
+ });
723
+
724
+ document.getElementById('sidebar-overlay').addEventListener('click', () => {
725
+ document.getElementById('sidebar').classList.add('-translate-x-full');
726
+ document.getElementById('sidebar-overlay').classList.add('hidden');
727
+ });
728
+
729
+ // Logout
730
+ document.getElementById('logout-btn').addEventListener('click', () => {
731
+ AdminPanel.logout();
732
+ });
733
+ }
734
+ </script>
735
+
736
+ <style>
737
+ .tab-btn {
738
+ border-color: transparent;
739
+ color: #6B7280;
740
+ }
741
+ .tab-btn:hover {
742
+ color: #374151;
743
+ }
744
+ .tab-btn.active {
745
+ color: #3B82F6;
746
+ border-color: #3B82F6;
747
+ }
748
+ </style>
749
+ </body>
750
+ </html>
templates/admin_patients.html ADDED
@@ -0,0 +1,527 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Patients - CBT Companion Admin</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
9
+ <script>
10
+ tailwind.config = {
11
+ theme: {
12
+ extend: {
13
+ colors: {
14
+ primary: '#3B82F6',
15
+ secondary: '#10B981',
16
+ danger: '#EF4444',
17
+ warning: '#F59E0B',
18
+ }
19
+ }
20
+ }
21
+ }
22
+ </script>
23
+ </head>
24
+ <body class="bg-gray-100 min-h-screen">
25
+ <div class="flex h-screen overflow-hidden">
26
+ <!-- Sidebar -->
27
+ <aside id="sidebar" class="fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-xl transform -translate-x-full transition-transform duration-300 lg:relative lg:translate-x-0">
28
+ <!-- Logo -->
29
+ <div class="flex items-center justify-between h-16 px-6 border-b border-gray-200">
30
+ <div class="flex items-center space-x-3">
31
+ <div class="w-8 h-8 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-lg flex items-center justify-center">
32
+ <svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
33
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"></path>
34
+ </svg>
35
+ </div>
36
+ <span class="text-lg font-bold text-gray-800">CBT Admin</span>
37
+ </div>
38
+ <button id="close-sidebar" class="lg:hidden p-2 rounded-lg hover:bg-gray-100">
39
+ <svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
40
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
41
+ </svg>
42
+ </button>
43
+ </div>
44
+
45
+ <!-- Navigation -->
46
+ <nav class="px-4 py-6 space-y-2">
47
+ <a href="/admin/dashboard" class="flex items-center px-4 py-3 text-gray-600 hover:bg-gray-50 rounded-lg transition-colors">
48
+ <svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
49
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
50
+ </svg>
51
+ Dashboard
52
+ </a>
53
+ <a href="/admin/patients" class="flex items-center px-4 py-3 text-blue-600 bg-blue-50 rounded-lg">
54
+ <svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
55
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>
56
+ </svg>
57
+ Patients
58
+ </a>
59
+ </nav>
60
+
61
+ <!-- User Info -->
62
+ <div class="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
63
+ <div class="flex items-center space-x-3">
64
+ <div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-indigo-600 rounded-full flex items-center justify-center">
65
+ <span id="user-initials" class="text-white font-semibold text-sm">AD</span>
66
+ </div>
67
+ <div class="flex-1 min-w-0">
68
+ <p id="user-name" class="text-sm font-medium text-gray-900 truncate">Admin</p>
69
+ <p id="user-email" class="text-xs text-gray-500 truncate">admin@example.com</p>
70
+ </div>
71
+ <button id="logout-btn" class="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors" title="Logout">
72
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
73
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
74
+ </svg>
75
+ </button>
76
+ </div>
77
+ </div>
78
+ </aside>
79
+
80
+ <!-- Sidebar Overlay -->
81
+ <div id="sidebar-overlay" class="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden hidden"></div>
82
+
83
+ <!-- Main Content -->
84
+ <main class="flex-1 overflow-y-auto">
85
+ <!-- Top Bar -->
86
+ <header class="bg-white shadow-sm sticky top-0 z-30">
87
+ <div class="flex items-center justify-between h-16 px-6">
88
+ <div class="flex items-center space-x-4">
89
+ <button id="open-sidebar" class="lg:hidden p-2 rounded-lg hover:bg-gray-100">
90
+ <svg class="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
91
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
92
+ </svg>
93
+ </button>
94
+ <h1 class="text-xl font-semibold text-gray-800">Patients</h1>
95
+ </div>
96
+ <button id="export-btn" class="flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors">
97
+ <svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
98
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
99
+ </svg>
100
+ Export CSV
101
+ </button>
102
+ </div>
103
+ </header>
104
+
105
+ <!-- Patients Content -->
106
+ <div class="p-6">
107
+ <!-- Search and Filters -->
108
+ <div class="bg-white rounded-xl shadow-sm p-4 mb-6">
109
+ <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
110
+ <!-- Search -->
111
+ <div class="relative flex-1 max-w-md">
112
+ <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
113
+ <svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
114
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
115
+ </svg>
116
+ </div>
117
+ <input type="text" id="search-input" placeholder="Search patients..."
118
+ class="block w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
119
+ </div>
120
+
121
+ <!-- Filters -->
122
+ <div class="flex items-center gap-4">
123
+ <select id="filter-status" class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
124
+ <option value="">All Status</option>
125
+ <option value="active">Active (7 days)</option>
126
+ <option value="inactive">Inactive</option>
127
+ </select>
128
+ <select id="filter-alerts" class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
129
+ <option value="">All Alerts</option>
130
+ <option value="has-alerts">Has Alerts</option>
131
+ <option value="no-alerts">No Alerts</option>
132
+ </select>
133
+ </div>
134
+ </div>
135
+ </div>
136
+
137
+ <!-- Patients Table -->
138
+ <div class="bg-white rounded-xl shadow-sm overflow-hidden">
139
+ <div class="overflow-x-auto">
140
+ <table class="min-w-full divide-y divide-gray-200">
141
+ <thead class="bg-gray-50">
142
+ <tr>
143
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" data-sort="name">
144
+ <div class="flex items-center">
145
+ Patient
146
+ <svg class="w-4 h-4 ml-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
147
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"></path>
148
+ </svg>
149
+ </div>
150
+ </th>
151
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
152
+ Email
153
+ </th>
154
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" data-sort="sessions">
155
+ <div class="flex items-center">
156
+ Sessions
157
+ <svg class="w-4 h-4 ml-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
158
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"></path>
159
+ </svg>
160
+ </div>
161
+ </th>
162
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100" data-sort="last_active">
163
+ <div class="flex items-center">
164
+ Last Active
165
+ <svg class="w-4 h-4 ml-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
166
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"></path>
167
+ </svg>
168
+ </div>
169
+ </th>
170
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
171
+ Alerts
172
+ </th>
173
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
174
+ Status
175
+ </th>
176
+ <th scope="col" class="relative px-6 py-3">
177
+ <span class="sr-only">Actions</span>
178
+ </th>
179
+ </tr>
180
+ </thead>
181
+ <tbody id="patients-table-body" class="bg-white divide-y divide-gray-200">
182
+ <!-- Loading state -->
183
+ <tr id="loading-row">
184
+ <td colspan="7" class="px-6 py-12 text-center">
185
+ <svg class="animate-spin h-8 w-8 mx-auto mb-4 text-gray-400" fill="none" viewBox="0 0 24 24">
186
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
187
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
188
+ </svg>
189
+ <p class="text-gray-500">Loading patients...</p>
190
+ </td>
191
+ </tr>
192
+ </tbody>
193
+ </table>
194
+ </div>
195
+
196
+ <!-- Pagination -->
197
+ <div id="pagination" class="hidden px-6 py-4 border-t border-gray-200 flex items-center justify-between">
198
+ <p class="text-sm text-gray-500">
199
+ Showing <span id="showing-start">1</span> to <span id="showing-end">10</span> of <span id="total-count">0</span> patients
200
+ </p>
201
+ <div class="flex items-center space-x-2">
202
+ <button id="prev-page" class="px-3 py-1 border border-gray-300 rounded-lg text-sm hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
203
+ Previous
204
+ </button>
205
+ <span id="page-info" class="text-sm text-gray-600">Page 1</span>
206
+ <button id="next-page" class="px-3 py-1 border border-gray-300 rounded-lg text-sm hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
207
+ Next
208
+ </button>
209
+ </div>
210
+ </div>
211
+ </div>
212
+ </div>
213
+ </main>
214
+ </div>
215
+
216
+ <!-- Toast Container -->
217
+ <div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2"></div>
218
+
219
+ <script src="{{ url_for('static', filename='js/admin.js') }}"></script>
220
+ <script>
221
+ let allPatients = [];
222
+ let filteredPatients = [];
223
+ let currentPage = 1;
224
+ const pageSize = 10;
225
+ let sortField = 'name';
226
+ let sortDirection = 'asc';
227
+
228
+ document.addEventListener('DOMContentLoaded', () => {
229
+ // Check authentication
230
+ if (!AdminPanel.isAuthenticated()) {
231
+ window.location.href = '/admin/login';
232
+ return;
233
+ }
234
+
235
+ // Set user info
236
+ const user = AdminPanel.getUser();
237
+ if (user) {
238
+ document.getElementById('user-name').textContent = user.name;
239
+ document.getElementById('user-email').textContent = user.email;
240
+ document.getElementById('user-initials').textContent = user.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
241
+ }
242
+
243
+ // Load patients
244
+ loadPatients();
245
+
246
+ // Setup event listeners
247
+ setupEventListeners();
248
+ });
249
+
250
+ async function loadPatients() {
251
+ try {
252
+ const data = await AdminPanel.fetchAPI('/admin/api/patients');
253
+ allPatients = data.patients;
254
+ applyFilters();
255
+ } catch (error) {
256
+ console.error('Error loading patients:', error);
257
+ AdminPanel.showToast('Failed to load patients', 'error');
258
+ }
259
+ }
260
+
261
+ function applyFilters() {
262
+ const search = document.getElementById('search-input').value.toLowerCase();
263
+ const statusFilter = document.getElementById('filter-status').value;
264
+ const alertsFilter = document.getElementById('filter-alerts').value;
265
+
266
+ filteredPatients = allPatients.filter(patient => {
267
+ // Search filter
268
+ const matchesSearch = !search ||
269
+ patient.name.toLowerCase().includes(search) ||
270
+ patient.email.toLowerCase().includes(search);
271
+
272
+ // Status filter
273
+ let matchesStatus = true;
274
+ if (statusFilter === 'active') {
275
+ const lastActive = patient.last_session_date ? new Date(patient.last_session_date) : null;
276
+ const sevenDaysAgo = new Date();
277
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
278
+ matchesStatus = lastActive && lastActive >= sevenDaysAgo;
279
+ } else if (statusFilter === 'inactive') {
280
+ const lastActive = patient.last_session_date ? new Date(patient.last_session_date) : null;
281
+ const sevenDaysAgo = new Date();
282
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
283
+ matchesStatus = !lastActive || lastActive < sevenDaysAgo;
284
+ }
285
+
286
+ // Alerts filter
287
+ let matchesAlerts = true;
288
+ if (alertsFilter === 'has-alerts') {
289
+ matchesAlerts = patient.unreviewed_alerts > 0;
290
+ } else if (alertsFilter === 'no-alerts') {
291
+ matchesAlerts = patient.unreviewed_alerts === 0;
292
+ }
293
+
294
+ return matchesSearch && matchesStatus && matchesAlerts;
295
+ });
296
+
297
+ // Sort
298
+ sortPatients();
299
+
300
+ // Reset to first page
301
+ currentPage = 1;
302
+ renderPatients();
303
+ }
304
+
305
+ function sortPatients() {
306
+ filteredPatients.sort((a, b) => {
307
+ let valA, valB;
308
+
309
+ switch (sortField) {
310
+ case 'name':
311
+ valA = a.name.toLowerCase();
312
+ valB = b.name.toLowerCase();
313
+ break;
314
+ case 'sessions':
315
+ valA = a.total_sessions;
316
+ valB = b.total_sessions;
317
+ break;
318
+ case 'last_active':
319
+ valA = a.last_session_date ? new Date(a.last_session_date) : new Date(0);
320
+ valB = b.last_session_date ? new Date(b.last_session_date) : new Date(0);
321
+ break;
322
+ default:
323
+ return 0;
324
+ }
325
+
326
+ if (valA < valB) return sortDirection === 'asc' ? -1 : 1;
327
+ if (valA > valB) return sortDirection === 'asc' ? 1 : -1;
328
+ return 0;
329
+ });
330
+ }
331
+
332
+ function renderPatients() {
333
+ const tbody = document.getElementById('patients-table-body');
334
+ const loadingRow = document.getElementById('loading-row');
335
+ const pagination = document.getElementById('pagination');
336
+
337
+ if (loadingRow) loadingRow.remove();
338
+
339
+ // Calculate pagination
340
+ const totalPages = Math.ceil(filteredPatients.length / pageSize);
341
+ const start = (currentPage - 1) * pageSize;
342
+ const end = Math.min(start + pageSize, filteredPatients.length);
343
+ const pagePatients = filteredPatients.slice(start, end);
344
+
345
+ // Clear table
346
+ tbody.innerHTML = '';
347
+
348
+ if (pagePatients.length === 0) {
349
+ tbody.innerHTML = `
350
+ <tr>
351
+ <td colspan="7" class="px-6 py-12 text-center text-gray-500">
352
+ No patients found
353
+ </td>
354
+ </tr>
355
+ `;
356
+ pagination.classList.add('hidden');
357
+ return;
358
+ }
359
+
360
+ // Render patients
361
+ pagePatients.forEach(patient => {
362
+ const row = createPatientRow(patient);
363
+ tbody.appendChild(row);
364
+ });
365
+
366
+ // Update pagination
367
+ pagination.classList.remove('hidden');
368
+ document.getElementById('showing-start').textContent = start + 1;
369
+ document.getElementById('showing-end').textContent = end;
370
+ document.getElementById('total-count').textContent = filteredPatients.length;
371
+ document.getElementById('page-info').textContent = `Page ${currentPage} of ${totalPages || 1}`;
372
+ document.getElementById('prev-page').disabled = currentPage === 1;
373
+ document.getElementById('next-page').disabled = currentPage >= totalPages;
374
+ }
375
+
376
+ function createPatientRow(patient) {
377
+ const row = document.createElement('tr');
378
+ row.className = 'hover:bg-gray-50 cursor-pointer transition-colors';
379
+ row.onclick = () => window.location.href = `/admin/patients/${patient.id}`;
380
+
381
+ const lastActive = patient.last_session_date ? new Date(patient.last_session_date) : null;
382
+ const sevenDaysAgo = new Date();
383
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
384
+ const isActive = lastActive && lastActive >= sevenDaysAgo;
385
+
386
+ row.innerHTML = `
387
+ <td class="px-6 py-4 whitespace-nowrap">
388
+ <div class="flex items-center">
389
+ <div class="w-10 h-10 bg-gradient-to-br from-blue-400 to-indigo-500 rounded-full flex items-center justify-center flex-shrink-0">
390
+ <span class="text-white font-semibold text-sm">${patient.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}</span>
391
+ </div>
392
+ <div class="ml-4">
393
+ <div class="text-sm font-medium text-gray-900">${AdminPanel.escapeHtml(patient.name)}</div>
394
+ <div class="text-sm text-gray-500">${patient.context || 'person'}</div>
395
+ </div>
396
+ </div>
397
+ </td>
398
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
399
+ ${AdminPanel.escapeHtml(patient.email)}
400
+ </td>
401
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
402
+ ${patient.total_sessions}
403
+ </td>
404
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
405
+ ${lastActive ? AdminPanel.formatDate(patient.last_session_date) : 'Never'}
406
+ </td>
407
+ <td class="px-6 py-4 whitespace-nowrap">
408
+ ${patient.unreviewed_alerts > 0
409
+ ? `<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full">${patient.unreviewed_alerts} pending</span>`
410
+ : '<span class="text-gray-400">-</span>'}
411
+ </td>
412
+ <td class="px-6 py-4 whitespace-nowrap">
413
+ ${isActive
414
+ ? '<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">Active</span>'
415
+ : '<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded-full">Inactive</span>'}
416
+ </td>
417
+ <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
418
+ <button onclick="event.stopPropagation(); window.location.href='/admin/patients/${patient.id}'" class="text-blue-600 hover:text-blue-900">
419
+ View
420
+ </button>
421
+ </td>
422
+ `;
423
+
424
+ return row;
425
+ }
426
+
427
+ function exportCSV() {
428
+ if (filteredPatients.length === 0) {
429
+ AdminPanel.showToast('No patients to export', 'warning');
430
+ return;
431
+ }
432
+
433
+ const headers = ['Name', 'Email', 'Context', 'Total Sessions', 'Total Exercises', 'Current Streak', 'Last Session', 'Unreviewed Alerts', 'Created At'];
434
+ const rows = filteredPatients.map(p => [
435
+ p.name,
436
+ p.email,
437
+ p.context,
438
+ p.total_sessions,
439
+ p.total_exercises,
440
+ p.current_streak,
441
+ p.last_session_date || '',
442
+ p.unreviewed_alerts,
443
+ p.created_at
444
+ ]);
445
+
446
+ const csvContent = [headers, ...rows]
447
+ .map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
448
+ .join('\n');
449
+
450
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
451
+ const link = document.createElement('a');
452
+ link.href = URL.createObjectURL(blob);
453
+ link.download = `patients_export_${new Date().toISOString().split('T')[0]}.csv`;
454
+ link.click();
455
+
456
+ AdminPanel.showToast('CSV exported successfully', 'success');
457
+ }
458
+
459
+ function setupEventListeners() {
460
+ // Search
461
+ let searchTimeout;
462
+ document.getElementById('search-input').addEventListener('input', () => {
463
+ clearTimeout(searchTimeout);
464
+ searchTimeout = setTimeout(applyFilters, 300);
465
+ });
466
+
467
+ // Filters
468
+ document.getElementById('filter-status').addEventListener('change', applyFilters);
469
+ document.getElementById('filter-alerts').addEventListener('change', applyFilters);
470
+
471
+ // Sort headers
472
+ document.querySelectorAll('[data-sort]').forEach(header => {
473
+ header.addEventListener('click', () => {
474
+ const field = header.dataset.sort;
475
+ if (sortField === field) {
476
+ sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
477
+ } else {
478
+ sortField = field;
479
+ sortDirection = 'asc';
480
+ }
481
+ applyFilters();
482
+ });
483
+ });
484
+
485
+ // Pagination
486
+ document.getElementById('prev-page').addEventListener('click', () => {
487
+ if (currentPage > 1) {
488
+ currentPage--;
489
+ renderPatients();
490
+ }
491
+ });
492
+
493
+ document.getElementById('next-page').addEventListener('click', () => {
494
+ const totalPages = Math.ceil(filteredPatients.length / pageSize);
495
+ if (currentPage < totalPages) {
496
+ currentPage++;
497
+ renderPatients();
498
+ }
499
+ });
500
+
501
+ // Export
502
+ document.getElementById('export-btn').addEventListener('click', exportCSV);
503
+
504
+ // Sidebar toggle
505
+ document.getElementById('open-sidebar').addEventListener('click', () => {
506
+ document.getElementById('sidebar').classList.remove('-translate-x-full');
507
+ document.getElementById('sidebar-overlay').classList.remove('hidden');
508
+ });
509
+
510
+ document.getElementById('close-sidebar').addEventListener('click', () => {
511
+ document.getElementById('sidebar').classList.add('-translate-x-full');
512
+ document.getElementById('sidebar-overlay').classList.add('hidden');
513
+ });
514
+
515
+ document.getElementById('sidebar-overlay').addEventListener('click', () => {
516
+ document.getElementById('sidebar').classList.add('-translate-x-full');
517
+ document.getElementById('sidebar-overlay').classList.add('hidden');
518
+ });
519
+
520
+ // Logout
521
+ document.getElementById('logout-btn').addEventListener('click', () => {
522
+ AdminPanel.logout();
523
+ });
524
+ }
525
+ </script>
526
+ </body>
527
+ </html>
wearable.py ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Wearable Device Module
3
+ Handles sensor data from wearable devices (PPG, GSR, Accelerometer)
4
+ """
5
+
6
+ from flask import Blueprint, request, jsonify
7
+ from auth import token_required
8
+ from database import get_db
9
+ from datetime import datetime
10
+
11
+ wearable_bp = Blueprint('wearable', __name__)
12
+
13
+
14
+ @wearable_bp.route("/api/wearable/data", methods=["POST"])
15
+ @token_required
16
+ def receive_sensor_data():
17
+ """
18
+ Receive sensor data from wearable device.
19
+
20
+ Expected JSON payload:
21
+ {
22
+ "ppg": 75.5, # Photoplethysmography value (heart rate/pulse)
23
+ "gsr": 2.3, # Galvanic Skin Response (skin conductance)
24
+ "acc_x": 0.12, # Accelerometer X-axis
25
+ "acc_y": -0.05, # Accelerometer Y-axis
26
+ "acc_z": 9.81, # Accelerometer Z-axis
27
+ "timestamp": "2024-01-31T12:00:00Z" # Optional: device timestamp
28
+ }
29
+ """
30
+ try:
31
+ user = request.current_user
32
+ db = get_db()
33
+
34
+ data = request.json
35
+
36
+ if not data:
37
+ return jsonify({"error": "No data provided"}), 400
38
+
39
+ # Extract sensor values
40
+ ppg = data.get("ppg")
41
+ gsr = data.get("gsr")
42
+ acc_x = data.get("acc_x")
43
+ acc_y = data.get("acc_y")
44
+ acc_z = data.get("acc_z")
45
+ device_timestamp = data.get("timestamp")
46
+
47
+ # Validate required fields
48
+ if ppg is None or gsr is None:
49
+ return jsonify({"error": "ppg and gsr values are required"}), 400
50
+
51
+ if acc_x is None or acc_y is None or acc_z is None:
52
+ return jsonify({"error": "acc_x, acc_y, and acc_z values are required"}), 400
53
+
54
+ # Save to database
55
+ record_id = db.save_wearable_data(
56
+ user_id=user["id"],
57
+ ppg=float(ppg),
58
+ gsr=float(gsr),
59
+ acc_x=float(acc_x),
60
+ acc_y=float(acc_y),
61
+ acc_z=float(acc_z),
62
+ device_timestamp=device_timestamp
63
+ )
64
+
65
+ return jsonify({
66
+ "success": True,
67
+ "record_id": record_id,
68
+ "message": "Sensor data saved successfully"
69
+ })
70
+
71
+ except ValueError as e:
72
+ return jsonify({"error": f"Invalid data format: {str(e)}"}), 400
73
+ except Exception as e:
74
+ print(f"Error saving wearable data: {str(e)}")
75
+ return jsonify({"error": "Failed to save sensor data"}), 500
76
+
77
+
78
+ @wearable_bp.route("/api/wearable/batch", methods=["POST"])
79
+ @token_required
80
+ def receive_batch_data():
81
+ """
82
+ Receive batch sensor data from wearable device.
83
+ Useful for sending multiple readings at once (e.g., when device reconnects).
84
+
85
+ Expected JSON payload:
86
+ {
87
+ "readings": [
88
+ {"ppg": 75.5, "gsr": 2.3, "acc_x": 0.1, "acc_y": -0.05, "acc_z": 9.81, "timestamp": "..."},
89
+ {"ppg": 76.0, "gsr": 2.4, "acc_x": 0.2, "acc_y": -0.04, "acc_z": 9.80, "timestamp": "..."},
90
+ ...
91
+ ]
92
+ }
93
+ """
94
+ try:
95
+ user = request.current_user
96
+ db = get_db()
97
+
98
+ data = request.json
99
+ readings = data.get("readings", [])
100
+
101
+ if not readings:
102
+ return jsonify({"error": "No readings provided"}), 400
103
+
104
+ if len(readings) > 1000:
105
+ return jsonify({"error": "Maximum 1000 readings per batch"}), 400
106
+
107
+ saved_count = 0
108
+ errors = []
109
+
110
+ for i, reading in enumerate(readings):
111
+ try:
112
+ ppg = reading.get("ppg")
113
+ gsr = reading.get("gsr")
114
+ acc_x = reading.get("acc_x")
115
+ acc_y = reading.get("acc_y")
116
+ acc_z = reading.get("acc_z")
117
+ device_timestamp = reading.get("timestamp")
118
+
119
+ if None in (ppg, gsr, acc_x, acc_y, acc_z):
120
+ errors.append(f"Reading {i}: missing required fields")
121
+ continue
122
+
123
+ db.save_wearable_data(
124
+ user_id=user["id"],
125
+ ppg=float(ppg),
126
+ gsr=float(gsr),
127
+ acc_x=float(acc_x),
128
+ acc_y=float(acc_y),
129
+ acc_z=float(acc_z),
130
+ device_timestamp=device_timestamp
131
+ )
132
+ saved_count += 1
133
+
134
+ except (ValueError, TypeError) as e:
135
+ errors.append(f"Reading {i}: {str(e)}")
136
+
137
+ return jsonify({
138
+ "success": True,
139
+ "saved_count": saved_count,
140
+ "total_readings": len(readings),
141
+ "errors": errors if errors else None
142
+ })
143
+
144
+ except Exception as e:
145
+ print(f"Error saving batch wearable data: {str(e)}")
146
+ return jsonify({"error": "Failed to save batch data"}), 500
147
+
148
+
149
+ @wearable_bp.route("/api/wearable/history", methods=["GET"])
150
+ @token_required
151
+ def get_sensor_history():
152
+ """
153
+ Get user's sensor data history.
154
+
155
+ Query parameters:
156
+ - limit: Number of records to return (default: 100, max: 1000)
157
+ - offset: Number of records to skip (for pagination)
158
+ - start_date: Filter records from this date (ISO format)
159
+ - end_date: Filter records until this date (ISO format)
160
+ """
161
+ try:
162
+ user = request.current_user
163
+ db = get_db()
164
+
165
+ limit = min(int(request.args.get("limit", 100)), 1000)
166
+ offset = int(request.args.get("offset", 0))
167
+ start_date = request.args.get("start_date")
168
+ end_date = request.args.get("end_date")
169
+
170
+ records = db.get_wearable_history(
171
+ user_id=user["id"],
172
+ limit=limit,
173
+ offset=offset,
174
+ start_date=start_date,
175
+ end_date=end_date
176
+ )
177
+
178
+ return jsonify({
179
+ "records": records,
180
+ "count": len(records),
181
+ "limit": limit,
182
+ "offset": offset
183
+ })
184
+
185
+ except Exception as e:
186
+ print(f"Error fetching wearable history: {str(e)}")
187
+ return jsonify({"error": "Failed to fetch sensor history"}), 500
188
+
189
+
190
+ @wearable_bp.route("/api/wearable/latest", methods=["GET"])
191
+ @token_required
192
+ def get_latest_reading():
193
+ """Get the most recent sensor reading for the user."""
194
+ try:
195
+ user = request.current_user
196
+ db = get_db()
197
+
198
+ record = db.get_latest_wearable_data(user["id"])
199
+
200
+ if not record:
201
+ return jsonify({
202
+ "record": None,
203
+ "message": "No sensor data found"
204
+ })
205
+
206
+ return jsonify({
207
+ "record": record
208
+ })
209
+
210
+ except Exception as e:
211
+ print(f"Error fetching latest wearable data: {str(e)}")
212
+ return jsonify({"error": "Failed to fetch latest reading"}), 500
213
+
214
+
215
+ @wearable_bp.route("/api/wearable/stats", methods=["GET"])
216
+ @token_required
217
+ def get_sensor_stats():
218
+ """
219
+ Get aggregated statistics for user's sensor data.
220
+
221
+ Query parameters:
222
+ - period: 'day', 'week', 'month' (default: 'day')
223
+ """
224
+ try:
225
+ user = request.current_user
226
+ db = get_db()
227
+
228
+ period = request.args.get("period", "day")
229
+ if period not in ("day", "week", "month"):
230
+ return jsonify({"error": "Invalid period. Use 'day', 'week', or 'month'"}), 400
231
+
232
+ stats = db.get_wearable_stats(user["id"], period)
233
+
234
+ return jsonify({
235
+ "period": period,
236
+ "stats": stats
237
+ })
238
+
239
+ except Exception as e:
240
+ print(f"Error fetching wearable stats: {str(e)}")
241
+ return jsonify({"error": "Failed to fetch sensor statistics"}), 500