root commited on
Commit
2b5ce02
·
1 Parent(s): aeed659

feat: add session grouping, improve chart sorting, and optimize crop loading

Browse files
dashboard.py CHANGED
@@ -141,49 +141,49 @@ def dashboard():
141
  if filter_type == 'collections':
142
  # Only show neetprep collections
143
  sessions_rows = conn.execute("""
144
- SELECT s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type,
145
  0 as page_count,
146
  COUNT(nb.id) as question_count
147
  FROM sessions s
148
  LEFT JOIN neetprep_bookmarks nb ON s.id = nb.session_id
149
  WHERE s.user_id = ? AND s.session_type = 'neetprep_collection'
150
- GROUP BY s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type
151
  ORDER BY s.created_at DESC
152
  """, (current_user.id,)).fetchall()
153
  elif filter_type == 'standard':
154
  # Only show standard sessions (exclude collections and final_pdf)
155
  sessions_rows = conn.execute("""
156
- SELECT s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type,
157
  COUNT(CASE WHEN i.image_type = 'original' THEN 1 END) as page_count,
158
  COUNT(CASE WHEN i.image_type = 'cropped' THEN 1 END) as question_count
159
  FROM sessions s
160
  LEFT JOIN images i ON s.id = i.session_id
161
  WHERE s.user_id = ? AND (s.session_type IS NULL OR s.session_type NOT IN ('final_pdf', 'neetprep_collection'))
162
- GROUP BY s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type
163
  ORDER BY s.created_at DESC
164
  """, (current_user.id,)).fetchall()
165
  else:
166
  # Show all (both standard and collections, but not final_pdf)
167
  # First get standard sessions
168
  standard_sessions = conn.execute("""
169
- SELECT s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type,
170
  COUNT(CASE WHEN i.image_type = 'original' THEN 1 END) as page_count,
171
  COUNT(CASE WHEN i.image_type = 'cropped' THEN 1 END) as question_count
172
  FROM sessions s
173
  LEFT JOIN images i ON s.id = i.session_id
174
  WHERE s.user_id = ? AND (s.session_type IS NULL OR s.session_type NOT IN ('final_pdf', 'neetprep_collection'))
175
- GROUP BY s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type
176
  """, (current_user.id,)).fetchall()
177
 
178
  # Then get neetprep collections
179
  collection_sessions = conn.execute("""
180
- SELECT s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type,
181
  0 as page_count,
182
  COUNT(nb.id) as question_count
183
  FROM sessions s
184
  LEFT JOIN neetprep_bookmarks nb ON s.id = nb.session_id
185
  WHERE s.user_id = ? AND s.session_type = 'neetprep_collection'
186
- GROUP BY s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type
187
  """, (current_user.id,)).fetchall()
188
 
189
  # Combine and sort by created_at
@@ -206,6 +206,37 @@ def dashboard():
206
 
207
  return render_template('dashboard.html', sessions=sessions, show_size=bool(show_size), filter_type=filter_type)
208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  @dashboard_bp.route('/sessions/batch_delete', methods=['POST'])
210
  @login_required
211
  def batch_delete_sessions():
@@ -258,6 +289,36 @@ def batch_delete_sessions():
258
  return jsonify({'error': str(e)}), 500
259
 
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  @dashboard_bp.route('/sessions/reduce_space/<session_id>', methods=['POST'])
262
  @login_required
263
  def reduce_space(session_id):
 
141
  if filter_type == 'collections':
142
  # Only show neetprep collections
143
  sessions_rows = conn.execute("""
144
+ SELECT s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type, s.group_name,
145
  0 as page_count,
146
  COUNT(nb.id) as question_count
147
  FROM sessions s
148
  LEFT JOIN neetprep_bookmarks nb ON s.id = nb.session_id
149
  WHERE s.user_id = ? AND s.session_type = 'neetprep_collection'
150
+ GROUP BY s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type, s.group_name
151
  ORDER BY s.created_at DESC
152
  """, (current_user.id,)).fetchall()
153
  elif filter_type == 'standard':
154
  # Only show standard sessions (exclude collections and final_pdf)
155
  sessions_rows = conn.execute("""
156
+ SELECT s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type, s.group_name,
157
  COUNT(CASE WHEN i.image_type = 'original' THEN 1 END) as page_count,
158
  COUNT(CASE WHEN i.image_type = 'cropped' THEN 1 END) as question_count
159
  FROM sessions s
160
  LEFT JOIN images i ON s.id = i.session_id
161
  WHERE s.user_id = ? AND (s.session_type IS NULL OR s.session_type NOT IN ('final_pdf', 'neetprep_collection'))
162
+ GROUP BY s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type, s.group_name
163
  ORDER BY s.created_at DESC
164
  """, (current_user.id,)).fetchall()
165
  else:
166
  # Show all (both standard and collections, but not final_pdf)
167
  # First get standard sessions
168
  standard_sessions = conn.execute("""
169
+ SELECT s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type, s.group_name,
170
  COUNT(CASE WHEN i.image_type = 'original' THEN 1 END) as page_count,
171
  COUNT(CASE WHEN i.image_type = 'cropped' THEN 1 END) as question_count
172
  FROM sessions s
173
  LEFT JOIN images i ON s.id = i.session_id
174
  WHERE s.user_id = ? AND (s.session_type IS NULL OR s.session_type NOT IN ('final_pdf', 'neetprep_collection'))
175
+ GROUP BY s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type, s.group_name
176
  """, (current_user.id,)).fetchall()
177
 
178
  # Then get neetprep collections
179
  collection_sessions = conn.execute("""
180
+ SELECT s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type, s.group_name,
181
  0 as page_count,
182
  COUNT(nb.id) as question_count
183
  FROM sessions s
184
  LEFT JOIN neetprep_bookmarks nb ON s.id = nb.session_id
185
  WHERE s.user_id = ? AND s.session_type = 'neetprep_collection'
186
+ GROUP BY s.id, s.created_at, s.original_filename, s.persist, s.name, s.session_type, s.group_name
187
  """, (current_user.id,)).fetchall()
188
 
189
  # Combine and sort by created_at
 
206
 
207
  return render_template('dashboard.html', sessions=sessions, show_size=bool(show_size), filter_type=filter_type)
208
 
209
+ @dashboard_bp.route('/sessions/update_group', methods=['POST'])
210
+ @login_required
211
+ def update_session_group():
212
+ data = request.json
213
+ session_id = data.get('session_id')
214
+ group_name = data.get('group_name')
215
+
216
+ if not session_id:
217
+ return jsonify({'error': 'Session ID is required'}), 400
218
+
219
+ # Sanitize group_name: empty string should be stored as NULL or empty
220
+ if group_name:
221
+ group_name = group_name.strip()
222
+ else:
223
+ group_name = None
224
+
225
+ try:
226
+ conn = get_db_connection()
227
+ # Security Check: Ensure the session belongs to the current user
228
+ session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone()
229
+ if not session_owner or session_owner['user_id'] != current_user.id:
230
+ conn.close()
231
+ return jsonify({'error': 'Unauthorized'}), 403
232
+
233
+ conn.execute('UPDATE sessions SET group_name = ? WHERE id = ?', (group_name, session_id))
234
+ conn.commit()
235
+ conn.close()
236
+ return jsonify({'success': True})
237
+ except Exception as e:
238
+ return jsonify({'error': str(e)}), 500
239
+
240
  @dashboard_bp.route('/sessions/batch_delete', methods=['POST'])
