stanlee47 Claude Sonnet 4.6 commited on
Commit
03cf1a4
·
1 Parent(s): e111c8a

Fix ML false positives + improve admin panel

Browse files

ML Bug Fixes (ml_inference.py, wearable.py):
- Remove double normalization: simple_standardize() was running on top of
already z-normalized features, corrupting the model's input distribution
- Fix accelerometer gravity: backend now subtracts 9.81 m/s² to match ESP32
firmware (acc_motion = abs(total - 9.81)), not include gravity offset
- Add MIN_CONFIDENCE = 0.55 threshold in wearable.py — uncertain predictions
no longer trigger alerts, crisis flags, or depression episodes
- Log ESP32 on-device DRI score in server output for debugging

Backend (database.py):
- get_dashboard_stats: add active_episodes + wearable_readings_today
- get_all_users: add has_active_episode per patient for patients table

Admin Panel (templates/):
- Dashboard: 3-col main stats + new wearable row (Active Risk Episodes, 24h readings)
- Patients list: new Wearable Risk column with pulsing badge for active episodes
- Patient detail: risk badge next to name + border highlight when active episode
- ML tab: Active Episode banner with start time and peak confidence; chart labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

database.py CHANGED
@@ -867,6 +867,12 @@ class Database:
867
  (r[0],)
868
  ).fetchone()[0]
869
 
 
 
 
 
 
 
870
  users.append({
871
  "id": r[0],
872
  "email": r[1],
@@ -877,7 +883,8 @@ class Database:
877
  "total_exercises": r[6] or 0,
878
  "current_streak": r[7] or 0,
879
  "last_session_date": r[8],
880
- "unreviewed_alerts": flag_count
 
881
  })
882
 
883
  return users
@@ -1013,13 +1020,26 @@ class Database:
1013
  "SELECT COUNT(*) FROM sessions WHERE completed = 1"
1014
  ).fetchone()[0]
1015
 
 
 
 
 
 
 
 
 
 
 
 
1016
  return {
1017
  "total_users": total_users,
1018
  "sessions_today": sessions_today,
1019
  "unreviewed_alerts": unreviewed_alerts,
1020
  "avg_mood_change": avg_mood_change,
1021
  "total_sessions": total_sessions,
1022
- "completed_sessions": completed_sessions
 
 
1023
  }
1024
 
1025
  def get_user_wearable_summary(self, user_id: str) -> dict:
 
867
  (r[0],)
868
  ).fetchone()[0]
869
 
870
+ # Check for active ML-detected risk episode
871
+ active_ep = self.conn.execute(
872
+ "SELECT COUNT(*) FROM depression_episodes WHERE user_id = ? AND ended_at IS NULL",
873
+ (r[0],)
874
+ ).fetchone()[0]
875
+
876
  users.append({
877
  "id": r[0],
878
  "email": r[1],
 
883
  "total_exercises": r[6] or 0,
884
  "current_streak": r[7] or 0,
885
  "last_session_date": r[8],
886
+ "unreviewed_alerts": flag_count,
887
+ "has_active_episode": active_ep > 0,
888
  })
889
 
890
  return users
 
1020
  "SELECT COUNT(*) FROM sessions WHERE completed = 1"
1021
  ).fetchone()[0]
1022
 
1023
+ # Active ML-detected risk episodes
1024
+ active_episodes = self.conn.execute(
1025
+ "SELECT COUNT(*) FROM depression_episodes WHERE ended_at IS NULL"
1026
+ ).fetchone()[0]
1027
+
1028
+ # Wearable readings in last 24 hours
1029
+ wearable_today = self.conn.execute(
1030
+ """SELECT COUNT(*) FROM wearable_data
1031
+ WHERE recorded_at >= datetime('now', '-24 hours')"""
1032
+ ).fetchone()[0]
1033
+
1034
  return {
1035
  "total_users": total_users,
1036
  "sessions_today": sessions_today,
1037
  "unreviewed_alerts": unreviewed_alerts,
1038
  "avg_mood_change": avg_mood_change,
1039
  "total_sessions": total_sessions,
1040
+ "completed_sessions": completed_sessions,
1041
+ "active_episodes": active_episodes,
1042
+ "wearable_readings_today": wearable_today,
1043
  }
1044
 
1045
  def get_user_wearable_summary(self, user_id: str) -> dict:
ml_inference.py CHANGED
@@ -157,10 +157,12 @@ def prepare_sensor_data(raw_readings):
157
  ppg_values = [r['ppg'] for r in raw_readings]
158
  gsr_values = [r['gsr'] for r in raw_readings]
159
 
160
- # Calculate motion magnitude
 
161
  acc_values = []
162
  for r in raw_readings:
163
- motion = np.sqrt(r['acc_x']**2 + r['acc_y']**2 + r['acc_z']**2)
 
164
  acc_values.append(motion)
165
 
166
  # Z-normalize using rolling window statistics
@@ -258,8 +260,11 @@ def predict_risk(raw_readings):
258
  "message": "Need at least 25 readings for prediction"
259
  }
260
 
261
- # Normalize features
262
- features = simple_standardize(features)
 
 
 
263
 
264
  # Convert to tensor
265
  model = model_singleton.get_model()
 
157
  ppg_values = [r['ppg'] for r in raw_readings]
158
  gsr_values = [r['gsr'] for r in raw_readings]
159
 
160
+ # Calculate motion magnitude — subtract 9.81 to remove gravity offset,
161
+ # matching the ESP32 firmware: acc_motion = abs(acc_total - 9.81)
162
  acc_values = []
163
  for r in raw_readings:
164
+ total = np.sqrt(r['acc_x']**2 + r['acc_y']**2 + r['acc_z']**2)
165
+ motion = abs(total - 9.81)
166
  acc_values.append(motion)
167
 
168
  # Z-normalize using rolling window statistics
 
260
  "message": "Need at least 25 readings for prediction"
261
  }
262
 
263
+ # NOTE: simple_standardize is intentionally NOT called here.
264
+ # Features are already extracted from z-normalized sensor data inside
265
+ # prepare_sensor_data → z_normalize → extract_features_from_window.
266
+ # Applying a second normalization pass corrupts the feature distributions
267
+ # the model was trained on and causes systematic misclassification.
268
 
269
  # Convert to tensor
270
  model = model_singleton.get_model()
