lanna_lalala;- commited on
Commit
08e67e4
·
1 Parent(s): bdbcf73

feat: import backend code (clean history)

Browse files
phase/Student_view/chatbot.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import datetime
3
+ import os
4
+ from openai import OpenAI
5
+ from dotenv import load_dotenv
6
+
7
+ # Load environment variables from .env file if present
8
+ load_dotenv()
9
+
10
+ # Ensure OPENAI_API_KEY is set
11
+ api_key = os.getenv("OPENAI_API_KEY")
12
+ if not api_key:
13
+ st.error("⚠️ Please set the OPENAI_API_KEY environment variable or .env file before running the app.")
14
+
15
+ client = OpenAI(api_key=api_key)
16
+
17
+ # --- Helper to add message ---
18
+ def add_message(text: str, sender: str):
19
+ st.session_state.messages.append(
20
+ {
21
+ "id": str(datetime.datetime.now().timestamp()),
22
+ "text": text,
23
+ "sender": sender,
24
+ "timestamp": datetime.datetime.now()
25
+ }
26
+ )
27
+
28
+ def show_page():
29
+ st.title("🤖 AI Financial Tutor")
30
+ st.caption("Get personalized help with your financial questions")
31
+
32
+ # --- Initialize state ---
33
+ if "messages" not in st.session_state:
34
+ st.session_state.messages = [
35
+ {
36
+ "id": "1",
37
+ "text": "Hi! I'm your AI Financial Tutor. I'm here to help you learn about personal finance, investing, budgeting, and more. What would you like to know?",
38
+ "sender": "assistant",
39
+ "timestamp": datetime.datetime.now()
40
+ }
41
+ ]
42
+ if "is_typing" not in st.session_state:
43
+ st.session_state.is_typing = False
44
+
45
+ # --- Chat Container ---
46
+ chat_container = st.container()
47
+ with chat_container:
48
+ for msg in st.session_state.messages:
49
+ if msg["sender"] == "assistant":
50
+ st.markdown(f"<div style='background-color:#e0e0e0; color:black; padding:10px; border-radius:12px; max-width:70%; margin-bottom:5px;'>{msg['text']}<br><sub>{msg['timestamp'].strftime('%H:%M')}</sub></div>", unsafe_allow_html=True)
51
+ else:
52
+ st.markdown(f"<div style='background-color:#4CAF50; color:white; padding:10px; border-radius:12px; max-width:70%; margin-left:auto; margin-bottom:5px;'>{msg['text']}<br><sub>{msg['timestamp'].strftime('%H:%M')}</sub></div>", unsafe_allow_html=True)
53
+
54
+ if st.session_state.is_typing:
55
+ st.markdown("🤖 _FinanceBot is typing..._")
56
+
57
+ # --- Quick Questions ---
58
+ if len(st.session_state.messages) == 1:
59
+ st.markdown("Try asking about:")
60
+ cols = st.columns(2)
61
+ quick_questions = [
62
+ "How does compound interest work?",
63
+ "How much should I save for emergencies?",
64
+ "What's a good budgeting strategy?",
65
+ "How do I start investing?"
66
+ ]
67
+ for i, q in enumerate(quick_questions):
68
+ if cols[i % 2].button(q):
69
+ add_message(q, "user")
70
+ st.session_state.is_typing = True
71
+ st.rerun()
72
+
73
+ # --- Input Box ---
74
+ user_input = st.chat_input("Ask me anything about personal finance...")
75
+ if user_input:
76
+ add_message(user_input, "user")
77
+ st.session_state.is_typing = True
78
+ st.rerun()
79
+
80
+ # --- Call OpenAI API directly ---
81
+ if st.session_state.is_typing:
82
+ try:
83
+ with st.spinner("FinanceBot is thinking..."):
84
+ messages_payload = [
85
+ {"role": m['sender'], "content": m['text']} for m in st.session_state.messages
86
+ ]
87
+ # Ensure role is either 'user' or 'assistant'
88
+ for m in messages_payload:
89
+ if m['role'] not in ['user', 'assistant']:
90
+ m['role'] = 'user'
91
+
92
+ response = client.chat.completions.create(
93
+ model="gpt-4o-mini",
94
+ messages=messages_payload
95
+ )
96
+ bot_reply = response.choices[0].message.content
97
+ add_message(bot_reply, "assistant")
98
+ except Exception as e:
99
+ add_message(f"⚠️ Error: {e}", "assistant")
100
+ finally:
101
+ st.session_state.is_typing = False
102
+ st.rerun()
103
+
104
+ # --- Navigation Button ---
105
+ if st.button("Back to Dashboard", key="ai_tutor_back_btn"):
106
+ st.session_state.current_page = "Student Dashboard"
107
+ st.rerun()
phase/Student_view/game.py ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ from utils import db as dbapi
3
+ import os
4
+
5
+
6
+ # --- Load external CSS (optional) ---
7
+ def load_css(file_name: str):
8
+ try:
9
+ with open(file_name, "r", encoding="utf-8") as f:
10
+ st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
11
+ except FileNotFoundError:
12
+ st.warning("⚠️ Stylesheet not found. Please ensure 'assets/styles.css' exists.")
13
+
14
+
15
+ st.session_state.setdefault("current_game", None)
16
+
17
+
18
+ # --- GAME RENDERERS ---
19
+ def _render_budget_builder():
20
+ try:
21
+ from phase.Student_view.games import budgetbuilder as budget_module
22
+ except Exception as e:
23
+ st.error(f"Couldn't import Budget Builder module: {e}")
24
+ return
25
+
26
+ if hasattr(budget_module, "show_budget_builder"):
27
+ budget_module.show_budget_builder()
28
+ elif hasattr(budget_module, "show_page"):
29
+ budget_module.show_page()
30
+ else:
31
+ st.error("Budget Builder module found, but no show function (show_budget_builder/show_page).")
32
+
33
+ def _render_debt_dilemma():
34
+ try:
35
+ from phase.Student_view.games import debtdilemma as debt_module
36
+ except Exception as e:
37
+ st.error(f"Couldn't import Debt Dilemma module: {e}")
38
+ return
39
+
40
+ if hasattr(debt_module, "show_debt_dilemma"):
41
+ debt_module.show_debt_dilemma()
42
+ elif hasattr(debt_module, "show_page"):
43
+ debt_module.show_page()
44
+ else:
45
+ st.error("Debt Dilemma module found, but no show function (show_debt_dilemma/show_page).")
46
+
47
+ def _render_money_match():
48
+ """
49
+ Renders Money Match if the file exists at phase/games/MoneyMatch.py
50
+ and exposes a show_page() function.
51
+ """
52
+ try:
53
+
54
+ from phase.Student_view.games import MoneyMatch as mm_module
55
+ except Exception as e:
56
+ st.error(f"Couldn't import Money Match module: {e}")
57
+ st.info("Tip: ensure phase/games/MoneyMatch.py exists and defines show_page()")
58
+ return
59
+
60
+ if hasattr(mm_module, "show_page"):
61
+ mm_module.show_page()
62
+ else:
63
+ st.error("Money Match module found, but no show_page() function.")
64
+
65
+ #render for profit puzzle
66
+ def _render_profit_puzzle():
67
+ try:
68
+ from phase.Student_view.games import profitpuzzle as pp_module
69
+ except Exception as e:
70
+ st.error(f"Couldn't import Profit Puzzle module: {e}")
71
+ return
72
+
73
+ if hasattr(pp_module, "show_profit_puzzle"):
74
+ pp_module.show_profit_puzzle()
75
+ elif hasattr(pp_module, "show_page"):
76
+ pp_module.show_page()
77
+ else:
78
+ st.error("Profit Puzzle module found, but no show function (show_profit_puzzle/show_page).")
79
+
80
+
81
+ import textwrap
82
+
83
+ def render_leaderboard(leaderboard):
84
+ def rank_symbol(rank):
85
+ if rank == "You":
86
+ return "🟢"
87
+ if isinstance(rank, int):
88
+ return "🥇" if rank == 1 else "🥈" if rank == 2 else "🥉" if rank == 3 else f"#{rank}"
89
+ return str(rank)
90
+
91
+ def rank_medal_class(rank):
92
+ if isinstance(rank, int) and rank in (1, 2, 3):
93
+ return f"medal-{rank}"
94
+ return ""
95
+
96
+ rows = []
97
+ head = '<div class="lb-head">🏆 Leaderboard</div>'
98
+ for p in leaderboard:
99
+ is_you = p["rank"] == "You"
100
+ medal_cls = rank_medal_class(p["rank"])
101
+ symbol = rank_symbol(p["rank"])
102
+ you_pill = '<span class="lb-you-pill">YOU</span>' if is_you else ""
103
+ rows.append(
104
+ textwrap.dedent(f"""
105
+ <div class="lb-row {'is-you' if is_you else ''}">
106
+ <div class="lb-rank {medal_cls}">{symbol}</div>
107
+ <div class="lb-name">{p['name']}</div>
108
+ <div class="lb-level">Lvl {p['level']}</div>
109
+ <div class="lb-xp">{p['xp']:,} XP</div>
110
+ {you_pill}
111
+ </div>
112
+ """).strip()
113
+ )
114
+
115
+ html = textwrap.dedent(f"""
116
+ <div class="leaderboard">
117
+ {head}
118
+ {''.join(rows)}
119
+ </div>
120
+ """).strip()
121
+
122
+ st.markdown(html, unsafe_allow_html=True)
123
+
124
+ def _load_leaderboard(user_id: int, limit: int = 10) -> list[dict]:
125
+ """
126
+ Shape returned fits render_leaderboard:
127
+ [{"rank": int|"You", "name": str, "level": int, "xp": int, "user_id": int}, ...]
128
+ Prefers class leaderboard if current_class_id is set; else global.
129
+ Ensures current user is included and labeled "You".
130
+ """
131
+ you_name = (st.session_state.get("user") or {}).get("name") or "You"
132
+ class_id = st.session_state.get("current_class_id")
133
+
134
+ # try to pick a class automatically if none set (optional)
135
+ if not class_id and hasattr(dbapi, "list_classes_for_student"):
136
+ try:
137
+ classes = dbapi.list_classes_for_student(user_id) or []
138
+ if classes:
139
+ class_id = classes[0]["class_id"]
140
+ st.session_state.current_class_id = class_id
141
+ except Exception:
142
+ pass
143
+
144
+ # fetch rows from DB
145
+ try:
146
+ if class_id and hasattr(dbapi, "leaderboard_for_class"):
147
+ rows = dbapi.leaderboard_for_class(class_id, limit=limit) or []
148
+ elif hasattr(dbapi, "leaderboard_global"):
149
+ rows = dbapi.leaderboard_global(limit=limit) or []
150
+ else:
151
+ # fallback using existing function if needed
152
+ rows = []
153
+ if class_id and hasattr(dbapi, "class_student_metrics"):
154
+ for m in dbapi.class_student_metrics(class_id) or []:
155
+ xp = int(m.get("total_xp", 0))
156
+ rows.append({
157
+ "user_id": m.get("student_id"),
158
+ "name": m.get("name") or m.get("email") or "Student",
159
+ "xp": xp,
160
+ "level": dbapi.level_from_xp(xp),
161
+ })
162
+ except Exception as e:
163
+ st.warning(f"Leaderboard error: {e}")
164
+ rows = []
165
+
166
+ # ensure current user present
167
+ if not any(r.get("user_id") == user_id for r in rows):
168
+ try:
169
+ stats = dbapi.user_xp_and_level(user_id) or {}
170
+ rows.append({
171
+ "user_id": user_id,
172
+ "name": you_name,
173
+ "xp": int(stats.get("xp", 0)),
174
+ "level": int(stats.get("level", 1)),
175
+ })
176
+ except Exception:
177
+ rows.append({"user_id": user_id, "name": you_name, "xp": 0, "level": 1})
178
+
179
+ # sort, rank, and mark "You"
180
+ rows.sort(key=lambda r: int(r.get("xp", 0)), reverse=True)
181
+ ranked = []
182
+ for i, r in enumerate(rows, start=1):
183
+ ranked.append({
184
+ "rank": i,
185
+ "user_id": r["user_id"],
186
+ "name": r["name"],
187
+ "level": int(r["level"]),
188
+ "xp": int(r["xp"]),
189
+ })
190
+ for r in ranked:
191
+ if r["user_id"] == user_id:
192
+ r["rank"] = "You"
193
+ break
194
+
195
+ # "You" first visually, then others by rank
196
+ you = [r for r in ranked if r["rank"] == "You"]
197
+ others = [r for r in ranked if r["rank"] != "You"]
198
+ return (you + others)[:limit]
199
+
200
+
201
+ # --- MAIN GAMES HUB & ROUTER ---
202
+ def show_games():
203
+ load_css(os.path.join("assets", "styles.css"))
204
+
205
+ if "user" not in st.session_state or st.session_state.user is None:
206
+ st.error("❌ Please login first.")
207
+ st.session_state.current_page = "Welcome"
208
+ st.rerun()
209
+
210
+ game_key = st.session_state.current_game
211
+
212
+ # If a specific game is active → render it
213
+ if game_key is not None:
214
+ if game_key == "budget_builder":
215
+ _render_budget_builder()
216
+ elif game_key == "money_match":
217
+ _render_money_match()
218
+ elif game_key == "debt_dilemma":
219
+ _render_debt_dilemma()
220
+ elif game_key == "profit_puzzle":
221
+ _render_profit_puzzle()
222
+
223
+ st.markdown("---")
224
+ if st.button("⬅ Back to Games Hub"):
225
+ st.session_state.current_game = None
226
+ st.rerun()
227
+ return # don’t render the hub
228
+
229
+ # ===== Games Hub =====
230
+ st.title("Financial Games")
231
+ st.subheader("Learn by playing! Master financial concepts through interactive games.")
232
+
233
+ # Progress overview
234
+ col1, col2 = st.columns([1, 5])
235
+ with col1:
236
+ st.markdown(
237
+ """
238
+ <div style="
239
+ width:50px; height:50px;
240
+ border-radius:15px;
241
+ background: linear-gradient(135deg, #22c55e, #059669);
242
+ display:flex; align-items:center; justify-content:center;
243
+ font-size:28px;
244
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
245
+ ">
246
+
247
+ </div>
248
+ """,
249
+ unsafe_allow_html=True
250
+ )
251
+
252
+ with col2:
253
+ # pull live XP/level from DB
254
+ user_id = st.session_state.user["user_id"]
255
+ try:
256
+ stats = dbapi.user_xp_and_level(user_id) # {'xp': int, 'level': int, 'streak': int}
257
+ total_xp = int(stats.get("xp", 0))
258
+ level = int(stats.get("level", 1))
259
+ # keep session in sync (optional)
260
+ st.session_state.xp = total_xp
261
+ st.session_state.streak = stats.get("streak", 0)
262
+ except Exception:
263
+ # safe fallback if DB hiccups
264
+ total_xp = int(st.session_state.get("xp", 0))
265
+ level = max(1, (total_xp // 500) + 1)
266
+
267
+ # leveling curve: every 500 XP is a level
268
+ level_floor = (level - 1) * 500 # XP at start of this level
269
+ level_cap = level * 500 # XP needed to reach next level
270
+ in_level_xp = max(0, total_xp - level_floor)
271
+ span = max(1, level_cap - level_floor) # usually 500
272
+ progress_pct = int((in_level_xp / span) * 100)
273
+
274
+ st.write(f"Level {level} Experience Points")
275
+
276
+ # progress bar shows progress within *current* level
277
+ st.markdown(f"""
278
+ <div style="background:#e0e0e0;border-radius:12px;padding:3px;width:100%;">
279
+ <div style="
280
+ width:{progress_pct}%;
281
+ background:linear-gradient(135deg,#22c55e,#059669);
282
+ height:24px;border-radius:10px;text-align:right;
283
+ color:white;font-weight:bold;padding-right:8px;line-height:24px;">
284
+ {total_xp:,} / {level_cap:,} XP
285
+ </div>
286
+ </div>
287
+ """, unsafe_allow_html=True)
288
+
289
+
290
+
291
+ st.markdown("---")
292
+
293
+ # Game list
294
+ games = [
295
+ {"key": "money_match", "icon": "💰", "title": "Money Match",
296
+ "description": "Drag coins and notes to match target values. Perfect for learning denominations!",
297
+ "difficulty": "Easy", "xp": "+10 XP", "time": "5-10 min", "color": "linear-gradient(135deg, #22c55e, #059669)"},
298
+ {"key": "budget_builder", "icon": "📊", "title": "Budget Builder",
299
+ "description": "Allocate your weekly allowance across different spending categories with real-time pie charts.",
300
+ "difficulty": "Easy", "xp": "+50 XP", "time": "10-15 min", "color": "linear-gradient(135deg, #3b82f6, #06b6d4)"},
301
+ {"key": "profit_puzzle", "icon": "🧩", "title": "Profit Puzzle",
302
+ "description": "Solve business scenarios with interactive sliders. Calculate profit, revenue, and costs!",
303
+ "difficulty": "Medium", "xp": "+150 XP", "time": "10-15 min", "color": "linear-gradient(135deg, #6366f1, #8b5cf6)"},
304
+ {"key": "debt_dilemma", "icon": "⚠️", "title": "Debt Dilemma",
305
+ "description": "Experience borrowing JA$100 and learn about interest, repayment schedules, and credit scores.",
306
+ "difficulty": "Hard", "xp": "+200 XP", "time": "20-25 min", "color": "linear-gradient(135deg, #f97316, #dc2626)"},
307
+ ]
308
+
309
+ cols = st.columns(2)
310
+ color_map = {"Easy": "green", "Medium": "orange", "Hard": "red"}
311
+
312
+ for i, g in enumerate(games):
313
+ with cols[i % 2]:
314
+ st.markdown(
315
+ f"""
316
+ <div style="
317
+ width:60px; height:60px;
318
+ border-radius:16px;
319
+ background:{g['color']};
320
+ display:flex; align-items:center; justify-content:center;
321
+ font-size:28px; margin-bottom:10px;
322
+ box-shadow: 0 4px 6px rgba(0,0,0,0.1);
323
+ ">
324
+ {g['icon']}
325
+ </div>
326
+ """,
327
+ unsafe_allow_html=True
328
+ )
329
+ st.subheader(g["title"])
330
+ st.write(g["description"])
331
+ diff_color = color_map.get(g["difficulty"], "gray")
332
+ st.markdown(
333
+ f"<div style='color:{diff_color}; font-weight:bold'>{g['difficulty']}</div> | "
334
+ f"{g['xp']} | {g['time']}",
335
+ unsafe_allow_html=True
336
+ )
337
+ if st.button("▶ Play Now", key=f"play_{g['key']}"):
338
+ st.session_state.current_game = g["key"]
339
+ st.rerun()
340
+
341
+
342
+ st.markdown("---")
343
+
344
+ # Leaderboard & Tips
345
+ col_leader, col_tips = st.columns(2)
346
+ with col_leader:
347
+ user_id = st.session_state.user["user_id"]
348
+ lb = _load_leaderboard(user_id, limit=10)
349
+ if lb:
350
+ render_leaderboard(lb)
351
+ else:
352
+ st.info("No leaderboard data yet.")
353
+
354
+
355
+
356
+
357
+ with col_tips:
358
+ st.subheader("Game Tips")
359
+ for tip in [
360
+ "🌟 Start with easier games to build confidence",
361
+ "⏰ Take your time to understand concepts",
362
+ "🏆 Replay games to improve your score",
363
+ "🌍 Apply game lessons to real life",
364
+ ]:
365
+ st.markdown(f"- {tip}")
phase/Student_view/games/MoneyMatch.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # phase\Student_view\games\MoneyMatch.py
2
+
3
+ import time
4
+ import os
5
+ import random
6
+ from pathlib import Path
7
+ import streamlit as st
8
+ from utils import db as dbapi
9
+ import time
10
+ from utils import db as db_util
11
+
12
+ # ---------- paths ----------
13
+ PROJECT_ROOT = Path(__file__).resolve().parents[3]
14
+
15
+ def _asset(*parts: str) -> str:
16
+ # JMD image path
17
+ return str((PROJECT_ROOT / "assets" / "images" / Path(*parts)).resolve())
18
+
19
+ def _safe_image(path: str, *, caption: str = ""):
20
+ if not os.path.exists(path):
21
+ st.warning(f"Image not found: {Path(path).name}. Button still works.")
22
+ return False
23
+ st.image(path, use_column_width=True, caption=caption)
24
+ return True
25
+
26
+ # ---------- state helpers ----------
27
+ def _init_state():
28
+ ss = st.session_state
29
+ if "mm_level" not in ss: ss.mm_level = 1
30
+ if "mm_xp" not in ss: ss.mm_xp = 0
31
+ if "mm_matches" not in ss: ss.mm_matches = 0
32
+ if "mm_target" not in ss: ss.mm_target = random.randint(7, 10000) # randon goal generator
33
+ if "mm_selected" not in ss: ss.mm_selected = []
34
+ if "mm_total" not in ss: ss.mm_total = 0
35
+ if "mm_start_ts" not in ss: ss.mm_start_ts = time.perf_counter()
36
+ if "mm_saved" not in ss: ss.mm_saved = False
37
+
38
+ def _reset_round(new_target: int | None = None):
39
+ ss = st.session_state
40
+ ss.mm_selected = []
41
+ ss.mm_total = 0
42
+ ss.mm_target = new_target if new_target is not None else random.randint(7, 10000)
43
+ ss.mm_start_ts = time.perf_counter()
44
+ ss.mm_saved = False
45
+
46
+ def _award_xp(gained: int):
47
+ ss = st.session_state
48
+ ss.mm_xp += gained
49
+ ss.mm_matches += 1
50
+ while ss.mm_xp >= ss.mm_level * 100:
51
+ ss.mm_level += 1
52
+
53
+ def _persist_success(gained_xp: int):
54
+ user = st.session_state.get("user")
55
+ if not user or "user_id" not in user:
56
+ st.error("Not saving. No logged-in user_id in session.")
57
+ return
58
+ elapsed_ms = int((time.perf_counter() - st.session_state.mm_start_ts) * 1000)
59
+ try:
60
+ dbapi.record_money_match_play(
61
+ user["user_id"],
62
+ target=int(st.session_state.mm_target),
63
+ total=int(st.session_state.mm_total),
64
+ elapsed_ms=elapsed_ms,
65
+ matched=True,
66
+ gained_xp=int(gained_xp),
67
+ )
68
+ st.toast(f"Saved to TiDB +{gained_xp} XP")
69
+ except Exception as e:
70
+ st.error(f"Save failed: {e}")
71
+
72
+ # --- CSS injection (run every render) ---
73
+ def _inject_css():
74
+ css_path = PROJECT_ROOT / "assets" / "styles.css"
75
+ try:
76
+ css = css_path.read_text(encoding="utf-8")
77
+ st.markdown(f"<style>{css}</style>", unsafe_allow_html=True)
78
+ except Exception:
79
+ # don't crash the page because of styling
80
+ pass
81
+
82
+
83
+ # ---------- denominations ----------
84
+ DENOMS = [
85
+ ("JA$1", 1, _asset("jmd", "jmd_1.jpeg")),
86
+ ("JA$5", 5, _asset("jmd", "jmd_5.jpeg")),
87
+ ("JA$10", 10, _asset("jmd", "jmd_10.jpeg")),
88
+ ("JA$20", 20, _asset("jmd", "jmd_20.jpeg")),
89
+ ("JA$50", 50, _asset("jmd", "jmd_50.jpg")),
90
+ ("JA$100", 100, _asset("jmd", "jmd_100.jpg")),
91
+ ("JA$500", 500, _asset("jmd", "jmd_500.jpg")),
92
+ ("JA$1000", 1000, _asset("jmd", "jmd_1000.jpeg")),
93
+ ("JA$2000", 2000, _asset("jmd", "jmd_2000.jpeg")),
94
+ ("JA$5000", 5000, _asset("jmd", "jmd_5000.jpeg")),
95
+ ]
96
+
97
+ # ---------- main ----------
98
+ def show_page():
99
+ _init_state()
100
+ _inject_css() # <- keep this here so it runs on every rerun
101
+ ss = st.session_state
102
+
103
+
104
+ if st.button("← Back to Games"):
105
+ ss.current_game = None
106
+ st.rerun()
107
+
108
+ st.title("Money Match Challenge")
109
+
110
+ left, right = st.columns([1.75, 1])
111
+
112
+ with left:
113
+ st.markdown('<div class="mm-card">', unsafe_allow_html=True)
114
+ st.markdown(f"<h3>Target: <span class='mm-target'>JA${ss.mm_target}</span></h3>", unsafe_allow_html=True)
115
+ st.markdown(f"<div class='mm-total'>JA${ss.mm_total}</div>", unsafe_allow_html=True)
116
+
117
+ ratio = min(ss.mm_total / ss.mm_target, 1.0) if ss.mm_target else 0
118
+ st.progress(ratio)
119
+
120
+ diff = ss.mm_target - ss.mm_total
121
+ need_text = "Perfect match. Click Next round." if diff == 0 else (f"Need JA${diff} more" if diff > 0 else f"Overshot by JA${abs(diff)}")
122
+ st.caption(need_text)
123
+
124
+ # autosave stats if perfect match
125
+ if diff == 0 and not ss.mm_saved:
126
+ gained = 10
127
+ _persist_success(gained)
128
+ _award_xp(gained)
129
+ ss.mm_saved = True
130
+
131
+ # tray
132
+ if ss.mm_selected:
133
+ chips = " ".join([f"<span class='mm-chip'>${v}</span>" for v in ss.mm_selected])
134
+ st.markdown(f"<div class='mm-tray'>{chips}</div>", unsafe_allow_html=True)
135
+ else:
136
+ st.markdown("<div class='mm-tray mm-empty'>Selected money will appear here</div>", unsafe_allow_html=True)
137
+
138
+ c1, c2 = st.columns([1,1])
139
+ with c1:
140
+ if st.button("⟲ Reset"):
141
+ _reset_round(ss.mm_target)
142
+ st.rerun()
143
+ with c2:
144
+ if ss.mm_total == ss.mm_target:
145
+ if st.button("Next round ▶"):
146
+ gained = 10
147
+ # avoid double insert when autosave
148
+ if not ss.mm_saved:
149
+ _persist_success(gained)
150
+ _award_xp(gained)
151
+ _reset_round()
152
+ st.rerun()
153
+
154
+ st.markdown("</div>", unsafe_allow_html=True)
155
+
156
+ # Money Collection
157
+ st.markdown("<h4>Money Collection</h4>", unsafe_allow_html=True)
158
+ grid_cols = st.columns(4)
159
+ for i, (label, value, img) in enumerate(DENOMS):
160
+ with grid_cols[i % 4]:
161
+ _safe_image(img, caption=label)
162
+ if st.button(label, key=f"mm_add_{value}"):
163
+ ss.mm_selected.append(value)
164
+ ss.mm_total += value
165
+ st.rerun()
166
+
167
+ with right:
168
+ st.markdown(
169
+ f"""
170
+ <div class="mm-side-card">
171
+ <h4>🏆 Stats</h4>
172
+ <div class="mm-metric"><span>Current Level</span><b>{ss.mm_level}</b></div>
173
+ <div class="mm-metric"><span>Total XP</span><b>{ss.mm_xp}</b></div>
174
+ <div class="mm-metric"><span>Matches Made</span><b>{ss.mm_matches}</b></div>
175
+ </div>
176
+ """,
177
+ unsafe_allow_html=True,
178
+ )
179
+ st.markdown(
180
+ """
181
+ <div class="mm-side-card">
182
+ <h4>How to Play</h4>
183
+ <ol class="mm-howto">
184
+ <li>Look at the target amount</li>
185
+ <li>Click coins and notes to add them</li>
186
+ <li>Match the target exactly to earn XP</li>
187
+ <li>Level up with each successful match</li>
188
+ </ol>
189
+ </div>
190
+ """,
191
+ unsafe_allow_html=True,
192
+ )
phase/Student_view/games/budgetbuilder.py ADDED
@@ -0,0 +1,489 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # phase\Student_view\games\budgetbuilder.py
2
+
3
+ import streamlit as st
4
+ from utils import db as dbapi
5
+ import matplotlib.pyplot as plt
6
+
7
+ def show_budget_builder():
8
+ # Add custom CSS for improved styling
9
+ st.markdown("""
10
+ <style>
11
+ /* Main container styling */
12
+ .main .block-container {
13
+ padding-top: 2rem;
14
+ padding-bottom: 2rem;
15
+ max-width: 1200px;
16
+ }
17
+
18
+ /* Card-like styling for sections */
19
+ .budget-card {
20
+ background: white;
21
+ border-radius: 12px;
22
+ padding: 1.5rem;
23
+ box-shadow: 0 2px 8px rgba(0,0,0,0.1);
24
+ margin-bottom: 1.5rem;
25
+ border: 1px solid #e5e7eb;
26
+ }
27
+
28
+ /* Header styling */
29
+ .main h1 {
30
+ color: #1f2937;
31
+ font-weight: 700;
32
+ margin-bottom: 0.5rem;
33
+ }
34
+
35
+ .main h2 {
36
+ color: #374151;
37
+ font-weight: 600;
38
+ margin-bottom: 1rem;
39
+ font-size: 1.5rem;
40
+ }
41
+
42
+ .main h3 {
43
+ color: #4b5563;
44
+ font-weight: 600;
45
+ margin-bottom: 0.75rem;
46
+ font-size: 1.25rem;
47
+ }
48
+
49
+ /* Slider styling improvements */
50
+ .stSlider > div > div > div > div {
51
+ background-color: #f3f4f6;
52
+ border-radius: 8px;
53
+ }
54
+
55
+ /* Updated button styling with specific colors for Check Budget (green) and Reset (gray) */
56
+ .stButton > button {
57
+ border-radius: 8px;
58
+ border: none;
59
+ font-weight: 600;
60
+ padding: 0.5rem 1rem;
61
+ transition: all 0.2s;
62
+ }
63
+
64
+ .stButton > button:hover {
65
+ transform: translateY(-1px);
66
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
67
+ }
68
+
69
+ /* Green button for Check Budget */
70
+ .stButton > button[kind="primary"] {
71
+ background-color: #10b981;
72
+ color: white;
73
+ }
74
+
75
+ .stButton > button[kind="primary"]:hover {
76
+ background-color: #059669;
77
+ }
78
+
79
+ /* Gray button for Reset */
80
+ .stButton > button[kind="secondary"] {
81
+ background-color: white;
82
+ color: black;
83
+ }
84
+
85
+ .stButton > button[kind="secondary"]:hover {
86
+ background-color: #f3f4f6;
87
+ }
88
+
89
+ /* Success/Error message styling */
90
+ .stSuccess {
91
+ border-radius: 8px;
92
+ border-left: 4px solid #10b981;
93
+ }
94
+
95
+ .stError {
96
+ border-radius: 8px;
97
+ border-left: 4px solid #ef4444;
98
+ }
99
+
100
+ /* Metric styling */
101
+ .metric-container {
102
+ background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
103
+ border-radius: 12px;
104
+ padding: 1rem;
105
+ border: 1px solid #e2e8f0;
106
+ text-align: center;
107
+ margin-bottom: 1rem;
108
+ }
109
+
110
+ /* Table styling */
111
+ .stTable {
112
+ border-radius: 8px;
113
+ overflow: hidden;
114
+ border: 1px solid #e5e7eb;
115
+ }
116
+
117
+ /* Progress bar styling */
118
+ .stProgress > div > div > div {
119
+ background-color: #10b981;
120
+ border-radius: 4px;
121
+ }
122
+
123
+ /* Info box styling */
124
+ .stInfo {
125
+ border-radius: 8px;
126
+ border-left: 4px solid #3b82f6;
127
+ background-color: #eff6ff;
128
+ }
129
+ </style>
130
+ """, unsafe_allow_html=True)
131
+
132
+ # -----------------------------
133
+ # Define Levels and Categories
134
+ # -----------------------------
135
+ levels = [
136
+ {
137
+ "id": 1,
138
+ "title": "First Budget",
139
+ "description": "Learn basic budget allocation",
140
+ "scenario": "You're 14 and just started getting a weekly allowance. Your parents want to see you can manage money responsibly before increasing it.",
141
+ "income": 300,
142
+ "objectives": [
143
+ "Save at least 20% of your income",
144
+ "Don't spend more than 30% on entertainment",
145
+ "Allocate money for food and transport"
146
+ ],
147
+ "constraints": {
148
+ "savings": {"min": 60},
149
+ "fun": {"max": 90},
150
+ "food": {"min": 40, "required": True},
151
+ "transport": {"min": 30, "required": True},
152
+ },
153
+ "success": [
154
+ ("Save at least JA$60 (20%)", lambda cats, inc: cats["savings"] >= 60),
155
+ ("Keep entertainment under JA$90 (30%)", lambda cats, inc: cats["fun"] <= 90),
156
+ ("Balance your budget completely", lambda cats, inc: sum(cats.values()) == inc),
157
+ ],
158
+ "xp": 20,
159
+ },
160
+ {
161
+ "id": 2,
162
+ "title": "Emergency Fund",
163
+ "description": "Build an emergency fund while managing expenses",
164
+ "scenario": "Your phone broke last month and you had no savings to fix it. This time, build an emergency fund while still enjoying life.",
165
+ "income": 400,
166
+ "objectives": [
167
+ "Build an emergency fund (JA$100+)",
168
+ "Still save for long-term goals",
169
+ "Cover all essential expenses",
170
+ ],
171
+ "constraints": {
172
+ "savings": {"min": 150}, # Emergency + regular savings
173
+ "food": {"min": 60, "required": True},
174
+ "transport": {"min": 40, "required": True},
175
+ "school": {"min": 20, "required": True},
176
+ },
177
+ "success": [
178
+ ("Save at least JA$150 total", lambda cats, inc: cats["savings"] >= 150),
179
+ (
180
+ "Cover all essential expenses",
181
+ lambda cats, inc: cats["food"] >= 60
182
+ and cats["transport"] >= 40
183
+ and cats["school"] >= 20,
184
+ ),
185
+ ],
186
+ "xp": 30,
187
+ },
188
+ {
189
+ "id": 3,
190
+ "title": "Reduced Income",
191
+ "description": "Manage when money is tight",
192
+ "scenario": "Your allowance got cut because of family finances. You need to make tough choices while still maintaining your savings habit.",
193
+ "income": 250,
194
+ "objectives": [
195
+ "Still save something (minimum JA$25)",
196
+ "Cut non-essential spending",
197
+ "Maintain essential expenses",
198
+ ],
199
+ "constraints": {
200
+ "savings": {"min": 25},
201
+ "fun": {"max": 40},
202
+ "food": {"min": 50, "required": True},
203
+ "transport": {"min": 35, "required": True},
204
+ },
205
+ "success": [
206
+ ("Save at least JA$25 (10%)", lambda cats, inc: cats["savings"] >= 25),
207
+ ("Keep entertainment under JA$40", lambda cats, inc: cats["fun"] <= 40),
208
+ ("Balance your budget", lambda cats, inc: sum(cats.values()) == inc),
209
+ ],
210
+ "xp": 35,
211
+ },
212
+ {
213
+ "id": 4,
214
+ "title": "Debt & Goals",
215
+ "description": "Pay off debt while saving for something special",
216
+ "scenario": "You borrowed JA$100 from your parents for a school trip. Now you need to pay it back (JA$25/week) while saving for a new game console.",
217
+ "income": 450,
218
+ "objectives": [
219
+ "Pay debt installment (JA$25)",
220
+ "Save for console (JA$50+ per week)",
221
+ "Don't compromise on essentials",
222
+ ],
223
+ "constraints": {
224
+ "savings": {"min": 75}, # 50 for console + 25 debt payment
225
+ "food": {"min": 70, "required": True},
226
+ "transport": {"min": 45, "required": True},
227
+ "school": {"min": 30, "required": True},
228
+ },
229
+ "success": [
230
+ ("Allocate JA$75+ for savings & debt", lambda cats, inc: cats["savings"] >= 75),
231
+ (
232
+ "Cover all essentials adequately",
233
+ lambda cats, inc: cats["food"] >= 70
234
+ and cats["transport"] >= 45
235
+ and cats["school"] >= 30,
236
+ ),
237
+ ],
238
+ "xp": 40,
239
+ },
240
+ {
241
+ "id": 5,
242
+ "title": "Master Budgeter",
243
+ "description": "Handle multiple financial goals like an adult",
244
+ "scenario": "You're 16 now with part-time job income. Manage multiple goals: emergency fund, college savings, social life, and family contribution.",
245
+ "income": 600,
246
+ "objectives": [
247
+ "Build emergency fund (JA$50)",
248
+ "Save for college (JA$100)",
249
+ "Contribute to family (JA$40)",
250
+ "Maintain social life and hobbies",
251
+ ],
252
+ "constraints": {
253
+ "savings": {"min": 150}, # Emergency + college
254
+ "charity": {"min": 40}, # Family contribution
255
+ "food": {"min": 80, "required": True},
256
+ "transport": {"min": 60, "required": True},
257
+ "school": {"min": 50, "required": True},
258
+ },
259
+ "success": [
260
+ ("Save JA$150+ for future goals", lambda cats, inc: cats["savings"] >= 150),
261
+ ("Contribute JA$40+ to family", lambda cats, inc: cats["charity"] >= 40),
262
+ (
263
+ "Balance entertainment & responsibilities",
264
+ lambda cats, inc: cats["fun"] >= 30 and cats["fun"] <= 150,
265
+ ),
266
+ ("Perfect budget balance", lambda cats, inc: sum(cats.values()) == inc),
267
+ ],
268
+ "xp": 50,
269
+ },
270
+ ]
271
+
272
+ # -----------------------------
273
+ # Initialize Session State
274
+ # -----------------------------
275
+ if "current_level" not in st.session_state:
276
+ st.session_state.current_level = 1
277
+ if "completed_levels" not in st.session_state:
278
+ st.session_state.completed_levels = []
279
+ if "categories" not in st.session_state:
280
+ st.session_state.categories = {}
281
+ if "level_completed" not in st.session_state:
282
+ st.session_state.level_completed = False
283
+
284
+ # -----------------------------
285
+ # Categories Master
286
+ # -----------------------------
287
+ categories_master = {
288
+ "food": {"name": "Food & Snacks", "color": "#16a34a", "icon": "🍎", "min": 0, "max": 300},
289
+ "savings": {"name": "Savings", "color": "#2563eb", "icon": "💰", "min": 0, "max": 400},
290
+ "fun": {"name": "Entertainment", "color": "#dc2626", "icon": "🎮", "min": 0, "max": 300},
291
+ "charity": {"name": "Charity/Family", "color": "#e11d48", "icon": "❤️", "min": 0, "max": 200},
292
+ "transport": {"name": "Transport", "color": "#ea580c", "icon": "🚌", "min": 0, "max": 200},
293
+ "school": {"name": "School Supplies", "color": "#0891b2", "icon": "📚", "min": 0, "max": 150},
294
+ }
295
+ if not st.session_state.categories:
296
+ st.session_state.categories = {cid: 0 for cid in categories_master.keys()}
297
+
298
+ # -----------------------------
299
+ # Current Level Setup
300
+ # -----------------------------
301
+ level = [l for l in levels if l["id"] == st.session_state.current_level][0]
302
+
303
+ # Header section with improved styling
304
+ st.markdown(f"""
305
+ <div style="text-align: center; padding: 2rem 0; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
306
+ border-radius: 12px; margin-bottom: 2rem; color: white;">
307
+ <h1 style="color: white; margin-bottom: 0.5rem;">💵 Budget Builder</h1>
308
+ </div>
309
+ """, unsafe_allow_html=True)
310
+
311
+ # Level progress indicator
312
+ st.markdown(f"""
313
+ <div style="background: #f8fafc; border-radius: 8px; padding: 1rem; margin-bottom: 2rem; border: 1px solid #e2e8f0;">
314
+ <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
315
+ <span style="font-weight: 600; color: #374151;">Level Progress</span>
316
+ <span style="color: #6b7280;">{len(st.session_state.completed_levels)}/5 Complete</span>
317
+ </div>
318
+ </div>
319
+ """, unsafe_allow_html=True)
320
+
321
+ # Scenario description with better styling
322
+ st.markdown(f"""
323
+ <div style="background: #eff6ff; border-radius: 8px; padding: 1.5rem; margin-bottom: 2rem;
324
+ border-left: 4px solid #3b82f6;">
325
+ <h4 style="color: #1e40af; margin-bottom: 0.5rem;">📖 Scenario</h4>
326
+ <p style="color: #1f2937; margin-bottom: 1rem;">{level["scenario"]}</p>
327
+ <div style="background: white; border-radius: 6px; padding: 1rem; border: 1px solid #dbeafe;">
328
+ <strong style="color: #059669;">Weekly Income: JA${level['income']}</strong>
329
+ </div>
330
+ </div>
331
+ """, unsafe_allow_html=True)
332
+
333
+ # -----------------------------
334
+ # Two-column layout
335
+ # -----------------------------
336
+ left_col, right_col = st.columns([2, 1], gap="large")
337
+
338
+ with left_col:
339
+ st.markdown(f"""
340
+ <div class="budget-card" style="background: #f8fafc; border-left: 4px solid #10b981;">
341
+ <h3 style="color: #059669; margin-bottom: 1rem;">🎯 Objectives</h3>
342
+ {''.join([f'<div style="margin-bottom: 0.5rem; color: #374151;">• {obj}</div>' for obj in level["objectives"]])}
343
+ </div>
344
+ """, unsafe_allow_html=True)
345
+
346
+ st.markdown("""
347
+ <h2 style="color: #374151; margin-bottom: 1.5rem;">💰 Budget Allocation</h3>
348
+ <p style="color: #6b7280; margin-bottom: 1.5rem;">Use the sliders below to allocate your weekly income across different categories. Make sure to meet the objectives!</p>
349
+ """, unsafe_allow_html=True)
350
+
351
+ st.markdown("### 📊 Allocate Your Budget")
352
+ # Render sliders without dynamic inter-dependencies
353
+ for cid, cat in categories_master.items():
354
+ constraints = level["constraints"].get(cid, {})
355
+ min_val = 0
356
+ #max is set to the level income for more flexibility
357
+ max_val = level["income"]
358
+ st.session_state.categories[cid] = st.slider(
359
+ f"{cat['icon']} {cat['name']}",
360
+ min_value=min_val,
361
+ max_value=max_val,
362
+ value=st.session_state.categories[cid],
363
+ step=5,
364
+ help=f"Min: JA${min_val}, Max: JA${max_val}"
365
+ )
366
+
367
+ # Calculate totals after sliders have been selected
368
+ total_allocated = sum(st.session_state.categories.values())
369
+ remaining = level["income"] - total_allocated
370
+ st.metric("Remaining", f"JA${remaining}", delta_color="inverse" if remaining < 0 else "normal")
371
+
372
+
373
+ # Remaining budget display with better styling
374
+ color = "#ef4444" if remaining < 0 else "#059669" if remaining == 0 else "#f59e0b"
375
+ st.markdown(f"""
376
+ <div class="metric-container" style="border-left: 4px solid {color};">
377
+ <h4 style="color: #6b7280; margin-bottom: 0.5rem;">Remaining Budget</h4>
378
+ <h2 style="color: {color}; margin: 0;">JA${remaining}</h2>
379
+ </div>
380
+ """, unsafe_allow_html=True)
381
+
382
+ st.markdown('</div>', unsafe_allow_html=True)
383
+
384
+ col1, col2 = st.columns(2)
385
+ with col1:
386
+ if st.button("✅ Check Budget", use_container_width=True, type="primary"):
387
+ results = [(desc, fn(st.session_state.categories, level["income"])) for desc, fn in level["success"]]
388
+ all_passed = all(r[1] for r in results)
389
+
390
+ if all_passed and remaining == 0:
391
+ st.success(f"🎉 Level {level['id']} Complete! +{level['xp']} XP")
392
+ st.session_state.level_completed = True
393
+ if level["id"] not in st.session_state.completed_levels:
394
+ st.session_state.completed_levels.append(level["id"])
395
+ else:
396
+ st.error("❌ Not complete yet. Check the requirements!")
397
+ for desc, passed in results:
398
+ icon = "✅" if passed else "⚠️"
399
+ st.markdown(f"{icon} {desc}")
400
+
401
+ with col2:
402
+ # Reset button
403
+ if st.button("🔄 Reset Budget", use_container_width=True, type="secondary"):
404
+ # Reset all category amounts
405
+ for cid in categories_master.keys():
406
+ st.session_state[cid] = 0
407
+ # Reset the dictionary in session_state too
408
+ st.session_state.categories = {cid: 0 for cid in categories_master.keys()}
409
+ st.session_state.level_completed = False
410
+ st.experimental_rerun()
411
+
412
+
413
+ # Next Level button
414
+ if st.session_state.level_completed and st.session_state.current_level < len(levels):
415
+ if st.button("➡️ Next Level", use_container_width=True, type="primary"):
416
+ st.session_state.current_level += 1
417
+ st.session_state.categories = {cid: 0 for cid in categories_master.keys()}
418
+ st.session_state.level_completed = False
419
+ st.experimental_rerun()
420
+
421
+ with right_col:
422
+ criteria_html = ""
423
+ for desc, fn in level["success"]:
424
+ passed = fn(st.session_state.categories, level["income"])
425
+ icon = "✅" if passed else "⚠️"
426
+ color = "#059669" if passed else "#f59e0b"
427
+ criteria_html += f"<div style='margin-bottom: 0.5rem; color: {color};'>{icon} {desc}</div>"
428
+
429
+ st.markdown(f"""
430
+ <div class="budget-card">
431
+ <h3 style="color: #374151; margin-bottom: 1rem;">✅ Success Criteria</h3>
432
+ {criteria_html}
433
+ </div>
434
+ """, unsafe_allow_html=True)
435
+
436
+ breakdown_html = ""
437
+ for cid, amount in st.session_state.categories.items():
438
+ if amount > 0:
439
+ cat = categories_master[cid]
440
+ percentage = (amount / level["income"]) * 100
441
+ breakdown_html += f"""
442
+ <div style="display:flex; justify-content:space-between; align-items:center;
443
+ padding:0.5rem; margin-bottom:0.5rem; background:#f8fafc; border-radius:6px;">
444
+ <span style="color:#374151;">{cat['icon']} {cat['name']}</span>
445
+ <div style="text-align:right;">
446
+ <div style="font-weight:600; color:#1f2937;">JA${amount}</div>
447
+ <div style="font-size:0.8rem; color:#6b7280;">{percentage:.1f}%</div>
448
+ </div>
449
+ </div>
450
+ """
451
+
452
+ st.markdown(f"""
453
+ <div class="budget-card">
454
+ <h3 style="color:#374151; margin-bottom:1rem;">📊 Budget Breakdown</h3>
455
+ {breakdown_html}
456
+ </div>
457
+ """, unsafe_allow_html=True)
458
+
459
+
460
+ st.markdown("""
461
+ <div class="budget-card" style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
462
+ border-left: 4px solid #f59e0b;">
463
+ <h3 style="color: #92400e; margin-bottom: 1rem;">💡 Level Tips</h3>
464
+ <div style="color: #451a03;">
465
+ <div style="margin-bottom: 0.5rem;">💰 Start with essentials like food and transport</div>
466
+ <div style="margin-bottom: 0.5rem;">🎯 The 50/30/20 rule: needs, wants, savings</div>
467
+ <div>📊 Review and adjust your budget regularly</div>
468
+ </div>
469
+ </div>
470
+ """, unsafe_allow_html=True)
471
+
472
+ if len(st.session_state.completed_levels) == len(levels):
473
+ st.balloons()
474
+ st.markdown("""
475
+ <div style="text-align: center; padding: 2rem; background: linear-gradient(135deg, #10b981 0%, #059669 100%);
476
+ border-radius: 12px; color: white; margin-top: 2rem;">
477
+ <h2 style="color: white; margin-bottom: 1rem;">🎉 Congratulations!</h2>
478
+ <h3 style="color: #d1fae5; margin: 0;">You are now a Master Budgeter!</h3>
479
+ </div>
480
+ <br>
481
+ """, unsafe_allow_html=True)
482
+
483
+ # Show a restart button
484
+ if st.button("🔄 Restart Game"):
485
+ st.session_state.current_level = 1
486
+ st.session_state.completed_levels = []
487
+ st.session_state.categories = {cid: 0 for cid in categories_master.keys()}
488
+ st.session_state.level_completed = False
489
+ st.experimental_rerun()
phase/Student_view/games/debtdilemma.py ADDED
@@ -0,0 +1,1026 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # phase\Student_view\games\debtdilemma.py
2
+ import streamlit as st
3
+ from dataclasses import dataclass, field
4
+ from typing import List, Optional, Dict, Literal
5
+ import random
6
+ import math
7
+ import os
8
+ from utils import db as dbapi
9
+
10
+
11
+ def load_css(file_name: str):
12
+ try:
13
+ with open(file_name, "r", encoding="utf-8") as f:
14
+ st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
15
+ except FileNotFoundError:
16
+ st.warning("⚠️ Stylesheet not found. Please ensure 'assets/styles.css' exists.")
17
+
18
+
19
+ DD_SCOPE_CLASS = "dd-scope"
20
+
21
+ def _ensure_dd_css():
22
+ """Inject CSS for Debt Dilemma buttons once, scoped under .dd-scope."""
23
+
24
+ if st.session_state.get("_dd_css_injected"):
25
+ return
26
+ st.session_state["_dd_css_injected"] = True
27
+
28
+
29
+ st.markdown("""
30
+ <style>
31
+ .dd-scope .stButton > button {
32
+ border: none;
33
+ border-radius: 25px;
34
+ padding: 0.75rem 1.5rem;
35
+ font-weight: 700;
36
+ box-shadow: 0 4px 15px rgba(0,0,0,.2);
37
+ transition: all .3s ease;
38
+ }
39
+ .dd-scope .stButton > button:hover {
40
+ transform: translateY(-2px);
41
+ box-shadow: 0 6px 20px rgba(0,0,0,.3);
42
+ }
43
+ .dd-scope .dd-success .stButton > button {
44
+ background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
45
+ color: #fff;
46
+ }
47
+ .dd-scope .dd-warning .stButton > button {
48
+ background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
49
+ color: #000;
50
+ }
51
+ .dd-scope .dd-danger .stButton > button {
52
+ background: linear-gradient(135deg, #ff6b6b 0%, #ffa500 100%);
53
+ color: #000;
54
+ }
55
+ .dd-scope .dd-neutral .stButton > button {
56
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
57
+ color: #fff;
58
+ }
59
+ </style>
60
+ """, unsafe_allow_html=True)
61
+
62
+ def buttondd(label: str, *, key: str, variant: str = "neutral", **kwargs) -> bool:
63
+ """
64
+ Scoped button wrapper. Use just like st.button but styles are limited to the Debt Dilemma container.
65
+
66
+ Example:
67
+ buttondd("Pay", key="btn_pay", variant="success", on_click=fn, use_container_width=True)
68
+ """
69
+ _ensure_dd_css()
70
+
71
+ st.markdown(f'<div class="dd-{variant}">', unsafe_allow_html=True)
72
+ clicked = st.button(label, key=key, **kwargs)
73
+ st.markdown('</div>', unsafe_allow_html=True)
74
+ return clicked
75
+
76
+
77
+ setattr(st, "buttondd", buttondd)
78
+
79
+
80
+ # ==== Currency & economy tuning ====
81
+ CURRENCY = "JMD$"
82
+ MONEY_SCALE = 1000 # 1 "game dollar" = 1,000 JMD
83
+
84
+ def jmd(x: int | float) -> int:
85
+ """Scale a base unit to JMD integer."""
86
+ return int(round(x * MONEY_SCALE))
87
+
88
+ def fmt_money(x: int | float) -> str:
89
+ """Format with thousands separator and currency."""
90
+ # round instead of floor so UI doesn't show 0 while a tiny positive remains
91
+ return f"{CURRENCY}{int(round(x)):,}"
92
+
93
+ def clamp_money(x: float) -> int:
94
+ """Round to nearest JMD and never go negative."""
95
+ # helper to normalize all balances to integer JMD
96
+ return max(0, int(round(x)))
97
+
98
+ # Fees (scaled)
99
+ LATE_FEE_BASE = jmd(10) # ~JMD$10,000
100
+ LATE_FEE_PER_MISS = jmd(5) # +JMD$5,000 per missed
101
+ EMERGENCY_FEE = jmd(25) # ~JMD$25,000
102
+ SMALL_PROC_FEE = jmd(2) # ~JMD$2,000 for event shortfalls
103
+
104
+ # ==== Starting wallet config ====
105
+ START_WALLET_MIN = 0
106
+ START_WALLET_MAX = jmd(10) # JMD $0–10,000
107
+ DISBURSE_LOAN_TO_WALLET = False # keep loan off-wallet by default (e.g., pays tuition)
108
+
109
+ # --- Credit-score tuning ---
110
+ CS_EVENT_DECLINE_MIN = 15 # min points to deduct when you skip an expense event
111
+ CS_EVENT_DECLINE_MAX = 100 # max points
112
+ CS_EVENT_DECLINE_PER_K = 5 # ~5 pts per JMD$1,000 of expense you duck
113
+ CS_EMERGENCY_EVENT_HIT = 60 # when an event forces an emergency loan
114
+
115
+ # --- Utilities month-end penalties ---
116
+ UTILITY_NONPAY_CS_HIT = 25
117
+ UTILITY_NONPAY_HAPPY_HIT = 8
118
+ UTILITY_RECONNECT_FEE = jmd(2) # ~JMD$2,000 added to debt
119
+
120
+ # ===============================
121
+ # Types
122
+ # ===============================
123
+ @dataclass
124
+ class LoanDetails:
125
+ principal: int
126
+ interestRate: float
127
+ monthlyPayment: int
128
+ totalOwed: float
129
+ monthsPaid: int
130
+ totalMonths: int
131
+ missedPayments: int
132
+ creditScore: int
133
+
134
+ @dataclass
135
+ class RandomEvent:
136
+ id: str
137
+ title: str
138
+ description: str
139
+ icon: str
140
+ type: Literal['opportunity','expense','penalty','bonus']
141
+ impact: Dict[str, int] = field(default_factory=dict)
142
+ choices: Optional[Dict[str,str]] = None
143
+
144
+ @dataclass
145
+ class GameLevel:
146
+ level: int
147
+ name: str
148
+ loanAmount: int
149
+ interestRate: float
150
+ monthlyPayment: int
151
+ totalMonths: int
152
+ startingIncome: int
153
+ description: str
154
+
155
+ # ===============================
156
+ # Data (shorter game: 3 levels)
157
+ # ===============================
158
+ GAME_LEVELS: List[GameLevel] = [
159
+ GameLevel(1, "🎓 Student Loan", jmd(100), 0.15, jmd(25), 3, jmd(120), "Your first small loan as a student - let's learn together! 📚"),
160
+ GameLevel(2, "🚗 Car Loan", jmd(250), 0.18, jmd(50), 3, jmd(140), "Buying your first car - bigger responsibility but you've got this! 🌟"),
161
+ GameLevel(3, "💳 Credit Card Debt", jmd(400), 0.22, jmd(70), 3, jmd(160), "High-interest credit card debt - time to be extra careful! ⚠️"),
162
+ ]
163
+
164
+ # Replaced 'Clothes' with 'Snacks' and added happiness boosts
165
+ EXPENSES = [
166
+ {"id": "food", "name": "Food", "amount": jmd(0.9), "required": True, "healthImpact": -15, "emoji": "🍎"}, # ~JMD$900 per day
167
+ {"id": "transport", "name": "Transport", "amount": jmd(0.5), "required": True, "healthImpact": 0, "emoji": "🚌"}, # ~JMD$500
168
+ {"id": "utilities", "name": "Utilities", "amount": jmd(7), "required": True, "healthImpact": -5, "emoji": "💡"}, # ~JMD$7,000/mo
169
+ {"id": "entertainment","name": "Entertainment","amount": jmd(1.5), "required": False, "healthImpact": 0, "happinessBoost": 5, "emoji": "🎮"},
170
+ {"id": "snacks", "name": "Snacks", "amount": jmd(0.8), "required": False, "healthImpact": 0, "happinessBoost": 5, "emoji": "🍿"},
171
+ ]
172
+
173
+ LEVEL_EVENT_POOL = {
174
+ 1: [ # Student Loan level
175
+ RandomEvent("games_day", "🏟️ School Games Day", "Your school is holding Games Day. Small fee, but huge fun and morale!", "🏟️", "expense",
176
+ {"wallet": -jmd(2), "happiness": 5}, {"accept": f"Join ({fmt_money(jmd(2))}, +5% happy)", "decline": "Skip"}),
177
+ RandomEvent("book_fair", "📚 Book Fair", "Discounted textbooks help your grades (and future pay!).", "📚", "opportunity",
178
+ {"wallet": -jmd(3), "income": jmd(1), "happiness": 3}, {"accept": "Buy books", "decline": "Pass"}),
179
+ RandomEvent("tuition_deadline", "🎓 Tuition Deadline", "A small admin fee pops up unexpectedly.", "🎓", "expense",
180
+ {"wallet": -jmd(3.5)}, {"accept": "Pay fee", "decline": "Appeal"}),
181
+ ],
182
+ 2: [ # Car Loan level
183
+ RandomEvent("gas_hike", "⛽ Gas Price Hike", "Fuel costs rise this week.", "⛽", "expense",
184
+ {"wallet": -jmd(2.5)}, {"accept": "Buy gas", "decline": "Drive less"}),
185
+ RandomEvent("oil_change", "🛠️ Discount Oil Change", "Maintenance now saves larger repair later.", "🛠️", "opportunity",
186
+ {"wallet": -jmd(3), "creditScore": 5}),
187
+ ],
188
+ 3: [ # Credit Card level
189
+ RandomEvent("flash_sale", "🛍️ Flash Sale Temptation", "Limited-time sale! Tempting but watch your debt.", "🛍️", "penalty",
190
+ {"debt": jmd(4), "happiness": 4}, {"accept": "Buy (+debt)", "decline": "Resist"}),
191
+ RandomEvent("cashback", "💳 Cashback Bonus", "Your card offers a cashback promo.", "💳", "bonus",
192
+ {"wallet": jmd(3)}),
193
+ ],
194
+ }
195
+
196
+ EVENT_POOL: List[RandomEvent] = [
197
+ # Money-earning opportunities
198
+ RandomEvent("yard_sale", "🧹 Yard Sale Fun!", "You sell old items and make some quick cash! Great job being resourceful! 🌟", "🧹", "opportunity", {"wallet": jmd(6)}),
199
+ RandomEvent("tutoring", "📚 Tutoring Helper", "You help someone with homework and get paid! Sharing knowledge feels great! 😊", "📚", "opportunity", {"wallet": jmd(5)}),
200
+ RandomEvent("odd_jobs", "🧰 Weekend Helper", "You mow lawns and wash a car over the weekend! Hard work pays off! 💪", "🧰", "opportunity", {"wallet": jmd(7)}),
201
+
202
+ # Bonuses / grants
203
+ RandomEvent("overtime_work", "💼 Extra Work Time", "Your boss offers you overtime this period. Extra money but you'll be tired! 😴", "💼", "opportunity",
204
+ {"wallet": jmd(8)}, {"accept": f"Work overtime (+{fmt_money(jmd(8))}) 💪", "decline": "Rest instead 😴"}),
205
+ RandomEvent("freelance_job", "💻 Weekend Project", "A friend asks you to help with their business for some quick cash! 🤝", "💻", "opportunity",
206
+ {"wallet": jmd(6)}, {"accept": f"Take the job (+{fmt_money(jmd(6))}) 💼", "decline": "Enjoy your weekend 🌈"}),
207
+ RandomEvent("bonus_payment", "⭐ Amazing Work!", "Your excellent work this period earned you a bonus! You're doing great! 🎉", "⭐", "bonus", {"wallet": jmd(5), "creditScore": 10}),
208
+ RandomEvent("scholarship_opportunity", "🎓 Learning Reward", "You qualify for a small educational grant! Knowledge pays off! 📖", "🎓", "bonus", {"wallet": jmd(10), "income": jmd(2)}),
209
+
210
+ # Health & happiness helpers
211
+ RandomEvent("mental_health", "🧠 Feeling Better", "A free counseling session can help you feel better and happier! 🌈", "🧠", "opportunity",
212
+ {"wallet": 0, "health": 10, "happiness": 10}, {"accept": "Feel better! 😊", "decline": "Maybe later 🤔"}),
213
+ RandomEvent("health_checkup", "🏥 Health Check", "Local clinic does a free health checkup! Taking care of yourself is important! 💚", "🏥", "opportunity",
214
+ {"wallet": 0, "health": 10, "happiness": 5}, {"accept": "Get healthy! 💪", "decline": "Skip it 🤷"}),
215
+
216
+ # Expenses / penalties
217
+ RandomEvent("landlord_eviction", "🏠 Moving Costs", "You need a small deposit for a new place soon. Moving can be expensive! 📦", "🏠", "expense",
218
+ {"wallet": -jmd(9)}, {"accept": f"Pay deposit (-{fmt_money(jmd(9))}) 🏠", "decline": "Try to negotiate 🤝"}),
219
+ RandomEvent("transport_breakdown", "🚫 Transport Trouble", "Your usual transport is down. You need an alternative way to get around! 🚶", "🚫", "expense",
220
+ {"wallet": -jmd(3)}, {"accept": f"Pay for ride (-{fmt_money(jmd(3))}) 🚗", "decline": "Walk everywhere 🚶"}),
221
+ RandomEvent("utilities_shutoff", "⚡ Utility Warning", "Utilities will be shut off if not paid soon! Don't let the lights go out! 💡", "⚡", "expense",
222
+ {"wallet": -jmd(4)}, {"accept": f"Pay now (-{fmt_money(jmd(4))}) 💡", "decline": "Risk it 😬"}),
223
+ ]
224
+
225
+ # ===============================
226
+ # Helpers
227
+ # ===============================
228
+ def get_level(level:int) -> GameLevel:
229
+ return GAME_LEVELS[level-1]
230
+
231
+ def required_expenses_total() -> int:
232
+ return sum(e["amount"] for e in EXPENSES if e["required"])
233
+
234
+ def progress_percent(total_owed: float, monthly_payment: int, total_months: int) -> float:
235
+ pct = ((total_months - (total_owed / max(monthly_payment,1))) / total_months) * 100
236
+ return max(0.0, min(100.0, pct))
237
+
238
+ def payoff_projection(balance: float, apr: float, monthly_payment: int):
239
+ """
240
+ Simulate payoff using the game's timing:
241
+ - Player pays during the month (before interest).
242
+ - At month end, interest accrues on the remaining balance and is added.
243
+ Returns (months_needed, total_interest_paid). If payment <= interest, returns (None, None).
244
+ """
245
+ r = apr / 12.0
246
+ if balance <= 0:
247
+ return 0, 0
248
+ if r <= 0:
249
+ months = math.ceil(balance / max(1, monthly_payment))
250
+ return months, 0
251
+ if monthly_payment <= balance * r:
252
+ return None, None
253
+ months = 0
254
+ total_interest = 0.0
255
+ b = float(balance)
256
+ for _ in range(10000): # safety cap
257
+ pay = min(monthly_payment, b)
258
+ b -= pay
259
+ months += 1
260
+ if b <= 1e-6:
261
+ break
262
+ interest = b * r
263
+ b += interest
264
+ total_interest += interest
265
+ if monthly_payment <= b * r - 1e-9:
266
+ return None, None
267
+ return months, int(round(total_interest))
268
+
269
+
270
+ def _award_level_completion_if_needed():
271
+ """Give exactly +50 XP once per completed level, including the last level."""
272
+ user = st.session_state.get("user")
273
+ if not user:
274
+ return
275
+ lvl = int(st.session_state.currentLevel)
276
+ key = f"_dd_xp_awarded_L{lvl}"
277
+ if st.session_state.get(key):
278
+ return # already awarded for this level
279
+
280
+ try:
281
+ dbapi.record_debt_dilemma_round(
282
+ user["user_id"],
283
+ level=lvl,
284
+ round_no=0,
285
+ wallet=int(st.session_state.wallet),
286
+ health=int(st.session_state.health),
287
+ happiness=int(st.session_state.happiness),
288
+ credit_score=int(st.session_state.loan.creditScore),
289
+ event_json={"phase": st.session_state.gamePhase},
290
+ outcome="level_complete" if st.session_state.gamePhase == "level-complete" else "game_complete",
291
+ gained_xp=50
292
+ )
293
+ st.session_state[key] = True
294
+ st.success("Saved +50 XP for completing this loan")
295
+ except Exception as e:
296
+ st.error(f"Could not save completion XP: {e}")
297
+
298
+
299
+ def check_loan_completion() -> bool:
300
+ """Advance level or finish game when loan is cleared. Returns True if game phase changed."""
301
+ loan = st.session_state.loan
302
+ # use integerized check; tiny float dust won't block completion
303
+ if clamp_money(loan.totalOwed) == 0:
304
+ if st.session_state.currentLevel < len(GAME_LEVELS):
305
+ st.session_state.gamePhase = "level-complete"
306
+ st.toast(f"Level {st.session_state.currentLevel} complete! Ready for next?")
307
+ else:
308
+ st.session_state.gamePhase = "completed"
309
+ st.toast("All levels done! 🎉")
310
+ return True
311
+ return False
312
+
313
+ def init_state():
314
+ if "gamePhase" not in st.session_state:
315
+ st.session_state.update({
316
+ "gamePhase": "setup",
317
+ "currentMonth": 1,
318
+ "currentDay": 1,
319
+ "daysInMonth": 28,
320
+ "roundsLeft": 6,
321
+ "wallet": random.randint(START_WALLET_MIN, START_WALLET_MAX),
322
+ "monthlyIncome": GAME_LEVELS[0].startingIncome,
323
+ "health": 100,
324
+ "happiness": 100,
325
+ "monthsWithoutFood": 0,
326
+ "currentEvent": None,
327
+ "eventHistory": [],
328
+ "difficultyMultiplier": 1.0,
329
+ "currentLevel": 1,
330
+ "paidExpenses": [],
331
+ "hasWorkedThisMonth": False,
332
+ "achievements": [],
333
+ "lastWorkPeriod": 0,
334
+ "amountPaidThisMonth": 0,
335
+ "fullPaymentMadeThisMonth": False,
336
+ "paidFoodToday": False,
337
+ })
338
+ lvl = get_level(1)
339
+ st.session_state["loan"] = LoanDetails(
340
+ principal=lvl.loanAmount,
341
+ interestRate=lvl.interestRate,
342
+ monthlyPayment=lvl.monthlyPayment,
343
+ totalOwed=float(lvl.loanAmount),
344
+ monthsPaid=0,
345
+ totalMonths=lvl.totalMonths,
346
+ missedPayments=0,
347
+ creditScore=random.randint(200, 600),
348
+ )
349
+
350
+ # Fortnight helper (every 2 weeks)
351
+ def current_fortnight() -> int:
352
+ return 1 + (st.session_state.currentDay - 1) // 14
353
+
354
+ # ===== End checks =====
355
+ def check_end_conditions() -> bool:
356
+ if st.session_state.health <= 0:
357
+ st.session_state.health = 0
358
+ st.session_state.gamePhase = "hospital"
359
+ st.toast("You've been hospitalized! Health reached 0%. Game Over.")
360
+ return True
361
+ if st.session_state.happiness <= 0:
362
+ st.session_state.happiness = 0
363
+ st.session_state.gamePhase = "burnout"
364
+ st.toast("Happiness reached 0%. You gave up. Game Over.")
365
+ return True
366
+ return False
367
+
368
+ # ===== Day advancement =====
369
+ def advance_day(no_event: bool = False):
370
+ """Advance one day. If no_event=True, skip daily event roll (use when an action already consumed the day)."""
371
+ if st.session_state.gamePhase == "repaying":
372
+ if not st.session_state.paidFoodToday:
373
+ st.session_state.health = max(0, st.session_state.health - 5)
374
+ st.toast("You skipped food today. Health -5%")
375
+ st.session_state.paidFoodToday = False
376
+
377
+ if not no_event and st.session_state.gamePhase == "repaying" and st.session_state.currentEvent is None:
378
+ new_evt = gen_random_event()
379
+ if new_evt:
380
+ st.session_state.currentEvent = new_evt
381
+ st.toast(f"New event: {new_evt.title}")
382
+ return
383
+
384
+ if check_end_conditions():
385
+ return
386
+
387
+ st.session_state.currentDay += 1
388
+ if st.session_state.currentDay > st.session_state.daysInMonth:
389
+ st.session_state.currentDay = 1
390
+ next_month()
391
+ else:
392
+ st.toast(f"Day {st.session_state.currentDay}/{st.session_state.daysInMonth}")
393
+
394
+ def fast_forward_to_month_end():
395
+ st.toast("Skipping to month end…")
396
+ st.session_state.currentDay = st.session_state.daysInMonth
397
+ next_month()
398
+
399
+ # ===============================
400
+ # Random events
401
+ # ===============================
402
+ def set_event(evt: Optional[RandomEvent]):
403
+ st.session_state["currentEvent"] = evt
404
+
405
+ def gen_random_event() -> Optional[RandomEvent]:
406
+ currentMonth = st.session_state.currentMonth
407
+ difficulty = st.session_state.difficultyMultiplier
408
+ base = 0.08
409
+ eventChance = min(base + (currentMonth * 0.03) + (difficulty * 0.02), 0.4)
410
+ if random.random() < eventChance:
411
+ seen = set(st.session_state.eventHistory)
412
+ level_specific = LEVEL_EVENT_POOL.get(st.session_state.currentLevel, [])
413
+ pool = EVENT_POOL + level_specific
414
+ available = [e for e in pool if (e.id not in seen) or (e.type in ("opportunity","bonus"))]
415
+ if available:
416
+ return random.choice(available)
417
+ return None
418
+
419
+ # ===============================
420
+ # Game actions
421
+ # ===============================
422
+ def start_loan():
423
+ st.session_state.gamePhase = "repaying"
424
+ if DISBURSE_LOAN_TO_WALLET:
425
+ st.session_state.wallet += st.session_state.loan.principal
426
+ st.toast(f"Loan approved! {fmt_money(st.session_state.loan.principal)} added to your wallet.")
427
+ else:
428
+ st.toast("Loan approved! Funds go directly to fees (not your wallet).")
429
+
430
+ def do_skip_payment():
431
+ loan: LoanDetails = st.session_state.loan
432
+ loan.missedPayments += 1
433
+ loan.creditScore = max(300, loan.creditScore - 50)
434
+ st.toast("Payment missed! Credit score -50.")
435
+ advance_day(no_event=False)
436
+
437
+ def can_work_this_period() -> bool:
438
+ return st.session_state.lastWorkPeriod != current_fortnight()
439
+
440
+ WORK_HAPPINESS_COST = 10
441
+ WORK_MIN = jmd(6) # ~JMD$6,000
442
+ WORK_VAR = jmd(3) # up to +JMD$3,000
443
+
444
+ def do_work_for_money():
445
+ if not can_work_this_period():
446
+ st.toast("You already worked this fortnight. Try later.")
447
+ return
448
+ earnings = WORK_MIN + random.randint(0, WORK_VAR)
449
+ st.session_state.wallet += earnings
450
+ st.session_state.happiness = max(0, st.session_state.happiness - WORK_HAPPINESS_COST)
451
+ st.session_state.hasWorkedThisMonth = True
452
+ st.session_state.lastWorkPeriod = current_fortnight()
453
+ st.toast(f"Work done! +{fmt_money(earnings)}, Happiness -{WORK_HAPPINESS_COST} (uses 1 day)")
454
+ if st.session_state.happiness <= 30 and "workaholic" not in st.session_state.achievements:
455
+ st.session_state.achievements.append("workaholic")
456
+ st.toast("Achievement: Workaholic - Worked while happiness was low!")
457
+ if not check_end_conditions():
458
+ advance_day(no_event=True)
459
+
460
+ def do_make_payment_full():
461
+ make_payment(st.session_state.loan.monthlyPayment)
462
+
463
+ def do_make_payment_partial():
464
+ # use ceil so a tiny remainder (e.g., 0.4 JMD) can be fully cleared
465
+ leftover = math.ceil(st.session_state.loan.totalOwed)
466
+ pay_what = max(0, min(st.session_state.wallet - required_expenses_total(), leftover))
467
+ make_payment(pay_what)
468
+
469
+ def make_payment(amount: int):
470
+ loan: LoanDetails = st.session_state.loan
471
+ required_total = required_expenses_total()
472
+ if amount <= 0:
473
+ st.toast("Enter a valid payment amount.")
474
+ return
475
+ if st.session_state.wallet >= amount and st.session_state.wallet - amount >= required_total:
476
+ st.session_state.wallet -= amount
477
+ # clamp debt after payment to eliminate float dust
478
+ loan.totalOwed = clamp_money(loan.totalOwed - amount)
479
+ st.session_state.amountPaidThisMonth += amount
480
+ if amount >= loan.monthlyPayment:
481
+ loan.monthsPaid += 1
482
+ st.session_state.fullPaymentMadeThisMonth = True
483
+ st.toast(f"Payment successful! {fmt_money(amount)} paid.")
484
+ else:
485
+ still = max(0, loan.monthlyPayment - amount)
486
+ st.toast(f"Partial payment {fmt_money(amount)}. Need {fmt_money(still)} more for full.")
487
+ # rely on integerized zero check
488
+ if check_loan_completion():
489
+ return
490
+ if st.session_state.gamePhase == "repaying":
491
+ advance_day(no_event=True)
492
+ else:
493
+ st.toast("Not enough money (remember mandatory expenses).")
494
+
495
+ # Paying expenses is free; Food heals
496
+ def pay_expense(expense: Dict):
497
+ if st.session_state.wallet >= expense["amount"]:
498
+ st.session_state.wallet -= expense["amount"]
499
+ if expense["id"] not in st.session_state.paidExpenses:
500
+ st.session_state.paidExpenses.append(expense["id"])
501
+ st.toast(f"Paid {fmt_money(expense['amount'])} for {expense['name']}")
502
+ boost = int(expense.get("happinessBoost", 0))
503
+ if boost:
504
+ before = st.session_state.happiness
505
+ st.session_state.happiness = min(100, st.session_state.happiness + boost)
506
+ st.toast(f"Happiness +{st.session_state.happiness - before}%")
507
+ if expense["id"] == "food":
508
+ st.session_state.paidFoodToday = True
509
+ before_h = st.session_state.health
510
+ st.session_state.health = min(100, st.session_state.health + 10)
511
+ healed = st.session_state.health - before_h
512
+ if healed > 0:
513
+ st.toast(f"Health +{healed}% from eating well")
514
+ if expense["id"] == "utilities":
515
+ before = st.session_state.happiness
516
+ st.session_state.happiness = max(0, st.session_state.happiness - 3)
517
+ st.toast(f"Happiness -{before - st.session_state.happiness}% (paid utilities)")
518
+ check_end_conditions()
519
+ else:
520
+ st.toast(f"Can't afford {expense['name']}! It will be auto-deducted at month end.")
521
+
522
+ # Resolving events: consumes 1 day
523
+ def handle_event_choice(accept: bool):
524
+ evt: Optional[RandomEvent] = st.session_state.currentEvent
525
+ if not evt:
526
+ return
527
+ loan: LoanDetails = st.session_state.loan
528
+
529
+ if accept and evt.impact:
530
+ if "wallet" in evt.impact:
531
+ delta = evt.impact["wallet"]
532
+ if delta < 0 and st.session_state.wallet < abs(delta):
533
+ st.toast("You can't afford this! Emergency loan taken.")
534
+ short = abs(delta) + SMALL_PROC_FEE
535
+ st.session_state.wallet = 0
536
+ # clamp after adding emergency shortfall
537
+ loan.totalOwed = clamp_money(loan.totalOwed + short)
538
+ loan.creditScore = max(300,loan.creditScore - CS_EMERGENCY_EVENT_HIT)
539
+ st.toast(f"Added to debt: {fmt_money(short)}")
540
+ else:
541
+ st.session_state.wallet += delta
542
+ st.toast(f"{'+' if delta>0 else ''}{fmt_money(delta)} {'earned' if delta>0 else 'spent'}.")
543
+ if "income" in evt.impact:
544
+ st.session_state.monthlyIncome = max(jmd(0.05), st.session_state.monthlyIncome + evt.impact["income"])
545
+ if "creditScore" in evt.impact:
546
+ loan.creditScore = max(300, min(850, loan.creditScore + evt.impact["creditScore"]))
547
+ if "debt" in evt.impact:
548
+ # clamp after debt increase from event
549
+ loan.totalOwed = clamp_money(loan.totalOwed + evt.impact["debt"])
550
+ if "health" in evt.impact:
551
+ st.session_state.health = min(100, max(0, st.session_state.health + evt.impact["health"]))
552
+ if "happiness" in evt.impact:
553
+ st.session_state.happiness = min(100, max(0, st.session_state.happiness + evt.impact["happiness"]))
554
+ elif not accept:
555
+ if evt.type == "expense":
556
+ st.toast("You avoided the expense but there might be consequences…")
557
+ if random.random() < 0.5 and "wallet" in evt.impact:
558
+ base_k = abs(evt.impact["wallet"]) / MONEY_SCALE # convert JMD → 'thousands'
559
+ penalty = int(round(base_k * CS_EVENT_DECLINE_PER_K))
560
+ penalty = max(CS_EVENT_DECLINE_MIN, min(CS_EVENT_DECLINE_MAX, penalty))
561
+ loan.creditScore = max(300, loan.creditScore - penalty)
562
+ st.toast(f"Credit score penalty: -{penalty}")
563
+ else:
564
+ st.toast("You declined the opportunity.")
565
+
566
+ st.session_state.eventHistory.append(evt.id)
567
+ st.session_state.difficultyMultiplier += 0.1
568
+ st.session_state.currentEvent = None
569
+ if not check_end_conditions():
570
+ advance_day(no_event=True)
571
+
572
+ # ===============================
573
+ # Month processing
574
+ # ===============================
575
+ def check_achievements():
576
+ if st.session_state.health == 100 and "perfect-health" not in st.session_state.achievements:
577
+ st.session_state.achievements.append("perfect-health")
578
+ st.toast("Achievement: Perfect Health!")
579
+ if st.session_state.health <= 20 and "survivor" not in st.session_state.achievements:
580
+ st.session_state.achievements.append("survivor")
581
+ st.toast("Achievement: Survivor!")
582
+ if st.session_state.happiness >= 90 and "happy-camper" not in st.session_state.achievements:
583
+ st.session_state.achievements.append("happy-camper")
584
+ st.toast("Achievement: Happy Camper!")
585
+ if st.session_state.wallet <= jmd(0.01) and st.session_state.happiness >= 50 and "broke-not-broken" not in st.session_state.achievements:
586
+ st.session_state.achievements.append("broke-not-broken")
587
+ st.toast("Achievement: Broke But Not Broken!")
588
+ if st.session_state.loan.creditScore >= 800 and "credit-master" not in st.session_state.achievements:
589
+ st.session_state.achievements.append("credit-master")
590
+ st.toast("Achievement: Credit Master!")
591
+
592
+ def next_month():
593
+ loan: LoanDetails = st.session_state.loan
594
+ st.session_state.lastWorkPeriod = 0
595
+
596
+ if st.session_state.gamePhase == "repaying":
597
+ if not st.session_state.fullPaymentMadeThisMonth:
598
+ loan.missedPayments += 1
599
+ st.toast("You missed this month’s full payment.")
600
+ st.session_state.amountPaidThisMonth = 0
601
+ st.session_state.fullPaymentMadeThisMonth = False
602
+
603
+ unpaid = [e for e in EXPENSES if e["required"] and e["id"] not in st.session_state.paidExpenses]
604
+ total_forced = sum(e["amount"] for e in unpaid)
605
+ total_health_loss = sum(abs(e.get("healthImpact", 0)) for e in unpaid)
606
+
607
+ if total_forced > 0:
608
+ if st.session_state.wallet >= total_forced:
609
+ st.session_state.wallet -= total_forced
610
+ st.session_state.health = max(0, st.session_state.health - total_health_loss)
611
+ st.toast(f"Mandatory expenses auto-deducted: {fmt_money(total_forced)}, Health -{total_health_loss}")
612
+ else:
613
+ shortfall = total_forced - st.session_state.wallet
614
+ st.session_state.wallet = 0
615
+ st.session_state.health = max(0, st.session_state.health - total_health_loss - 10)
616
+ # clamp after emergency shortfall + fee
617
+ loan.totalOwed = clamp_money(loan.totalOwed + shortfall + EMERGENCY_FEE)
618
+ loan.creditScore = max(300, loan.creditScore - 35)
619
+ st.toast(f"Couldn't afford mandatory expenses! Emergency loan: {fmt_money(shortfall + EMERGENCY_FEE)}, Health -{total_health_loss + 10}")
620
+
621
+ if st.session_state.currentLevel >= 3 and st.session_state.wallet < st.session_state.monthlyIncome * 0.5:
622
+ loss = int(((st.session_state.monthlyIncome * 0.5) - st.session_state.wallet) / jmd(1))
623
+ if loss > 0:
624
+ st.session_state.happiness = max(0, st.session_state.happiness - loss)
625
+ st.toast(f"Low funds affecting mood! Happiness -{loss}")
626
+
627
+ st.session_state.currentMonth += 1
628
+ st.session_state.currentDay = 1
629
+ st.session_state.roundsLeft -= 1
630
+ st.session_state.wallet += st.session_state.monthlyIncome
631
+ st.session_state.paidExpenses = []
632
+ st.session_state.hasWorkedThisMonth = False
633
+
634
+ if st.session_state.roundsLeft <= 0:
635
+ st.toast("Time's up! You ran out of rounds!")
636
+ st.session_state.gamePhase = "completed"
637
+ return
638
+
639
+ if loan.missedPayments > 0:
640
+ late_fee = LATE_FEE_BASE + (loan.missedPayments * LATE_FEE_PER_MISS)
641
+ # clamp after applying late fees
642
+ loan.totalOwed = clamp_money(loan.totalOwed + late_fee)
643
+ st.toast(f"Late fee applied: {fmt_money(late_fee)}")
644
+ loan.missedPayments = 0
645
+
646
+ # Extra month-end consequences if Utilities weren't paid
647
+ unpaid_ids = {e["id"] for e in unpaid}
648
+ if "utilities" in unpaid_ids:
649
+ # Credit score & happiness hit + reconnection fee
650
+ loan.creditScore = max(300, loan.creditScore - UTILITY_NONPAY_CS_HIT)
651
+ st.session_state.happiness = max(0, st.session_state.happiness - UTILITY_NONPAY_HAPPY_HIT)
652
+ # clamp after reconnect fee
653
+ loan.totalOwed = clamp_money(loan.totalOwed + UTILITY_RECONNECT_FEE)
654
+ st.toast(
655
+ f"Utilities unpaid: Credit -{UTILITY_NONPAY_CS_HIT}, "
656
+ f"Happiness -{UTILITY_NONPAY_HAPPY_HIT}, "
657
+ f"Reconnect fee {fmt_money(UTILITY_RECONNECT_FEE)}"
658
+ )
659
+
660
+ check_achievements()
661
+
662
+ if st.session_state.gamePhase == "repaying":
663
+ # integerize monthly interest and clamp new total
664
+ monthly_interest = clamp_money((loan.totalOwed * loan.interestRate) / 12.0)
665
+ loan.totalOwed = clamp_money(loan.totalOwed + monthly_interest)
666
+ # ensure completion triggers even after month-end math
667
+ check_loan_completion()
668
+
669
+ st.toast(f"Month {st.session_state.currentMonth}: +{fmt_money(st.session_state.monthlyIncome)} income. {st.session_state.roundsLeft} rounds left.")
670
+ check_end_conditions()
671
+
672
+ # ===============================
673
+ # UI
674
+ # ===============================
675
+ def header():
676
+ level = get_level(st.session_state.currentLevel)
677
+ base_payday_hint = "Paid at month end"
678
+ st.markdown(f"""
679
+ <div class="game-header">
680
+ <div class="game-title">🎮 Debt Dilemma 💳</div>
681
+ <h3>Month {st.session_state.currentMonth} · Day {st.session_state.currentDay}/{st.session_state.daysInMonth}</h3>
682
+ <p>Level {st.session_state.currentLevel}: {level.name}</p>
683
+ </div>
684
+ """, unsafe_allow_html=True)
685
+
686
+ st.markdown(f"""
687
+ <div class="metric-card">
688
+ <h3>📊 Your Status</h3>
689
+ <div style="display:flex;flex-wrap:wrap;gap:1rem;justify-content:space-between;margin-top:1rem;">
690
+ <div><strong>💰 Wallet:</strong> {fmt_money(st.session_state.wallet)}</div>
691
+ <div><strong>💼 Base Salary:</strong> {fmt_money(st.session_state.monthlyIncome)} <small>({base_payday_hint})</small></div>
692
+ <div><strong>📊 Credit:</strong> {st.session_state.loan.creditScore}</div>
693
+ <div><strong>❤️ Health:</strong> {st.session_state.health}%</div>
694
+ <div><strong>😊 Happy:</strong> {st.session_state.happiness}%</div>
695
+ </div>
696
+ <div style="margin-top:.5rem;">
697
+ <small>💡 “Work for Money” is extra: once per fortnight you can earn ~JMD$6k–9k for 1 day, and it lowers happiness by 10%.</small>
698
+ </div>
699
+ </div>
700
+ """, unsafe_allow_html=True)
701
+
702
+ def setup_screen():
703
+ level = get_level(st.session_state.currentLevel)
704
+
705
+ # --- Header ---
706
+ st.markdown(f"""
707
+ <div class="game-header">
708
+ <div class="game-title">🎯 Level {level.level}: {level.name}</div>
709
+ <p>{level.description}</p>
710
+ </div>
711
+ """, unsafe_allow_html=True)
712
+
713
+ # --- Loan Info ---
714
+ months_est, interest_est = payoff_projection(
715
+ balance=float(level.loanAmount),
716
+ apr=level.interestRate,
717
+ monthly_payment=level.monthlyPayment
718
+ )
719
+ if months_est is None:
720
+ proj_html = "<small>🧮 Projection: Payment too low — balance will grow.</small>"
721
+ else:
722
+ years_est = months_est / 12.0
723
+ proj_html = (f"<small>🧮 Projection: ~{months_est} payments (~{years_est:.1f} years), "
724
+ f"est. interest {fmt_money(interest_est)}</small>")
725
+
726
+ st.markdown("### 📋 Loan Details")
727
+ st.markdown(f"""
728
+ <div class="metric-card">
729
+ <h3>💳 Loan Information</h3>
730
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-top:1rem;">
731
+ <div><strong>💰 Amount:</strong> {fmt_money(level.loanAmount)}</div>
732
+ <div><strong>📈 Interest:</strong> {int(level.interestRate*100)}% yearly</div>
733
+ <div><strong>💳 Monthly Payment:</strong> {fmt_money(level.monthlyPayment)}</div>
734
+ <div><strong>⏰ Time Limit (target):</strong> {level.totalMonths} months</div>
735
+ </div>
736
+ <div style="margin-top:.5rem;">{proj_html}</div>
737
+ </div>
738
+ """, unsafe_allow_html=True)
739
+
740
+ # --- Player situation ---
741
+ st.markdown("### 🌟 Your Current Situation")
742
+ st.markdown(f"""
743
+ <div class="metric-card">
744
+ <h3>💼 Your Financial Status</h3>
745
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin-top:1rem;">
746
+ <div><strong>💰 Wallet (random start):</strong> {fmt_money(st.session_state.wallet)}</div>
747
+ <div><strong>💼 Base Salary (month end):</strong> {fmt_money(st.session_state.monthlyIncome)}</div>
748
+ <div><strong>🏠 Required Expenses (per month):</strong> {fmt_money(required_expenses_total())}</div>
749
+ <div><strong>📊 Credit Score:</strong> {st.session_state.loan.creditScore}</div>
750
+ </div>
751
+ <div style="margin-top:.5rem;">
752
+ <small>💡 You get your <strong>base salary automatically at month end</strong>. The <em>Work for Money</em> button gives <strong>extra</strong> cash (~JMD$6k–9k) and can be used <strong>once per fortnight</strong> (costs 1 day, -10% happiness).</small>
753
+ </div>
754
+ </div>
755
+ """, unsafe_allow_html=True)
756
+
757
+ st.markdown("""
758
+ ### 🎮 Game Rules - Let's Learn Together!
759
+ 🎯 **Your Mission:** Pay off your loan while staying healthy and happy!
760
+
761
+ 📚 **Important Rules:**
762
+ - 💰 Interest grows your debt each month - pay on time!
763
+ - ❤️ Health 0% = hospital visit (game over!)
764
+ - 😊 Happiness 0% = you give up (game over!)
765
+ - 🍎 Food keeps you healthy (+10 health when paid!)
766
+ - 🎮 Entertainment & 🍿 Snacks make you happy (+5% each!)
767
+ - 🎲 Random events happen daily - some good, some challenging!
768
+
769
+ ⏰ **Time Costs:**
770
+ - 💼 Work (extra) = 1 day (**once per fortnight**)
771
+ - 💳 Make loan payment = 1 day
772
+ - 🎲 Handle events = 1 day
773
+ - 🏠 Paying expenses = FREE (no time cost!)
774
+
775
+ 💡 **Payday:** Your **base salary** hits your wallet automatically at **month end**.
776
+ """)
777
+
778
+ # use st.buttondd (scoped) instead of st.button
779
+ st.buttondd(
780
+ f"🚀 Accept Level {level.level} Loan & {'Receive ' + fmt_money(level.loanAmount) if DISBURSE_LOAN_TO_WALLET else 'Start the Level'}!",
781
+ use_container_width=True,
782
+ on_click=start_loan,
783
+ key="btn_start_loan",
784
+ variant="success"
785
+ )
786
+
787
+ def main_screen():
788
+ header()
789
+
790
+ left, right = st.columns([2,1])
791
+
792
+ with left:
793
+ evt: Optional[RandomEvent] = st.session_state.currentEvent
794
+ if evt:
795
+ st.markdown(f"""
796
+ <div class="event-card">
797
+ <div class="event-title">{evt.icon} {evt.title}</div>
798
+ <p>{evt.description}</p>
799
+ </div>
800
+ """, unsafe_allow_html=True)
801
+
802
+ badge_colors = {
803
+ "opportunity": "🌟 GREAT OPPORTUNITY!",
804
+ "expense": "⚠️ EXPENSE ALERT",
805
+ "penalty": "⛔ CHALLENGE",
806
+ "bonus": "🎁 AWESOME BONUS!"
807
+ }
808
+ st.success(badge_colors[evt.type])
809
+
810
+ c1, c2 = st.columns(2)
811
+ if evt.choices:
812
+ with c1:
813
+ st.buttondd(evt.choices["accept"], use_container_width=True, on_click=lambda: handle_event_choice(True), key="evt_accept", variant="success")
814
+ with c2:
815
+ st.buttondd(evt.choices["decline"], use_container_width=True, on_click=lambda: handle_event_choice(False), key="evt_decline", variant="warning")
816
+ else:
817
+ st.buttondd("✨ Continue (uses 1 day)", use_container_width=True, on_click=lambda: handle_event_choice(True), key="evt_continue", variant="success")
818
+
819
+ st.markdown("---")
820
+
821
+ # --- Loan status + payoff projection ---
822
+ progress = progress_percent(st.session_state.loan.totalOwed, st.session_state.loan.monthlyPayment, st.session_state.loan.totalMonths)/100
823
+ months_est, interest_est = payoff_projection(
824
+ st.session_state.loan.totalOwed,
825
+ st.session_state.loan.interestRate,
826
+ st.session_state.loan.monthlyPayment
827
+ )
828
+ if months_est is None:
829
+ proj_html = "<div><strong>🧮 Projection:</strong> Payment too low — balance will grow.</div>"
830
+ else:
831
+ years_est = months_est / 12.0
832
+ proj_html = (
833
+ f"<div><strong>🧮 Projection:</strong> ~{months_est} payments "
834
+ f"(~{years_est:.1f} years), est. interest {fmt_money(interest_est)}</div>"
835
+ )
836
+
837
+ st.markdown(f"""
838
+ <div class="metric-card">
839
+ <h3>💳 Loan Status</h3>
840
+ <div style="margin-top: 1rem;">
841
+ <div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
842
+ <div><strong>💰 Still Owed:</strong> {fmt_money(st.session_state.loan.totalOwed)}</div>
843
+ <div><strong>💳 Monthly Due:</strong> {fmt_money(st.session_state.loan.monthlyPayment)}</div>
844
+ </div>
845
+ {proj_html}
846
+ <div style="margin: 1rem 0;">
847
+ <div style="background: #e0e0e0; border-radius: 10px; height: 20px; overflow: hidden;">
848
+ <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); height: 100%; width: {progress*100}%; border-radius: 10px;"></div>
849
+ </div>
850
+ <div style="text-align: center; margin-top: 0.5rem;"><strong>🎯 Progress: {progress*100:.1f}% Complete!</strong></div>
851
+ </div>
852
+ <div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: .5rem;">
853
+ <div><strong>✅ Payments:</strong> {st.session_state.loan.monthsPaid}/{st.session_state.loan.totalMonths}</div>
854
+ <div><strong>{"⚠️" if st.session_state.loan.missedPayments > 0 else "✅"} Missed:</strong> {st.session_state.loan.missedPayments}</div>
855
+ <div><small>{(st.session_state.loan.interestRate*100/12):.1f}% monthly interest</small></div>
856
+ <div><small>📅 Paid This Month: {fmt_money(st.session_state.amountPaidThisMonth)}{" — ✅ Full" if st.session_state.fullPaymentMadeThisMonth else ""}</small></div>
857
+ </div>
858
+ </div>
859
+ </div>
860
+ """, unsafe_allow_html=True)
861
+
862
+ can_afford = st.session_state.wallet >= (st.session_state.loan.monthlyPayment + required_expenses_total())
863
+ # use ceil so you can actually clear small residuals
864
+ pay_what = max(0, min(st.session_state.wallet - required_expenses_total(), math.ceil(st.session_state.loan.totalOwed)))
865
+
866
+ b1, b2, b3 = st.columns([2,2,2])
867
+ with b1:
868
+ st.buttondd(
869
+ f"💰 Full Payment (1 day) {fmt_money(st.session_state.loan.monthlyPayment)}",
870
+ disabled=not can_afford,
871
+ use_container_width=True,
872
+ on_click=do_make_payment_full,
873
+ key="btn_pay_full",
874
+ variant="success" if can_afford else "warning"
875
+ )
876
+ with b2:
877
+ st.buttondd(
878
+ f"💸 Pay What I Can (1 day) {fmt_money(pay_what)}",
879
+ disabled=pay_what<=0,
880
+ use_container_width=True,
881
+ on_click=do_make_payment_partial,
882
+ key="btn_pay_partial",
883
+ variant="success" if pay_what>0 else "warning"
884
+ )
885
+ with b3:
886
+ st.buttondd("⏭️ Skip Payment (1 day)", use_container_width=True, on_click=do_skip_payment, key="btn_skip", variant="danger")
887
+
888
+ st.markdown("### 🏠 Monthly Expenses (Free Actions - No Time Cost!)")
889
+ cols = st.columns(2)
890
+ for i, exp in enumerate(EXPENSES):
891
+ with cols[i % 2]:
892
+ required_text = "⚠️ Required" if exp["required"] else "🌟 Optional"
893
+ happiness_text = f"<br><small>😊 (+{exp.get('happinessBoost', 0)}% happiness)</small>" if exp.get('happinessBoost', 0) > 0 else ""
894
+ st.markdown(f"""
895
+ <div class="expense-card">
896
+ <h4>{exp['emoji']} {exp['name']} - {fmt_money(exp['amount'])}</h4>
897
+ <p>{required_text}{happiness_text}</p>
898
+ </div>
899
+ """, unsafe_allow_html=True)
900
+ disabled = st.session_state.wallet < exp["amount"]
901
+ st.buttondd(
902
+ f"{exp['emoji']} Pay",
903
+ key=f"pay_{exp['id']}",
904
+ disabled=disabled,
905
+ on_click=lambda e=exp: pay_expense(e),
906
+ use_container_width=True,
907
+ variant="success" if not disabled else "warning"
908
+ )
909
+
910
+ st.markdown("---")
911
+ label = "🌅 End Day & See What Happens!"
912
+ if st.session_state.currentDay == st.session_state.daysInMonth:
913
+ label = f"🗓️ End Month {st.session_state.currentMonth} → Payday: {fmt_money(st.session_state.monthlyIncome)}!"
914
+ st.buttondd(label, disabled=st.session_state.currentEvent is not None, use_container_width=True, on_click=lambda: advance_day(no_event=False), key="btn_end_day", variant="success")
915
+ st.buttondd("⏩ Skip to Month End (When Low on Money!)", use_container_width=True, on_click=fast_forward_to_month_end, key="btn_ff", variant="warning")
916
+
917
+ with right:
918
+ health_color = "🟢" if st.session_state.health > 70 else "🟡" if st.session_state.health > 30 else "🔴"
919
+ happiness_color = "😊" if st.session_state.happiness > 70 else "😐" if st.session_state.happiness > 30 else "😢"
920
+
921
+ net_monthly = st.session_state.monthlyIncome - required_expenses_total() - st.session_state.loan.monthlyPayment
922
+ net_color = "🟢" if net_monthly > 0 else "🔴"
923
+
924
+ st.markdown("### 🌟 Your Wellbeing")
925
+ st.markdown(f"""
926
+ <div class="metric-card">
927
+ <h3>💪 Status Overview</h3>
928
+ <div style="margin-top: 1rem;">
929
+ <div style="display: flex; justify-content: space-between; margin-bottom: 1rem;">
930
+ <div><strong>❤️ Health:</strong> {health_color} {st.session_state.health}%</div>
931
+ <div><strong>😊 Happiness:</strong> {happiness_color} {st.session_state.happiness}%</div>
932
+ </div>
933
+ <div style="text-align: center; padding: 1rem 0; border-top: 1px solid #eee;">
934
+ <strong>💹 Monthly Budget:</strong> {net_color} {fmt_money(net_monthly)}<br>
935
+ <small>After loan & required expenses</small>
936
+ </div>
937
+ </div>
938
+ </div>
939
+ """, unsafe_allow_html=True)
940
+
941
+ # Work button
942
+ work_available = can_work_this_period()
943
+ st.buttondd("💼 Work for Money! (1 day, once/fortnight)\n~JMD$6k–9k, -10% Happiness",
944
+ disabled=not work_available,
945
+ on_click=do_work_for_money,
946
+ key="btn_work",
947
+ variant="success" if work_available else "warning")
948
+ cur_fn = current_fortnight()
949
+ st.caption(f"📅 Fortnight {cur_fn}/2 — you can work once each 2 weeks!")
950
+
951
+ def reset_game():
952
+ # INTEGRATION: only reset the Debt Dilemma state; then rerun
953
+ for k in list(st.session_state.keys()):
954
+ # keep global app keys like 'user', 'current_page', 'current_game'
955
+ if k not in {"user", "current_page", "xp", "streak", "current_game", "temp_user"}:
956
+ del st.session_state[k]
957
+ init_state()
958
+ st.rerun()
959
+
960
+ def hospital_screen():
961
+ st.error("🏥 You've been hospitalized. Health hit 0%. Game over.")
962
+ # use scoped button
963
+ st.buttondd("🔁 Play again", on_click=reset_game, key="btn_again_hospital", variant="success")
964
+
965
+ def burnout_screen():
966
+ st.warning("😵 You burned out. Happiness hit 0%. Game over.")
967
+ # use scoped button
968
+ st.buttondd("🔁 Play again", on_click=reset_game, key="btn_again_burnout", variant="success")
969
+
970
+ def level_complete_screen():
971
+ _award_level_completion_if_needed()
972
+ st.success(f"🎉 Level {st.session_state.currentLevel} complete!")
973
+ def _go_next():
974
+ st.session_state.currentLevel += 1
975
+ lvl = get_level(st.session_state.currentLevel)
976
+ st.session_state.loan = LoanDetails(
977
+ principal=lvl.loanAmount,
978
+ interestRate=lvl.interestRate,
979
+ monthlyPayment=lvl.monthlyPayment,
980
+ totalOwed=float(lvl.loanAmount),
981
+ monthsPaid=0,
982
+ totalMonths=lvl.totalMonths,
983
+ missedPayments=0,
984
+ creditScore=st.session_state.loan.creditScore,
985
+ )
986
+ st.session_state.monthlyIncome = lvl.startingIncome
987
+ st.session_state.gamePhase = "setup"
988
+ # use scoped button
989
+ st.buttondd("➡️ Start next level", on_click=_go_next, key="btn_next_level", variant="success")
990
+
991
+ def completed_screen():
992
+ _award_level_completion_if_needed()
993
+ st.balloons()
994
+ st.success("🏁 You’ve finished all levels or ran out of rounds!")
995
+ # use scoped button
996
+ st.buttondd("🔁 Play again", on_click=reset_game, key="btn_again_completed", variant="success")
997
+
998
+ # ===============================
999
+ # Public entry point expected by game.py
1000
+ # ===============================
1001
+ def show_debt_dilemma():
1002
+
1003
+ load_css(os.path.join("assets", "styles.css"))
1004
+
1005
+ _ensure_dd_css()
1006
+ st.markdown(f'<div class="{DD_SCOPE_CLASS}">', unsafe_allow_html=True) # OPEN SCOPE
1007
+
1008
+ # Initialize game state
1009
+ init_state()
1010
+
1011
+ # Route within the game
1012
+ phase = st.session_state.gamePhase
1013
+ if phase == "setup":
1014
+ setup_screen()
1015
+ elif phase == "hospital":
1016
+ hospital_screen()
1017
+ elif phase == "burnout":
1018
+ burnout_screen()
1019
+ elif phase == "level-complete":
1020
+ level_complete_screen()
1021
+ elif phase == "completed":
1022
+ completed_screen()
1023
+ else:
1024
+ main_screen()
1025
+
1026
+ st.markdown('</div>', unsafe_allow_html=True) # CLOSE SCOPE
phase/Student_view/games/profitpuzzle.py ADDED
@@ -0,0 +1,495 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from utils import db as dbapi
2
+ import streamlit as st
3
+
4
+ def _refresh_global_xp():
5
+ user = st.session_state.get("user")
6
+ if not user:
7
+ return
8
+ try:
9
+ stats = dbapi.user_xp_and_level(user["user_id"])
10
+ st.session_state.xp = stats.get("xp", st.session_state.get("xp", 0))
11
+ st.session_state.streak = stats.get("streak", st.session_state.get("streak", 0))
12
+ except Exception as e:
13
+ st.warning(f"XP refresh failed: {e}")
14
+
15
+ # --- CSS Styling ---
16
+ def load_css():
17
+ st.markdown("""
18
+ <style>
19
+ /* Hide Streamlit default elements */
20
+ #MainMenu {visibility: hidden;}
21
+ footer {visibility: hidden;}
22
+ header {visibility: hidden;}
23
+
24
+ /* Main container styling */
25
+ .main .block-container {
26
+ padding-top: 2rem;
27
+ padding-bottom: 2rem;
28
+ font-family: "Comic Sans MS", "Segoe UI", Arial, sans-serif;
29
+ }
30
+
31
+ /* Game header styling */
32
+ .game-header {
33
+ background: linear-gradient(135deg, #d946ef, #ec4899);
34
+ padding: 2rem;
35
+ border-radius: 15px;
36
+ color: white;
37
+ text-align: center;
38
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
39
+ margin-bottom: 2rem;
40
+ }
41
+
42
+ /* Scenario card styling */
43
+ .scenario-card {
44
+ background: #ffffff;
45
+ padding: 2rem;
46
+ border-radius: 15px;
47
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
48
+ border: 2px solid #e5e7eb;
49
+ margin-bottom: 1.5rem;
50
+ }
51
+
52
+ /* Variables display */
53
+ .variables-card {
54
+ background: linear-gradient(to right, #4ade80, #22d3ee);
55
+ padding: 1.5rem;
56
+ border-radius: 12px;
57
+ color: white;
58
+ margin: 1rem 0;
59
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
60
+ }
61
+
62
+ /* Progress card */
63
+ .progress-card {
64
+ background: #3b82f6;
65
+ padding: 1.5rem;
66
+ border-radius: 12px;
67
+ color: white;
68
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
69
+ margin-bottom: 1rem;
70
+ }
71
+
72
+ /* XP display */
73
+ .xp-display {
74
+ background: #10b981;
75
+ padding: 1rem;
76
+ border-radius: 12px;
77
+ color: white;
78
+ text-align: center;
79
+ font-weight: bold;
80
+ margin-bottom: 1rem;
81
+ }
82
+
83
+ /* Solution card */
84
+ .solution-card {
85
+ background: #f0f9ff;
86
+ padding: 1.5rem;
87
+ border-radius: 12px;
88
+ border: 2px solid #0ea5e9;
89
+ margin: 1rem 0;
90
+ }
91
+
92
+ /* Custom button styling */
93
+ /* Default button styling (white buttons) */
94
+ .stButton > button {
95
+ background: #ffffff !important;
96
+ color: #111827 !important; /* dark gray text */
97
+ border: 2px solid #d1d5db !important;
98
+ border-radius: 12px !important;
99
+ padding: 0.75rem 1.5rem !important;
100
+ font-weight: bold !important;
101
+ font-size: 1.1rem !important;
102
+ transition: all 0.3s ease !important;
103
+ font-family: "Comic Sans MS", "Segoe UI", Arial, sans-serif !important;
104
+ }
105
+
106
+ .stButton > button:hover {
107
+ background: #f9fafb !important;
108
+ transform: translateY(-2px) !important;
109
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important;
110
+ }
111
+
112
+ /* Next button styling */
113
+ .next-btn button {
114
+ background: #3b82f6 !important;
115
+ color: white !important;
116
+ border: none !important;
117
+ }
118
+ .next-btn button:hover {
119
+ background: #2563eb !important;
120
+ }
121
+
122
+ /* Restart button styling */
123
+ .restart-btn button {
124
+ background: #ec4899 !important;
125
+ color: white !important;
126
+ border: none !important;
127
+ }
128
+ .restart-btn button:hover {
129
+ background: #db2777 !important;
130
+ }
131
+
132
+
133
+
134
+ /* Text input styling */
135
+ .stTextInput > div > div > input {
136
+ border-radius: 12px !important;
137
+ border: 2px solid #d1d5db !important;
138
+ padding: 12px 16px !important;
139
+ font-size: 1.1rem !important;
140
+ font-family: "Comic Sans MS", "Segoe UI", Arial, sans-serif !important;
141
+ }
142
+
143
+ .stTextInput > div > div > input:focus {
144
+ border-color: #10b981 !important;
145
+ box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2) !important;
146
+ }
147
+
148
+ /* Slider styling */
149
+ .stSlider > div > div > div {
150
+ background: linear-gradient(to right, #4ade80, #22d3ee) !important;
151
+ }
152
+
153
+ /* Sidebar styling */
154
+ .css-1d391kg {
155
+ background: #f8fafc;
156
+ border-radius: 12px;
157
+ padding: 1rem;
158
+ }
159
+
160
+ /* Success/Error message styling */
161
+ .stSuccess {
162
+ background: #dcfce7 !important;
163
+ border: 2px solid #16a34a !important;
164
+ border-radius: 12px !important;
165
+ color: #15803d !important;
166
+ }
167
+
168
+ .stError {
169
+ background: #fef2f2 !important;
170
+ border: 2px solid #dc2626 !important;
171
+ border-radius: 12px !important;
172
+ color: #dc2626 !important;
173
+ }
174
+
175
+ .stInfo {
176
+ background: #eff6ff !important;
177
+ border: 2px solid #2563eb !important;
178
+ border-radius: 12px !important;
179
+ color: #1d4ed8 !important;
180
+ }
181
+
182
+ .stWarning {
183
+ background: #fffbeb !important;
184
+ border: 2px solid #d97706 !important;
185
+ border-radius: 12px !important;
186
+ color: #92400e !important;
187
+ }
188
+
189
+ /* Difficulty badge styling */
190
+ .difficulty-easy {
191
+ background: #dcfce7;
192
+ color: #16a34a;
193
+ padding: 0.25rem 0.75rem;
194
+ border-radius: 12px;
195
+ font-weight: bold;
196
+ font-size: 0.9rem;
197
+ }
198
+
199
+ .difficulty-medium {
200
+ background: #fef3c7;
201
+ color: #d97706;
202
+ padding: 0.25rem 0.75rem;
203
+ border-radius: 12px;
204
+ font-weight: bold;
205
+ font-size: 0.9rem;
206
+ }
207
+
208
+ .difficulty-hard {
209
+ background: #fecaca;
210
+ color: #dc2626;
211
+ padding: 0.25rem 0.75rem;
212
+ border-radius: 12px;
213
+ font-weight: bold;
214
+ font-size: 0.9rem;
215
+ }
216
+ </style>
217
+ """, unsafe_allow_html=True)
218
+
219
+ #--- Show progress in sidebar ---
220
+ # --- Sidebar Progress ---
221
+ def show_profit_progress_sidebar():
222
+ scenarios = st.session_state.get("profit_scenarios", [])
223
+ total_scenarios = len(scenarios)
224
+ current_s = st.session_state.get("current_scenario", 0)
225
+ completed_count = len(st.session_state.get("completed_scenarios", []))
226
+
227
+ with st.sidebar:
228
+ #add sidebar details for eg
229
+ st.sidebar.markdown(f"""
230
+ <div class="xp-display">
231
+ <h2>🏆 Your Progress</h2>
232
+ <p style="font-size: 1.5rem;">Total XP: {st.session_state.get("score", 0)}</p>
233
+ </div>
234
+ """, unsafe_allow_html=True)
235
+
236
+ st.sidebar.markdown("### 🎯 Challenge List")
237
+ for i, s in enumerate(scenarios):
238
+ if i in st.session_state.completed_scenarios:
239
+ st.sidebar.success(f"✅ {s['title']}")
240
+ elif i == st.session_state.current_scenario:
241
+ st.sidebar.info(f"🎯 {s['title']} (Current)")
242
+ else:
243
+ st.sidebar.write(f"⭕ {s['title']}")
244
+
245
+ st.sidebar.markdown("""
246
+ <div style="background: #f0f9ff; padding: 1rem; border-radius: 12px; border: 2px solid #0ea5e9; margin-top: 1rem;">
247
+ <h3>🧮 Profit Formula</h3>
248
+ <p><strong>Profit = Revenue - Cost</strong></p>
249
+ <hr style="border-color: #0ea5e9;">
250
+ <p><strong>Revenue</strong> = Units × Selling Price</p>
251
+ <p><strong>Cost</strong> = Units × Cost per Unit</p>
252
+ </div>
253
+ """, unsafe_allow_html=True)
254
+
255
+ #space and back button
256
+ st.sidebar.markdown("<br>", unsafe_allow_html=True)
257
+
258
+ if st.button("← Back to Games Hub", use_container_width=True):
259
+ st.session_state.current_game = None
260
+ st.rerun()
261
+
262
+ def _current_scenario():
263
+ ps = st.session_state.get("profit_scenarios", [])
264
+ idx = st.session_state.get("current_scenario", 0)
265
+ return (ps[idx] if ps and 0 <= idx < len(ps) else None)
266
+
267
+
268
+ def next_scenario():
269
+ total = len(st.session_state.get("profit_scenarios", []))
270
+ if st.session_state.get("current_scenario", 0) < total - 1:
271
+ st.session_state.current_scenario += 1
272
+ st.session_state.user_answer = ""
273
+ st.session_state.show_solution = False
274
+ st.rerun()
275
+
276
+ def reset_game():
277
+ st.session_state.current_scenario = 0
278
+ st.session_state.user_answer = ""
279
+ st.session_state.show_solution = False
280
+ st.session_state.score = 0
281
+ st.session_state.completed_scenarios = []
282
+ st.rerun()
283
+
284
+
285
+
286
+ # --- Profit Puzzle Game ---
287
+ def show_profit_puzzle():
288
+ # Load CSS styling
289
+ load_css()
290
+
291
+ st.markdown("""
292
+ <div class="game-header">
293
+ <h1>🎯 Profit Puzzle Challenge!</h1>
294
+ <p>Learn to calculate profits while having fun! 🚀</p>
295
+ </div>
296
+ """, unsafe_allow_html=True)
297
+
298
+ # -------------------------
299
+ # Game State Management
300
+ # -------------------------
301
+ if "current_scenario" not in st.session_state:
302
+ st.session_state.current_scenario = 0
303
+ if "user_answer" not in st.session_state:
304
+ st.session_state.user_answer = ""
305
+ if "show_solution" not in st.session_state:
306
+ st.session_state.show_solution = False
307
+ if "score" not in st.session_state:
308
+ st.session_state.score = 0
309
+ if "completed_scenarios" not in st.session_state:
310
+ st.session_state.completed_scenarios = []
311
+ if "slider_units" not in st.session_state:
312
+ st.session_state.slider_units = 10
313
+ if "slider_price" not in st.session_state:
314
+ st.session_state.slider_price = 50
315
+ if "slider_cost" not in st.session_state:
316
+ st.session_state.slider_cost = 30
317
+
318
+ # -------------------------
319
+ # Scenario Setup
320
+ # -------------------------
321
+ scenarios = [
322
+ {
323
+ "id": "juice-stand",
324
+ "title": "🧃 Juice Stand Profit",
325
+ "description": "You sold juice at your school event. Calculate your profit!",
326
+ "variables": {"units": 10, "sellingPrice": 50, "costPerUnit": 30},
327
+ "difficulty": "easy",
328
+ "xpReward": 20
329
+ },
330
+ {
331
+ "id": "craft-business",
332
+ "title": "🎨 Craft Business",
333
+ "description": "Your handmade crafts are selling well. What's your profit?",
334
+ "variables": {"units": 15, "sellingPrice": 80, "costPerUnit": 45},
335
+ "difficulty": "medium",
336
+ "xpReward": 20
337
+ },
338
+ {
339
+ "id": "bake-sale",
340
+ "title": "🧁 School Bake Sale",
341
+ "description": "You organized a bake sale fundraiser. Calculate the profit!",
342
+ "variables": {"units": 25, "sellingPrice": 60, "costPerUnit": 35},
343
+ "difficulty": "medium",
344
+ "xpReward": 20
345
+ },
346
+ {
347
+ "id": "tutoring-service",
348
+ "title": "📚 Tutoring Service",
349
+ "description": "You've been tutoring younger students. What's your profit after expenses?",
350
+ "variables": {"units": 8, "sellingPrice": 200, "costPerUnit": 50},
351
+ "difficulty": "hard",
352
+ "xpReward": 40
353
+ },
354
+ {
355
+ "id": "dynamic-scenario",
356
+ "title": "🎮 Custom Business Scenario",
357
+ "description": "Use the sliders to create your own business scenario and calculate profit!",
358
+ "variables": {"units": st.session_state.slider_units,
359
+ "sellingPrice": st.session_state.slider_price,
360
+ "costPerUnit": st.session_state.slider_cost},
361
+ "difficulty": "medium",
362
+ "xpReward": 50
363
+ }
364
+ ]
365
+
366
+ # after scenarios = [...]
367
+ st.session_state.profit_scenarios = scenarios # Store scenarios in session state for sidebar access
368
+ scenario = scenarios[st.session_state.current_scenario]
369
+ is_dynamic = scenario["id"] == "dynamic-scenario"
370
+
371
+ # -------------------------
372
+ # Helper Functions
373
+ # -------------------------
374
+ def calculate_profit(units, price, cost):
375
+ return units * (price - cost)
376
+
377
+ def check_answer():
378
+ try:
379
+ user_val = float(st.session_state.user_answer)
380
+ except ValueError:
381
+ st.warning("Please enter a number.")
382
+ return
383
+
384
+ units = int(scenario["variables"]["units"])
385
+ price = int(scenario["variables"]["sellingPrice"])
386
+ cost = int(scenario["variables"]["costPerUnit"])
387
+ actual_profit = units * (price - cost)
388
+
389
+ correct = abs(user_val - actual_profit) < 0.01
390
+ reward = int(scenario.get("xpReward", 20)) if correct else 0
391
+
392
+ # UI feedback
393
+ if correct:
394
+ st.success(f"✅ Awesome! You got it right! +{reward} XP 🎉")
395
+ st.session_state.score += reward
396
+ if st.session_state.current_scenario not in st.session_state.completed_scenarios:
397
+ st.session_state.completed_scenarios.append(st.session_state.current_scenario)
398
+ else:
399
+ st.error(f"❌ Oops. Correct profit is JA${actual_profit:.2f}")
400
+
401
+ # Persist to TiDB if logged in
402
+ user = st.session_state.get("user")
403
+ if user:
404
+ try:
405
+ dbapi.record_profit_puzzle_result(
406
+ user_id=user["user_id"],
407
+ scenario_id=scenario.get("id") or f"scenario_{st.session_state.current_scenario}",
408
+ title=scenario.get("title", f"Scenario {st.session_state.current_scenario+1}"),
409
+ units=units, price=price, cost=cost,
410
+ user_answer=user_val, actual_profit=float(actual_profit),
411
+ is_correct=bool(correct), gained_xp=int(reward)
412
+ )
413
+ _refresh_global_xp()
414
+ except Exception as e:
415
+ st.warning(f"Could not save result: {e}")
416
+ else:
417
+ st.info("Login to earn and save XP.")
418
+
419
+ st.session_state.show_solution = True
420
+
421
+ def next_scenario():
422
+ if st.session_state.current_scenario < len(scenarios) - 1:
423
+ st.session_state.current_scenario += 1
424
+ st.session_state.user_answer = ""
425
+ st.session_state.show_solution = False
426
+
427
+ def reset_game():
428
+ st.session_state.current_scenario = 0
429
+ st.session_state.user_answer = ""
430
+ st.session_state.show_solution = False
431
+ st.session_state.score = 0
432
+ st.session_state.completed_scenarios = []
433
+
434
+ # -------------------------
435
+ # UI Layout
436
+ # -------------------------
437
+
438
+ difficulty_class = f"difficulty-{scenario['difficulty']}"
439
+ st.markdown(f"""
440
+ <div class="scenario-card">
441
+ <h2>{scenario['title']}</h2>
442
+ <span class="{difficulty_class}">{scenario['difficulty'].upper()}</span>
443
+ <p style="margin-top: 1rem; font-size: 1.1rem;">{scenario["description"]}</p>
444
+ <h3>📊 Business Details</h3>
445
+ <p><strong>Units Sold:</strong> {scenario['variables']['units']}</p>
446
+ <p><strong>Selling Price per Unit:</strong> JA${scenario['variables']['sellingPrice']}</p>
447
+ <p><strong>Cost per Unit:</strong> JA${scenario['variables']['costPerUnit']}</p>
448
+ </div>
449
+ """, unsafe_allow_html=True)
450
+
451
+ if is_dynamic:
452
+ st.markdown("### 🎛️ Customize Your Business")
453
+ st.session_state.slider_units = st.slider("Units Sold", 1, 50, st.session_state.slider_units)
454
+ st.session_state.slider_price = st.slider("Selling Price per Unit (JA$)", 10, 200, st.session_state.slider_price, 5)
455
+ st.session_state.slider_cost = st.slider("Cost per Unit (JA$)", 5, st.session_state.slider_price - 1, st.session_state.slider_cost, 5)
456
+
457
+ scenario["variables"] = {
458
+ "units": st.session_state.slider_units,
459
+ "sellingPrice": st.session_state.slider_price,
460
+ "costPerUnit": st.session_state.slider_cost
461
+ }
462
+
463
+
464
+ st.markdown("### 💰 What's the profit?")
465
+ st.text_input("Enter Profit (JA$):", key="user_answer", disabled=st.session_state.show_solution, placeholder="Type your answer here...")
466
+
467
+ if not st.session_state.show_solution:
468
+ st.button("🎯 Check My Answer!", on_click=check_answer)
469
+ else:
470
+ actual_profit = calculate_profit(
471
+ scenario["variables"]["units"],
472
+ scenario["variables"]["sellingPrice"],
473
+ scenario["variables"]["costPerUnit"]
474
+ )
475
+
476
+ st.markdown(f"""
477
+ <div class="solution-card">
478
+ <h3>🧮 Solution Breakdown</h3>
479
+ <p><strong>Revenue:</strong> {scenario['variables']['units']} × JA${scenario['variables']['sellingPrice']} = JA${scenario['variables']['units'] * scenario['variables']['sellingPrice']}</p>
480
+ <p><strong>Total Cost:</strong> {scenario['variables']['units']} × JA${scenario['variables']['costPerUnit']} = JA${scenario['variables']['units'] * scenario['variables']['costPerUnit']}</p>
481
+ <p><strong>Profit:</strong> JA${scenario['variables']['units'] * scenario['variables']['sellingPrice']} - JA${scenario['variables']['units'] * scenario['variables']['costPerUnit']} = <span style="color: #10b981; font-weight: bold; font-size: 1.2rem;">JA${actual_profit}</span></p>
482
+ </div>
483
+ """, unsafe_allow_html=True)
484
+
485
+ next_col, restart_col = st.columns(2)
486
+ with next_col:
487
+ if st.session_state.current_scenario < len(scenarios) - 1:
488
+ st.markdown('<div class="next-btn">', unsafe_allow_html=True)
489
+ st.button("➡️ Next Challenge", on_click=next_scenario)
490
+ st.markdown('</div>', unsafe_allow_html=True)
491
+ with restart_col:
492
+ st.markdown('<div class="restart-btn">', unsafe_allow_html=True)
493
+ st.button("🔄 Start Over", on_click=reset_game)
494
+ st.markdown('</div>', unsafe_allow_html=True)
495
+
phase/Student_view/lesson.py ADDED
@@ -0,0 +1,717 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ from utils import db as dbapi
3
+ import os
4
+
5
+
6
+ # --- Load external CSS ---
7
+ def load_css(file_name):
8
+ try:
9
+ with open(file_name, 'r') as f:
10
+ st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
11
+ except FileNotFoundError:
12
+ st.warning("⚠️ Stylesheet not found. Please ensure 'assets/styles.css' exists.")
13
+
14
+
15
+ # --- Data structure for lessons ---
16
+ lessons_by_level = {
17
+ "beginner": [
18
+ {
19
+ "id": 1,
20
+ "title": "Introduction to Money",
21
+ "description": "Learn the basics of what money is, its history and how it works",
22
+ "duration": "20 min",
23
+ "completed": False,
24
+ "locked": False,
25
+ "difficulty": "Easy",
26
+ "content": "",
27
+ "topics": ["What is money? (coins, notes, digital money)",
28
+ "History of money in Jamaica (Jamaican coins, notes, and famous historical figures)",
29
+ "How money is used in daily life",
30
+ "Different types of currencies",
31
+ "Recognizing and counting Jamaican coins and notes",
32
+ "Play: Money Match","Quiz", "Summary"]
33
+ },
34
+ {
35
+ "id": 2,
36
+ "title": "Earning and Spending",
37
+ "description": "Start building the habit of saving money",
38
+ "duration": "8 min",
39
+ "completed": False,
40
+ "locked": False,
41
+ "difficulty": "Easy",
42
+ "topics": ["Jobs people do to earn money",
43
+ "Allowances and pocket money",
44
+ "Basic needs vs wants",
45
+ "Making choices when spending",
46
+ "Simple budget for small items",
47
+ "Play: Budget Builder","Quiz", "Summary"]
48
+ },
49
+ {
50
+ "id": 3,
51
+ "title": "Saving Money",
52
+ "description": "Learn to distinguish between essential and optional purchases",
53
+ "duration": "12 min",
54
+ "completed": False,
55
+ "locked": False,
56
+ "difficulty": "Easy",
57
+ "topics": ["Why saving is important",
58
+ "Where to save (piggy banks, banks)",
59
+ "Basic needs vs wants",
60
+ "Setting small savings goals",
61
+ "Reward of saving (buying a toy, snack, or school supplies)",
62
+ "Play Piggy Bank challenge","Quiz", "Summary"]
63
+ },
64
+ {
65
+ "id": 4,
66
+ "title": "Simple Financial Responsibility",
67
+ "description": "Learn to distinguish between essential and optional purchases",
68
+ "duration": "12 min",
69
+ "completed": False,
70
+ "locked": False,
71
+ "difficulty": "Easy",
72
+ "topics": ["Making smart choices with money",
73
+ "Giving and sharing (donations, helping family/friends)",
74
+ "Recognizing scams or unsafe spending",
75
+ "Introduction to keeping a simple money diary",
76
+ "Play Smart Shopper","Quiz", "Summary"]
77
+ }
78
+ ],
79
+ "intermediate": [
80
+ {
81
+ "id": 5,
82
+ "title": "Understanding Compound Interest",
83
+ "description": "Learn how your money can grow exponentially over time",
84
+ "duration": "15 min",
85
+ "completed": True,
86
+ "locked": False,
87
+ "difficulty": "Medium",
88
+ "content": "Compound interest is the eighth wonder of the world..."
89
+ },
90
+ {
91
+ "id": 6,
92
+ "title": "Building an Emergency Fund",
93
+ "description": "Create a financial safety net for unexpected expenses",
94
+ "duration": "12 min",
95
+ "completed": False,
96
+ "locked": False,
97
+ "difficulty": "Medium",
98
+ "content": "An emergency fund is your financial security blanket..."
99
+ },
100
+ {
101
+ "id": 7,
102
+ "title": "Introduction to Investing",
103
+ "description": "Basic concepts of making your money work for you",
104
+ "duration": "18 min",
105
+ "completed": False,
106
+ "locked": False,
107
+ "difficulty": "Medium",
108
+ "content": "Investing is putting your money to work..."
109
+ }
110
+ ],
111
+ "advanced": [
112
+ {
113
+ "id": 8,
114
+ "title": "Stock Market Mastery",
115
+ "description": "Advanced strategies for stock market investing",
116
+ "duration": "25 min",
117
+ "completed": False,
118
+ "locked": True,
119
+ "difficulty": "Hard",
120
+ "content": "Deep dive into stock analysis, market trends..."
121
+ },
122
+ {
123
+ "id": 9,
124
+ "title": "Retirement Planning Strategies",
125
+ "description": "Advanced retirement and tax-advantaged planning",
126
+ "duration": "30 min",
127
+ "completed": False,
128
+ "locked": True,
129
+ "difficulty": "Hard",
130
+ "content": "Advanced retirement planning strategies including 401(k)..."
131
+ },
132
+ {
133
+ "id": 10,
134
+ "title": "Real Estate Investment",
135
+ "description": "Building wealth through property investment",
136
+ "duration": "22 min",
137
+ "completed": False,
138
+ "locked": True,
139
+ "difficulty": "Hard",
140
+ "content": "Learn about real estate as an investment vehicle..."
141
+ }
142
+ ]
143
+ }
144
+
145
+ # --- Utility functions ---
146
+
147
+ def _db_to_general_lesson_shape(L: dict, sections: list) -> dict:
148
+ """Convert a DB lesson+sections into the same dict shape used by general lessons."""
149
+ level = (L.get("level") or "beginner").lower()
150
+ difficulty = {"beginner": "Easy", "intermediate": "Medium", "advanced": "Hard"}.get(level, "Easy")
151
+ topics = [ (s.get("title") or f"Topic {i+1}") for i, s in enumerate(sections) ]
152
+
153
+ # pick a duration heuristic if you don’t store it
154
+ duration = L.get("duration") or f"{max(6, len(topics)*6)} min"
155
+
156
+ return {
157
+ "id": int(L["lesson_id"]),
158
+ "title": L.get("title") or "Untitled",
159
+ "description": L.get("description") or "",
160
+ "duration": duration,
161
+ "completed": False,
162
+ "locked": False,
163
+ "difficulty": difficulty,
164
+ "topics": topics,
165
+ # keep optional extra fields if you like:
166
+ "_db_sections": sections, # stash raw sections so our DB renderer can use them
167
+ "_db_level": level,
168
+ }
169
+
170
+
171
+ def get_difficulty_color(difficulty):
172
+ """Return color based on difficulty level"""
173
+ colors = {
174
+ "Easy": "#28a745", # Green
175
+ "Medium": "#ffc107", # Yellow
176
+ "Hard": "#dc3545" # Red
177
+ }
178
+ return colors.get(difficulty, "#6c757d")
179
+
180
+ def get_level_info(level):
181
+
182
+ level_info = {
183
+ "beginner": {
184
+ "display": "🌱 Beginner",
185
+ "color": "#28a745",
186
+ "description": "Build your financial foundation"
187
+ },
188
+ "intermediate": {
189
+ "display": "🚀 Intermediate",
190
+ "color": "#007bff",
191
+ "description": "Grow your financial knowledge"
192
+ },
193
+ "advanced": {
194
+ "display": "🎓 Advanced",
195
+ "color": "#6f42c1",
196
+ "description": "Master advanced strategies"
197
+ }
198
+ }
199
+ return level_info.get(level, level_info["beginner"])
200
+
201
+ # --- Per-user lesson progress (in memory via session_state) ---
202
+ def _ensure_progress_state():
203
+ if "topic_progress" not in st.session_state:
204
+ st.session_state.topic_progress = {} # {lesson_id: max_topic_index_seen}
205
+ if "lesson_completed" not in st.session_state:
206
+ st.session_state.lesson_completed = {} # {lesson_id: True/False}
207
+
208
+ def _mark_topic_seen(lesson_id: int, topic_index: int):
209
+ _ensure_progress_state()
210
+ current = st.session_state.topic_progress.get(lesson_id, 0)
211
+ st.session_state.topic_progress[lesson_id] = max(current, topic_index)
212
+
213
+ def _is_lesson_completed(lesson_id: int) -> bool:
214
+ _ensure_progress_state()
215
+ return st.session_state.lesson_completed.get(lesson_id, False)
216
+
217
+ def _complete_lesson(lesson):
218
+ _ensure_progress_state()
219
+ st.session_state.lesson_completed[lesson["id"]] = True
220
+ # reflect in the in-memory lessons list too (so the cards show ✓)
221
+ lesson["completed"] = True
222
+
223
+
224
+ #-----
225
+
226
+ def show_lesson_cards(lessons, user_level):
227
+
228
+ level_info = get_level_info(user_level)
229
+
230
+ # Level header
231
+ st.markdown(f"""
232
+ <div style="background-color: {level_info['color']}; color: white; padding: 1.5rem; border-radius: 12px; margin-bottom: 2rem;">
233
+ <h2 style="margin: 0; font-size: 2rem;">{level_info['display']}</h2>
234
+ <p style="margin: 0.5rem 0 0 0; font-size: 1.1rem; opacity: 0.9;">{level_info['description']}</p>
235
+ </div>
236
+ """, unsafe_allow_html=True)
237
+
238
+ # Stats
239
+ total_lessons = len(lessons)
240
+ completed_lessons = sum(1 for lesson in lessons if lesson["completed"])
241
+
242
+ col1, col2, col3, col4 = st.columns(4)
243
+ with col1:
244
+ st.metric("📚 Total Lessons", total_lessons)
245
+ with col2:
246
+ st.metric("✅ Completed", completed_lessons)
247
+ with col3:
248
+ st.metric("📈 Progress", f"{int((completed_lessons/total_lessons)*100)}%")
249
+ with col4:
250
+ available_lessons = sum(1 for lesson in lessons if not lesson["locked"])
251
+ st.metric("🔓 Available", available_lessons)
252
+
253
+ st.markdown("---")
254
+
255
+ # Display lessons in a 3-column grid like Microsoft Learn
256
+ cols = st.columns(3)
257
+
258
+ for i, lesson in enumerate(lessons):
259
+ lesson["completed"] = lesson.get("completed") or _is_lesson_completed(lesson["id"])
260
+
261
+ with cols[i % 3]:
262
+ # Determine card status and styling
263
+ if lesson["locked"]:
264
+ icon_html = '🔒'
265
+ card_opacity = "0.6"
266
+ elif lesson["completed"]:
267
+ icon_html = '✓'
268
+ card_opacity = "1"
269
+ else:
270
+ icon_html = '📖'
271
+ card_opacity = "1"
272
+
273
+ card_html = f"""
274
+ <div class="lesson-card" style="
275
+ background: white;
276
+ border: 1px solid #e1e5e9;
277
+ border-radius: 8px;
278
+ padding: 24px;
279
+ margin-bottom: 15px;
280
+ opacity: {card_opacity};
281
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
282
+ height: 250px;
283
+ transition: all 0.3s ease;
284
+ cursor: pointer;
285
+ ">
286
+ <div style="display: flex; justify-content: space-between; align-items: flex-start;">
287
+ <h3 style="margin: 0; font-size: 18px; font-weight: 600; color: #323130; line-height: 1.3;">{lesson['title']}</h3>
288
+ <div style="color: #28a745; font-size: 24px;">{icon_html}</div>
289
+ </div>
290
+ <p style="margin: 0 0 16px 0; color: #605e5c; font-size: 14px; line-height: 1.4;">{lesson['description']}</p>
291
+ <div style="margin-bottom: 16px;">
292
+ <span style="background-color: #f3f2f1; color: #323130; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: 500;">{lesson['difficulty']}</span>
293
+ </div>
294
+ <div style="color: #0078d4; font-size: 14px; font-weight: 500;">{lesson['duration']}</div>
295
+ </div>
296
+ """
297
+
298
+ st.markdown(card_html, unsafe_allow_html=True)
299
+
300
+ # Native Streamlit buttons that actually work
301
+ if lesson["locked"]:
302
+ st.button("🔒 Locked", key=f"lesson_locked_{lesson['id']}", disabled=True, use_container_width=True)
303
+ elif lesson["completed"]:
304
+ if st.button("✅ Review", key=f"lesson_review_{lesson['id']}", use_container_width=True, type="secondary"):
305
+ st.session_state.selected_lesson = lesson["id"]
306
+ st.rerun()
307
+ else:
308
+ if st.button("▶ Start Lesson", key=f"lesson_btn_{lesson['id']}", type="primary", use_container_width=True):
309
+ st.session_state.selected_lesson = lesson["id"]
310
+ st.rerun()
311
+
312
+
313
+
314
+ def show_lesson_detail(lesson):
315
+ st.markdown(f"""
316
+ <h1 style="font-size:2.5rem; margin-bottom:0;">{lesson['title']}</h1>
317
+ <p style="color: #666; margin-top:0;">{lesson['duration']} &nbsp;•&nbsp; Module &nbsp;•&nbsp; {len(lesson.get("topics", []))} units</p>
318
+ <hr style="margin:1rem 0;"/>
319
+ """, unsafe_allow_html=True)
320
+
321
+ # Learning objectives section
322
+ st.subheader("Learning objectives")
323
+ st.markdown("""
324
+ <p style="color:#888; margin-bottom:0.5rem;">In this module, you'll</p>
325
+ """, unsafe_allow_html=True)
326
+
327
+ st.markdown(
328
+ "- Focus on: Basic money concepts \n"
329
+ "- Saving \n"
330
+ "- Spending \n"
331
+ "- Understanding simple financial transactions"
332
+ )
333
+
334
+ # Start button
335
+ if st.button("▶ Start", key=f"start_{lesson['id']}", type="primary"):
336
+ st.session_state.start_lesson = True
337
+ st.session_state.current_topic = 1
338
+ # reset progress for this lesson only if not previously completed
339
+ _ensure_progress_state()
340
+ if not _is_lesson_completed(lesson["id"]):
341
+ st.session_state.topic_progress[lesson["id"]] = 1
342
+ st.rerun()
343
+
344
+
345
+ st.markdown("---")
346
+
347
+ # Topics list (with clickable links)
348
+ st.subheader("Topics")
349
+ for i, t in enumerate(lesson.get("topics", []), start=1):
350
+ st.markdown(f"- [{t}](#topic-{i})")
351
+
352
+ st.markdown("---")
353
+
354
+ # Back button
355
+ if st.button("⬅ Back to Lessons", key=f"back_{lesson['id']}"):
356
+ st.session_state.selected_lesson = None
357
+ st.rerun()
358
+
359
+
360
+
361
+ def load_topic_content(lesson_id, topic_index):
362
+ """Load topic content for a specific lesson and topic index"""
363
+ file_path = os.path.join("phase", "Student_view", "lessons", f"lesson_{lesson_id}", f"topic_{topic_index}.txt")
364
+ if os.path.exists(file_path):
365
+ try:
366
+ with open(file_path, "r", encoding="utf-8") as f:
367
+ return f.read()
368
+ except Exception as e:
369
+ return f"⚠️ Error loading topic content: {e}"
370
+ return "⚠️ Topic content not available."
371
+
372
+
373
+ def show_lesson_page(lesson, lessons):
374
+ # Back link
375
+ if st.button("⬅ Back to Lessons", key=f"back_top_{lesson['id']}"):
376
+ st.session_state.selected_lesson = None
377
+ st.session_state.current_topic = 1
378
+ st.session_state.current_lesson = None
379
+ st.session_state.start_lesson = False
380
+ st.rerun()
381
+
382
+ # Header
383
+ st.markdown(f"""
384
+ <div style="background: linear-gradient(135deg,#20c997,#17a2b8); padding: 1.5rem; border-radius: 12px; color: white; margin-bottom: 2rem;">
385
+ <h2 style="margin:0;">{lesson['title']}</h2>
386
+ <p style="margin:0.3rem 0;">{lesson['description']}</p>
387
+ <div style="margin-top:0.5rem;">
388
+ <span style="background:#ffffff22; padding:6px 12px; border-radius:6px; margin-right:6px;">⏱ {lesson['duration']}</span>
389
+ <span style="background:#ffffff22; padding:6px 12px; border-radius:6px; margin-right:6px;">📘 Module {lesson['id']}</span>
390
+ <span style="background:#ffffff22; padding:6px 12px; border-radius:6px;">⭐ {lesson['difficulty']}</span>
391
+ </div>
392
+ </div>
393
+ """, unsafe_allow_html=True)
394
+
395
+ # Two-column layout
396
+ col1, col2 = st.columns([2, 1])
397
+
398
+ # --- LEFT COLUMN: Lesson content ---
399
+ with col1:
400
+ topics = lesson.get("topics", [])
401
+ if "current_topic" not in st.session_state:
402
+ st.session_state.current_topic = 1
403
+
404
+ # Clamp to valid range in case topics changed
405
+ st.session_state.current_topic = max(1, min(st.session_state.current_topic, len(topics)))
406
+ topic_idx = st.session_state.current_topic - 1
407
+ topic_name = topics[topic_idx]
408
+
409
+ # Track progress (max topic reached)
410
+ _mark_topic_seen(lesson["id"], st.session_state.current_topic)
411
+
412
+ st.subheader(f"Topic {st.session_state.current_topic}: {topic_name}")
413
+
414
+ # --- Special handling for Play / Quiz / Normal ---
415
+ if topic_name.lower().startswith("play"):
416
+ st.info("🎮 This is a game-based activity to reinforce learning.")
417
+ st.write("👉 Instructions on how to play go here.")
418
+ if st.button("▶ Play Game", key=f"play_game_{lesson['id']}_{topic_idx}"):
419
+ st.markdown(
420
+ '<a href="http://your-game-url.com" target="_blank">Open Game in New Tab</a>',
421
+ unsafe_allow_html=True
422
+ )
423
+
424
+ elif "quiz" in topic_name.lower():
425
+ st.info("📝 Time for a quick quiz!")
426
+ st.write("👉 Answer the following questions to test your knowledge.")
427
+ answer = st.radio("Q1. What is money?", ["Coins", "Bananas", "Shoes"])
428
+ if st.button("Submit Answer", key=f"quiz_submit_{lesson['id']}_{topic_idx}"):
429
+ if answer == "Coins":
430
+ st.success("✅ Correct!")
431
+ else:
432
+ st.error("❌ Try again.")
433
+ else:
434
+ content = load_topic_content(lesson["id"], st.session_state.current_topic)
435
+ st.markdown(f"""
436
+ <div class="topic-content">
437
+ {content}
438
+ </div>
439
+ """, unsafe_allow_html=True)
440
+
441
+ # Topic navigation buttons
442
+ prev_col, next_col = st.columns([1, 1])
443
+
444
+ with prev_col:
445
+ st.markdown("<div class='topic-nav-btn prev-btn'>", unsafe_allow_html=True)
446
+ if st.session_state.current_topic > 1:
447
+ if st.button("⬅ Previous Topic", key=f"prev_topic_{lesson['id']}", type="secondary"):
448
+ st.session_state.current_topic -= 1
449
+ st.rerun()
450
+ st.markdown("</div>", unsafe_allow_html=True)
451
+
452
+ with next_col:
453
+ st.markdown("<div class='topic-nav-btn next-btn'>", unsafe_allow_html=True)
454
+ is_last_topic = (st.session_state.current_topic >= len(topics))
455
+
456
+ if not is_last_topic:
457
+ # Normal next
458
+ if st.button("Next Topic ➡", key=f"next_topic_{lesson['id']}", type="primary"):
459
+ st.session_state.current_topic += 1
460
+ st.rerun()
461
+ else:
462
+ # 🔥 Replace Next with Complete on the last topic
463
+ if not _is_lesson_completed(lesson["id"]):
464
+ if st.button("✅ Complete Module", key=f"complete_{lesson['id']}", type="primary"):
465
+ _complete_lesson(lesson)
466
+ st.success("🎉 Module completed! Great job.")
467
+ # After completion, bounce back to lessons
468
+ st.session_state.selected_lesson = None
469
+ st.session_state.current_topic = 1
470
+ st.session_state.start_lesson = False
471
+ st.rerun()
472
+ else:
473
+ st.button("✅ Completed", key=f"completed_{lesson['id']}", disabled=True)
474
+ st.markdown("</div>", unsafe_allow_html=True)
475
+
476
+
477
+ # --- RIGHT COLUMN: Progress + Lesson navigation ---
478
+ with col2:
479
+ st.subheader("📊 Progress")
480
+ _ensure_progress_state()
481
+ topics_count = max(1, len(lesson.get("topics", [])))
482
+ seen = st.session_state.topic_progress.get(lesson["id"], 0)
483
+ # progress is capped at total topics, and if marked complete, force to full
484
+ if _is_lesson_completed(lesson["id"]):
485
+ pct = 1.0
486
+ else:
487
+ pct = min(seen / topics_count, 1.0)
488
+ st.progress(pct)
489
+
490
+ st.subheader("➡ Lesson Navigation")
491
+ idx = next((i for i, l in enumerate(lessons) if l["id"] == lesson["id"]), 0)
492
+
493
+ if idx > 0:
494
+ if st.button("⬅ Previous Lesson", key=f"prev_lesson_{lesson['id']}"):
495
+ st.session_state.selected_lesson = lessons[idx - 1]["id"]
496
+ st.session_state.current_topic = 1
497
+ st.rerun()
498
+
499
+ if idx < len(lessons) - 1:
500
+ if st.button("Next Lesson ➡", key=f"next_lesson_{lesson['id']}"):
501
+ st.session_state.selected_lesson = lessons[idx + 1]["id"]
502
+ st.session_state.current_topic = 1
503
+ st.rerun()
504
+
505
+
506
+ st.markdown("---")
507
+
508
+
509
+ def render_assigned_lesson(lesson_id: int, assignment_id: int | None = None):
510
+ """Render a teacher-assigned lesson from the DB using the SAME structure/CSS as general lessons."""
511
+ user = st.session_state.user
512
+ user_id = user["user_id"]
513
+
514
+ data = dbapi.get_lesson(lesson_id) # {"lesson": {...}, "sections":[...] }
515
+ if not data or not data.get("lesson"):
516
+ st.error("Lesson not found.")
517
+ if st.button("⬅ Back to classes"):
518
+ st.session_state.current_page = "Teacher Link"
519
+ st.rerun()
520
+ return
521
+
522
+ L = data["lesson"]
523
+ sections = sorted(data.get("sections", []), key=lambda s: int(s.get("position", 0)))
524
+ # 👉 adapt to the same card/page structure used by general lessons
525
+ lesson = _db_to_general_lesson_shape(L, sections)
526
+
527
+ # initialization flags (mirror general flow)
528
+ if "start_lesson" not in st.session_state:
529
+ st.session_state.start_lesson = False
530
+ if "selected_lesson" not in st.session_state:
531
+ st.session_state.selected_lesson = lesson["id"]
532
+
533
+ # Show like general: first a detail screen with Start, then topic pages
534
+ if st.session_state.start_lesson:
535
+ show_db_lesson_page(lesson)
536
+ else:
537
+ show_db_lesson_detail(lesson)
538
+
539
+
540
+ def show_db_lesson_detail(lesson):
541
+ # Same header + objectives block as your general detail view
542
+ st.markdown(f"""
543
+ <h1 style="font-size:2.5rem; margin-bottom:0;">{lesson['title']}</h1>
544
+ <p style="color: #666; margin-top:0;">{lesson['duration']} &nbsp;•&nbsp; Module &nbsp;•&nbsp; {len(lesson.get("topics", []))} units</p>
545
+ <hr style="margin:1rem 0;"/>
546
+ """, unsafe_allow_html=True)
547
+
548
+ st.subheader("Learning objectives")
549
+ st.markdown("<p style='color:#888;margin-bottom:.5rem;'>In this module, you'll</p>", unsafe_allow_html=True)
550
+ st.markdown(
551
+ "- Focus on: Key ideas from this lesson \n"
552
+ "- Build understanding step by step \n"
553
+ "- Practice and check your knowledge"
554
+ )
555
+
556
+ if st.button("▶ Start", key=f"db_start_{lesson['id']}", type="primary"):
557
+ st.session_state.start_lesson = True
558
+ st.session_state.current_topic = 1
559
+ _ensure_progress_state()
560
+ if not _is_lesson_completed(lesson["id"]):
561
+ st.session_state.topic_progress[lesson["id"]] = 1
562
+ st.rerun()
563
+
564
+ st.markdown("---")
565
+
566
+ st.subheader("Topics")
567
+ for i, t in enumerate(lesson.get("topics", []), start=1):
568
+ st.markdown(f"- [{t}](#topic-{i})")
569
+
570
+ st.markdown("---")
571
+
572
+ if st.button("⬅ Back to Lessons", key=f"db_back_detail_{lesson['id']}"):
573
+ st.session_state.selected_lesson = None
574
+ st.rerun()
575
+
576
+
577
+ def show_db_lesson_page(lesson):
578
+ # Back link – same as general
579
+ if st.button("⬅ Back to Lessons", key=f"db_back_top_{lesson['id']}"):
580
+ st.session_state.selected_lesson = None
581
+ st.session_state.current_topic = 1
582
+ st.session_state.current_lesson = None
583
+ st.session_state.start_lesson = False
584
+ st.rerun()
585
+
586
+ # Header – same gradient & chips
587
+ st.markdown(f"""
588
+ <div style="background: linear-gradient(135deg,#20c997,#17a2b8); padding: 1.5rem; border-radius: 12px; color: white; margin-bottom: 2rem;">
589
+ <h2 style="margin:0;">{lesson['title']}</h2>
590
+ <p style="margin:.3rem 0;">{lesson['description']}</p>
591
+ <div style="margin-top:.5rem;">
592
+ <span style="background:#ffffff22;padding:6px 12px;border-radius:6px;margin-right:6px;">⏱ {lesson['duration']}</span>
593
+ <span style="background:#ffffff22;padding:6px 12px;border-radius:6px;margin-right:6px;">📘 Module {lesson['id']}</span>
594
+ <span style="background:#ffffff22;padding:6px 12px;border-radius:6px;">⭐ {lesson['difficulty']}</span>
595
+ </div>
596
+ </div>
597
+ """, unsafe_allow_html=True)
598
+
599
+ # Layout
600
+ col1, col2 = st.columns([2, 1])
601
+
602
+ # LEFT: topic content (pulled from DB sections)
603
+ with col1:
604
+ topics = lesson.get("topics", [])
605
+ sections = lesson.get("_db_sections", [])
606
+ if "current_topic" not in st.session_state:
607
+ st.session_state.current_topic = 1
608
+
609
+ st.session_state.current_topic = max(1, min(st.session_state.current_topic, len(topics)))
610
+ idx = st.session_state.current_topic - 1
611
+ topic_name = topics[idx]
612
+ _mark_topic_seen(lesson["id"], st.session_state.current_topic)
613
+
614
+ st.subheader(f"Topic {st.session_state.current_topic}: {topic_name}")
615
+
616
+ # Same content container + CSS class as general
617
+ body = (sections[idx].get("content") or "⚠️ Topic content not available.")
618
+ st.markdown(f"<div class='topic-content'>{body}</div>", unsafe_allow_html=True)
619
+
620
+ # Prev/Next / Complete – identical logic
621
+ prev_col, next_col = st.columns([1, 1])
622
+ with prev_col:
623
+ st.markdown("<div class='topic-nav-btn prev-btn'>", unsafe_allow_html=True)
624
+ if st.session_state.current_topic > 1:
625
+ if st.button("⬅ Previous Topic", key=f"db_prev_topic_{lesson['id']}", type="secondary"):
626
+ st.session_state.current_topic -= 1
627
+ st.rerun()
628
+ st.markdown("</div>", unsafe_allow_html=True)
629
+
630
+ with next_col:
631
+ st.markdown("<div class='topic-nav-btn next-btn'>", unsafe_allow_html=True)
632
+ is_last = (st.session_state.current_topic >= len(topics))
633
+ if not is_last:
634
+ if st.button("Next Topic ➡", key=f"db_next_topic_{lesson['id']}", type="primary"):
635
+ st.session_state.current_topic += 1
636
+ st.rerun()
637
+ else:
638
+ if not _is_lesson_completed(lesson["id"]):
639
+ if st.button("✅ Complete Module", key=f"db_complete_{lesson['id']}", type="primary"):
640
+ _complete_lesson(lesson)
641
+ st.success("🎉 Module completed! Great job.")
642
+ st.session_state.selected_lesson = None
643
+ st.session_state.current_topic = 1
644
+ st.session_state.start_lesson = False
645
+ st.rerun()
646
+ else:
647
+ st.button("✅ Completed", key=f"db_completed_{lesson['id']}", disabled=True)
648
+ st.markdown("</div>", unsafe_allow_html=True)
649
+
650
+ # RIGHT: progress + lesson navigation – same UI
651
+ with col2:
652
+ st.subheader("📊 Progress")
653
+ _ensure_progress_state()
654
+ topics_count = max(1, len(lesson.get("topics", [])))
655
+ seen = st.session_state.topic_progress.get(lesson["id"], 0)
656
+ pct = 1.0 if _is_lesson_completed(lesson["id"]) else min(seen / topics_count, 1.0)
657
+ st.progress(pct)
658
+
659
+ st.subheader("➡ Lesson Navigation")
660
+ # For DB lessons, we don’t have a linear list like catalog; just basic controls
661
+ if st.button("⬅ Back to Lessons", key=f"db_back_side_{lesson['id']}"):
662
+ st.session_state.selected_lesson = None
663
+ st.session_state.current_topic = 1
664
+ st.rerun()
665
+
666
+ st.markdown("---")
667
+
668
+
669
+ def show_page():
670
+ # Load CSS
671
+ css_path = os.path.join("assets", "styles.css")
672
+ load_css(css_path)
673
+
674
+ st.markdown("""<style> .markdown { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; line-height: 1.6;} </style>""", unsafe_allow_html=True)
675
+
676
+ # Check login
677
+ if not st.session_state.get("user"):
678
+ st.error("❌ Please login first to access lessons.")
679
+ st.info("💡 Create an account to track your progress and unlock advanced content!")
680
+ return
681
+
682
+ user = st.session_state.user
683
+ user_level = user.get("level", "beginner").lower()
684
+
685
+ # Deep link from Teacher Link: if a selected_lesson isn't in the catalog,
686
+ # render it from the DB instead.
687
+ deep_lesson_id = st.session_state.get("selected_lesson")
688
+ deep_assignment_id = st.session_state.get("selected_assignment")
689
+
690
+ # Try to resolve in catalog first
691
+ lessons = lessons_by_level.get(user_level, [])
692
+ catalog_ids = {l["id"] for l in lessons}
693
+
694
+ if deep_lesson_id and deep_lesson_id not in catalog_ids:
695
+ # 👉 This is a teacher-assigned(DB) lesson. Open it directly.
696
+ render_assigned_lesson(int(deep_lesson_id), deep_assignment_id)
697
+ return
698
+
699
+ # Initialize session state
700
+ if "selected_lesson" not in st.session_state:
701
+ st.session_state.selected_lesson = None
702
+ if "start_lesson" not in st.session_state:
703
+ st.session_state.start_lesson = False
704
+
705
+ # If a lesson is selected
706
+ if st.session_state.selected_lesson:
707
+ lesson = next((l for l in lessons if l["id"] == st.session_state.selected_lesson), None)
708
+ if lesson:
709
+ if st.session_state.start_lesson: # 🚀 Jump straight into topics
710
+ show_lesson_page(lesson, lessons)
711
+ else:
712
+ show_lesson_detail(lesson)
713
+ else:
714
+ st.warning("⚠️ Lesson not found")
715
+ else:
716
+ # Otherwise → show lesson cards
717
+ show_lesson_cards(lessons, user_level)
phase/Student_view/lessons/lesson_1/topic_1.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ Money is something we use to buy things we need or want, like food, toys, or clothes.
2
+
3
+ In Jamaica, money comes in different forms:
4
+ - coins, which are small metal pieces like the shiny 1-dollar or 5-dollar coins
5
+ - notes, which are colorful paper bills such as the 50-dollar or 100-dollar notes featuring pictures of Jamaican heroes.
6
+
7
+ There's also digital money, which is like invisible money stored on cards or phones that you can use to pay without touching cash, for example, when parents use their phone to buy groceries at the supermarket.
phase/Student_view/quiz.py ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ from utils.quizdata import quizzes_data
3
+ import datetime
4
+ import json
5
+ from utils import db as dbapi
6
+
7
+
8
+
9
+
10
+ def _load_quiz_obj(quiz_id):
11
+ """
12
+ Return a normalized quiz object from either quizzes_data (built-in)
13
+ or the DB (utils.db.get_quiz). Always shape as:
14
+ {"title": str, "questions": [{"question","options","answer","points"}...]}
15
+ For DB rows, convert answer_key letters (e.g., "A") into the option text.
16
+ """
17
+ # Prefer the built-in quizzes when present (IDs 1..5)
18
+ if quiz_id in quizzes_data:
19
+ q = quizzes_data[quiz_id]
20
+ # ensure each question has points
21
+ for qq in q.get("questions", []):
22
+ qq.setdefault("points", 1)
23
+ return q
24
+
25
+ # Fallback: DB quiz
26
+ data = dbapi.get_quiz(quiz_id) # {'quiz': {...}, 'items': [...]}
27
+ if not data:
28
+ return {"title": f"Quiz {quiz_id}", "questions": []}
29
+
30
+ items_out = []
31
+ for it in (data.get("items") or []):
32
+ # decode JSON if needed
33
+ opts = it.get("options")
34
+ if isinstance(opts, (str, bytes)):
35
+ try:
36
+ opts = json.loads(opts)
37
+ except Exception:
38
+ opts = []
39
+ opts = opts or []
40
+
41
+ ans = it.get("answer_key")
42
+ if isinstance(ans, (str, bytes)):
43
+ try:
44
+ ans = json.loads(ans)
45
+ except Exception:
46
+ # allow a single letter like "A"
47
+ pass
48
+
49
+ # convert letter(s) -> option text
50
+ def letter_to_text(letter):
51
+ if isinstance(letter, str):
52
+ idx = ord(letter.upper()) - 65 # A->0, B->1...
53
+ return opts[idx] if 0 <= idx < len(opts) else letter
54
+ return letter
55
+
56
+ if isinstance(ans, list):
57
+ ans_text = [letter_to_text(a) for a in ans]
58
+ else:
59
+ ans_text = letter_to_text(ans)
60
+
61
+ items_out.append({
62
+ "question": it.get("question", ""),
63
+ "options": opts,
64
+ "answer": ans_text, # text or list of texts
65
+ "points": int(it.get("points", 1)),
66
+ })
67
+
68
+ title = (data.get("quiz") or {}).get("title", f"Quiz {quiz_id}")
69
+ return {"title": title, "questions": items_out}
70
+
71
+ def _letter_to_index(ch: str) -> int:
72
+ return ord(ch.upper()) - 65 # 'A'->0, 'B'->1, ...
73
+
74
+ def _correct_to_indices(correct, options: list[str]):
75
+ """
76
+ Map 'correct' (letters like 'A' or ['A','C'] OR option text(s)) -> list of indices.
77
+ """
78
+ idxs = []
79
+ if isinstance(correct, list):
80
+ for c in correct:
81
+ if isinstance(c, str):
82
+ if len(c) == 1 and c.isalpha():
83
+ idxs.append(_letter_to_index(c))
84
+ elif c in options:
85
+ idxs.append(options.index(c))
86
+ elif isinstance(correct, str):
87
+ if len(correct) == 1 and correct.isalpha():
88
+ idxs.append(_letter_to_index(correct))
89
+ elif correct in options:
90
+ idxs.append(options.index(correct))
91
+ # keep only valid unique indices
92
+ return sorted({i for i in idxs if 0 <= i < len(options)})
93
+
94
+ def _normalize_user_to_indices(user_answer, options: list[str]):
95
+ """
96
+ user_answer can be option text (or list of texts), or letters; return indices.
97
+ """
98
+ idxs = []
99
+ if isinstance(user_answer, list):
100
+ for a in user_answer:
101
+ if isinstance(a, str):
102
+ if a in options:
103
+ idxs.append(options.index(a))
104
+ elif len(a) == 1 and a.isalpha():
105
+ idxs.append(_letter_to_index(a))
106
+ elif isinstance(user_answer, str):
107
+ if user_answer in options:
108
+ idxs.append(options.index(user_answer))
109
+ elif len(user_answer) == 1 and user_answer.isalpha():
110
+ idxs.append(_letter_to_index(user_answer))
111
+ return sorted([i for i in idxs if 0 <= i < len(options)])
112
+
113
+ # --- Helper for level styling ---
114
+ def get_level_style(level):
115
+ if level.lower() == "beginner":
116
+ return ("#28a745", "Beginner") # Green
117
+ elif level.lower() == "intermediate":
118
+ return ("#ffc107", "Intermediate") # Yellow
119
+ elif level.lower() == "advanced":
120
+ return ("#dc3545", "Advanced") # Red
121
+ else:
122
+ return ("#6c757d", level)
123
+
124
+
125
+ # --- Sidebar Progress ---
126
+ def show_quiz_progress_sidebar(quiz_id):
127
+ qobj = _load_quiz_obj(quiz_id)
128
+ total_q = max(1, len(qobj.get("questions", [])))
129
+ current_q = int(st.session_state.get("current_q", 0))
130
+ answered_count = len(st.session_state.get("answers", {}))
131
+
132
+ with st.sidebar:
133
+ st.markdown("""
134
+ <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px;">
135
+ <h3 style="margin: 0; color: #333;">Quiz Progress</h3>
136
+ <div style="font-size: 18px;">☰</div>
137
+ </div>
138
+ """, unsafe_allow_html=True)
139
+
140
+ st.markdown(f"""
141
+ <div style="margin-bottom: 15px;">
142
+ <strong style="color: #333; font-size: 14px;">{qobj.get('title','Quiz')}</strong>
143
+ </div>
144
+ """, unsafe_allow_html=True)
145
+
146
+ progress_value = (current_q) / total_q if current_q < total_q else 1.0
147
+ st.progress(progress_value)
148
+
149
+ st.markdown(f"""
150
+ <div style="text-align: center; margin: 10px 0; font-weight: bold; color: #333;">
151
+ {min(current_q + 1, total_q)} of {total_q}
152
+ </div>
153
+ """, unsafe_allow_html=True)
154
+
155
+ cols = st.columns(5)
156
+ for i in range(total_q):
157
+ col = cols[i % 5]
158
+ with col:
159
+ if i == current_q and current_q < total_q:
160
+ st.markdown(f"""
161
+ <div style="background-color: #28a745; color: white; text-align: center; padding: 8px; border-radius: 6px; margin: 2px; font-weight: bold; font-size: 14px;">
162
+ {i + 1}
163
+ </div>
164
+ """, unsafe_allow_html=True)
165
+ elif i in st.session_state.get("answers", {}):
166
+ st.markdown(f"""
167
+ <div style="background-color: #d4edda; color: #155724; text-align: center; padding: 8px; border-radius: 6px; margin: 2px; font-size: 14px;">
168
+ {i + 1}
169
+ </div>
170
+ """, unsafe_allow_html=True)
171
+ else:
172
+ st.markdown(f"""
173
+ <div style="background-color: #f8f9fa; color: #6c757d; text-align: center; padding: 8px; border-radius: 6px; margin: 2px; border: 1px solid #dee2e6; font-size: 14px;">
174
+ {i + 1}
175
+ </div>
176
+ """, unsafe_allow_html=True)
177
+
178
+ st.markdown(f"""
179
+ <div style="font-size: 12px; color: #666; margin: 15px 0;">
180
+ <div style="margin: 5px 0;">
181
+ <span style="display: inline-block; width: 12px; height: 12px; background-color: #28a745; border-radius: 50%; margin-right: 8px;"></span>
182
+ <span>Answered ({answered_count})</span>
183
+ </div>
184
+ <div style="margin: 5px 0;">
185
+ <span style="display: inline-block; width: 12px; height: 12px; background-color: #17a2b8; border-radius: 50%; margin-right: 8px;"></span>
186
+ <span>Current</span>
187
+ </div>
188
+ <div style="margin: 5px 0;">
189
+ <span style="display: inline-block; width: 12px; height: 12px; background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 50%; margin-right: 8px;"></span>
190
+ <span>Not answered</span>
191
+ </div>
192
+ </div>
193
+ """, unsafe_allow_html=True)
194
+
195
+ if st.button("← Back to Quizzes", use_container_width=True):
196
+ st.session_state.selected_quiz = None
197
+ st.rerun()
198
+
199
+ # --- Quiz Question ---
200
+ def show_quiz(quiz_id):
201
+ qobj = _load_quiz_obj(quiz_id)
202
+ q_index = int(st.session_state.current_q)
203
+ questions = qobj.get("questions", [])
204
+ question_data = questions[q_index]
205
+
206
+ st.header(qobj.get("title", "Quiz"))
207
+ st.subheader(question_data.get("question", ""))
208
+
209
+ options = question_data.get("options", [])
210
+ correct_answer = question_data.get("answer")
211
+ key = f"q_{q_index}"
212
+ prev_answer = st.session_state.answers.get(q_index)
213
+
214
+ if isinstance(correct_answer, list):
215
+ # multiselect; convert any letter defaults to texts
216
+ default_texts = []
217
+ if isinstance(prev_answer, list):
218
+ for a in prev_answer:
219
+ if isinstance(a, str):
220
+ if a in options:
221
+ default_texts.append(a)
222
+ elif len(a) == 1 and a.isalpha():
223
+ i = _letter_to_index(a)
224
+ if 0 <= i < len(options):
225
+ default_texts.append(options[i])
226
+ answer = st.multiselect("Select all that apply:", options, default=default_texts, key=key)
227
+ else:
228
+ # single answer; compute default index from letter or text
229
+ if isinstance(prev_answer, str):
230
+ if prev_answer in options:
231
+ default_idx = options.index(prev_answer)
232
+ elif len(prev_answer) == 1 and prev_answer.isalpha():
233
+ i = _letter_to_index(prev_answer)
234
+ default_idx = i if 0 <= i < len(options) else 0
235
+ else:
236
+ default_idx = 0
237
+ else:
238
+ default_idx = 0
239
+ answer = st.radio("Select your answer:", options, index=default_idx, key=key)
240
+
241
+ st.session_state.answers[q_index] = answer # auto-save
242
+
243
+ if st.button("Next Question ➡"):
244
+ st.session_state.current_q += 1
245
+ st.rerun()
246
+
247
+
248
+
249
+ # --- Quiz Results ---
250
+ def show_results(quiz_id):
251
+ qobj = _load_quiz_obj(quiz_id)
252
+ questions = qobj.get("questions", [])
253
+
254
+ total_points = 0
255
+ earned_points = 0
256
+ details = {"answers": {}}
257
+
258
+ for i, q in enumerate(questions):
259
+ options = q.get("options", []) or []
260
+ pts = int(q.get("points", 1))
261
+ total_points += pts
262
+
263
+ correct = q.get("answer")
264
+ correct_idx = _correct_to_indices(correct, options)
265
+
266
+ user_answer = st.session_state.answers.get(i)
267
+ user_idx = _normalize_user_to_indices(user_answer, options)
268
+
269
+ is_correct = (sorted(user_idx) == sorted(correct_idx))
270
+ if is_correct:
271
+ earned_points += pts
272
+
273
+ # friendly display
274
+ correct_disp = ", ".join(options[j] for j in correct_idx if 0 <= j < len(options)) or str(correct)
275
+ user_disp = ", ".join(options[j] for j in user_idx if 0 <= j < len(options)) or (
276
+ ", ".join(user_answer) if isinstance(user_answer, list) else str(user_answer)
277
+ )
278
+
279
+ if is_correct:
280
+ st.markdown(f"✅ **Q{i+1}: {q.get('question','')}** \nYour answer: {user_disp}")
281
+ else:
282
+ st.markdown(f"❌ **Q{i+1}: {q.get('question','')}** \nYour answer: {user_disp} \nCorrect answer: {correct_disp}")
283
+
284
+ details["answers"][str(i+1)] = {
285
+ "question": q.get("question", ""),
286
+ "selected": user_answer,
287
+ "correct": correct,
288
+ "points": pts,
289
+ "earned": pts if is_correct else 0
290
+ }
291
+
292
+ percent = int(round(100 * earned_points / max(1, total_points)))
293
+ st.success(f"{qobj.get('title','Quiz')} - Completed! 🎉")
294
+ st.markdown(f"### 🏆 Score: {percent}% ({earned_points}/{total_points} points)")
295
+
296
+ # Save submission to DB for assigned quizzes
297
+ if isinstance(quiz_id, int):
298
+ assignment_id = st.session_state.get("current_assignment")
299
+ if assignment_id:
300
+ dbapi.submit_quiz(
301
+ student_id=st.session_state.user["user_id"],
302
+ assignment_id=assignment_id,
303
+ quiz_id=quiz_id,
304
+ score=int(earned_points),
305
+ total=int(total_points),
306
+ details=details
307
+ )
308
+
309
+ if st.button("🔁 Retake Quiz"):
310
+ st.session_state.current_q = 0
311
+ st.session_state.answers = {}
312
+ st.rerun()
313
+
314
+ if st.button("⬅ Back to Quizzes"):
315
+ st.session_state.selected_quiz = None
316
+ st.rerun()
317
+
318
+ # tutor handoff (kept as-is)
319
+ wrong_answers = []
320
+ for i, q in enumerate(questions):
321
+ user_answer = st.session_state.answers.get(i)
322
+ correct = q.get("answer")
323
+ if (isinstance(correct, list) and set(user_answer or []) != set(correct)) or (not isinstance(correct, list) and user_answer != correct):
324
+ wrong_answers.append((q.get("question",""), user_answer, correct, q.get("explanation","")))
325
+ if wrong_answers and st.button("💬 Talk to AI Financial Tutor"):
326
+ st.session_state.selected_quiz = None
327
+ st.session_state.current_page = "Chatbot"
328
+ st.session_state.current_q = 0
329
+ st.session_state.answers = {}
330
+ if "messages" not in st.session_state:
331
+ st.session_state.messages = []
332
+ wrong_q_text = "\n".join(
333
+ [f"Q: {q}\nYour answer: {ua}\nCorrect answer: {ca}\nExplanation: {ex}"
334
+ for q, ua, ca, ex in wrong_answers])
335
+ tutor_prompt = f"I just completed a financial quiz and got some questions wrong. Here are the details:\n{wrong_q_text}\nCan you help me understand these concepts better?"
336
+ st.session_state.messages.append({
337
+ "id": str(datetime.datetime.now().timestamp()),
338
+ "text": tutor_prompt,
339
+ "sender": "user",
340
+ "timestamp": datetime.datetime.now()
341
+ })
342
+ st.session_state.is_typing = True
343
+ st.rerun()
344
+
345
+ # --- Quiz List ---
346
+ def show_quiz_list():
347
+ st.title("📊 Financial Knowledge Quizzes")
348
+ st.caption("Test your financial literacy across different modules")
349
+
350
+ cols = st.columns(3)
351
+ for i, (quiz_id, quiz) in enumerate(quizzes_data.items()):
352
+ col = cols[i % 3]
353
+ with col:
354
+ color, label = get_level_style(quiz["level"])
355
+ st.markdown(f"""
356
+ <div style="border:1px solid #e1e5e9; border-radius:12px; padding:20px; margin-bottom:20px; background:white; box-shadow:0 2px 6px rgba(0,0,0,0.08);">
357
+ <span style="background-color:{color}; color:white; font-size:12px; padding:4px 8px; border-radius:6px;">{label}</span>
358
+ <span style="float:right; color:#666; font-size:13px;">⏱ {quiz['duration']}</span>
359
+ <h4 style="margin-top:10px; margin-bottom:6px; color:#222;">{quiz['title']}</h4>
360
+ <p style="font-size:14px; color:#555; line-height:1.4; margin-bottom:10px;">{quiz['description']}</p>
361
+ <p style="font-size:13px; color:#666;">📝 {len(quiz['questions'])} questions</p>
362
+ </div>
363
+ """, unsafe_allow_html=True)
364
+
365
+ if st.button("Start Quiz ➡", key=f"quiz_{quiz_id}"):
366
+ st.session_state.selected_quiz = quiz_id
367
+ st.session_state.current_q = 0
368
+ st.session_state.answers = {}
369
+ st.rerun()
370
+
371
+
372
+ # --- Main Router for Quiz Page ---
373
+ def show_page():
374
+ if "selected_quiz" not in st.session_state:
375
+ st.session_state.selected_quiz = None
376
+ if "current_q" not in st.session_state:
377
+ st.session_state.current_q = 0
378
+ if "answers" not in st.session_state:
379
+ st.session_state.answers = {}
380
+
381
+ if st.session_state.selected_quiz is None:
382
+ show_quiz_list()
383
+ else:
384
+ quiz_id = st.session_state.selected_quiz
385
+ qobj = _load_quiz_obj(quiz_id)
386
+ total_q = len(qobj.get("questions", []))
387
+ if st.session_state.current_q < total_q:
388
+ show_quiz(quiz_id)
389
+ else:
390
+ show_results(quiz_id)
phase/Student_view/teacherlink.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # phase/Student_view/teacherlink.py
2
+ import os
3
+ import streamlit as st
4
+ from utils import db as dbapi
5
+
6
+ def load_css(file_name: str):
7
+ try:
8
+ with open(file_name, "r", encoding="utf-8") as f:
9
+ st.markdown(f"<style>{f.read()}</style>", unsafe_allow_html=True)
10
+ except FileNotFoundError:
11
+ pass
12
+
13
+ def _progress_0_1(v):
14
+ try:
15
+ f = float(v)
16
+ except Exception:
17
+ return 0.0
18
+ # accept 0..1 or 0..100
19
+ return max(0.0, min(1.0, f if f <= 1.0 else f / 100.0))
20
+
21
+ def show_code():
22
+ load_css(os.path.join("assets", "styles.css"))
23
+
24
+ if "user" not in st.session_state or not st.session_state.user:
25
+ st.error("Please log in as a student.")
26
+ return
27
+ if st.session_state.user["role"] != "Student":
28
+ st.error("This page is for students.")
29
+ return
30
+
31
+ student_id = st.session_state.user["user_id"]
32
+ st.markdown("## 👥 Join a Class")
33
+ st.caption("Enter class code from your teacher")
34
+
35
+ raw = st.text_input(
36
+ label="Class Code",
37
+ placeholder="e.g. FIN5A2024",
38
+ key="class_code_input",
39
+ label_visibility="collapsed"
40
+ )
41
+
42
+ # custom button style
43
+ st.markdown(
44
+ """
45
+ <style>
46
+ .stButton>button#join_class_btn {
47
+ background-color: #28a745; /* Bootstrap green */
48
+ color: white;
49
+ border-radius: 5px;
50
+ padding: 10px 16px;
51
+ font-weight: 600;
52
+ }
53
+ .stButton>button#join_class_btn:hover {
54
+ background-color: #218838;
55
+ color: white;
56
+ }
57
+ </style>
58
+ """,
59
+ unsafe_allow_html=True,
60
+ )
61
+
62
+ if st.button("Join Class", key="join_class_btn"):
63
+ code = (raw or "").strip().upper()
64
+ if not code:
65
+ st.error("Enter a class code.")
66
+ else:
67
+ try:
68
+ class_id = dbapi.join_class_by_code(student_id, code)
69
+ st.success("🎉 Joined the class!")
70
+ st.rerun()
71
+ except ValueError as e:
72
+ st.error(str(e))
73
+
74
+ st.markdown("---")
75
+ st.markdown("## Your Classes")
76
+
77
+ classes = dbapi.list_classes_for_student(student_id)
78
+ if not classes:
79
+ st.info("You haven’t joined any classes yet. Ask your teacher for a class code.")
80
+ return
81
+
82
+ # one card per class
83
+ for c in classes:
84
+ class_id = c["class_id"]
85
+ counts = dbapi.class_content_counts(class_id) # lessons/quizzes count
86
+ prog = dbapi.student_class_progress(student_id, class_id)
87
+
88
+ st.markdown(f"### {c['name']}")
89
+ st.caption(f"Teacher: {c['teacher_name']} • Code: {c['code']} • Joined: {str(c['joined_at'])[:10]}")
90
+
91
+ st.progress(_progress_0_1(prog["overall_progress"]))
92
+ st.caption(
93
+ f"{prog['lessons_completed']}/{prog['total_assigned_lessons']} lessons completed • "
94
+ f"Avg quiz: {int(round(100 * (prog['avg_score'] or 0)))}%"
95
+ )
96
+
97
+ # top metrics
98
+ m1, m2, m3, m4 = st.columns(4)
99
+ m1.metric("Lessons", counts.get("lessons", 0))
100
+ m2.metric("Quizzes", counts.get("quizzes", 0))
101
+ m3.metric("Overall", f"{int(round(100*_progress_0_1(prog['overall_progress'])))}%")
102
+ m4.metric("Avg Quiz", f"{int(round(100*(prog['avg_score'] or 0)))}%")
103
+
104
+ # Leave class
105
+ leave_col, _ = st.columns([1,3])
106
+ with leave_col:
107
+ if st.button("🚪 Leave Class", key=f"leave_{class_id}"):
108
+ dbapi.leave_class(student_id, class_id)
109
+ st.toast("Left class.", icon="👋")
110
+ st.rerun()
111
+
112
+ # Assignments for THIS class with THIS student's progress
113
+ st.markdown("#### Teacher Lessons & Quizzes")
114
+ rows = dbapi.student_assignments_for_class(student_id, class_id)
115
+ if not rows:
116
+ st.info("No assignments yet.")
117
+ else:
118
+ lessons_tab, quizzes_tab = st.tabs(["📘 Lessons", "🏆 Quizzes"])
119
+
120
+ with lessons_tab:
121
+ for r in rows:
122
+ if r["lesson_id"] is None:
123
+ continue
124
+
125
+ status = r.get("status") or "not_started"
126
+ pos = r.get("current_pos") or 0
127
+ pct = 1.0 if status == "completed" else min(0.95, float(pos or 0) * 0.1)
128
+
129
+ st.subheader(r["title"])
130
+ st.caption(f"{r['subject']} • {r['level']} • Due: {str(r['due_at'])[:10] if r.get('due_at') else '—'}")
131
+ st.progress(_progress_0_1(pct))
132
+
133
+ c1, c2 = st.columns(2)
134
+ with c1:
135
+ # pass lesson & assignment to the Lessons page
136
+ if st.button("▶️ Start Lesson", key=f"start_lesson_{r['assignment_id']}"):
137
+ st.session_state.selected_lesson = r["lesson_id"]
138
+ st.session_state.selected_assignment = r["assignment_id"]
139
+ st.session_state.current_page = "Lessons"
140
+ st.rerun()
141
+ with c2:
142
+ st.write(f"Status: **{status}**")
143
+
144
+ with quizzes_tab:
145
+ any_quiz = False
146
+ for r in rows:
147
+ if not r.get("quiz_id"):
148
+ continue
149
+ any_quiz = True
150
+
151
+ st.subheader(r["title"])
152
+ score, total = r.get("score"), r.get("total")
153
+ if score is not None and total:
154
+ st.caption(f"Last score: {int(round(100*float(score)/float(total)))}%")
155
+ else:
156
+ st.caption("No submission yet")
157
+
158
+ # pass quiz & assignment to the Quiz page
159
+ if st.button("📝 Start Quiz", key=f"start_quiz_{class_id}_{r['quiz_id']}"):
160
+ st.session_state.selected_quiz = r["quiz_id"] # numeric quiz_id from DB
161
+ st.session_state.current_assignment = r["assignment_id"] # you’ll need this when submitting
162
+ st.session_state.current_page = "Quiz"
163
+ st.rerun()
164
+
165
+ if not any_quiz:
166
+ st.info("No quizzes yet for this class.")
167
+
168
+ st.markdown("---")
phase/Teacher_view/classmanage.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # phase/Teacher_view/classmanage.py
2
+
3
+ import streamlit as st
4
+ import random
5
+ import string
6
+ from datetime import datetime
7
+ from utils import db as dbapi
8
+
9
+ def _metric_card(label: str, value: str, caption: str = ""):
10
+ st.markdown(
11
+ f"""
12
+ <div class="metric-card">
13
+ <div class="metric-value">{value}</div>
14
+ <div class="metric-label">{label}</div>
15
+ <div class="metric-caption">{caption}</div>
16
+ </div>
17
+ """,
18
+ unsafe_allow_html=True,
19
+ )
20
+
21
+ def show_page():
22
+ user = st.session_state.user
23
+ teacher_id = user["user_id"]
24
+
25
+ st.title("📚 Classroom Management")
26
+ st.caption("Manage all your classrooms and students")
27
+
28
+ # -------- Create Classroom --------
29
+ with st.expander("➕ Create Classroom", expanded=False):
30
+ new_name = st.text_input("Classroom Name", key="new_classroom_name")
31
+ if st.button("Create Classroom"):
32
+ name = new_name.strip()
33
+ if name:
34
+ out = dbapi.create_class(teacher_id, name)
35
+ st.session_state.selected_class_id = out["class_id"]
36
+ st.success(f'Classroom "{name}" created with code: {out["code"]}')
37
+ st.rerun()
38
+ else:
39
+ st.error("Enter a real name, not whitespace.")
40
+
41
+ # -------- Load classes for this teacher --------
42
+ classes = dbapi.list_classes_by_teacher(teacher_id)
43
+ if not classes:
44
+ st.info("No classrooms yet. Create one above, then share the code.")
45
+ return
46
+
47
+ # Picker like your mock header bar
48
+ st.subheader("Your Classrooms")
49
+ options = {f"{c['name']} (Code: {c.get('code','')})": c for c in classes}
50
+ selected_label = st.selectbox("Select a classroom", list(options.keys()))
51
+ selected = options[selected_label]
52
+ class_id = selected["class_id"]
53
+
54
+ st.markdown("---")
55
+ st.header(selected["name"])
56
+
57
+ # -------- Code stripe --------
58
+ st.subheader("Class Code")
59
+ c1, c2, c3 = st.columns([3, 1, 1])
60
+ with c1:
61
+ st.markdown(f"**`{selected.get('code', 'UNKNOWN')}`**")
62
+ with c2:
63
+ if st.button("📋 Copy Code"):
64
+ st.toast("Code is shown above. Copy it.")
65
+ with c3:
66
+ st.button("🗑️ Delete Class", disabled=True, help="Soft-delete coming later")
67
+
68
+ # -------- Tabs --------
69
+ tab_students, tab_content, tab_analytics = st.tabs(["👥 Students", "📘 Content", "📊 Analytics"])
70
+
71
+ # ============== Students tab ==============
72
+ with tab_students:
73
+ # search input
74
+ q = st.text_input("Search students by name or email", "")
75
+ roster = dbapi.list_students_in_class(class_id)
76
+
77
+ # simple filter
78
+ if q.strip():
79
+ ql = q.lower()
80
+ roster = [r for r in roster if ql in r["name"].lower() or ql in r["email"].lower()]
81
+
82
+ st.caption(f"{len(roster)} Students Found")
83
+
84
+ if not roster:
85
+ st.info("No students in this class yet.")
86
+ else:
87
+ for s in roster:
88
+ st.subheader(f"👤 {s['name']}")
89
+ st.caption(s["email"])
90
+ joined = s.get("joined_at") or s.get("created_at")
91
+ st.caption(f"📅 Joined: {str(joined)[:10]}")
92
+ st.progress(0.0) # placeholder bar to match your style
93
+ cols = st.columns(3)
94
+ cols[0].metric("⭐ Level", s["level_slug"].capitalize())
95
+ cols[1].metric("📊 Avg Score", "—") # can be filled per-student later
96
+ cols[2].metric("🔥 Streak", "—") # from streaks table if you want
97
+ st.markdown("---")
98
+
99
+ # ============== Content tab ==============
100
+ with tab_content:
101
+ counts = dbapi.class_content_counts(class_id)
102
+ left, right = st.columns(2)
103
+ with left:
104
+ _metric_card("📖 Custom Lessons", str(counts["lessons"]), "Lessons created for this classroom")
105
+ with right:
106
+ _metric_card("🏆 Custom Quizzes", str(counts["quizzes"]), "Quizzes created for this classroom")
107
+
108
+ # Optional list so teachers know what those numbers are
109
+ assigs = dbapi.list_class_assignments(class_id)
110
+ if assigs:
111
+ st.markdown("#### Assigned items")
112
+ for a in assigs:
113
+ has_quiz = " + Quiz" if a["quiz_id"] else ""
114
+ st.markdown(f"- **{a['title']}** · {a['subject']} · {a['level']}{has_quiz}")
115
+
116
+ # ============== Analytics tab ==============
117
+ with tab_analytics:
118
+ stats = dbapi.class_analytics(class_id)
119
+ g1, g2, g3 = st.columns(3)
120
+ with g1:
121
+ _metric_card("📊 Class Average", f"{round(stats['class_avg']*100)}%", "Average quiz performance")
122
+ with g2:
123
+ _metric_card("🪙 Total XP", f"{stats['total_xp']}", "Combined XP earned")
124
+ with g3:
125
+ _metric_card("📘 Lessons Completed", f"{stats['lessons_completed']}", "Total lessons completed")
phase/Teacher_view/contentmanage.py ADDED
@@ -0,0 +1,618 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # phase/Teacher_view/contentmanage.py
2
+ import json
3
+ import streamlit as st
4
+ from datetime import datetime
5
+ from utils import db as dbapi
6
+
7
+ # ---------- small UI helpers ----------
8
+ def _pill(text):
9
+ return f"<span style='background:#eef6ff;border:1px solid #cfe3ff;border-radius:999px;padding:2px 8px;font-size:12px;margin-right:6px'>{text}</span>"
10
+
11
+ def _progress(val: float):
12
+ pct = max(0, min(100, int(round(val * 100))))
13
+ return f"""
14
+ <div style="height:8px;background:#eef2ff;border-radius:999px;overflow:hidden">
15
+ <div style="width:{pct}%;height:100%;background:#3b82f6"></div>
16
+ </div>
17
+ """
18
+
19
+
20
+ # ---------- OpenAI quiz generator ----------
21
+ def _generate_quiz_from_text(content: str, n_questions: int = 5):
22
+ """
23
+ Returns a list of dicts like:
24
+ {"question": "...", "options": ["A","B","C","D"], "answer_key": "B", "points": 1}
25
+ Uses OPENAI_API_KEY from your env.
26
+ """
27
+ system = (
28
+ "You are a Jamaican primary school financial literacy teacher. "
29
+ "Write clear multiple-choice questions (A-D) about the provided lesson content. "
30
+ "Keep language simple and age-appropriate. Only one correct answer per question."
31
+ )
32
+ user = (
33
+ f"Create {n_questions} MCQs strictly in this JSON format:\n"
34
+ "{\n"
35
+ ' \"items\":[\n'
36
+ ' {\"question\":\"...\", \"options\":[\"A\",\"B\",\"C\",\"D\"], \"answer_key\":\"A\"}\n'
37
+ " ]\n"
38
+ "}\n\n"
39
+ "Lesson content:\n"
40
+ f"{content}"
41
+ )
42
+
43
+ def _normalize(items):
44
+ out = []
45
+ for it in (items or [])[:n_questions]:
46
+ q = str(it.get("question", "")).strip()
47
+ opts = it.get("options", [])
48
+ if not q or not isinstance(opts, list) or len(opts) < 2:
49
+ continue
50
+ while len(opts) < 4:
51
+ opts.append("Option")
52
+ opts = opts[:4]
53
+ key = str(it.get("answer_key", "A")).strip().upper()[:1]
54
+ if key not in ("A","B","C","D"):
55
+ key = "A"
56
+ out.append({"question": q, "options": opts, "answer_key": key, "points": 1})
57
+ return out
58
+
59
+ try:
60
+ from openai import OpenAI
61
+ client = OpenAI()
62
+
63
+ # 1) Preferred path: Responses API
64
+ try:
65
+ resp = client.responses.create(
66
+ model="gpt-4o-mini",
67
+ temperature=0.2,
68
+ response_format={"type": "json_object"},
69
+ input=[
70
+ {"role": "system", "content": [{"type": "text", "text": system}]},
71
+ {"role": "user", "content": [{"type": "text", "text": user}]},
72
+ ],
73
+ )
74
+ raw = getattr(resp, "output_text", "") or ""
75
+ data = json.loads(raw)
76
+ return _normalize(data.get("items", []))
77
+
78
+ # 2) Fallback: Chat Completions
79
+ except Exception:
80
+ resp = client.chat.completions.create(
81
+ model="gpt-4o-mini",
82
+ temperature=0.2,
83
+ messages=[{"role":"system","content":system},{"role":"user","content":user}],
84
+ response_format={"type": "json_object"},
85
+ )
86
+ raw = resp.choices[0].message.content.strip()
87
+ data = json.loads(raw)
88
+ return _normalize(data.get("items", []))
89
+
90
+ except Exception as e:
91
+ with st.expander("Quiz generation error details"):
92
+ st.code(str(e))
93
+ st.warning("Quiz generation failed. Check API key and your openai package version.")
94
+ return []
95
+
96
+ # ---------- Create panels ----------
97
+ def _create_lesson_panel(teacher_id: int):
98
+ st.markdown("### ✍️ Create New Lesson")
99
+
100
+ classes = dbapi.list_classes_by_teacher(teacher_id)
101
+ class_opts = {f"{c['name']} (code {c['code']})": c["class_id"] for c in classes} if classes else {}
102
+
103
+ if "cl_topic_count" not in st.session_state:
104
+ st.session_state.cl_topic_count = 2 # start with two topics
105
+
106
+ # UI-manipulation buttons OUTSIDE the form
107
+ cols_btn = st.columns([1,1,6])
108
+ with cols_btn[0]:
109
+ if st.button("➕ Add topic", type="secondary"):
110
+ st.session_state.cl_topic_count = min(20, st.session_state.cl_topic_count + 1)
111
+ st.rerun()
112
+ with cols_btn[1]:
113
+ if st.button("➖ Remove last", type="secondary", disabled=st.session_state.cl_topic_count <= 1):
114
+ st.session_state.cl_topic_count = max(1, st.session_state.cl_topic_count - 1)
115
+ st.rerun()
116
+
117
+ with st.form("create_lesson_form", clear_on_submit=False):
118
+ c1, c2 = st.columns([2,1])
119
+ title = c1.text_input("Title", placeholder="e.g., Jamaican Money Recognition")
120
+ level = c2.selectbox("Level", ["beginner","intermediate","advanced"], index=0)
121
+ description = st.text_area("Short description")
122
+ subject = st.selectbox("Subject", ["numeracy","finance"], index=0)
123
+
124
+ st.markdown("#### Topics")
125
+ topic_rows = []
126
+ for i in range(1, st.session_state.cl_topic_count + 1):
127
+ with st.expander(f"Topic {i}", expanded=True if i <= 2 else False):
128
+ t = st.text_input(f"Topic {i} title", key=f"t_title_{i}")
129
+ b = st.text_area(f"Topic {i} content", key=f"t_body_{i}", height=150)
130
+ topic_rows.append((t, b))
131
+
132
+ add_summary = st.checkbox("Append a Summary section at the end", value=True)
133
+ summary_text = ""
134
+ if add_summary:
135
+ summary_text = st.text_area(
136
+ "Summary notes",
137
+ key="summary_notes",
138
+ height=120,
139
+ placeholder="Key ideas, local examples, common mistakes, quick recap..."
140
+ )
141
+
142
+ st.markdown("#### Assign to class (optional)")
143
+ assign_classes = st.multiselect("Choose one or more classes", list(class_opts.keys()))
144
+
145
+ st.markdown("#### Auto-generate a quiz from this lesson (optional)")
146
+ gen_quiz = st.checkbox("Generate a quiz from content", value=False)
147
+ q_count = st.slider("", 3, 10, 5) # label empty because already described
148
+
149
+ # ONLY keep the main submit button inside the form
150
+ submitted = st.form_submit_button("Create lesson", type="primary")
151
+
152
+
153
+ if not submitted:
154
+ return
155
+
156
+ # build sections payload for DB
157
+ sections = []
158
+ for t, b in topic_rows:
159
+ if (t or b):
160
+ sections.append({"title": t or "Topic", "content": b or ""})
161
+
162
+ if add_summary:
163
+ sections.append({
164
+ "title": "Summary",
165
+ "content": (summary_text or "Write a short recap of the most important ideas.").strip()
166
+ })
167
+
168
+ if not title or not sections:
169
+ st.error("Please add a title and at least one topic.")
170
+ return
171
+
172
+ # create lesson
173
+ lesson_id = dbapi.create_lesson(teacher_id, title, description, subject, level, sections)
174
+ st.success(f"✅ Lesson created (ID {lesson_id}).")
175
+
176
+ # assign to chosen classes (lesson only for now)
177
+ for label in assign_classes:
178
+ dbapi.assign_to_class(lesson_id, None, class_opts[label], teacher_id)
179
+
180
+ # auto-generate quiz
181
+ if gen_quiz:
182
+ text = "\n\n".join([s["title"] + "\n" + (s["content"] or "") for s in sections])
183
+ with st.spinner("Generating quiz..."):
184
+ items = _generate_quiz_from_text(text, n_questions=q_count)
185
+ if items:
186
+ qid = dbapi.create_quiz(lesson_id, f"{title} - Quiz", items, {})
187
+ st.success(f"🧠 Quiz generated and saved (ID {qid}).")
188
+ for label in assign_classes:
189
+ dbapi.assign_to_class(lesson_id, qid, class_opts[label], teacher_id)
190
+
191
+ st.session_state.show_create_lesson = False
192
+ st.rerun()
193
+
194
+
195
+
196
+ def _create_quiz_panel(teacher_id: int):
197
+ st.markdown("### 🏆 Create New Quiz")
198
+
199
+ # teacher lessons to link
200
+ lessons = dbapi.list_lessons_by_teacher(teacher_id)
201
+ lesson_map = {f"{L['title']} (#{L['lesson_id']})": L["lesson_id"] for L in lessons}
202
+ if not lesson_map:
203
+ st.info("Create a lesson first, then link a quiz to it.")
204
+ return
205
+
206
+ # dynamic questions
207
+ if "cq_q_count" not in st.session_state:
208
+ st.session_state.cq_q_count = 5
209
+
210
+ with st.form("create_quiz_form", clear_on_submit=False):
211
+ c1, c2 = st.columns([2,1])
212
+ title = c1.text_input("Title", placeholder="e.g., Currency Basics Quiz")
213
+ lesson_label = c2.selectbox("Linked Lesson", list(lesson_map.keys()))
214
+
215
+ st.markdown("#### Questions (up to 10)")
216
+ items = []
217
+ for i in range(1, st.session_state.cq_q_count + 1):
218
+ with st.expander(f"Question {i}", expanded=(i <= 2)):
219
+ q = st.text_area(f"Prompt {i}", key=f"q_{i}")
220
+ cA, cB = st.columns(2)
221
+ a = cA.text_input(f"Option A (correct?)", key=f"optA_{i}")
222
+ b = cB.text_input(f"Option B", key=f"optB_{i}")
223
+ cC, cD = st.columns(2)
224
+ c = cC.text_input(f"Option C", key=f"optC_{i}")
225
+ d = cD.text_input(f"Option D", key=f"optD_{i}")
226
+ correct = st.radio("Correct answer", ["A","B","C","D"], index=0, key=f"ans_{i}", horizontal=True)
227
+ items.append({"question": q, "options": [a,b,c,d], "answer_key": correct, "points": 1})
228
+
229
+ row = st.columns([1,1,4,2])
230
+ with row[0]:
231
+ if st.form_submit_button("➕ Add question", type="secondary", disabled=st.session_state.cq_q_count >= 10):
232
+ st.session_state.cq_q_count = min(10, st.session_state.cq_q_count + 1)
233
+ st.rerun()
234
+ with row[1]:
235
+ if st.form_submit_button("➖ Remove last", type="secondary", disabled=st.session_state.cq_q_count <= 1):
236
+ st.session_state.cq_q_count = max(1, st.session_state.cq_q_count - 1)
237
+ st.rerun()
238
+
239
+ submitted = row[3].form_submit_button("Create quiz", type="primary")
240
+
241
+ if not submitted:
242
+ return
243
+ if not title:
244
+ st.error("Please add a quiz title.")
245
+ return
246
+
247
+ # sanitize items
248
+ cleaned = []
249
+ for it in items:
250
+ q = (it["question"] or "").strip()
251
+ opts = [o for o in it["options"] if (o or "").strip()]
252
+ if len(opts) < 2 or not q:
253
+ continue
254
+ while len(opts) < 4:
255
+ opts.append("Option")
256
+ cleaned.append({"question": q, "options": opts[:4], "answer_key": it["answer_key"], "points": 1})
257
+
258
+ if not cleaned:
259
+ st.error("Add at least one valid question.")
260
+ return
261
+
262
+ qid = dbapi.create_quiz(lesson_map[lesson_label], title, cleaned, {})
263
+ st.success(f"✅ Quiz created (ID {qid}).")
264
+
265
+ st.session_state.show_create_quiz = False
266
+ st.rerun()
267
+
268
+
269
+ def _edit_lesson_panel(teacher_id: int, lesson_id: int):
270
+ data = dbapi.get_lesson(lesson_id)
271
+ L = data["lesson"]
272
+ secs = data["sections"] or []
273
+
274
+ key_cnt = f"el_cnt_{lesson_id}"
275
+ if key_cnt not in st.session_state:
276
+ st.session_state[key_cnt] = max(1, len(secs))
277
+
278
+ st.markdown("### ✏️ Edit Lesson")
279
+
280
+ #Move UI-manipulation buttons
281
+ tools = st.columns([1,1,8])
282
+ with tools[0]:
283
+ if st.button("➕ Add section", key=f"el_add_{lesson_id}", use_container_width=True):
284
+ st.session_state[key_cnt] = min(50, st.session_state[key_cnt] + 1)
285
+ st.rerun()
286
+ with tools[1]:
287
+ if st.button("➖ Remove last", key=f"el_rem_{lesson_id}",
288
+ disabled=st.session_state[key_cnt] <= 1, use_container_width=True):
289
+ st.session_state[key_cnt] = max(1, st.session_state[key_cnt] - 1)
290
+ st.rerun()
291
+
292
+ # The form only has fields + a single submit (Save)
293
+ with st.form(f"edit_lesson_form_{lesson_id}", clear_on_submit=False):
294
+ c1, c2 = st.columns([2,1])
295
+ title = c1.text_input("Title", value=L["title"])
296
+ level = c2.selectbox(
297
+ "Level",
298
+ ["beginner","intermediate","advanced"],
299
+ index=["beginner","intermediate","advanced"].index(L["level"])
300
+ )
301
+ description = st.text_area("Short description", value=L.get("description") or "")
302
+ subject = st.selectbox("Subject", ["numeracy","finance"], index=(0 if L["subject"]=="numeracy" else 1))
303
+
304
+ st.markdown("#### Sections")
305
+ edited_sections = []
306
+ total = st.session_state[key_cnt]
307
+ for i in range(1, total + 1):
308
+ s = secs[i-1] if i-1 < len(secs) else {"title":"", "content":""}
309
+ with st.expander(f"Section {i}", expanded=(i <= 2)):
310
+ t = st.text_input(f"Title {i}", value=s.get("title") or "", key=f"el_t_{lesson_id}_{i}")
311
+ b = st.text_area(f"Content {i}", value=s.get("content") or "", height=150, key=f"el_b_{lesson_id}_{i}")
312
+ edited_sections.append({"title": t or "Section", "content": b or ""})
313
+
314
+ save = st.form_submit_button("💾 Save changes", type="primary", use_container_width=True)
315
+
316
+ # Cancel is a normal button outside the form
317
+ actions = st.columns([8,2])
318
+ with actions[1]:
319
+ cancel_clicked = st.button("✖ Cancel", key=f"el_cancel_{lesson_id}", type="secondary", use_container_width=True)
320
+
321
+ if cancel_clicked:
322
+ st.session_state.show_edit_lesson = False
323
+ st.session_state.edit_lesson_id = None
324
+ st.rerun()
325
+
326
+ if not save:
327
+ return
328
+
329
+ # validation + persist
330
+ if not title or not any((s["title"] or s["content"]).strip() for s in edited_sections):
331
+ st.error("Title and at least one non-empty section are required.")
332
+ return
333
+
334
+ ok = dbapi.update_lesson(lesson_id, teacher_id, title, description, subject, level, edited_sections)
335
+ if ok:
336
+ st.success("✅ Lesson updated.")
337
+ st.session_state.show_edit_lesson = False
338
+ st.session_state.edit_lesson_id = None
339
+ st.rerun()
340
+ else:
341
+ st.error("Could not update this lesson. Check ownership or DB errors.")
342
+
343
+
344
+ def _edit_quiz_panel(teacher_id: int, quiz_id: int):
345
+ data = dbapi.get_quiz(quiz_id) # {'quiz': {...}, 'items': [...]}
346
+ if not data or not data.get("quiz"):
347
+ st.error("Quiz not found.")
348
+ return
349
+
350
+ Q = data["quiz"]
351
+ raw_items = data.get("items", [])
352
+
353
+ def _dec(x):
354
+ if isinstance(x, str):
355
+ try:
356
+ return json.loads(x)
357
+ except Exception:
358
+ return x
359
+ return x
360
+
361
+ # Normalize into simple dicts that the form can bind to
362
+ items = []
363
+ for it in raw_items:
364
+ opts = _dec(it.get("options")) or []
365
+ while len(opts) < 4:
366
+ opts.append("Option")
367
+ opts = opts[:4]
368
+
369
+ ans = _dec(it.get("answer_key"))
370
+ if isinstance(ans, list) and ans:
371
+ ans = ans[0]
372
+ ans = (str(ans) or "A").upper()[:1]
373
+ if ans not in ("A","B","C","D"):
374
+ ans = "A"
375
+
376
+ items.append({
377
+ "question": (it.get("question") or "").strip(),
378
+ "options": opts,
379
+ "answer_key": ans,
380
+ "points": int(it.get("points") or 1),
381
+ })
382
+
383
+ key_cnt = f"eq_cnt_{quiz_id}"
384
+ if key_cnt not in st.session_state:
385
+ st.session_state[key_cnt] = max(1, len(items) or 5)
386
+
387
+ st.markdown("### ✏️ Edit Quiz")
388
+
389
+ with st.form(f"edit_quiz_form_{quiz_id}", clear_on_submit=False):
390
+ title = st.text_input("Title", value=Q.get("title") or f"Quiz #{quiz_id}")
391
+
392
+ edited = []
393
+ total = st.session_state[key_cnt]
394
+ for i in range(1, total + 1):
395
+ it = items[i-1] if i-1 < len(items) else {"question":"", "options":["","","",""], "answer_key":"A", "points":1}
396
+ with st.expander(f"Question {i}", expanded=(i <= 2)):
397
+ q = st.text_area(f"Prompt {i}", value=it["question"], key=f"eq_q_{quiz_id}_{i}")
398
+ cA, cB = st.columns(2)
399
+ a = cA.text_input(f"Option A", value=it["options"][0], key=f"eq_A_{quiz_id}_{i}")
400
+ b = cB.text_input(f"Option B", value=it["options"][1], key=f"eq_B_{quiz_id}_{i}")
401
+ cC, cD = st.columns(2)
402
+ c = cC.text_input(f"Option C", value=it["options"][2], key=f"eq_C_{quiz_id}_{i}")
403
+ d = cD.text_input(f"Option D", value=it["options"][3], key=f"eq_D_{quiz_id}_{i}")
404
+ correct = st.radio("Correct answer", ["A","B","C","D"],
405
+ index=["A","B","C","D"].index(it["answer_key"]),
406
+ key=f"eq_ans_{quiz_id}_{i}", horizontal=True)
407
+ edited.append({"question": q, "options": [a,b,c,d], "answer_key": correct, "points": 1})
408
+
409
+ row = st.columns([1,1,6,2,2])
410
+ with row[0]:
411
+ if st.form_submit_button("➕ Add question", type="secondary"):
412
+ st.session_state[key_cnt] = min(20, st.session_state[key_cnt] + 1)
413
+ st.rerun()
414
+ with row[1]:
415
+ if st.form_submit_button("➖ Remove last", type="secondary", disabled=st.session_state[key_cnt] <= 1):
416
+ st.session_state[key_cnt] = max(1, st.session_state[key_cnt] - 1)
417
+ st.rerun()
418
+
419
+ save = row[3].form_submit_button("💾 Save", type="primary")
420
+ cancel = row[4].form_submit_button("✖ Cancel", type="secondary")
421
+
422
+ if cancel:
423
+ st.session_state.show_edit_quiz = False
424
+ st.session_state.edit_quiz_id = None
425
+ st.rerun()
426
+
427
+ if not save:
428
+ return
429
+
430
+ # sanitize
431
+ cleaned = []
432
+ for it in edited:
433
+ q = (it["question"] or "").strip()
434
+ opts = [o for o in it["options"] if (o or "").strip()]
435
+ if not q or len(opts) < 2:
436
+ continue
437
+ while len(opts) < 4:
438
+ opts.append("Option")
439
+ cleaned.append({
440
+ "question": q,
441
+ "options": opts[:4],
442
+ "answer_key": it["answer_key"], # single letter
443
+ "points": 1
444
+ })
445
+
446
+ if not title or not cleaned:
447
+ st.error("Title and at least one valid question are required.")
448
+ return
449
+
450
+ ok = dbapi.update_quiz(quiz_id, teacher_id, title, cleaned, settings={})
451
+ if ok:
452
+ st.success("✅ Quiz updated.")
453
+ st.session_state.show_edit_quiz = False
454
+ st.session_state.edit_quiz_id = None
455
+ st.rerun()
456
+ else:
457
+ st.error("Could not update this quiz. Check ownership or DB errors.")
458
+
459
+
460
+ # ---------- Main page ----------
461
+ def show_page():
462
+ user = st.session_state.user
463
+ teacher_id = user["user_id"]
464
+
465
+ st.title("📚 Content Management")
466
+ st.caption("Create and manage custom lessons and quizzes")
467
+
468
+ # preload lists
469
+ lessons = dbapi.list_lessons_by_teacher(teacher_id)
470
+ quizzes = dbapi.list_quizzes_by_teacher(teacher_id)
471
+
472
+ # top action bar (no popovers)
473
+ a1, a2, _sp = st.columns([3,3,4])
474
+ if a1.button("➕ Create Lesson", use_container_width=True):
475
+ st.session_state.show_create_lesson = True
476
+ if a2.button("🏆 Create Quiz", use_container_width=True):
477
+ st.session_state.show_create_quiz = True
478
+
479
+ # big inline create panels
480
+ if st.session_state.get("show_create_lesson"):
481
+ with st.container(border=True):
482
+ _create_lesson_panel(teacher_id)
483
+ st.markdown("---")
484
+
485
+ if st.session_state.get("show_create_quiz"):
486
+ with st.container(border=True):
487
+ _create_quiz_panel(teacher_id)
488
+ st.markdown("---")
489
+
490
+ # ----- Inline lesson edit panel, when triggered -----
491
+ if st.session_state.get("show_edit_lesson") and st.session_state.get("edit_lesson_id"):
492
+ with st.container(border=True):
493
+ _edit_lesson_panel(teacher_id, st.session_state.edit_lesson_id)
494
+ st.markdown("---")
495
+ # ----- Inline quiz edit panel, when triggered -----
496
+ if st.session_state.get("show_edit_quiz") and st.session_state.get("edit_quiz_id"):
497
+ with st.container(border=True):
498
+ _edit_quiz_panel(teacher_id, st.session_state.edit_quiz_id)
499
+ st.markdown("---")
500
+
501
+
502
+ # Tabs
503
+ tab1, tab2 = st.tabs([f"Custom Lessons ({len(lessons)})", f"Custom Quizzes ({len(quizzes)})"])
504
+
505
+ # ========== LESSONS ==========
506
+ with tab1:
507
+ if not lessons:
508
+ st.info("No lessons yet. Use **Create Lesson** above.")
509
+ else:
510
+ # all students across teacher's classes (optional “assign to students” inline UI you already had)
511
+ all_students = dbapi.list_all_students_for_teacher(teacher_id)
512
+ student_options = {f"{s['name']} · {s['email']}": s["user_id"] for s in all_students}
513
+
514
+ for L in lessons:
515
+ assignees = dbapi.list_assigned_students_for_lesson(L["lesson_id"])
516
+ assignee_names = [a["name"] for a in assignees]
517
+ created = L["created_at"].strftime("%Y-%m-%d") if isinstance(L["created_at"], datetime) else str(L["created_at"])[:10]
518
+ count = len(assignees)
519
+
520
+ with st.container(border=True):
521
+ c1, c2 = st.columns([8,3])
522
+ with c1:
523
+ st.markdown(f"### {L['title']}")
524
+ st.caption(L.get("description") or "")
525
+ st.markdown(
526
+ _pill(L["level"].capitalize()) +
527
+ _pill(L["subject"]) +
528
+ _pill(f"{count} student{'s' if count != 1 else ''} assigned") +
529
+ _pill(f"Created {created}"),
530
+ unsafe_allow_html=True
531
+ )
532
+ with c2:
533
+ b1, b2 = st.columns([1,1])
534
+ with b1:
535
+ if st.button("Edit", key=f"edit_{L['lesson_id']}"):
536
+ st.session_state.edit_lesson_id = L["lesson_id"]
537
+ st.session_state.show_edit_lesson = True
538
+ st.rerun()
539
+ with b2:
540
+ if st.button("Delete", key=f"del_{L['lesson_id']}"):
541
+ ok, msg = dbapi.delete_lesson(L["lesson_id"], teacher_id)
542
+ if ok: st.success("Lesson deleted"); st.rerun()
543
+ else: st.error(msg)
544
+
545
+ st.markdown("**Assigned Students:**")
546
+ if assignee_names:
547
+ st.markdown(" ".join(_pill(n) for n in assignee_names), unsafe_allow_html=True)
548
+ else:
549
+ st.caption("No students assigned yet.")
550
+
551
+ # ========== QUIZZES ==========
552
+ with tab2:
553
+ if not quizzes:
554
+ st.info("No quizzes yet. Use **Create Quiz** above.")
555
+ else:
556
+ for Q in quizzes:
557
+ assignees = dbapi.list_assigned_students_for_quiz(Q["quiz_id"])
558
+ created = Q["created_at"].strftime("%Y-%m-%d") if isinstance(Q["created_at"], datetime) else str(Q["created_at"])[:10]
559
+ num_qs = int(Q.get("num_items", 0))
560
+
561
+ with st.container(border=True):
562
+ c1, c2 = st.columns([8,3])
563
+ with c1:
564
+ st.markdown(f"### {Q['title']}")
565
+ st.caption(f"Lesson: {Q['lesson_title']}")
566
+ st.markdown(
567
+ _pill(f"{num_qs} question{'s' if num_qs != 1 else ''}") +
568
+ _pill(f"{len(assignees)} students assigned") +
569
+ _pill(f"Created {created}"),
570
+ unsafe_allow_html=True
571
+ )
572
+ with c2:
573
+ b1, b2 = st.columns(2)
574
+ with b1:
575
+ if st.button("Edit", key=f"editq_{Q['quiz_id']}"):
576
+ st.session_state.edit_quiz_id = Q["quiz_id"]
577
+ st.session_state.show_edit_quiz = True
578
+ st.rerun()
579
+ with b2:
580
+ if st.button("Delete", key=f"delq_{Q['quiz_id']}"):
581
+ ok, msg = dbapi.delete_quiz(Q["quiz_id"], teacher_id)
582
+ if ok: st.success("Quiz deleted"); st.rerun()
583
+ else: st.error(msg)
584
+
585
+ st.markdown("**Assigned Students:**")
586
+ if assignees:
587
+ st.markdown(" ".join(_pill(a['name']) for a in assignees), unsafe_allow_html=True)
588
+ else:
589
+ st.caption("No students assigned yet.")
590
+
591
+ with st.expander("View questions", expanded=False):
592
+ data = dbapi.get_quiz(Q["quiz_id"]) # {'quiz': {...}, 'items': [...]}
593
+ items = data.get("items", []) if data else []
594
+ if not items:
595
+ st.info("No items found for this quiz.")
596
+ else:
597
+ labels = ["A","B","C","D"]
598
+ for i, it in enumerate(items, start=1):
599
+ # Handle JSON columns that may come back as strings
600
+ opts = it.get("options")
601
+ if isinstance(opts, str):
602
+ try:
603
+ opts = json.loads(opts)
604
+ except Exception:
605
+ opts = [opts]
606
+ answer = it.get("answer_key")
607
+ if isinstance(answer, str):
608
+ try:
609
+ answer = json.loads(answer)
610
+ except Exception:
611
+ pass
612
+
613
+ st.markdown(f"**Q{i}.** {it.get('question','').strip()}")
614
+ for j, opt in enumerate((opts or [])[:4]):
615
+ st.write(f"{labels[j]}) {opt}")
616
+ ans_text = answer if isinstance(answer, str) else ",".join(answer or [])
617
+ st.caption(f"Answer: {ans_text}")
618
+ st.markdown("---")
phase/Teacher_view/studentlist.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # phase/Teacher_view/studentlist.py
2
+ import streamlit as st
3
+ from utils import db as dbapi
4
+
5
+ # ---------- tiny helpers ----------
6
+ def _avatar(name: str) -> str:
7
+
8
+ return "🧑‍🎓" if hash(name) % 2 else "👩‍🎓"
9
+
10
+ def _report_text(r, level, avg_pct):
11
+ return (
12
+ "STUDENT PROGRESS REPORT\n"
13
+ "======================\n"
14
+ f"Student: {r['name']}\n"
15
+ f"Email: {r['email']}\n"
16
+ f"Joined: {str(r['joined_at'])[:10]}\n\n"
17
+ "PROGRESS OVERVIEW\n"
18
+ "-----------------\n"
19
+ f"Lessons Completed: {int(r['lessons_completed'] or 0)}/{int(r['total_assigned_lessons'] or 0)}\n"
20
+ f"Average Quiz Score: {avg_pct}%\n"
21
+ f"Total XP: {int(r['total_xp'] or 0)}\n"
22
+ f"Current Level: {level}\n"
23
+ f"Study Streak: {int(r['streak_days'] or 0)} days\n"
24
+ )
25
+
26
+ def _level_from_xp(total_xp: int) -> int:
27
+ try:
28
+ xp = int(total_xp or 0)
29
+ except Exception:
30
+ xp = 0
31
+ return 1 + xp // 500
32
+
33
+
34
+ ROW_CSS = """
35
+ <style>
36
+ .sm-chip{display:inline-block;padding:4px 10px;border-radius:999px;background:#eef7f1;color:#0b8f5d;font-weight:600;font-size:.80rem;margin-left:8px}
37
+ .sm-pill{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid #e6e6e6;border-radius:8px;background:#fff}
38
+ .sm-row{border:1px solid #eee;border-radius:12px;padding:16px 16px;margin:10px 0;background:#fff}
39
+ .sm-row:hover{box-shadow:0 2px 10px rgba(0,0,0,.04)}
40
+ .sm-right{display:flex;gap:16px;align-items:center;justify-content:flex-end}
41
+ .sm-metric{min-width:90px;text-align:right}
42
+ .sm-metric .label{color:#777;font-size:.75rem}
43
+ .sm-metric .value{font-weight:700;font-size:1.1rem}
44
+ .sm-name{font-size:1.05rem;font-weight:700}
45
+ .sm-sub{color:#6c6c6c;font-size:.85rem}
46
+ </style>
47
+ """
48
+
49
+ # ---------- page ----------
50
+ def show_page():
51
+ st.title("🎓 Student Management")
52
+ st.caption("Monitor and manage your students' progress")
53
+ st.markdown(ROW_CSS, unsafe_allow_html=True)
54
+
55
+ teacher = st.session_state.user
56
+ teacher_id = teacher["user_id"]
57
+
58
+ classes = dbapi.list_classes_by_teacher(teacher_id)
59
+ if not classes:
60
+ st.info("No classes yet. Create one in Classroom Management.")
61
+ return
62
+
63
+ # class selector
64
+ idx = st.selectbox(
65
+ "Choose a class",
66
+ list(range(len(classes))),
67
+ index=0,
68
+ format_func=lambda i: f"{classes[i]['name']}"
69
+ )
70
+ selected = classes[idx]
71
+ class_id = selected["class_id"]
72
+ code_row = dbapi.get_class(class_id)
73
+
74
+ # get students before drawing chips
75
+ rows = dbapi.class_student_metrics(class_id)
76
+
77
+ # code + student chip row
78
+ chip1, chip2 = st.columns([1, 1])
79
+ with chip1:
80
+ st.markdown(
81
+ f'<div class="sm-chip">Code: {code_row.get("code","")}</div>',
82
+ unsafe_allow_html=True
83
+ )
84
+ with chip2:
85
+ st.markdown(
86
+ f'<div class="sm-chip">👥 {len(rows)} Students</div>',
87
+ unsafe_allow_html=True
88
+ )
89
+
90
+ st.markdown("---")
91
+
92
+ # search line
93
+ query = st.text_input(
94
+ "Search students by name or email",
95
+ placeholder="Type a name or email..."
96
+ ).strip().lower()
97
+
98
+ if query:
99
+ rows = [r for r in rows if query in r["name"].lower() or query in r["email"].lower()]
100
+
101
+ # student rows
102
+ for r in rows:
103
+ name = r["name"]
104
+ email = r["email"]
105
+ joined = str(r["joined_at"])[:10]
106
+ total_xp = int(r["total_xp"] or 0)
107
+ level = _level_from_xp(total_xp)
108
+ lessons_completed = int(r["lessons_completed"] or 0)
109
+ total_assigned = int(r["total_assigned_lessons"] or 0)
110
+ avg_pct = round((r["avg_score"] or 0) * 100)
111
+ streak = int(r["streak_days"] or 0)
112
+
113
+ with st.container():
114
+ st.markdown('<div class="sm-row">', unsafe_allow_html=True)
115
+
116
+ # top bar: avatar + name/email + right metrics
117
+ a, b, c = st.columns([0.7, 4, 3])
118
+ with a:
119
+ st.markdown(f"### {_avatar(name)}")
120
+ with b:
121
+ st.markdown(f'<div class="sm-name">{name}</div>', unsafe_allow_html=True)
122
+ st.markdown(f'<div class="sm-sub">{email} · Joined {joined}</div>', unsafe_allow_html=True)
123
+ with c:
124
+ st.markdown(
125
+ '<div class="sm-right">'
126
+ f'<div class="sm-metric"><div class="value">{level}</div><div class="label">Level</div></div>'
127
+ f'<div class="sm-metric"><div class="value">{avg_pct}%</div><div class="label">Avg Score</div></div>'
128
+ f'<div class="sm-metric"><div class="value">{streak}</div><div class="label">Streak</div></div>'
129
+ "</div>",
130
+ unsafe_allow_html=True
131
+ )
132
+
133
+ # progress bar
134
+ st.caption("Overall Progress")
135
+ frac = (lessons_completed / total_assigned) if total_assigned > 0 else 0.0
136
+ st.progress(min(1.0, frac))
137
+ st.caption(f"{lessons_completed}/{total_assigned} lessons")
138
+
139
+ # actions row
140
+ d1, d2, spacer = st.columns([2, 1.3, 5])
141
+ with d1:
142
+ with st.popover("👁️ View Details"):
143
+ # list the student's assignments
144
+ items = dbapi.list_assignments_for_student(r["student_id"])
145
+ if items:
146
+ for it in items[:25]:
147
+ tag = " + Quiz" if it["quiz_id"] else ""
148
+ st.markdown(f"- **{it['title']}** · {it['subject']} · {it['level']}{tag} · Status: {it['status']}")
149
+ else:
150
+ st.info("No assignments yet.")
151
+ with d2:
152
+ rep = _report_text(r, level, avg_pct)
153
+ st.download_button(
154
+ "⬇️ Export",
155
+ data=rep,
156
+ file_name=f"{name.replace(' ','_')}_report.txt",
157
+ mime="text/plain",
158
+ key=f"dl_{r['student_id']}"
159
+ )
160
+
161
+ st.markdown('</div>', unsafe_allow_html=True)