241
  @login_required
242
  def batch_delete_sessions():
 
289
  return jsonify({'error': str(e)}), 500
290
 
291
 
292
+ @dashboard_bp.route('/sessions/batch_update_group', methods=['POST'])
293
+ @login_required
294
+ def batch_update_session_group():
295
+ data = request.json
296
+ session_ids = data.get('ids', [])
297
+ group_name = data.get('group_name')
298
+
299
+ if not session_ids:
300
+ return jsonify({'error': 'No session IDs provided'}), 400
301
+
302
+ if group_name:
303
+ group_name = group_name.strip()
304
+ else:
305
+ group_name = None
306
+
307
+ try:
308
+ conn = get_db_connection()
309
+ for session_id in session_ids:
310
+ # Security Check
311
+ session_owner = conn.execute('SELECT user_id FROM sessions WHERE id = ?', (session_id,)).fetchone()
312
+ if session_owner and session_owner['user_id'] == current_user.id:
313
+ conn.execute('UPDATE sessions SET group_name = ? WHERE id = ?', (group_name, session_id))
314
+
315
+ conn.commit()
316
+ conn.close()
317
+ return jsonify({'success': True})
318
+ except Exception as e:
319
+ return jsonify({'error': str(e)}), 500
320
+
321
+
322
  @dashboard_bp.route('/sessions/reduce_space/<session_id>', methods=['POST'])
323
  @login_required
324
  def reduce_space(session_id):
database.py CHANGED
@@ -29,7 +29,8 @@ def setup_database():
29
  persist INTEGER DEFAULT 0,
30
  name TEXT,
31
  user_id INTEGER,
32
- session_type TEXT DEFAULT 'standard'
 
33
  );
34
  """)
35
 
@@ -390,6 +391,11 @@ def setup_database():
390
  except sqlite3.OperationalError:
391
  cursor.execute("ALTER TABLE users ADD COLUMN two_page_crop INTEGER DEFAULT 0")
392
 
 
 
 
 
 
393
  try:
394
  cursor.execute("SELECT pdf_viewer FROM users LIMIT 1")
395
  except sqlite3.OperationalError:
 
29
  persist INTEGER DEFAULT 0,
30
  name TEXT,
31
  user_id INTEGER,
32
+ session_type TEXT DEFAULT 'standard',
33
+ group_name TEXT
34
  );
35
  """)
36
 
 
391
  except sqlite3.OperationalError:
392
  cursor.execute("ALTER TABLE users ADD COLUMN two_page_crop INTEGER DEFAULT 0")
393
 
394
+ try:
395
+ cursor.execute("SELECT group_name FROM sessions LIMIT 1")
396
+ except sqlite3.OperationalError:
397
+ cursor.execute("ALTER TABLE sessions ADD COLUMN group_name TEXT")
398
+
399
  try:
400
  cursor.execute("SELECT pdf_viewer FROM users LIMIT 1")
401
  except sqlite3.OperationalError:
routes/general.py CHANGED
@@ -1,5 +1,5 @@
1
  import os
2
- from flask import jsonify, render_template, redirect, url_for, current_app
3
  from .common import main_bp, get_db_connection, login_required, current_user, upload_progress
4
  from strings import ROUTE_INDEX, METHOD_DELETE, METHOD_POST
5
 
@@ -107,96 +107,33 @@ def chart():
107
  def stacked_chart():
108
  conn = get_db_connection()
109
 
110
- # Get classified questions grouped by session_id, subject, and topic (chapter)
111
- # Filter for only specific subjects (P, C, B, Z, M) as requested
112
- # Also normalize subject names to ensure consistency
113
- classified_data = conn.execute("""
114
- SELECT s.id as session_id, s.name as session_name,
115
- CASE
116
- WHEN q.subject = 'CHEMISTRY' THEN 'Chemistry'
117
- WHEN q.subject = 'PHYSICS' THEN 'Physics'
118
- WHEN q.subject = 'BIOLOGY' THEN 'Biology'
119
- WHEN q.subject = 'MATHEMATICS' THEN 'Mathematics'
120
- WHEN q.subject = 'ZOOLOGY' THEN 'Zoology'
121
- WHEN q.subject = 'BOTANY' THEN 'Botany'
122
- ELSE q.subject
123
- END as subject,
124
- q.chapter, COUNT(*) as question_count
125
- FROM questions q
126
- JOIN sessions s ON q.session_id = s.id
127
- WHERE s.user_id = ?
128
- AND q.subject IS NOT NULL
129
- AND q.chapter IS NOT NULL
130
- AND UPPER(SUBSTR(TRIM(
131
- CASE
132
- WHEN q.subject = 'CHEMISTRY' THEN 'Chemistry'
133
- WHEN q.subject = 'PHYSICS' THEN 'Physics'
134
- WHEN q.subject = 'BIOLOGY' THEN 'Biology'
135
- WHEN q.subject = 'MATHEMATICS' THEN 'Mathematics'
136
- WHEN q.subject = 'ZOOLOGY' THEN 'Zoology'
137
- WHEN q.subject = 'BOTANY' THEN 'Botany'
138
- ELSE q.subject
139
- END
140
- ), 1, 1)) IN ('P', 'C', 'B', 'Z', 'M')
141
- GROUP BY s.id, s.name,
142
- CASE
143
- WHEN q.subject = 'CHEMISTRY' THEN 'Chemistry'
144
- WHEN q.subject = 'PHYSICS' THEN 'Physics'
145
- WHEN q.subject = 'BIOLOGY' THEN 'Biology'
146
- WHEN q.subject = 'MATHEMATICS' THEN 'Mathematics'
147
- WHEN q.subject = 'ZOOLOGY' THEN 'Zoology'
148
- WHEN q.subject = 'BOTANY' THEN 'Botany'
149
- ELSE q.subject
150
- END,
151
- q.chapter
152
- ORDER BY s.created_at DESC, s.id,
153
- CASE
154
- WHEN q.subject = 'CHEMISTRY' THEN 'Chemistry'
155
- WHEN q.subject = 'PHYSICS' THEN 'Physics'
156
- WHEN q.subject = 'BIOLOGY' THEN 'Biology'
157
- WHEN q.subject = 'MATHEMATICS' THEN 'Mathematics'
158
- WHEN q.subject = 'ZOOLOGY' THEN 'Zoology'
159
- WHEN q.subject = 'BOTANY' THEN 'Botany'
160
- ELSE q.subject
161
- END,
162
- q.chapter
163
- """, (current_user.id,)).fetchall()
164
-
165
- # Group the data by session_id for the chart
166
- sessions_data = {}
167
- for row in classified_data:
168
- session_id = row['session_id']
169
- session_name = row['session_name'] or session_id # Use session_id if name is None
170
- subject = row['subject']
171
- topic = row['chapter']
172
- count = row['question_count']
173
-
174
- if session_id not in sessions_data:
175
- sessions_data[session_id] = {
176
- 'session_name': session_name,
177
- 'subjects': {}
178
- }
179
-
180
- if subject not in sessions_data[session_id]['subjects']:
181
- sessions_data[session_id]['subjects'][subject] = {}
182
-
183
- sessions_data[session_id]['subjects'][subject][topic] = count
184
 
