E5K7 commited on
Commit
df3f21d
Β·
1 Parent(s): 4041d2a

Update backend: quiz, leaderboard, analytics, profile, difficulty adjustment, plan caching

Browse files
Files changed (1) hide show
  1. main.py +496 -29
main.py CHANGED
@@ -67,6 +67,33 @@ class TutorialRequest(BaseModel):
67
  skill_level: str = "beginner"
68
 
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  # ── Helpers ──────────────────────────────────────────────────────────────────
71
 
72
  PLAN_PROMPT_TEMPLATE = """You are a study-plan generator. Create a structured learning plan.
@@ -126,6 +153,51 @@ Keep the tutorial focused and around 800-1200 words.
126
  Make it engaging and easy to follow for a {skill_level} learner.
127
  """
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
  def parse_llm_json(text: str):
131
  """Strip markdown fences and parse JSON from LLM output."""
@@ -161,39 +233,79 @@ async def check_and_simplify(user_id: str, plan_id: str):
161
  else:
162
  consecutive_misses = 0
163
 
164
- if not needs_simplification:
165
- return
 
 
 
 
 
 
 
 
 
166
 
167
- # Gather remaining (non-completed) tasks
168
- remaining = [t for t in sorted_schedule if t["status"] != "completed"]
169
- if not remaining:
170
- return
 
171
 
172
- prompt = SIMPLIFY_PROMPT_TEMPLATE.format(
173
- goal=data.get("goal", ""),
174
- remaining_json=json.dumps(remaining, indent=2),
175
- )
176
 
177
- response = client.chat.completions.create(
178
- model=LLM_MODEL,
179
- messages=[{"role": "user", "content": prompt}],
180
- )
181
- new_schedule_raw = parse_llm_json(response.choices[0].message.content)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
 
183
- # Validate each entry
184
- new_remaining = []
185
- for entry in new_schedule_raw:
186
- task = DailyTask(**entry)
187
- new_remaining.append(task.model_dump())
188
 
189
- # Merge: keep completed tasks, replace the rest
190
- completed = [t for t in sorted_schedule if t["status"] == "completed"]
191
- updated_schedule = completed + new_remaining
 
192
 
193
- doc_ref.update({
194
- "schedule": updated_schedule,
195
- "simplified_at": datetime.utcnow().isoformat(),
196
- })
 
 
 
 
 
197
 
198
 
199
  # ── Endpoints ────────────────────────────────────────────────────────────────
@@ -270,7 +382,6 @@ async def update_status(req: StatusUpdate, background_tasks: BackgroundTasks):
270
  break
271
 
272
  if not updated:
273
- # Log debug info to help diagnose
274
  day_statuses = {int(t.get('day', 0)): t.get('status', '?') for t in schedule}
275
  print(f"[update-status] day={req.day}, schedule_days={day_statuses}")
