From d8fbe01d1012c2c7e5c8abee1e221c1cce67d806 Mon Sep 17 00:00:00 2001 From: Alex Do Date: Fri, 28 Nov 2025 13:52:12 +0000 Subject: [PATCH 1/2] feat(aoc): add page for advent of code --- .env.example | 5 +- .github/workflows/deploy.yml | 1 + codesoc.py | 34 ++++- requirements.txt | 1 + static/js/aoc.js | 110 ++++++++++++++++ static/styles/styles-body-aoc.css | 201 ++++++++++++++++++++++++++++++ templates/aoc.html | 84 +++++++++++++ templates/reusable/header.html | 8 +- 8 files changed, 440 insertions(+), 4 deletions(-) create mode 100644 static/js/aoc.js create mode 100644 static/styles/styles-body-aoc.css create mode 100644 templates/aoc.html diff --git a/.env.example b/.env.example index 52d1158..d15afdd 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,7 @@ MAIL_USERNAME=your-email@gmail.com MAIL_PASSWORD=your-gmail-app-password # Note: Use Gmail App Password, not regular password -# Generate at: https://myaccount.google.com/apppasswords \ No newline at end of file +# Generate at: https://myaccount.google.com/apppasswords + +# Advent of Code session cookie for leaderboard API +AOC_SESSION_COOKIE=your-aoc-session-cookie diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f81952c..f2dd728 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -51,6 +51,7 @@ jobs: cd ~/website/ echo "MAIL_USERNAME=${{ secrets.MAIL_USERNAME }}" > .env echo "MAIL_PASSWORD=${{ secrets.MAIL_PASSWORD }}" >> .env + echo "AOC_SESSION_COOKIE=${{ secrets.AOC_SESSION_COOKIE }}" >> .env - name: Install dependencies if: success() diff --git a/codesoc.py b/codesoc.py index 160cc1b..0a39c29 100644 --- a/codesoc.py +++ b/codesoc.py @@ -1,4 +1,4 @@ -from flask import Flask, render_template, request +from flask import Flask, render_template, request, jsonify from flask_migrate import Migrate from db_schema import db, ExecMember, BlogPost, Sponsor, SponsorNews from flask_mail import Mail, Message @@ -7,6 +7,7 @@ from dotenv import load_dotenv from db_manager import DatabaseManager from schema_generator import generate_all_schemas, save_schemas_to_file +import requests # Load environment variables from .env file load_dotenv() @@ -22,7 +23,10 @@ app.config["MAIL_USE_SSL"] = True import os -app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{os.path.join(app.instance_path, 'codesoc.sqlite')}" + +app.config["SQLALCHEMY_DATABASE_URI"] = ( + f"sqlite:///{os.path.join(app.instance_path, 'codesoc.sqlite')}" +) app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db.init_app(app) @@ -33,6 +37,11 @@ db_manager = DatabaseManager() db_manager.init_app(app) +# Advent of Code configuration +AOC_SESSION_COOKIE = os.environ.get("AOC_SESSION_COOKIE", "") +AOC_LEADERBOARD_ID = "3615951" +AOC_YEAR = "2025" + @app.route("/") def home(): @@ -115,6 +124,11 @@ def execTeam(): ) +@app.route("/aoc") +def aoc(): + return render_template("aoc.html", pageName="aoc") + + @app.route("/blog") def blog(): blogPosts = BlogPost.query.all() @@ -138,6 +152,22 @@ def payBill(blogID): return render_template("blogNotFound.html", pageName="blog") +@app.route("/api/aoc/leaderboard") +def aoc_leaderboard(): + """Fetch Advent of Code leaderboard data""" + if not AOC_SESSION_COOKIE: + return jsonify({"error": "AOC session cookie not configured"}), 500 + + try: + url = f"https://adventofcode.com/{AOC_YEAR}/leaderboard/private/view/{AOC_LEADERBOARD_ID}.json" + cookies = {"session": AOC_SESSION_COOKIE} + response = requests.get(url, cookies=cookies, timeout=10) + response.raise_for_status() + return jsonify(response.json()) + except requests.exceptions.RequestException as e: + return jsonify({"error": str(e)}), 500 + + # CLI Commands for database management @app.cli.command("db-reset") def db_reset(): diff --git a/requirements.txt b/requirements.txt index e52fbf5..40300d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ Flask-SQLAlchemy===3.1.1 Werkzeug===3.1.3 jsonschema===4.22.0 python-dotenv===1.1.1 +requests===2.32.3 diff --git a/static/js/aoc.js b/static/js/aoc.js new file mode 100644 index 0000000..aafddef --- /dev/null +++ b/static/js/aoc.js @@ -0,0 +1,110 @@ +$(function () { + loadLeaderboard(); +}); + +function loadLeaderboard() { + $.ajax({ + url: '/api/aoc/leaderboard', + method: 'GET', + success: function(data) { + displayLeaderboard(data); + }, + error: function(xhr, status, error) { + showError(); + console.error('Error loading leaderboard:', error); + } + }); +} + +function displayLeaderboard(data) { + // Hide loading state + $('#loadingState').hide(); + + // Parse members into array and sort by local_score + const members = Object.values(data.members); + members.sort((a, b) => b.local_score - a.local_score); + + // Calculate statistics + const totalMembers = members.length; + const totalStars = members.reduce((sum, member) => sum + member.stars, 0); + const daysActive = calculateActiveDays(data); + + // Update stats + $('#totalMembers').text(totalMembers); + $('#totalStars').text(totalStars); + $('#daysActive').text(daysActive + ' / ' + data.num_days); + + // Generate leaderboard table + const tbody = $('#leaderboardBody'); + tbody.empty(); + + members.forEach((member, index) => { + const rank = index + 1; + const name = member.name || 'Anonymous User #' + member.id; + const stars = member.stars; + const score = member.local_score; + + // Determine rank class for top 3 + let rankClass = ''; + let rowClass = ''; + if (rank === 1) { + rankClass = 'first'; + rowClass = 'top-3'; + } else if (rank === 2) { + rankClass = 'second'; + rowClass = 'top-3'; + } else if (rank === 3) { + rankClass = 'third'; + rowClass = 'top-3'; + } + + const row = ` + + ${rank} + ${escapeHtml(name)} + ${stars} + ${score} + + `; + + tbody.append(row); + }); + + // Show table + $('#leaderboardTable').show(); + + // Update last updated time + const now = new Date(); + $('#lastUpdated').text('Last updated: ' + now.toLocaleString()); +} + +function calculateActiveDays(data) { + // Count how many different days have been completed by at least one member + const completedDays = new Set(); + + Object.values(data.members).forEach(member => { + if (member.completion_day_level) { + Object.keys(member.completion_day_level).forEach(day => { + completedDays.add(parseInt(day)); + }); + } + }); + + return completedDays.size; +} + +function showError() { + $('#loadingState').hide(); + $('#errorState').show(); +} + +function escapeHtml(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, function(m) { return map[m]; }); +} diff --git a/static/styles/styles-body-aoc.css b/static/styles/styles-body-aoc.css new file mode 100644 index 0000000..7536fab --- /dev/null +++ b/static/styles/styles-body-aoc.css @@ -0,0 +1,201 @@ +main { + padding: 2rem; + max-width: 1400px; + margin: 0 auto; +} + +#pageTitle { + font-family: "objektiv-mk1", sans-serif; + font-size: 3rem; + font-weight: 700; + color: #f0f0f0; + margin-bottom: 1rem; +} + +#pageTitle span { + color: #008eff; +} + +.leaderboard-info { + margin-bottom: 2rem; +} + +.leaderboard-info p { + font-family: "objektiv-mk1", sans-serif; + font-size: 1.1rem; + color: #f0f0f0; + margin-bottom: 0.5rem; +} + +.leaderboard-stats { + display: flex; + gap: 2rem; + margin: 1rem 0; + flex-wrap: wrap; +} + +.stat-box { + background: linear-gradient(135deg, #008eff 0%, #0066cc 100%); + padding: 1.5rem; + border-radius: 8px; + color: white; + min-width: 150px; +} + +.stat-label { + font-family: "objektiv-mk1", sans-serif; + font-size: 0.9rem; + opacity: 0.9; + margin-bottom: 0.5rem; +} + +.stat-value { + font-family: "objektiv-mk1", sans-serif; + font-size: 2rem; + font-weight: 700; +} + +.leaderboard-container { + background: #1a1a1a; + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + overflow: hidden; + margin-top: 2rem; +} + +.leaderboard-table { + width: 100%; + border-collapse: collapse; +} + +.leaderboard-table thead { + background: linear-gradient(135deg, #008eff 0%, #0066cc 100%); + color: white; +} + +.leaderboard-table th { + font-family: "objektiv-mk1", sans-serif; + padding: 1rem; + text-align: left; + font-weight: 600; + font-size: 1rem; +} + +.leaderboard-table th.rank-col { + width: 80px; + text-align: center; +} + +.leaderboard-table th.stars-col, +.leaderboard-table th.score-col { + width: 100px; + text-align: center; +} + +.leaderboard-table tbody tr { + border-bottom: 1px solid #1a1a1a; + transition: background-color 0.2s ease; +} + +.leaderboard-table tbody tr:hover { + background-color: #f8f9fa; +} + +.leaderboard-table tbody tr.top-3 { + background: linear-gradient(90deg, + rgba(0, 142, 255, 0.05) 0%, + rgba(0, 142, 255, 0) 100%); +} + +.leaderboard-table td { + font-family: "objektiv-mk1", sans-serif; + padding: 1rem; + color: #f0f0f0; +} + +.rank { + text-align: center; + font-weight: 700; + font-size: 1.2rem; + color: #008eff; +} + +.rank.first { + color: #ffd700; +} + +.rank.second { + color: #c0c0c0; +} + +.rank.third { + color: #cd7f32; +} + +.member-name { + font-weight: 600; + font-size: 1rem; +} + +.stars, +.score { + text-align: center; + font-weight: 600; +} + +.stars { + color: #ffd700; +} + +.loading-state { + text-align: center; + padding: 3rem; + font-family: "objektiv-mk1", sans-serif; + color: #666; +} + +.error-state { + text-align: center; + padding: 3rem; + font-family: "objektiv-mk1", sans-serif; + color: #d32f2f; + background: #ffebee; + border-radius: 8px; + margin: 2rem 0; +} + +.last-updated { + font-family: "objektiv-mk1", sans-serif; + font-size: 0.9rem; + color: #999; + text-align: right; + margin-top: 1rem; + font-style: italic; +} + +@media (max-width: 768px) { + #pageTitle { + font-size: 2rem; + } + + .leaderboard-stats { + flex-direction: column; + gap: 1rem; + } + + .stat-box { + min-width: 100%; + } + + .leaderboard-table th, + .leaderboard-table td { + padding: 0.75rem 0.5rem; + font-size: 0.9rem; + } + + .leaderboard-table th.rank-col, + .leaderboard-table th.stars-col, + .leaderboard-table th.score-col { + width: auto; + } +} diff --git a/templates/aoc.html b/templates/aoc.html new file mode 100644 index 0000000..0898265 --- /dev/null +++ b/templates/aoc.html @@ -0,0 +1,84 @@ + + + + + Advent of Code 2025 | CodeSoc + + + + + + + + + + + + + + + +
+ +
+ + {% include "reusable/header.html" %} + +
+