185
  conn.close()
186
-
187
- return render_template('stacked_chart.html', sessions_data=sessions_data)
188
 
189
 
190
  @main_bp.route('/api/stacked_chart_data')
191
  @login_required
192
  def api_stacked_chart_data():
193
  conn = get_db_connection()
 
194
 
195
- # Get classified questions grouped by session_id, subject, and topic (chapter)
196
- # Filter for only specific subjects (P, C, B, Z, M) as requested
197
- # Also normalize subject names to ensure consistency
198
- classified_data = conn.execute("""
199
- SELECT s.id as session_id, s.name as session_name, s.original_filename,
 
 
 
 
 
 
 
 
200
  CASE
201
  WHEN q.subject = 'CHEMISTRY' THEN 'Chemistry'
202
  WHEN q.subject = 'PHYSICS' THEN 'Physics'
@@ -205,14 +142,12 @@ def api_stacked_chart_data():
205
  WHEN q.subject = 'ZOOLOGY' THEN 'Zoology'
206
  WHEN q.subject = 'BOTANY' THEN 'Botany'
207
  ELSE q.subject
208
- END as subject,
209
- q.chapter, COUNT(*) as question_count
210
- FROM questions q
211
- JOIN sessions s ON q.session_id = s.id
212
- WHERE s.user_id = ?
213
- AND q.subject IS NOT NULL
214
- AND q.chapter IS NOT NULL
215
- AND UPPER(SUBSTR(TRIM(
216
  CASE
217
  WHEN q.subject = 'CHEMISTRY' THEN 'Chemistry'
218
  WHEN q.subject = 'PHYSICS' THEN 'Physics'
@@ -221,9 +156,12 @@ def api_stacked_chart_data():
221
  WHEN q.subject = 'ZOOLOGY' THEN 'Zoology'
222
  WHEN q.subject = 'BOTANY' THEN 'Botany'
223
  ELSE q.subject
224
- END
225
- ), 1, 1)) IN ('P', 'C', 'B', 'Z', 'M')
226
- GROUP BY s.id, s.name, s.original_filename,
 
 
 
227
  CASE
228
  WHEN q.subject = 'CHEMISTRY' THEN 'Chemistry'
229
  WHEN q.subject = 'PHYSICS' THEN 'Physics'
@@ -245,10 +183,10 @@ def api_stacked_chart_data():
245
  ELSE q.subject
246
  END,
247
  q.chapter
248
- """, (current_user.id,)).fetchall()
249
 
250
- # Get topic occurrence statistics - total occurrences and number of sessions for each topic
251
- topic_stats = conn.execute("""
252
  SELECT
253
  CASE
254
  WHEN q.subject = 'CHEMISTRY' THEN 'Chemistry'
@@ -264,20 +202,7 @@ def api_stacked_chart_data():
264
  COUNT(DISTINCT q.session_id) as total_sessions_with_topic
265
  FROM questions q
266
  JOIN sessions s ON q.session_id = s.id
267
- WHERE s.user_id = ?
268
- AND q.subject IS NOT NULL
269
- AND q.chapter IS NOT NULL
270
- AND UPPER(SUBSTR(TRIM(
271
- CASE
272
- WHEN q.subject = 'CHEMISTRY' THEN 'Chemistry'
273
- WHEN q.subject = 'PHYSICS' THEN 'Physics'
274
- WHEN q.subject = 'BIOLOGY' THEN 'Biology'
275
- WHEN q.subject = 'MATHEMATICS' THEN 'Mathematics'
276
- WHEN q.subject = 'ZOOLOGY' THEN 'Zoology'
277
- WHEN q.subject = 'BOTANY' THEN 'Botany'
278
- ELSE q.subject
279
- END
280
- ), 1, 1)) IN ('P', 'C', 'B', 'Z', 'M')
281
  GROUP BY
282
  CASE
283
  WHEN q.subject = 'CHEMISTRY' THEN 'Chemistry'
@@ -289,59 +214,14 @@ def api_stacked_chart_data():
289
  ELSE q.subject
290
  END,
291
  q.chapter
292
- """, (current_user.id,)).fetchall()
293
-
294
- # Get wrong ratios for topics - aggregated by topic across all sessions
295
- wrong_ratios = conn.execute("""
296
- SELECT
297
- CASE
298
- WHEN q.subject = 'CHEMISTRY' THEN 'Chemistry'
299
- WHEN q.subject = 'PHYSICS' THEN 'Physics'
300
- WHEN q.subject = 'BIOLOGY' THEN 'Biology'
301
- WHEN q.subject = 'MATHEMATICS' THEN 'Mathematics'
302
- WHEN q.subject = 'ZOOLOGY' THEN 'Zoology'
303
- WHEN q.subject = 'BOTANY' THEN 'Botany'
304
- ELSE q.subject
305
- END as subject,
306
- q.chapter,
307
- SUM(CASE WHEN q.status = 'wrong' THEN 1 ELSE 0 END) as total_wrongs,
308
- COUNT(DISTINCT q.session_id) as total_sessions_with_wrongs
309
- FROM questions q
310
- JOIN sessions s ON q.session_id = s.id
311
- WHERE s.user_id = ?
312
- AND q.subject IS NOT NULL
313
- AND q.chapter IS NOT NULL
314
- AND UPPER(SUBSTR(TRIM(
315
- CASE
316
- WHEN q.subject = 'CHEMISTRY' THEN 'Chemistry'
317
- WHEN q.subject = 'PHYSICS' THEN 'Physics'
318
- WHEN q.subject = 'BIOLOGY' THEN 'Biology'
319
- WHEN q.subject = 'MATHEMATICS' THEN 'Mathematics'
320
- WHEN q.subject = 'ZOOLOGY' THEN 'Zoology'
321
- WHEN q.subject = 'BOTANY' THEN 'Botany'
322
- ELSE q.subject
323
- END
324
- ), 1, 1)) IN ('P', 'C', 'B', 'Z', 'M')
325
- GROUP BY
326
- CASE
327
- WHEN q.subject = 'CHEMISTRY' THEN 'Chemistry'
328
- WHEN q.subject = 'PHYSICS' THEN 'Physics'
329
- WHEN q.subject = 'BIOLOGY' THEN 'Biology'
330
- WHEN q.subject = 'MATHEMATICS' THEN 'Mathematics'
331
- WHEN q.subject = 'ZOOLOGY' THEN 'Zoology'
332
- WHEN q.subject = 'BOTANY' THEN 'Botany'
333
- ELSE q.subject
334
- END,
335
- q.chapter
336
- """, (current_user.id,)).fetchall()
337
 
