diff --git a/badges.css b/badges.css new file mode 100644 index 0000000..588eead --- /dev/null +++ b/badges.css @@ -0,0 +1,2 @@ +/* Optional future shared badge styling; current implementation uses inline styles in badges.js */ + diff --git a/badges.js b/badges.js new file mode 100644 index 0000000..d298dfa --- /dev/null +++ b/badges.js @@ -0,0 +1,253 @@ +/* + * badges.js β Unified βAchievements & Badgesβ system (Milestones) + * + * Computes milestones from existing quiz stats stored by quizProgress.js: + * localStorage key: learnsphere_quiz_progress_v1 + * + * Badges are persisted once unlocked in: + * localStorage key: learnsphere_achievements_v1 + */ + +(function () { + const QUIZ_PROGRESS_KEY = "learnsphere_quiz_progress_v1"; // for visibility in devtools + const ACHIEVEMENTS_KEY = "learnsphere_achievements_v1"; + + const BADGES = [ + { + id: "first_quiz_attempt", + title: "First quiz attempt", + description: "Complete your first quiz.", + icon: "π", + getProgress: (stats) => { + const firstAttempt = (stats.attemptCount || 0) >= 1; + return { + unlocked: firstAttempt, + progressText: firstAttempt ? "Unlocked" : "0/1" + }; + } + }, + { + id: "five_topics_completed", + title: "5 topics completed", + description: "Attempt quizzes in at least 5 topics.", + icon: "π", + getProgress: (stats) => { + const target = 5; + const done = stats.topicAttemptedCount || 0; + return { + unlocked: done >= target, + progressText: `${Math.min(done, target)}/${target}` + }; + } + }, + { + id: "seven_day_streak", + title: "7-day practice streak", + description: "Practice every day for 7 days.", + icon: "π₯", + getProgress: (stats) => { + const target = 7; + const done = stats.currentStreak || 0; + return { + unlocked: done >= target, + progressText: `${Math.min(done, target)}/${target}` + }; + } + }, + { + id: "ninety_percent_accuracy", + title: "90%+ accuracy", + description: "Maintain 90% accuracy across your attempts.", + icon: "π―", + getProgress: (stats) => { + const target = 0.9; + const total = stats.overallTotalAnswers || 0; + const acc = stats.overallAccuracy; + const unlocked = typeof acc === "number" && acc >= target && total > 0; + + // For progress text, show either current acc or 0/1. + let progressText; + if (total <= 0 || typeof acc !== "number") { + progressText = "No attempts"; + } else { + progressText = `${Math.round(acc * 100)}%`; + } + + return { + unlocked, + progressText + }; + } + } + ]; + + function loadAchievements() { + try { + const raw = localStorage.getItem(ACHIEVEMENTS_KEY); + if (!raw) return { unlocked: {} }; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return { unlocked: {} }; + if (!parsed.unlocked || typeof parsed.unlocked !== "object") parsed.unlocked = {}; + return parsed; + } catch { + return { unlocked: {} }; + } + } + + function saveAchievements(ach) { + try { + localStorage.setItem(ACHIEVEMENTS_KEY, JSON.stringify(ach)); + } catch (e) { + console.warn("LearnSphere: Could not save achievements.", e); + } + } + + function safeNumber(n) { + return typeof n === "number" && !Number.isNaN(n) ? n : null; + } + + function buildStatsFromQuizProgress() { + if (!window.quizProgress) { + return { + attemptCount: 0, + topicAttemptedCount: 0, + currentStreak: 0, + overallAccuracy: null, + overallTotalAnswers: 0 + }; + } + + // Streak + const streak = window.quizProgress.getStreak ? window.quizProgress.getStreak() : { currentStreak: 0 }; + + // Overall accuracy + const overall = window.quizProgress.getOverallAccuracy ? window.quizProgress.getOverallAccuracy() : { accuracy: null, total: 0 }; + + // Topic completion proxy: count of topics with at least 1 quiz attempt + const byTopic = window.quizProgress.getAllTopicStats ? window.quizProgress.getAllTopicStats() : {}; + const topics = window.quizProgress.QUIZ_TOPICS || []; + let topicAttemptedCount = 0; + for (const t of topics) { + const a = byTopic[t.id]; + const attempts = a?.attempts || 0; + if (attempts >= 1) topicAttemptedCount++; + } + + // Attempt count (best effort): derived from topic stats totals + // (quizProgress stores attempt list, but we don't have direct getter) + // So estimate by summing per-topic attempts. + let attemptCount = 0; + for (const tId of Object.keys(byTopic || {})) { + attemptCount += (byTopic[tId]?.attempts || 0); + } + + return { + attemptCount, + topicAttemptedCount, + currentStreak: safeNumber(streak?.currentStreak) ?? 0, + overallAccuracy: overall?.accuracy == null ? null : safeNumber(overall.accuracy), + overallTotalAnswers: safeNumber(overall?.total) ?? 0 + }; + } + + function unlockNewBadges(ach, stats) { + let changed = false; + + for (const badge of BADGES) { + const already = !!ach.unlocked[badge.id]; + const prog = badge.getProgress(stats); + if (!already && prog.unlocked) { + ach.unlocked[badge.id] = { unlockedAt: new Date().toISOString() }; + changed = true; + } + } + + if (changed) saveAchievements(ach); + return ach; + } + + function badgeCardHTML(badge, unlocked, progressText) { + const dim = unlocked ? "" : "opacity:0.55; filter: grayscale(0.3);"; + const border = unlocked ? "border-color: rgba(102,252,241,0.55);" : "border-color: rgba(255,255,255,0.12);"; + const shadow = unlocked ? "0 10px 26px rgba(102,252,241,0.16)" : "none"; + + return ` +