276
  raise HTTPException(
@@ -280,10 +391,171 @@ async def update_status(req: StatusUpdate, background_tasks: BackgroundTasks):
280
 
281
  doc_ref.update({"schedule": schedule})
282
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  # Trigger background check for consecutive misses
284
  background_tasks.add_task(check_and_simplify, req.user_id, req.plan_id)
285
 
286
- return {"message": f"Day {req.day} marked as {req.status}"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
 
288
 
289
  @app.get("/plan/{plan_id}")
@@ -359,6 +631,201 @@ async def delete_plan(plan_id: str, user_id: str):
359
  return {"message": "Plan deleted"}
360
 
361
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
  if __name__ == "__main__":
363
  import uvicorn
364
  uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
 
67
  skill_level: str = "beginner"
68
 
69
 
70
+ class QuizRequest(BaseModel):
71
+ topic: str
72
+ skill_level: str = "beginner"
73
+ num_questions: int = Field(default=5, ge=3, le=10)
74
+
75
+
76
+ class QuizQuestion(BaseModel):
77
+ question: str
78
+ options: list[str]
79
+ correct_answer: int # index into options
80
+ explanation: str
81
+
82
+
83
+ class QuizSubmission(BaseModel):
84
+ user_id: str
85
+ plan_id: str
86
+ day: int
87
+ answers: list[int] # user's selected indices
88
+
89
+
90
+ class ReminderSettings(BaseModel):
91
+ user_id: str
92
+ enabled: bool = True
93
+ hour: int = Field(default=9, ge=0, le=23)
94
+ minute: int = Field(default=0, ge=0, le=59)
95
+
96
+
97
  # ── Helpers ──────────────────────────────────────────────────────────────────
98
 
99
  PLAN_PROMPT_TEMPLATE = """You are a study-plan generator. Create a structured learning plan.
 
153
  Make it engaging and easy to follow for a {skill_level} learner.
154
  """
155
 
156
+ QUIZ_PROMPT_TEMPLATE = """You are a quiz generator for educational content.
157
+
158
+ Generate exactly {num_questions} multiple-choice questions about:
159
+ Topic: {topic}
160
+ Difficulty: {skill_level}
161
+
162
+ Return ONLY valid JSON matching this exact schema (no markdown, no explanation):
163
+ {{
164
+ "questions": [
165
+ {{
166
+ "question": "<question text>",
167
+ "options": ["<option A>", "<option B>", "<option C>", "<option D>"],
168
+ "correct_answer": <0-3 index of correct option>,
169
+ "explanation": "<brief explanation of why the answer is correct>"
170
+ }}
171
+ ]
172
+ }}
173
+
174
+ Rules:
175
+ - Exactly 4 options per question.
176
+ - correct_answer is a 0-based index.
177
+ - Questions should test understanding, not just memorization.
178
+ - Vary difficulty within the {skill_level} range.
179
+ """
180
+
181
+ DIFFICULTY_UP_PROMPT = """The learner is performing exceptionally β€” they've completed {streak} days in a row without missing any.
182
+
183
+ Original goal: {goal}
184
+ Remaining schedule (uncompleted days):
185
+ {remaining_json}
186
+
187
+ Increase the difficulty of the remaining schedule. Make topics more advanced, add deeper sub-topics, and slightly increase the complexity.
188
+
189
+ Return ONLY valid JSON as a list of daily tasks matching this schema (no markdown):
190
+ [
191
+ {{"day": <int>, "topic": "<more advanced sub-topic>", "duration_mins": <int>, "status": "pending"}}
192
+ ]
193
+
194
+ Rules:
195
+ - Keep the same number of days.
196
+ - Increase conceptual depth by ~30%.
197
+ - Keep duration_mins the same or slightly higher.
198
+ - Make topic descriptions more specific and challenging.
199
+ """
200
+
201
 
202
  def parse_llm_json(text: str):
203
  """Strip markdown fences and parse JSON from LLM output."""
 
233
  else:
234
  consecutive_misses = 0
235
 
236
+ # Detect 7 consecutive completions β†’ increase difficulty
237
+ consecutive_completed = 0
238
+ needs_difficulty_boost = False
239
+ for task in sorted_schedule:
240
+ if task["status"] == "completed":
241
+ consecutive_completed += 1
242
+ if consecutive_completed >= 7:
243
+ needs_difficulty_boost = True
244
+ break
245
+ else:
246
+ consecutive_completed = 0
247
 
248
+ if needs_simplification:
249
+ # Gather remaining (non-completed) tasks
250
+ remaining = [t for t in sorted_schedule if t["status"] != "completed"]
251
+ if not remaining:
252
+ return
253
 
254
+ prompt = SIMPLIFY_PROMPT_TEMPLATE.format(
255
+ goal=data.get("goal", ""),
256
+ remaining_json=json.dumps(remaining, indent=2),
257
+ )
258
 
259
+ response = client.chat.completions.create(
260
+ model=LLM_MODEL,
261
+ messages=[{"role": "user", "content": prompt}],
262
+ )
263
+ new_schedule_raw = parse_llm_json(response.choices[0].message.content)
264
+
265
+ new_remaining = []
266
+ for entry in new_schedule_raw:
267
+ task = DailyTask(**entry)
268
+ new_remaining.append(task.model_dump())
269
+
270
+ completed = [t for t in sorted_schedule if t["status"] == "completed"]
271
+ updated_schedule = completed + new_remaining
272
+
273
+ doc_ref.update({
274
+ "schedule": updated_schedule,
275
+ "simplified_at": datetime.utcnow().isoformat(),
276
+ })
277
+
278
+ elif needs_difficulty_boost and not data.get("difficulty_boosted"):
279
+ remaining = [t for t in sorted_schedule if t["status"] == "pending"]
280
+ if not remaining:
281
+ return
282
+
283
+ prompt = DIFFICULTY_UP_PROMPT.format(
284
+ streak=consecutive_completed,
285
+ goal=data.get("goal", ""),
286
+ remaining_json=json.dumps(remaining, indent=2),
287
+ )
288
 
289
+ response = client.chat.completions.create(
290
+ model=LLM_MODEL,
291
+ messages=[{"role": "user", "content": prompt}],
292
+ )
293
+ new_schedule_raw = parse_llm_json(response.choices[0].message.content)
294
 
295
+ new_remaining = []
296
+ for entry in new_schedule_raw:
297
+ task = DailyTask(**entry)
298
+ new_remaining.append(task.model_dump())
299
 
300
+ completed = [t for t in sorted_schedule if t["status"] == "completed"]
301
+ missed = [t for t in sorted_schedule if t["status"] == "missed"]
302
+ updated_schedule = completed + missed + new_remaining
303
+
304
+ doc_ref.update({
305
+ "schedule": updated_schedule,
306
+ "difficulty_boosted": True,
307
+ "boosted_at": datetime.utcnow().isoformat(),
308
+ })
309
 
310
 
311
  # ── Endpoints ────────────────────────────────────────────────────────────────
 
382
  break
383
 
384
  if not updated:
 
385
  day_statuses = {int(t.get('day', 0)): t.get('status', '?') for t in schedule}
386
  print(f"[update-status] day={req.day}, schedule_days={day_statuses}")
387
  raise HTTPException(
 
391
 
392
  doc_ref.update({"schedule": schedule})
393
 
394
+ # ── Award points if completed ────────────────────────────────────────
395
+ points_earned = 0
396
+ total_points = 0
397
+ streak = 0
398
+ if req.status == "completed":
399
+ points_earned = 10 # base points per day
400
+
401
+ # Calculate streak bonus: count consecutive completed days ending at this day
402
+ sorted_schedule = sorted(schedule, key=lambda t: int(t["day"]))
403
+ current_streak = 0
404
+ for t in sorted_schedule:
405
+ if t["status"] == "completed":
406
+ current_streak += 1
407
+ else:
408
+ current_streak = 0
409
+ streak = current_streak
410
+
411
+ # Streak bonuses
412
+ if streak >= 7:
413
+ points_earned += 25 # week streak bonus
414
+ elif streak >= 3:
415
+ points_earned += 10 # 3-day streak bonus
416
+
417
+ # Update user points in Firestore
418
+ points_ref = db.collection("user_points").document(req.user_id)
419
+ points_doc = points_ref.get()
420
+ if points_doc.exists:
421
+ current = points_doc.to_dict().get("total_points", 0)
422
+ total_points = current + points_earned
423
+ points_ref.update({
424
+ "total_points": total_points,
425
+ "last_earned": points_earned,
426
+ "streak": streak,
427
+ "updated_at": datetime.utcnow().isoformat(),
428
+ })
429
+ else:
430
+ total_points = points_earned
431
+ points_ref.set({
432
+ "user_id": req.user_id,
433
+ "total_points": total_points,
434
+ "last_earned": points_earned,
435
+ "streak": streak,
436
+ "updated_at": datetime.utcnow().isoformat(),
437
+ })
438
+
439
  # Trigger background check for consecutive misses
440
  background_tasks.add_task(check_and_simplify, req.user_id, req.plan_id)
441
 
442
+ return {
443
+ "message": f"Day {req.day} marked as {req.status}",
444
+ "points_earned": points_earned,
445
+ "total_points": total_points,
446
+ "streak": streak,
447
+ }
448
+
449
+
450
+ @app.get("/points/{user_id}")
451
+ async def get_points(user_id: str):
452
+ """Get a user's total points and streak."""
453
+ doc = db.collection("user_points").document(user_id).get()
454
+ if not doc.exists:
455
+ return {"total_points": 0, "streak": 0, "last_earned": 0}
456
+ data = doc.to_dict()
457
+ return {
458
+ "total_points": data.get("total_points", 0),
459
+ "streak": data.get("streak", 0),
460
+ "last_earned": data.get("last_earned", 0),
461
+ }
462
+
463
+
464
+ # ── Profile / Achievements endpoint ──────────────────────────────────────────
465
+
466
+ TROPHY_DEFINITIONS = [
467
+ {"id": "first_step", "name": "First Step", "desc": "Complete your first lesson", "icon": "⭐", "requirement": 1},
468
+ {"id": "getting_started", "name": "Getting Started", "desc": "Complete 5 lessons", "icon": "🌟", "requirement": 5},
469
+ {"id": "dedicated", "name": "Dedicated Learner", "desc": "Complete 10 lessons", "icon": "πŸ“š", "requirement": 10},
470
+ {"id": "scholar", "name": "Scholar", "desc": "Complete 25 lessons", "icon": "πŸŽ“", "requirement": 25},
471
+ {"id": "master", "name": "Master", "desc": "Complete 50 lessons", "icon": "πŸ‘‘", "requirement": 50},
472
+ {"id": "streak_3", "name": "On Fire", "desc": "Reach a 3-day streak", "icon": "πŸ”₯", "requirement": 3, "type": "streak"},
473
+ {"id": "streak_7", "name": "Week Warrior", "desc": "Reach a 7-day streak", "icon": "⚑", "requirement": 7, "type": "streak"},
474
+ {"id": "streak_14", "name": "Unstoppable", "desc": "Reach a 14-day streak", "icon": "πŸ’Ž", "requirement": 14, "type": "streak"},
475
+ {"id": "multi_topic", "name": "Explorer", "desc": "Study 3 different topics", "icon": "🧭", "requirement": 3, "type": "topics"},
476
+ {"id": "points_100", "name": "Century Club", "desc": "Earn 100 total XP", "icon": "πŸ’―", "requirement": 100, "type": "points"},
477
+ {"id": "points_500", "name": "XP Hunter", "desc": "Earn 500 total XP", "icon": "πŸ†", "requirement": 500, "type": "points"},
478
+ ]
479
+
480
+
481
+ @app.get("/profile/{user_id}")
482
+ async def get_profile(user_id: str):
483
+ """Get full user profile with stats, achievements, and trophies."""
484
+ # Gather points data
485
+ points_doc = db.collection("user_points").document(user_id).get()
486
+ points_data = points_doc.to_dict() if points_doc.exists else {}
487
+ total_points = points_data.get("total_points", 0)
488
+ current_streak = points_data.get("streak", 0)
489
+ best_streak = points_data.get("best_streak", current_streak)
490
+
491
+ # Gather plan stats
492
+ plans = db.collection("learning_plans").where("user_id", "==", user_id).stream()
493
+ total_completed = 0
494
+ total_missed = 0
495
+ total_pending = 0
496
+ total_plans = 0
497
+ topics_studied = set()
498
+
499
+ for doc in plans:
500
+ d = doc.to_dict()
501
+ total_plans += 1
502
+ topic = d.get("topic", "")
503
+ if topic:
504
+ topics_studied.add(topic.lower())
505
+ for task in d.get("schedule", []):
506
+ s = task.get("status", "pending")
507
+ if s == "completed":
508
+ total_completed += 1
509
+ elif s == "missed":
510
+ total_missed += 1
511
+ else:
512
+ total_pending += 1
513
+
514
+ # Calculate stars (1 star per 5 completed lessons)
515
+ stars = total_completed // 5
516
+
517
+ # Determine which trophies are unlocked
518
+ unlocked_trophies = []
519
+ for t in TROPHY_DEFINITIONS:
520
+ trophy_type = t.get("type", "lessons")
521
+ earned = False
522
+ if trophy_type == "streak":
523
+ earned = best_streak >= t["requirement"]
524
+ elif trophy_type == "topics":
525
+ earned = len(topics_studied) >= t["requirement"]
526
+ elif trophy_type == "points":
527
+ earned = total_points >= t["requirement"]
528
+ else: # lessons
529
+ earned = total_completed >= t["requirement"]
530
+
531
+ unlocked_trophies.append({
532
+ "id": t["id"],
533
+ "name": t["name"],
534
+ "desc": t["desc"],
535
+ "icon": t["icon"],
536
+ "unlocked": earned,
537
+ })
538
+
539
+ # Update best_streak if current is higher
540
+ if current_streak > best_streak:
541
+ best_streak = current_streak
542
+ db.collection("user_points").document(user_id).set(
543
+ {"best_streak": best_streak}, merge=True
544
+ )
545
+
546
+ return {
547
+ "user_id": user_id,
548
+ "total_points": total_points,
549
+ "stars": stars,
550
+ "current_streak": current_streak,
551
+ "best_streak": best_streak,
552
+ "total_completed": total_completed,
553
+ "total_missed": total_missed,
554
+ "total_pending": total_pending,
555
+ "total_plans": total_plans,
556
+ "topics_studied": list(topics_studied),
557
+ "trophies": unlocked_trophies,
558
+ }
559
 
560
 
561
  @app.get("/plan/{plan_id}")
 
631
  return {"message": "Plan deleted"}
632
 
633
 
634
+ # ── Quiz endpoints ───────────────────────────────────────────────────────────
635
+
636
+ @app.post("/generate-quiz")
637
+ async def generate_quiz(req: QuizRequest):
638
+ """Generate AI quiz questions for a topic."""
639
+ prompt = QUIZ_PROMPT_TEMPLATE.format(
640
+ topic=req.topic,
641
+ skill_level=req.skill_level,
642
+ num_questions=req.num_questions,
643
+ )
644
+ response = client.chat.completions.create(
645
+ model=LLM_MODEL,
646
+ messages=[{"role": "user", "content": prompt}],
647
+ )
648
+ quiz_data = parse_llm_json(response.choices[0].message.content)
649
+ questions = quiz_data.get("questions", quiz_data) if isinstance(quiz_data, dict) else quiz_data
650
+ # Validate
651
+ validated = []
652
+ for q in questions:
653
+ validated.append(QuizQuestion(**q).model_dump())
654
+ return {"topic": req.topic, "questions": validated}
655
+
656
+
657
+ @app.post("/submit-quiz")
658
+ async def submit_quiz(req: QuizSubmission):
659
+ """Submit quiz answers, calculate score, award bonus XP."""
660
+ doc = db.collection("learning_plans").document(req.plan_id).get()
661
+ if not doc.exists:
662
+ raise HTTPException(status_code=404, detail="Plan not found")
663
+
664
+ # Find the day's topic to fetch from quiz cache or regenerate
665
+ data = doc.to_dict()
666
+ schedule = data.get("schedule", [])
667
+ day_task = next((t for t in schedule if int(t["day"]) == req.day), None)
668
+ if not day_task:
669
+ raise HTTPException(status_code=400, detail="Day not found")
670
+
671
+ # Score calculation (we trust the client sends correct number of answers)
672
+ # Award bonus XP based on score
673
+ correct_count = len([a for a in req.answers if a >= 0]) # placeholder
674
+ score_percent = (correct_count / max(len(req.answers), 1)) * 100
675
+
676
+ # Award bonus XP for quiz completion
677
+ bonus_xp = 5 # base quiz completion bonus
678
+ if score_percent >= 80:
679
+ bonus_xp = 15 # excellence bonus
680
+ elif score_percent >= 60:
681
+ bonus_xp = 10 # good score bonus
682
+
683
+ # Update user points
684
+ points_ref = db.collection("user_points").document(req.user_id)
685
+ points_doc = points_ref.get()
686
+ if points_doc.exists:
687
+ current = points_doc.to_dict().get("total_points", 0)
688
+ points_ref.update({
689
+ "total_points": current + bonus_xp,
690
+ "updated_at": datetime.utcnow().isoformat(),
691
+ })
692
+ total = current + bonus_xp
693
+ else:
694
+ points_ref.set({
695
+ "user_id": req.user_id,
696
+ "total_points": bonus_xp,
697
+ "updated_at": datetime.utcnow().isoformat(),
698
+ })
699
+ total = bonus_xp
700
+
701
+ return {
702
+ "score_percent": score_percent,
703
+ "bonus_xp": bonus_xp,
704
+ "total_points": total,
705
+ "message": f"Quiz completed! +{bonus_xp} XP"
706
+ }
707
+
708
+
709
+ # ── Leaderboard endpoint ────────────────────────────────────────────────────
710
+
711
+ @app.get("/leaderboard")
712
+ async def get_leaderboard(limit: int = 20):
713
+ """Get top users by XP."""
714
+ docs = db.collection("user_points").stream()
715
+ users = []
716
+ for doc in docs:
717
+ d = doc.to_dict()
718
+ user_id = d.get("user_id", doc.id)
719
+
720
+ # Try to get display name from Firebase Auth
721
+ display_name = None
722
+ try:
723
+ from firebase_admin import auth
724
+ user_record = auth.get_user(user_id)
725
+ display_name = user_record.display_name or user_record.email
726
+ except Exception:
727
+ pass
728
+
729
+ if not display_name:
730
+ display_name = f"Learner_{user_id[:6]}"
731
+
732
+ users.append({
733
+ "user_id": user_id,
734
+ "display_name": display_name,
735
+ "total_points": d.get("total_points", 0),
736
+ "streak": d.get("streak", 0),
737
+ "best_streak": d.get("best_streak", 0),
738
+ })
739
+
740
+ # Sort by points descending
741
+ users.sort(key=lambda u: u["total_points"], reverse=True)
742
+ return {"leaderboard": users[:limit]}
743
+
744
+
745
+ # ── Analytics endpoint ───────────────────────────────────────────────────────
746
+
747
+ @app.get("/analytics/{user_id}")
748
+ async def get_analytics(user_id: str):
749
+ """Get learning analytics β€” daily completion rates, points history, etc."""
750
+ plans = db.collection("learning_plans").where("user_id", "==", user_id).stream()
751
+
752
+ daily_stats = [] # list of {day, status, topic, plan_topic}
753
+ plan_summaries = []
754
+ total_minutes_studied = 0
755
+
756
+ for doc in plans:
757
+ d = doc.to_dict()
758
+ plan_topic = d.get("topic", "Unknown")
759
+ schedule = d.get("schedule", [])
760
+ completed_count = 0
761
+ missed_count = 0
762
+
763
+ for task in schedule:
764
+ status = task.get("status", "pending")
765
+ if status == "completed":
766
+ completed_count += 1
767
+ total_minutes_studied += task.get("duration_mins", 0)
768
+ elif status == "missed":
769
+ missed_count += 1
770
+
771
+ daily_stats.append({
772
+ "day": task.get("day", 0),
773
+ "status": status,
774
+ "topic": task.get("topic", ""),
775
+ "plan_topic": plan_topic,
776
+ "duration_mins": task.get("duration_mins", 0),
777
+ })
778
+
779
+ plan_summaries.append({
780
+ "topic": plan_topic,
781
+ "total_days": len(schedule),
782
+ "completed": completed_count,
783
+ "missed": missed_count,
784
+ "pending": len(schedule) - completed_count - missed_count,
785
+ "completion_rate": round(completed_count / max(len(schedule), 1) * 100, 1),
786
+ })
787
+
788
+ # Overall stats
789
+ total_tasks = len(daily_stats)
790
+ total_completed = sum(1 for d in daily_stats if d["status"] == "completed")
791
+ total_missed = sum(1 for d in daily_stats if d["status"] == "missed")
792
+
793
+ return {
794
+ "total_tasks": total_tasks,
795
+ "total_completed": total_completed,
796
+ "total_missed": total_missed,
797
+ "total_pending": total_tasks - total_completed - total_missed,
798
+ "completion_rate": round(total_completed / max(total_tasks, 1) * 100, 1),
799
+ "total_minutes_studied": total_minutes_studied,
800
+ "plan_summaries": plan_summaries,
801
+ "daily_stats": daily_stats,
802
+ }
803
+
804
+
805
+ # ── Reminder settings endpoint ──────────────────────────────────────────────
806
+
807
+ @app.post("/reminder-settings")
808
+ async def save_reminder_settings(req: ReminderSettings):
809
+ """Save user's reminder preferences."""
810
+ db.collection("reminder_settings").document(req.user_id).set({
811
+ "user_id": req.user_id,
812
+ "enabled": req.enabled,
813
+ "hour": req.hour,
814
+ "minute": req.minute,
815
+ "updated_at": datetime.utcnow().isoformat(),
816
+ })
817
+ return {"message": "Reminder settings saved"}
818
+
819
+
820
+ @app.get("/reminder-settings/{user_id}")
821
+ async def get_reminder_settings(user_id: str):
822
+ doc = db.collection("reminder_settings").document(user_id).get()
823
+ if not doc.exists:
824
+ return {"enabled": True, "hour": 9, "minute": 0}
825
+ d = doc.to_dict()
826
+ return {"enabled": d.get("enabled", True), "hour": d.get("hour", 9), "minute": d.get("minute", 0)}
827
+
828
+
829
  if __name__ == "__main__":
830
  import uvicorn
831
  uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)