algospaced-dsa / app.py
iamfebin's picture
Configure AlgoSpaced for Hugging Face Spaces deployment
aaa634c
Raw
History Blame Contribute Delete
28.2 kB
import streamlit as st
import urllib.parse
from streamlit_cookies_controller import CookieController
import db
import importlib
importlib.reload(db)
import srs_logic
import llm_reviewer
import gdrive_sync
st.set_page_config(page_title="AlgoSpaced", page_icon="🧠", layout="centered")
# Initialize Cookie Controller
controller = CookieController()
def trigger_delayed_reload(delay_ms=500):
st.components.v1.html(
f"""
<script>
setTimeout(function() {{
window.parent.location.reload();
}}, {delay_ms});
</script>
""",
height=0,
width=0
)
JOURNEY_SEQUENCE = [
217, 242, 1, 49, 347, 271, 238, 36, 128, 125, 167, 15, 11, 42, 121, 3, 424, 567, 76, 239, 20,
155, 150, 739, 853, 84, 704, 74, 875, 153, 33, 981, 4, 206, 21, 141, 143, 19, 138, 2, 287,
146, 23, 25, 226, 104, 543, 110, 100, 572, 235, 102, 199, 1448, 98, 230, 105, 124, 297, 703,
1046, 973, 215, 621, 355, 295, 78, 39, 40, 46, 90, 22, 79, 131, 17, 51, 208, 211, 212, 200,
695, 133, 286, 994, 417, 130, 207, 210, 261, 323, 684, 127, 743, 332, 1584, 778, 269, 787,
70, 746, 198, 213, 5, 647, 91, 322, 152, 139, 300, 416, 62, 1143, 309, 518, 494, 97, 329,
115, 72, 312, 10, 53, 55, 45, 134, 846, 1899, 763, 678, 57, 56, 435, 252, 253, 1851, 48,
54, 73, 202, 66, 50, 43, 2013, 136, 191, 338, 190, 268, 371, 7
]
@st.dialog("Problem Details")
def show_problem_details_dialog(problem_number):
completions = db.get_user_journey_progress()
is_completed = problem_number in completions
problems = db.get_problems_by_number(problem_number)
if problems:
st.markdown(f"### LeetCode {problem_number}")
# Display each method
for i, p in enumerate(problems):
with st.expander(f"Method: {p['pattern']} ({p['difficulty']})", expanded=(i == 0)):
st.markdown(f"**Complexity**: Time `{p['optimal_time']}` | Space `{p['optimal_space']}`")
st.markdown(f"[View Google Doc]({p['google_doc_url']})")
# Review button
if st.button("🎯 Review This Problem", key=f"rev_{p['id']}", use_container_width=True):
st.session_state.active_review_problem_id = p['id']
st.session_state.active_tab = "🎯 Review Queue"
st.rerun()
else:
st.warning("No local notes found for this problem yet.")
st.write("You can search for this problem on LeetCode or Google:")
leetcode_url = f"https://leetcode.com/problemset/?search={problem_number}"
google_url = f"https://www.google.com/search?q=leetcode+{problem_number}"
st.markdown(f"- [Search on LeetCode 🌐]({leetcode_url})")
st.markdown(f"- [Search on Google πŸ”]({google_url})")
st.markdown("---")
# Toggle viewed status
if is_completed:
st.write("Status: **Completed / Viewed** βœ…")
if st.button("πŸ”΄ Remove from Viewed", use_container_width=True):
db.toggle_journey_progress(problem_number)
st.rerun()
else:
st.write("Status: **Not Completed / Viewed** ❌")
if st.button("🟒 Mark as Completed / Viewed", type="primary", use_container_width=True):
db.toggle_journey_progress(problem_number)
st.rerun()
def show_journey_page():
import math
if "show_dialog_num" in st.session_state:
show_problem_details_dialog(st.session_state.show_dialog_num)
del st.session_state.show_dialog_num
st.subheader("πŸ—ΊοΈ Your Journey Path")
st.write("Visually track your algorithmic progress. Synced problems and manual completions are highlighted in **green/gold**.")
completions = db.get_user_journey_progress()
titles = db.get_problem_titles_by_number()
total = len(JOURNEY_SEQUENCE)
completed_count = len(completions.intersection(JOURNEY_SEQUENCE))
pct = int((completed_count / total) * 100) if total else 0
st.progress(pct / 100.0)
st.write(f"**Progress**: {completed_count} / {total} Problems Completed ({pct}%)")
css = """
<style>
.journey-wrapper {
display: flex;
justify-content: center;
width: 100%;
padding: 20px 0;
}
.journey-container {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 450px;
padding: 40px 0;
}
.journey-line {
position: absolute;
top: 0;
bottom: 0;
width: 6px;
background: rgba(128, 128, 128, 0.2);
z-index: 1;
border-radius: 3px;
}
.journey-node {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 60px;
height: 60px;
border-radius: 50%;
font-weight: bold;
font-size: 14px;
text-decoration: none !important;
z-index: 2;
margin: 15px 0;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
border: 3px solid rgba(255, 255, 255, 0.2);
}
.journey-node.completed {
background: linear-gradient(135deg, #10b981, #f59e0b);
color: #ffffff !important;
box-shadow: 0 0 15px rgba(245, 158, 11, 0.4), 0 4px 6px rgba(0, 0, 0, 0.2);
}
.journey-node.uncompleted {
background: rgba(128, 128, 128, 0.15);
color: rgba(255, 255, 255, 0.6) !important;
}
.journey-node:hover {
transform: scale(1.15);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
z-index: 10;
}
.journey-node.completed:hover {
background: linear-gradient(135deg, #059669, #d97706);
box-shadow: 0 0 20px rgba(217, 119, 6, 0.6);
}
.journey-node.uncompleted:hover {
background: rgba(128, 128, 128, 0.3);
color: #ffffff !important;
}
.journey-node::after {
content: attr(data-tooltip);
position: absolute;
bottom: 125%;
left: 50%;
transform: translateX(-50%) scale(0.9);
background: #1e293b;
color: #f8fafc;
padding: 8px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 500;
width: 220px;
text-align: center;
opacity: 0;
pointer-events: none;
transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
z-index: 100;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.journey-node:hover::after {
opacity: 1;
transform: translateX(-50%) scale(1);
}
</style>
"""
html = f"{css}<div class='journey-wrapper'><div class='journey-container'><div class='journey-line'></div>"
for i, num in enumerate(JOURNEY_SEQUENCE):
offset = int(math.sin(i * 0.8) * 65)
is_completed = num in completions
status_class = "completed" if is_completed else "uncompleted"
title = titles.get(num)
if title:
tooltip_text = f"{num}. {title}"
else:
tooltip_text = f"Problem {num} (Not Synced)"
html += f'<a href="?view_num={num}" target="_self" class="journey-node {status_class}" data-tooltip="{tooltip_text}" style="position: relative; left: {offset}px;">{num}</a>'
html += "</div></div>"
st.markdown(html, unsafe_allow_html=True)
# Check and restore session from cookies if present and not already restored
client = db.get_supabase_client()
if "session_restored" not in st.session_state and "signed_out" not in st.session_state:
cookies = st.context.cookies
access_token = cookies.get('sb_access_token')
refresh_token = cookies.get('sb_refresh_token')
if access_token and refresh_token:
try:
client.auth.set_session(access_token, refresh_token)
st.session_state.session_restored = True
st.rerun()
except Exception:
# Token might be invalid/expired, remove them
controller.remove('sb_access_token')
controller.remove('sb_refresh_token')
def dump_cookies_diagnostics():
import os
try:
log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "debug_cookies.txt")
cookies = st.context.cookies
session = client.auth.get_session()
user_id = session.user.id if session and session.user else None
with open(log_path, "w", encoding="utf-8") as f:
f.write(f"Cookies keys: {list(cookies.keys())}\n")
f.write(f"sb_access_token: {cookies.get('sb_access_token')[:20] if cookies.get('sb_access_token') else None}...\n")
f.write(f"sb_refresh_token: {cookies.get('sb_refresh_token')[:20] if cookies.get('sb_refresh_token') else None}...\n")
f.write(f"Session State keys: {list(st.session_state.keys())}\n")
f.write(f"Supabase Client User ID: {user_id}\n")
if "session_restored" in st.session_state:
f.write(f"session_restored: {st.session_state.session_restored}\n")
if "signed_out" in st.session_state:
f.write(f"signed_out: {st.session_state.signed_out}\n")
except Exception as e:
import traceback
with open("debug_cookies_error.txt", "w") as err_f:
err_f.write(f"Error: {e}\n")
err_f.write(traceback.format_exc())
dump_cookies_diagnostics()
def handle_review_completion(current_review, streak_data, rating):
new_box = srs_logic.evaluate_new_box(current_review['box_level'], rating)
next_review_date = srs_logic.calculate_next_review(new_box).isoformat()
times_correct = current_review['times_correct'] + (1 if rating == "Correct" else 0)
total_attempts = current_review['total_attempts'] + 1
db.update_review(current_review['id'], {
"box_level": new_box,
"next_review": next_review_date,
"times_correct": times_correct,
"total_attempts": total_attempts
})
new_streak = srs_logic.increment_streak(streak_data)
db.update_streak(new_streak)
st.toast(f"Review saved! Moved to Box {new_box}. Next review: {next_review_date[:10]}")
def show_auth_screen():
st.markdown("<h2 style='text-align: center;'>Welcome to AlgoSpaced 🧠</h2>", unsafe_allow_html=True)
st.markdown("<p style='text-align: center; color: #888;'>A cloud-syncing Spaced Repetition system for mastering LeetCode algorithmic paradigms.</p>", unsafe_allow_html=True)
# Sign in with Google (placed prominently at the top)
client = db.get_supabase_client()
if st.button("πŸ”΄ Sign in with Google", use_container_width=True):
with st.spinner("Redirecting to Google..."):
try:
# 1. Trigger oauth to generate the code challenge and verifier internally
res = client.auth.sign_in_with_oauth({
"provider": "google",
"options": {
"redirect_to": "http://localhost:8501"
}
})
# 2. Extract code_verifier from the client's internal storage
code_verifier = client.auth._storage.get_item("supabase.auth.token-code-verifier")
if res.url and code_verifier:
# 3. Parse res.url and safely append verifier as a query parameter inside redirect_to URL.
# This avoids overriding the 'state' parameter which Supabase reserves internally.
parsed_url = urllib.parse.urlparse(res.url)
query_params = urllib.parse.parse_qs(parsed_url.query)
if "redirect_to" in query_params:
base_redirect = query_params["redirect_to"][0]
separator = "&" if "?" in base_redirect else "?"
query_params["redirect_to"] = [f"{base_redirect}{separator}verifier={code_verifier}"]
# Rebuild the final authorize URL
new_query = urllib.parse.urlencode(query_params, doseq=True)
oauth_url = urllib.parse.urlunparse(parsed_url._replace(query=new_query))
# Redirect client browser
st.markdown(f'<meta http-equiv="refresh" content="0; url={oauth_url}">', unsafe_allow_html=True)
except Exception as e:
st.error(f"Google login failed: {e}")
st.markdown("<div style='text-align: center; margin: 15px 0; color: #666;'>β€” OR β€”</div>", unsafe_allow_html=True)
tab1, tab2 = st.tabs(["Log In", "Sign Up"])
with tab1:
email = st.text_input("Email Address", key="login_email")
password = st.text_input("Password", type="password", key="login_password")
if st.button("Log In", type="primary", use_container_width=True):
if not email or not password:
st.error("Please fill in all fields.")
return
with st.spinner("Logging in..."):
try:
res = client.auth.sign_in_with_password({"email": email, "password": password})
if res.user:
st.success("Successfully logged in! Redirecting...")
session = client.auth.get_session()
if session:
controller.set('sb_access_token', session.access_token)
controller.set('sb_refresh_token', session.refresh_token)
if "signed_out" in st.session_state:
del st.session_state.signed_out
trigger_delayed_reload(500)
except Exception as e:
st.error(f"Login failed: {e}")
with tab2:
new_email = st.text_input("Email Address", key="signup_email")
new_password = st.text_input("Password", type="password", key="signup_password")
confirm_password = st.text_input("Confirm Password", type="password", key="signup_confirm_password")
if st.button("Create Account", use_container_width=True):
if not new_email or not new_password or not confirm_password:
st.error("Please fill in all fields.")
return
if new_password != confirm_password:
st.error("Passwords do not match!")
return
with st.spinner("Signing up..."):
try:
res = client.auth.sign_up({"email": new_email, "password": new_password})
if res.user:
st.success("Sign up successful! If email confirmation is enabled in Supabase, check your inbox. Otherwise, you can Log In now.")
session = client.auth.get_session()
if session:
controller.set('sb_access_token', session.access_token)
controller.set('sb_refresh_token', session.refresh_token)
if "signed_out" in st.session_state:
del st.session_state.signed_out
trigger_delayed_reload(500)
except Exception as e:
st.error(f"Sign up failed: {e}")
def dump_user_data_to_file():
import os
try:
user_id = db.get_current_user_id()
if user_id:
client = db.get_supabase_client()
problems = client.table('problems').select('*').eq('user_id', user_id).execute().data
reviews = client.table('user_reviews').select('*').eq('user_id', user_id).execute().data
log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "debug_db.txt")
with open(log_path, "w", encoding="utf-8") as f:
f.write(f"User ID: {user_id}\n")
f.write(f"Problems Count: {len(problems)}\n")
f.write("Problems:\n")
for p in problems:
code_val = p.get('reference_code')
code_repr = repr(code_val[:50]) if code_val else str(code_val)
f.write(f" - ID: {p['id']}\n")
f.write(f" Name: {p['name']}\n")
f.write(f" Pattern: {p['pattern']}\n")
f.write(f" Code: {code_repr}\n")
f.write(f" Doc ID: {p.get('google_doc_id')}\n")
f.write(f" Created At: {p.get('created_at')}\n")
f.write(f"\nReviews Count: {len(reviews)}\n")
for r in reviews:
f.write(f" - ID: {r['id']}, Problem ID: {r['problem_id']}, Box: {r['box_level']}\n")
except Exception as e:
log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "debug_error.txt")
with open(log_path, "w", encoding="utf-8") as f:
f.write(str(e))
def main():
# Check for Journey Path view query parameter
query_params = st.query_params
if "view_num" in query_params:
try:
view_num = int(query_params["view_num"])
st.session_state.show_dialog_num = view_num
st.session_state.active_tab = "πŸ—ΊοΈ Journey Path"
st.query_params.clear()
st.rerun()
except Exception as e:
st.error(f"Error opening details modal: {e}")
# 0. Check for OAuth redirect code and verifier query parameter
if "code" in query_params and "verifier" in query_params:
auth_code = query_params["code"]
code_verifier = query_params["verifier"]
client = db.get_supabase_client()
with st.spinner("Completing Google authentication..."):
try:
# Seed the verifier back into the storage of the new client instance
client.auth._storage.set_item("supabase.auth.token-code-verifier", code_verifier)
# Exchange the authorization code for an active session
client.auth.exchange_code_for_session({
"auth_code": auth_code,
"code_verifier": code_verifier
})
session = client.auth.get_session()
if session:
controller.set('sb_access_token', session.access_token)
controller.set('sb_refresh_token', session.refresh_token)
if "signed_out" in st.session_state:
del st.session_state.signed_out
# Clear URL params
st.query_params.clear()
st.success("Successfully logged in with Google! Redirecting...")
trigger_delayed_reload(500)
except Exception as e:
st.error(f"Failed to exchange Google OAuth code: {e}")
# Authenticate User
client = db.get_supabase_client()
user = None
try:
session = client.auth.get_session()
if session and session.user:
user = session.user
except Exception:
pass
if not user:
show_auth_screen()
return
dump_user_data_to_file()
st.title("AlgoSpaced 🧠")
# 1. Lazy Check & Streak Banner
streak_data = db.get_active_streak()
if streak_data:
streak_data = srs_logic.lazy_check_streak(streak_data)
db.update_streak(streak_data)
# High visibility fluid CSS banner for streak
st.markdown(
f"""
<div style="background-color: #2e7b32; padding: 10px; border-radius: 5px; text-align: center; color: white; margin-bottom: 20px;">
<strong>πŸ”₯ Current Streak: {streak_data['current_streak']} Days | Longest: {streak_data['longest_streak']} Days</strong>
</div>
""",
unsafe_allow_html=True
)
# Navigation in Sidebar
if "active_tab" not in st.session_state:
st.session_state.active_tab = "🎯 Review Queue"
with st.sidebar:
st.header("Navigation")
app_mode = st.radio(
"Go to",
["🎯 Review Queue", "πŸ—ΊοΈ Journey Path"],
index=0 if st.session_state.active_tab == "🎯 Review Queue" else 1
)
st.session_state.active_tab = app_mode
st.markdown("---")
st.header("Settings & Sync")
st.write(f"Logged in as: **{user.email}**")
if st.button("Sync Google Drive Folder", use_container_width=True):
with st.spinner("Syncing..."):
import importlib
importlib.reload(db)
importlib.reload(gdrive_sync)
res = gdrive_sync.sync_gdrive()
st.info(res)
st.markdown("---")
# Collapsible Guide and Tips Expander
with st.sidebar.expander("πŸ’‘ Review Tips & Guide"):
st.markdown("""
### πŸ”„ The Core Cycle
AlgoSpaced uses a customized **Leitner System** to optimize retention:
- **Easy / Smashed It**: Moves up 1 Box (Max Box 5).
- **Medium / Struggles**: Stays in the current Box (resets interval).
- **Hard / Forgot**: Resets to **Box 1** immediately.
**Intervals**:
- **Box 1**: 1 day
- **Box 2**: 3 days
- **Box 3**: 7 days
- **Box 4**: 14 days
- **Box 5**: 30 days (Mastered!)
### πŸ“ Review Modes
- **AI Review**: Write code from memory. Gemini AI reviews logic, bugs, and time/space constraints.
- **Self-Grade (Offline)**: Quickly self-grade yourself if you have spotty connection or want a fast session.
### πŸ“„ Note Formatting (Google Doc)
Ensure your shared Google Doc follows this format to sync properly:
```markdown
121. Best Time to Buy and Sell Stock
Easy
Description of the problem...
Method 1 (One Pass):
Time Complexity: O(N)
Space Complexity: O(1)
Code:
```python
def maxProfit(prices):
# your code here
```
Method 2 (Brute Force):
Time Complexity: O(N^2)
...
```
""")
st.markdown("---")
if st.button("Sign Out", use_container_width=True):
client.auth.sign_out()
controller.remove('sb_access_token')
controller.remove('sb_refresh_token')
if "session_restored" in st.session_state:
del st.session_state.session_restored
st.session_state.signed_out = True
trigger_delayed_reload(500)
if st.session_state.active_tab == "πŸ—ΊοΈ Journey Path":
show_journey_page()
return
# Fetch current review card (handling custom reviews from Journey Path)
current_review = None
is_custom_review = False
if "active_review_problem_id" in st.session_state:
prob_id = st.session_state.active_review_problem_id
reviews = db.get_reviews_by_problem_id(prob_id)
if reviews:
current_review = reviews[0]
is_custom_review = True
else:
del st.session_state.active_review_problem_id
if not current_review:
due_reviews = db.get_due_reviews()
if not due_reviews:
st.info("πŸŽ‰ You're all caught up for today! Come back tomorrow.")
return
current_review = due_reviews[0]
problem = current_review['problems']
st.subheader(f"Review: {problem['name']}")
if is_custom_review:
if st.button("πŸ”™ Return to Daily Queue", use_container_width=True):
del st.session_state.active_review_problem_id
st.rerun()
st.write(f"**Pattern**: {problem['pattern']} | **Difficulty**: {problem['difficulty']}")
st.write(f"**Optimal Time**: {problem['optimal_time']} | **Optimal Space**: {problem['optimal_space']}")
if problem['google_doc_url']:
st.markdown(f"[View Google Doc]({problem['google_doc_url']})")
st.write(f"**Box Level**: {current_review['box_level']} (Next review was scheduled for: {current_review['next_review'][:10]})")
# Check if we have evaluation results for this card in the session state
eval_state_key = f"eval_{current_review['id']}"
if eval_state_key in st.session_state:
eval_res = st.session_state[eval_state_key]
st.markdown("---")
st.subheader("AI Evaluation Feedback")
is_correct = eval_res.get('is_correct', False)
if is_correct:
st.success("βœ… Solution Approved! You parsed the constraints and code structure correctly.")
else:
st.error("❌ Solution struggled. Keep reviewing the paradigm.")
st.write(f"**Detected Complexity**: Time: `{eval_res.get('detected_complexity', {}).get('time', 'N/A')}` | Space: `{eval_res.get('detected_complexity', {}).get('space', 'N/A')}`")
if eval_res.get('bugs'):
st.warning("**Bugs / Code Issues Identified**:")
for bug in eval_res['bugs']:
st.markdown(f"- {bug}")
else:
st.write("✨ No logic bugs detected!")
if eval_res.get('key_suggestion'):
st.info(f"πŸ’‘ **Key Suggestion**: {eval_res['key_suggestion']}")
if st.button("Next Card ➑️", type="primary", use_container_width=True):
rating = "Correct" if is_correct else "Incorrect"
handle_review_completion(current_review, streak_data, rating)
del st.session_state[eval_state_key]
st.rerun()
if st.button("πŸ”„ Try Again (Clear Feedback)", use_container_width=True):
del st.session_state[eval_state_key]
st.rerun()
return
mode = st.radio("Review Mode", ["AI Review", "Self-Grade (Offline)"], horizontal=True)
if mode == "AI Review":
code = st.text_area("Write your code here from memory:", height=300)
if st.button("Submit Code for Review", type="primary", use_container_width=True):
if not code.strip():
st.warning("Please type your solution before submitting.")
return
with st.spinner("Evaluating with Gemini AI..."):
eval_res = llm_reviewer.evaluate_code(
problem['name'],
problem['pattern'],
problem['optimal_time'],
problem['optimal_space'],
code
)
if "error" in eval_res:
st.error(f"Error calling AI: {eval_res['error']}")
else:
st.session_state[eval_state_key] = eval_res
st.rerun()
else:
st.warning("Self-Grade Mode Active. Evaluate yourself truthfully.")
with st.expander("Show Reference Code"):
st.code(problem.get('reference_code', 'No reference code available.'), language="python")
col1, col2, col3 = st.columns(3)
if col1.button("Forgot (Box 1)", use_container_width=True):
handle_review_completion(current_review, streak_data, "Incorrect")
st.rerun()
if col2.button("Mixed (Same Box)", use_container_width=True):
handle_review_completion(current_review, streak_data, "Mixed")
st.rerun()
if col3.button("Smashed It (+1 Box)", type="primary", use_container_width=True):
handle_review_completion(current_review, streak_data, "Correct")
st.rerun()
if __name__ == "__main__":
main()