From 98982c585d1ac63fca6249010c75e4ef3a8eed38 Mon Sep 17 00:00:00 2001 From: Aditya8369 Date: Tue, 9 Jun 2026 10:37:01 +0530 Subject: [PATCH 1/3] [ENHANCEMENT] Secure chatbot backend --- chatbot.py | 161 +++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 138 insertions(+), 23 deletions(-) diff --git a/chatbot.py b/chatbot.py index 75b0659..d2d160d 100644 --- a/chatbot.py +++ b/chatbot.py @@ -1,32 +1,104 @@ import os +import time +import html +from collections import defaultdict, deque + from flask import Flask, request, jsonify, render_template from flask_cors import CORS import google.generativeai as genai -# Load API Key securely from environment variable -# Never hardcode secrets in source code. -# Set your key with: export GEMINI_API_KEY="your_key_here" (Linux/Mac) -# set GEMINI_API_KEY=your_key_here (Windows) +# ----------------------------- +# Security: API key via env var +# ----------------------------- API_KEY = os.environ.get("GEMINI_API_KEY") if not API_KEY: raise EnvironmentError( "GEMINI_API_KEY environment variable is not set. " - "Please set it before running the application. " - "See .env.example for guidance." + "Set it before running the server." ) genai.configure(api_key=API_KEY) +# ----------------------------- +# App setup +# ----------------------------- app = Flask(__name__, template_folder="templates", static_folder="static") CORS(app) - -def format_response(text): - """Formats chatbot response for better readability.""" - # Safely convert markdown-like bold to HTML - formatted_text = text.replace("**", "").replace("*", "").replace("\n", "
") - return formatted_text.strip() +# Create the model once at startup (cheaper than per request) +MODEL_NAME = os.environ.get("GEMINI_MODEL", "gemini-1.5-pro") +model = genai.GenerativeModel(MODEL_NAME) + + +# ----------------------------- +# Input/output hardening +# ----------------------------- +MAX_MESSAGE_CHARS = int(os.environ.get("CHAT_MAX_MESSAGE_CHARS", "2000")) + +# Simple refusal/guardrail keywords (heuristic) +ABUSE_KEYWORDS = [ + # Prompt injection patterns + "ignore previous", + "disregard", + "system prompt", + "developer prompt", + "jailbreak", + "act as", + "bypass", + "reveal your instructions", + # Harmful/illegal categories (basic) + "how to build a bomb", + "make a bomb", + "poison", + "suicide", + "self-harm", + "kill yourself", + "kill someone", + "murder", + "steal", + "credit card", + "creditcard", +] + + +def format_response(text: str) -> str: + """Return safe text for the frontend (no unsafe HTML).""" + if text is None: + return "" + # Escape HTML to prevent XSS; preserve line breaks. + return html.escape(text).replace("\n", "
").strip() + + +def looks_like_abuse(user_text: str) -> bool: + t = (user_text or "").lower() + return any(k in t for k in ABUSE_KEYWORDS) + + +# ----------------------------- +# Rate limiting (per IP) +# In-memory sliding window. +# ----------------------------- +RATE_LIMIT_WINDOW_SECONDS = int(os.environ.get("CHAT_RATE_LIMIT_WINDOW_SECONDS", "60")) +RATE_LIMIT_COUNT = int(os.environ.get("CHAT_RATE_LIMIT_COUNT", "10")) + +# ip -> deque[timestamps] +_requests = defaultdict(deque) + + +def rate_limited(ip: str) -> bool: + now = time.time() + q = _requests[ip] + + # Drop old timestamps + while q and (now - q[0]) > RATE_LIMIT_WINDOW_SECONDS: + q.popleft() + + if len(q) >= RATE_LIMIT_COUNT: + return True + + q.append(now) + return False @app.route("/") @@ -43,27 +115,70 @@ def chat(): user_input = data["message"] - if not user_input or not user_input.strip(): + if not isinstance(user_input, str): + return jsonify({"error": "Message must be a string"}), 400 + + user_input = user_input.strip() + + if not user_input: return jsonify({"error": "Message cannot be empty"}), 400 + if len(user_input) > MAX_MESSAGE_CHARS: + return ( + jsonify({"error": f"Message too large (max {MAX_MESSAGE_CHARS} characters)."}), + 413, + ) + + # Rate limit per IP + ip = request.headers.get("X-Forwarded-For", request.remote_addr) or "unknown" + ip = ip.split(",")[0].strip() # handle proxies lists + + if rate_limited(ip): + return jsonify({"error": "Too many requests. Please try again later."}), 429 + + # Basic abuse prevention / guardrails + if looks_like_abuse(user_input): + reply = ( + "I can’t help with that request. " + "If you need help with a physics, maths, chemistry, or biology topic, tell me what concept you’re working on." + ) + return jsonify({"reply": format_response(reply)}) + try: - model = genai.GenerativeModel("gemini-1.5-pro") - response = model.generate_content(user_input) + # Keep the model constrained to educational tutoring. + system_guardrails = ( + "You are an educational tutor for Physics, Maths, Chemistry, and Biology. " + "Be concise, step-by-step when useful, and avoid unsafe or illegal content. " + "If the user asks for something unrelated, politely guide them back to learning topics." + ) + + # Structured prompt (reduces injection impact) + prompt = f"{system_guardrails}\n\nUser message: {user_input}" + + response = model.generate_content(prompt) + + reply_text = "" + if response and getattr(response, "candidates", None): + cand0 = response.candidates[0] if response.candidates else None + parts = getattr(getattr(cand0, "content", None), "parts", None) + if parts and len(parts) > 0: + reply_text = getattr(parts[0], "text", None) or "" - if response and response.candidates: - reply = format_response(response.candidates[0].content.parts[0].text) - else: - reply = "I'm not sure how to respond to that. Can you try rephrasing?" + if not reply_text: + reply_text = "I’m not sure how to respond to that. Can you try rephrasing?" + + return jsonify({"reply": format_response(reply_text)}) except Exception as e: # Log internally but don't expose raw exception to the client print(f"[ERROR] Chatbot error: {e}") - return jsonify({"reply": "An error occurred while processing your request. Please try again later."}), 500 - - return jsonify({"reply": reply}) + return ( + jsonify({"reply": "An error occurred while processing your request. Please try again later."}), + 500, + ) if __name__ == "__main__": - # Disable debug mode in production debug_mode = os.environ.get("FLASK_DEBUG", "false").lower() == "true" app.run(debug=debug_mode) + From 62991072f92f4ecae1c131cb91fd9abed50050ac Mon Sep 17 00:00:00 2001 From: Aditya8369 Date: Tue, 9 Jun 2026 10:53:14 +0530 Subject: [PATCH 2/3] Persistent user progress (quizzes, streaks, and learning dashboards) --- my_progress.html | 168 +++++++++++++++++++++++++ my_progress.js | 182 +++++++++++++++++++++++++++ quiz/motionquiz.html | 1 + quiz/motionquiz.js | 23 ++++ quiz/nlmquiz.html | 1 + quiz/nlmquiz.js | 23 ++++ quizProgress.js | 291 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 689 insertions(+) create mode 100644 my_progress.html create mode 100644 my_progress.js create mode 100644 quizProgress.js diff --git a/my_progress.html b/my_progress.html new file mode 100644 index 0000000..9e39e2a --- /dev/null +++ b/my_progress.html @@ -0,0 +1,168 @@ + + + + + + + My Progress - LearnSphere + + + + + + + + +
+

My Progress

+

Track quiz performance, accuracy trends, and daily streak.

+ +
+
+
Current Streak
+
+
+
+
+
Overall Accuracy
+
+
+
+
+ +
+
+

Accuracy over time

+ +
+ Based on the last 14 days of quiz attempts. +
+
+ +
+

Recommended next

+
+
+ Recommendations focus on weaker or less-attempted topics. +
+
+
+ +
+

Topic-wise performance

+
+
+ Shows accuracy and attempts per topic. +
+
+
+ + + + + + + diff --git a/my_progress.js b/my_progress.js new file mode 100644 index 0000000..6d6740b --- /dev/null +++ b/my_progress.js @@ -0,0 +1,182 @@ +function pct(n) { + if (typeof n !== "number" || Number.isNaN(n)) return "—"; + return `${Math.round(n * 100)}%`; +} + +function formatAttempts(n) { + if (typeof n !== "number") return "0"; + return String(n); +} + +function drawLineChart(canvas, labels, accuracyByDay) { + const ctx = canvas.getContext("2d"); + const w = canvas.width; + const h = canvas.height; + + // Clear + ctx.clearRect(0, 0, w, h); + + // Background grid + ctx.strokeStyle = "rgba(255,255,255,0.08)"; + ctx.lineWidth = 1; + for (let i = 1; i <= 4; i++) { + const y = (h / 5) * i; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + } + + // Build points (skip nulls) + const valid = accuracyByDay + .map((a, idx) => ({ a, idx })) + .filter(p => typeof p.a === "number"); + + if (valid.length < 2) { + ctx.fillStyle = "rgba(255,255,255,0.7)"; + ctx.font = "14px Arial"; + ctx.fillText("Complete at least 2 quiz attempts to see a trend.", 16, 28); + return; + } + + const xStep = w / (labels.length - 1); + const toY = (acc) => { + // acc: 0..1 + const marginTop = 16; + const marginBottom = 24; + const usable = h - marginTop - marginBottom; + return marginTop + (1 - acc) * usable; + }; + + // Line + ctx.strokeStyle = "#66fcf1"; + ctx.lineWidth = 2; + ctx.beginPath(); + + // Find first valid index + const firstIdx = valid[0].idx; + let started = false; + for (let i = 0; i < accuracyByDay.length; i++) { + const a = accuracyByDay[i]; + if (typeof a !== "number") continue; + const x = i * xStep; + const y = toY(a); + if (!started) { + ctx.moveTo(x, y); + started = true; + } else { + ctx.lineTo(x, y); + } + } + ctx.stroke(); + + // Points + valid.forEach(({ a, idx }) => { + const x = idx * xStep; + const y = toY(a); + ctx.fillStyle = "#66fcf1"; + ctx.beginPath(); + ctx.arc(x, y, 4, 0, 2 * Math.PI); + ctx.fill(); + }); + + // X labels (mm/dd) + ctx.fillStyle = "rgba(255,255,255,0.65)"; + ctx.font = "12px Arial"; + const stride = Math.max(1, Math.floor(labels.length / 6)); + labels.forEach((lab, i) => { + if (i % stride !== 0 && i !== labels.length - 1) return; + ctx.fillText(lab, i * xStep - 10, h - 8); + }); +} + +function init() { + const streak = window.quizProgress.getStreak(); + const streakValue = document.getElementById("streakValue"); + const streakMeta = document.getElementById("streakMeta"); + + if (streakValue) streakValue.textContent = String(streak.currentStreak || 0); + if (streakMeta) { + const last = streak.lastPracticeDate; + streakMeta.textContent = last ? `Last practice: ${last}` : "No practice yet."; + } + + const overall = window.quizProgress.getOverallAccuracy(); + const overallAccuracyValue = document.getElementById("overallAccuracyValue"); + const overallAccuracyMeta = document.getElementById("overallAccuracyMeta"); + + if (overallAccuracyValue) overallAccuracyValue.textContent = overall.accuracy == null ? "—" : pct(overall.accuracy); + if (overallAccuracyMeta) { + overallAccuracyMeta.textContent = overall.total > 0 ? `${overall.correct} correct out of ${overall.total} answers` : "Complete a quiz to populate your stats."; + } + + // Chart + const series = window.quizProgress.getAccuracySeries({ days: 14 }); + const canvas = document.getElementById("accuracyChart"); + if (canvas) { + // Ensure canvas resolution matches layout size + // (Canvas width/height are attributes; default may be too small.) + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.floor(rect.width * dpr); + canvas.height = Math.floor(240 * dpr); + + drawLineChart(canvas, series.labels, series.accuracyByDay); + } + + // Topic stats + const topicStatsEl = document.getElementById("topicStats"); + const byTopic = window.quizProgress.getAllTopicStats(); + + if (topicStatsEl) { + const topics = window.quizProgress.QUIZ_TOPICS; + const sorted = [...topics].sort((a, b) => { + const aAttempts = byTopic[a.id]?.attempts || 0; + const bAttempts = byTopic[b.id]?.attempts || 0; + return bAttempts - aAttempts; + }); + + topicStatsEl.innerHTML = ""; + + sorted.forEach(t => { + const agg = byTopic[t.id]; + const attempts = agg?.attempts || 0; + const qTotal = agg?.questionsTotal || 0; + const correctTotal = agg?.correctTotal || 0; + const accuracy = qTotal > 0 ? correctTotal / qTotal : null; + const barW = accuracy == null ? 0 : Math.max(0, Math.min(100, Math.round(accuracy * 100))); + + const row = document.createElement("div"); + row.className = "topic-row"; + row.innerHTML = ` +
${t.label}
+
+ +
+
+
${accuracy == null ? "—" : pct(accuracy)}
+
${formatAttempts(attempts)} attempts
+
+ `; + + topicStatsEl.appendChild(row); + }); + } + + // Recommendations + const recEl = document.getElementById("recommendedTopics"); + if (recEl) { + const recs = window.quizProgress.getRecommendedTopics({ limit: 3 }); + recEl.innerHTML = ""; + recs.forEach(r => { + const chip = document.createElement("span"); + chip.className = "recommend-chip"; + const accText = r.accuracy == null ? "not attempted" : `accuracy ${pct(r.accuracy)}`; + chip.textContent = `${r.topic.label} • ${accText}`; + recEl.appendChild(chip); + }); + } +} + +document.addEventListener("DOMContentLoaded", init); + diff --git a/quiz/motionquiz.html b/quiz/motionquiz.html index a57ac76..b13e2bc 100644 --- a/quiz/motionquiz.html +++ b/quiz/motionquiz.html @@ -49,6 +49,7 @@

🎉 Quiz Completed!

+ diff --git a/quiz/motionquiz.js b/quiz/motionquiz.js index 921b9bd..d4a13cd 100644 --- a/quiz/motionquiz.js +++ b/quiz/motionquiz.js @@ -111,6 +111,28 @@ function closeConfirmPopup() { } function showResults() { + const finishAt = Date.now(); + const totalQuestions = questions.length; + const totalScore = score; + const correctCount = score; + const startAt = window.__quizMotionStartedAt || finishAt; + const timeTakenMs = Math.max(0, finishAt - startAt); + + try { + if (window.quizProgress && typeof window.quizProgress.recordAttempt === 'function') { + window.quizProgress.recordAttempt({ + topicId: "physics-motion", + score: totalScore, + totalQuestions, + correctCount, + timeTakenMs, + quizId: "quiz:motion", + }); + } + } catch (e) { + console.warn("LearnSphere: Failed to record quiz progress", e); + } + document.getElementById("quiz-box").classList.add("hidden"); document.getElementById("result-box").classList.remove("hidden"); @@ -140,6 +162,7 @@ function updateProgressBar() { } document.addEventListener("DOMContentLoaded", () => { + window.__quizMotionStartedAt = Date.now(); document.getElementById("progress-bar").style.width = "0%"; loadQuestion(); }); diff --git a/quiz/nlmquiz.html b/quiz/nlmquiz.html index d6a2252..7bbd868 100644 --- a/quiz/nlmquiz.html +++ b/quiz/nlmquiz.html @@ -49,6 +49,7 @@

🎉 Quiz Completed!

+ diff --git a/quiz/nlmquiz.js b/quiz/nlmquiz.js index a4c20af..8108add 100644 --- a/quiz/nlmquiz.js +++ b/quiz/nlmquiz.js @@ -115,6 +115,28 @@ function closeConfirmPopup() { } function showResults() { + const finishAt = Date.now(); + const totalQuestions = questions.length; + const totalScore = score; + const correctCount = score; + const startAt = window.__quizNlmStartedAt || finishAt; + const timeTakenMs = Math.max(0, finishAt - startAt); + + try { + if (window.quizProgress && typeof window.quizProgress.recordAttempt === 'function') { + window.quizProgress.recordAttempt({ + topicId: "physics-nlm", + score: totalScore, + totalQuestions, + correctCount, + timeTakenMs, + quizId: "quiz:nlm", + }); + } + } catch (e) { + console.warn("LearnSphere: Failed to record quiz progress", e); + } + document.getElementById("quiz-box").classList.add("hidden"); document.getElementById("result-box").classList.remove("hidden"); @@ -144,6 +166,7 @@ function updateProgressBar() { } document.addEventListener("DOMContentLoaded", () => { + window.__quizNlmStartedAt = Date.now(); document.getElementById("progress-bar").style.width = "0%"; loadQuestion(); }); diff --git a/quizProgress.js b/quizProgress.js new file mode 100644 index 0000000..add1144 --- /dev/null +++ b/quizProgress.js @@ -0,0 +1,291 @@ +/** + * quizProgress.js — Learners Quiz Progress + Daily Streak (localStorage) + * + * Stores quiz attempts + aggregates so learner can see: + * - overall accuracy over time + * - topic-wise performance + * - recommended next quizzes (weak topics) + * - daily streak for practice + * + * Persistence: localStorage (backend can be added later) + */ + +const QUIZ_PROGRESS_KEY = "learnsphere_quiz_progress_v1"; + +/** + * Topic registry (used for analytics + recommendations). + * These ids should match what individual quiz pages pass as topicId. + */ +const QUIZ_TOPICS = [ + { id: "physics-motion", label: "Physics: Motion", subject: "physics", quizIds: ["quiz:motion"] }, + { id: "physics-nlm", label: "Physics: Newton's Laws of Motion", subject: "physics", quizIds: ["quiz:nlm"] }, + { id: "physics-projectile", label: "Physics: Projectile Motion", subject: "physics", quizIds: ["quiz:projectile"] }, + { id: "physics-ray", label: "Physics: Ray Optics", subject: "physics", quizIds: ["quiz:ray"] }, + + { id: "maths-calculus", label: "Maths: Calculus", subject: "maths", quizIds: ["quiz:calculus"] }, + { id: "maths-vectors", label: "Maths: Vectors & 3D Geometry", subject: "maths", quizIds: ["quiz:vectors"] }, + { id: "maths-probability", label: "Maths: Probability & Statistics", subject: "maths", quizIds: ["quiz:probability"] }, + { id: "maths-geometry", label: "Maths: Coordinate Geometry", subject: "maths", quizIds: ["quiz:geometry"] }, + + { id: "chemistry-atomic", label: "Chemistry: Atomic Structure", subject: "chemistry", quizIds: ["quiz:atomic"] }, + { id: "chemistry-bonding", label: "Chemistry: Chemical Bonding", subject: "chemistry", quizIds: ["quiz:bonding"] }, + { id: "chemistry-equil", label: "Chemistry: Equilibrium", subject: "chemistry", quizIds: ["quiz:equilibrium"] }, + { id: "chemistry-thermo", label: "Chemistry: Thermodynamics", subject: "chemistry", quizIds: ["quiz:thermo"] }, +]; + +function _todayLocalISODate() { + // YYYY-MM-DD in local time + const d = new Date(); + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}`; +} + +function _parseISODateToUTCStart(isoDateYYYYMMDD) { + // Treat isoDate as local date; convert to a numeric day token. + // For streak we only need day-to-day adjacency, so a day token in local time is fine. + const [y, m, d] = isoDateYYYYMMDD.split("-".map(Number); + const dt = new Date(y, m - 1, d, 0, 0, 0, 0); + return Math.floor(dt.getTime() / 86400000); +} + +function _loadState() { + try { + const raw = localStorage.getItem(QUIZ_PROGRESS_KEY); + if (!raw) return { attempts: [], byTopic: {}, streak: { lastPracticeDate: null, currentStreak: 0 } }; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") { + return { attempts: [], byTopic: {}, streak: { lastPracticeDate: null, currentStreak: 0 } }; + } + if (!Array.isArray(parsed.attempts)) parsed.attempts = []; + if (!parsed.byTopic || typeof parsed.byTopic !== "object") parsed.byTopic = {}; + if (!parsed.streak || typeof parsed.streak !== "object") parsed.streak = { lastPracticeDate: null, currentStreak: 0 }; + return parsed; + } catch { + return { attempts: [], byTopic: {}, streak: { lastPracticeDate: null, currentStreak: 0 } }; + } +} + +function _saveState(state) { + try { + localStorage.setItem(QUIZ_PROGRESS_KEY, JSON.stringify(state)); + } catch (e) { + console.warn("LearnSphere: Could not save quiz progress.", e); + } +} + +function _ensureTopicExists(topicId) { + if (!QUIZ_TOPICS.some(t => t.id === topicId)) { + // Still allow unknown topicId; dashboard will just show it under "Other". + QUIZ_TOPICS.push({ id: topicId, label: topicId, subject: "other", quizIds: [] }); + } +} + +function recordAttempt({ topicId, score, totalQuestions, correctCount, timeTakenMs, quizId = null }) { + if (!topicId) return; + + _ensureTopicExists(topicId); + + const state = _loadState(); + const now = Date.now(); + const today = _todayLocalISODate(); + + const total = Number(totalQuestions) || 0; + const got = Number(score) || 0; + const correct = typeof correctCount === "number" ? correctCount : got; + + const timeMs = typeof timeTakenMs === "number" && timeTakenMs >= 0 ? timeTakenMs : null; + + // Topic aggregate init + if (!state.byTopic[topicId]) { + state.byTopic[topicId] = { + attempts: 0, + bestScore: null, + latestScore: null, + correctTotal: 0, + questionsTotal: 0, + timeTakenMsTotal: 0, + timeTakenMsCount: 0, + lastAttemptAt: null, + }; + } + + const agg = state.byTopic[topicId]; + agg.attempts += 1; + agg.bestScore = agg.bestScore === null ? got : Math.max(agg.bestScore, got); + agg.latestScore = got; + agg.correctTotal += correct; + agg.questionsTotal += total; + agg.lastAttemptAt = now; + if (timeMs !== null) { + agg.timeTakenMsTotal += timeMs; + agg.timeTakenMsCount += 1; + } + + // Add attempt record for charts + state.attempts.push({ + topicId, + quizId, + score: got, + totalQuestions: total, + correctCount: correct, + accuracy: total > 0 ? correct / total : null, + timeTakenMs: timeMs, + startedAt: null, + finishedAt: now, + practiceDate: today, + }); + + // Keep attempts bounded + if (state.attempts.length > 500) { + state.attempts = state.attempts.slice(state.attempts.length - 500); + } + + // Update streak (daily practice) + const s = state.streak || { lastPracticeDate: null, currentStreak: 0 }; + + const prevDate = s.lastPracticeDate; + const prevToken = prevDate ? _parseISODateToUTCStart(prevDate) : null; + const todayToken = _parseISODateToUTCStart(today); + + if (!prevDate) { + s.currentStreak = 1; + s.lastPracticeDate = today; + } else if (today === prevDate) { + // Same day: do not increment + s.lastPracticeDate = today; + } else if (prevToken !== null && todayToken === prevToken + 1) { + s.currentStreak += 1; + s.lastPracticeDate = today; + } else { + s.currentStreak = 1; + s.lastPracticeDate = today; + } + + state.streak = s; + _saveState(state); + + return state; +} + +function getStreak() { + const state = _loadState(); + return state.streak || { lastPracticeDate: null, currentStreak: 0 }; +} + +function getTopicStats(topicId) { + const state = _loadState(); + return state.byTopic[topicId] || null; +} + +function getAllTopicStats() { + const state = _loadState(); + return state.byTopic || {}; +} + +function getAccuracySeries({ days = 14 } = {}) { + // Returns {labels:[], accuracyByDay:[]} + const state = _loadState(); + const attempts = state.attempts || []; + + const end = new Date(); + const labels = []; + const tokens = []; + + for (let i = days - 1; i >= 0; i--) { + const d = new Date(end); + d.setDate(end.getDate() - i); + const token = Math.floor(d.getTime() / 86400000); + // compute date string for label + // (yyyy intentionally unused) + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + const label = `${mm}/${dd}`; + + tokens.push(token); + labels.push(label); + } + + const byToken = new Map(); + tokens.forEach(t => byToken.set(t, { correct: 0, total: 0 })); + + attempts.forEach(a => { + if (!a.practiceDate) return; + const token = _parseISODateToUTCStart(a.practiceDate); + if (!byToken.has(token)) return; + if (typeof a.correctCount === "number" && typeof a.totalQuestions === "number") { + const bucket = byToken.get(token); + bucket.correct += a.correctCount; + bucket.total += a.totalQuestions; + byToken.set(token, bucket); + } + }); + + const accuracyByDay = tokens.map(t => { + const bucket = byToken.get(t); + if (!bucket || bucket.total <= 0) return null; + return bucket.correct / bucket.total; + }); + + return { labels, accuracyByDay }; +} + +function getOverallAccuracy() { + const state = _loadState(); + const attempts = state.attempts || []; + let correct = 0; + let total = 0; + for (const a of attempts) { + if (typeof a.correctCount === "number" && typeof a.totalQuestions === "number") { + correct += a.correctCount; + total += a.totalQuestions; + } + } + if (total <= 0) return { accuracy: null, correct, total }; + return { accuracy: correct / total, correct, total }; +} + +function getRecommendedTopics({ limit = 3 } = {}) { + const state = _loadState(); + const byTopic = state.byTopic || {}; + + const topicScores = QUIZ_TOPICS.map(t => { + const agg = byTopic[t.id]; + const attempts = agg?.attempts || 0; + const qTotal = agg?.questionsTotal || 0; + const correctTotal = agg?.correctTotal || 0; + const accuracy = qTotal > 0 ? correctTotal / qTotal : null; + + // Recommendation heuristic: + // - Prefer topics with low accuracy + // - Also prefer topics with fewer attempts (less practiced) + // Use a numeric weakness score where bigger means weaker. + let weakness = 0; + if (accuracy === null) { + weakness = 1.0; // unseen topic = highest weakness + } else { + weakness = (1 - accuracy); + } + + const attemptPenalty = attempts >= 8 ? -0.05 : 0; // slightly reduce for well-practiced + weakness += attemptPenalty; + + return { topic: t, attempts, accuracy, weakness }; + }); + + topicScores.sort((a, b) => b.weakness - a.weakness); + return topicScores.slice(0, limit); +} + +window.quizProgress = { + QUIZ_TOPICS, + recordAttempt, + getStreak, + getTopicStats, + getAllTopicStats, + getAccuracySeries, + getOverallAccuracy, + getRecommendedTopics, +}; + From 6a86030d21b5973a4cb143b98c0092ecfa0a0a34 Mon Sep 17 00:00:00 2001 From: Aditya8369 Date: Tue, 9 Jun 2026 11:14:11 +0530 Subject: [PATCH 3/3] Accessibility + UX improvements across the UI --- quiz/motionquiz.css | 89 +++++++++++++++++++++++++++++++++---------- quiz/motionquiz.html | 52 +++++++++++++++---------- quiz/motionquiz.js | 77 +++++++++++++++++++++++++++++++------ quiz/nlmquiz.css | 91 ++++++++++++++++++++++++++++++++++---------- quiz/nlmquiz.html | 52 +++++++++++++++---------- quiz/nlmquiz.js | 76 +++++++++++++++++++++++++++++++----- 6 files changed, 334 insertions(+), 103 deletions(-) diff --git a/quiz/motionquiz.css b/quiz/motionquiz.css index ca3f8bc..bfff603 100644 --- a/quiz/motionquiz.css +++ b/quiz/motionquiz.css @@ -8,6 +8,41 @@ body { min-height: 100vh; } +.skip-link { + position: absolute; + top: -100px; + left: 0; + background: #00ffcc; + color: #000; + padding: 10px 14px; + z-index: 9999; + border-radius: 8px; + text-decoration: none; +} + +.skip-link:focus-visible { + top: 10px; + outline: 2px solid #00ffcc; + outline-offset: 3px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +:focus-visible { + outline: 3px solid #00ffcc; + outline-offset: 3px; +} + .container { max-width: 900px; margin: auto; @@ -75,6 +110,11 @@ body { border: none; } +.option:focus-visible { + outline: 3px solid #00ffcc; + outline-offset: 3px; +} + .option:hover { background: #0056b3; transform: scale(1.05); @@ -103,6 +143,11 @@ button { transition: 0.3s; } +button:focus-visible { + outline: 3px solid #00ffcc; + outline-offset: 3px; +} + button:disabled { background: #444; cursor: not-allowed; @@ -134,6 +179,10 @@ button:hover:enabled { z-index: 1000; } +.popup:focus-visible { + outline: 3px solid #00ffcc; + outline-offset: 3px; +} .correct { color: green; @@ -145,25 +194,6 @@ button:hover:enabled { font-weight: bold; } -.option { - display: block; - width: 100%; - padding: 10px; - margin: 5px 0; - font-size: 16px; - cursor: pointer; - border: 2px solid #ccc; - border-radius: 5px; - background-color: blue; - transition: all 0.3s ease; -} - -.option.selected { - background-color: green; - color: white; - border-color: #007bff; -} - .correct-answer { color: green; font-weight: bold; @@ -184,6 +214,23 @@ button:hover:enabled { color: white; } -.hidden { - display: none; +@media (max-width: 560px) { + .options-grid { + grid-template-columns: 1fr; + } + + .button-group { + flex-direction: column; + align-items: stretch; + } } + +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + diff --git a/quiz/motionquiz.html b/quiz/motionquiz.html index b13e2bc..503cbfb 100644 --- a/quiz/motionquiz.html +++ b/quiz/motionquiz.html @@ -10,7 +10,9 @@ -
+ + +

Physics Motion Quiz 🎯

Test your knowledge of motion concepts!

@@ -19,38 +21,48 @@

Physics Motion Quiz 🎯

-
+

Loading...

-
- - - -
+
+ +
+ + + +
+ +

+ -