Advent of Code 2025.

+ +
+

Join our private leaderboard and compete with CodeSoc members!

+

Leaderboard Code: 3615951-6f59bea9

+
+ +
+
+
Total Members
+
-
+
+
+
Total Stars
+
-
+
+
+
Days Completed
+
-
+
+
+ +
+
+ Loading leaderboard data... +
+ + + + + + + + + + + + +
+ +
+
+ + {% include "reusable/footer.html" %} + + + diff --git a/templates/reusable/header.html b/templates/reusable/header.html index 2dd8e1c..583b45e 100644 --- a/templates/reusable/header.html +++ b/templates/reusable/header.html @@ -37,6 +37,12 @@ {% endif %} + @@ -96,4 +102,4 @@ - \ No newline at end of file + From aed0509012fb9f5678b53265774a0f70db21e0ea Mon Sep 17 00:00:00 2001 From: Alex Do Date: Fri, 28 Nov 2025 14:01:11 +0000 Subject: [PATCH 2/2] feat(aoc): add caching for latest leaderboard as an AOC-based req --- codesoc.py | 34 ++++++++++++++++++++++++++++++++-- static/js/aoc.js | 9 +++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/codesoc.py b/codesoc.py index 0a39c29..27feb81 100644 --- a/codesoc.py +++ b/codesoc.py @@ -8,6 +8,7 @@ from db_manager import DatabaseManager from schema_generator import generate_all_schemas, save_schemas_to_file import requests +from datetime import datetime, timedelta # Load environment variables from .env file load_dotenv() @@ -41,6 +42,10 @@ AOC_SESSION_COOKIE = os.environ.get("AOC_SESSION_COOKIE", "") AOC_LEADERBOARD_ID = "3615951" AOC_YEAR = "2025" +AOC_CACHE_DURATION = timedelta(minutes=15) + +# Cache for leaderboard data +aoc_cache = {"data": None, "last_updated": None} @app.route("/") @@ -154,17 +159,42 @@ def payBill(blogID): @app.route("/api/aoc/leaderboard") def aoc_leaderboard(): - """Fetch Advent of Code leaderboard data""" + """Fetch Advent of Code leaderboard data with 15-minute caching""" if not AOC_SESSION_COOKIE: return jsonify({"error": "AOC session cookie not configured"}), 500 + # Check if we have valid cached data + now = datetime.now() + if ( + aoc_cache["data"] is not None + and aoc_cache["last_updated"] is not None + and now - aoc_cache["last_updated"] < AOC_CACHE_DURATION + ): + response_data = aoc_cache["data"].copy() + response_data["cached_at"] = aoc_cache["last_updated"].isoformat() + return jsonify(response_data) + + # Cache is expired or empty, fetch new data try: url = f"https://adventofcode.com/{AOC_YEAR}/leaderboard/private/view/{AOC_LEADERBOARD_ID}.json" cookies = {"session": AOC_SESSION_COOKIE} response = requests.get(url, cookies=cookies, timeout=10) response.raise_for_status() - return jsonify(response.json()) + + # Update cache + aoc_cache["data"] = response.json() + aoc_cache["last_updated"] = now + + response_data = aoc_cache["data"].copy() + response_data["cached_at"] = now.isoformat() + return jsonify(response_data) except requests.exceptions.RequestException as e: + # If API call fails but we have stale cached data, return it anyway + if aoc_cache["data"] is not None: + response_data = aoc_cache["data"].copy() + if aoc_cache["last_updated"] is not None: + response_data["cached_at"] = aoc_cache["last_updated"].isoformat() + return jsonify(response_data) return jsonify({"error": str(e)}), 500 diff --git a/static/js/aoc.js b/static/js/aoc.js index aafddef..7f97f75 100644 --- a/static/js/aoc.js +++ b/static/js/aoc.js @@ -74,8 +74,13 @@ function displayLeaderboard(data) { $('#leaderboardTable').show(); // Update last updated time - const now = new Date(); - $('#lastUpdated').text('Last updated: ' + now.toLocaleString()); + if (data.cached_at) { + const cachedTime = new Date(data.cached_at); + $('#lastUpdated').text('Last updated: ' + cachedTime.toLocaleString()); + } else { + const now = new Date(); + $('#lastUpdated').text('Last updated: ' + now.toLocaleString()); + } } function calculateActiveDays(data) {