lanna_lalala;- commited on
Commit ·
08e67e4
1
Parent(s): bdbcf73
feat: import backend code (clean history)
Browse files- phase/Student_view/chatbot.py +107 -0
- phase/Student_view/game.py +365 -0
- phase/Student_view/games/MoneyMatch.py +192 -0
- phase/Student_view/games/budgetbuilder.py +489 -0
- phase/Student_view/games/debtdilemma.py +1026 -0
- phase/Student_view/games/profitpuzzle.py +495 -0
- phase/Student_view/lesson.py +717 -0
- phase/Student_view/lessons/lesson_1/topic_1.txt +7 -0
- phase/Student_view/quiz.py +390 -0
- phase/Student_view/teacherlink.py +168 -0
- phase/Teacher_view/classmanage.py +125 -0
- phase/Teacher_view/contentmanage.py +618 -0
- phase/Teacher_view/studentlist.py +161 -0
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']} • Module • {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']} • Module • {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)
|