338
  # Group the data by session_id for the chart
339
  sessions_data = {}
340
  for row in classified_data:
341
  session_id = row['session_id']
342
- session_name = row['session_name'] or session_id # Use session_id if name is None
343
 
344
- # Use original_filename if session_name is the same as session_id
345
  if session_name == session_id and row['original_filename']:
346
  session_name = row['original_filename']
347
 
@@ -352,6 +232,7 @@ def api_stacked_chart_data():
352
  if session_id not in sessions_data:
353
  sessions_data[session_id] = {
354
  'session_name': session_name,
 
355
  'subjects': {}
356
  }
357
 
@@ -360,40 +241,25 @@ def api_stacked_chart_data():
360
 
361
  sessions_data[session_id]['subjects'][subject][topic] = count
362
 
363
- # Create mapping for topic stats (total occurrences and number of sessions with topic)
364
  topic_stats_map = {}
365
  for row in topic_stats:
366
  subject = row['subject']
367
  topic = row['chapter']
368
- total_occurrences = row['total_occurrences']
369
- total_sessions_with_topic = row['total_sessions_with_topic']
370
-
371
  topic_stats_map[f"{subject}|{topic}"] = {
372
- 'total_occurrences': total_occurrences,
373
- 'total_sessions_with_topic': total_sessions_with_topic
374
  }
375
 
376
- # Add topic occurrence ratios to the data for each session
377
  for session_id in sessions_data:
 
378
  for subject in sessions_data[session_id]['subjects']:
 
379
  for topic in sessions_data[session_id]['subjects'][subject]:
380
- # Store topic occurrence ratio data
381
- if 'wrong_ratios' not in sessions_data[session_id]:
382
- sessions_data[session_id]['wrong_ratios'] = {}
383
- if subject not in sessions_data[session_id]['wrong_ratios']:
384
- sessions_data[session_id]['wrong_ratios'][subject] = {}
385
-
386
  key = f"{subject}|{topic}"
387
-
388
- # Get topic stats (total occurrences and number of sessions with topic)
389
  topic_stat = topic_stats_map.get(key, {'total_occurrences': 0, 'total_sessions_with_topic': 0})
390
-
391
- # Store topic occurrence ratio: total occurrences / number of sessions with topic
392
- sessions_data[session_id]['wrong_ratios'][subject][topic] = {
393
- 'total_occurrences': topic_stat['total_occurrences'],
394
- 'total_sessions_with_topic': topic_stat['total_sessions_with_topic']
395
- }
396
 
397
  conn.close()
398
-
399
  return jsonify(sessions_data)
 
1
  import os
2
+ from flask import jsonify, render_template, redirect, url_for, current_app, request
3
  from .common import main_bp, get_db_connection, login_required, current_user, upload_progress
4
  from strings import ROUTE_INDEX, METHOD_DELETE, METHOD_POST
5
 
 
107
  def stacked_chart():
108
  conn = get_db_connection()
109
 
110
+ # Get all unique group names for this user to populate the group selector
111
+ groups_rows = conn.execute("SELECT DISTINCT group_name FROM sessions WHERE user_id = ? AND group_name IS NOT NULL AND group_name != '' ORDER BY group_name", (current_user.id,)).fetchall()
112
+ groups = [row['group_name'] for row in groups_rows]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
114
  conn.close()
115
+ return render_template('stacked_chart.html', groups=groups)
 
116
 
117
 
118
  @main_bp.route('/api/stacked_chart_data')
119
  @login_required
120
  def api_stacked_chart_data():
121
  conn = get_db_connection()
122
+ group_filter = request.args.get('group')
123
 
124
+ # Base WHERE clause components
125
+ where_clause = "WHERE s.user_id = ? AND q.subject IS NOT NULL AND q.chapter IS NOT NULL"
126
+ params = [current_user.id]
127
+
128
+ if group_filter:
129
+ where_clause += " AND s.group_name = ?"
130
+ params.append(group_filter)
131
+ else:
132
+ # Default: show sessions with NO group
133
+ where_clause += " AND (s.group_name IS NULL OR s.group_name = '')"
134
+
135
+ # Filter for only specific subjects (P, C, B, Z, M)
136
+ where_clause += """ AND UPPER(SUBSTR(TRIM(
137
  CASE
138
  WHEN q.subject = 'CHEMISTRY' THEN 'Chemistry'
139
  WHEN q.subject = 'PHYSICS' THEN 'Physics'
 
142
  WHEN q.subject = 'ZOOLOGY' THEN 'Zoology'
143
  WHEN q.subject = 'BOTANY' THEN 'Botany'
144
  ELSE q.subject
145
+ END
146
+ ), 1, 1)) IN ('P', 'C', 'B', 'Z', 'M')"""
147
+
148
+ # Get classified questions grouped by session_id, subject, and topic (chapter)
149
+ classified_data = conn.execute(f"""
150
+ SELECT s.id as session_id, s.name as session_name, s.original_filename, s.created_at,
 
 
151
  CASE
152
  WHEN q.subject = 'CHEMISTRY' THEN 'Chemistry'
153
  WHEN q.subject = 'PHYSICS' THEN 'Physics'
 
156
  WHEN q.subject = 'ZOOLOGY' THEN 'Zoology'
157
  WHEN q.subject = 'BOTANY' THEN 'Botany'
158
  ELSE q.subject
159
+ END as subject,
160
+ q.chapter, COUNT(*) as question_count
161
+ FROM questions q
162
+ JOIN sessions s ON q.session_id = s.id
163
+ {where_clause}
164
+ GROUP BY s.id, s.name, s.original_filename, s.created_at,
165
  CASE
166
  WHEN q.subject = 'CHEMISTRY' THEN 'Chemistry'
167
  WHEN q.subject = 'PHYSICS' THEN 'Physics'
 
183
  ELSE q.subject
184
  END,
185
  q.chapter
186
+ """, params).fetchall()
187
 
188
+ # Get topic occurrence statistics
189
+ topic_stats = conn.execute(f"""
190
  SELECT
191
  CASE
192
  WHEN q.subject = 'CHEMISTRY' THEN 'Chemistry'
 
202
  COUNT(DISTINCT q.session_id) as total_sessions_with_topic
203
  FROM questions q
204
  JOIN sessions s ON q.session_id = s.id
205
+ {where_clause}
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  GROUP BY
207
  CASE
208
  WHEN q.subject = 'CHEMISTRY' THEN 'Chemistry'
 
214
  ELSE q.subject
215
  END,
216
  q.chapter
217
+ """, params).fetchall()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
  # Group the data by session_id for the chart