templates/admin_dashboard.html ADDED
@@ -0,0 +1,624 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-3 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
+ <p class="text-xs text-gray-400 mt-1"><span id="stat-sessions" class="font-medium text-gray-600">-</span> sessions today</p>
119
+ </div>
120
+ <div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center">
121
+ <svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
122
+ <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>
123
+ </svg>
124
+ </div>
125
+ </div>
126
+ </div>
127
+
128
+ <!-- Crisis Alerts Card -->
129
+ <div class="bg-white rounded-xl shadow-sm p-6 hover:shadow-md transition-shadow border-l-4 border-red-500">
130
+ <div class="flex items-center justify-between">
131
+ <div>
132
+ <p class="text-sm font-medium text-gray-500">Crisis Alerts</p>
133
+ <p id="stat-alerts" class="text-3xl font-bold text-red-600 mt-1">-</p>
134
+ <p class="text-xs text-gray-400 mt-1">unreviewed — requires attention</p>
135
+ </div>
136
+ <div class="w-12 h-12 bg-red-100 rounded-xl flex items-center justify-center">
137
+ <svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
138
+ <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>
139
+ </svg>
140
+ </div>
141
+ </div>
142
+ </div>
143
+
144
+ <!-- Avg Mood Change Card -->
145
+ <div class="bg-white rounded-xl shadow-sm p-6 hover:shadow-md transition-shadow">
146
+ <div class="flex items-center justify-between">
147
+ <div>
148
+ <p class="text-sm font-medium text-gray-500">Avg Mood Change</p>
149
+ <p id="stat-mood" class="text-3xl font-bold text-gray-800 mt-1">-</p>
150
+ <p class="text-xs text-gray-400 mt-1">across completed sessions</p>
151
+ </div>
152
+ <div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center">
153
+ <svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
154
+ <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>
155
+ </svg>
156
+ </div>
157
+ </div>
158
+ </div>
159
+ </div>
160
+
161
+ <!-- Wearable / ML Stats Row -->
162
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
163
+ <!-- Active Risk Episodes -->
164
+ <div id="card-active-episodes" class="bg-white rounded-xl shadow-sm p-6 hover:shadow-md transition-shadow border-l-4 border-orange-400">
165
+ <div class="flex items-center justify-between">
166
+ <div>
167
+ <p class="text-sm font-medium text-gray-500">Active Risk Episodes</p>
168
+ <p id="stat-active-episodes" class="text-3xl font-bold text-orange-600 mt-1">-</p>
169
+ <p class="text-xs text-gray-400 mt-1">patients with ongoing ML-detected stress</p>
170
+ </div>
171
+ <div class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center">
172
+ <svg class="w-6 h-6 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
173
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
174
+ </svg>
175
+ </div>
176
+ </div>
177
+ </div>
178
+
179
+ <!-- Wearable Readings Today -->
180
+ <div class="bg-white rounded-xl shadow-sm p-6 hover:shadow-md transition-shadow">
181
+ <div class="flex items-center justify-between">
182
+ <div>
183
+ <p class="text-sm font-medium text-gray-500">Wearable Readings (24h)</p>
184
+ <p id="stat-wearable-today" class="text-3xl font-bold text-teal-600 mt-1">-</p>
185
+ <p class="text-xs text-gray-400 mt-1">sensor data points received</p>
186
+ </div>
187
+ <div class="w-12 h-12 bg-teal-100 rounded-xl flex items-center justify-center">
188
+ <svg class="w-6 h-6 text-teal-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
189
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"></path>
190
+ </svg>
191
+ </div>
192
+ </div>
193
+ </div>
194
+ </div>
195
+
196
+ <!-- Charts Row -->
197
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
198
+ <!-- Session Trends Chart -->
199
+ <div class="bg-white rounded-xl shadow-sm p-6">
200
+ <h3 class="text-lg font-semibold text-gray-800 mb-4">Session Trends (30 Days)</h3>
201
+ <div class="h-64">
202
+ <canvas id="sessionsChart"></canvas>
203
+ </div>
204
+ </div>
205
+
206
+ <!-- Distortion Distribution Chart -->
207
+ <div class="bg-white rounded-xl shadow-sm p-6">
208
+ <h3 class="text-lg font-semibold text-gray-800 mb-4">Distortion Distribution</h3>
209
+ <div class="h-64 flex items-center justify-center">
210
+ <canvas id="distortionChart"></canvas>
211
+ </div>
212
+ </div>
213
+ </div>
214
+
215
+ <!-- Crisis Alerts Panel -->
216
+ <div class="bg-white rounded-xl shadow-sm overflow-hidden">
217
+ <div class="px-6 py-4 border-b border-gray-200 bg-gradient-to-r from-red-500 to-red-600">
218
+ <div class="flex items-center justify-between">
219
+ <h3 class="text-lg font-semibold text-white flex items-center">
220
+ <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
221
+ <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>
222
+ </svg>
223
+ Crisis Alerts
224
+ </h3>
225
+ <span id="alert-badge" class="px-3 py-1 bg-white text-red-600 text-sm font-semibold rounded-full">0 pending</span>
226
+ </div>
227
+ </div>
228
+
229
+ <div id="alerts-container" class="divide-y divide-gray-100">
230
+ <!-- Alerts will be populated here -->
231
+ <div id="alerts-loading" class="p-8 text-center text-gray-500">
232
+ <svg class="animate-spin h-8 w-8 mx-auto mb-4 text-gray-400" fill="none" viewBox="0 0 24 24">
233
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
234
+ <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>
235
+ </svg>
236
+ Loading alerts...
237
+ </div>
238
+ <div id="no-alerts" class="hidden p-8 text-center">
239
+ <svg class="w-12 h-12 mx-auto text-green-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
240
+ <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>
241
+ </svg>
242
+ <p class="text-gray-500">No pending alerts</p>
243
+ </div>
244
+ </div>
245
+ </div>
246
+
247
+ <!-- Recent Patients -->
248
+ <div class="bg-white rounded-xl shadow-sm overflow-hidden">
249
+ <div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
250
+ <h3 class="text-lg font-semibold text-gray-800">Recent Patients</h3>
251
+ <a href="/admin/patients" class="text-blue-600 hover:text-blue-700 text-sm font-medium">View All</a>
252
+ </div>
253
+ <div id="recent-patients" class="divide-y divide-gray-100">
254
+ <!-- Recent patients will be populated here -->
255
+ <div id="patients-loading" class="p-8 text-center text-gray-500">
256
+ <svg class="animate-spin h-8 w-8 mx-auto mb-4 text-gray-400" fill="none" viewBox="0 0 24 24">
257
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
258
+ <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>
259
+ </svg>
260
+ Loading patients...
261
+ </div>
262
+ </div>
263
+ </div>
264
+ </div>
265
+ </main>
266
+ </div>
267
+
268
+ <!-- Toast Container -->
269
+ <div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2"></div>
270
+
271
+ <script src="{{ url_for('static', filename='js/admin.js') }}"></script>
272
+ <script>
273
+ // Initialize dashboard
274
+ document.addEventListener('DOMContentLoaded', () => {
275
+ // Check authentication
276
+ if (!AdminPanel.isAuthenticated()) {
277
+ window.location.href = '/admin/login';
278
+ return;
279
+ }
280
+
281
+ // Set user info
282
+ const user = AdminPanel.getUser();
283
+ if (user) {
284
+ document.getElementById('user-name').textContent = user.name;
285
+ document.getElementById('user-email').textContent = user.email;
286
+ document.getElementById('user-initials').textContent = user.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
287
+ }
288
+
289
+ // Initialize charts and data
290
+ initializeDashboard();
291
+
292
+ // Setup event listeners
293
+ setupEventListeners();
294
+
295
+ // Start auto-refresh
296
+ setInterval(refreshData, 30000); // 30 seconds
297
+ });
298
+
299
+ let sessionsChart = null;
300
+ let distortionChart = null;
301
+
302
+ async function initializeDashboard() {
303
+ await Promise.all([
304
+ loadStats(),
305
+ loadAlerts(),
306
+ loadRecentPatients(),
307
+ loadSessionsChart(),
308
+ loadDistortionChart()
309
+ ]);
310
+ }
311
+
312
+ async function refreshData() {
313
+ await Promise.all([
314
+ loadStats(),
315
+ loadAlerts()
316
+ ]);
317
+ document.getElementById('last-updated').textContent = 'Updated just now';
318
+ }
319
+
320
+ async function loadStats() {
321
+ try {
322
+ const data = await AdminPanel.fetchAPI('/admin/api/stats');
323
+ document.getElementById('stat-patients').textContent = data.total_users;
324
+ document.getElementById('stat-sessions').textContent = data.sessions_today;
325
+ document.getElementById('stat-alerts').textContent = data.unreviewed_alerts;
326
+
327
+ const moodChange = data.avg_mood_change;
328
+ const moodEl = document.getElementById('stat-mood');
329
+ if (moodChange > 0) {
330
+ moodEl.textContent = '+' + moodChange;
331
+ moodEl.classList.remove('text-gray-800');
332
+ moodEl.classList.add('text-green-600');
333
+ } else if (moodChange < 0) {
334
+ moodEl.textContent = moodChange;
335
+ moodEl.classList.remove('text-gray-800');
336
+ moodEl.classList.add('text-red-600');
337
+ } else {
338
+ moodEl.textContent = '0';
339
+ }
340
+
341
+ // Wearable / ML stats
342
+ const episodes = data.active_episodes ?? 0;
343
+ document.getElementById('stat-active-episodes').textContent = episodes;
344
+ const episodeCard = document.getElementById('card-active-episodes');
345
+ if (episodes > 0) {
346
+ episodeCard.classList.add('border-orange-500');
347
+ episodeCard.classList.remove('border-orange-400');
348
+ }
349
+
350
+ document.getElementById('stat-wearable-today').textContent = data.wearable_readings_today ?? 0;
351
+
352
+ } catch (error) {
353
+ console.error('Error loading stats:', error);
354
+ }
355
+ }
356
+
357
+ async function loadAlerts() {
358
+ try {
359
+ const data = await AdminPanel.fetchAPI('/admin/api/alerts?reviewed=false');
360
+ const container = document.getElementById('alerts-container');
361
+ const loading = document.getElementById('alerts-loading');
362
+ const noAlerts = document.getElementById('no-alerts');
363
+ const badge = document.getElementById('alert-badge');
364
+
365
+ loading.classList.add('hidden');
366
+
367
+ if (data.alerts.length === 0) {
368
+ noAlerts.classList.remove('hidden');
369
+ badge.textContent = '0 pending';
370
+ return;
371
+ }
372
+
373
+ noAlerts.classList.add('hidden');
374
+ badge.textContent = data.alerts.length + ' pending';
375
+
376
+ // Clear and populate alerts
377
+ container.innerHTML = '';
378
+ data.alerts.slice(0, 5).forEach(alert => {
379
+ container.appendChild(createAlertElement(alert));
380
+ });
381
+
382
+ } catch (error) {
383
+ console.error('Error loading alerts:', error);
384
+ }
385
+ }
386
+
387
+ function createAlertElement(alert) {
388
+ const div = document.createElement('div');
389
+ div.className = 'p-4 hover:bg-red-50 transition-colors';
390
+ div.innerHTML = `
391
+ <div class="flex items-start justify-between">
392
+ <div class="flex items-start space-x-3">
393
+ <div class="w-2 h-2 bg-red-500 rounded-full mt-2 animate-pulse"></div>
394
+ <div>
395
+ <a href="/admin/patients/${alert.user_id}" class="font-medium text-gray-900 hover:text-blue-600">
396
+ ${AdminPanel.escapeHtml(alert.user_name)}
397
+ </a>
398
+ <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>
399
+ <p class="text-sm text-gray-600 mt-1 line-clamp-2">${AdminPanel.escapeHtml(alert.message_content)}</p>
400
+ <p class="text-xs text-gray-400 mt-1">${AdminPanel.formatTimeAgo(alert.flagged_at)}</p>
401
+ </div>
402
+ </div>
403
+ <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">
404
+ Review
405
+ </button>
406
+ </div>
407
+ `;
408
+ return div;
409
+ }
410
+
411
+ async function reviewAlert(alertId) {
412
+ try {
413
+ await AdminPanel.fetchAPI(`/admin/api/alerts/${alertId}/review`, { method: 'POST' });
414
+ AdminPanel.showToast('Alert marked as reviewed', 'success');
415
+ await loadAlerts();
416
+ await loadStats();
417
+ } catch (error) {
418
+ AdminPanel.showToast('Failed to review alert', 'error');
419
+ }
420
+ }
421
+
422
+ async function loadRecentPatients() {
423
+ try {
424
+ const data = await AdminPanel.fetchAPI('/admin/api/patients');
425
+ const container = document.getElementById('recent-patients');
426
+ const loading = document.getElementById('patients-loading');
427
+
428
+ loading.classList.add('hidden');
429
+
430
+ if (data.patients.length === 0) {
431
+ container.innerHTML = '<div class="p-8 text-center text-gray-500">No patients yet</div>';
432
+ return;
433
+ }
434
+
435
+ container.innerHTML = '';
436
+ data.patients.slice(0, 5).forEach(patient => {
437
+ const div = document.createElement('div');
438
+ div.className = 'p-4 hover:bg-gray-50 transition-colors cursor-pointer';
439
+ div.onclick = () => window.location.href = `/admin/patients/${patient.id}`;
440
+ div.innerHTML = `
441
+ <div class="flex items-center justify-between">
442
+ <div class="flex items-center space-x-3">
443
+ <div class="w-10 h-10 bg-gradient-to-br from-blue-400 to-indigo-500 rounded-full flex items-center justify-center">
444
+ <span class="text-white font-semibold text-sm">${patient.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}</span>
445
+ </div>
446
+ <div>
447
+ <p class="font-medium text-gray-900">${AdminPanel.escapeHtml(patient.name)}</p>
448
+ <p class="text-sm text-gray-500">${patient.total_sessions} sessions</p>
449
+ </div>
450
+ </div>
451
+ <div class="flex items-center space-x-2">
452
+ ${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>` : ''}
453
+ <span class="text-sm text-gray-400">${patient.last_session_date ? AdminPanel.formatDate(patient.last_session_date) : 'Never'}</span>
454
+ </div>
455
+ </div>
456
+ `;
457
+ container.appendChild(div);
458
+ });
459
+
460
+ } catch (error) {
461
+ console.error('Error loading patients:', error);
462
+ }
463
+ }
464
+
465
+ async function loadSessionsChart() {
466
+ try {
467
+ const data = await AdminPanel.fetchAPI('/admin/api/charts/sessions');
468
+
469
+ const ctx = document.getElementById('sessionsChart').getContext('2d');
470
+
471
+ // Fill missing dates with 0
472
+ const dates = [];
473
+ const counts = [];
474
+ const today = new Date();
475
+
476
+ for (let i = 29; i >= 0; i--) {
477
+ const date = new Date(today);
478
+ date.setDate(date.getDate() - i);
479
+ const dateStr = date.toISOString().split('T')[0];
480
+ dates.push(date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }));
481
+
482
+ const found = data.data.find(d => d.date === dateStr);
483
+ counts.push(found ? found.count : 0);
484
+ }
485
+
486
+ if (sessionsChart) {
487
+ sessionsChart.destroy();
488
+ }
489
+
490
+ sessionsChart = new Chart(ctx, {
491
+ type: 'line',
492
+ data: {
493
+ labels: dates,
494
+ datasets: [{
495
+ label: 'Sessions',
496
+ data: counts,
497
+ borderColor: '#3B82F6',
498
+ backgroundColor: 'rgba(59, 130, 246, 0.1)',
499
+ fill: true,
500
+ tension: 0.4,
501
+ pointRadius: 0,
502
+ pointHoverRadius: 6,
503
+ pointHoverBackgroundColor: '#3B82F6',
504
+ pointHoverBorderColor: '#fff',
505
+ pointHoverBorderWidth: 2
506
+ }]
507
+ },
508
+ options: {
509
+ responsive: true,
510
+ maintainAspectRatio: false,
511
+ plugins: {
512
+ legend: { display: false }
513
+ },
514
+ scales: {
515
+ x: {
516
+ grid: { display: false },
517
+ ticks: {
518
+ maxRotation: 0,
519
+ maxTicksLimit: 7
520
+ }
521
+ },
522
+ y: {
523
+ beginAtZero: true,
524
+ grid: { color: 'rgba(0,0,0,0.05)' },
525
+ ticks: { stepSize: 1 }
526
+ }
527
+ },
528
+ interaction: {
529
+ intersect: false,
530
+ mode: 'index'
531
+ }
532
+ }
533
+ });
534
+
535
+ } catch (error) {
536
+ console.error('Error loading sessions chart:', error);
537
+ }
538
+ }
539
+
540
+ async function loadDistortionChart() {
541
+ try {
542
+ const data = await AdminPanel.fetchAPI('/admin/api/charts/distortions');
543
+
544
+ const ctx = document.getElementById('distortionChart').getContext('2d');
545
+
546
+ if (distortionChart) {
547
+ distortionChart.destroy();
548
+ }
549
+
550
+ const labels = {
551
+ 'G1': 'Binary Thinking',
552
+ 'G2': 'Overgeneralization',
553
+ 'G3': 'Attention Bias',
554
+ 'G4': 'Emotion-Driven'
555
+ };
556
+
557
+ const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444'];
558
+
559
+ distortionChart = new Chart(ctx, {
560
+ type: 'doughnut',
561
+ data: {
562
+ labels: Object.keys(data).map(k => labels[k] || k),
563
+ datasets: [{
564
+ data: Object.values(data),
565
+ backgroundColor: colors,
566
+ borderWidth: 0,
567
+ hoverOffset: 8
568
+ }]
569
+ },
570
+ options: {
571
+ responsive: true,
572
+ maintainAspectRatio: false,
573
+ plugins: {
574
+ legend: {
575
+ position: 'right',
576
+ labels: {
577
+ usePointStyle: true,
578
+ padding: 16
579
+ }
580
+ }
581
+ },
582
+ cutout: '65%'
583
+ }
584
+ });
585
+
586
+ } catch (error) {
587
+ console.error('Error loading distortion chart:', error);
588
+ }
589
+ }
590
+
591
+ function setupEventListeners() {
592
+ // Sidebar toggle
593
+ document.getElementById('open-sidebar').addEventListener('click', () => {
594
+ document.getElementById('sidebar').classList.remove('-translate-x-full');
595
+ document.getElementById('sidebar-overlay').classList.remove('hidden');
596
+ });
597
+
598
+ document.getElementById('close-sidebar').addEventListener('click', () => {
599
+ document.getElementById('sidebar').classList.add('-translate-x-full');
600
+ document.getElementById('sidebar-overlay').classList.add('hidden');
601
+ });
602
+
603
+ document.getElementById('sidebar-overlay').addEventListener('click', () => {
604
+ document.getElementById('sidebar').classList.add('-translate-x-full');
605
+ document.getElementById('sidebar-overlay').classList.add('hidden');
606
+ });
607
+
608
+ // Logout
609
+ document.getElementById('logout-btn').addEventListener('click', () => {
610
+ AdminPanel.logout();
611
+ });
612
+
613
+ // Refresh
614
+ document.getElementById('refresh-btn').addEventListener('click', async () => {
615
+ const btn = document.getElementById('refresh-btn');
616
+ btn.classList.add('animate-spin');
617
+ await refreshData();
618
+ btn.classList.remove('animate-spin');
619
+ AdminPanel.showToast('Data refreshed', 'success');
620
+ });
621
+ }
622
+ </script>
623
+ </body>
624
+ </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
+ <!-- Username Input -->
54
+ <div>
55
+ <label for="email" class="block text-sm font-medium text-gray-700 mb-2">Username</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 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
60
+ </svg>
61
+ </div>
62
+ <input type="text" 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="admin123">
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,968 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 id="patient-header-card" 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
+ <div class="flex items-center space-x-2">
124
+ <h2 id="patient-name" class="text-2xl font-bold text-gray-800">Loading...</h2>
125
+ <span id="risk-badge" class="hidden px-2 py-0.5 text-xs font-semibold rounded-full"></span>
126
+ </div>
127
+ <p id="patient-email" class="text-gray-500">loading@example.com</p>
128
+ <p id="patient-member-since" class="text-sm text-gray-400 mt-1">Member since --</p>
129
+ </div>
130
+ </div>
131
+ <div class="mt-4 md:mt-0 flex flex-wrap gap-4">
132
+ <div class="text-center px-4 py-2 bg-blue-50 rounded-lg">
133
+ <p id="stat-sessions" class="text-2xl font-bold text-blue-600">-</p>
134
+ <p class="text-xs text-gray-500">Sessions</p>
135
+ </div>
136
+ <div class="text-center px-4 py-2 bg-green-50 rounded-lg">
137
+ <p id="stat-exercises" class="text-2xl font-bold text-green-600">-</p>
138
+ <p class="text-xs text-gray-500">Exercises</p>
139
+ </div>
140
+ <div class="text-center px-4 py-2 bg-purple-50 rounded-lg">
141
+ <p id="stat-streak" class="text-2xl font-bold text-purple-600">-</p>
142
+ <p class="text-xs text-gray-500">Day Streak</p>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ </div>
147
+
148
+ <!-- Tabs -->
149
+ <div class="bg-white rounded-xl shadow-sm overflow-hidden">
150
+ <div class="border-b border-gray-200">
151
+ <nav class="flex -mb-px overflow-x-auto">
152
+ <button class="tab-btn active px-6 py-4 text-sm font-medium border-b-2 whitespace-nowrap" data-tab="overview">
153
+ Overview
154
+ </button>
155
+ <button class="tab-btn px-6 py-4 text-sm font-medium border-b-2 whitespace-nowrap" data-tab="vitals">
156
+ Vitals
157
+ </button>
158
+ <button class="tab-btn px-6 py-4 text-sm font-medium border-b-2 whitespace-nowrap" data-tab="sessions">
159
+ Sessions
160
+ </button>
161
+ <button class="tab-btn px-6 py-4 text-sm font-medium border-b-2 whitespace-nowrap" data-tab="crisis">
162
+ Crisis History
163
+ </button>
164
+ <button class="tab-btn px-6 py-4 text-sm font-medium border-b-2 whitespace-nowrap" data-tab="ml">
165
+ ML Predictions
166
+ </button>
167
+ </nav>
168
+ </div>
169
+
170
+ <!-- Tab Content -->
171
+ <div class="p-6">
172
+ <!-- Overview Tab -->
173
+ <div id="tab-overview" class="tab-content">
174
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
175
+ <!-- Distortion Pattern Radar Chart -->
176
+ <div class="bg-gray-50 rounded-lg p-4">
177
+ <h3 class="text-lg font-semibold text-gray-800 mb-4">Distortion Pattern</h3>
178
+ <div class="h-64">
179
+ <canvas id="distortionRadarChart"></canvas>
180
+ </div>
181
+ </div>
182
+
183
+ <!-- Mood Tracking Line Chart -->
184
+ <div class="bg-gray-50 rounded-lg p-4">
185
+ <h3 class="text-lg font-semibold text-gray-800 mb-4">Mood Tracking</h3>
186
+ <div class="h-64">
187
+ <canvas id="moodChart"></canvas>
188
+ </div>
189
+ </div>
190
+ </div>
191
+
192
+ <!-- Distortion Legend -->
193
+ <div class="mt-6 grid grid-cols-2 md:grid-cols-4 gap-4">
194
+ <div class="bg-blue-50 p-4 rounded-lg">
195
+ <h4 class="font-semibold text-blue-700">G1: Binary Thinking</h4>
196
+ <p class="text-sm text-gray-600 mt-1">All-or-nothing, black-and-white patterns</p>
197
+ </div>
198
+ <div class="bg-green-50 p-4 rounded-lg">
199
+ <h4 class="font-semibold text-green-700">G2: Overgeneralization</h4>
200
+ <p class="text-sm text-gray-600 mt-1">Broad conclusions from single events</p>
201
+ </div>
202
+ <div class="bg-yellow-50 p-4 rounded-lg">
203
+ <h4 class="font-semibold text-yellow-700">G3: Attention Bias</h4>
204
+ <p class="text-sm text-gray-600 mt-1">Focus on negatives, filtering positives</p>
205
+ </div>
206
+ <div class="bg-red-50 p-4 rounded-lg">
207
+ <h4 class="font-semibold text-red-700">G4: Emotion-Driven</h4>
208
+ <p class="text-sm text-gray-600 mt-1">Feelings treated as facts</p>
209
+ </div>
210
+ </div>
211
+ </div>
212
+
213
+ <!-- Vitals Tab -->
214
+ <div id="tab-vitals" class="tab-content hidden">
215
+ <!-- Current Readings -->
216
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
217
+ <div class="bg-red-50 rounded-lg p-4">
218
+ <div class="flex items-center justify-between">
219
+ <div>
220
+ <p class="text-sm text-gray-500">Heart Rate (PPG)</p>
221
+ <p id="vital-ppg" class="text-2xl font-bold text-red-600">--</p>
222
+ </div>
223
+ <svg class="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
224
+ <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>
225
+ </svg>
226
+ </div>
227
+ <p id="vital-ppg-time" class="text-xs text-gray-400 mt-2">Last updated: --</p>
228
+ </div>
229
+ <div class="bg-blue-50 rounded-lg p-4">
230
+ <div class="flex items-center justify-between">
231
+ <div>
232
+ <p class="text-sm text-gray-500">Skin Conductance (GSR)</p>
233
+ <p id="vital-gsr" class="text-2xl font-bold text-blue-600">--</p>
234
+ </div>
235
+ <svg class="w-8 h-8 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
236
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
237
+ </svg>
238
+ </div>
239
+ <p id="vital-gsr-time" class="text-xs text-gray-400 mt-2">Last updated: --</p>
240
+ </div>
241
+ <div class="bg-green-50 rounded-lg p-4">
242
+ <div class="flex items-center justify-between">
243
+ <div>
244
+ <p class="text-sm text-gray-500">Activity Level</p>
245
+ <p id="vital-activity" class="text-2xl font-bold text-green-600">--</p>
246
+ </div>
247
+ <svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
248
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"></path>
249
+ </svg>
250
+ </div>
251
+ <p id="vital-activity-time" class="text-xs text-gray-400 mt-2">X: -- Y: -- Z: --</p>
252
+ </div>
253
+ </div>
254
+
255
+ <!-- Vitals Charts -->
256
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
257
+ <div class="bg-gray-50 rounded-lg p-4">
258
+ <h3 class="text-lg font-semibold text-gray-800 mb-4">Heart Rate Trend (24h)</h3>
259
+ <div class="h-64">
260
+ <canvas id="ppgChart"></canvas>
261
+ </div>
262
+ </div>
263
+ <div class="bg-gray-50 rounded-lg p-4">
264
+ <h3 class="text-lg font-semibold text-gray-800 mb-4">Skin Conductance (24h)</h3>
265
+ <div class="h-64">
266
+ <canvas id="gsrChart"></canvas>
267
+ </div>
268
+ </div>
269
+ </div>
270
+
271
+ <!-- Vitals Summary Table -->
272
+ <div class="mt-6 bg-gray-50 rounded-lg p-4">
273
+ <h3 class="text-lg font-semibold text-gray-800 mb-4">Vitals Summary</h3>
274
+ <div class="overflow-x-auto">
275
+ <table class="min-w-full">
276
+ <thead>
277
+ <tr class="border-b border-gray-200">
278
+ <th class="text-left py-2 text-sm font-medium text-gray-500">Metric</th>
279
+ <th class="text-center py-2 text-sm font-medium text-gray-500">Min</th>
280
+ <th class="text-center py-2 text-sm font-medium text-gray-500">Avg</th>
281
+ <th class="text-center py-2 text-sm font-medium text-gray-500">Max</th>
282
+ </tr>
283
+ </thead>
284
+ <tbody id="vitals-summary-body">
285
+ <tr>
286
+ <td colspan="4" class="py-4 text-center text-gray-500">Loading...</td>
287
+ </tr>
288
+ </tbody>
289
+ </table>
290
+ </div>
291
+ </div>
292
+ </div>
293
+
294
+ <!-- Sessions Tab -->
295
+ <div id="tab-sessions" class="tab-content hidden">
296
+ <div id="sessions-timeline" class="space-y-4">
297
+ <!-- Sessions will be populated here -->
298
+ <div class="text-center text-gray-500 py-8">Loading sessions...</div>
299
+ </div>
300
+ </div>
301
+
302
+ <!-- ML Predictions Tab -->
303
+ <div id="tab-ml" class="tab-content hidden">
304
+
305
+ <!-- Active Episode Banner (shown only if active) -->
306
+ <div id="active-episode-banner" class="hidden mb-6 bg-orange-50 border border-orange-300 rounded-lg p-4">
307
+ <div class="flex items-start space-x-3">
308
+ <svg class="w-6 h-6 text-orange-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
309
+ <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>
310
+ </svg>
311
+ <div>
312
+ <p class="font-semibold text-orange-800">Active Risk Episode</p>
313
+ <p id="episode-detail" class="text-sm text-orange-700 mt-1">Loading...</p>
314
+ </div>
315
+ </div>
316
+ </div>
317
+
318
+ <!-- Summary cards -->
319
+ <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
320
+ <div class="bg-green-50 rounded-lg p-4 text-center">
321
+ <p class="text-sm text-gray-500">Normal</p>
322
+ <p id="ml-count-normal" class="text-2xl font-bold text-green-600">--</p>
323
+ <p class="text-xs text-gray-400">predictions</p>
324
+ </div>
325
+ <div class="bg-yellow-50 rounded-lg p-4 text-center">
326
+ <p class="text-sm text-gray-500">Mild Stress</p>
327
+ <p id="ml-count-mild" class="text-2xl font-bold text-yellow-600">--</p>
328
+ <p class="text-xs text-gray-400">predictions</p>
329
+ </div>
330
+ <div class="bg-red-50 rounded-lg p-4 text-center">
331
+ <p class="text-sm text-gray-500">High Stress</p>
332
+ <p id="ml-count-high" class="text-2xl font-bold text-red-600">--</p>
333
+ <p class="text-xs text-gray-400">predictions</p>
334
+ </div>
335
+ </div>
336
+
337
+ <!-- Risk level over time chart -->
338
+ <div class="bg-gray-50 rounded-lg p-4 mb-6">
339
+ <h3 class="text-lg font-semibold text-gray-800 mb-1">Risk Level Over Time</h3>
340
+ <p class="text-xs text-gray-400 mb-4">Green = Normal · Yellow = Mild Stress · Red = High Stress</p>
341
+ <div class="h-72">
342
+ <canvas id="mlRiskChart"></canvas>
343
+ </div>
344
+ </div>
345
+
346
+ <!-- Confidence over time chart -->
347
+ <div class="bg-gray-50 rounded-lg p-4">
348
+ <h3 class="text-lg font-semibold text-gray-800 mb-1">Model Confidence Over Time</h3>
349
+ <p class="text-xs text-gray-400 mb-4">Predictions below 55% confidence are not counted as alerts</p>
350
+ <div class="h-64">
351
+ <canvas id="mlConfidenceChart"></canvas>
352
+ </div>
353
+ </div>
354
+ </div>
355
+
356
+ <!-- Crisis History Tab -->
357
+ <div id="tab-crisis" class="tab-content hidden">
358
+ <div id="crisis-history" class="space-y-4">
359
+ <!-- Crisis history will be populated here -->
360
+ <div class="text-center text-gray-500 py-8">Loading crisis history...</div>
361
+ </div>
362
+ </div>
363
+ </div>
364
+ </div>
365
+ </div>
366
+ </main>
367
+ </div>
368
+
369
+ <!-- Toast Container -->
370
+ <div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2"></div>
371
+
372
+ <script src="{{ url_for('static', filename='js/admin.js') }}"></script>
373
+ <script>
374
+ const userId = '{{ user_id }}';
375
+ let patientData = null;
376
+ let distortionRadarChart = null;
377
+ let moodChart = null;
378
+ let ppgChart = null;
379
+ let gsrChart = null;
380
+ let mlRiskChart = null;
381
+ let mlConfidenceChart = null;
382
+
383
+ document.addEventListener('DOMContentLoaded', () => {
384
+ // Check authentication
385
+ if (!AdminPanel.isAuthenticated()) {
386
+ window.location.href = '/admin/login';
387
+ return;
388
+ }
389
+
390
+ // Set admin user info
391
+ const user = AdminPanel.getUser();
392
+ if (user) {
393
+ document.getElementById('admin-name').textContent = user.name;
394
+ document.getElementById('admin-email').textContent = user.email;
395
+ document.getElementById('admin-initials').textContent = user.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
396
+ }
397
+
398
+ // Load patient data
399
+ loadPatientData();
400
+
401
+ // Setup event listeners
402
+ setupEventListeners();
403
+ });
404
+
405
+ async function loadPatientData() {
406
+ try {
407
+ patientData = await AdminPanel.fetchAPI(`/admin/api/patients/${userId}`);
408
+
409
+ // Update header
410
+ document.getElementById('patient-name').textContent = patientData.name;
411
+ document.getElementById('patient-email').textContent = patientData.email;
412
+ document.getElementById('patient-initials').textContent = patientData.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
413
+ document.getElementById('patient-member-since').textContent = `Member since ${AdminPanel.formatDate(patientData.created_at)}`;
414
+
415
+ // Update stats
416
+ document.getElementById('stat-sessions').textContent = patientData.stats.total_sessions;
417
+ document.getElementById('stat-exercises').textContent = patientData.stats.total_exercises;
418
+ document.getElementById('stat-streak').textContent = patientData.stats.current_streak;
419
+
420
+ // Risk badge in header
421
+ const depStats = patientData.depression_stats || {};
422
+ const activeEp = patientData.recent_episodes?.find(e => !e.ended_at);
423
+ const badge = document.getElementById('risk-badge');
424
+ if (activeEp) {
425
+ const peakRisk = activeEp.peak_risk_level ?? activeEp.risk_level ?? 1;
426
+ if (peakRisk >= 2) {
427
+ badge.textContent = '⚠ HIGH STRESS';
428
+ badge.className = 'px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-700';
429
+ } else {
430
+ badge.textContent = '⚠ MILD STRESS';
431
+ badge.className = 'px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-100 text-orange-700';
432
+ }
433
+ badge.classList.remove('hidden');
434
+ // Highlight header card border
435
+ document.getElementById('patient-header-card').classList.add('border-l-4', peakRisk >= 2 ? 'border-red-400' : 'border-orange-400');
436
+ }
437
+
438
+ // Show content
439
+ document.getElementById('loading-state').classList.add('hidden');
440
+ document.getElementById('patient-content').classList.remove('hidden');
441
+
442
+ // Load charts
443
+ loadDistortionRadarChart();
444
+ loadMoodChart();
445
+ loadVitalsData();
446
+ loadSessionsTimeline();
447
+ loadCrisisHistory();
448
+ loadMLPredictions();
449
+
450
+ } catch (error) {
451
+ console.error('Error loading patient:', error);
452
+ AdminPanel.showToast('Failed to load patient data', 'error');
453
+ }
454
+ }
455
+
456
+ function loadDistortionRadarChart() {
457
+ const ctx = document.getElementById('distortionRadarChart').getContext('2d');
458
+ const pattern = patientData.distortion_pattern;
459
+
460
+ if (distortionRadarChart) distortionRadarChart.destroy();
461
+
462
+ distortionRadarChart = new Chart(ctx, {
463
+ type: 'radar',
464
+ data: {
465
+ labels: ['Binary Thinking (G1)', 'Overgeneralization (G2)', 'Attention Bias (G3)', 'Emotion-Driven (G4)'],
466
+ datasets: [{
467
+ label: 'Occurrences',
468
+ data: [pattern.G1, pattern.G2, pattern.G3, pattern.G4],
469
+ backgroundColor: 'rgba(59, 130, 246, 0.2)',
470
+ borderColor: '#3B82F6',
471
+ borderWidth: 2,
472
+ pointBackgroundColor: '#3B82F6',
473
+ pointBorderColor: '#fff',
474
+ pointBorderWidth: 2
475
+ }]
476
+ },
477
+ options: {
478
+ responsive: true,
479
+ maintainAspectRatio: false,
480
+ scales: {
481
+ r: {
482
+ beginAtZero: true,
483
+ ticks: { stepSize: 1 }
484
+ }
485
+ },
486
+ plugins: {
487
+ legend: { display: false }
488
+ }
489
+ }
490
+ });
491
+ }
492
+
493
+ function loadMoodChart() {
494
+ const ctx = document.getElementById('moodChart').getContext('2d');
495
+ const moodHistory = patientData.mood_history || [];
496
+
497
+ if (moodChart) moodChart.destroy();
498
+
499
+ const labels = moodHistory.map(m => AdminPanel.formatDate(m.date));
500
+ const moodStart = moodHistory.map(m => m.mood_start);
501
+ const moodEnd = moodHistory.map(m => m.mood_end);
502
+
503
+ moodChart = new Chart(ctx, {
504
+ type: 'line',
505
+ data: {
506
+ labels: labels,
507
+ datasets: [
508
+ {
509
+ label: 'Mood Start',
510
+ data: moodStart,
511
+ borderColor: '#EF4444',
512
+ backgroundColor: 'rgba(239, 68, 68, 0.1)',
513
+ tension: 0.4,
514
+ fill: false
515
+ },
516
+ {
517
+ label: 'Mood End',
518
+ data: moodEnd,
519
+ borderColor: '#10B981',
520
+ backgroundColor: 'rgba(16, 185, 129, 0.1)',
521
+ tension: 0.4,
522
+ fill: false
523
+ }
524
+ ]
525
+ },
526
+ options: {
527
+ responsive: true,
528
+ maintainAspectRatio: false,
529
+ scales: {
530
+ y: {
531
+ beginAtZero: true,
532
+ max: 10,
533
+ title: { display: true, text: 'Mood Level' }
534
+ }
535
+ },
536
+ plugins: {
537
+ legend: {
538
+ position: 'top'
539
+ }
540
+ }
541
+ }
542
+ });
543
+ }
544
+
545
+ async function loadVitalsData() {
546
+ try {
547
+ const vitalsData = await AdminPanel.fetchAPI(`/admin/api/charts/vitals/${userId}?hours=24`);
548
+ const summary = patientData.wearable_summary;
549
+
550
+ // Update current readings
551
+ if (summary.latest) {
552
+ document.getElementById('vital-ppg').textContent = summary.latest.ppg.toFixed(1);
553
+ document.getElementById('vital-gsr').textContent = summary.latest.gsr.toFixed(4);
554
+ const magnitude = Math.sqrt(
555
+ Math.pow(summary.latest.acc_x, 2) +
556
+ Math.pow(summary.latest.acc_y, 2) +
557
+ Math.pow(summary.latest.acc_z, 2)
558
+ ).toFixed(2);
559
+ document.getElementById('vital-activity').textContent = magnitude;
560
+ document.getElementById('vital-ppg-time').textContent = `Last updated: ${AdminPanel.formatTimeAgo(summary.latest.recorded_at)}`;
561
+ document.getElementById('vital-gsr-time').textContent = `Last updated: ${AdminPanel.formatTimeAgo(summary.latest.recorded_at)}`;
562
+ 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)}`;
563
+ }
564
+
565
+ // Update summary table
566
+ const dayStats = summary.day;
567
+ const summaryBody = document.getElementById('vitals-summary-body');
568
+ summaryBody.innerHTML = `
569
+ <tr class="border-b border-gray-100">
570
+ <td class="py-2 text-sm text-gray-900">Heart Rate (PPG)</td>
571
+ <td class="py-2 text-sm text-center text-gray-600">${dayStats.ppg.min ?? '--'}</td>
572
+ <td class="py-2 text-sm text-center text-gray-600">${dayStats.ppg.avg ?? '--'}</td>
573
+ <td class="py-2 text-sm text-center text-gray-600">${dayStats.ppg.max ?? '--'}</td>
574
+ </tr>
575
+ <tr class="border-b border-gray-100">
576
+ <td class="py-2 text-sm text-gray-900">Skin Conductance (GSR)</td>
577
+ <td class="py-2 text-sm text-center text-gray-600">${dayStats.gsr.min ?? '--'}</td>
578
+ <td class="py-2 text-sm text-center text-gray-600">${dayStats.gsr.avg ?? '--'}</td>
579
+ <td class="py-2 text-sm text-center text-gray-600">${dayStats.gsr.max ?? '--'}</td>
580
+ </tr>
581
+ <tr>
582
+ <td class="py-2 text-sm text-gray-900">Readings (24h)</td>
583
+ <td colspan="3" class="py-2 text-sm text-center text-gray-600">${dayStats.reading_count} readings</td>
584
+ </tr>
585
+ `;
586
+
587
+ // Load charts
588
+ loadPPGChart(vitalsData.data);
589
+ loadGSRChart(vitalsData.data);
590
+
591
+ } catch (error) {
592
+ console.error('Error loading vitals:', error);
593
+ }
594
+ }
595
+
596
+ function loadPPGChart(data) {
597
+ const ctx = document.getElementById('ppgChart').getContext('2d');
598
+
599
+ if (ppgChart) ppgChart.destroy();
600
+
601
+ if (data.length === 0) {
602
+ ctx.font = '14px sans-serif';
603
+ ctx.fillStyle = '#9CA3AF';
604
+ ctx.textAlign = 'center';
605
+ ctx.fillText('No data available', ctx.canvas.width / 2, ctx.canvas.height / 2);
606
+ return;
607
+ }
608
+
609
+ ppgChart = new Chart(ctx, {
610
+ type: 'line',
611
+ data: {
612
+ labels: data.map(d => new Date(d.recorded_at).toLocaleTimeString()),
613
+ datasets: [{
614
+ label: 'PPG',
615
+ data: data.map(d => d.ppg),
616
+ borderColor: '#EF4444',
617
+ backgroundColor: 'rgba(239, 68, 68, 0.1)',
618
+ fill: true,
619
+ tension: 0.4,
620
+ pointRadius: 0
621
+ }]
622
+ },
623
+ options: {
624
+ responsive: true,
625
+ maintainAspectRatio: false,
626
+ plugins: { legend: { display: false } },
627
+ scales: {
628
+ x: { display: false },
629
+ y: { beginAtZero: false }
630
+ }
631
+ }
632
+ });
633
+ }
634
+
635
+ function loadGSRChart(data) {
636
+ const ctx = document.getElementById('gsrChart').getContext('2d');
637
+
638
+ if (gsrChart) gsrChart.destroy();
639
+
640
+ if (data.length === 0) {
641
+ ctx.font = '14px sans-serif';
642
+ ctx.fillStyle = '#9CA3AF';
643
+ ctx.textAlign = 'center';
644
+ ctx.fillText('No data available', ctx.canvas.width / 2, ctx.canvas.height / 2);
645
+ return;
646
+ }
647
+
648
+ gsrChart = new Chart(ctx, {
649
+ type: 'line',
650
+ data: {
651
+ labels: data.map(d => new Date(d.recorded_at).toLocaleTimeString()),
652
+ datasets: [{
653
+ label: 'GSR',
654
+ data: data.map(d => d.gsr),
655
+ borderColor: '#3B82F6',
656
+ backgroundColor: 'rgba(59, 130, 246, 0.1)',
657
+ fill: true,
658
+ tension: 0.4,
659
+ pointRadius: 0
660
+ }]
661
+ },
662
+ options: {
663
+ responsive: true,
664
+ maintainAspectRatio: false,
665
+ plugins: { legend: { display: false } },
666
+ scales: {
667
+ x: { display: false },
668
+ y: { beginAtZero: false }
669
+ }
670
+ }
671
+ });
672
+ }
673
+
674
+ function loadSessionsTimeline() {
675
+ const container = document.getElementById('sessions-timeline');
676
+ const sessions = patientData.sessions || [];
677
+
678
+ if (sessions.length === 0) {
679
+ container.innerHTML = `
680
+ <div class="text-center py-8">
681
+ <svg class="w-12 h-12 mx-auto text-gray-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
682
+ <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>
683
+ </svg>
684
+ <p class="text-gray-500">No sessions yet</p>
685
+ </div>
686
+ `;
687
+ return;
688
+ }
689
+
690
+ const groupNames = {
691
+ 'G1': 'Binary Thinking',
692
+ 'G2': 'Overgeneralization',
693
+ 'G3': 'Attention Bias',
694
+ 'G4': 'Emotion-Driven',
695
+ 'G0': 'No Distortion'
696
+ };
697
+
698
+ container.innerHTML = sessions.map(session => {
699
+ const moodChange = session.mood_end && session.mood_start ? session.mood_end - session.mood_start : null;
700
+ const moodArrow = moodChange !== null
701
+ ? (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>'))
702
+ : '<span class="text-gray-400">--</span>';
703
+
704
+ return `
705
+ <div class="bg-gray-50 rounded-lg p-4 hover:bg-gray-100 transition-colors">
706
+ <div class="flex items-center justify-between">
707
+ <div class="flex items-center space-x-4">
708
+ <div class="w-10 h-10 rounded-full flex items-center justify-center ${session.completed ? 'bg-green-100' : 'bg-gray-200'}">
709
+ ${session.completed
710
+ ? '<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>'
711
+ : '<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>'}
712
+ </div>
713
+ <div>
714
+ <p class="font-medium text-gray-900">${AdminPanel.formatDate(session.started_at)}</p>
715
+ <p class="text-sm text-gray-500">${session.locked_group ? groupNames[session.locked_group] || session.locked_group : 'N/A'}</p>
716
+ </div>
717
+ </div>
718
+ <div class="flex items-center space-x-6 text-sm">
719
+ <div class="text-center">
720
+ <p class="text-gray-500">Mood</p>
721
+ <p class="font-medium">${moodArrow}</p>
722
+ </div>
723
+ <div class="text-center">
724
+ <p class="text-gray-500">Stage</p>
725
+ <p class="font-medium text-gray-900">${session.stages_reached || 1}/3</p>
726
+ </div>
727
+ <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'}">
728
+ ${session.completed ? 'Completed' : 'Incomplete'}
729
+ </span>
730
+ </div>
731
+ </div>
732
+ </div>
733
+ `;
734
+ }).join('');
735
+ }
736
+
737
+ function loadCrisisHistory() {
738
+ const container = document.getElementById('crisis-history');
739
+ const crisisHistory = patientData.crisis_history || [];
740
+
741
+ if (crisisHistory.length === 0) {
742
+ container.innerHTML = `
743
+ <div class="text-center py-8">
744
+ <svg class="w-12 h-12 mx-auto text-green-300 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
745
+ <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>
746
+ </svg>
747
+ <p class="text-gray-500">No crisis flags recorded</p>
748
+ </div>
749
+ `;
750
+ return;
751
+ }
752
+
753
+ container.innerHTML = crisisHistory.map(crisis => `
754
+ <div class="bg-red-50 border border-red-200 rounded-lg p-4">
755
+ <div class="flex items-start justify-between">
756
+ <div class="flex items-start space-x-3">
757
+ <div class="w-2 h-2 bg-red-500 rounded-full mt-2 ${!crisis.reviewed ? 'animate-pulse' : ''}"></div>
758
+ <div>
759
+ <div class="flex items-center space-x-2">
760
+ <span class="px-2 py-0.5 bg-red-100 text-red-700 text-xs font-medium rounded">${AdminPanel.escapeHtml(crisis.trigger_word)}</span>
761
+ <span class="text-sm text-gray-500">${AdminPanel.formatTimeAgo(crisis.flagged_at)}</span>
762
+ </div>
763
+ <p class="text-gray-700 mt-2">${AdminPanel.escapeHtml(crisis.message_content)}</p>
764
+ </div>
765
+ </div>
766
+ <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'}">
767
+ ${crisis.reviewed ? 'Reviewed' : 'Pending'}
768
+ </span>
769
+ </div>
770
+ </div>
771
+ `).join('');
772
+ }
773
+
774
+ async function loadMLPredictions() {
775
+ try {
776
+ const resp = await AdminPanel.fetchAPI(`/admin/api/charts/window-predictions/${userId}?limit=200`);
777
+ const data = resp.data || [];
778
+
779
+ // Show active episode banner
780
+ const activeEp = patientData.recent_episodes?.find(e => !e.ended_at);
781
+ if (activeEp) {
782
+ const startedAt = AdminPanel.formatTimeAgo(activeEp.started_at);
783
+ const riskLabel = activeEp.peak_risk_level >= 2 ? 'High Stress' : 'Mild Stress';
784
+ const confPct = activeEp.peak_confidence ? (activeEp.peak_confidence * 100).toFixed(0) + '%' : 'N/A';
785
+ document.getElementById('episode-detail').textContent =
786
+ `Started ${startedAt} · Peak: ${riskLabel} (${confPct} confidence)`;
787
+ document.getElementById('active-episode-banner').classList.remove('hidden');
788
+ }
789
+
790
+ if (data.length === 0) {
791
+ document.getElementById('ml-count-normal').textContent = '0';
792
+ document.getElementById('ml-count-mild').textContent = '0';
793
+ document.getElementById('ml-count-high').textContent = '0';
794
+ return;
795
+ }
796
+
797
+ // Summary counts
798
+ const counts = { 0: 0, 1: 0, 2: 0 };
799
+ data.forEach(d => counts[d.risk_level] = (counts[d.risk_level] || 0) + 1);
800
+ document.getElementById('ml-count-normal').textContent = counts[0];
801
+ document.getElementById('ml-count-mild').textContent = counts[1];
802
+ document.getElementById('ml-count-high').textContent = counts[2];
803
+
804
+ const labels = data.map(d => {
805
+ const dt = new Date(d.predicted_at);
806
+ return dt.toLocaleDateString() + ' ' + dt.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
807
+ });
808
+
809
+ // Point colors per risk level
810
+ const pointColors = data.map(d =>
811
+ d.risk_level === 0 ? '#10B981' :
812
+ d.risk_level === 1 ? '#F59E0B' : '#EF4444'
813
+ );
814
+
815
+ // --- Risk Level Chart ---
816
+ const riskCtx = document.getElementById('mlRiskChart').getContext('2d');
817
+ if (mlRiskChart) mlRiskChart.destroy();
818
+
819
+ mlRiskChart = new Chart(riskCtx, {
820
+ type: 'line',
821
+ data: {
822
+ labels,
823
+ datasets: [{
824
+ label: 'Risk Level',
825
+ data: data.map(d => d.risk_level),
826
+ borderColor: '#6366F1',
827
+ backgroundColor: 'rgba(99,102,241,0.08)',
828
+ fill: true,
829
+ tension: 0.3,
830
+ pointRadius: 5,
831
+ pointBackgroundColor: pointColors,
832
+ pointBorderColor: '#fff',
833
+ pointBorderWidth: 2
834
+ }]
835
+ },
836
+ options: {
837
+ responsive: true,
838
+ maintainAspectRatio: false,
839
+ scales: {
840
+ x: { display: false },
841
+ y: {
842
+ min: -0.2, max: 2.2,
843
+ ticks: {
844
+ stepSize: 1,
845
+ callback: v => ['Normal', 'Mild Stress', 'High Stress'][v] ?? v
846
+ },
847
+ grid: { color: 'rgba(0,0,0,0.05)' }
848
+ }
849
+ },
850
+ plugins: {
851
+ legend: { display: false },
852
+ tooltip: {
853
+ callbacks: {
854
+ label: ctx => {
855
+ const d = data[ctx.dataIndex];
856
+ const label = ['Normal', 'Mild Stress', 'High Stress'][d.risk_level];
857
+ return `${label} (confidence: ${(d.confidence * 100).toFixed(1)}%)`;
858
+ },
859
+ title: ctx => labels[ctx[0].dataIndex]
860
+ }
861
+ }
862
+ }
863
+ }
864
+ });
865
+
866
+ // --- Confidence Chart ---
867
+ const confCtx = document.getElementById('mlConfidenceChart').getContext('2d');
868
+ if (mlConfidenceChart) mlConfidenceChart.destroy();
869
+
870
+ mlConfidenceChart = new Chart(confCtx, {
871
+ type: 'bar',
872
+ data: {
873
+ labels,
874
+ datasets: [{
875
+ label: 'Confidence',
876
+ data: data.map(d => +(d.confidence * 100).toFixed(1)),
877
+ backgroundColor: pointColors.map(c => c + 'BB'),
878
+ borderColor: pointColors,
879
+ borderWidth: 1,
880
+ borderRadius: 3
881
+ }]
882
+ },
883
+ options: {
884
+ responsive: true,
885
+ maintainAspectRatio: false,
886
+ scales: {
887
+ x: { display: false },
888
+ y: {
889
+ min: 0, max: 100,
890
+ ticks: { callback: v => v + '%' },
891
+ title: { display: true, text: 'Confidence (%)' }
892
+ }
893
+ },
894
+ plugins: {
895
+ legend: { display: false },
896
+ tooltip: {
897
+ callbacks: {
898
+ label: ctx => `${ctx.raw}% confidence`,
899
+ title: ctx => labels[ctx[0].dataIndex]
900
+ }
901
+ }
902
+ }
903
+ }
904
+ });
905
+
906
+ } catch (error) {
907
+ console.error('Error loading ML predictions:', error);
908
+ }
909
+ }
910
+
911
+ function setupEventListeners() {
912
+ // Tab switching
913
+ document.querySelectorAll('.tab-btn').forEach(btn => {
914
+ btn.addEventListener('click', () => {
915
+ // Update buttons
916
+ document.querySelectorAll('.tab-btn').forEach(b => {
917
+ b.classList.remove('active', 'text-blue-600', 'border-blue-600');
918
+ b.classList.add('text-gray-500', 'border-transparent', 'hover:text-gray-700');
919
+ });
920
+ btn.classList.add('active', 'text-blue-600', 'border-blue-600');
921
+ btn.classList.remove('text-gray-500', 'border-transparent', 'hover:text-gray-700');
922
+
923
+ // Update content
924
+ document.querySelectorAll('.tab-content').forEach(content => {
925
+ content.classList.add('hidden');
926
+ });
927
+ document.getElementById(`tab-${btn.dataset.tab}`).classList.remove('hidden');
928
+ });
929
+ });
930
+
931
+ // Sidebar toggle
932
+ document.getElementById('open-sidebar').addEventListener('click', () => {
933
+ document.getElementById('sidebar').classList.remove('-translate-x-full');
934
+ document.getElementById('sidebar-overlay').classList.remove('hidden');
935
+ });
936
+
937
+ document.getElementById('close-sidebar').addEventListener('click', () => {
938
+ document.getElementById('sidebar').classList.add('-translate-x-full');
939
+ document.getElementById('sidebar-overlay').classList.add('hidden');
940
+ });
941
+
942
+ document.getElementById('sidebar-overlay').addEventListener('click', () => {
943
+ document.getElementById('sidebar').classList.add('-translate-x-full');
944
+ document.getElementById('sidebar-overlay').classList.add('hidden');
945
+ });
946
+
947
+ // Logout
948
+ document.getElementById('logout-btn').addEventListener('click', () => {
949
+ AdminPanel.logout();
950
+ });
951
+ }
952
+ </script>
953
+
954
+ <style>
955
+ .tab-btn {
956
+ border-color: transparent;
957
+ color: #6B7280;
958
+ }
959
+ .tab-btn:hover {
960
+ color: #374151;
961
+ }
962
+ .tab-btn.active {
963
+ color: #3B82F6;
964
+ border-color: #3B82F6;
965
+ }
966
+ </style>
967
+ </body>
968
+ </html>
templates/admin_patients.html ADDED
@@ -0,0 +1,535 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ Wearable Risk
175
+ </th>
176
+ <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
177
+ Status
178
+ </th>
179
+ <th scope="col" class="relative px-6 py-3">
180
+ <span class="sr-only">Actions</span>
181
+ </th>
182
+ </tr>
183
+ </thead>
184
+ <tbody id="patients-table-body" class="bg-white divide-y divide-gray-200">
185
+ <!-- Loading state -->
186
+ <tr id="loading-row">
187
+ <td colspan="8" class="px-6 py-12 text-center">
188
+ <svg class="animate-spin h-8 w-8 mx-auto mb-4 text-gray-400" fill="none" viewBox="0 0 24 24">
189
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
190
+ <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>
191
+ </svg>
192
+ <p class="text-gray-500">Loading patients...</p>
193
+ </td>
194
+ </tr>
195
+ </tbody>
196
+ </table>
197
+ </div>
198
+
199
+ <!-- Pagination -->
200
+ <div id="pagination" class="hidden px-6 py-4 border-t border-gray-200 flex items-center justify-between">
201
+ <p class="text-sm text-gray-500">
202
+ Showing <span id="showing-start">1</span> to <span id="showing-end">10</span> of <span id="total-count">0</span> patients
203
+ </p>
204
+ <div class="flex items-center space-x-2">
205
+ <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">
206
+ Previous
207
+ </button>
208
+ <span id="page-info" class="text-sm text-gray-600">Page 1</span>
209
+ <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">
210
+ Next
211
+ </button>
212
+ </div>
213
+ </div>
214
+ </div>
215
+ </div>
216
+ </main>
217
+ </div>
218
+
219
+ <!-- Toast Container -->
220
+ <div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2"></div>
221
+
222
+ <script src="{{ url_for('static', filename='js/admin.js') }}"></script>
223
+ <script>
224
+ let allPatients = [];
225
+ let filteredPatients = [];
226
+ let currentPage = 1;
227
+ const pageSize = 10;
228
+ let sortField = 'name';
229
+ let sortDirection = 'asc';
230
+
231
+ document.addEventListener('DOMContentLoaded', () => {
232
+ // Check authentication
233
+ if (!AdminPanel.isAuthenticated()) {
234
+ window.location.href = '/admin/login';
235
+ return;
236
+ }
237
+
238
+ // Set user info
239
+ const user = AdminPanel.getUser();
240
+ if (user) {
241
+ document.getElementById('user-name').textContent = user.name;
242
+ document.getElementById('user-email').textContent = user.email;
243
+ document.getElementById('user-initials').textContent = user.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
244
+ }
245
+
246
+ // Load patients
247
+ loadPatients();
248
+
249
+ // Setup event listeners
250
+ setupEventListeners();
251
+ });
252
+
253
+ async function loadPatients() {
254
+ try {
255
+ const data = await AdminPanel.fetchAPI('/admin/api/patients');
256
+ allPatients = data.patients;
257
+ applyFilters();
258
+ } catch (error) {
259
+ console.error('Error loading patients:', error);
260
+ AdminPanel.showToast('Failed to load patients', 'error');
261
+ }
262
+ }
263
+
264
+ function applyFilters() {
265
+ const search = document.getElementById('search-input').value.toLowerCase();
266
+ const statusFilter = document.getElementById('filter-status').value;
267
+ const alertsFilter = document.getElementById('filter-alerts').value;
268
+
269
+ filteredPatients = allPatients.filter(patient => {
270
+ // Search filter
271
+ const matchesSearch = !search ||
272
+ patient.name.toLowerCase().includes(search) ||
273
+ patient.email.toLowerCase().includes(search);
274
+
275
+ // Status filter
276
+ let matchesStatus = true;
277
+ if (statusFilter === 'active') {
278
+ const lastActive = patient.last_session_date ? new Date(patient.last_session_date) : null;
279
+ const sevenDaysAgo = new Date();
280
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
281
+ matchesStatus = lastActive && lastActive >= sevenDaysAgo;
282
+ } else if (statusFilter === 'inactive') {
283
+ const lastActive = patient.last_session_date ? new Date(patient.last_session_date) : null;
284
+ const sevenDaysAgo = new Date();
285
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
286
+ matchesStatus = !lastActive || lastActive < sevenDaysAgo;
287
+ }
288
+
289
+ // Alerts filter
290
+ let matchesAlerts = true;
291
+ if (alertsFilter === 'has-alerts') {
292
+ matchesAlerts = patient.unreviewed_alerts > 0;
293
+ } else if (alertsFilter === 'no-alerts') {
294
+ matchesAlerts = patient.unreviewed_alerts === 0;
295
+ }
296
+
297
+ return matchesSearch && matchesStatus && matchesAlerts;
298
+ });
299
+
300
+ // Sort
301
+ sortPatients();
302
+
303
+ // Reset to first page
304
+ currentPage = 1;
305
+ renderPatients();
306
+ }
307
+
308
+ function sortPatients() {
309
+ filteredPatients.sort((a, b) => {
310
+ let valA, valB;
311
+
312
+ switch (sortField) {
313
+ case 'name':
314
+ valA = a.name.toLowerCase();
315
+ valB = b.name.toLowerCase();
316
+ break;
317
+ case 'sessions':
318
+ valA = a.total_sessions;
319
+ valB = b.total_sessions;
320
+ break;
321
+ case 'last_active':
322
+ valA = a.last_session_date ? new Date(a.last_session_date) : new Date(0);
323
+ valB = b.last_session_date ? new Date(b.last_session_date) : new Date(0);
324
+ break;
325
+ default:
326
+ return 0;
327
+ }
328
+
329
+ if (valA < valB) return sortDirection === 'asc' ? -1 : 1;
330
+ if (valA > valB) return sortDirection === 'asc' ? 1 : -1;
331
+ return 0;
332
+ });
333
+ }
334
+
335
+ function renderPatients() {
336
+ const tbody = document.getElementById('patients-table-body');
337
+ const loadingRow = document.getElementById('loading-row');
338
+ const pagination = document.getElementById('pagination');
339
+
340
+ if (loadingRow) loadingRow.remove();
341
+
342
+ // Calculate pagination
343
+ const totalPages = Math.ceil(filteredPatients.length / pageSize);
344
+ const start = (currentPage - 1) * pageSize;
345
+ const end = Math.min(start + pageSize, filteredPatients.length);
346
+ const pagePatients = filteredPatients.slice(start, end);
347
+
348
+ // Clear table
349
+ tbody.innerHTML = '';
350
+
351
+ if (pagePatients.length === 0) {
352
+ tbody.innerHTML = `
353
+ <tr>
354
+ <td colspan="8" class="px-6 py-12 text-center text-gray-500">
355
+ No patients found
356
+ </td>
357
+ </tr>
358
+ `;
359
+ pagination.classList.add('hidden');
360
+ return;
361
+ }
362
+
363
+ // Render patients
364
+ pagePatients.forEach(patient => {
365
+ const row = createPatientRow(patient);
366
+ tbody.appendChild(row);
367
+ });
368
+
369
+ // Update pagination
370
+ pagination.classList.remove('hidden');
371
+ document.getElementById('showing-start').textContent = start + 1;
372
+ document.getElementById('showing-end').textContent = end;
373
+ document.getElementById('total-count').textContent = filteredPatients.length;
374
+ document.getElementById('page-info').textContent = `Page ${currentPage} of ${totalPages || 1}`;
375
+ document.getElementById('prev-page').disabled = currentPage === 1;
376
+ document.getElementById('next-page').disabled = currentPage >= totalPages;
377
+ }
378
+
379
+ function createPatientRow(patient) {
380
+ const row = document.createElement('tr');
381
+ row.className = 'hover:bg-gray-50 cursor-pointer transition-colors';
382
+ row.onclick = () => window.location.href = `/admin/patients/${patient.id}`;
383
+
384
+ const lastActive = patient.last_session_date ? new Date(patient.last_session_date) : null;
385
+ const sevenDaysAgo = new Date();
386
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
387
+ const isActive = lastActive && lastActive >= sevenDaysAgo;
388
+
389
+ row.innerHTML = `
390
+ <td class="px-6 py-4 whitespace-nowrap">
391
+ <div class="flex items-center">
392
+ <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">
393
+ <span class="text-white font-semibold text-sm">${patient.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}</span>
394
+ </div>
395
+ <div class="ml-4">
396
+ <div class="text-sm font-medium text-gray-900">${AdminPanel.escapeHtml(patient.name)}</div>
397
+ <div class="text-sm text-gray-500">${patient.context || 'person'}</div>
398
+ </div>
399
+ </div>
400
+ </td>
401
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
402
+ ${AdminPanel.escapeHtml(patient.email)}
403
+ </td>
404
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
405
+ ${patient.total_sessions}
406
+ </td>
407
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
408
+ ${lastActive ? AdminPanel.formatDate(patient.last_session_date) : 'Never'}
409
+ </td>
410
+ <td class="px-6 py-4 whitespace-nowrap">
411
+ ${patient.unreviewed_alerts > 0
412
+ ? `<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-700 rounded-full">${patient.unreviewed_alerts} pending</span>`
413
+ : '<span class="text-gray-400">-</span>'}
414
+ </td>
415
+ <td class="px-6 py-4 whitespace-nowrap">
416
+ ${patient.has_active_episode
417
+ ? `<span class="px-2 py-1 text-xs font-medium bg-orange-100 text-orange-700 rounded-full animate-pulse">⚠ Active</span>`
418
+ : '<span class="text-gray-400 text-xs">Normal</span>'}
419
+ </td>
420
+ <td class="px-6 py-4 whitespace-nowrap">
421
+ ${isActive
422
+ ? '<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded-full">Active</span>'
423
+ : '<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-600 rounded-full">Inactive</span>'}
424
+ </td>
425
+ <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
426
+ <button onclick="event.stopPropagation(); window.location.href='/admin/patients/${patient.id}'" class="text-blue-600 hover:text-blue-900">
427
+ View
428
+ </button>
429
+ </td>
430
+ `;
431
+
432
+ return row;
433
+ }
434
+
435
+ function exportCSV() {
436
+ if (filteredPatients.length === 0) {
437
+ AdminPanel.showToast('No patients to export', 'warning');
438
+ return;
439
+ }
440
+
441
+ const headers = ['Name', 'Email', 'Context', 'Total Sessions', 'Total Exercises', 'Current Streak', 'Last Session', 'Unreviewed Alerts', 'Created At'];
442
+ const rows = filteredPatients.map(p => [
443
+ p.name,
444
+ p.email,
445
+ p.context,
446
+ p.total_sessions,
447
+ p.total_exercises,
448
+ p.current_streak,
449
+ p.last_session_date || '',
450
+ p.unreviewed_alerts,
451
+ p.created_at
452
+ ]);
453
+
454
+ const csvContent = [headers, ...rows]
455
+ .map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
456
+ .join('\n');
457
+
458
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
459
+ const link = document.createElement('a');
460
+ link.href = URL.createObjectURL(blob);
461
+ link.download = `patients_export_${new Date().toISOString().split('T')[0]}.csv`;
462
+ link.click();
463
+
464
+ AdminPanel.showToast('CSV exported successfully', 'success');
465
+ }
466
+
467
+ function setupEventListeners() {
468
+ // Search
469
+ let searchTimeout;
470
+ document.getElementById('search-input').addEventListener('input', () => {
471
+ clearTimeout(searchTimeout);
472
+ searchTimeout = setTimeout(applyFilters, 300);
473
+ });
474
+
475
+ // Filters
476
+ document.getElementById('filter-status').addEventListener('change', applyFilters);
477
+ document.getElementById('filter-alerts').addEventListener('change', applyFilters);
478
+
479
+ // Sort headers
480
+ document.querySelectorAll('[data-sort]').forEach(header => {
481
+ header.addEventListener('click', () => {
482
+ const field = header.dataset.sort;
483
+ if (sortField === field) {
484
+ sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
485
+ } else {
486
+ sortField = field;
487
+ sortDirection = 'asc';
488
+ }
489
+ applyFilters();
490
+ });
491
+ });
492
+
493
+ // Pagination
494
+ document.getElementById('prev-page').addEventListener('click', () => {
495
+ if (currentPage > 1) {
496
+ currentPage--;
497
+ renderPatients();
498
+ }
499
+ });
500
+
501
+ document.getElementById('next-page').addEventListener('click', () => {
502
+ const totalPages = Math.ceil(filteredPatients.length / pageSize);
503
+ if (currentPage < totalPages) {
504
+ currentPage++;
505
+ renderPatients();
506
+ }
507
+ });
508
+
509
+ // Export
510
+ document.getElementById('export-btn').addEventListener('click', exportCSV);
511
+
512
+ // Sidebar toggle
513
+ document.getElementById('open-sidebar').addEventListener('click', () => {
514
+ document.getElementById('sidebar').classList.remove('-translate-x-full');
515
+ document.getElementById('sidebar-overlay').classList.remove('hidden');
516
+ });
517
+
518
+ document.getElementById('close-sidebar').addEventListener('click', () => {
519
+ document.getElementById('sidebar').classList.add('-translate-x-full');
520
+ document.getElementById('sidebar-overlay').classList.add('hidden');
521
+ });
522
+
523
+ document.getElementById('sidebar-overlay').addEventListener('click', () => {
524
+ document.getElementById('sidebar').classList.add('-translate-x-full');
525
+ document.getElementById('sidebar-overlay').classList.add('hidden');
526
+ });
527
+
528
+ // Logout
529
+ document.getElementById('logout-btn').addEventListener('click', () => {
530
+ AdminPanel.logout();
531
+ });
532
+ }
533
+ </script>
534
+ </body>
535
+ </html>
wearable.py CHANGED
@@ -63,6 +63,13 @@ def run_ml_inference_and_alert(user_id: str, record_id: str, db):
63
  RISK_EMOJI = {0: "✅ NORMAL", 1: "⚠️ MILD_STRESS", 2: "🚨 HIGH_STRESS"}
64
  print(f" → {RISK_EMOJI[risk_level]} | confidence={confidence:.2%} | readings_used={n}")
65
 
 
 
 
 
 
 
 
66
  # Save prediction to its own table
67
  db.save_window_prediction(user_id, prediction, confidence, risk_level,
68
  readings_used=n)
@@ -518,6 +525,9 @@ def receive_device_data():
518
  acc_y = data.get("acc_y")
519
  acc_z = data.get("acc_z")
520
  device_timestamp = data.get("timestamp")
 
 
 
521
 
522
  # Validate required fields
523
  if ppg is None or gsr is None:
@@ -537,8 +547,9 @@ def receive_device_data():
537
  device_timestamp=device_timestamp
538
  )
539
 
 
540
  print(f"[DATA] user={user['id'][:8]}... | ppg={float(ppg):.3f} gsr={float(gsr):.1f} "
541
- f"acc=({float(acc_x):.2f},{float(acc_y):.2f},{float(acc_z):.2f}) | saved={record_id[:8]}...")
542
 
543
  # Run ML inference (results logged to backend console, not returned)
544
  run_ml_inference_and_alert(user["id"], record_id, db)
 
63
  RISK_EMOJI = {0: "✅ NORMAL", 1: "⚠️ MILD_STRESS", 2: "🚨 HIGH_STRESS"}
64
  print(f" → {RISK_EMOJI[risk_level]} | confidence={confidence:.2%} | readings_used={n}")
65
 
66
+ # Require minimum confidence before acting on the prediction.
67
+ # Low-confidence predictions (model unsure) should not trigger alerts or episodes.
68
+ MIN_CONFIDENCE = 0.55
69
+ if risk_level >= 1 and confidence < MIN_CONFIDENCE:
70
+ print(f"[ML] Skipping alert — confidence {confidence:.2%} < {MIN_CONFIDENCE:.0%} threshold")
71
+ risk_level = 0 # treat as normal for alert/episode logic
72
+
73
  # Save prediction to its own table
74
  db.save_window_prediction(user_id, prediction, confidence, risk_level,
75
  readings_used=n)
 
525
  acc_y = data.get("acc_y")
526
  acc_z = data.get("acc_z")
527
  device_timestamp = data.get("timestamp")
528
+ # On-device DRI score computed by ESP32 after its 5-min personal baseline calibration
529
+ device_dri_score = data.get("dri_score")
530
+ device_condition = data.get("condition")
531
 
532
  # Validate required fields
533
  if ppg is None or gsr is None:
 
547
  device_timestamp=device_timestamp
548
  )
549
 
550
+ dri_info = f" | device_dri={float(device_dri_score):.2f} ({device_condition})" if device_dri_score is not None else ""
551
  print(f"[DATA] user={user['id'][:8]}... | ppg={float(ppg):.3f} gsr={float(gsr):.1f} "
552
+ f"acc=({float(acc_x):.2f},{float(acc_y):.2f},{float(acc_z):.2f}){dri_info} | saved={record_id[:8]}...")
553
 
554
  # Run ML inference (results logged to backend console, not returned)
555
  run_ml_inference_and_alert(user["id"], record_id, db)