220
  sessions_data = {}
221
  for row in classified_data:
222
  session_id = row['session_id']
223
+ session_name = row['session_name'] or session_id
224
 
 
225
  if session_name == session_id and row['original_filename']:
226
  session_name = row['original_filename']
227
 
 
232
  if session_id not in sessions_data:
233
  sessions_data[session_id] = {
234
  'session_name': session_name,
235
+ 'created_at': row['created_at'],
236
  'subjects': {}
237
  }
238
 
 
241
 
242
  sessions_data[session_id]['subjects'][subject][topic] = count
243
 
244
+ # Create mapping for topic stats
245
  topic_stats_map = {}
246
  for row in topic_stats:
247
  subject = row['subject']
248
  topic = row['chapter']
 
 
 
249
  topic_stats_map[f"{subject}|{topic}"] = {
250
+ 'total_occurrences': row['total_occurrences'],
251
+ 'total_sessions_with_topic': row['total_sessions_with_topic']
252
  }
253
 
254
+ # Add topic statistics to sessions_data
255
  for session_id in sessions_data:
256
+ sessions_data[session_id]['wrong_ratios'] = {}
257
  for subject in sessions_data[session_id]['subjects']:
258
+ sessions_data[session_id]['wrong_ratios'][subject] = {}
259
  for topic in sessions_data[session_id]['subjects'][subject]:
 
 
 
 
 
 
260
  key = f"{subject}|{topic}"
 
 
261
  topic_stat = topic_stats_map.get(key, {'total_occurrences': 0, 'total_sessions_with_topic': 0})
262
+ sessions_data[session_id]['wrong_ratios'][subject][topic] = topic_stat
 
 
 
 
 
263
 
264
  conn.close()
 
265
  return jsonify(sessions_data)
templates/cropv2.html CHANGED
@@ -671,6 +671,7 @@
671
  infoEl: document.getElementById('progress-info'),
672
  textEl: document.querySelector('#progress-info .progress-text'),
673
  bytesEl: document.getElementById('progress-bytes'),
 
674
 
675
  formatBytes(bytes) {
676
  if (bytes === 0) return '0 B';
@@ -681,18 +682,41 @@
681
  },
682
 
683
  show(progress = 0) {
684
- this.el.style.transform = `scaleX(${progress})`;
685
  this.el.classList.add('active');
686
  this.infoEl.classList.add('show');
687
  },
688
 
689
- update(progress, loadedBytes = 0, totalBytes = 0) {
690
- const p = Math.min(1, Math.max(0, progress));
691
- this.el.style.transform = `scaleX(${p})`;
692
- this.textEl.textContent = Math.round(p * 100) + '%';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
693
 
694
- if (totalBytes > 0) {
695
- this.bytesEl.textContent = `${this.formatBytes(loadedBytes)} / ${this.formatBytes(totalBytes)}`;
 
 
 
 
696
  }
697
  },
698
 
@@ -700,13 +724,46 @@
700
  this.el.classList.remove('active');
701
  this.infoEl.classList.remove('show');
702
  setTimeout(() => {
703
- this.el.style.transform = 'scaleX(0)';
704
- this.textEl.textContent = '0%';
705
- this.bytesEl.textContent = '0 KB / 0 KB';
 
 
706
  }, 300);
707
  }
708
  };
709
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
710
  // --- INDEXED DB CACHE WITH LAZY LOADING ---
711
  const ThumbCache = {
712
  DB_NAME: 'PDF_Crop_Thumbs',
@@ -765,9 +822,9 @@
765
  imgElement.classList.add('loaded');
766
  if (loaderElement) loaderElement.remove();
767
  } else {
768
- // Not cached - fetch with progress
769
  try {
770
- const blob = await this.fetchWithProgress(url, showProgress);
771
  const txWrite = db.transaction(this.STORE_NAME, 'readwrite');
772
  txWrite.objectStore(this.STORE_NAME).put({
773
  url: url,
@@ -779,6 +836,7 @@
779
  imgElement.classList.add('loaded');
780
  if (loaderElement) loaderElement.remove();
781
  } catch (err) {
 
782
  imgElement.src = url;
783
  imgElement.classList.add('loaded');
784
  if (loaderElement) loaderElement.remove();
@@ -799,20 +857,20 @@
799
  xhr.responseType = 'blob';
800
 
801
  if (showProgress) {
802
- ProgressBar.show(0);
 
803
  }
804
 
805
  xhr.onprogress = (e) => {
806
  if (e.lengthComputable && showProgress) {
807
- const progress = e.loaded / e.total;
808
- ProgressBar.update(progress, e.loaded, e.total);
809
  }
810
  };
811
 
812
  xhr.onload = () => {
813
  if (showProgress) {
814
- ProgressBar.update(1, xhr.response.size, xhr.response.size);
815
- setTimeout(() => ProgressBar.hide(), 500);
816
  }
817
  if (xhr.status === 200) {
818
  resolve(xhr.response);
@@ -822,7 +880,7 @@
822
  };
823
 
824
  xhr.onerror = () => {
825
- if (showProgress) ProgressBar.hide();
826
  reject(new Error('Network error'));
827
  };
828
 
@@ -935,7 +993,7 @@
935
  els.cropArea.classList.add('loading');
936
 
937
  try {
938
- const blob = await ThumbCache.fetchWithProgress(imageUrl, true);
939
  els.image.src = URL.createObjectURL(blob);
940
  els.image.onload = () => {
941
  els.image.classList.add('loaded');
@@ -949,7 +1007,6 @@
949
  els.cropArea.classList.remove('loading');
950
  fitImage();
951
  };
952
- ProgressBar.hide();
953
  }
954
  }
955
 
@@ -960,7 +1017,7 @@
960
  els.rightCropArea.classList.add('loading');
961
 
962
  try {
963
- const blob = await ThumbCache.fetchWithProgress(imageUrl, false); // Don't show progress for second image
964
  els.rightImage.src = URL.createObjectURL(blob);
965
  els.rightImage.onload = () => {
966
  els.rightImage.classList.add('loaded');
 
671
  infoEl: document.getElementById('progress-info'),
672
  textEl: document.querySelector('#progress-info .progress-text'),
673
  bytesEl: document.getElementById('progress-bytes'),
674
+ activeRequests: new Map(), // url -> {loaded, total}
675
 
676
  formatBytes(bytes) {
677
  if (bytes === 0) return '0 B';
 
682
  },
683
 
684
  show(progress = 0) {
685
+ if (progress > 0) this.el.style.transform = `scaleX(${progress})`;
686
  this.el.classList.add('active');
687
  this.infoEl.classList.add('show');
688
  },
689
 
690
+ update(url, loaded, total) {
691
+ this.activeRequests.set(url, { loaded, total });
692
+ this.calculateTotal();
693
+ },
694
+
695
+ calculateTotal() {
696
+ let totalLoaded = 0;
697
+ let totalSize = 0;
698
+ let count = 0;
699
+
700
+ this.activeRequests.forEach(req => {
701
+ totalLoaded += req.loaded;
702
+ totalSize += req.total;
703
+ count++;
704
+ });
705
+
706
+ if (totalSize > 0) {
707
+ const p = totalLoaded / totalSize;
708
+ this.el.style.transform = `scaleX(${p})`;
709
+ this.textEl.textContent = Math.round(p * 100) + '%';
710
+ this.bytesEl.textContent = `${this.formatBytes(totalLoaded)} / ${this.formatBytes(totalSize)}`;
711
+ }
712
+ },
713
 
714
+ removeRequest(url) {
715
+ this.activeRequests.delete(url);
716
+ if (this.activeRequests.size === 0) {
717
+ this.hide();
718
+ } else {
719
+ this.calculateTotal();
720
  }
721
  },
722
 
 
724
  this.el.classList.remove('active');
725
  this.infoEl.classList.remove('show');
726
  setTimeout(() => {
727
+ if (this.activeRequests.size === 0) {
728
+ this.el.style.transform = 'scaleX(0)';
729
+ this.textEl.textContent = '0%';
730
+ this.bytesEl.textContent = '0 KB / 0 KB';
731
+ }
732
  }, 300);
733
  }
734
  };
735
 
736
+ // --- FETCH QUEUE FOR PARALLEL LOADING ---
737
+ const FetchQueue = {
738
+ maxParallel: 8,
739
+ running: 0,
740
+ queue: [],
741
+
742
+ async add(task) {
743
+ return new Promise((resolve, reject) => {
744
+ this.queue.push({ task, resolve, reject });
745
+ this.process();
746
+ });
747
+ },
748
+
749
+ async process() {
750
+ if (this.running >= this.maxParallel || this.queue.length === 0) return;
751
+
752
+ this.running++;
753
+ const { task, resolve, reject } = this.queue.shift();
754
+
755
+ try {
756
+ const result = await task();
757
+ resolve(result);
758
+ } catch (err) {
759
+ reject(err);
760
+ } finally {
761
+ this.running--;
762
+ this.process();
763
+ }
764
+ }
765
+ };
766
+
767
  // --- INDEXED DB CACHE WITH LAZY LOADING ---
768
  const ThumbCache = {
769
  DB_NAME: 'PDF_Crop_Thumbs',
 
822
  imgElement.classList.add('loaded');
823
  if (loaderElement) loaderElement.remove();
824
  } else {
825
+ // Not cached - queue fetch
826
  try {
827
+ const blob = await FetchQueue.add(() => this.fetchWithProgress(url, showProgress));
828
  const txWrite = db.transaction(this.STORE_NAME, 'readwrite');
829
  txWrite.objectStore(this.STORE_NAME).put({
830
  url: url,
 
836
  imgElement.classList.add('loaded');
837
  if (loaderElement) loaderElement.remove();
838
  } catch (err) {
839
+ console.error('Fetch failed for', url, err);
840
  imgElement.src = url;
841
  imgElement.classList.add('loaded');
842
  if (loaderElement) loaderElement.remove();
 
857
  xhr.responseType = 'blob';
858
 
859
  if (showProgress) {
860
+ ProgressBar.show();
861
+ ProgressBar.update(url, 0, 0); // Init
862
  }
863
 
864
  xhr.onprogress = (e) => {
865
  if (e.lengthComputable && showProgress) {
866
+ ProgressBar.update(url, e.loaded, e.total);
 
867
  }
868
  };
869
 
870
  xhr.onload = () => {
871
  if (showProgress) {
872
+ ProgressBar.update(url, xhr.response.size, xhr.response.size);
873
+ setTimeout(() => ProgressBar.removeRequest(url), 200);
874
  }
875
  if (xhr.status === 200) {
876
  resolve(xhr.response);
 
880
  };
881
 
882
  xhr.onerror = () => {
883
+ if (showProgress) ProgressBar.removeRequest(url);
884
  reject(new Error('Network error'));
885
  };
886
 
 
993
  els.cropArea.classList.add('loading');
994
 
995
  try {
996
+ const blob = await FetchQueue.add(() => ThumbCache.fetchWithProgress(imageUrl, true));
997
  els.image.src = URL.createObjectURL(blob);
998
  els.image.onload = () => {
999
  els.image.classList.add('loaded');
 
1007
  els.cropArea.classList.remove('loading');
1008
  fitImage();
1009
  };
 
1010
  }
1011
  }
1012
 
 
1017
  els.rightCropArea.classList.add('loading');
1018
 
1019
  try {
1020
+ const blob = await FetchQueue.add(() => ThumbCache.fetchWithProgress(imageUrl, true));
1021
  els.rightImage.src = URL.createObjectURL(blob);
1022
  els.rightImage.onload = () => {
1023
  els.rightImage.classList.add('loaded');
templates/dashboard.html CHANGED
@@ -43,7 +43,10 @@
43
  <div class="container-fluid mt-4" style="width: 90%; margin: auto;">
44
  <div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
45
  <h2>Session Dashboard</h2>
46
- <button id="delete-selected-btn" class="btn btn-danger" style="display: none;">Delete Selected</button>
 
 
 
47
  </div>
48
 
49
  <!-- Filter tabs -->
@@ -68,6 +71,7 @@
68
  <th scope="col" style="width: 3%;">S.No.</th>
69
  <th scope="col" style="width: 3%;"><input type="checkbox" id="select-all-checkbox"></th>
70
  <th scope="col" class="name-column">Name</th>
 
71
  <th scope="col">Created At</th>
72
  <th scope="col">Pages</th>
73
  <th scope="col">{{ 'Bookmarks' if filter_type == 'collections' else 'Questions' }}</th>
@@ -88,6 +92,11 @@
88
  <i class="fas fa-pencil-alt edit-name-icon ms-2" style="cursor: pointer;"></i>
89
  <input type="text" class="form-control form-control-sm d-none" value="{{ session.name or session.original_filename }}">
90
  </td>
 
 
 
 
 
91
  <td><span class="badge bg-secondary">{{ session.created_at | humanize }}</span></td>
92
  <td>{{ session.page_count }}</td>
93
  <td>{{ session.question_count }}</td>
@@ -147,12 +156,14 @@
147
  $(document).ready(function() {
148
  // Checkbox selection logic
149
  const deleteBtn = $('#delete-selected-btn');
 
150
  const selectAllCheckbox = $('#select-all-checkbox');
151
  const sessionCheckboxes = $('.session-checkbox');
152
 
153
  function toggleDeleteButton() {
154
  const anyChecked = sessionCheckboxes.is(':checked');
155
  deleteBtn.css('display', anyChecked ? 'inline-block' : 'none');
 
156
  }
157
 
158
  selectAllCheckbox.on('change', function() {
@@ -191,6 +202,31 @@ $(document).ready(function() {
191
  }
192
  });
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  // Editable name
195
  $('.edit-name-icon').on('click', function() {
196
  const icon = $(this);
@@ -232,6 +268,47 @@ $(document).ready(function() {
232
  }
233
  });
234
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  // Toggle persist
236
  $('.toggle-persist-btn').on('click', function() {
237
  const button = $(this);
 
43
  <div class="container-fluid mt-4" style="width: 90%; margin: auto;">
44
  <div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
45
  <h2>Session Dashboard</h2>
46
+ <div class="d-flex gap-2">
47
+ <button id="batch-group-btn" class="btn btn-outline-info" style="display: none;">Set Group</button>
48
+ <button id="delete-selected-btn" class="btn btn-danger" style="display: none;">Delete Selected</button>
49
+ </div>
50
  </div>
51
 
52
  <!-- Filter tabs -->
 
71
  <th scope="col" style="width: 3%;">S.No.</th>
72
  <th scope="col" style="width: 3%;"><input type="checkbox" id="select-all-checkbox"></th>
73
  <th scope="col" class="name-column">Name</th>
74
+ <th scope="col">Group</th>
75
  <th scope="col">Created At</th>
76
  <th scope="col">Pages</th>
77
  <th scope="col">{{ 'Bookmarks' if filter_type == 'collections' else 'Questions' }}</th>
 
92
  <i class="fas fa-pencil-alt edit-name-icon ms-2" style="cursor: pointer;"></i>
93
  <input type="text" class="form-control form-control-sm d-none" value="{{ session.name or session.original_filename }}">
94
  </td>
95
+ <td class="editable-group">
96
+ <span class="group-name text-muted small">{{ session.group_name or 'None' }}</span>
97
+ <i class="fas fa-pencil-alt edit-group-icon ms-2" style="cursor: pointer; font-size: 0.8rem;"></i>
98
+ <input type="text" class="form-control form-control-sm d-none" value="{{ session.group_name or '' }}" placeholder="Group Name">
99
+ </td>
100
  <td><span class="badge bg-secondary">{{ session.created_at | humanize }}</span></td>
101
  <td>{{ session.page_count }}</td>
102
  <td>{{ session.question_count }}</td>
 
156
  $(document).ready(function() {
157
  // Checkbox selection logic
158
  const deleteBtn = $('#delete-selected-btn');
159
+ const batchGroupBtn = $('#batch-group-btn');
160
  const selectAllCheckbox = $('#select-all-checkbox');
161
  const sessionCheckboxes = $('.session-checkbox');
162
 
163
  function toggleDeleteButton() {
164
  const anyChecked = sessionCheckboxes.is(':checked');
165
  deleteBtn.css('display', anyChecked ? 'inline-block' : 'none');
166
+ batchGroupBtn.css('display', anyChecked ? 'inline-block' : 'none');
167
  }
168
 
169
  selectAllCheckbox.on('change', function() {
 
202
  }
203
  });
204
 
205
+ // Batch update group
206
+ batchGroupBtn.on('click', function() {
207
+ const selectedIds = [];
208
+ sessionCheckboxes.filter(':checked').each(function() {
209
+ selectedIds.push($(this).closest('tr').data('session-id'));
210
+ });
211
+
212
+ const newGroup = prompt(`Enter group name for ${selectedIds.length} sessions (leave empty to remove group):`);
213
+ if (newGroup !== null) {
214
+ $.ajax({
215
+ url: '/sessions/batch_update_group',
216
+ type: 'POST',
217
+ contentType: 'application/json',
218
+ data: JSON.stringify({ ids: selectedIds, group_name: newGroup.trim() || null }),
219
+ success: function(response) {
220
+ if (response.success) {
221
+ location.reload();
222
+ } else {
223
+ alert('Error updating groups: ' + response.error);
224
+ }
225
+ }
226
+ });
227
+ }
228
+ });
229
+
230
  // Editable name
231
  $('.edit-name-icon').on('click', function() {
232
  const icon = $(this);
 
268
  }
269
  });
270
 
271
+ // Group editing logic
272
+ $('.edit-group-icon').on('click', function() {
273
+ const icon = $(this);
274
+ const span = icon.prev('.group-name');
275
+ const input = icon.next('input');
276
+ span.addClass('d-none');
277
+ icon.addClass('d-none');
278
+ input.removeClass('d-none').focus();
279
+ });
280
+
281
+ $('.editable-group input').on('blur keyup', function(e) {
282
+ if (e.type === 'blur' || e.key === 'Enter') {
283
+ const input = $(this);
284
+ const span = input.siblings('.group-name');
285
+ const icon = input.siblings('.edit-group-icon');
286
+ const newGroup = input.val().trim();
287
+ const oldGroup = span.text() === 'None' ? '' : span.text();
288
+ const sessionId = input.closest('tr').data('session-id');
289
+
290
+ if (newGroup !== oldGroup) {
291
+ $.ajax({
292
+ url: '/sessions/update_group',
293
+ type: 'POST',
294
+ contentType: 'application/json',
295
+ data: JSON.stringify({ session_id: sessionId, group_name: newGroup }),
296
+ success: function(response) {
297
+ if (response.success) {
298
+ span.text(newGroup || 'None');
299
+ } else {
300
+ alert('Error updating group: ' + response.error);
301
+ input.val(oldGroup);
302
+ }
303
+ }
304
+ });
305
+ }
306
+ input.addClass('d-none');
307
+ span.removeClass('d-none');
308
+ icon.removeClass('d-none');
309
+ }
310
+ });
311
+
312
  // Toggle persist
313
  $('.toggle-persist-btn').on('click', function() {
314
  const button = $(this);
templates/stacked_chart.html CHANGED
@@ -206,7 +206,17 @@
206
  <div class="card bg-dark text-white border-0 shadow">
207
  <div class="card-header text-center py-4" style="background: rgba(255,255,255,0.03); border-bottom: 1px solid rgba(255,255,255,0.1);">
208
  <h1 class="h3 mb-1 fw-bold">Classified Questions</h1>
209
- <p class="mb-0 text-white-50 small">Session → Subjects → Topics</p>
 
 
 
 
 
 
 
 
 
 
210
  </div>
211
  <div class="card-body px-3 px-md-4 py-4">
212
 
@@ -286,6 +296,12 @@ let originalData = null;
286
  let selectedSubjects = new Set();
287
  let sortByWrongRatio = false;
288
 
 
 
 
 
 
 
289
  const SUBJECT_PALETTE = {
290
  P: { border: '#f97316' },
291
  C: { border: '#22d3ee' },
@@ -390,32 +406,62 @@ const topAxisPlugin = {
390
  };
391
 
392
  document.addEventListener('DOMContentLoaded', function () {
393
- fetch('/api/stacked_chart_data')
394
- .then(r => r.json())
395
- .then(data => {
396
- originalData = data;
397
-
398
- const allSubjects = new Set();
399
- Object.keys(data).forEach(sid => {
400
- Object.keys(data[sid].subjects).forEach(s => allSubjects.add(s));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  });
 
402
 
403
- const subjectList = Array.from(allSubjects);
404
- subjectList.forEach(s => selectedSubjects.add(s));
405
-
406
- createSubjectCheckboxes(subjectList);
407
- createMainChartSubjectCheckboxes(subjectList);
408
 
409
- createStackedChart(data);
410
- createTopicChart(data);
411
- populateDataTable(data);
 
412
 
413
- document.getElementById('sortByWrongRatio').addEventListener('change', function () {
414
- sortByWrongRatio = this.checked;
415
- applySubjectFilter();
416
- });
417
- })
418
- .catch(err => console.error('Error fetching chart data:', err));
419
  });
420
 
421
  function buildPills(container, subjects, idPrefix, labelFn) {
@@ -473,9 +519,10 @@ function applySubjectFilter() {
473
  }
474
 
475
  const filtered = {};
476
- Object.keys(originalData).forEach(sid => {
477
  filtered[sid] = {
478
  session_name: originalData[sid].session_name,
 
479
  subjects: {},
480
  wrong_ratios: originalData[sid].wrong_ratios
481
  };
@@ -492,7 +539,7 @@ function applySubjectFilter() {
492
 
493
  function createStackedChart(data) {
494
  const ctx = document.getElementById('stackedChart').getContext('2d');
495
- const sessionIds = Object.keys(data);
496
  const sessionNames = sessionIds.map(id => data[id].session_name || id);
497
 
498
  const datasetMap = {};
@@ -722,7 +769,7 @@ function parseWrongRatio(ratioStr) {
722
  function populateDataTable(data) {
723
  const tbody = document.getElementById('dataTableBody');
724
  tbody.innerHTML = '';
725
- const sessionIds = Object.keys(data);
726
 
727
  sessionIds.forEach(sid => {
728
  const sd = data[sid];
 
206
  <div class="card bg-dark text-white border-0 shadow">
207
  <div class="card-header text-center py-4" style="background: rgba(255,255,255,0.03); border-bottom: 1px solid rgba(255,255,255,0.1);">
208
  <h1 class="h3 mb-1 fw-bold">Classified Questions</h1>
209
+ <p class="mb-3 text-white-50 small">Session → Subjects → Topics</p>
210
+
211
+ <div class="d-flex justify-content-center align-items-center gap-2">
212
+ <span class="small text-white-50">View Group:</span>
213
+ <select id="groupSelector" class="form-select form-select-sm bg-dark text-white border-secondary" style="width: auto; min-width: 150px;">
214
+ <option value="">Main (No Group)</option>
215
+ {% for group in groups %}
216
+ <option value="{{ group }}">{{ group }}</option>
217
+ {% endfor %}
218
+ </select>
219
+ </div>
220
  </div>
221
  <div class="card-body px-3 px-md-4 py-4">
222
 
 
296
  let selectedSubjects = new Set();
297
  let sortByWrongRatio = false;
298
 
299
+ function getSortedSessionIds(data) {
300
+ return Object.keys(data).sort((a, b) => {
301
+ return new Date(data[b].created_at) - new Date(data[a].created_at);
302
+ });
303
+ }
304
+
305
  const SUBJECT_PALETTE = {
306
  P: { border: '#f97316' },
307
  C: { border: '#22d3ee' },
 
406
  };
407
 
408
  document.addEventListener('DOMContentLoaded', function () {
409
+ const groupSelector = document.getElementById('groupSelector');
410
+
411
+ function fetchData(group = '') {
412
+ // Show loading state in table
413
+ document.getElementById('dataTableBody').innerHTML = `
414
+ <tr>
415
+ <td colspan="6">
416
+ <div class="loading-overlay">
417
+ <div class="spinner-border spinner-border-sm me-2" role="status"></div>
418
+ Loading group data…
419
+ </div>
420
+ </td>
421
+ </tr>`;
422
+
423
+ const url = group ? `/api/stacked_chart_data?group=${encodeURIComponent(group)}` : '/api/stacked_chart_data';
424
+
425
+ fetch(url)
426
+ .then(r => r.json())
427
+ .then(data => {
428
+ originalData = data;
429
+
430
+ const allSubjects = new Set();
431
+ Object.keys(data).forEach(sid => {
432
+ Object.keys(data[sid].subjects).forEach(s => allSubjects.add(s));
433
+ });
434
+
435
+ const subjectList = Array.from(allSubjects);
436
+ // Reset selected subjects to include all from the new data
437
+ selectedSubjects = new Set();
438
+ subjectList.forEach(s => selectedSubjects.add(s));
439
+
440
+ createSubjectCheckboxes(subjectList);
441
+ createMainChartSubjectCheckboxes(subjectList);
442
+
443
+ createStackedChart(data);
444
+ createTopicChart(data);
445
+ populateDataTable(data);
446
+ })
447
+ .catch(err => {
448
+ console.error('Error fetching chart data:', err);
449
+ document.getElementById('dataTableBody').innerHTML = '<tr><td colspan="6" class="text-center text-danger">Error loading data</td></tr>';
450
  });
451
+ }
452
 
453
+ // Initial fetch
454
+ fetchData(groupSelector.value);
 
 
 
455
 
456
+ // Fetch on group change
457
+ groupSelector.addEventListener('change', function() {
458
+ fetchData(this.value);
459
+ });
460
 
461
+ document.getElementById('sortByWrongRatio').addEventListener('change', function () {
462
+ sortByWrongRatio = this.checked;
463
+ applySubjectFilter();
464
+ });
 
 
465
  });
466
 
467
  function buildPills(container, subjects, idPrefix, labelFn) {
 
519
  }
520
 
521
  const filtered = {};
522
+ getSortedSessionIds(originalData).forEach(sid => {
523
  filtered[sid] = {
524
  session_name: originalData[sid].session_name,
525
+ created_at: originalData[sid].created_at,
526
  subjects: {},
527
  wrong_ratios: originalData[sid].wrong_ratios
528
  };
 
539
 
540
  function createStackedChart(data) {
541
  const ctx = document.getElementById('stackedChart').getContext('2d');
542
+ const sessionIds = getSortedSessionIds(data);
543
  const sessionNames = sessionIds.map(id => data[id].session_name || id);
544
 
545
  const datasetMap = {};
 
769
  function populateDataTable(data) {
770
  const tbody = document.getElementById('dataTableBody');
771
  tbody.innerHTML = '';
772
+ const sessionIds = getSortedSessionIds(data);
773
 
774
  sessionIds.forEach(sid => {
775
  const sd = data[sid];